diff --git a/.gitignore b/.gitignore index 10498ce..209932b 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,11 @@ CMakeUserPresets.json /vcpkg/ # ---- external source-built deps (方案②-修订: VTK 源码编到 install 前缀) ---- -/external/ +# 用 /external/* 而非 /external/ 忽略目录内容,使下方 vendored 子目录可被例外重新纳入 +# (git 无法重新纳入被整体忽略目录内的文件)。 +/external/* +# 例外:vendored 3DGPRViewer 数据生成链(原样拷贝的算法源码,版权自有)需入库。 +!/external/gpr3dviewer/ # ---- Visual Studio / IDE ---- .vs/ @@ -47,3 +51,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/ diff --git a/.superpowers/sdd/task-12b-report.md b/.superpowers/sdd/task-12b-report.md new file mode 100644 index 0000000..18df85e --- /dev/null +++ b/.superpowers/sdd/task-12b-report.md @@ -0,0 +1,67 @@ +# Task 12b 报告:SetPartitions 单 mapper fps 去风险探针 + +## 状态 +完成(探针真实跑出,结论:渲出但未达交互级)。 + +## 实测环境与数据 +- store(9c/12 同款单线全分辨率整卷):`D:\Git\lanbingtech\geopro\build\tmp\gpr_store_B_001` +- 整卷维度:44476 × 29 × 162 = 208,948,248 体素,417,896,496 B(398.5 MB,VTK_SHORT) +- 离屏渲染(vtkRenderWindow SetOffScreenRenderingOn),硬件加速 OpenGL(offscreen-smoke 闸门 OK)。 + +## 实现要点(tools/gpr_poc/main.cpp 新增 `renderC-partitioned` 子命令) +- WholeVolumeSource 重组**整卷单个** vtkImageData(不预切块)。 +- 关键:`vtkGPUVolumeRayCastMapper` 抽象基类**无** `SetPartitions`;该 API 在 OpenGL 具体实现 + `vtkOpenGLGPUVolumeRayCastMapper`(工厂默认产物)上。故直接建该具体类, + `SetInputData(整卷)` + `SetPartitions(ceil(nx/16384),1,1)`。 +- 分区数:沿线 44476 → `ceil(44476/16384)=3`(每区 ~14826 ≤16384);ny=29、nz=162 → 1。 + 实测 `SetPartitions(3,1,1)`。 +- 量化域传函复用现有 `makeI16VolumeProperty`(qmin/qmax、kBlank 透明、q.toPhys 反查 ColorScale)。 +- 双闸(同 9c,绝不把空纹理假帧率当性能): + ① CapturingOutputWindow 捕获 3D 纹理维度错误; + ② 真实回读像素统计非背景像素。 +- 相机修正:整卷极扁长(44476:29:162),首版用 `ResetCamera()` 全体 + 仅取末帧像素时, + 末帧恰好边缘视角 → 误报“非空像素=0”。修正为:以 mapper 包围盒定向 + 抬高/旋转视角让薄维度可见, + 且旋转扫描中**多帧采样非背景像素取最大值**(区分“真渲不出”与“采样时机不巧”)。修正后稳定渲出。 + +## 核心结论:SetPartitions 单 mapper 是否真渲出 + fps + 内存 + 分区数 +- **分区数**:SetPartitions(3, 1, 1)。 +- **是否真渲出**:**是**。无纹理维度错误(SetPartitions 成功绕过 GL_MAX_3D_TEXTURE_SIZE=16384 纹理墙), + 真实回读非背景像素 1264(非空),一个 mapper 一次 ray cast。 +- **体绘制 fps**:**~8.8 ~ 11 fps**(多次实测 8.84 / 10.95 / 10.59,落在 8.8–11 区间)。 +- **峰值进程内存**:~556 ~ 653 MB(整卷 398.5 MB 常驻 + 渲染开销)。 + +## 对照表 +| 路径 | 是否渲出 | fps | +|---|---|---| +| renderB 整卷单 SmartVolumeMapper | INVALID(纹理墙,沿线 44476>16384) | — | +| renderC MultiBlock(每块一 mapper) | 渲出 | 9.5 静态 / 1.45 换页 | +| **renderC-partitioned 单 mapper SetPartitions** | **渲出** | **~8.8–11(静态整卷)** | + +## 是否达交互级 +**否**。目标 ≥15~30 fps,实测 8.8–11 fps,低于交互级下限。 + +## 判据落点 +- “对的架构”(单 mapper + SetPartitions)**确实绕过了纹理墙、确实把全分辨率整卷一次性渲出**—— + 这点比 9c(INVALID)与 12(每块一 mapper)都更干净,证明架构方向正确。 +- 但**纯 GPU ray cast 静态整卷 fps 仍只有 ~9–11**,与 renderC MultiBlock 的 9.5 静态 **基本同档**, + 未拉开差距、未到交互级。即:**“每块一 mapper”不是 9.5fps 的主要元凶;瓶颈在 208M 体素全分辨率 + 整卷的 ray cast 本身**(采样量 + 显存带宽),单 mapper 分区并不能把它变快。 +- 结论:**VTK 这条路(整卷全分辨率体绘制)的交互级天花板在本数据上已暴露**。要到交互级, + production C 必须靠 LOD/降采样/核外换块(动态分辨率),而非寄望“单 mapper 分区”本身提速。 + brief 判据的“仍不到 → 评估 OpenVDS/自建 GL”一支成立。 + +## concerns +1. **fps 受相机框选与视图影响**:8.8–11 的波动主要来自每帧旋转中视线穿过体的采样深度差异; + 该数为“静态整卷、绕轴旋转”口径,已剔除换页/解压(不像 renderC 动态 1.45 含 update)。 + 作为“单 mapper 分区静态整卷 fps 天花板”是诚实的,但生产中真实 fps 还会被交互缩放/平移影响。 +2. **首版“空渲染”教训已修正**:极扁长体 + 末帧单采样会假报空;现多帧取最大 + 视角抬高,已稳定非空。 + 报告口径据此可信。 +3. **本探针只验静态整卷**(遵 YAGNI,未做 LOD/换块/后台解压)。production C 的动态分辨率方案 + 还需单独验证其在“降采样后”能否到交互级——这是下一根要验的链子,不在本探针范围。 +4. SetPartitions 在 9.6 属 `vtkOpenGLGPUVolumeRayCastMapper`(非抽象基类);若后续 VTK 升级 + 该 API 位置变动需留意。 + +## 交付物 +- 代码:`tools/gpr_poc/main.cpp`(新增 `renderC-partitioned` 子命令)。 +- 结果:`docs/superpowers/plans/poc-results-C.md`(含对照表与判据结论)。 +- 报告:本文件 `.superpowers/sdd/task-12b-report.md`。 diff --git a/.superpowers/sdd/task-12c-report.md b/.superpowers/sdd/task-12c-report.md new file mode 100644 index 0000000..f9876b2 --- /dev/null +++ b/.superpowers/sdd/task-12c-report.md @@ -0,0 +1,80 @@ +# Task 12c 报告:LOD-fps 探针(全量交互渲染最后一根链子) + +## 状态 + +**完成 / PASS** —— 四件事(a/b/c/d)全做,双闸通过(无纹理维度错误 + 三段均回读非空像素), +真实实测,未编造。LOD-based C 路线在本机判据下钉死可行。 + +## 实测数字(本机 RTX 3060 Laptop GPU,离屏,frames=120,多次重跑稳定) + +| 项 | 维度 | 结果 | 交互级判据 | +|---|---|---|---| +| (a) 粗层概览 fps | level2 整卷 11119×8×41 (~3.6M 体素) | **~752 fps**(多跑 590~759) | ✔ 远超 ≥30 | +| (b) 全分辨率局部 fps | level0 局部 256×29×162 (~120 万体素,4 brick 列) | **~380 fps**(多跑 374~422) | ✔ 远超 ≥30 | +| (c) LOD 切换过渡 | 切换帧 60/120,从远观(level2)dolly 拉近到近观局部(level0) | 平均 **1.09ms/帧**,切换帧 **~5.5ms**(尖峰 ~6×邻帧),最大 ~6.95ms | 无可感知卡顿 ✔ | + +- **粗层概览 fps**:~752 fps(达交互级 ✔) +- **全分辨率局部 fps**:~380 fps(达交互级 ✔) +- **LOD 切换过渡帧耗时 / 是否卡顿**:切换帧 ~5.5ms(仍 <1 个 60Hz 帧 16.7ms)→ **无可感知卡顿** +- **截图路径**:`docs/superpowers/plans/poc-lod-shots/` + - `lod-overview.png`(level2 整线概览,全 2200m 线呈细带) + - `lod-fullres-local.png`(level0 局部,全分辨率板面有细节) + - `lod-transition-mid.png`(切换后推近的过渡中间帧) +- **是否都达交互级**:**是**。(a)/(b) 均 >>30fps;(c) 切换无可感知卡顿。 + +## 设计与诚实测法 + +- 在真实金字塔 store(`gpr_poc build ... --levels 3`,level0=44476×29×162, + level1=22238×15×81,level2=11119×8×41,level3=5560×4×21)上跑,非合成。 +- (a)/(b):把对应 level 的所有 brick 重组成单张 VTK_SHORT vtkImageData + (逻辑同 `WholeVolumeSource`,按 level 维度 + spacing×2^level / 局部段 X 偏移), + 喂 `buildVoxelI16FromImage`(SmartVolumeMapper,GPU 路径),旋相机 120 帧测 fps。 + level2/局部段单轴均 <16384 → 单 3D 纹理可成,无纹理墙。 +- (c):同一窗口,相机从远观(level2 整卷)dolly 拉近;第 60 帧跨越 LOD 切换那一下 + 把体从 level2 概览换成 level0 局部 + 焦点移到局部段中心,**逐帧记帧耗时**, + 标切换帧尖峰。这是审核人加的验收点①(测切换动态,非两端静态)。 +- (d):`vtkWindowToImageFilter`+`vtkPNGWriter` 存 3 张 PNG,供人眼判 + “概览糊→拉近清晰”(审核人验收点②)。 +- **双闸(同 9c,绝不把空纹理假帧率当性能)**: + ① `CapturingOutputWindow` 捕获 3D 纹理维度错误(实测=否); + ② 真实回读前缓冲像素,统计非背景像素(概览 1889 / 局部 167612 / 过渡 21924, + 三段均非空)。两闸全过,fps 可信。 + +## 卡顿判据说明(避免误报) + +切换帧含一次性建 actor / 换 mapper 输入,~5.5ms,是邻帧(~0.9ms)的 ~6×;但绝对值 +仍 < 1 个 60Hz 帧(16.7ms),人眼不可感。故采用**绝对耗时判据**:切换帧 >33ms(2 帧) +才记“可感知卡顿”,16.7~33ms 记“轻微抖动”,亚毫秒基线下尖峰倍数虽大但绝对值低不算 +卡顿。本机切换帧 ~5.5ms → 无可感知卡顿。 + +## 判据结论 + +粗层概览 + 全分辨率局部**都达交互级**(≥30fps,远超)且切换**无不可接受卡顿** +→ 命中 brief 第一条判据:**LOD-based C 路线钉死可行**。 + +对照 12b:整卷全分辨率 ray cast(2.08 亿体素)~10fps 是硬上限;本探针证实 +“渲更少体素 = LOD” 这根杠杆有效——粗层 ~752fps、全分辨率局部 ~380fps,两端都远 +在交互级,且 LOD 切换瞬态 ~5.5ms 无卡顿。 + +## 最低配未验声明(审核人验收点③) + +本探针**仅在本机(RTX 3060 Laptop GPU,NVIDIA 555.97,OpenGL 4.5)跑得上限数字**。 +**最低配机器未验证**,需用户在目标机跑 `gpr_poc renderLOD ` 或提供型号后再评估。 +本机数字是上限,最低配可能更低。 + +## 进程峰值内存 + +~99 MB(探针逐 level 重组单张 image,未常驻整卷;level0 局部仅取 4 brick 列)。 + +## Concerns + +1. **截图视觉偏暗/偏细**:体绘制 `kMaxOpacity=0.15`(复用探针传函)+ 整线物理纵横比 + 极扁(2200m × ~1.5m × 8m),故概览图中整线呈一条细带、过渡中间帧呈小斜板。 + 这是物理真实呈现(整线本就是长薄带),非渲染缺陷;但作为“人眼判可接受度”素材 + 偏素净。若需更醒目的生产视觉,需后续调传函不透明度/着色与取景,超出探针范畴(YAGNI)。 +2. **(c) 为单次脚本化切换**:测的是“从 level2 直切 level0 局部”一次硬切的瞬态; + 生产里多级连续 LOD/视野自适应的换页节奏、预取与 morphing/淡入是探针过了之后的 + 工程(brief 明确不在本探针范围)。 +3. **(b) 局部仅取 4 brick 列(256 体素宽)**:证“全分辨率局部块快”;若生产需更宽的 + 全分辨率窗口(仍需 <16384 或分区/分块),fps 会随体素数下降,需届时按窗口大小复测。 +4. **最低配仍是最大未知**(见上声明)。 diff --git a/.superpowers/sdd/task-12d-fix-report.md b/.superpowers/sdd/task-12d-fix-report.md new file mode 100644 index 0000000..349350d --- /dev/null +++ b/.superpowers/sdd/task-12d-fix-report.md @@ -0,0 +1,53 @@ +# Task 12d-fix 报告:修 gpr_poc view 空窗 + 控制台乱码 + +## 状态 +DONE。两 bug 均修复,构建通过(Community vcvars64 直驱 ninja,exit 0),离屏自检通过。 + +## 提交短哈希 +`1495d0e`(feat/vtk-3d-view 分支) + +## 改动文件 +仅 `tools/gpr_poc/main.cpp`(+70 -1)。 + +### Bug 1:概览空窗(LOD 策略错) +- 根因:`view` 每帧 `viewRefreshBlocks` 无脑走分块路径,相机概览时 `pickLevel` 选 level1(696 块)被 budget=64 砍到 64/696(9% 稀疏)→ 看着空。 +- 修复:`viewRefreshBlocks` 按相机选中 level 分流(同 12c renderLOD 已验): + - 相机选中 **level0**(最近、要全分辨率,X=44476 无法成单纹理)→ 分块 + budget(核外 LRU,原路径不变)。 + - 相机选中 **level≥1**(概览/中远)→ `wholeVolumeLevelFor` 从 picked 起向粗找第一个“整卷各轴 ≤16384”的层(本数据 level0/1 的 X=44476/22238>16384 → 升 level2),用 `buildLevelImage` 整卷重组单张 image,单块喂 mapper(忽略 budget,粗层本就小)。整卷 image 按 level 缓存,仅 level 变化时重组。 +- 效果:概览不再是 64/696 稀疏块,而是 **1 个整卷块**渲染完整体。 + +### Bug 2:控制台中文乱码(GBK) +- 修复:`main()` 入口 `#ifdef _WIN32` 下 `SetConsoleOutputCP(CP_UTF8);`(含 ``)。保留全文件已有中文输出,全子命令受益。 + +## 离屏自检结果(view --smoke,tmp\store_lod_001) +修复前: +``` +[view] 预热: level=1 视野块=696/696 驻留=64 渲染块=64 ← 64/696 稀疏 +``` +修复后: +``` +[view] 预热: level=1 视野块=696/696 驻留=64 渲染块=1 ← 整卷单块(升 level2) +=== view --smoke 离屏冒烟 === +近观 level=1 → 拉远 level=3 → 再拉近 level=1 +LOD 随缩放切换 : 是 ✔ (blocksFar=1) +纹理维度错误 : 否 +渲出非空像素 : 是 (近=1024000 远拉近=1024000) +smoke 结果 : OK ✔ 不崩 +``` +- **概览渲染块 64 → 1(整卷)**:核心修复,整卷完整渲染而非 9% 稀疏。 +- 渲出非空像素:是(1024000,无纹理错、不崩)。注:该视角整卷与原稀疏块均填满帧,像素计数饱和,故区分性证据是“渲染块 64→1(整卷)”。 +- **编码正常**:`=== view --smoke 离屏冒烟 ===` 等中文在 UTF-8 控制台正确显示,无 GBK 乱码。 + +## 提交干净性确认 +- `git diff --cached --stat` 提交前确认 index 仅含 `tools/gpr_poc/main.cpp`,无 chart/scatter/quill/rangeslider/Dialog/FormK 等并行会话文件。 +- 仅 `git add tools/gpr_poc/main.cpp`(及本报告),绝无 `git add -A`。 + +## 给用户的重跑命令 +真窗口交互(开窗即见完整粗层体,滚轮拉近变清晰/分块): +``` +build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 +``` +离屏自检: +``` +build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 --smoke +``` diff --git a/.superpowers/sdd/task-12d-polish-report.md b/.superpowers/sdd/task-12d-polish-report.md new file mode 100644 index 0000000..6ff62d4 --- /dev/null +++ b/.superpowers/sdd/task-12d-polish-report.md @@ -0,0 +1,45 @@ +# Task 12d-polish 报告:梯度不透明度 + 光照 打磨探针 + +## 状态 +完成。真实离屏渲染、真实 fps,无编造。三张对比图均通过双闸(无 3D 纹理维度错 + 渲出高于背景像素)。 + +## 命令 +`gpr_poc polish tmp\store_lod_001 --frames 90`(默认取景 El45/Az30/Zoom1.5「斜穿俯视」,视线从上方斜穿体内部而非只看端面)。 + +## 测试体 +- 全分辨率 level0 局部段:256 x 29 x 162(沿线中段 4 brick 列 [345,349)/695),垂向夸张 exagg=8(放大薄 Y/Z 轴使截面可读)。 +- 三图标量传函/配色/取景/夸张全相同,唯一变量是「梯度不透明度 / 光照」。 + +## 梯度幅值分布(量化域,中心差分,545211 样本,按实测标定阈值) +median=5.32,p75=20.1,p90=196.2,p99=9058.5,max=21470。 +梯度不透明度 piecewise(按此分布标定,非猜):grad≤5.32→0.0、grad=196.2(p90)→0.5、grad≥9058.5(p99)→0.9。 +即:占多数的低梯度均匀区透明,仅高梯度处(层界面)不透明。 +标量不透明度峰值:基线 a=0.15(与默认体绘制同档→白雾);b/c 梯度门控压住均匀区后提到 0.6,让层界面净不透明度(标量×梯度)足够高、层面成实面。 + +## 三张对比图(docs/superpowers/plans/poc-lod-shots/) + +| 图 | 路径 | 高于背景像素(>35) | 结构像素(>50) | 平均亮度(0-255) | fps | +|---|---|---|---|---|---| +| a 基线白雾 | polish-a-value.png | 219980 (21.5%) | 145874 (14.2%) | 20.07 | 160.8 | +| b +梯度不透明度 | polish-b-grad.png | 50358 (4.9%) | 36430 (3.6%) | 15.71 | 58.2 | +| c +梯度+光照 | polish-c-grad-shade.png | 25008 (2.4%) | 756 (0.07%) | 14.81 | 57.3 | + +光照参数(c):ShadeOn,Ambient 0.3 / Diffuse 0.7 / Specular 0.2 / SpecularPower 10。 + +## 目视结论:内部层是否「浮」出来了? + +**部分浮出来了,但不是全身——这正是层状数据的固有限制。** + +- **a(基线)**:一根平滑均匀的灰蓝色长条,没有任何内部层次,只有端面隐约可辨——就是需求描述的「体中间均匀白雾、只端面有层次」。穿透均匀水平层积分成雾。 +- **b(+梯度不透明度)**:体的大部分(沿线中段那段均匀体)变透明、白雾消失,**端部/过渡区露出清晰的水平层状条纹**(层界面),底部另现一块淡蓝层状斑。证实:梯度不透明度确实把均匀积分雾抹掉、把层界面显出来了。 +- **c(+梯度+光照)**:在 b 基础上端部层条纹带上轻微立体明暗(层带有了明暗起伏的层次感),但 shading 整体压暗,可见区更少更暗。 + +**关键如实结论**:梯度不透明度 + 光照**能消除均匀白雾、并让「确有梯度突变的层界面」浮出成可读的层状条纹**——打磨方向有效。**但对这条道路 GPR 数据,强梯度集中在端部/过渡区;沿线的长段水平层因「沿测线方向看过去是均匀的」(梯度低)会整段变透明,而不会显出层。**所以打磨**改善了「层界面可见性」**,但**无法让整条体内部都「长出层」——长均匀段的内部偏雾/偏空是层状数据本身的固有属性,不是没打磨。** 想看长段内部层,应配合切片/正交截面,而非纯体绘制穿透积分。 + +## fps 代价 +梯度不透明度需逐采样点算梯度,fps 从基线 160 降到 ~58(约 -64%),但仍远高于交互级 15fps,**可接受**。光照(c)相对 b 近乎免费(58→57)。本机 RTX 3060 数;最低配未验。 + +## 提交自检 +- 仅 `git add` tools/gpr_poc/main.cpp + 3 张 polish-*.png + 本报告;未 `git add -A`。 +- `git diff --cached --stat` 确认无 chart/scatter/quill/rangeslider/Dialog/FormK。 +- 未改任何交互默认(探针性质,仅新增 polish 子命令与三个 polish 专用辅助函数)。 diff --git a/.superpowers/sdd/task-12d-report.md b/.superpowers/sdd/task-12d-report.md new file mode 100644 index 0000000..bf9284b --- /dev/null +++ b/.superpowers/sdd/task-12d-report.md @@ -0,0 +1,173 @@ +# Task 12d 收尾探针报告 —— 视觉调优 + fps 预算 + 可交互开窗 + +实测环境: 本机 RTX 3060 / VTK 9.6 / MSVC+Ninja。store: `tmp/store_lod_001` +(level0 = 44476×29×162, 4 层金字塔, brick=64, 2.09 亿体素)。 + +所有数字为真实离屏实测, 双闸(纹理错捕获 + 回读非空像素)防假帧率。 + +--- + +## 状态 + +完成。三件事全部落地、编译通过、离屏实测出数: +- ① `tune` 视觉调优: 出 `lod-tuned-local.png` / `lod-tuned-overview.png`, 打印调优前后 fps 对照。 +- ② `fps-budget`: 递增全分辨率窗口 fps 表 + 每帧体素预算结论。 +- ③ `view`: 真窗口 + interactor + 缩放切 LOD + 屏幕 fps 文本; 离屏 `--smoke` 通过不崩。 + +改动文件: `tools/gpr_poc/main.cpp` (新增 3 个子命令 + 视觉调优共享构件), 新增两张调优截图, +追加写 `docs/superpowers/plans/poc-results-C.md`。 + +--- + +## ① 视觉调优: 调优前后 fps 对照(证实视觉调优 fps 近乎中性) + +`gpr_poc tune --opacity 0.7 --exagg 8 --localBricks 4` (level0 256×29×162 局部段): + +| 配置 | 色阶 | 不透明度 | 垂向夸张 | 局部 fps | +|---|---|---|---|---| +| 调优前(基线) | 蓝-白-红线性单斜坡 | 0.15 | 1× | 323.3 | +| 调优后 | 结构色阶(深蓝→青→白→黄→红) + 双端斜坡 | 0.7 | 8× | 349.2 | + +**fps 变化 = −8.0%(即调优后反而更快)**。完全证实探针认知: + +- 隔离实验(`--exagg 1`): 不透明度 0.15→0.5/0.6、换结构色阶, fps −5.5%(更快)。 + → **配色/不透明度对 fps 近乎中性, 调高不透明度甚至更快(光线提前终止)。** +- 隔离实验(`--opacity 0.9 --exagg 10`): fps 反而 +49%(更快)。 + 双端斜坡把占多数的近零背景设透明, 不透明片段少 + 提前终止, 抵消了夸张放大的屏占。 +- 早先一版"线性单斜坡 + exagg 8"曾掉 34%, 经排查 **掉帧全部来自垂向夸张(8× 放大薄轴 + → 屏占变大 → ray-cast 片段变多), 与不透明度/配色无关**。改用双端斜坡(背景透明)后 + 即转为净加速。 + +**关键视觉修复**: GPR/地震体值集中在零附近(背景), 强反射在正负两端。原线性单斜坡让 +近零背景填满体、遮住结构(实测渲出一块均匀蓝板, 无结构)。改为**双端斜坡(中段透明 + +正负两端不透明)** 后, 截面的层状反射(地层条带)清晰可辨。 + +调优截图: +- `docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png` + —— 全分辨率局部段, 可见多条水平层状反射条带(地层结构)+ 一处相干蓝色异常体。 +- `docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png` + —— 粗层(level2)概览。物理真实: 整线 2.2km×1.5m×8m 极扁, 概览就是一条细带(可接受)。 + +> 诚实说明: 体物理纵横比极端(X≈2.2km vs Y≈1.5m / Z≈8m), 即便取局部段 + 8× 夸张, +> 单帧里结构仍偏小、偏一隅, 背景大片黑。结构确实可辨(层状条带 + 异常体), 但"一眼炸裂" +> 受物理形态限制——这正是 brief 预期的"细带本质"。production 可配可调色阶/取景控件让 +> 用户交互找最佳视角(即 ③ view)。 + +--- + +## ② fps 预算: 递增全分辨率(level0)窗口 → 每帧体素预算 + +`gpr_poc fps-budget --bricks 4,16,64,128,256,512,695 --frames 90` +(沿线中段递增 brick 列, 单 image 整段体绘制, 双闸): + +| brick 段 | 维度 | 体素数 | 体绘制 fps | ≥30 | 备注 | +|---|---|---|---|---|---| +| 4 | 256×29×162 | 1,202,688 | 218.3 | 是 | | +| 16 | 1024×29×162 | 4,810,752 | 155.7 | 是 | | +| 64 | 4096×29×162 | 19,243,008 | 240.9 | 是 | | +| 128 | 8192×29×162 | 38,486,016 | 305.8 | 是 | | +| 256 | 16384×29×162 | 76,972,032 | 329.7 | 是 | 触达 GL_MAX_3D_TEXTURE_SIZE=16384 | +| 512 | 32768×29×162 | 153,944,064 | INVALID | 否 | X=32768>16384, 纹理墙, 双闸标 INVALID | +| 695 | 44476×29×162 | 208,948,248 | INVALID | 否 | 同上 | + +### 每帧体素预算结论(重要, 与 brief 框架略有出入但更真实) + +- **fps 在所有可上传测点(≤16384 单轴)始终 ≫ 30(218~330fps), 全程没跌破 30。** fps 不随 + 体素数单调下降(甚至上升), 因 ray-cast 成本主要由屏占 × 采样步长决定, 而薄维度(Y29/Z162) + 使光线路径短, 单 3D 纹理上传成功后体素总数不是瓶颈。 +- **真正的硬墙是 GL_MAX_3D_TEXTURE_SIZE = 16384**: 单轴超 16384 → 整段无法成单张 3D 纹理 + (512/695 行双闸正确判 INVALID, 绝不当真上报)。 +- 因此本数据集上, **"单张 3D 纹理的每帧体素预算" = 单轴 ≤16384 → ≈ 7700 万体素(256 brick 列)** + 跑 ~330fps 仍极宽裕; **限制 production LOD 每帧块数的不是 30fps 阈值, 而是 16384 纹理墙—— + 超墙必须切块(MultiBlock / SetPartitions / 本机核外 OutOfCoreSource)。** +- fps 驱动的体素预算(跌破 30)只会在远更大/更稠密体或多块叠加渲染时出现; 本数据集薄维度下 + GPU 余量充足, 未触达。 + +> 这与 brief"找 fps<30 阈值"的设想不同, 但是实测真相: **本数据集的命门是纹理尺寸墙, +> 不是帧率墙**。如实记录。 + +--- + +## ③ `gpr_poc view ` —— 真窗口可交互(给用户肉眼测 + 最低配机跑) + +实现要点: +- 真 `vtkRenderWindow` + `vtkRenderWindowInteractor`(`vtkInteractorStyleTrackballCamera`), + 挂 `OutOfCoreSource`(核外 LOD + 视野选块, budget 限驻留, 内存恒定)。 +- 相机变化(`EndInteractionEvent`)→ `source.update(camera)` 重选 LOD/视野块 → 重建 MultiBlock + → 重渲。**缩放跨越距离/对角线档位时 LOD 真切换**(离屏 smoke 实测 level 1↔0 切换)。 +- 屏幕左上角 `vtkTextActor` 实时显示 `fps | LOD level | blocks | exagg`, 每帧更新。 +- 默认结构色阶 + 双端斜坡不透明度 + 垂向夸张(同 ①)。 +- 参数: `--exagg N --opacity F --budget K`(K=每帧最大全分辨率块数, 接 ② 预算)。 + +离屏 smoke(`view --smoke`)实测: +``` +预热: level=1 视野块=696/696 驻留=64 渲染块=64 +近观 level=1 → 拉远 level=1 → 再拉近 level=0 +LOD 随缩放切换 : 是 ✔ +纹理维度错误 : 否 +渲出非空像素 : 是 (近=1024000 远拉近=1024000) +smoke 结果 : OK ✔ 不崩 +``` + +### view 命令用法 + +``` +gpr_poc view [--exagg 8] [--opacity 0.6] [--budget 64] [--smoke] +``` +- 不带 `--smoke` = 开真窗口可交互(留给用户跑)。 +- 带 `--smoke` = 离屏建管线 + 模拟缩放验 LOD 切换 + 验不崩(CI/无显示环境用)。 + +--- + +## 给用户的肉眼测试说明(请转达用户) + +**启动命令**(在已构建的仓库根目录): +``` +build\release\tools\gpr_poc\gpr_poc.exe view tmp\store_lod_001 --exagg 8 --opacity 0.6 --budget 64 +``` +- DLL/PATH: 无需手设。CMake 已把 VTK/Qt 等运行时 DLL 拷到 exe 旁(`gpr_poc.exe` 同目录), + 直接双击/命令行运行即可。 +- 若换其它 store, 把 `tmp\store_lod_001` 换成你的金字塔 store 目录(需先 `gpr_poc build ... --levels 3`)。 + +**操作:** +- **滚轮**: 向前滚拉近 → 应看到全分辨率结构(屏幕 `LOD level` 数字变小, 0=最细); + 向后滚拉远 → 变粗层概览(level 数字变大, 体变糊)。 +- **左键拖动**: 旋转视角(TrackballCamera)。 +- **q 键 / 关窗**: 退出。 + +**判断点(可接受标准):** +1. **拉近后能否看清地质结构**: 局部段应呈现水平层状反射条带(地层)+ 可辨的相干异常体。 + 能看出层次即可接受(受物理细带形态限制, 不会像规则立方体那样饱满)。 +2. **概览(细带)可不可接受**: 拉远后是一条细长带(整线 2.2km×1.5m×8m 物理真实), 接受它是细带。 +3. **拉近/拉远切 LOD 时卡不卡、糊→清过渡能不能接受**: 切换应顺滑, 无明显卡死/长 stall + (本机切换 ~5-9ms, 远小于 1 个 60Hz 帧 16.7ms, 不可感)。 +4. **屏幕 fps 是否 ≥30**: 屏幕左上角实时 fps。本机(RTX 3060)远超 30(数百 fps); + **最低配机重点看这条**——拉到最细 LOD、最大夸张时 fps 是否仍 ≥30。 + +**最低配怎么跑:** +- 把整个 `build\release\tools\gpr_poc\` 目录(含所有 DLL)+ 一个 store 目录拷到目标机, + 跑上面的 `view` 命令, 肉眼看屏幕 fps 与交互流畅度。 +- 或无显示/批处理场景跑 `gpr_poc fps-budget tmp\store_lod_001` 出该机的体素-fps 表对照。 + +--- + +## 最低配未验声明 + +本探针仅在本机 **RTX 3060** 跑出上限数字(数百 fps, 余量充足)。**最低配机器未验证**, +需用户拿目标机跑 `gpr_poc view `(肉眼判 fps≥30 + 交互流畅)或 `gpr_poc fps-budget ` +(出该机体素-fps 表)。production 是否对最低配可用, 以目标机实测为准。 + +--- + +## Concerns + +1. **视觉天花板受物理形态限制**: 体极扁(2.2km×1.5m×8m), 单帧结构偏小偏一隅。这是数据物理 + 真实, 非 bug; production 应给用户交互色阶/取景/裁剪控件(view 已具备旋转缩放, 色阶可参数化)。 +2. **fps 不是本数据集的瓶颈, 纹理尺寸墙(16384)才是**: 与 brief"找 fps<30 阈值"设想不同。 + 每帧体素预算结论是"单轴 ≤16384 即可单纹理上传, fps 仍 ≫30", 超墙必须切块。如实记录。 +3. **view 的 LOD 阈值按未夸张几何标定**: `pickLevel` 用 level0 原始对角线算距离比, 而 actor + 已 `SetScale(1,exagg,exagg)`。夸张会轻微平移"缩放-LOD 映射"档位, 但切换仍正常触发 + (smoke 实测 level 1↔0)。若用户觉得切档时机别扭, 后续可让 pickLevel 感知夸张系数。 +4. **view 连续拖动 fps 文本基于上一帧耗时估算**(单帧 wall-clock 倒数), 非滑动平均, 数字会抖; + 足够给用户感知量级(几十/几百 fps), 非精密基准(精密基准走 fps-budget/renderLOD 离屏)。 +5. `last-metrics.txt`(repo 根, 探针追加输出)未纳入提交——它从未被 git 跟踪, 是瞬时产物。 diff --git a/.superpowers/sdd/task-9b-report.md b/.superpowers/sdd/task-9b-report.md new file mode 100644 index 0000000..9f0db28 --- /dev/null +++ b/.superpowers/sdd/task-9b-report.md @@ -0,0 +1,68 @@ +# Task 9b 报告:gpr_poc CLI + 真实数据 headless 度量 + +状态:**PARTIAL / BLOCKED** +- CLI 编译链接通过;`selftest` PASS(合成数据端到端跑通整条地基)。 +- 真实明星路数据 **BLOCKED**:前置 IO 层 `readIprb` 的 `traces=lastTrace+1` 严格校验 + 与真实文件「道数=lastTrace」系统性不符,装配阶段即抛异常,无法实测建体指标。 + **未擅自修改前置/其单测**(被现有测试钉死的契约 + 跨任务边界),故真实指标暂缺,如实记录。 + +--- + +## 1. 交付物(均为本会话自有文件) + +- `tools/gpr_poc/main.cpp` —— CLI:`build` / `load` / `selftest` 三子命令。 +- `tools/gpr_poc/Probe.hpp` —— header-only 计时(steady_clock)+ 峰值内存(Psapi `PeakWorkingSetSize`)。 + 含 `NOMINMAX`/`WIN32_LEAN_AND_MEAN` 防 `` 宏污染 `std::numeric_limits::min/max`。 +- `tools/gpr_poc/CMakeLists.txt` —— 可执行 `gpr_poc`,链 `geopro_io_gpr/geopro_core/geopro_store/geopro_render` + + Windows `Psapi`;`vtk_module_autoinit` 注册 VTK 工厂。 +- 顶层 `CMakeLists.txt` —— 加 `add_subdirectory(tools/gpr_poc)`(在 `add_subdirectory(src)` 之后)。 +- `docs/superpowers/plans/poc-results-B.md` —— 实测结果(selftest PASS + 真实数据 BLOCKED 根因表)。 + +注:库目标实际名为 `geopro_store`(brief 写作 geopro_store/已对齐)与 `geopro_data`; +本工具链 `geopro_store`(分块存储),正确。 + +## 2. 构建 + +- 配置:`cmd /c "build.bat configure"`(preset msvc-release,build/release)成功 + (cmd 被环境劫持但真实命令仍执行;以 build.ninja 出现 gpr_poc target 确认)。 +- 编译:PowerShell + vcvars64 直驱 cmake `--build build/release --target gpr_poc`。 + 首次失败:`` min/max 宏污染 → 加 NOMINMAX 修复 → 二次链接成功。 +- 运行需 PATH 带 Qt6/VTK/vcpkg bin(headless 工具仍依赖这些 DLL)。 + +## 3. selftest 结果 + +``` +gpr_poc selftest +[selftest] GridSpec 2x2x8 dz=0.714286 +[selftest] PASS (exit 0) +``` +覆盖:assembleGprSurvey → buildGprVolume → write(brick=4) → buildPyramid(1) → +WholeVolumeSource,断言维度/层数/体素非 blank 全通过。 + +## 4. 真实数据指标 + +**未实测(BLOCKED)**。根因:`readIprb`(`src/io/gpr/IprbReader.cpp:16`) +`traces=lastTrace+1` 严格字节校验;真实明星路 14 通道每个恰含 `lastTrace` 道(少 1 道), +逐通道实测一致(详见 poc-results-B.md §2 表)。非 OOM/超时——装配前读入即失败, +调 `--cellXY` 无法绕过。现有 `tests/io/gpr/test_iprb_reader.cpp:30-31` 锁定该抛异常契约。 + +用的参数:`--line 001 --cellXY 0.2 --cellZ 0.05 --levels 2`(建体未到达)。 +预估几何(非实测,供核对):nx≈11118, ny≈8, nz≈1(深度尺度因土速单位为微米级, +cellZ=0.05 压成单层——需 POC owner 复核土速/时窗单位与 cellZ)。 + +## 5. 提交前自检 + +- 仅 `git add` 自有文件:`tools/gpr_poc/*`、顶层 `CMakeLists.txt`、 + `docs/superpowers/plans/poc-results-B.md`、本报告 `.superpowers/sdd/task-9b-report.md`。 +- `git diff --cached --stat` 确认无 chart/scatter/quill/rangeslider 等并行会话行。 +- 顶层 CMakeLists 的暂存 diff 应仅含新增的 `add_subdirectory(tools/gpr_poc)` 一行块。 + +## 6. Concerns / 需 owner 决策 + +1. **真实数据 BLOCKER(高)**:`readIprb` 道数契约与真实数据不符。建议放宽为 + 「道数 = 文件字节 / (samples·2)」(容忍 ±N 道),或确认 LAST TRACE 语义后去 +1, + 并同步改单测。落地后重跑两条命令即可补齐 §4 真实指标。 +2. **深度尺度(中)**:SOIL VELOCITY=100 m/s(头单位 m/µs ×1e6)→ 深度跨度微米级, + cellZ=0.05 会把 Z 压成 1 层。影响真实体维度与 9c 渲染基准,需确认单位约定。 +3. 顶层 CMakeLists 当前 working tree 已有他会话的修改(视觉设计/chart 等);本会话只新增 + add_subdirectory 一行,暂存时务必只 stage 该文件并核对 diff,勿带入其他未暂存改动。 diff --git a/.superpowers/sdd/task-9c-report.md b/.superpowers/sdd/task-9c-report.md new file mode 100644 index 0000000..9beebee --- /dev/null +++ b/.superpowers/sdd/task-9c-report.md @@ -0,0 +1,44 @@ +# Task 9c 报告:POC-B 离屏 GPU 渲染基准 + +状态:**DONE**(闸门通过;真实基准实测完成;关键发现如实记录,无任何编造 fps) + +执行机:Windows 11,MSVC(VS18 Community)+ Ninja,Release;GPU = NVIDIA RTX 3060 Laptop GPU,OpenGL 4.5.0 NVIDIA 555.97。 +日期:2026-06-23。 + +--- + +## 1. 交付物 + +- `tools/gpr_poc/main.cpp`:新增两个子命令 + - `gpr_poc offscreen-smoke` —— 最小离屏渲染冒烟(闸门),打印 OK/FAIL + GL 能力。 + - `gpr_poc renderB [--frames 120]` —— 离屏体绘制 + 切片扫描 fps 基准。 +- `tools/gpr_poc/CMakeLists.txt`:补 VTK 组件(RenderingVolume / RenderingVolumeOpenGL2 / ImagingCore / InteractionStyle)。 +- `docs/superpowers/plans/poc-results-B.md`:新增「§4 离屏 GPU 渲染基准」段(闸门 + 真实指标 + 关键发现 + 结论)。 +- `build/_t9c_build.bat`:本任务用的 gpr_poc 单 target 构建脚本(vcvars64 直驱 cmake)。 + +## 2. 闸门结果 —— OK + +`offscreen-smoke`:离屏 vtkRenderWindow(SetOffScreenRendering+SetShowWindow(false))→ cube actor → Render() → +GetRGBACharPixelData 读回 65536 像素,非背景 28224。GL vendor=NVIDIA,硬件加速 True。**离屏 GL 可用**,继续真实基准。 + +## 3. 真实 GPU 指标(line 001, cellXY=0.05, cellZ=0.05) + +- 体维度:**44476 × 29 × 162**;体素数 ≈2.09 亿;整卷字节 **398.54 MB**(int16)。 +- **体绘制 fps:INVALID** —— 整卷 X 维 44476 超 `GL_MAX_3D_TEXTURE_SIZE=16384`, + `vtkVolumeTexture` 报 `Invalid texture dimensions [44476,29,162]`,未真正绘出体数据。 + raw_fps=295.6 是空纹理假帧率,已显式标 INVALID,**不作为体绘制性能上报**。 +- **切片扫描 fps:54.6 fps**(120 帧沿 Z 扫整卷,vtkImageReslice 2D 切面 + 2D 纹理;不受 3D 纹理上限约束)。≥30fps 目标达成。 +- 是否进显存:**否**(瓶颈是单轴纹理维度上限 16384,非显存字节;整卷 398 MB << RTX 3060 6GB 显存)。 +- GPU 显存(NVX):**N/A**(随包 VTK 安装未带 GLEW 头,无法链 GL loader 直查;GL 扩展列表确认机器支持 NVX_gpu_memory_info)。 +- 进程峰值内存:**≈509 MB**(加载整卷 398 MB + 渲染管线)。 +- build 峰值内存:4830 MB(装配阶段 double survey 主导,与 §2 一致);无 OOM,cellXY=0.05 一次通过。 + +## 4. concerns + +1. **整卷朴素体绘制对长测线根本不可行**:X=44476 撞 OpenGL 单轴 3D 纹理上限 16384。 + 与显存容量无关,是硬限制。任何「整卷一次性 3D 纹理」方案对长测线都会撞墙。 + 这是 **Task 12(核外 / 分块 LOD / 体纹理分区 `vtkOpenGLGPUVolumeRayCastMapper::SetPartitions`)** + 的硬性依据。本任务按约束未做核外,仅如实记录。 +2. SmartVolumeMapper 报 GPURenderMode=2 但纹理上传失败——`GetLastUsedRenderMode()` 不能单独作为 + 「真的渲染出来了」的判据;renderB 已加 OutputWindow 捕获 + 维度超限双判据才下 INVALID 结论。 +3. GPU 显存读数缺失(N/A):仅因 VTK 安装未带 GLEW 头;若需要可后续单独链 GL loader 调 NVX 枚举。 diff --git a/CLAUDE.md b/CLAUDE.md index daced9b..d1d28b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,22 @@ When your changes create orphans: The test: Every changed line should trace directly to the user's request. +**Exception — Tech debt must be fixed, not deferred:** When you discover a real bug, +broken behavior, or technical debt while working (even if it predates this change and +you didn't introduce it), fix it — do not use "not introduced by this round / pre-existing" +as a reason to leave it. Surface it, then handle it. (User directive, 2026-06-25, binding.) +This overrides the "don't fix what isn't broken" bias above *for genuine defects* — it does +not license cosmetic refactors or unrequested rewrites. + +**Do it yourself — never offload work you can do (User directive, 2026-06-25, binding):** +If you have the tools to do something, DO IT — never tell the user to do it for you. +Read the logs yourself (`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log` via Bash/grep), +inspect data/fixtures yourself, build and link yourself (`build.bat app` via PowerShell), +diagnose by adding logging and then reading that log yourself. The ONLY things to ask the +user for are: (a) closing a running app so the exe can relink (LNK1104 — a lock only they can +release), and (b) genuine product decisions. Do not ask the user to read logs, inspect data, +run diagnostics, or interpret output — that is your job. + ## 4. Goal-Driven Execution **Define success criteria. Loop until verified.** diff --git a/CMakeLists.txt b/CMakeLists.txt index 7d766ba..ac9ea1a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,7 +76,17 @@ if(EXISTS "${CMAKE_SOURCE_DIR}/external/qwt-src/src") include("${CMAKE_SOURCE_DIR}/cmake/qwt.cmake") endif() +# vendored 3DGPRViewer 数据生成链(原样拷贝,算法零改动):geopro_gpr3dv 静态库。 +# 链:多通道 .iprh/.iprb → GPRDataModel 立方体 → RadarProcessor 处理。生产管线 A 地基。 +add_subdirectory(external/gpr3dviewer) + add_subdirectory(src) +# POC-B headless 度量 CLI(gpr_poc)。链 io_gpr/core/store/render,在真实数据上跑端到端度量。 +add_subdirectory(tools/gpr_poc) + +# gpr3dv 冒烟 CLI:走 vendored 原版 API(loadImpulseMultiChannel → buildVolumeData → runPipeline)。 +add_subdirectory(tools/gpr3dv_smoke) + enable_testing() add_subdirectory(tests) diff --git a/build.bat b/build.bat index 86db412..1e23b5e 100644 --- a/build.bat +++ b/build.bat @@ -2,11 +2,13 @@ REM ============================================================ REM geopro build helper (Windows / MSVC + Ninja, CMake presets) REM -REM Usage: build [app | all | test | run | configure] -REM app (default) build target geopro_desktop -REM all build all targets +REM Usage: build [app | all | test | run | rebuild | configure] +REM app (default) build target geopro_desktop (incremental) +REM all build all targets (incremental) REM test build + run unit tests via ctest -REM run build + launch geopro_desktop +REM run incremental build + launch geopro_desktop +REM rebuild FORCE clean rebuild (--clean-first) + launch - use when +REM incremental seems stale / changes not showing up REM configure force re-run CMake configure (after CMakeLists changes) REM REM Requires: Visual Studio 2022/2026 (Desktop C++ workload, ships @@ -25,14 +27,17 @@ if not exist "%VSWHERE%" ( echo [build] vswhere not found. Open "x64 Native Tools Command Prompt for VS" and build manually. exit /b 1 ) -for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -property installationPath`) do set "VSPATH=%%i" -if not defined VSPATH ( echo [build] Visual Studio not found. & exit /b 1 ) +REM -all -prerelease for VS2026 preview (note: -latest yields empty on this preview, and +REM -products * would pull in the bundled BuildTools whose vcpkg/env breaks our preset); +REM -requires ensures the C++ toolset is present. Multiple installs -> last one wins. +for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -all -prerelease -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i" +if not defined VSPATH ( echo [build] Visual Studio with C++ toolset not found. Install the VS Desktop C++ workload. & exit /b 1 ) set "VCVARS=%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" set "CMAKE=%VSPATH%\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" set "CTEST=%VSPATH%\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\ctest.exe" -if not exist "%VCVARS%" ( echo [build] vcvars64.bat not found: %VCVARS% & exit /b 1 ) -if not exist "%CMAKE%" ( echo [build] cmake not found: %CMAKE% & exit /b 1 ) +if not exist "%VCVARS%" ( echo [build] vcvars64.bat not found: "%VCVARS%" & exit /b 1 ) +if not exist "%CMAKE%" ( echo [build] cmake not found: "%CMAKE%" & exit /b 1 ) REM --- activate MSVC environment (cl / link / include / lib) --- call "%VCVARS%" >nul @@ -45,7 +50,8 @@ if /i "%CMD%"=="app" goto :app if /i "%CMD%"=="all" goto :all if /i "%CMD%"=="test" goto :test if /i "%CMD%"=="run" goto :run -echo [build] unknown command "%CMD%". Use: app ^| all ^| test ^| run ^| configure +if /i "%CMD%"=="rebuild" goto :rebuild +echo [build] unknown command "%CMD%". Use: app ^| all ^| test ^| run ^| rebuild ^| configure exit /b 1 :ensure @@ -77,3 +83,11 @@ call :ensure "%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop || exit /b 1 "%BUILDDIR%\src\app\geopro_desktop.exe" exit /b %errorlevel% + +:rebuild +REM Force full clean rebuild (--clean-first) then launch; avoids flaky ninja incremental. +REM If geopro_desktop is already running, link fails (LNK1104, exe locked) - close it first. +call :ensure +"%CMAKE%" --build "%BUILDDIR%" --target geopro_desktop --clean-first || exit /b 1 +"%BUILDDIR%\src\app\geopro_desktop.exe" +exit /b %errorlevel% diff --git a/docs/Geopro3.0_视觉设计规范.md b/docs/Geopro3.0_视觉设计规范.md index 278678c..6693612 100644 --- a/docs/Geopro3.0_视觉设计规范.md +++ b/docs/Geopro3.0_视觉设计规范.md @@ -394,6 +394,105 @@ ## 7. 表单、表格、对话框等通用组件(补充 · 原型未全部出现但客户端必备) +### 7.0 表单布局(编辑态 / 只读态)— 总则 + +> 本节是「编辑/只读表单」的**单一事实来源**,统一约束四类表单:①弹出编辑/新建对话框、②属性子视图(对象属性 / 数据集属性)、③动态表单(`getDynamicForm` 驱动)、④首选项设置。控件本身的样式仍引用 §7.1–7.3、§6.12–6.13;本节只定义「把这些控件组织成一张表单」的布局、分组、状态、校验与配色规则。 +> +> **设计取向**(参考主流优秀客户端):macOS 系统设置 / Windows 11 设置(分组卡片 + 左标签)、JetBrains IDE 设置(密集左标签)、Figma 右侧属性面板(紧凑内联编辑)、Linear / Stripe(清晰节奏与即时校验)。在「信息密度优先」(§0.3)前提下取其**密集左标签 + 清晰分组 + 即时校验 + 克制留白**。 + +#### 7.0.1 两种表单形态(先定形态,再排布局) + +| 形态 | 用途 | 实现 | +|---|---|---| +| **只读表单**(展示) | 纯查看的属性详情 | 用 §6.4 **属性键值表**(键值两列,数值等宽,不可编辑) | +| **可编辑表单**(编辑/新建) | 编辑、新建、设置 | 用本节「标签 + 控件」行式表单 | + +**铁律**:一张表单只要存在**任一可编辑字段**,整张表即用「可编辑表单」形态;其中的只读字段以**禁用态控件**(§7.1 禁用)呈现,**不得**在同一张表单里一半键值表、一半输入框——保证可编辑与只读字段在同一栅格中对齐一致。 + +#### 7.0.2 表单栅格与行结构 + +| 元素 | 规范 | +|---|---| +| 标签位置 | **左侧标签列**(默认,密集专业风),标签文字**右对齐**贴近字段;字段名过长或窄单列对话框可改顶部标签 | +| 标签列宽 | 可编辑表单**固定 `100px`**(`space::kFormLabelCol`,右对齐,跨表单等宽);纯只读键值表**固定 `72px`**(`space::kDetailKeyCol`,§6.4)。**不取区间**——区间会让不同实现各选一值而漂移 | +| 标签 ↔ 字段间距 | `space/md`(12) | +| 字段控件高 | `28px`(§7.1);行与行垂直间距 `space/sm`(8) | +| 字段宽度 | 宽面板/对话框中**不要拉满**,单字段最大宽约 `360px`(多行/长文本可更宽);窄属性面板中填充可用宽度 | +| 表单内边距 | 对话框内 `space/xl`(24);面板内 `space/lg`(12–16) | +| 列数 | **单列优先**;信息多用分组而非多列。仅「短字段(数值+单位)」可两列并排 | + +#### 7.0.3 分组(Section) + +- 分组标题:`text/heading`,上方留 `space/lg`,标题下可加 1px `divider` 贯通。 +- 组**内**字段紧凑(行距 `space/sm`),组**间**留 `space/lg`–`space/xl`。 +- 对话框内多组(对齐原版 `getDynamicForm` 的 基本信息 / 测线布设 / 数据质量 等):组数多时用**锚点分组**或**分页签**,避免一屏堆叠过长。 + +#### 7.0.4 字段状态与标记 + +| 标记/态 | 规范 | +|---|---| +| **必填** | 标签后红色 `*`(`status/danger`),紧贴标签 | +| 可选 | **默认不标记**(保持干净);确需时标签后加 `text/tertiary` 的「(可选)」 | +| **只读/禁用** | §7.1 禁用态(底 `neutral-50`/Dark `#1A1F26`、文字 `text/disabled`、禁用光标)——明确不可编辑,其值**仍随表单提交** | +| **错误** | §7.1 错误态:描边 `status/danger` + 字段下方 `text/caption` `status/danger` 说明 | +| 帮助/说明 | 字段下方 `text/caption` `text/tertiary` 一行(与错误说明**互斥**位置) | +| 单位/前后缀 | 框内右端 `text/tertiary`(§7.1 前后缀) | + +#### 7.0.5 校验与提交反馈 + +- **即时校验**:失焦/输入时校验单字段,错误就地显示(§7.1 错误态),不打断输入。 +- **提交校验**:点「保存/确定」校验全表 → 有错则**滚动并聚焦第一个错误字段** + 就地显示错误;可同时在表单顶部用**行内提示**(§7.7 inline alert)汇总「请完善 N 项」。**不要**只弹一个模糊 toast。 +- **脏标记**:表单无用户修改时「保存」按钮**置灰不可点**;任一字段改动后启用(与已落地的对象属性面板一致)。 +- **提交中**:主按钮 loading(spinner/禁用)防重复提交;成功后 Toast(§7.7)+ 关闭或刷新。 + +#### 7.0.6 弹出编辑 / 新建对话框(body) + +- 外壳遵 §7.5(容器 `radius/lg` + 标题栏 + 底部操作栏)。body 即本节表单:内距 `space/xl`、单列左标签、分组按 7.0.3。 +- 底部操作栏:**取消(次按钮,左)+ 主操作(确定/保存,Primary,右)**;破坏性确认用 Danger(§7.5/§7.6)。 +- **新建 vs 编辑**:编辑态预填现值;不可改字段(如类型、按 API 只读的名称)用**禁用控件**而非省略;标题区分「新建 XX / 编辑 XX」。 + +#### 7.0.7 四类表单的归口(落地映射) + +| 表单 | 形态 | 要点 | +|---|---|---| +| 对象属性面板(对象属性 Tab) | 可编辑表单 | 名称**仅顶部显示一处**且按 API 只读;只读字段禁用灰显;脏标记控制保存 | +| 数据集属性面板 | 上半 §6.4 **只读键值表** + 下半**可编辑描述**(单字段可编辑表单) | 两段职责分明 | +| 动态表单(`getDynamicForm`) | 可编辑表单 | 分组 = `formList` 组;控件按 `displayComponentType` 映射(§7.1/7.2/7.3/6.12);必填/只读由 `requiredType`/`fieldUseType` 决定(7.0.4) | +| 首选项设置(§7.10) | 设置型表单变体 | 主从布局 + 设置行(左:标题+说明,右:控件) | + +#### 7.0.8 配色与排版令牌(汇总 · 禁硬编码) + +| 角色 | token | +|---|---| +| 标签文字 | `text/secondary`(必填 `*` 用 `status/danger`) | +| 字段值 / 输入文字 | `text/primary`;数值/坐标/编号用等宽 `type::kMonoFamily` | +| 分组标题 | `text/heading` | +| 分隔线 | `divider` | +| 错误(描边+说明) | `status/danger` | +| 只读字段 底/文字 | `neutral-50`(Dark `#1A1F26`)/ `text/disabled` | +| 字段 背景/边框/focus | 见 §7.1(`bg/panel`、`border/default`→`border/strong`→`border/focus`) | + +#### 7.0.9 可访问性 + +- 标签与控件**关联**(点击标签聚焦其控件 / buddy)。 +- Tab 焦点顺序自上而下、左到右;焦点环可见(§10、§12)。 +- **不以颜色为唯一信息**:必填除 `*` 外,校验失败有文字说明;错误态有文字。 +- 可点控件最小命中区 ≥ `24×24px`(§12)。 + +#### 7.0.10 实现纪律(单一实现 · 禁止手搭) + +> **本节是 §7.0 能落地的关键。** 文档约束管不住代码——同类表单分散在各文件里手搭 `QFormLayout`/`QLabel`、各填各的边距与列宽,必然漂移(曾出现:三维体/切片/异常详情用裸 `QFormLayout`,与属性面板天差地别;同为下拉框,设置页无箭头、对象属性偏矮带箭头)。一致性**只能由代码复用强制**。 + +- **唯一实现(必须经此产出,不得另起炉灶)**: + - 只读键值详情 → `DynamicFormView`(§6.4 渲染器)。对话框用 `formkit::DetailForm`(链式 `group()/row()`)构模型,`formkit::buildDetailDialog()` 铺骨架。 + - 可编辑表单 → `formkit::makeEditForm()` + `formkit::editLabel()` + `formkit::capField()` + `formkit::addSection()`。`DynamicFormEditor` 与各参数对话框(如「生成三维体」)**都走同一组**,确保标签列宽/行距/分组/字段上限逐像素一致。 +- **禁止**:业务表单/对话框直接 `new QFormLayout` + `new QLabel("名",值)` 手搭键值;禁止在表单里逐处写死边距/列宽/字号(用 `space::*`/`type::*` 令牌)。 +- **精确常量(单一来源 `Theme.hpp`,禁止区间/魔数)**:可编辑标签列 `space::kFormLabelCol=100`;只读键列 `space::kDetailKeyCol=72`;字段最大宽 `space::kFormFieldMax=360`;行距 `space::kMd`;分组上间距 `space::kLg`。 +- **控件构造一致性**:下拉框统一用不可编辑 `QComboBox`(无候选项的「选择」字段退化为 `QLineEdit` 自由文本,**不得**用「可编辑下拉框」——其几何/高度与不可编辑款不一致)。全局 QSS **不覆写** `QComboBox::drop-down`/`::down-arrow`,保留 Fusion 原生箭头随调色板自适应(覆写却不提供箭头图会致箭头消失)。`QComboBox` 的 `min-height`/`padding` 与 `QLineEdit` 完全对齐 → 同高。 +- **新增表单的验收**:截图与既有「对象属性 / 数据详情」并排,标签列、行高、分组标题、下拉框外观应**无法区分**;做不到即说明绕开了上述唯一实现。 + +--- + ### 7.1 输入框(Text Input) | 状态 | 规范 | diff --git a/docs/OPTIMIZATION-BACKLOG.md b/docs/OPTIMIZATION-BACKLOG.md new file mode 100644 index 0000000..f20452b --- /dev/null +++ b/docs/OPTIMIZATION-BACKLOG.md @@ -0,0 +1,76 @@ +# 待优化清单(Optimization Backlog) + +> 全局「待优化 / 技术债 / 性能与体验改进」登记簿。**所有**后续发现但当下不做(或暂以折中实现)的优化点 +> 都登记到此,并随进展更新状态。区别于 bug(bug 当场修,见 CLAUDE.md 技术债规则)——这里收录的是 +> 「能用但不够理想、需要更大改造才能做到位」的优化项。 + +## 状态图例 +- 🔴 Open — 待优化,尚未动工 +- 🟡 In Progress — 正在做 +- 🟢 Done — 已完成(保留记录,标注完成 commit/日期) +- ⚪ Won't Do — 评估后决定不做(标注原因) + +## 维护约定 +- 新增项用递增 ID(OPT-NNN),不复用已删 ID。 +- 每项含:背景/现状、期望、难点、状态、记录日期、关联 commit。 +- 状态变更时更新「状态」行与「更新」行,不删历史。 + +--- + +## OPT-001 · 放大系数(VE) 完全无重绘的即时缩放 +- **状态**:🔴 Open +- **记录日期**:2026-06-25 +- **背景/现状**:`VtkSceneController::setVerticalExaggeration` 当前走「保留相机重建」(commit `7ff6f18`)—— + 改 VE 时相机不再跳远视角、原地按新夸张重绘,但**数据/底图仍会重建并重绘一次**(有一次闪烁)。 + 根因:VE 被烤进几何——帘面用 `actor->SetScale(1,1,VE)`、体素把 VE 烤进 image 的 z origin/spacing、 + 地形烤进 `buildTerrain`;且切片附着依赖**含 VE 的 currentVolumeImage_**。 +- **期望**:拖动放大系数时纯 actor 层 Z 缩放,**零重载零重绘**、即时跟手(理想可恢复拖动实时预览)。 +- **难点**: + - 体素须改为「image 建在 VE=1、vtkVolume prop 用 `SetScale(1,1,VE)`」,但切片重采样依赖含 VE 的 image + 几何,需同步改造切片附着/重采样链(InteractionManager)。 + - 地形/帘面/体素三类 actor 的 VE 应统一走 actor 变换,避免混用(部分烤几何、部分 actor 缩放)。 + - 底图(TileBasemap) VE 同步是否也能免重载需评估。 +- **关联**:`7ff6f18`(当前保留相机的折中实现)。 +- **更新**:— + +--- + +## OPT-002 · 多三维体并发的切片渲染 +- **状态**:🟢 Done(issue2/③/反向② `69e8790`;④ 拾取串选 `63cda56`,体 PickableOff,逻辑修复待 live 复核) +- **记录日期**:2026-06-25 +- **背景/现状**:切片渲染绑定单一「当前体」——`syncSlices` 只显示 `sp.volumeDsId == currentVolumeDsId()` + 的切片,`currentVolumeDsId` = 最后添加的体(`VtkSceneView::volumeOwnerDs_`)。勾选第二个三维体后它 + 成为 current,第一个体的切片被 `syncSlices` 隐藏(用户 issue2:选第二个体时第一个体的切片消失)。 + 根因:`InteractionManager` 把切片附着到单个 `currentVolumeImage_`,不支持同时挂多个体的 image。 +- **期望**:多个体同时渲染时,各自的已勾选切片都能并存显示(按各切片的 `volumeDsId` 取对应体 image 重采样)。 +- **难点**:`InteractionManager` 的切片附着/重采样改为「按 volumeDsId 多体管理」;`VtkSceneView` 需暴露 + 多个体的 image(非单 `currentVolumeImage_`);切片拾取/选中也要按所属体区分。与 OPT 无关的切片右键 + 「保存/导出」依赖 selectedSlice 当前体,也需一并核对。 +- **关联**:syncSlices/onVolumeChanged(`src/app/main.cpp`)、`VtkSceneView::currentVolumeImage_`。 +- **同簇问题(一并改造)**: + - **③ 右键体却把切片建到 current 体**:右键三维体 A「生成切片」时仍用 `currentVolumeDsId()`(=最后渲染的 + 体)创建切片。需把目标体 dsId 随右键带下来,并让 addSlice 用 A 的 image(依赖多体 image 管理)。 + - **④ 切片拾取串选**:已修(`63cda56`)。根因=点击落体内部时 picker 命中体、worldPoint 落体内 → + `nearestSlice` 按平面距离选错切片。修法=体 actor `PickableOff`,光标拾取只落切片平面 → worldPoint + 落在光标下那张切片 → 选对(`onPick` 仅命中时触发,未命中不误选)。重叠切片仍按最前优先(合理)。 + 逻辑闭合但**未 live 点击验证**(工具无法交互点击 3D 切片);若仍有偏差需 live 复核(重叠循环切换等)。 +- **更新**:2026-06-25 issue2+③+反向²(`69e8790`)+④(`63cda56`) 全部实现。 + +--- + +## OPT-003 · 二维分析 C 期:dd_raster 栅格地理配准渲染(阻塞·待后端端点) +- **状态**:🔴 Open(**阻塞**:后端无栅格数据端点) +- **记录日期**:2026-06-26 +- **背景/现状**:二维分析改造分期 A→B→C(spec `docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`)。 + A(一场景两相机)、B(足迹高程 Z 拖动)已实现(commit `6a10975`、`bdebe54`)。**C 期=dd_raster 栅格** + (DD0623 新增 ddCode,展示模式 2D,形态=栅格/遥感影像)尚未做。 +- **期望**:`dd_raster` 纳入 2D 维度过滤(`dimOf`/`dimensionOf` 加 `dd_raster`→Dim2D);col2D 勾选渲染按 + ddCode 分派(轨迹走 `loadMapLine`,栅格走**栅格加载**,不可串);栅格取**像素 + 四至/仿射 + 投影 CRS** + 作地理配准纹理平面贴到地形上(带高程,可被 B 期 Z 拖动),类似底图瓦片按经纬定位。 +- **阻塞点(为何不做)**:实测后端 API 文档 `docs/apis/business_OpenAPI.json` **无任何 dd_raster / 栅格影像 + 端点**(仅有 grid 行/反演 grid、GPR 通道图,均非带四至+投影的栅格)。无端点 → 无像素/地理范围可加载 → + C 期无数据可渲染。**须后端提供返回「像素 + 四至/仿射 + 投影」的端点后方可落地。** +- **难点**:栅格加载路径(新)、按 ddCode 的渲染分派、地理配准纹理平面(参考 `TileBasemap` 的 `buildFlat`/ + `buildWarped` 按经纬贴地形 + DEM 位移)。 +- **关联**:spec §6/§9/§10/§11;handoff `docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md` §0。 +- **更新**:— diff --git a/docs/api/vtk-3d-openapi.json b/docs/api/vtk-3d-openapi.json new file mode 100644 index 0000000..7a6e0fb --- /dev/null +++ b/docs/api/vtk-3d-openapi.json @@ -0,0 +1,544 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Geopro3 三维视图 API(三维体 / 切片 / 异常 三件套)", + "version": "0.6.1-draft", + "description": "VTK 三维视图后端接口。归属结构(2026-06-24 修订):**GS/项目根/TM → 三维体(dd_voxel) → 切片(dd_slice)**(三维体生成位置由用户在生成对话框选择,默认单GS挂该GS/跨GS挂项目根,可改为项目内任意 GS/TM;源数据集与归属解耦),异常挂在三维体上(remarkSourceId=三维体 dsObjectId)。\n\n**总原则:实体无关的契约一律复用存量;只为各自特有、存量装不下的部分扩展。**\n- 三维体/切片对后端 = 纯元数据 dsObject:增删改查/属性复用存量 dsObject 面,各加 1 个登记端点;体素字节/切面数据全在客户端(算+存+取+渲染),后端零数据端点。\n- 异常复用整套存量 /business/exception 端点(端点不限实体类型,三维体 id 直接塞 remarkSourceId);**异常体(consortium)分组也是存量已有**(consortiumId/Name/Type)。3D 仅扩展两处:location 加 worldPts+plane(三维几何)、加截图(R88)。\n\n响应统一信封 `{ code:int, msg:string, data:object|array }`,code==200 成功;列表/集合放 data.value。\n\n依赖前提:异常 remarkSourceId 指向三维体,须等三维体登记出真 dsObjectId 后,3D 异常才能接真端点。" + }, + "servers": [ + { "url": "/", "description": "业务网关根(各路径已含 /business 前缀)" } + ], + "tags": [ + { "name": "dsObject-reuse", "description": "复用的存量统一面(三维体/切片共用增删改查+属性)" }, + { "name": "voxel-new", "description": "三维体新增:仅登记记录(体素全在客户端)" }, + { "name": "slice-new", "description": "切片新增:仅登记记录(切面全在客户端)" }, + { "name": "exception-reuse", "description": "复用的存量异常面(端点不限实体类型;3D 仅扩展 location 几何+截图)" } + ], + "paths": { + "/business/projectStruct/queryProjectStruct/{projectId}": { + "get": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 查询项目结构树(GS/TM 骨架)", + "description": "存量端点。提供三维体挂载所需的 TM 节点;三维体/切片作为派生数据另经 data/page 取。", + "operationId": "queryProjectStruct", + "parameters": [ { "name": "projectId", "in": "path", "required": true, "schema": { "type": "string" } } ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/StructNodeList" } } } ] } } } + } + } + } + }, + "/business/dsObject/data/page": { + "post": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 分页查询某父节点下的数据集行", + "description": "存量端点(loadRowsAsync)。查三维体:structParentId=生成位置节点 id(GS/项目根/TM)、structParentConfType=1或2;查某三维体下切片:structParentId=该三维体 dsObjectId。\n\n**实测口径(2026-06-25)**:①行在 `data.list`(非 data.value);②返回项目结构下**各类** dsObject(文件/网格/反演/三维体/切片/异常…),行 ddCode 非仅 voxel/slice;③客户端用 classifyType==1 时改打 `/business/dsObject/file/page`(文件型),否则 `data/page`;④**行不返回业务字段值**(properties 为文件元数据或 null),**装置类型(arrayType) 不在 data/page 行上**——它属测线脚本配置(ScriptInfoVO),故客户端「类型筛选」改按行自带的 typeName/dsTypeCode 范围筛,而非 arrayType。", + "operationId": "dsObjectDataPage", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DsPageRequest" } } } }, + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DsPage" } } } ] } } } + } + } + } + }, + "/business/dsObject/getDetail/{dsObjectId}": { + "get": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 数据集详情(描述 + attachedParameters)", + "description": "存量端点。三维体构建参数(attachedParameters.voxelParams)、切片三点位姿(attachedParameters.slicePose)从这里读出。", + "operationId": "dsObjectGetDetail", + "parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DsObjectDetail" } } } ] } } } + } + } + } + }, + "/business/dsObject/dynamicForm/{dsObjectId}": { + "get": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 数据集属性(动态表单)", + "description": "存量端点。三维体/切片可读属性由后端为 dd_voxel/dd_slice 注册 formList 后从此返回,无需为属性新增固定字段接口。", + "operationId": "dsObjectDynamicForm", + "parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/DynamicForm" } } } ] } } } + } + } + } + }, + "/business/dsObject/updateDsObject/": { + "put": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 更新数据集(描述 / attachedParameters)", + "description": "存量端点。改名/改描述/改三维体参数/改切片位姿都走这条。注意 URL 末尾斜杠为服务端实证要求。", + "operationId": "updateDsObject", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateDsObjectRequest" } } } }, + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + } + }, + "/business/dsObject/{dsObjectId}": { + "delete": { + "tags": ["dsObject-reuse"], + "summary": "[复用] 删除数据集", + "description": "存量端点。删三维体级联其下切片/异常记录(客户端另清本地体素落盘);删切片不影响异常(异常挂三维体、与切片解耦)。", + "operationId": "deleteDsObject", + "parameters": [ { "$ref": "#/components/parameters/DsObjectIdPath" } ], + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + } + }, + "/business/dsObject/voxel/generate": { + "post": { + "tags": ["voxel-new"], + "summary": "[新增] 登记三维体记录", + "description": "在生成位置节点(GS/项目根/TM)下登记一条 dd_voxel dsObject(名称 + 构建参数写入 attachedParameters.voxelParams),返回新 dsObjectId。只建记录、不触发后端计算——体素插值/落盘/渲染全在客户端。", + "operationId": "registerVoxel", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VoxelGenerateRequest" } } } }, + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/CreatedRef" } } } ] } } } + } + } + } + }, + "/business/dsObject/slice/generate": { + "post": { + "tags": ["slice-new"], + "summary": "[新增] 登记切片记录", + "description": "在所属三维体下登记一条 dd_slice dsObject(名称 + 三点位姿写入 attachedParameters.slicePose),返回新 dsObjectId。只建记录、不触发后端计算——切面据「体+位姿」在客户端重采样渲染。\n\n**实测(2026-06-25)**:客户端 mock 仓储无项目上下文,当前打印的请求体 projectId 为空;接真后端前客户端需补 nav.currentProjectId()。其余字段(volumeDsId/name/axis/三点/colorScaleId)与本 schema 一致。", + "operationId": "registerSlice", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SliceGenerateRequest" } } } }, + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/CreatedRef" } } } ] } } } + } + } + } + }, + "/business/exceptionType/queryExceptionTypeByProjectIdAndType/{projectId}/{remarkSourceType}": { + "get": { + "tags": ["exception-reuse"], + "summary": "[复用] 异常类型列表", + "description": "存量端点(queryExceptionTypeData)。按项目 + 标注形态(remarkSourceType=1点/2线/3面/4文字)查可用异常类型。与实体类型无关 → 3D 通用。data.value 为类型数组。", + "operationId": "queryExceptionTypes", + "parameters": [ + { "name": "projectId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "remarkSourceType", "in": "path", "required": true, "schema": { "type": "string", "enum": ["1", "2", "3", "4"] }, "description": "标注形态:1点/2线/3面/4文字" } + ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionTypeList" } } } ] } } } + } + } + } + }, + "/business/exceptionType": { + "post": { + "tags": ["exception-reuse"], + "summary": "[复用] 新增异常类型", + "description": "存量端点(addExceptionType)。异常属性 + 标注名称双 Tab 组装的类型定义(含图例/字段列表)。3D 直接复用。", + "operationId": "addExceptionType", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddExceptionTypeRequest" } } } }, + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + } + }, + "/business/exception/getExceptionName": { + "post": { + "tags": ["exception-reuse"], + "summary": "[复用] 取建议异常名称", + "description": "存量端点(queryExceptionNameInProfileInversion)。按异常类型 + 被标注实体回填建议名。3D 把 remarkSourceId 填三维体 id 即可。data 为纯字符串(名称)。", + "operationId": "getExceptionName", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["exceptionTypeId", "remarkSourceId"], + "properties": { + "exceptionTypeId": { "type": "string" }, + "remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" } + } + } + } + } + }, + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "type": "string", "description": "建议名称(纯字符串)" } } } ] } } } + } + } + } + }, + "/business/exception": { + "post": { + "tags": ["exception-reuse"], + "summary": "[复用+扩展] 新增异常", + "description": "存量端点(newExceptionInProfileInversion)。2D 字段全复用;**3D 扩展**:location 增加 worldPts(三维几何) + plane(所在平面),并增加 screenshot(R88 截图)。remarkSourceId=三维体 dsObjectId;consortiumId 归入异常体(存量已有)。", + "operationId": "newException", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NewExceptionRequest" } } } }, + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + }, + "put": { + "tags": ["exception-reuse"], + "summary": "[复用+扩展] 更新异常", + "description": "存量端点(updateExceptionDataInProfileInversion)。改名/备注/几何;3D 同样可改扩展后的 location 与截图。", + "operationId": "updateException", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateExceptionRequest" } } } }, + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + } + }, + "/business/exception/{id}": { + "delete": { + "tags": ["exception-reuse"], + "summary": "[复用] 删除异常", + "description": "存量端点(deleteExceptionDataInProfileInversion)。删异常体=按 consortiumId 循环删其下异常(无专用批删端点)。", + "operationId": "deleteException", + "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ], + "responses": { "200": { "$ref": "#/components/responses/StatusOk" } } + } + }, + "/business/exception/queryException/{remarkSourceId}": { + "get": { + "tags": ["exception-reuse"], + "summary": "[复用+扩展] 查某实体下的异常列表", + "description": "存量端点。2D 传 dsObjectId、3D 传三维体 dsObjectId。返回该实体全部异常;客户端按 consortiumId 分组成异常体树。**3D 扩展**:返回项 location 含 worldPts+plane、并含 screenshot。", + "operationId": "queryExceptionBySource", + "parameters": [ { "name": "remarkSourceId", "in": "path", "required": true, "schema": { "type": "string" }, "description": "2D=dsObjectId;3D=三维体 dsObjectId" } ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionList" } } } ] } } } + } + } + } + }, + "/business/exception/queryExceptionByTmObjectId/{tmObjectId}": { + "get": { + "tags": ["exception-reuse"], + "summary": "[复用] 查某 TM 下全部异常(按异常体分组)", + "description": "存量端点(loadExceptionsByTmAsync)。返回 TM 下全部异常,含 consortiumId/consortiumName/consortiumType(异常体分组,存量已有),客户端按 consortiumId 归组。", + "operationId": "queryExceptionByTm", + "parameters": [ { "name": "tmObjectId", "in": "path", "required": true, "schema": { "type": "string" } } ], + "responses": { + "200": { + "description": "成功", + "content": { "application/json": { "schema": { "allOf": [ { "$ref": "#/components/schemas/Envelope" }, { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/ExceptionList" } } } ] } } } + } + } + } + } + }, + "components": { + "parameters": { + "DsObjectIdPath": { + "name": "dsObjectId", "in": "path", "required": true, + "schema": { "type": "string" }, "description": "数据集 dsObject id" + } + }, + "responses": { + "StatusOk": { + "description": "操作状态", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Envelope" } } } + } + }, + "schemas": { + "Envelope": { + "type": "object", + "required": ["code", "msg"], + "properties": { + "code": { "type": "integer", "example": 200, "description": "200=成功,其它=失败" }, + "msg": { "type": "string", "example": "操作成功" }, + "data": { "description": "业务载荷(对象或数组)", "nullable": true } + } + }, + "CreatedRef": { + "type": "object", + "required": ["dsObjectId"], + "properties": { "dsObjectId": { "type": "string", "description": "新建数据集 id" } } + }, + "Vec3": { + "type": "array", "description": "世界系三分量(米)", + "items": { "type": "number", "format": "double" }, "minItems": 3, "maxItems": 3 + }, + "Pt2": { + "type": "object", "description": "2D 点(剖面系:x=距离, y=深度)", + "properties": { "x": { "type": "number", "format": "double" }, "y": { "type": "number", "format": "double" } } + }, + "InterpModel": { + "type": "string", "enum": ["Idw", "Kriging"], "default": "Idw", + "description": "插值模型(本期仅 Idw 实现,Kriging 占位)" + }, + "RemarkSourceType": { + "type": "string", "enum": ["1", "2", "3", "4"], + "description": "标注形态(非实体类型):1点/2线/3面/4文字" + }, + "StructNodeList": { + "type": "object", "description": "结构树信封内层(data.value)", + "properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/StructNode" } } } + }, + "StructNode": { + "type": "object", "description": "项目结构扁平节点(GS/TM),客户端按 parentId 建树", + "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "parentId": { "type": "string" }, + "typeName": { "type": "string" }, "confCode": { "type": "string" }, + "typeId": { "type": "string", "description": "类型 id(编辑时 getDynamicForm 必需)" }, + "type": { "type": "integer" } + } + }, + "DsPageRequest": { + "type": "object", "description": "存量 dsObject/data/page 请求体", + "required": ["projectId", "structParentId", "structParentConfType", "classifyTypeList", "pageNo", "pageSize"], + "properties": { + "projectId": { "type": "string" }, + "structParentId": { "type": "string", "description": "查三维体填生成位置节点 id(GS/项目根/TM);查切片填所属三维体 dsObjectId" }, + "structParentConfType": { "type": "integer", "description": "父节点配置类型:1=GS/项目根 2=TM(三维体生成位置);查切片时=三维体所在层级" }, + "classifyTypeList": { "type": "array", "items": { "type": "integer" }, "description": "数据类别过滤(dd_voxel/dd_slice 的 classify code 由后端定义)" }, + "pageNo": { "type": "integer", "default": 1 }, + "pageSize": { "type": "integer", "default": 20 } + } + }, + "DsPage": { + "type": "object", "description": "分页结果。**注意**:data/page 的行在 `data.list`(不是 data.value),与 structNode/exception 等集合用 data.value 不同(实测客户端按 data.list 解析)。", + "properties": { "total": { "type": "integer" }, "list": { "type": "array", "items": { "$ref": "#/components/schemas/DsRow" } } } + }, + "DsRow": { + "type": "object", + "description": "数据集行(data/page 通用 dsObject 行;客户端 parseDsRows 实测口径)。data/page 返回项目结构下**各类** dsObject(文件/网格/反演/三维体/切片/异常…),非仅三维体/切片。", + "required": ["id", "ddCode"], + "properties": { + "id": { "type": "string" }, + "dsName": { "type": "string", "description": "数据集名" }, + "name": { "type": "string", "example": "电阻率反演数据", "description": "**数据集类型名**(客户端映射为 typeName;注意 JSON 键是 name,非 typeName)。类型筛选即按此值的范围筛。" }, + "ddCode": { "type": "string", "description": "通用 dd 码:dd_voxel/dd_slice/dd_anomaly/dd_section/dd_inversion_data/dd_grid/dd_file 等(非仅 voxel/slice)" }, + "dsTypeCode": { "type": "string", "nullable": true, "description": "数据集类型代码(如 'ERT platform inversion data';分段按它归类,typeName 缺失时筛选回退它)" }, + "createTime": { "type": "string", "nullable": true, "description": "创建时间(列表副标题/时间筛选用)" }, + "sourceShowParentId": { "type": "string", "nullable": true, "description": "显示树父(派生数据挂源数据下);客户端 parentId 优先取它,缺则回退 parentId" }, + "parentId": { "type": "string", "nullable": true, "description": "sourceShowParentId 的回退" }, + "structParentId": { "type": "string", "nullable": true, "description": "结构归属父(项目根/GS/TM)——分段树据此把 ds 挂到 GS/TM 容器下" }, + "structParentConfType": { "type": "integer", "nullable": true, "description": "结构父配置类型:1=GS/项目根 2=TM" }, + "properties": { + "description": "字段值(泛型 JSON)。**两种形态都可能**:数组 [{confFieldId,value}] 或对象 {confFieldId:value};文件型 ds 此处可能是文件元数据;派生数据可能为 null。**实测 data/page 多不返回业务字段值(装置/采集时间等),故装置类型不在此**。", + "nullable": true + }, + "file": { + "type": "object", "nullable": true, "description": "文件型 ds 的文件信息", + "properties": { "name": { "type": "string" }, "url": { "type": "string" }, "size": { "type": "integer" } } + } + } + }, + "DsObjectDetail": { + "type": "object", "description": "存量 dsObject/getDetail 返回。三维体/切片机器参数搭车 attachedParameters。", + "properties": { + "dsObjectId": { "type": "string" }, "name": { "type": "string" }, + "description": { "type": "string", "nullable": true }, + "attachedParameters": { + "type": "object", "description": "附加参数(自由结构化 blob)", + "properties": { + "deltaContent": { "type": "array", "items": { "type": "object" }, "description": "描述富文本(Quill delta ops)" }, + "voxelParams": { "allOf": [ { "$ref": "#/components/schemas/VolumeBuildParams" } ], "nullable": true, "description": "dd_voxel 专属:构建参数(体素字节在客户端本地)" }, + "slicePose": { "allOf": [ { "$ref": "#/components/schemas/SliceSpec" } ], "nullable": true, "description": "dd_slice 专属:三点位姿(切面在客户端重采样)" } + } + } + } + }, + "DynamicForm": { + "type": "object", "description": "ds 属性动态表单(存量统一模型)", + "properties": { + "formList": { + "type": "array", "description": "分组列表", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "分组名" }, + "values": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "value": { "type": "string" } } } } + } + } + } + } + }, + "UpdateDsObjectRequest": { + "type": "object", "required": ["dsObjectId"], + "properties": { + "dsObjectId": { "type": "string" }, "description": { "type": "string" }, + "attachedParameters": { + "type": "object", "description": "改三维体参数/切片位姿放这里", + "properties": { + "deltaContent": { "type": "array", "items": { "type": "object" } }, + "voxelParams": { "allOf": [ { "$ref": "#/components/schemas/VolumeBuildParams" } ], "nullable": true }, + "slicePose": { "allOf": [ { "$ref": "#/components/schemas/SliceSpec" } ], "nullable": true } + } + } + } + }, + "VolumeBuildParams": { + "type": "object", "description": "三维体构建参数(必存元数据;体素字节由客户端按此本地插值,不上后端)", + "required": ["sourceDatasetIds"], + "properties": { + "sourceDatasetIds": { "type": "array", "items": { "type": "string" }, "minItems": 1, "description": "源数据集 id(≥1,被引用即锁定不可改)" }, + "interpModel": { "$ref": "#/components/schemas/InterpModel" }, + "cellXY": { "type": "number", "format": "double", "default": 1.0, "description": "水平网格间距(米)" }, + "cellZ": { "type": "number", "format": "double", "default": 0.5, "description": "竖向网格间距(米)" }, + "power": { "type": "number", "format": "double", "default": 2.0, "description": "IDW 幂" }, + "maxDist": { "type": "number", "format": "double", "default": 4.0, "description": "超距 blank" }, + "colorScaleId": { "type": "string", "nullable": true, "description": "色阶来源 ds(空=取首个源色阶)" } + } + }, + "VoxelGenerateRequest": { + "type": "object", "required": ["projectId", "structParentId", "structParentConfType", "name", "sourceDatasetIds"], + "properties": { + "projectId": { "type": "string" }, + "structParentId": { "type": "string", "description": "生成位置节点 id —— 三维体挂在所选 GS/项目根/TM 下" }, + "structParentConfType": { "type": "integer", "default": 1, "description": "1=GS/项目根 2=TM;默认单GS挂该GS、跨GS挂项目根,用户可在生成对话框改为任意 GS/TM" }, + "name": { "type": "string" }, + "sourceDatasetIds": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "interpModel": { "$ref": "#/components/schemas/InterpModel" }, + "cellXY": { "type": "number", "format": "double", "default": 1.0 }, + "cellZ": { "type": "number", "format": "double", "default": 0.5 }, + "power": { "type": "number", "format": "double", "default": 2.0 }, + "maxDist": { "type": "number", "format": "double", "default": 4.0 }, + "colorScaleId": { "type": "string", "nullable": true } + } + }, + "SliceSpec": { + "type": "object", + "description": "切面精确几何(三点+轴向)。法向=normalize((p1-o)×(p2-o)),可派生。存于 attachedParameters.slicePose。", + "required": ["volumeDsId", "origin", "point1", "point2"], + "properties": { + "volumeDsId": { "type": "string", "description": "所属三维体 dsObjectId" }, + "axis": { "type": "integer", "enum": [0, 1, 2, 3], "default": 3, "description": "0 上下/1 前后/2 左右/3 任意" }, + "origin": { "$ref": "#/components/schemas/Vec3" }, + "point1": { "$ref": "#/components/schemas/Vec3" }, + "point2": { "$ref": "#/components/schemas/Vec3" }, + "colorScaleId": { "type": "string", "nullable": true } + } + }, + "SliceGenerateRequest": { + "type": "object", "required": ["projectId", "volumeDsId", "name", "origin", "point1", "point2"], + "properties": { + "projectId": { "type": "string" }, + "volumeDsId": { "type": "string", "description": "所属三维体 dsObjectId —— 切片挂在三维体下" }, + "name": { "type": "string" }, + "axis": { "type": "integer", "enum": [0, 1, 2, 3], "default": 3 }, + "origin": { "$ref": "#/components/schemas/Vec3" }, + "point1": { "$ref": "#/components/schemas/Vec3" }, + "point2": { "$ref": "#/components/schemas/Vec3" }, + "colorScaleId": { "type": "string", "nullable": true } + } + }, + "ExceptionLocation": { + "type": "object", + "description": "异常几何载荷。2D 用 coordinate;**3D 扩展** worldPts+plane。后端须扩展 location schema 以往返保存 3D 字段。", + "properties": { + "coordinate": { "type": "array", "items": { "$ref": "#/components/schemas/Pt2" }, "description": "2D 剖面坐标点(存量)" }, + "worldPts": { "type": "array", "items": { "$ref": "#/components/schemas/Vec3" }, "description": "【3D 扩展】异常多边形/折线世界 3D 点" }, + "plane": { + "type": "object", "nullable": true, "description": "【3D 扩展】异常所在平面", + "properties": { "normal": { "$ref": "#/components/schemas/Vec3" }, "origin": { "$ref": "#/components/schemas/Vec3" } } + } + } + }, + "ExceptionRecord": { + "type": "object", + "description": "异常记录(queryException 返回项)。前段为存量 2D 字段,末尾为 3D 扩展。", + "properties": { + "id": { "type": "string" }, + "exceptionName": { "type": "string" }, + "exceptionTypeId": { "type": "string" }, + "exceptionTypeName": { "type": "string" }, + "remark": { "type": "string", "nullable": true }, + "createTime": { "type": "string" }, + "markType": { "$ref": "#/components/schemas/RemarkSourceType" }, + "remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" }, + "remarkSourceType": { "$ref": "#/components/schemas/RemarkSourceType" }, + "location": { "$ref": "#/components/schemas/ExceptionLocation" }, + "latitudeLongitude": { "type": "object", "description": "经纬度坐标(存量展示用)", "properties": { "latLon": { "type": "array", "items": { "type": "object", "properties": { "longitude": { "type": "number" }, "latitude": { "type": "number" } } } } } }, + "geographicalCoordinates": { "type": "object", "description": "投影坐标(存量展示用)", "properties": { "coordinates": { "type": "array", "items": { "type": "object", "properties": { "northCoord": { "type": "number" }, "eastCoord": { "type": "number" } } } } } }, + "consortiumId": { "type": "string", "nullable": true, "description": "异常体分组 id(存量已有);空=未分组 loose" }, + "consortiumName": { "type": "string", "nullable": true }, + "consortiumType": { "type": "string", "nullable": true }, + "legend": { "type": "object", "description": "图例样式(颜色/线宽/虚实等)" }, + "screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】异常截图(base64 或文件引用,传输方式由后端定)" } + } + }, + "ExceptionList": { + "type": "object", "description": "异常列表信封内层(data.value)", + "properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/ExceptionRecord" } } } + }, + "ExceptionTypeRow": { + "type": "object", "description": "异常类型项", + "properties": { + "id": { "type": "string" }, + "exceptionTypeName": { "type": "string" }, + "exceptionTypeCode": { "type": "string" }, + "exceptionMarkType": { "$ref": "#/components/schemas/RemarkSourceType" }, + "legend": { "type": "object" } + } + }, + "ExceptionTypeList": { + "type": "object", "description": "异常类型列表信封内层(data.value)", + "properties": { "value": { "type": "array", "items": { "$ref": "#/components/schemas/ExceptionTypeRow" } } } + }, + "AddExceptionTypeRequest": { + "type": "object", + "description": "新增异常类型(对照存量 addExceptionType 全字段)", + "required": ["exceptionTypeName", "exceptionMarkType", "projectId"], + "properties": { + "exceptionTypeName": { "type": "string" }, + "exceptionTypeCode": { "type": "string" }, + "standardNumber": { "type": "string", "nullable": true }, + "standardName": { "type": "string", "nullable": true }, + "description": { "type": "string", "nullable": true }, + "legend": { "type": "object", "description": "按 markType 的图例样式" }, + "exceptionNameList": { "type": "array", "items": { "type": "object", "properties": { "fieldName": { "type": "string" }, "fieldCode": { "type": "string" }, "sort": { "type": "integer" } } } }, + "customFormat": { "type": "string", "nullable": true }, + "separatorSymbol": { "type": "string", "nullable": true }, + "projectId": { "type": "string" }, + "exceptionMarkType": { "$ref": "#/components/schemas/RemarkSourceType" }, + "type": { "type": "integer", "default": 2 } + } + }, + "NewExceptionRequest": { + "type": "object", + "description": "新增异常。存量字段 + 3D 扩展(location.worldPts/plane、screenshot)。", + "required": ["exceptionName", "exceptionTypeId", "projectId", "remarkSourceId", "remarkSourceType", "location"], + "properties": { + "exceptionName": { "type": "string" }, + "exceptionTypeId": { "type": "string" }, + "projectId": { "type": "string" }, + "remarkSourceId": { "type": "string", "description": "2D=dsObjectId;3D=三维体 dsObjectId" }, + "remarkSourceType": { "$ref": "#/components/schemas/RemarkSourceType" }, + "remark": { "type": "string", "nullable": true }, + "location": { "$ref": "#/components/schemas/ExceptionLocation" }, + "consortiumId": { "type": "string", "nullable": true, "description": "归入异常体(存量已有;空=loose)" }, + "screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】异常截图" } + } + }, + "UpdateExceptionRequest": { + "type": "object", + "description": "更新异常(对照存量 updateException)。", + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "exceptionName": { "type": "string" }, + "remark": { "type": "string", "nullable": true }, + "location": { "allOf": [ { "$ref": "#/components/schemas/ExceptionLocation" } ], "nullable": true }, + "consortiumId": { "type": "string", "nullable": true }, + "screenshot": { "type": "string", "nullable": true, "description": "【3D 扩展 R88】" } + } + } + } + } +} diff --git a/docs/questions/2026-06-16-反演剖面竖向字段(y-z-elevation)语义待确认.md b/docs/questions/2026-06-16-反演剖面竖向字段(y-z-elevation)语义待确认.md new file mode 100644 index 0000000..3dd7f69 --- /dev/null +++ b/docs/questions/2026-06-16-反演剖面竖向字段(y-z-elevation)语义待确认.md @@ -0,0 +1,82 @@ +# 反演剖面(dd_inversion_data)竖向字段 y / z / elevation 语义待业务确认 + +- 日期:2026-06-16 +- 背景:桌面客户端在 3D 视图里把 ERT 反演剖面(`dd_inversion_data`)渲染成**竖直帘面**。水平方向已用 `lat/lon` 摆到真实测线位置(弯曲测线渲染为曲面,已验证)。**竖直方向用哪个字段、如何定位,目前不确定**,需业务/数据方确认。 +- 数据来源:线上 `GET /business/dd/ert/inversion/rows/{dsObjectId}`。 + +--- + +## 1. 接口返回的竖向相关字段 + +`dd/ert/inversion/rows` 的 `data` 含: +- `x`:长度 `nx`,水平轴(距离?)。 +- `y`:长度 `ny`,竖直轴(含义不明,见下)。 +- `v`:`[ny][nx]` 电阻率值矩阵(不规则区大量 `null`)。 +- `z`:`[ny][nx]`,逐格一个数(含义不明)。 +- `elevation`:长度 `nx`,疑似每列地表高程。 +- `lat` / `lon`:长度 `nx`,每列经纬度(已用于水平定位)。 +- `vmin` / `vmax`、`sectionType` 等。 + +--- + +## 2. 核心问题:y / z / elevation 的范围与关系**跨数据集不一致** + +抽样 4 条真实 `dd_inversion_data`(**全部 `sectionType=1`**,即不是"剖面类型不同"导致): + +| 数据集 | 项目 | x 范围 | **y 范围** | **z 范围** | **elevation 范围** | z/v 空值 | +|---|---|---|---|---|---|---| +| T251230002-M-2 | 地大华睿演示 | [200.0, 437.7] | **[-35.1, -1.1]** | [-101.5, -0.09] | [-35.0, -34.1] | 0 | +| T120526003-3 | 香港威立雅 | [2.9, 74.6] | **[13.1, 26.2]** | [2.1, 15.4] | [41.6, 51.0] | 1618/1900 | +| ERT1-WS | 演示(高密度+瞬变) | [0.2, 75.7] | **[9.8, 26.7]** | [0.1, 15.4] | [36.1, 37.9] | 14911/90000 | +| ert2-ws | 射洪垃圾填埋场 | [0.04, 235.4] | **[287.6, 353.4]** | [-10.1, -0.03] | [287.8, 292.8] | 0 | + +**观察到的矛盾:** +1. `y` 的范围毫无统一规律:有负(-35~-1)、有小正(9~27)、有大正(287~353)。无法判断它是"深度(向下为正)"、"相对层号"、还是"绝对高程"。 +2. `z` 同样无规律:有深负(-101.5)、有小正(0~15)、有小负(-10)。 +3. `elevation` 看起来最像"地表高程"(随项目所在地不同:-34 / 41~51 / 36 / 288~293),但与 `y`、`z` 的换算关系**对不上**(例:射洪 `y`≈`elevation`≈287~353,而地大 `y`=-35~-1 远小于 `elevation`=-34)。 +4. `z` 的空值数恰好等于 `v` 的空值数 → `z` 随"有无数据"分布(像是逐格的某个量)。 + +> 结论:仅凭数据无法可靠推断竖向模型,且**不同数据集疑似采用了不同的竖向约定/基准**(可能与上传来源/格式有关)。 + +--- + +## 3. 客户端做法演变 + +- **早期(已废弃)**:竖直 Z = `-y[j]`(把 `y` 当"深度向下"),平顶、不随地表。加真实地形底图后暴露问题:剖面整体沉到地下。 +- **当前(2026-06-17)**:竖直 Z = `+y[j]`(把 `y` 当**真实高程**),与同样按真实高程渲染的地形底图同系对齐 → 剖面顶≈地表、露出地面(复刻原版观感)。详见 §6。 +- 仍**未使用 `z` 和 `alt/elevation`**。 + +--- + +## 4. 请业务/数据方确认的问题 + +1. **`y` 是什么?** 深度(向下为正/为负?)、相对层号、还是绝对高程?为何不同数据集范围差异巨大(-35~-1 vs 287~353)?是否存在多套竖向基准? +2. **`z`(`[ny][nx]`)是什么?** 逐格的真实高程?深度?还是别的量(如反演网格的实际竖向坐标/褶皱面)? +3. **`elevation`(`[nx]`)是什么?** 每列地表高程吗?单位、基准(海拔/相对)? +4. **3D 剖面竖向应如何定位?** + - (A) 维持现状:平顶"距离×深度"面(与 2D 详情一致);或 + - (B) 跟随地表起伏:顶面按 `elevation`/`z` 摆到真实高程、随地形上下。 + 若选 (B),请给出**用哪个字段、如何换算**(Z = ?(y, z, elevation))。 +5. **垂向单位与方向**:米?向上为正还是向下为正? +6. **不同数据集的竖向差异**是数据本身的真实差异,还是上传/解析造成的不一致(需后端修正)? + +--- + +## 5. 影响 + +- 现状 (A) 已能正确渲染(与 2D 详情一致),可继续使用。 +- 若业务要 (B) 地形跟随,需上面第 2/3/4 项的明确定义后,客户端按真实竖向模型实现(避免凭猜测导致渲染错误)。 + +--- + +## 6. 进展(2026-06-17):原版 web 代码已部分解答,但仍有跨数据集风险 + +拿到原版 `commercial-admin` 代码(`dataView/threeMap/threeMap.vue` `addEntityToMap`)后确认其做法: +- **`data.y` = 真实高程**(不是深度)。原版 `originY = data.y[0]`、`localY = data.y - originY`(相对形状),剖面世界 Z = `originY + localY = data.y`,并用 **`alt`(测线每点地表高程)** 算坡度/地表偏移;地形用 Mapbox 真实 DEM。两者同在真实高程系 → 剖面顶≈地表、露出地面。 +- **无垂直夸张**(`scale.y = 1`,真实比例)。 + +**客户端据此已改为 Z = `+y`(§3 当前),与原版完全一致**(实证:`threeMap.vue:676` `position.z = originY + zCorrection`,`originY = data.y[0]`,注释明说"剖面底部对应真实海拔 originY",网格 `localY = data.y - originY`,`rotateX` 后竖向 = `data.y`)。 + +**原版的 `alt` 仅用于**:水平定位(`geo2pos` 取 `.x/.y`)+ 坡度补偿 `zCorrection`(而对应的 `rotateZ` 在 667 行被注释掉、实际不生效)。**原版并不用 alt 把剖面顶贴地表**——之前文档此处的"alt 贴地表"为臆测,已更正。 + +**⚠️ §2 表中 `y` 跨数据集不一致是数据层面问题,原版同样存在**:原版也按 `data.y` 摆放,对 `y≠真实高程` 的数据集(香港 `T120526003-3` `y`=13~26 而 `elevation`=41~51)一样会整体偏离地表。客户端与原版行为一致,非客户端 bug。若要纠正,属数据/业务侧(统一 `y` 的高程基准),非渲染侧凭 alt 推算。 diff --git a/docs/questions/2026-06-17-3D地球改造-现状与约束评估.md b/docs/questions/2026-06-17-3D地球改造-现状与约束评估.md new file mode 100644 index 0000000..11263ce --- /dev/null +++ b/docs/questions/2026-06-17-3D地球改造-现状与约束评估.md @@ -0,0 +1,89 @@ +# 3D 地球改造:当前实现方式、目标、问题/约束评估 + +- 日期:2026-06-17 +- 目的:评估桌面客户端 VTK 三维视图从「平面局部坐标场景」改造为「3D 地球(对齐原版 web)」的可行性、影响范围与约束,供决策是否立项。 +- 说明:**纯文字描述,不含代码**。结论待 subagent 基于代码实测评审。 + +--- + +## 1. 原版(web)目标形态(已实地分析) +- 原版 dataView 用 **Three.js 3D 地球**(容器 `threeMap`,单 WebGL canvas;`__THREE__` 在,Cesium 加载但 `cesium-widget` 未用)。 +- 底图瓦片:**Mapbox 卫星** XYZ(`api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.webp`)贴在球面。 +- 反演剖面等数据按**真实经纬**贴在弯曲球面上(竖直帘面),可从太空旋转/缩放飞到地面。 +- 需求表要求底图为「天地图」(与原版实际用 Mapbox 不一致);天地图同样有卫星 WMTS(Web Mercator z/x/y),瓦片源可换、不影响"球 vs 平面"这一架构问题。 + +--- + +## 2. 当前桌面实现方式(文字描述) + +**渲染技术**:桌面用 **VTK**(非 Three.js / 非 Cesium),是与 web 完全不同的另一套实现。 + +**坐标系:局部切平面(不是地球球面)** +- 用 `GeoLocalFrame`:等距圆柱(equirectangular)近似,把经纬度投影成以某原点为中心的**局部米平面**(x=东、y=北,单位米)。这是一个**小范围测区的平面近似**,不是地心 ECEF 球面坐标。 +- 竖直方向 Z = 深度(向下为负),与经纬无关。 +- 原点最近改为"按首个真实剖面 lat/lon 中心就地重锚",使局部坐标从 0 附近起。 + +**各组件都建立在这个局部平面坐标系上**: +- **帘面(剖面)**:每列经纬经 `GeoLocalFrame` 投到局部米平面,Z 取深度;整体是局部平面里的一面"墙"。 +- **切片交互**:在体素/剖面上沿轴向/任意角度切片,基于局部直角坐标的平面/重采样。 +- **坐标轴**:取场景局部包围盒造立方体坐标轴,刻度可反算回经纬度显示。 +- **相机预设**:前/后/左/右/上/下 6 向 + Zoom/Fit,都假设局部直角坐标、Z 向上。 +- **拾取/选中**:在局部坐标场景里按包围盒/距离判定。 +- **底图(我刚做的)**:天地图瓦片平铺成**一块平面地面**(z=0),在局部坐标系里,不是球。 + +**结论**:当前是"**以测区为中心的局部平面 3D 场景**",适合近距离审视单个剖面/切片;不是地球。spec 当初有意如此选择(VTK ≠ web 球,且三栏/切片在局部直角系才好实现)。 + +--- + +## 3. 目标:3D 地球(对齐原版) +- 整个地球为球面(地心 ECEF / 椭球坐标),可从太空旋转、缩放飞到地面。 +- 卫星瓦片(天地图卫星)贴满球面,随缩放 LOD 加细。 +- 数据(帘面/切片/异常)按真实经纬贴在弯曲球面对应位置。 +- 相机为地球导航(轨道环绕 + 飞向目标),而非 6 向局部预设。 + +--- + +## 4. 从"局部平面"到"3D 地球"的问题 / 约束(核心) + +1. **坐标系根本改变**:局部切平面米 → 地心 ECEF(经纬高→三维笛卡尔球面坐标)。这不是"加个底图",而是**整个 3D 视图的坐标基准更换**。 + +2. **所有依赖局部坐标的组件都要重做**: + - 帘面定位(局部米 → 球面 ECEF 的竖直面); + - 切片几何与重采样(轴向/任意切片在球面坐标下的平面定义变复杂); + - 立方体坐标轴(球面上"立方体轴"语义不再适用,需换成经纬网/比例尺); + - 6 向相机预设(前/后/左/右/上/下在球面无固定意义,需改地球导航); + - 拾取/选中(球面坐标下判定); + - 纵向比例(垂向夸张)在球面上的语义与实现。 + +3. **VTK 无现成"瓦片地球"**:Cesium/Three.js 有内建的 LOD 瓦片地球;VTK 没有。要自建:球面几何 + 多级瓦片调度 + 投影贴图 + (大气/光照),工作量大。 + +4. **数值精度**:ECEF 坐标量级约 6.4×10⁶ 米,GPU 渲染管线的浮点矩阵在该量级有抖动(jitter)(数据数组本身已用双精度)。标准规避法是"相机相对原点偏移"——**而当前的 `GeoLocalFrame.reanchor`(一切相对数据中心原点渲染)正是这种规避**;即局部平面方案天然避开了球面会引入的精度问题(佐证务实方案)。 + +4b. **【评审补充,最硬的拦路虎】体素/切片基于 `vtkImageData`(轴对齐规则栅格)**:三维体是 `vtkImageData`(`SetOrigin`/`SetSpacing` 轴对齐),切片工具对它重采样。`vtkImageData` 本质是轴对齐规则网格,**无法弯曲贴到球面**。这是真 3D 地球最根本的不兼容点,比"切片重采样变复杂"更强。 + +4c. **【评审补充】地形 actor(TerrainActor)也全程局部系**:DEM 顶点经 `frame.toLocal` 摆放 + 独立 EPSG 重投影/墨卡托纹理坐标,球面化都要重做。 + +4d. **【评审补充】纵向夸张(VE)散布三处**:帘面 `SetScale(1,1,ve)`、体素烤进 origin/spacing、地形 zScale。球面上"缩放 Z"无单一含义(Z 是径向、方向逐点变),三处都要重定义。 + +4e. **【评审补充】2D 俯视测线模式(Map2D)**:`applyTop2D + addSurveyLine` 也建在 z=0 局部平面,球面基准下要么单独保留平面投影、要么重新推导。 + +5. **小尺度上收益有限**:数据都在几百米的小测区;该尺度下地球曲率不可见——"飞到地面的地球"与"局部卫星平面"看到的卫星图基本一致。3D 地球真正多出的是"从太空看全球/转地球"的整体观感与"和原版一致"。 + +6. **与既有功能的冲突**:本轮已完成的三栏/帘面/切片/坐标轴/相机预设全部基于局部平面;改球面等于把这些重写或重新适配,回归风险高、且 GUI 不可由我自测。 + +--- + +## 5. 影响范围 / 工作量(定性) +- **底图本身**(瓦片源换天地图):小。 +- **平面底图 → 局部卫星平面**:小(现成 TileBasemap 换源)。 +- **平面场景 → 3D 地球**:**大重构**,触及坐标系 + 帘面/切片/轴/相机/拾取/底图全链,属重新立项分期,不宜并入当前轮次。 + +--- + +## 6. 决策建议 +- 3D 地球是**根本性重构**(非底图增量);本地剖面尺度(几百米)下与"局部卫星平面"视觉收益差异有限;务实方案是"局部天地图卫星平面",3D 地球如确需"地球观感"则单独立项分期。 + +## 7. 评审结论(opus 子代理 · 基于代码实测) +- §2 当前实现的 7 项描述(局部等距圆柱坐标、帘面/切片/坐标轴/相机/拾取/底图均依赖局部平面)**逐条经代码核实,全部准确**(与源码逐字吻合)。 +- §4 "球=根本性重构"的判断**成立且偏保守**——文档**低估**了耦合面:另需重做 **地形 actor、体素 `vtkImageData` 轴对齐(最硬拦路虎,规则栅格无法贴球)、三处纵向夸张、2D 俯视模式**(已补入 §4b–4e)。无任何"夸大耦合"之处。 +- 总体结论 **SOUND**:3D 地球触及整条 3D 管线根基,非底图增量;局部卫星平面是务实折中;站点尺度曲率视觉可忽略。其中**体素/切片基于 vtkImageData 无法弯曲贴球**是最强佐证。 diff --git a/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md b/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md new file mode 100644 index 0000000..4aeddbf --- /dev/null +++ b/docs/superpowers/HANDOFF-2026-06-26-vtk-anomaly-2d-analysis.md @@ -0,0 +1,100 @@ +# HANDOFF — VTK 3D 视图:创建异常打磨 + 切片/异常交互 + 二维分析改造(2026-06-26) + +> 分支 `feat/vtk-3d-view`。桌面端 Qt6 + VTK 9.6。本会话围绕「创建异常」全链路打磨、切片/异常交互修复、全局中文化,最后转入「二维分析改造」的需求厘清 + 写 spec。**下一步=按 spec 实现二维分析 A 期。** + +--- + +## 0. 立刻要做的事(下个会话从这里开始) +**二维分析改造 A 期已实现**(未提交,下个会话需用户实跑反馈手感/角度)。spec:`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`(commit `227ee8f`)。分期 A→B→C: +- **A(已实现 ✅,build+439测试全绿,未提交)**:一场景两相机。切「二维分析」tab → 近俯视(下压12°≈78°俯角)+禁旋转(左键改平移、仅平移/缩放);按维度翻 actor `SetVisibility`(轨迹↔体/帘面/异常,**不清空**);切片 `SetEnabled` 显隐(不销毁);地形+底图常驻;切回三维还原相机快照。**待用户实跑**:①近俯视角度是否合适②切换是否瞬时③左键平移手感④切回三维视角还原是否自然。 + - 改动文件:`CameraPreset.{hpp,cpp}`(applyNearTop2D)、`PickInteractorStyle.{hpp,cpp}`(setLock2D)、`SliceTool.{hpp,cpp}`(setVisible)、`InteractionManager.{hpp,cpp}`(setMode2D)、`VtkSceneView.{hpp,cpp}`(setAnalysisMode2D+mapLineDs_+相机快照)、`ColumnDrawer.{hpp,cpp}`(analysisModeChanged 信号)、`main.cpp`(接信号)。 + - 已知小风险:2D 取景 `computeDataBounds` 含隐藏的 3D 体包围盒(地形主导,影响小);切片 `SetEnabled` 显隐属 GUI 不可自测项。 +- **B(已实现 ✅,build+441测试全绿,未提交,待实跑)**:二维里选中足迹(单/Ctrl 多选)→ 竖向拖动只改**高程 Z**、锁 XY、顶部实时高程读数浮层;Z 偏移按 dsId 持久(切走再回/全量重建保留)。手势:单击足迹=选中、Ctrl+单击=多选切换、点空白=取消+平移、(多)选后竖向拖动=整体改 Z。 + - 实现:`VtkSceneView` 加 `pickMapLineAt/nudgeSelectedMapLinesZ/selectedMapLineZ/clearMapLineSelection`(vtkCellPicker+PickFromList 只拾可见足迹、选中高亮黄加粗、`mapLineZOffset_` 持久);`PickInteractorStyle` lock2D 下命中足迹→Z 拖动(`onPick2D/onDrag2D/onDrag2DEnd`+`worldPerPixelZ` 像素→世界Z)、否则平移;`InteractionManager::pickStyle()` 暴露样式;`main.cpp` 接回调 + 高程读数浮层(复用提示样式)。 + - **待用户实跑**:①拾取灵敏度(tol 0.012)②拖动 Z 灵敏度/方向(上移=抬高)③多选拖动④读数是否合理(现为 actor 包围盒中心世界 Z,含 placement+偏移,未除 VE)。 +- **C(下一步)**:dd_raster 纳入 2D 过滤 + 按 ddCode 分派渲染 + 栅格地理配准贴地形。**阻塞:dd_raster 数据端点未确认**(需后端给「像素 + 四至/投影」端点)。 + +--- + +## 1. 环境 / 命令 / 铁律(必读) +- **构建**:用 PowerShell 工具跑 `cmd /c "D:\Git\lanbingtech\geopro\build.bat app"`(Claude 的 Bash 跑 build 会被环境劫持,用 PowerShell)。测试 `build.bat test`(ctest,**439 用例**)。`vswhere.exe not recognized` 噪声无害。LNK1104=exe 被运行中的 app 锁,需用户关 app 才能链接。 +- **回复用中文**(用户要求)。 +- **CLAUDE.md 两条绑定规则**:①发现技术债当场修,不以"非本轮引入"搪塞;②**能自己做的绝不让用户做**——日志(`%LOCALAPPDATA%/Geomative/Geopro3/logs/geopro_*.log`)/数据/构建/诊断都自己来,只在 LNK1104 关 app、或真正产品决策才找用户。 +- **git**:精确 `git add `(仓库有并行 GPR 会话的脏文件 `.superpowers/sdd/*`、`docs/.../poc-lod-shots/*.png`,**勿误提交**)。提交无 Co-Authored-By(全局禁)。 +- **后端 token(可访问真实接口,用户给的)**:`geomativeauthorization: Geomative e6c1259748644c8da0954d864bb82604`;base URL `http://tenant.geomative.cn/pop-api`;样例 projectId `1439735554211840`。可用 curl 查接口。 + +--- + +## 2. 本会话已完成(提交清单,新→旧) +**创建异常全链路(核心工作量)** +- `227ee8f` 二维分析 spec(doc)。 +- `c1a824e` 二维维度分类对齐数据字典 DD0623(去 3 个已删除轨迹类型,dimensionOf/dimOf/测试)。 +- `e8bb2f8`/`1648ccb`/`f230ca8` 异常绘制提示:vtkTextActor 渲染不出中文 → 改 **app 层 QLabel 浮层**(右上角、深底方角、不挡鼠标);列表切到别对象清切片选中(`deselectSlice`)。 +- `9782a2b` 删除切片/异常加确认框 + **弹框按钮全局中文化**(Qt zh_CN 翻译器 + `formkit::addDialogButtons` 默认「确定/取消」+ 打包补 qtbase_zh_CN.qm)。 +- `306d7bc` 提示移右上角 + **线双击结束含双击位置**(去回滚)。 +- `d7ab770` **切片保存后定稿锁定**(`SetInteraction(0)` 不可移动/旋转)+ VTK/列表菜单去「保存·另存」。 +- `91a7106` **结束手势**:点=单击即完成、线=双击、面=**点回起点闭合**(近起点 12px 吸附+橡皮筋指向起点)。 +- `1a70ca0` 异常对话框加**样式预览**(选中类型 legend 可视化:点球/线/面)。 +- `4ae8286` **异常截图配色与切面一致**:取 widget 自身 `GetColorMap()` 输出(非另建 LUT)→ 逐像素一致 + RGBA 外区透明消蓝边。 +- `d470dc8` 双击/单击隔离(后被 `306d7bc` 改回"含双击位置")+ 异常类型下拉误显「暂无数据」(`EmptyAwareComboBox::realItemCount` 用错 flags 角色→改 `model()->flags()`)。 +- `04af569` 返工(点交互/点渲染小球/截图/类型空态)。 +- `75c1327`→`3ed1ea7`→`58544ff`→`c6756aa` 截图相机方案A(已废)、**异常类型接平台真实类型**(`listExceptionTypes` 按形态)、**点/线/面三态**子菜单、**样式接平台 legend**(`getExceptionTypeDetail`)。 +- 截图最终方案:**只从切片 2D 剖面图、按异常几何 buffer 裁剪**(`captureAnomalyShotFromSlice`,GIS buffer+掩膜,点圆/线胶囊/面外扩多边形)。 + +**其它** +- `56e4b3a` 登录验证码容器白底。 +- `8563693` 分段折叠向上收起(stretch 动态:展开=1/折叠=0+尾弹簧)。 +- `d6e52cb` 三维分析分段面板视觉打磨(chevron 段头/描边新增按钮/顶部留白)。 +- `fb911a9` 坐标轴面板硬编码色 token 化。 +- `cdd7613` vtk-3d-openapi 文档对齐实测(DsPage 行在 `data.list`、DsRow 全字段、装置不在 data/page)。 +- `2f6ec7d`→`1742b75`→`31ad7a4` **装置筛选**:data/page 行不带 properties→改按行自带 `typeName/dsTypeCode` 筛选;parseDsRows 兼容对象形态。 +- 期间生成了一次安装包:`installer/dist/Geopro_Setup_3.0.0-20260625.exe`(脚本 `installer/build_installer.ps1`,含 windeployqt+样本+PROJ+vc_redist)。 + +--- + +## 3. 创建异常功能现状(已落地,端到端可用,mock 保存) +入口:VTK 切片右键 →「创建异常 → 点/线/面」。流程:圈定(AnomalyDrawTool)→草稿渲染→**从切片2D图buffer裁剪截图**→对话框(名称/异常类型[接平台]/样式预览/备注/截图)→保存(mock)→刷新树+渲染异常 actor。 +- **关键文件**:`src/render/interact/AnomalyDrawTool.{hpp,cpp}`(三态绘制:点单击完成/线双击/面点起点闭合,Esc取消/Backspace撤点);`src/app/AnomalySaveDialog.{hpp,cpp}`(接 `cmdRepo.listExceptionTypes`/`getExceptionTypeDetail`,样式预览);`src/app/SliceExport.{hpp,cpp}`(`captureAnomalyShotFromSlice`);`src/render/actors/AnomalyActor.cpp`(点=球/线=折线/面=闭合多边形);`src/app/main.cpp`(onSliceContextMenuRequested ~509 起:菜单+绘制+对话框+保存接线,QLabel 提示浮层)。 +- **样式来源**:选中平台异常类型 → `getExceptionTypeDetail` 取 legend(polylineColor/Width/Shape, pointColor…)→ 套到异常 lineColor/lineWidth/dashed。 +- **截图**:只裁切片那张 2D 剖面图(`InteractionManager::selectedSliceColorImage` 现取 `SliceTool::coloredResliceImage()`=widget ColorMap 输出,与屏幕同源)。 + +### 仍卡在后端(P3,非客户端问题) +- **异常真保存 `newException`** 卡:异常 `remarkSourceId` 须指向真实 dsObjectId(三维体/切片),但真后端**无任何登记三维体/切片为 dsObject 的端点**(实测 `voxel/generate`、`slice/generate`、通用 `dsObject create` 全无)。当前 `saveAnomaly` 是内存 mock。后端补登记端点后整链可接真。 +- 平台「点」类异常类型该项目为空(线/面有)→ 画点时类型下拉空属正常。 + +--- + +## 4. 二维分析改造——已确认的设计决策(与用户逐条敲定) +1. **不分两个场景**:一个 3D 地形场景(带高程)+ 底图贴地形,两栏只是相机不同。"二维分析只是 3D 的固定视角"。 +2. **二维相机**:锁定**近俯视(75–80°,非绝对正俯视)**,禁旋转,仅平移+缩放。(绝对正俯视看不出高程→留倾斜) +3. **切 tab 显隐**:翻另一方数据集 `SetVisibility`(**不显示,非清空**)→ 性能零代价(VTK 跳过不可见 actor)、切换瞬时、只占内存。地形+底图常驻。**绝不清空**(重体素重建会卡)。 +4. **高程拖动(C1)**:二维里选中 2D 内容(单/多选)→ 竖向拖动只改**高程 Z**、锁 XY、实时读数。用途=分离叠在一起的 2D 层。 +5. **维度过滤**:2D = `dd_trajectory_data`(+ C 期 `dd_raster`);`dd_radar_2d/3d` 是 3D(不进 2D)。已对齐 DD0623(`c1a824e`)。 +6. **雷达客户反馈**:只影响数据模型 + **3D 视图**渲染(二维雷达=线、三维雷达=切面、带打标)+ 详情页校准——**均属另立任务,不在本次 2D 改造**。2D 只显示轨迹线、打标暂不做(与本设计一致)。 + +--- + +## 5. 关键代码锚点(二维分析改造用) +- 切 2D 视图模式钩子:`Column2DDataset::view2DModeChanged` → `main.cpp` 接 `sceneCtrl`(搜 `view2DModeChanged`)。 +- 维度分类:`src/app/DatasetDimension.cpp::dimOf`(col2D 用);`src/data/api/Api3dRepository.cpp` + `src/data/repo/LocalSample3dRepository.cpp::dimensionOf`。 +- 2D 内容注入:`main.cpp` ~502 `col2D->setDatasets(splitByDimension(...).dim2D)`;勾选渲染 `Column2DDataset::checkedDatasetsChanged` → `loadMapLine`/`MapLineActor`。 +- 场景/相机/可见:`VtkSceneView`(actor 管理)、`VtkSceneController`、`InteractionManager`(拾取/选中,新增 `deselectSlice`)。 +- 地形+底图:`buildTerrain`(带高程,VE 垂直夸张相关)、`TileBasemap`(天地图按经纬贴)。 +- 数据字典:`D:\Projects\GEOPRO\DD0623.xlsx`(46 个 ddCode,列:序号/ddCode/领域/含义/状态/**展示模式(2D/3D)**/**展示形态**/备注…)。解析:openpyxl 可用,中文 GBK 终端会乱码→导 UTF-8 文件再 Read。 + +--- + +## 6. 待确认/风险 +- **dd_raster 数据端点**(像素+四至/投影)未确认 → C 期阻塞。 +- 近俯视角度需实机调;高程是否落库暂定会话内(不落库)。 +- §3 翻可见标志需可靠区分每个 actor 的维度归属。 +- 切相机/可见与现有 view2DModeChanged、底图、地形 VE 逻辑勿打架。 +- **GUI 无法登录自测**:相机/截图/交互类改动我只能保证编译+逻辑,视觉/手感需用户实跑反馈。 + +--- + +## 7. 相关记忆/文档 +- 记忆库 `MEMORY.md`(如 login-chain-truth、vtk-3d-persistence-structure、build-vs2026-vcpkg-gotchas、deploy-hardcoded-dev-paths 等)。 +- vtk-3d API 设计草案:`docs/api/vtk-3d-openapi.json`(已对齐实测,0.6.1)。 +- 真后端 API:`docs/apis/business_OpenAPI.json`。 +- 二维分析 spec:`docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md`。 diff --git a/docs/superpowers/HANDOFF-vtk-3d-backend-api.md b/docs/superpowers/HANDOFF-vtk-3d-backend-api.md new file mode 100644 index 0000000..8c29621 --- /dev/null +++ b/docs/superpowers/HANDOFF-vtk-3d-backend-api.md @@ -0,0 +1,84 @@ +# 交接:VTK 三维视图 后端 API 设计(三维体 / 切片 / 异常) + +> 给下一个会话无缝接手用。更新日期 2026-06-24。分支 `feat/vtk-3d-view`。本会话**纯设计 + 产出对接文档,未改业务代码**。配套实现交接见 [`HANDOFF-vtk-3d.md`](./HANDOFF-vtk-3d.md)。 +> +> **进度速览**:澄清了"三维体/切片/异常存为 ds、能否复用存量 ds 接口"的全部疑问,产出后端对接 Swagger `docs/api/vtk-3d-openapi.json`(OpenAPI 3.0.3,15 路径/26 schema),已提交 `9d3b103`(未推送)。记忆 [[vtk-3d-persistence-structure]] 已同步定论。**无半成品**。下一会话方向:① 把这份契约交付后端排期;② 后端就绪后做客户端切真实(接口不动,换 `Api3dRepository` 实现)。 + +## 1. 背景 +- 代码库 `D:\Git\lanbingtech\geopro` = 原版 web 系统移植的 Qt 桌面客户端;原版 web 源码 `D:\Git\lanbingtech\commercial-admin` 是复刻权威。 +- 三维体/切片/异常的渲染/交互**已实现**(见另一份 handoff),但持久化全是 **mock**(`I3dSceneRepository` 留缝、`Api3dRepository` 内存 map)。 +- 本会话起点问题:三维体/切片要存为 ds、异常要挂三维体,**现有 ds 接口能否复用?** 由此推导出后端要补哪些端点,并产出 Swagger。 + +## 2. 本会话已完成 +- **摸清存量两套仓储 + 真实端点**(见 §6 代码地图)。 +- **澄清一系列关键设计点**(§3)。 +- **产出 `docs/api/vtk-3d-openapi.json`**:三件套后端对接设计稿,JSON 校验通过、无悬空/未用 schema。 +- **提交** `9d3b103`(仅该文件,未推送;按全局设置未加署名)。曾有中间产物 `docs/api/voxel-slice-openapi.json`,迭代后已删并合入 vtk-3d-openapi.json。 +- **更新记忆** [[vtk-3d-persistence-structure]],加全部定论 + 文档路径 + commit 号。 + +## 3. 设计定论(多轮被用户纠正后收敛,**这是核心交付**) + +1. **三维体/切片对后端 = 纯元数据 dsObject** + - 增删改查/属性**复用存量 dsObject 面**:`projectStruct`树 / `dsObject/data/page`列表 / `getDetail`详情 / `dynamicForm`属性 / `updateDsObject`更新 / `dsObject/{id}`删除。 + - 各只加 **1 个登记端点**(`/dsObject/voxel/generate`、`/dsObject/slice/generate`)——存量"建 ds = 文件 import"对生成类不适用;登记只建记录、**不触发后端计算**。 + +2. **体素字节/切面数据全在客户端,后端零数据端点** + - 连 GPR 大体(13.6G)体素也**不上传**、即使落盘也在客户端本地(`ChunkedVolumeStore`);后端只持有 dsObject 记录。 + - 故**无** meta/brick/上传/切片位姿端点(设计途中曾加过,全删)。params/voxel 是否本地落盘是纯客户端策略,后端不感知。 + +3. **小块结构化数据搭车 `attachedParameters`** + - 三维体构建参数(`voxelParams`)、切片三点位姿(`slicePose`)存这里,读走 `getDetail`、写走 `updateDsObject`(与描述 `deltaContent` 同机制)。`dynamicForm` 只放给人看的键值属性。 + +4. **归属结构:TM → 三维体(dd_voxel) → 切片(dd_slice)**,异常挂三维体 + - **三维体挂在 TM 下**(本会话纠正,之前记忆误写"对象/GS")。`structParentConfType=2`(TM)。 + +5. **异常复用整套 `/business/exception` 端点**(实体无关,三维体 id 直接塞 `remarkSourceId`) + - **异常体(consortium)分组是存量已有**(`parseExceptions` 已解析 `consortiumId/Name/Type`,`queryExceptionByTmObjectId` 按其分组)——**非 3D 新增**(此前误判已纠正)。 + - 3D 仅扩展两处、都在 body/返回字段(**不新增端点**):① `location` 加 `worldPts`(三维多边形点)+`plane`(所在平面),存量是 2D `coordinate[{x,y}]`+经纬/投影坐标;② 截图(R88)。 + +## 4. 剩余工作(按依赖顺序) + +**后端**(阻塞一切,把 `vtk-3d-openapi.json` 交付排期): +1. 注册 `dd_voxel`/`dd_slice` 两个 dd 类型 + 各自 `classifyType` code。 +2. 实现两个登记端点(`voxel/generate`、`slice/generate`),返回真 `dsObjectId`。 +3. 为这两类型注册 `dynamicForm`(属性表单)。 +4. 扩展异常 `location` schema 容纳 3D `worldPts`+`plane` 并保证往返不丢。 +5. 加异常 `screenshot` 字段 + 定传输方式。 + +**客户端**(后端就绪后;接口 `I3dSceneRepository` 不动,留缝就是为此): +6. `Api3dRepository` 内存 map 换成调真实端点。 +7. `main.cpp:450-451` 手动 append 三维体/切片行改成走后端树。 +8. 3D 异常路径从 `I3dSceneRepository`(mock) 切到 `IDatasetCommandRepository`(真),`remarkSourceId`=三维体真 id。 + +## 5. 待确认问题 +- `classifyTypeList` 里 dd_voxel/dd_slice 的具体 code(后端定)。 +- `location` 3D 字段精确结构(后端定)。 +- 截图传输:base64 内联 vs 单独上传(后端定)。 +- **未验证风险**:原系统 `remarkSourceId` 只挂过 2D dsObject,没挂过三维体 id;`listExceptionTypes`/`getExceptionName` 挂三维体时是否如预期,需后端整链就绪后实测。 + +## 6. 代码地图(本会话查证过的关键文件) +| 文件 | 作用 | +|---|---| +| `src/data/repo/I3dSceneRepository.hpp` | mock 留缝接口(三维体/切片/异常/任务 CRUD)| +| `src/data/api/Api3dRepository.{hpp,cpp}` | mock 实现(`volumes_`/`slices_`/`anomalies_` 内存 map)| +| `src/data/repo/LocalSample3dRepository.cpp` | 本地样本 stub | +| `src/data/repo/IDatasetCommandRepository.hpp` | **真实**异常端点 + ds 详情写操作 | +| `src/data/api/ApiProjectRepository.cpp` | **真实** dsObject 面(getDetail/dynamicForm/data/page/delete/update/import/projectStruct)| +| `src/core/model/Anomaly.hpp` | 异常模型(2D: localPts/lonLat/eastNorth;3D: worldPts/plane)| +| `src/data/repo/VolumeBuildParams.hpp` | 三维体构建参数 | +| `src/data/store/ChunkedVolumeStore.hpp` | GPR 分块体存储(客户端本地落盘)| +| `src/app/main.cpp` | 接线(450-451 合并注入、577/661/745 createSlice/createVolume)| +| `src/data/dto/NavDto.cpp:374` | `parseExceptions`(含 consortium 字段,证实异常体存量已有)| +| `src/data/dto/DatasetChartDto.cpp` | 异常 location/坐标系解析 | +| `docs/api/vtk-3d-openapi.json` | **本会话产出** | + +**后端响应信封**:`{code:int, msg:string, data:object|array}`,`code==200` 成功,列表/集合放 `data.value`。 + +## 7. 铁律 / 注意 +- **贴源码/日志,别凭印象**:本会话多次因臆测("装不下"、"异常体是 3D 新增"、自造端点不贴存量)被用户纠正——任何接口字段/语义都先去代码里核实。 +- **复用优先于新建**:实体无关的契约一律复用存量;只有"生成动作"和"3D 几何/截图"这种存量真装不下的才扩展。 +- 全部回复中文。 + +## 8. 相关 +- 记忆:[[vtk-3d-persistence-structure]](已含本会话定论)、[[gpr-volume-design-trio]]、[[dataset-list-is-tree]]、[[vtk96-hdfwriter-no-imagedata]]。 +- 实现交接:[`HANDOFF-vtk-3d.md`](./HANDOFF-vtk-3d.md)。 diff --git a/docs/superpowers/HANDOFF-vtk-3d.md b/docs/superpowers/HANDOFF-vtk-3d.md new file mode 100644 index 0000000..3719cc0 --- /dev/null +++ b/docs/superpowers/HANDOFF-vtk-3d.md @@ -0,0 +1,115 @@ +# 交接:VTK 三维视图(feat/vtk-3d-view) + +> 给下一个会话无缝接手用。更新日期 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`。 +- 任务:实现需求表「补充需求」页 = VTK 三维视图整套结构/交互。需求源:`D:\Projects\GEOPRO\Geopro3.0 需求表.xlsx`「补充需求」页。 +- 原版 web 源码在 **`D:\Git\lanbingtech\commercial-admin`**(Vue + three-tile),是**复刻的权威参照**(threeMap.vue / mapSource.js / src/apis/)。 +- 三栏结构(三维数据集 / 二维数据集 / 三维分析)+ 真实 ERT 反演剖面(帘面)+ 底图地形——这些**已完成**。本会话主要做了**底图/地形 + 剖面垂直配准 + 增量渲染**,并为下一阶段(三维体/切片/异常)做了**设计定稿**。 + +## 2. 本会话已完成(均已编译绿 + 提交;用户验收"差不多了") +**底图 + 真实地形**(核心在 `src/app/TileBasemap.{hpp,cpp}`): +- 影像=**天地图卫星**(`img_w`,tk 内嵌);地形=**Mapbox terrain-RGB DEM**(原版同源,pk token 内嵌;`elev=-10000+(R*65536+G*256+B)*0.1`)。 +- **四叉树多级 LOD**(按瓦片屏幕像素误差递归细分,近细远粗)。根 `kRootZoom=9`、阈值 `kTargetPx=384`、叶上限 `kMaxLeaves=200`。 +- **视锥剔除只用 4 个侧面**(不用近/远裁剪面——远裁剪面随已加载几何变化会误剔除远块)。`frustum_[24]` 用焦点自校正法向。 +- **并发限流** `kMaxConcurrent=12`(请求队列 `netQueue_`/`pumpNetQueue`)。 +- **缓存**:影像纹理 `texCache_` + DEM `demCache_`,跨隐藏/重选保留 → 重选秒出。 +- **合并渲染** `requestRender()`(Qt 队列合并同轮多次渲染请求为一帧;并在渲染前 `ResetCameraClippingRange`)。 +- **动态范围**:底图最大距离 = 剖面合并范围半径×10,夹 [2km,30km],随勾选增删自动伸缩(`dataRadiusProvider_` 查 `VtkSceneView::dataHorizontalRadius()`)。**超过范围的粗瓦也强制细分**(否则一块巨瓦盖住中心、绕过距离剔除)。 +- **地形半透明 0.55**(`kTerrainOpacity`)——地下剖面可从任意角度透过地面看到(地球物理标准做法;解决"前后左右预设看不到剖面=不透明地形遮挡地下")。 +- **近裁剪容差 1e-5**(`SetNearClippingPlaneTolerance`,VtkSceneView 构造)——远处底图把近裁剪面顶出去会切掉近处剖面,调小修复。 +- **就近优先加载**(离相机近的瓦片先拉)。 +- 瓦片纹理 mipmap + 各向异性 16x + edgeClamp。 + +**剖面垂直配准 + VE**: +- 剖面 Z = **`+g.y`(真实高程)**——与原版一致(实证 `threeMap.vue:676`:剖面世界竖向 = data.y)。地形也用真实高程 → 同系对齐、剖面顶≈地表露出。(早期错把 y 当深度 `-y`,已改。) +- 垂直夸张默认 **1.0**,收敛为**单一来源** `kVerticalExaggeration`(main.cpp),下发控制器/底图/UI。剖面与地形用同一 VE。 + +**增量渲染**(`VtkSceneController` + `VtkSceneView`): +- 勾选/取消 = **按 dsId 增删图元**,不再整场 clear + 全量重建;`clear()` 保留底图;增量不重置相机(视角不跳);首批数据/全量重建才 `fitView`。 +- `computeDataBounds()` 只算数据图元(不含底图)→ 坐标轴/取景/预设(前后左右)不被公里级底图撑大/推远。 +- `onCameraChanged` 回调:相机程序化变化(取景/预设/缩放)后通知底图按新视锥重算覆盖(治"首帧部分瓦片要微动才出")。 +- 默认底图=天地图;首个剖面重锚 frame 后经 `onFrameReanchored` 在数据位置加载底图。 +- VTK 全屏含左侧三栏(drawer 在 vtkDock 内 + 进入全屏展开)。 + +## 3. 当前状态(2026-06-18 更新) +- 底图/地形/剖面配准/增量渲染:**完成且可用**。编译绿。所有改动已提交到 `feat/vtk-3d-view`。 +- **#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 + 代码现状 + 实现拆解 + 持久化策略)。已与用户拍板的关键决策: +- **创建三维体 = 客户端**:「三维数据集」栏多选剖面 → 选插值模型/参数 → `core::IdwInterpolator` 生成体素。 +- **异常 = 接真实后端端点**(已从 web 源码挖到,见设计文档 §3):`POST/PUT/DELETE /business/exception` + `exceptionType/*` + `exceptionConsortium/*` + 读 `queryException*`/`queryExceptionTree`。`remarkSourceId/Type` 填切片数据集。 +- **三维体网格 / 切片持久化 / 三维分析任务 = 后端无端点 → 先本地 mock**(保持 `I3dSceneRepository` 接口不变,端点就绪只换实现)。 +- **持久化策略**(设计文档 §7):三维体保存时 **参数(源数据+插值模型/参数+色阶) + 网格规格 GridSpec 必存,明细 values 可选**;加载时有明细直接渲染、无明细按参数重算填入固定 GridSpec(锚定切片/异常坐标)。带切片/异常或大/慢的体建议存明细。 +- 数据模型层级:**三维体(源数据+插值参数) → 切片(属于体,保存后成 dd_slice) → 异常(画在切片平面,圈定保存)**,三者皆可持久化。 + +**实现拆解(设计文档 §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. **✅ 完成(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` **已接 P1 色阶编辑器**(详见下表)。已移除"显示/隐藏"(勾选即显隐)。 +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`。**已知限制**:切片采样分辨率/值域需渲染层回写仓储才有,当前不展示。 | +| **真实色阶编辑(P1–P4 ✅ 全期完成,2D/3D 共享)** | 功能 | 否 | 编辑器**与数据集视图(2D)共用**,复刻原版四件套(`colorLevel/contourLevel/contourLine/colorEditor.vue` + `colorUtils.js`)。**P1** 主表 `ColorScaleConfigDialog`(层级/颜色 + 新增/删除 + 双击改值/改色)。**P2 层级⚙** `ContourLevelDialog`(normal/log/equalArea + 间隔↔层数双向 + 校验) + 纯算法 `ContourLevels`(6 单测) + `interpColor`(mapColors)。**P2 线形⚙** `ContourLineDialog`(线型/线显/线色/标注显/标注色) + `ContourLineConfig`。**P3 颜色⚙** `ColorGradientDialog` + 自绘 `GradientEditWidget`(可拖拽连续渐变) + 预设/反向/整体透明度 → 按层级位置采样回填。**P4** 文件 IO `ColorScaleIO.{hpp,cpp}`(纯函数 `.lvl`/`.clr` 解析生成,4 单测,与原系统互通):主表 导入/导出=`.lvl`,颜色⚙ 导入/导出=`.clr`。**接入**:① 3D 右键「色阶」→ `VtkSceneController::setVolumeColorScale` 重建体素+切片(用 colorScale 含透明度,mock 持久 `volumeScaleCache_`);② 2D `GridDataChartView`「色阶配置」→ 同编辑器 → 色阶 + 线形/标注到 `ContourPlotItem`。设计见 `specs/2026-06-19-vtk-3d-color-scale-editor-design.md`。**已知边界**:模板库(后端)以文件导入导出替代;`RawDataChartView`「色阶配置」仍占位(原始散点无等值线网格);帘面(源剖面)独立色阶不联动。 | +| **收口提 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。 +- `docs/questions/2026-06-17-3D地球改造-现状与约束评估.md` ← 已决:维持局部平面方案,不做真 3D 地球(opus 评审:球面=根本性重构)。 +- 旧 spec/plans(`2026-06-15-vtk-3d-*`、`three-column-refactor*`)为历史,留档。 + +## 6. 关键铁律 / 坑(务必遵守) +- **构建**:`build.bat rebuild` 会**自动启动 app**(后台命令不返回);编译验证用 **`build.bat app`**。Claude 无法 GUI 验证 VTK 渲染。 +- **不能靠猜**:渲染/裁剪类问题用**日志取证**(`%LOCALAPPDATA%\Geomative\Geopro3\logs\geopro_YYYYMMDD.log`,含 `[basemap]`/`[view]` 的 qInfo/qWarning),或读真实数据/原版源码。本会话多次因臆测被用户纠正——**贴源码/日志,别凭印象**。 +- **勿动未跟踪文件**(见顶部);**勿用 `git add -A`**(会误纳)。逐个文件 `git add`。 +- **API 凭证不得提交**(探测脚本用完即删)。 +- 原版 web 源码 `D:\Git\lanbingtech\commercial-admin` 是复刻权威;需求 xlsx 用 `python openpyxl` 读到 UTF-8 临时文件再看(控制台中文乱码)。 +- 内嵌 token:天地图 tk + Mapbox pk(公开客户端 token,同原版,可提交)。 +- 全部回复中文。 + +## 7. 代码地图(关键文件) +- `src/app/TileBasemap.{hpp,cpp}` — 底图+地形(四叉树/剔除/限流/缓存/动态范围/半透明/合并渲染)。 +- `src/app/VtkSceneView.{hpp,cpp}` — I3dSceneView 实现:clear/addCurtain(dsId)/addVolume(dsId)/removeDataset/render/renderIncremental/computeDataBounds/dataHorizontalRadius;近裁剪容差;onCameraChanged/onFrameReanchored 回调。 +- `src/controller/VtkSceneController.{hpp,cpp}` — 增量渲染编排(setCheckedDatasets diff、addDatasetAsync、fitOnArrival)。 +- `src/render/actors/CurtainActor.cpp` — 帘面(Z=+g.y 真实高程)。 +- `src/render/interact/{SliceTool,InteractionManager,SlicePlaneMath}.*` — 切片交互。 +- `src/render/{VoxelFromScatters,actors/VoxelActor}.*` + `src/core/algo/IdwInterpolator.*` — 体素插值/绘制(三维体复用)。 +- `src/data/repo/I3dSceneRepository.hpp` — 接口(loadVolume/createSlice/saveSlice/deleteSlice/loadAnomalyTree/saveAnomaly/.../loadTaskRecords)。 +- `src/data/repo/LocalSample3dRepository.cpp`(内存 mock 参考实现)、`src/data/api/Api3dRepository.cpp`(真实路径,多为 stub `kNotReady`,待按设计文档实现)。 +- `src/app/panels/columns/{Column3DDataset,Column2DDataset,Column3DAnalysis,ColumnDrawer}.*` — 三栏 UI(信号定义全、main.cpp 未全接)。 +- `src/app/main.cpp` — 装配/接线(搜 `basemap`/`colAnalysis`/`onCameraChanged`/`kVerticalExaggeration`)。 +- `src/data/dto/DatasetChartDto.cpp` — `parseInversionGrid`(x/y/v/lat/lon;**未解析 elevation/alt**)。 diff --git a/docs/superpowers/HANDOFF-vtk-category-view-refactor.md b/docs/superpowers/HANDOFF-vtk-category-view-refactor.md new file mode 100644 index 0000000..09c4631 --- /dev/null +++ b/docs/superpowers/HANDOFF-vtk-category-view-refactor.md @@ -0,0 +1,207 @@ +# 交接:VTK 三维分析视图重构(按数据类型分组 + 对象树联动) + +> 给下一个会话无缝接手。日期 2026-06-24。分支 `feat/vtk-3d-view`。 +> 本会话**产出 spec + 实施 plan + openapi 修订,未改业务代码**——下一步是按 plan 执行实现。 +> +> **速览**:把 VTK 左侧三 tab 重构为「按数据类型大类分组」两 tab;经真实接口实测定分类/字段、opus 子代理评审、客户两轮交互确认后定稿。产出 spec(`specs/2026-06-24-vtk-category-view-refactor-design.md`)+ 实施 plan(`plans/2026-06-24-vtk-category-view-refactor.md`,8 phase/12 task)+ openapi v0.6(`docs/api/vtk-3d-openapi.json`)。 + +--- + +## ⏩ 实施进度(2026-06-24 续会话) + +**Task 1-10 完成 + Task 12 大部分 + Task 11 Step 1-4a 完成。剩 4 项(见末尾「剩余真实状态」)。** + +### 续会话第二批提交(在 `d539fc1` 之后) +``` +6edfad9 feat(app): VtkViewToolbar 接入中央画布(view/zoom/fit+axesSettings弹窗) # Task12 工具条✓ +1d744ba feat(app): 对象树拉取改 checkedSourcesChanged+confType 分流(GS直挂ds) # Task12 #1✓ +2f07e60 feat(app): 三维体段「体→切片/异常」三级树注入+异常排除渲染勾选+即时进树 # Task11 Step4a✓ +9899d5f→修正 feat(app): 创建异常按切片是否已保存挂体/切片(resolveAnomalyMount) # Task11 Step3✓ +07be3ae feat(data): Api3dRepository.anomalyRows 按 remarkSourceId 供三级树注入 # Task11 Step2✓ +52830bb feat(core): Anomaly volumeDsId→remarkSourceId + resolveAnomalyMount # Task11 Step1✓ +6e3c810 docs: 异常归属设计修订(取消独立异常区,挂体/切片+三级树) # spec/plan +901c84e feat(app): 对象树→splitByCategory→5段数据流+勾选分流渲染+生成入口 # Task12 阶段A✓ +``` +⚠️ 教训:`git diff --cached` 必须当 STOP 闸门——9899d5f 曾误带并行 GPR 会话已暂存的新文件,已 reset 拆分修正。 + +### 🚧 剩余真实状态(续会话第三批更新) + +**已再完成**:#2 装置枚举接口(`listArrayTypes` GET `/business/script/arrayTypeList`→`DatasetFieldDictionary.arrayTypeEnum`→段头装置下拉显示+过滤,commit b9a6551)、#4 VolumeParamsDialog 扩展(左侧源列表可增删+生成位置下拉 GS/TM→`req.structParentId/confType`,a41b428)、Task11 异常双击详情(`anomalyById`→AnomalyPropertiesDialog,cfd242c)。 + +**仅剩 #6 退役旧栏**——⚠️ **不是纯删,直接删会丢功能**,须先补迁: +1. **垂直夸张(VE)滑块**:现挂旧 c3(main :1032/1036 + Column3DDataset VE 滑块);退役后无 UI 入口,需迁 `VtkViewToolbar` 或段头 + 接 `sceneCtrl->setVerticalExaggeration`。 +2. **切片勾选同步**:main :624/663 `colAnalysis()->setItemChecked`(切片保存自动勾选 / 关闭取消勾选);需给 `CategoryAnalysisTab` 加 `setItemChecked(dsId,bool)`(按 dsId 在 voxel 段树定位项设勾选),main 改调它。 +3. `refreshAnomalies` 去 ca 依赖(:420-446 用 `ca->anomalyFilterMode()/setAnomalies()`)→ 改全渲染(异常显隐档位是边缘功能,简化为全显示)。 +4. 删 main 中 c3/ca 共 ~35 处接线(:463/666-700/794-796/800-981/1032-1036)+ ColumnDrawer `col3D_/colAnalysis_` 成员/访问器/实例 + `Column3D*` include + 旧 `createVolume(VolumeBuildParams,name)` 重载(无调用者后)。 +5. `splitByDimension` 保留(refreshAnalysis 仍用其 dim2D 喂 col2D)。 + +**#6 不影响当前功能**(旧栏已 hide、app 完整可用),是独立清理工程,无法 headless 验证——建议新会话清爽做。 + +#### (历史)剩余真实状态(4 项,均无法 headless 验证) + +1. **Task11 Step4b — 异常/切片树内交互**:三级树「展示」已通(Step4a);缺 dd_anomaly 双击详情(main detailRequested 加 dd_anomaly 分支→需 Api3d 加 `anomalyById`→AnomalyPropertiesDialog)、右键删除(CategorySection 加右键菜单+信号→main deleteAnomaly/deleteSlice)、切片保存位姿、per-anomaly 显隐。现 refreshAnomalies 仍用隐藏 colAnalysis 的 filterMode(异常默认全渲染,可接受)。 +2. **Task12 #2 dict 填充 — 真实阻塞**:装置类型筛选需 `DatasetFieldDictionary` 填充,但 `parseFieldMapping` 需**原始 dynamicForm JSON**(含 confFieldId/optionsObject),而现有 `loadDatasetFormAsync` 返回的是解析后 `DynamicForm`(仅 name/value,丢了 optionsObject)。**需新增"拉原始 dynamicForm JSON"异步接口**;且装置 value→中文字典源 spec §11 本就标注待坐实。当前 dict=nullptr,装置筛选退化不筛、日期筛选回退 createTime(数据照常出)。 +3. **Task12 #4 — VolumeParamsDialog 扩展**:左侧勾选源 ds 树(按 GS 分组·可二次增删) + 右侧「生成位置」下拉(项目内 GS/TM,默认单GS→该GS/跨GS→项目根)→填 req.structParentId/structParentConfType。现用基础 VolumeParamsDialog(归属走默认)。 +4. **Task12 #6 — 退役旧栏**:删 main 中 c3/ca 旧接线(:653区 c3 view/zoom/gen、:730区 ca 接线)、col3D()/colAnalysis() 调用、Column3D* 实例;删 ColumnDrawer col3D_/colAnalysis_ 成员+访问器;refreshAnomalies 去 ca 依赖(改全渲染)。**须先做 Step4b 把 ca 的切片删除/保存/异常详情功能迁到新树**,否则删 ca=丢功能。splitByDimension 保留(dim2D 用)。 + +> 已就绪未删:旧 `Column3DDataset/Column3DAnalysis` 仍实例化但隐藏,`checkedTmsChanged` 信号与新 `checkedSourcesChanged` 并存(主接线已切新信号,旧信号仅剩 :1369 nav 接线在用)。 + +### (历史)Task 1-10 完成记录 +**已提交、可编译、逻辑层单测全绿(425 测试,5 个失败均为 PROJ_DATA 环境性、非回归)。** + +提交链(在 `a7d558b` docs 之后): +``` +a06d9e8 feat(data): createVolume(VoxelGenerateRequest) 重载+fromRequest 派生+请求体打印(mock) # Task10 +3af7e44 feat(ui): VtkViewToolbar 画布工具条 + AxesSettingsDialog 坐标轴设置 # Task9 +98114a3 feat(ui): CategoryAnalysisTab(QScrollArea 5段)+ColumnDrawer 两tab(旧栏隐藏过渡) # Task8 +30e990d feat(ui): CategorySection 类型段组件 + DatasetFieldDictionary 缓存类 # Task7 +40646f7 refactor(tree): 评审修复-抽 recomputeAllGsStates 去 nullptr 信号 hack # Task4-6 review 修复 +c5b3907 feat(data): DatasetFieldDictionary 解析 arrayType/collectTime 映射+装置字典 # Task6 +1978a31 feat(tree): GS 三态状态机(停 AutoTristate)+右键 ds/tm + checkedSourcesChanged # Task4+5(合并) +6b39901 test(data): 补 properties[1] 日期项断言 +f00a214 feat(data): VoxelGenerateRequest/SliceGenerateRequest DTO + toJson # Task3 +07cf75d feat(app): CategoryConfig 映射表 + splitByCategory # Task2 +5a719ca feat(data): DsRow 加 dsTypeCode/properties + parseDsRows 解析 # Task1 +``` + +**已就绪的新构件(均编译通过)**: +- 逻辑层:`DsRow` 扩字段、`splitByCategory`(CategoryConfig 5 段)、`Vtk3dRequests`(Voxel/Slice DTO + toJson + `fromRequest`)、`DatasetFieldDictionary`(parseFieldMapping + 缓存类)、`ObjectTreeSelection`(aggregateGsState/dedupeSources)。 +- 对象树:`ObjectTreePanel` GS 三态状态机(停 AutoTristate)+ 右键「选择▸ds/tm」+ `checkedSourcesChanged(QList)` 信号(**与旧 `checkedTmsChanged` 并存**,Task 12 删旧)。 +- UI 组件:`CategorySection`(段头装置/日期筛选+段体可勾选树+「+新增三维体」+双击详情)、`CategoryAnalysisTab`(QScrollArea 5 段,setBuckets/section/勾选并集)、`ColumnDrawer` 已改两 tab(三维分析=analysisTab / 二维分析=col2D;**旧 col3D_/colAnalysis_ 仍实例化但 hide()、不入 tab**,保留访问器供 main 过渡)、`VtkViewToolbar`、`AxesSettingsDialog`。 +- data:`Api3dRepository::createVolume(VoxelGenerateRequest)` 重载(组装真实请求体+打印+`lastVoxelRequest`),旧 `createVolume(VolumeBuildParams,name)` 保留。 + +**⚠️ 当前过渡态**:app 可编译运行,但「三维分析」tab 是**空的 CategoryAnalysisTab**(数据接线在 Task 12);旧三维数据集/三维分析功能已隐藏。对象树 GS 三态+右键 ds/tm **已在现 app 生效**(旧 checkedTmsChanged 仍兼容),可立即真实验证(plan Task 4 Step 7 清单)。 + +**偏离 plan 的决策(已记录理由)**:① Task 4+5 合并一个 commit(plan Task4 右键已引用 Task5 的 emitCheckedSources,循环依赖);② DatasetFieldDictionary 异步拉取下放 main(data 层无网络,类只内存缓存);③ CategorySection 段体先平铺(populateDatasetList),「项目根/GS/TM 容器节点分层」推迟 Task 12 接真实 StructNode;④ createSlice 虚接口未改签名,SliceGenerateRequest 组装并入 Task 12 main 层。 + +### 🔧 Task 11+12 待做(main.cpp 接线总成,最高风险,须真实 app 验证) + +`main.cpp` 1943 行,接线密集。关键现状符号位置: +- `:361` `new ColumnDrawer(centerWidget)` —— 需改传 dict(构造已支持第2参 `DatasetFieldDictionary* dict=nullptr`)。 +- `:397 refreshAnomalies` / `:448 refreshAnalysis` 闭包用 `drawer->colAnalysis()`。 +- `:442 c3 = drawer->col3D()`;`:641` c3 checkedDatasetsChanged→sceneCtrl;`:653` c3 generateVolumeRequested→createVolume;`:898/902` c3 verticalExaggeration。 +- `:579/745` createSlice 调用(补 projectId + 组装 SliceGenerateRequest)。 +- `:584/623` `colAnalysis()->setItemChecked`;`:666 ca=colAnalysis()`。 +- `:1117/1267` 对象树 `checkedTmsChanged` 接线(→改 `checkedSourcesChanged` + confType 分流 `loadRowsAsync(projId, src.id, src.confType, 3, ...)`)。 +- `:1134 splitByDimension`→`splitByCategory` + `analysisTab()->setBuckets`;`:1135 col3D/col2D setDatasets`。 +- `:1215 clearCentral`。`:1328 drawer->expand()`。 + +**Task 11 缺口**:`CategoryAnalysisTab`/`CategorySection` 尚无 colAnalysis 的 `setItemChecked`、异常子区 API;三维体段需迁入 Column3DAnalysis 异常控件(参 `src/app/panels/columns/Column3DAnalysis.{hpp,cpp}` + main `:397 refreshAnomalies`)。 + +**Task 12 要点**(plan §Task12 Step1-8 已详列):① 对象树勾选→confType 分流拉取→splitByCategory→analysisTab setBuckets;② analysisTab checkedDatasetsChanged→并入 checkedProfiles/checkedAnalysis→pushChecked;③ generateVolumeRequested→`VolumeParamsDialog` 扩展(左侧勾选源树·可二次增删 + 右侧「生成位置」下拉=项目内 GS/TM)→组装 `VoxelGenerateRequest`→`createVolume(req)`;④ 工具条 `VtkViewToolbar` 叠加中央 QVTK + AxesSettingsDialog 接坐标轴;⑤ createSlice 补 `nav.currentProjectId()`;⑥ 删旧 checkedTmsChanged/col3D()/colAnalysis()/splitByDimension/Column3D* 引用 + setStructure 传对象树同源 StructNode(容器分层)。 + +#### ✅ Task 12 阶段 A 已完成(commit 901c84e)—— 核心数据流接通 +- `refreshAnalysis` 重构为统一入口:`lastSourceRows + volumeRows + sliceRows` → `splitByCategory` → `analysisTab->setBuckets`(5 段出数据)+ `splitByDimension(...).dim2D` → `col2D`。 +- 对象树 `checkedTmsChanged` 接线:finish 改 `*lastSourceRows=*acc; refreshAnalysis()`(仍用 checkedTmsChanged,**confType 分流见剩余①**)。 +- `analysisTab` 三接线:checkedDatasetsChanged→按 isSlice/isVolume 分流(切片→checkedSliceIds+syncSlices / 体素→checkedAnalysis / 反演剖面→checkedProfiles)→pushChecked;generateVolumeRequested→VolumeParamsDialog→组装 `VoxelGenerateRequest`→`createVolume(req)`;detailRequested→Slice/Volume 属性对话框。 +- `clearCentral` 改走 lastSourceRows/refreshAnalysis。 +- **现可验**:勾对象树 TM → 电阻率/视/瞬变段出数据 → 勾选段内 ds → 帘面渲染;生成三维体 → voxel 段出现 → 勾选渲体;切片段同理。 + +#### 🔧 Task 12 剩余精修(6 项,新会话做,每步 build + 真实验证) +1. **confType 分流**:对象树接线从 `checkedTmsChanged(QStringList tmIds)` 换 `checkedSourcesChanged(QList)`,`loadRowsAsync(projId, src.id, src.confType, 3,1,100000)` 第3参传 src.confType(支持 GS 直挂 ds,现仅 TM)。改 main `:~1171` 对象树接线 + `:~1330` 第二个 checkedTmsChanged→nav 接线。 +2. **dict 填充**:main 创建 `DatasetFieldDictionary` 并传 `new ColumnDrawer(centerWidget, &dict)`;对每个反演 dsType 调 `loadDatasetFormAsync`→`parseFieldMapping`→`dict.setFields`(装置/日期筛选才生效,现 dict=nullptr 退化不筛)。 +3. **工具条接入**:实例化 `VtkViewToolbar` 叠加中央 QVTK,信号接 sceneCtrl(viewRequested/zoom/fit 接现有 c3 对应槽 :647 区;axesSettingsRequested→弹 `AxesSettingsDialog`→应用坐标轴);VE 控件迁工具条。 +4. **VolumeParamsDialog 扩展**:左侧勾选源 ds 树(按 GS 分组·可二次增删) + 右侧「生成位置」下拉(项目内 GS/TM,默认 源单GS→该GS/跨GS→项目根)→ 填 `req.structParentId/structParentConfType`。 +5. **三维体段三级树 + 异常按归属挂体/切片(Task 11 本体,已重新设计,见 spec §8 / plan Task 11)**:**取消独立异常区**——异常作叶子挂归属实体(体/切片)下。归属链 `异常→所在切片→切片所属体`,挂载目标由「切片是否已保存成 dd_slice」决定(已存挂切片、临时挂体)。改 `Anomaly`(volumeDsId→remarkSourceId+remarkSourceType)、Api3d 异常 mock 按归属存查、main 创建逻辑判断切片是否保存、三维体段「体→切片/异常」三级树。多体渲染本就支持(`dsProps_` 按 dsId),`volumeOwnerDs_`=当前切片源体。 +6. **退役旧栏**:删 main 中 c3/ca 旧接线(:641/669-760 区)、col3D()/colAnalysis() 调用、`Column3D*` 实例;删 ColumnDrawer 的 col3D_/colAnalysis_ 成员+访问器;评估删 splitByDimension(dim2D 改轻量过滤)。 +- 切片保存/关闭(:584/623 setItemChecked)、createSlice projectId(:579/745) 随 #5/#1 一并处理。 + +> 下方为初版交接(spec/plan 设计定论,仍有效)。 + +## 1. 背景 + +- 代码库 = 原版 web 系统(`D:\Git\lanbingtech\commercial-admin`,复刻权威)移植的 Qt6 桌面客户端。 +- 现状 VTK 视图左侧 `ColumnDrawer` 是三 tab(三维数据集/二维数据集/三维分析),`splitByDimension` 按 `ddCode` 分 3 维度。 +- 需求(用户截图设计):把「三维数据集」并入「三维分析」,改成**按数据类型大类分组**(电阻率/视电阻率/瞬变电磁/三维体/切片),每类各自筛选+操作;对象树勾选联动、支持 GS/项目直挂数据;全局视图控制移 VTK 画布工具条。 + +## 2. 本会话已完成 + +1. **摸透现状源码**(ColumnDrawer/Column3D*/DatasetDimension/DatasetListPanel/ObjectTreePanel/RepoTypes/NavDto/Api3dRepository/main.cpp 接线等)。 +2. **真实接口实测定关键事实**(用用户给的 token 直连后端,见 §6)——纠正了多个二手猜测。 +3. **brainstorming 澄清全部设计决策**(容器布局、分类策略、对象树三态交互、装置类型、生成入口等)。 +4. **产出 spec** 并经 **opus 子代理评审 + 据评审修订**(GS 三态停 AutoTristate、properties 两接口形态澄清、帘面勾选链承接等)。 +5. **产出实施 plan**(8 phase/12 task,TDD bite-sized,逻辑层完整测试代码、UI 层 build+手动验证)。 +6. **客户两轮交互确认**收敛生成三维体交互(见 §3 第 5 条),同步更新 spec/openapi(v0.6)/plan。 +7. **openapi 修订**:三维体归属层级 TM → GS/项目根 → 再放开 GS/项目根/TM。 + +## 3. 最终设计定论(核心,已写入 spec) + +1. **两 tab**:「三维分析」(5 段)+「二维分析」(现 `Column2DDataset` 不动)。 +2. **大类分类按 `dsTypeCode`**(不是 ddCode)——电阻率/视电阻率/瞬变电磁三者 ddCode 同为 `dd_inversion_data`,只 dsTypeCode 不同。映射表 `CategoryConfig` 驱动;三维体/切片按 ddCode(`dd_voxel`/`dd_slice`)识别。 +3. **对象树联动**: + - 非根 GS = **三态复选框**(聚合「GS 自身 ds 开关」+「子 TM 勾选」)+ 右键「选择▸ds/tm」;**必须停用 `Qt::ItemIsAutoTristate`、手动维护三态**(UserRole 存 ds 开关)。 + - 项目根 = 无复选框、直挂 ds 固定显示、其下 TM 各自二态勾选。 + - 信号 `checkedTmsChanged` → `checkedSourcesChanged(QList)`,按 `structParentConfType`(1=GS/项目, 2=TM) 分流 `loadRowsAsync`。 +4. **装置类型/采集时间筛选**:是 ds 结构化属性(`arrayType`/`collectTime`),值在 ds 行 `properties[]`(confFieldId),经 `DatasetFieldDictionary`(按 dsType 拉一次 `dynamicForm` 取 confFieldId↔fieldCode 映射 + 装置 value→中文字典)解析。装置类型只电阻率/视电阻率段有。日期筛选按 collectTime(三维体/切片回退 createTime)。 +5. **新增三维体(客户两轮确认定稿)**: + - **入口**:数据类型**段头**「+新增三维体」按钮(电阻率/视电阻率/瞬变段),**不在**对象树/容器节点右键。 + - **源数据集** = 三维分析中**当前勾选的同类型 ds**(本段类型、可跨 GS)。 + - 点击 → `VolumeParamsDialog`:**左侧**树状(按 GS 分组)展示勾选源、**可勾选/取消供确认或二次修改**;**右侧**参数:名称、**生成位置**下拉、插值模型、水平/竖向间距、IDW 幂次、最大影响距离。 + - **生成位置(归属)**:默认 = 源同属单 GS→该 GS、源跨 GS→项目根;用户可改为**项目内任意 GS/TM**(`structParentConfType` 1 或 2)。源与归属解耦。 +6. **VTK 画布工具条**:全局视图控制(坐标轴/比例/快捷视图/缩放)移到中央 VTK 竖排工具条;设置→`AxesSettingsDialog`(X/Y/深度 显示+min/max);快捷视图按钮文字「前/后/上/下/左/右」;复位=现「适配」。 +7. **请求体 DTO**:`createVolume/createSlice` 扩参,内部按 `VoxelGenerateRequest/SliceGenerateRequest` 组装真实请求体结构(仍 mock 存储、假 id),`toJson` 序列化;重构落地即可从 UI 产出给后端联调的真实请求体。 +8. **三维体/切片/异常**仍 `Api3dRepository` mock;异常迁入三维体段。 + +## 4. 剩余工作 = 执行 plan + +`plans/2026-06-24-vtk-category-view-refactor.md`,8 phase/12 task,依赖顺序: +- **Phase 1-2**(Task 1-3):DsRow 扩展+parseDsRows / CategoryConfig+splitByCategory / 请求体 DTO —— **纯逻辑完整 TDD**。 +- **Phase 3-4**(Task 4-6):GS 三态状态机+checkedSourcesChanged / DatasetFieldDictionary —— 逻辑可单测。 +- **Phase 5-6**(Task 7-9):CategorySection / CategoryAnalysisTab+ColumnDrawer / VtkViewToolbar+AxesSettingsDialog —— build+手动验证。 +- **Phase 7-8**(Task 10-12):Api3d 扩参组装请求体 / 段重组(异常迁三维体段) / main.cpp 接线总成 + 退役 Column3DDataset/Column3DAnalysis。 + +**建议**:subagent-driven(每 task 新 subagent + task 间两阶段 review)+ 独立 git worktree 隔离。每 task 自带测试/验证+commit;Phase 间「新旧信号并存→Task 12 统一切换删旧」保证每次可编译。 + +**plan 中 Task 7 段头按钮 + Task 12 `VolumeParamsDialog` 扩展(左侧源列表二次增删 + 生成位置下拉)已按 §3.5 定稿写好。** + +## 5. 待确认 / 风险 + +- **装置类型 value→中文 字典源**(唯一待坐实,不阻塞实现):实测 `arrayType` 原始值(如 `1429468249448449`)**不在**该字段 `optionsObject`(另一套 id `1456095451258368…`)里;`parseDynamicForm:208` 也不翻译;但用户说客户端属性面板显中文。Task 6 先用 optionsObject 建表 + 缺失回退原值。**需用户提供客户端数据集属性面板截图**以定位现有翻译路径(候选:`fieldConfigJsonObject.fieldDataRadius` 指向的全局字典 / `script/arrayTypeList`)。 +- **段体容器树建法**(Task 7 核心):段体要呈现「项目根/GS/TM 容器节点→ds」层级,需 main 传 `setStructure`(对象树同源 StructNode)+ ds 的 `structParentId/structParentConfType`(Task 1 已解析)建容器节点。 +- 三维体/切片/异常仍 mock;后端就绪后切真实(`I3dSceneRepository` 留缝,见 `HANDOFF-vtk-3d-backend-api.md`)。 + +## 6. 关键实测事实 + 接口(直连后端核实) + +- 后端基址 `http://tenant.geomative.cn/pop-api`;header `geomativeauthorization: Geomative `(token 本会话由用户提供,下会话需重新索取)。 +- 样本项目「演示项目(高密度+瞬变)」`1438889436225536`:项目根 GS `1438889436291072` 下直挂 TM(ERT1-8 confCode=ERT、TEM1-15 confCode=TEM01),**无中间 GS 层**(印证「项目根本质是 GS」「TM 可直挂项目根」)。 +- **大类 dsTypeCode**:电阻率=`ERT platform inversion data`、视电阻率=`visual resistivity data`、瞬变电磁反演剖面=`DD TRANSIENT ELECTROMAGNETIC INVERSION`(三者 ddCode 同 `dd_inversion_data`)。 +- **ds 行 `properties`**(`dsObject/data/page`)= `[{confFieldId,value}]` 数组;**`dynamicForm` 的 `properties`** = `{fieldCode:value}` map(两接口形态不同,勿混)。 +- 装置类型字段 `arrayType`、采集时间 `collectTime`(fieldCode);装置枚举 `GET /business/script/arrayTypeList` → `[{itemValue,name}]`(15 项)。 +- 层级 `structParentConfType`:1=GS/项目根,2=TM;`loadRowsAsync` 第 3 参即透传它(现状 main.cpp:1142 写死 2)。 +- 详情接口 `dsObject/getDetail/{id}`、动态表单 `dsObject/dynamicForm/{id}`(客户端 `loadDatasetFormAsync`→`parseDynamicForm`)。 + +## 7. 代码地图 + +**现状关键文件(重构基础):** +| 文件 | 作用 | +|---|---| +| `src/app/panels/columns/ColumnDrawer.{hpp,cpp}` | 现三 tab 容器(→两 tab) | +| `src/app/panels/columns/Column3DDataset/Column3DAnalysis/Column2DDataset` | 三栏现状(前两退役、二维不动) | +| `src/app/DatasetDimension.{hpp,cpp}` | splitByDimension(→splitByCategory 替代) | +| `src/app/panels/DatasetListPanel.cpp` | populateDatasetList/卡片委托/applyDatasetFilter(复用) | +| `src/app/panels/ObjectTreePanel.{hpp,cpp}` | 对象树(GS:123 AutoTristate、:174 itemChanged、:207 右键) | +| `src/data/repo/RepoTypes.hpp` | DsRow(扩 dsTypeCode/properties/structParent*) | +| `src/data/dto/NavDto.cpp` | parseDsRows:116 / parseDynamicForm:179 | +| `src/data/api/Api3dRepository.{hpp,cpp}` | 三维体/切片/异常 mock(createVolume/createSlice 扩参) | +| `src/data/repo/VolumeBuildParams.hpp` / `src/core/model/Anomaly.hpp` | 三维体参数 / 异常模型 | +| `src/app/main.cpp` | 接线(:388-460 异常/勾选、:1113-1225 对象树→分类分发) | +| `src/data/api/ApiProjectRepository.cpp` | loadRowsAsync parentConfType 透传 | + +**新建(plan §文件结构):** `CategoryConfig.hpp`、`DatasetCategory.{hpp,cpp}`、`Vtk3dRequests.{hpp,cpp}`、`DatasetFieldDictionary.{hpp,cpp}`、`CategorySection.{hpp,cpp}`、`CategoryAnalysisTab.{hpp,cpp}`、`VtkViewToolbar.{hpp,cpp}`、`AxesSettingsDialog.{hpp,cpp}` + 对应 tests。 + +**契约文档:** `docs/api/vtk-3d-openapi.json`(v0.6-draft)、真实 schema `docs/apis/business_OpenAPI.json`(DsObjectDataVO/DsTypeVO/ArrayTypeVO)。 + +## 8. 铁律 / 注意 + +- **贴源码/实测,别凭印象**:本会话多次因二手猜测("装置类型要后端补字段"、"properties 形态矛盾"、归属层级)被用户/实测纠正——任何字段/语义先去代码或真实接口核实。 +- **复用优先**:DatasetListPanel/Api3dRepository/Column2DDataset 复用,不重写。 +- **每 task TDD + 可编译 + commit**;不留 TODO 占位。 +- 全部回复中文。 + +## 9. 相关 + +- spec:`docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md` +- plan:`docs/superpowers/plans/2026-06-24-vtk-category-view-refactor.md` +- 后端契约交接:`docs/superpowers/HANDOFF-vtk-3d-backend-api.md`(三维体/切片/异常端点) +- 记忆:`vtk-3d-persistence-structure`、`dataset-detail-types-catalog`、`web-3d-view-threetile`、`gpr-volume-design-trio` +- 提交:`eceb964`(spec+openapi v0.5)、`ef10c35`(plan + 客户变动 + openapi v0.6) diff --git a/docs/superpowers/mockups/2026-06-16-three-column-layout.html b/docs/superpowers/mockups/2026-06-16-three-column-layout.html new file mode 100644 index 0000000..a8dd0a2 --- /dev/null +++ b/docs/superpowers/mockups/2026-06-16-three-column-layout.html @@ -0,0 +1,495 @@ + + + + + +三栏结构重构 · 高保真原型对比 + + + + +
+ 三栏结构重构 · 布局方案 +
+ + + +
+ 配色取自 Theme.cpp 深色令牌 · 右键「三维分析」树里的三维体试试创建切片 +
+ + +
+
+ +
地大演示项目 · 工作区
+
+
+
GZ
+
+ +
+ +
+ + +
+
+
+
VTK视图 · 地大演示项目
+
+
+
+
+
+
+
+
+
+
+
+
数据详情
+
选中数据集查看详情(源数据 / 切片 / 异常 / 插值模型 / 色阶 / 测量)
+
+
+ + +
+
+
异常 / 对象属性
+
+
    +
  • 异常体 A 随GS
  • +
  • 分组-1
  • +
  • 异常 #1
  • +
  • 异常 #2
  • +
+
+
+
+
数据集属性
+
名称 / 类型 / 维度 / 创建时间…
+
+
+
+
+ + +
+
切片
+
色阶
+
显示 / 隐藏
+
数据详情
+
+
+
上下
+
前后
+
左右
+
任意
+
+ +
+
保存
+
保存为
+
导出
+
删除
+
+
色阶
+
显示 / 隐藏
+
数据详情
+
+ + + + diff --git a/docs/superpowers/plans/2026-06-15-vtk-3d-p1-revive-rendering.md b/docs/superpowers/plans/2026-06-15-vtk-3d-p1-revive-rendering.md new file mode 100644 index 0000000..5124c19 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-vtk-3d-p1-revive-rendering.md @@ -0,0 +1,73 @@ +# P1:复活中央 VTK 渲染(地基) + +- 日期:2026-06-15 +- 分支:`feat/vtk-3d-view` +- 上游 spec:`specs/2026-06-15-vtk-3d-supplementary-design.md`(§8 编排层、§5.2 Scene 缺口、§6 接口) +- 目标:把当前**空壳**中央 VTK 复活为数据驱动——勾选对象 → 经仓储取数据 → 调现有 actor → 渲染。复用 render 层零改 actor(除 Scene 加 `vtkProp` 入口)。这是后续所有 3D 功能的地基,**最低风险、最快见效**。 +- 不在本期:三栏 UI、坐标轴、切片交互、异常体、任务面板(P2+)。本期只让"勾选→出图"重新跑通,并立起 `I3dSceneRepository` + `VtkSceneController` 两个骨架。 + +--- + +## 成功判据(goal-driven) + +1. 启动 → 勾选样本对象 → 中央 3D 视图出现帘面(`buildCurtain`),2D 模式出现俯视测线(`buildSurveyLine`)。 +2. 「视图详情」浮层勾选 体素/地形 → 对应 actor 出现/消失(不再是死代码)。 +3. `VtkSceneController` 的编排逻辑有单测(fake repo,断言 actor 集合),不依赖 GUI。 +4. `main.cpp` 中死掉的 `rebuildCentral` lambda + 裸 `show*` 标志被 `VtkSceneController` 取代;`slicePlane` 等未用声明清理(仅清本期引入/相关的孤儿,不动无关代码)。 +5. 构建通过 + 现有测试全绿 + 新测试通过。 + +--- + +## 阶段(每步含 verify) + +### Step 0 — 基线 +- 跑现有测试与构建,记录绿基线。→ verify:`ctest` 全绿、`cmake --build` 通过。 + +### Step 1 — Scene 支持体绘制 vtkVolume(评审 HIGH,TDD) +- `src/render/Scene.hpp/.cpp`:新增 `void addViewProp(vtkProp* p)`(内部 `renderer_->AddViewProp`);`addActor` 保留(可改为转调 addViewProp)。 +- 先写测试 `tests/render/test_scene.cpp`:加入一个 `vtkVolume`(或 mock prop)后 `renderer()->GetViewProps()` 计数 +1;`clear()` 后归零。 +- → verify:RED→GREEN;体绘制 prop 能进场。 + +### Step 2 — I3dSceneRepository + LocalSample3dRepository(TDD) +- `src/data/repo/I3dSceneRepository.hpp`:**异步**接口(回调/Qt 信号范式,见 spec §6)。本期最小集: + - `DsDimension dimensionOf(const DsRow&)`(同步纯函数,§6.1 映射表)。 + - `loadVolume(dsId, onOk(VolumeGrid), onErr)`(§6.2)。 + - `loadTerrainPaths(onOk(demPath,imagePath), onErr)`(包 `LocalSampleRepository::demPath/imagePath`)。 + - 切片/异常/任务等签名**本期只声明占位**(留 P3/P4),不实现。 +- `src/data/repo/LocalSample3dRepository.*`:组合现有 `LocalSampleRepository`(`loadVoxelScatters`→`VoxelFromScatters` 出 `VolumeGrid`;DEM/影像路径直透)。同步数据包成异步壳。 +- 测试 `tests/data/test_3d_repo.cpp`:`dimensionOf` 各 ddCode 映射正确;`loadVolume` 回调收到 `vol.nx()>0 && vmax>vmin`。 +- → verify:RED→GREEN。 + +### Step 3 — VtkSceneController(TDD 编排逻辑) +- `src/controller/VtkSceneController.hpp/.cpp`(QObject): + - 输入(槽/setter):`setCheckedDatasets(QStringList)`、`setViewMode(Map2D/View3D)`、`setLayer(Curtain/Voxel/Terrain, bool)`、`setVerticalExaggeration(double)`。 + - 内部:按维度/图层决定调哪些 actor builder;缓存 `VolumeGrid`/`Grid` 避免重复取数。 + - 输出:`scene.clear()` → `addActor/addViewProp` → `renderWindow->Render()`。把现有 `rebuildCentralScene` 的帘面/测线逻辑并入,新增 voxel(`buildVoxelFromScatters`→`addViewProp`)、terrain(`buildTerrain`) 分支。 +- 测试 `tests/controller/test_vtk_scene_controller.cpp`:注入 **fake repo + fake scene**(记录 add 的 prop 类型/数量),断言: + - 2D 模式 + 勾选 1 ds → 1 个测线 actor; + - 3D 模式 + showCurtain → 1 帘面 actor;+ showVoxel → 多 1 个 volume prop;+ showTerrain → 多 1 个 terrain actor; + - 取消勾选 → clear 后无 prop。 +- → verify:RED→GREEN,编排逻辑脱离 GUI 可测。 + +### Step 4 — 接入 main.cpp(集成) +- 用 `VtkSceneController` 实例取代 `rebuildCentral` lambda(`main.cpp:508`)与裸 `show*` 标志(246–251)。 +- 接线:`ObjectTreePanel` 勾选变化 → `setCheckedDatasets`;`act2D/act3D` → `setViewMode`;`chkCurtain/chkVoxel/chkTerrain` → `setLayer`;主题切换 → 触发重渲染(控制器内重跑)。 +- 清理本期产生的孤儿:未用的 `slicePlane` 声明、被取代的 lambda/标志(只清相关项,遵守 surgical changes)。`chkSlice` 暂保留禁用(P3 接)。 +- → verify:构建通过;启动手测达成「成功判据 1、2」。 + +### Step 5 — 构建 + 运行验证 +- `cmake --build` + `ctest`。 +- 运行客户端,勾选样本对象,截图确认帘面/体素/地形渲染。 +- → verify:成功判据 1–5 全达成。 + +### Step 6 — 代码审查 + 提交 +- `cpp-reviewer` 审查(内存安全/RAII/分层);按需修。 +- 提交:`feat(vtk): P1 复活中央渲染 — VtkSceneController + I3dSceneRepository(LocalSample) + Scene 加 vtkProp 入口`。 + +--- + +## 风险 / 注意 +- **Ninja 增量不可靠**(记忆 [[build-ninja-stale-shared-header]]):改 `Field/RepoTypes` 等共享头后,若崩溃先 clean 重编、验 obj 新鲜度。 +- 体绘制 `vtkVolume` 经 `addViewProp` 进场;`clear()` 须用 `RemoveAllViewProps` 覆盖 volume(确认现有 `clear()` 实现)。 +- LocalSample 只合成单 GS→TM→DS,勾选粒度对齐其结构即可;真实多对象/多剖面在接 Api 实现时验证。 +- 接口务必异步壳,别图省事写同步(评审 HIGH,否则 P3+ 接后端返工)。 diff --git a/docs/superpowers/plans/2026-06-15-vtk-3d-p2-dataset3d-bar.md b/docs/superpowers/plans/2026-06-15-vtk-3d-p2-dataset3d-bar.md new file mode 100644 index 0000000..0151a32 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-vtk-3d-p2-dataset3d-bar.md @@ -0,0 +1,43 @@ +# P2:三维数据集栏(坐标轴 / 比例 / 快捷视图 / Zoom) + +- 日期:2026-06-15 +- 分支:`feat/vtk-3d-view` +- 上游:spec `2026-06-15-vtk-3d-supplementary-design.md`(§4 行 C3–C8、§5.3、§7.2);接 P1(`VtkSceneController`/`I3dSceneView`/`VtkSceneView` 已就位) +- 目标:给三维视图加一组**纯前端、无后端依赖**的相机/坐标轴/比例控件(补充需求"三维数据集栏"工具条的功能项)。P1 已让中央渲染复活,P2 让用户能调坐标轴、纵向比例、快捷视角、缩放。 + +## 范围 +**范围内**: +- **快捷视图**(C7):前/后/左/右/上/下 6 向相机预设 + **Zoom In/Out/Fit**(C8)。 +- **水平/垂直比例**(C6):可调纵向夸张(复用 P1 已有 `VtkSceneController::setVerticalExaggeration`),UI 滑块/输入;范围如 1–10,默认 2。 +- **坐标轴**(C3–I5):显示方式 标准/三维立体/不显示;刻度单位 无/米/英尺/经纬度;O 点位置;字体。 +- 控件挂到三维视图工具条/浮层(**不做**完整三栏 tab 重构——留后续 UI 期;本期控件先以工具条形式接入现有三维视图)。 + +**范围外/后续**:三栏 tab 结构重构、二维数据集栏底图(P5)、三维分析切片(P3)。 + +## 关键设计 +- **CameraPreset 扩展**(`src/render/CameraPreset.*`):`applyView(vtkRenderer*, ViewDir)`,`ViewDir∈{Front,Back,Left,Right,Top,Bottom}`,设 position/focalPoint/viewUp 后 `ResetCamera`;`zoomBy(vtkRenderer*, factor)`(In=1.2/Out=1/1.2),`fit(=ResetCamera)`。世界系:x=East,y=North,z=-depth(与 actor 一致)。 +- **坐标轴**(新 `src/render/actors/AxesActor.*` 或 `CubeAxes.*`): + - 标准 = `vtkCubeAxesActor`(包围盒 + 刻度 + 标签);三维立体 = `vtkCubeAxesActor` 闭合立方/或叠加 `vtkAxesActor` 方向标(**语义待 1.0 确认,先合理近似**);不显示 = 不加。 + - 刻度单位:米(原值)/英尺(×3.28084)/经纬度(用 `GeoLocalFrame` 反算)/无(隐藏刻度标签)。 + - O 点:默认数据包围盒角;字体:`SetTitleTextProperty/SetLabelTextProperty` 设字号字体。 + - 输入 = renderer bounds + frame;产出 `vtkSmartPointer`,由 `I3dSceneView` 加入场景(经 `addViewProp`)。 +- **GeoLocalFrame 补反算**:`LocalXY → (lat,lon)`,加 `toLatLon(double x,double y)`(等距圆柱反算:lon=lon0+x/mPerDegLon,lat=lat0+y/mPerDegLat)。 +- **I3dSceneView 扩接口**:`setAxes(mode,unit,font,...)`、`applyCameraView(ViewDir)`、`zoom(factor)`、`fitView()`;`VtkSceneView` 实现,fake 测试记录。 +- **VtkSceneController**:加 `setAxesMode/setAxesUnit/...`、`applyView/zoomIn/zoomOut/fit` 槽,转发给 view;坐标轴在 rebuild 时按当前模式重建(轴随数据包围盒变)。 + +## 步骤(TDD) +0. 基线:`build.bat test` 全绿。 +1. **GeoLocalFrame.toLatLon**(TDD):往返 `toLocal∘toLatLon≈恒等` 测试。 +2. **CameraPreset 6 向 + zoom**(TDD):各 ViewDir 断言 position/focalPoint/viewUp 方向正确(如 Top: pos.z>focal.z、viewUp=+y;Front: pos.yGetInteractor()`) + `On()`;vtkSmartPointer 持有防析构;场景 clear/切视图时正确 Off()+释放,避免悬挂观察者/崩溃。 +- **纵向夸张一致**:切片附着的 image 已烤入 VE(P1 体素管线),切面几何与体绘制对齐;VE 变化触发体素重建 → 切片须重附着或关闭重建。 +- **2D 模式无切片**:切片仅三维视图;切到二维须 Off 所有切片工具。 +- **自定义 InteractorStyle 与 QVTK 默认**:替换 style 后须保留相机拖动等基本交互(继承 TrackballCamera)。 +- **构建**:增量链接错用 `cmake --build build\release --clean-first`,**勿删 build 目录**([[build-vs2026-vcpkg-gotchas]]);app 在运行致 LNK1104 则以 ctest 为准、提示关 app 重链。 +- **交互件难单测**:VTK widget 需 render window/interactor,CI 无显示环境多半跳过;故纯逻辑(plane/相机数学)尽量抽出单测,widget 行为靠目视。报告标清目视项。 +- 切片"标准/任意"手感、色阶、正视/翻转细节若需对齐 Geopro 1.0,先合理实现,待 1.0 实地学习再精修([[study-original-via-playwright]])。 diff --git a/docs/superpowers/plans/2026-06-16-vtk-3d-p5-2d-dataset-bar.md b/docs/superpowers/plans/2026-06-16-vtk-3d-p5-2d-dataset-bar.md new file mode 100644 index 0000000..2360449 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-vtk-3d-p5-2d-dataset-bar.md @@ -0,0 +1,70 @@ +# P5 二维数据集栏渲染 + 底图 Implementation Plan + +> 接续:三栏结构重构(已完成) + 真实 ERT 帘面(已完成)。本计划做「二维数据集」栏的真实渲染 + 天地图底图。 +> GUI 渲染正确性须用户实测(Claude 不能 GUI 测;见构建铁律)。 + +**Goal:** 「二维数据集」栏勾选 → 在 VTK 视图的 2D 俯视面渲染:轨迹线(dd_trajectory_data)、网格面(dd_grid),并叠加天地图底图(可切换 天地图/Google/隐藏)。 + +**Architecture:** 复用现有 `VtkSceneController`(已有 ViewMode::Map2D + addSurveyLine) + `GeoLocalFrame`(已重锚真实数据) + 天地图 token/WMTS(已存在于 trajectory_map.html)。新增:2D 数据异步加载(轨迹/网格) + 2D actor + 底图瓦片层(VTK)。 + +**已确认事实(真实 API/代码):** +- 天地图 token:`TK=aca91d8c9f59a4f779f39061b8a07737`,WMTS XYZ:`http://t{0-7}.tianditu.gov.cn/{layer}_w/wmts?...&tileMatrixSet=w&TileMatrix={z}&TileRow={y}&TileCol={x}&style=default&format=tiles&tk=TK`(图层 `vec`街道/`img`卫星 + `cva`/`cia`注记,EPSG:3857,原生 z18)。见 `src/app/resources/map/trajectory_map.html`。 +- 轨迹数据:`GET /business/dd/ert/trajectory/line?dsObjectId={id}&frontCrsCode=EPSG:4326` → `data.electrodelList`[],每项 `{electrodeNo, electrodeCoordinate}`(经纬度)。 +- 网格数据:`dd/ert/grid/rows/{id}`(GET) 实测 404 → **本计划 Task 0 先确认正确端点**(疑似 query 参数或 POST;参考 `src/app/panels/chart/GridStrategy.hpp` + `ApiDatasetRepository` 的 `gridRowsBatch`/`makeGridRows`)。 +- `dimensionOf`:`dd_trajectory_data`→Dim2D(已);`dd_grid`→当前 Other(**需加 Dim2D**)。 +- col2D(`Column2DDataset`) 信号 `basemapChanged/view2DModeChanged/customZChanged/checkedDatasetsChanged` 在 main.cpp **当前未接线**(T7 留待本期)。 + +--- + +## Task 0:确认 dd_grid 端点 ✅(已做,结论纠正本计划) +- `dd_grid` = **「白化数据」分页坐标点表**:`GET /business/dd/ert/grid/rows?dsObjectId=&pageNo=&pageSize=` → `data={rowList[{x,y,id}], gridHeaderDisplay[x,y], total}`(见 `ApiDatasetRepository::gridRowsBatch` + `GridStrategy.hpp`)。 +- **结论:dd_grid 是表格数据、不是 2D 地图面**,不作渲染层 → 维持 `Other`(仅在「数据详情」看表)。 +- **2D 地图可渲染类型只剩 `dd_trajectory_data`(轨迹线)+ 底图**。Task 1 取消(不把 dd_grid 归 2D);Task 2/3/4 只做轨迹。 + +## Task 1:(取消)dd_grid 非地图渲染类型,维持 Other。 + +## Task 2:2D 数据异步加载(轨迹 + 网格) +在 `Api3dRepository`(真实) + `LocalSample3dRepository`(样本 stub) 加异步方法(照 `loadSection` 范式): +- `loadTrajectory(dsId, onOk(vector), onErr)`:真实走 `trajectory/line`,解析 `electrodelList[].electrodeCoordinate` → 经纬序列。 +- `load2dGrid(dsId, onOk(SectionData/Grid), onErr)`:按 Task 0 的端点解析网格面。 +- `I3dSceneRepository` 加这两个虚方法(接口扩展,LocalSample stub 返回样本/空)。 +- 验证:编译绿;FakeSceneRepo 加 override。 + +## Task 3:2D actor(render 层) +- 轨迹线:新增 `render/actors/TrajectoryActor`(或复用 `MapLineActor`):经 `GeoLocalFrame.toLocal` 把经纬序列 → 局部米折线,摆在 Z=0 平面(或 2D视图 Z)。橙色(对齐轨迹详情 `#ff8c00`)。 +- 网格面:复用 `MapLineActor`(俯视红线) 或 `GridContourActor`(着色面,需 frame)。 +- 单测:纯几何(经纬→局部米折线点数/坐标)抽出可测。 + +## Task 4:VtkSceneView + Controller 接 2D 渲染 +- `I3dSceneView`/`VtkSceneView` 加 `addTrajectory(...)`、`add2dGrid(...)`。 +- `VtkSceneController`:Map2D 分支里,对勾选的 2D ds 按维度调 `loadTrajectory`/`load2dGrid` → addTrajectory/add2dGrid(异步 + QPointer/gen 守护,照帘面范式)。 +- `setViewMode`:col2D 的「2D视图位置」(关闭/Z=0/顶部/底部/自定义Z) 控制 2D 面的 Z(自定义Z=世界绝对米,见三栏 spec)。 + +## Task 5:main.cpp 接 col2D 信号 +- `drawer->col2D()` 的 `checkedDatasetsChanged` → 渲染 2D ds(同 col3D 模式,按维度过滤后的真实 dsId)。 +- `view2DModeChanged/customZChanged` → setViewMode + 2D 面 Z。 +- `basemapChanged` → Task 6 底图开关。 +- 验证:编译绿 + 用户实测(勾选轨迹 ds → 俯视面出橙色测线;网格 ds → 面)。 + +## Task 6:天地图底图瓦片层(最复杂,可独立验证) +新增 `render/ground/TileGroundLayer`: +- 输入:当前数据地理范围(从已渲染 actor 的 lat/lon 包围盒,或 GeoLocalFrame 原点 + 视域)→ 选合适 zoom(数据跨度→z)。 +- 瓦片数学:EPSG:3857 经纬→TileRow/Col(标准 Web Mercator 瓦片公式),取覆盖范围的瓦片集。 +- 异步拉取:`QNetworkAccessManager` GET 天地图 WMTS URL(token/子域见上)→ QImage → vtkTexture。 +- 摆放:每块瓦片的地理 bbox → 四角经纬 `GeoLocalFrame.toLocal` → 局部米 → `vtkPlaneSource` + texture,置于 Z=底图平面。 +- 切换:`basemapChanged`(0天地图/1Google/2隐藏);Google 可后置(国内可用性),先天地图 + 隐藏。 +- LOD/随相机更新:本期可固定一档 zoom(覆盖数据范围),相机驱动 LOD 后置。 +- **配准是精度敏感点**:3857 瓦片范围反算到 GeoLocalFrame 必须与帘面/轨迹同一 frame,否则底图与数据错位。**须用户实测对齐**(Claude 不能 GUI 测)。 +- 单测:瓦片数学(经纬→z/x/y、瓦片 bbox→经纬)纯函数抽出可测。 + +--- + +## ④ 三维分析栏交互(本期受限,附记) +- 树/右键菜单已是 UI。`detailRequested`→`detailCtrl.openDataset` 已接(T7)。`显示/隐藏` 可接 actor 可见性。 +- **真切片**需 3D 体模型(dd_voxel/dd_Structual3D),后端缺 → 受限,待后端。 +- 切片 CRUD/色阶/异常 = P4,接口已留位(②a),待后端 + 1.0 参考。 + +## 风险/验证 +- 全部渲染须用户 `build.bat rebuild` 实测(Claude 不能 GUI 测)。 +- 底图配准、2D 面 Z、轨迹/网格定位都是精度敏感点 → 小步验证。 +- Task 0(grid 端点) 不明会卡 Task 2 的网格分支 → 先确认。 diff --git a/docs/superpowers/plans/2026-06-16-vtk-3d-three-column-refactor.md b/docs/superpowers/plans/2026-06-16-vtk-3d-three-column-refactor.md new file mode 100644 index 0000000..34fd5b1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-vtk-3d-three-column-refactor.md @@ -0,0 +1,869 @@ +# VTK 三栏结构重构 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 VTK 工作台的"旧二维/三维切换 + 三浮层"过渡态,重构成需求 A1 的「三个子列表栏」(三维数据集 / 二维数据集 / 三维分析),内嵌在唯一的中央「VTK视图」左侧,并接通已有渲染/切片能力;同时给 VTK视图 + 数据详情 加全屏按钮。 + +**Architecture:** 三栏抽成独立 widget(`src/app/panels/columns/`),各自只发信号、不依赖控制器;`ColumnDrawer` 作为 `vtkWidget` 在 HBox 中的**左侧兄弟控件**(非 GL 浮层,规避原生 GL 浮层 z 序/圆角伪影),可折叠。`main.cpp::buildWorkbench` 删三浮层+分段切换,改挂三栏并把信号接到既有 `VtkSceneController`/`InteractionManager`/`DatasetDetailController`。数据集列表由 `WorkbenchNavController` 取 `DsRow`、按 `I3dSceneRepository::dimensionOf` 过滤后分发到三栏。 + +**Tech Stack:** C++17, Qt6 Widgets, VTK9, Qt-ADS。构建 `build.bat`(见"构建/验证铁律"),测试 GoogleTest/CTest。 + +--- + +## ⚠️ 构建/验证铁律(每个 Task 都遵守) + +- 构建:从 Git Bash 调 `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"`。命令:`app`/`test`/`rebuild`(全量)/`configure`。 +- **ninja 偶发漏编** → 改头/布局后用 `build.bat rebuild`;验 exe 新鲜:`stat -c '%y' build/release/src/app/geopro_desktop.exe`。 +- **切勿 `rm -rf build/release`**(vcpkg 重编依赖极慢)。 +- **Claude 工具跑 build 偶被 Start-Process 钩子劫持静默不跑** → **所有"用户实测"步骤必须由用户在其终端 `build.bat rebuild` 跑并目视**。Claude 不能 GUI 测 VTK 交互。 +- 纯逻辑(如维度过滤)抽函数 + GoogleTest 单测;UI/交互靠 build 绿 + 用户实测清单。 + +## 测试方式约定(本计划特例,覆盖默认 TDD-everywhere) + +- **逻辑步骤**(标 `[逻辑]`):先写失败测试 → 跑红 → 实现 → 跑绿 → 提交。 +- **UI 步骤**(标 `[UI]`):实现 → `build.bat rebuild` 编绿 → **用户实测清单** → 提交。Claude 不声称"已验证"交互,只验证编译通过 + 代码读校。 + +--- + +## File Structure + +**新建:** +- `src/app/panels/columns/ColumnDrawer.hpp/.cpp` — 抽屉容器(QTabWidget 三 tab + 折叠开关)。 +- `src/app/panels/columns/Column3DDataset.hpp/.cpp` — 三维数据集栏(4 工具条栏位 + 3D 数据集树)。 +- `src/app/panels/columns/Column2DDataset.hpp/.cpp` — 二维数据集栏(地图/2D视图控件 + 2D 数据集树)。 +- `src/app/panels/columns/Column3DAnalysis.hpp/.cpp` — 三维分析栏(对象→三维体→切片 树 + 两个右键菜单)。 +- `src/app/DatasetDimension.hpp/.cpp` — 纯函数 `splitByDimension(...)`(可单测)。 +- `tests/app/test_dataset_dimension.cpp` — 维度过滤单测。 + +**修改:** +- `src/app/main.cpp` — `buildWorkbench`:删三浮层(393-556)/分段切换(380-389,832-843)/showLayerPanel(804-827)/相关 connect;改挂 ColumnDrawer;接信号;rename vtkDock;bump dockState 版本;接维度过滤;全屏按钮。 +- `src/app/Glyphs.hpp/.cpp` — 加 `Glyph::Fullscreen` + SVG。 +- `src/app/CMakeLists.txt` — 加新源文件。 +- `tests/CMakeLists.txt`(或对应)— 加 test_dataset_dimension。 + +--- + +## Task 1: 加 Fullscreen 图标 [UI] + +**Files:** +- Modify: `src/app/Glyphs.hpp:15-35`(Glyph 枚举) +- Modify: `src/app/Glyphs.cpp`(SVG path 映射,参照现有 case) + +- [ ] **Step 1: 在 Glyph 枚举加 Fullscreen** + +`src/app/Glyphs.hpp`,在 `Collapse,` 之后加: +```cpp + Collapse, // 折叠(双箭头) + Fullscreen, // 全屏 / 最大化 +``` + +- [ ] **Step 2: 在 Glyphs.cpp 的 svg 映射加 case** + +找到 `makeGlyph`/svg path 的 `switch`(参照 `case Glyph::Collapse:`),加: +```cpp + case Glyph::Fullscreen: + return QStringLiteral(""); +``` +(若已有 restore/缩小语义图标可复用,但需一个独立 Fullscreen 项。) + +- [ ] **Step 3: 编译** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"` +Expected: 编译通过(exe 重新生成)。 + +- [ ] **Step 4: 提交** +```bash +git add src/app/Glyphs.hpp src/app/Glyphs.cpp +git commit -m "feat(vtk): 加 Glyph::Fullscreen 图标(三栏重构全屏按钮用)" +``` + +--- + +## Task 2: 维度过滤纯函数 [逻辑] + +把"DsRow 列表 → 按维度分三组"抽成可单测纯函数。`dimensionOf` 已在 `I3dSceneRepository`(接口),这里做的是**列表分流**。 + +**Files:** +- Create: `src/app/DatasetDimension.hpp` +- Create: `src/app/DatasetDimension.cpp` +- Test: `tests/app/test_dataset_dimension.cpp` +- Modify: `src/app/CMakeLists.txt`、`tests/CMakeLists.txt` + +- [ ] **Step 1: 写失败测试** + +`tests/app/test_dataset_dimension.cpp`: +```cpp +#include +#include "app/DatasetDimension.hpp" +#include "data/repo/RepoTypes.hpp" + +using geopro::data::DsRow; +using geopro::app::splitByDimension; +using geopro::app::DimBuckets; + +static DsRow row(const char* id, const char* ddCode) { + DsRow r; r.id = id; r.ddCode = ddCode; return r; +} + +TEST(DatasetDimension, SplitsByDdCode) { + std::vector in{ + row("a", "dd_section"), // 3D + row("b", "dd_voxel"), // 3D + row("c", "dd_trajectory_data"), // 2D + row("d", "dd_slice"), // Analysis + row("e", "dd_unknownxyz"), // Other → 不入任何栏 + }; + DimBuckets b = splitByDimension(in); + ASSERT_EQ(b.dim3D.size(), 2u); + EXPECT_EQ(b.dim3D[0].id, "a"); + EXPECT_EQ(b.dim3D[1].id, "b"); + ASSERT_EQ(b.dim2D.size(), 1u); + EXPECT_EQ(b.dim2D[0].id, "c"); + ASSERT_EQ(b.analysis.size(), 1u); + EXPECT_EQ(b.analysis[0].id, "d"); +} + +TEST(DatasetDimension, EmptyInput) { + DimBuckets b = splitByDimension({}); + EXPECT_TRUE(b.dim3D.empty()); + EXPECT_TRUE(b.dim2D.empty()); + EXPECT_TRUE(b.analysis.empty()); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"` +Expected: FAIL(`DatasetDimension.hpp` 不存在 / 链接失败)。 + +- [ ] **Step 3: 写实现** + +`src/app/DatasetDimension.hpp`: +```cpp +#pragma once +#include +#include "data/repo/RepoTypes.hpp" + +namespace geopro::app { + +struct DimBuckets { + std::vector dim3D; + std::vector dim2D; + std::vector analysis; +}; + +// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。 +// Other 维度不入任何栏(保留 parentId 顺序,调用方可直接喂 populateDatasetList)。 +DimBuckets splitByDimension(const std::vector& rows); + +} // namespace geopro::app +``` + +`src/app/DatasetDimension.cpp`: +```cpp +#include "app/DatasetDimension.hpp" + +namespace geopro::app { + +namespace { +// 与 LocalSample3dRepository::dimensionOf 同一映射(spec §6.1)。 +// 抽到此处以便纯函数单测;将来后端返 dimension 字段时此函数改读字段即可。 +enum class Dim { D3, D2, Analysis, Other }; +Dim dimOf(const std::string& c) { + if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || + c == "dd_section" || c == "dd_inversion_data") + return Dim::D3; + if (c == "dd_slice") return Dim::Analysis; + if (c == "dd_trajectory_data") return Dim::D2; + return Dim::Other; +} +} // namespace + +DimBuckets splitByDimension(const std::vector& rows) { + DimBuckets b; + for (const auto& r : rows) { + switch (dimOf(r.ddCode)) { + case Dim::D3: b.dim3D.push_back(r); break; + case Dim::D2: b.dim2D.push_back(r); break; + case Dim::Analysis: b.analysis.push_back(r); break; + case Dim::Other: break; + } + } + return b; +} + +} // namespace geopro::app +``` + +> 注:`dimensionOf` 同时存在于 `LocalSample3dRepository`(渲染编排用)。此处复制映射是**有意**——纯函数便于单测、且与"将来后端返 dimension 字段"解耦。后续若收敛为单一真源,再让本函数调用注入的 repo。落地时若 reviewer 要求单一真源,可改签名 `splitByDimension(rows, const I3dSceneRepository&)`,本期按纯函数。 + +- [ ] **Step 4: 注册到 CMake** + +`src/app/CMakeLists.txt`:把 `DatasetDimension.cpp` 加入 app 目标源列表(仿照同目录 .cpp 的加法)。 +`tests/CMakeLists.txt`(或 tests/app):把 `test_dataset_dimension.cpp` 加入测试目标(仿照 `test_3d_repo` 的注册)。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat test"` +Expected: `DatasetDimension.*` 2 项 PASS;总数 ≥ 223/223。 + +- [ ] **Step 6: 提交** +```bash +git add src/app/DatasetDimension.hpp src/app/DatasetDimension.cpp tests/app/test_dataset_dimension.cpp src/app/CMakeLists.txt tests/CMakeLists.txt +git commit -m "feat(vtk): 维度过滤纯函数 splitByDimension + 单测" +``` + +--- + +## Task 3: 三维数据集栏 widget [UI] + +独立 widget:4 工具条栏位(坐标轴设置 / 水平垂直比例 / 快捷视图 / 缩放)+ 数据集树。只发信号。控件创建可**搬运** `main.cpp:433-516`(axisBar)的 combo/slider/button 构造与样式,重排成 4 分组(参照原型 `docs/superpowers/mockups/2026-06-16-three-column-layout.html` 的 `toolbar3D`)。 + +**Files:** +- Create: `src/app/panels/columns/Column3DDataset.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件(信号 API)** + +`src/app/panels/columns/Column3DDataset.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "controller/I3dSceneView.hpp" // AxesMode/AxesUnit/ViewDir +#include "data/repo/RepoTypes.hpp" + +class QTreeWidget; + +namespace geopro::app { + +// 三维数据集栏:坐标轴设置 + 水平/垂直比例 + 快捷视图 + 缩放 + 3D 数据集列表。 +class Column3DDataset : public QWidget { + Q_OBJECT +public: + explicit Column3DDataset(QWidget* parent = nullptr); + // 用 3D 维度的 ds 填充列表(调用 populateDatasetList)。 + void setDatasets(const std::vector& rows); + +signals: + void axesModeChanged(geopro::controller::AxesMode mode); + void axesUnitChanged(geopro::controller::AxesUnit unit); + void verticalExaggerationChanged(double ve); + void viewRequested(geopro::controller::ViewDir dir); + void zoomInRequested(); + void zoomOutRequested(); + void fitRequested(); + void oPointClicked(); // O点位置按钮(本期弹框留 stub) + void fontClicked(); // 字体按钮(本期 stub) + void checkedDatasetsChanged(const QStringList& dsIds); // 列表勾选变化 + +private: + QTreeWidget* list_ = nullptr; +}; + +} // namespace geopro::app +``` + +- [ ] **Step 2: 写实现(构造 4 分组 + 列表)** + +`src/app/panels/columns/Column3DDataset.cpp`:用 `QVBoxLayout` 堆 4 个分组 `QGroupBox`/`QFrame`(标题 + 表单行)+ `QTreeWidget` 列表。控件构造照搬 `main.cpp:464-500`(axesModeCombo/axesUnitCombo/veSlider/btnFront..btnFit),样式用 `applyTokenizedStyleSheet` 照搬 `main.cpp:437-457`。各控件 `connect` 到本类 `emit ...`。要点(完整骨架): +```cpp +#include "app/panels/columns/Column3DDataset.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include "app/Theme.hpp" +#include "app/panels/DatasetListPanel.hpp" // populateDatasetList + +using geopro::controller::AxesMode; +using geopro::controller::AxesUnit; +using geopro::controller::ViewDir; + +namespace geopro::app { + +Column3DDataset::Column3DDataset(QWidget* parent) : QWidget(parent) { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd); + root->setSpacing(space::kMd); + + // —— 坐标轴设置 组:显示方式▾ / O点位置(按钮) / 刻度▾ / 字体(按钮) —— + { + auto* form = new QFormLayout(); + auto* mode = new QComboBox(); + mode->addItem(QStringLiteral("标准"), static_cast(AxesMode::Standard)); + mode->addItem(QStringLiteral("三维立体"), static_cast(AxesMode::Stereo)); + mode->addItem(QStringLiteral("不显示"), static_cast(AxesMode::None)); + connect(mode, qOverload(&QComboBox::currentIndexChanged), this, + [this, mode](int){ emit axesModeChanged(static_cast(mode->currentData().toInt())); }); + auto* oPoint = new QPushButton(QStringLiteral("设置…")); + connect(oPoint, &QPushButton::clicked, this, &Column3DDataset::oPointClicked); + auto* unit = new QComboBox(); + unit->addItem(QStringLiteral("无刻度"), static_cast(AxesUnit::None)); + unit->addItem(QStringLiteral("米"), static_cast(AxesUnit::Meter)); + unit->addItem(QStringLiteral("英尺"), static_cast(AxesUnit::Feet)); + unit->addItem(QStringLiteral("经纬度"), static_cast(AxesUnit::LatLon)); + unit->setCurrentIndex(1); + connect(unit, qOverload(&QComboBox::currentIndexChanged), this, + [this, unit](int){ emit axesUnitChanged(static_cast(unit->currentData().toInt())); }); + auto* font = new QPushButton(QStringLiteral("设置…")); + connect(font, &QPushButton::clicked, this, &Column3DDataset::fontClicked); + form->addRow(QStringLiteral("显示方式"), mode); + form->addRow(QStringLiteral("O点位置"), oPoint); + form->addRow(QStringLiteral("刻度"), unit); + form->addRow(QStringLiteral("字体"), font); + root->addWidget(new QLabel(QStringLiteral("坐标轴设置"))); + root->addLayout(form); + } + // —— 水平/垂直比例 组:单个滑块 + 数值 —— + { + auto* row = new QHBoxLayout(); + auto* slider = new QSlider(Qt::Horizontal); + slider->setMinimum(1); slider->setMaximum(10); slider->setValue(2); + auto* val = new QLabel(QStringLiteral("2.0×")); + connect(slider, &QSlider::valueChanged, this, [this, val](int v){ + val->setText(QStringLiteral("%1.0×").arg(v)); + emit verticalExaggerationChanged(static_cast(v)); + }); + row->addWidget(slider, 1); row->addWidget(val); + root->addWidget(new QLabel(QStringLiteral("水平/垂直比例"))); + root->addLayout(row); + } + // —— 快捷视图 组:前/后/左/右/上/下 —— + { + auto* row = new QHBoxLayout(); + struct V { const char* t; ViewDir d; }; + for (V v : { V{"前",ViewDir::Front}, V{"后",ViewDir::Back}, V{"左",ViewDir::Left}, + V{"右",ViewDir::Right}, V{"上",ViewDir::Top}, V{"下",ViewDir::Bottom} }) { + auto* b = new QPushButton(QString::fromUtf8(v.t)); + ViewDir d = v.d; + connect(b, &QPushButton::clicked, this, [this, d]{ emit viewRequested(d); }); + row->addWidget(b); + } + root->addWidget(new QLabel(QStringLiteral("快捷视图"))); + root->addLayout(row); + } + // —— 缩放 组:放大/缩小/适配 —— + { + auto* row = new QHBoxLayout(); + auto* in = new QPushButton(QStringLiteral("放大")); + auto* out = new QPushButton(QStringLiteral("缩小")); + auto* fit = new QPushButton(QStringLiteral("适配")); + connect(in, &QPushButton::clicked, this, &Column3DDataset::zoomInRequested); + connect(out, &QPushButton::clicked, this, &Column3DDataset::zoomOutRequested); + connect(fit, &QPushButton::clicked, this, &Column3DDataset::fitRequested); + row->addWidget(in); row->addWidget(out); row->addWidget(fit); + root->addWidget(new QLabel(QStringLiteral("缩放"))); + root->addLayout(row); + } + // —— 数据集列表(3D 维度)—— + list_ = new QTreeWidget(); + list_->setHeaderHidden(true); + list_->setRootIsDecorated(true); + applyDatasetCardDelegate(list_); + connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int){ + QStringList ids; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->checkState(0) == Qt::Checked) + ids << (*it)->data(0, /*kDsIdRole*/ Qt::UserRole + 1).toString(); + emit checkedDatasetsChanged(ids); + }); + root->addWidget(list_, 1); +} + +void Column3DDataset::setDatasets(const std::vector& rows) { + populateDatasetList(list_, rows, /*append=*/false); + // 列表项需可勾选:populateDatasetList 后给每项加 Qt::ItemIsUserCheckable + Unchecked。 + for (QTreeWidgetItemIterator it(list_); *it; ++it) { + (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); + if ((*it)->checkState(0) == Qt::Unchecked || (*it)->checkState(0) == Qt::Checked) {} + else (*it)->setCheckState(0, Qt::Unchecked); + } +} + +} // namespace geopro::app +``` +> 注 1:`kDsIdRole` 的真实值见 `src/app/panels/DatasetListPanel.cpp`(`Qt::UserRole+? `)。落地时 include 该常量或用其公开定义,勿硬编码 `UserRole+1`——读 DatasetListPanel.hpp/.cpp 取真实 role 常量。 +> 注 2:`populateDatasetList` 生成的项默认不可勾选;本栏需勾选渲染,故 setDatasets 后补 `ItemIsUserCheckable` + `Unchecked`(见上)。 +> 注 3:分组标题/表单样式照原型;可用 `applyTokenizedStyleSheet` 套深色令牌(搬 `main.cpp:437-457`)。 + +- [ ] **Step 3: 注册 CMake + 编译** + +`src/app/CMakeLists.txt` 加 `panels/columns/Column3DDataset.cpp`。 +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"` +Expected: 编译通过。 + +- [ ] **Step 4: 提交** +```bash +git add src/app/panels/columns/Column3DDataset.hpp src/app/panels/columns/Column3DDataset.cpp src/app/CMakeLists.txt +git commit -m "feat(vtk): 三维数据集栏 widget(4工具条栏位+3D数据集列表,只发信号)" +``` + +--- + +## Task 4: 二维数据集栏 widget [UI] + +**Files:** +- Create: `src/app/panels/columns/Column2DDataset.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +`Column2DDataset.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "data/repo/RepoTypes.hpp" +class QTreeWidget; +namespace geopro::app { +class Column2DDataset : public QWidget { + Q_OBJECT +public: + explicit Column2DDataset(QWidget* parent = nullptr); + void setDatasets(const std::vector& rows); +signals: + void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏 + void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义 + void customZChanged(double z); // 世界绝对高程(米),向上为正 + void checkedDatasetsChanged(const QStringList& dsIds); +private: + QTreeWidget* list_ = nullptr; +}; +} +``` + +- [ ] **Step 2: 写实现** + +`Column2DDataset.cpp`:地图 combo(天地图/Google Map/隐藏)→ `basemapChanged`;2D视图 combo(关闭/Z=0/顶部/底部/自定义)→ `view2DModeChanged`,选"自定义"时显一个 `QDoubleSpinBox`(范围 ±1e6,后缀 " m")→ `customZChanged`;`QTreeWidget` 列表同 Task3(可勾选 + setDatasets 用 populateDatasetList)。骨架同 Column3DDataset 模式(QFormLayout 两组 + 列表),此处不赘述控件 connect(与 Task3 同形)。自定义 Z 输入框默认 `setVisible(false)`,在 `view2DModeChanged` 槽里 `zEdit->setVisible(index==4)`。 + +- [ ] **Step 3: 注册 CMake + 编译** + +`src/app/CMakeLists.txt` 加 `panels/columns/Column2DDataset.cpp`。 +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"` +Expected: 编译通过。 + +- [ ] **Step 4: 提交** +```bash +git add src/app/panels/columns/Column2DDataset.hpp src/app/panels/columns/Column2DDataset.cpp src/app/CMakeLists.txt +git commit -m "feat(vtk): 二维数据集栏 widget(地图/2D视图+自定义Z输入+2D列表)" +``` + +--- + +## Task 5: 三维分析栏 widget + 两个右键菜单 [UI] + +**Files:** +- Create: `src/app/panels/columns/Column3DAnalysis.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +`Column3DAnalysis.hpp`: +```cpp +#pragma once +#include +#include +#include +#include "data/repo/RepoTypes.hpp" +#include "render/interact/SlicePlaneMath.hpp" // SliceAxis +class QTreeWidget; +class QTreeWidgetItem; +namespace geopro::app { +// 三维分析栏:对象→三维体模型→切片 树 + 三维体/切片两套右键菜单。 +class Column3DAnalysis : public QWidget { + Q_OBJECT +public: + explicit Column3DAnalysis(QWidget* parent = nullptr); + void setDatasets(const std::vector& rows); // Analysis 维度(三维体/切片) +signals: + // 三维体右键:切片▸(上下/前后/左右/任意) + void sliceRequested(geopro::render::interact::SliceAxis axis); + void colorScaleRequested(const QString& dsId); // 三维体&切片(本期 stub) + void visibilityToggled(const QString& dsId); // 显示/隐藏 + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); + // 切片右键(本期 stub,菜单可见但发信号给上层提示"待实现") + void sliceSaveRequested(const QString& dsId); + void sliceSaveAsRequested(const QString& dsId); + void sliceExportRequested(const QString& dsId); + void sliceDeleteRequested(const QString& dsId); + void checkedItemsChanged(const QStringList& dsIds); +private: + void onContextMenu(const QPoint& pos); + QTreeWidget* tree_ = nullptr; +}; +} +``` + +- [ ] **Step 2: 写实现(树 + 右键分派)** + +`Column3DAnalysis.cpp`:`tree_` 设 `setContextMenuPolicy(Qt::CustomContextMenu)`,connect `customContextMenuRequested` → `onContextMenu`。节点类型用 `item->data(0, role)` 区分"三维体" vs "切片"(建树时按 ddCode:`dd_voxel/dd_Structual3D/dd_Property3D/dd_section` 为三维体;`dd_slice` 为切片)。右键分派(核心): +```cpp +void Column3DAnalysis::onContextMenu(const QPoint& pos) { + QTreeWidgetItem* it = tree_->itemAt(pos); + if (!it) return; + const QString dsId = it->data(0, kDsIdRole).toString(); + const QString ddCode = it->data(0, kDsDdCodeRole).toString(); + const QString name = it->data(0, kDsNameRole).toString(); + const bool isSlice = (ddCode == QStringLiteral("dd_slice")); + QMenu menu(this); + if (!isSlice) { + // 三维体数据集:切片▸(上下/前后/左右/任意) / 色阶 / 显示·隐藏 / 数据详情 + QMenu* sub = menu.addMenu(QStringLiteral("切片")); + using SA = geopro::render::interact::SliceAxis; + sub->addAction(QStringLiteral("上下"), this, [this]{ emit sliceRequested(SA::UpDown); }); + sub->addAction(QStringLiteral("前后"), this, [this]{ emit sliceRequested(SA::FrontBack); }); + sub->addAction(QStringLiteral("左右"), this, [this]{ emit sliceRequested(SA::LeftRight); }); + sub->addAction(QStringLiteral("任意"), this, [this]{ emit sliceRequested(SA::Oblique); }); + menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); }); + menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); }); + menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); }); + } else { + // 切片数据集:保存/保存为/导出/删除 — 色阶/显示·隐藏/数据详情 + menu.addAction(QStringLiteral("保存"), this, [this, dsId]{ emit sliceSaveRequested(dsId); }); + menu.addAction(QStringLiteral("保存为"), this, [this, dsId]{ emit sliceSaveAsRequested(dsId); }); + menu.addAction(QStringLiteral("导出"), this, [this, dsId]{ emit sliceExportRequested(dsId); }); + menu.addAction(QStringLiteral("删除"), this, [this, dsId]{ emit sliceDeleteRequested(dsId); }); + menu.addSeparator(); + menu.addAction(QStringLiteral("色阶"), this, [this, dsId]{ emit colorScaleRequested(dsId); }); + menu.addAction(QStringLiteral("显示 / 隐藏"), this, [this, dsId]{ emit visibilityToggled(dsId); }); + menu.addAction(QStringLiteral("数据详情"), this, [this, dsId, ddCode, name]{ emit detailRequested(dsId, ddCode, name); }); + } + menu.exec(tree_->viewport()->mapToGlobal(pos)); +} +``` +> `kDsIdRole/kDsDdCodeRole/kDsNameRole`:用 DatasetListPanel 的公开 role 常量(读 DatasetListPanel.hpp)。树构建:用 `populateDatasetList(tree_, analysisRows, false)` 起步(它已按 parentId 建树:切片挂三维体下),再补可勾选标志(同 Task3 注 2)。 + +- [ ] **Step 3: 注册 CMake + 编译** + +`src/app/CMakeLists.txt` 加 `panels/columns/Column3DAnalysis.cpp`。 +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"` +Expected: 编译通过。 + +- [ ] **Step 4: 提交** +```bash +git add src/app/panels/columns/Column3DAnalysis.hpp src/app/panels/columns/Column3DAnalysis.cpp src/app/CMakeLists.txt +git commit -m "feat(vtk): 三维分析栏 widget(对象→三维体→切片树+两套右键菜单)" +``` + +--- + +## Task 6: 抽屉容器 ColumnDrawer [UI] + +**Files:** +- Create: `src/app/panels/columns/ColumnDrawer.hpp/.cpp` +- Modify: `src/app/CMakeLists.txt` + +- [ ] **Step 1: 写头文件** + +`ColumnDrawer.hpp`: +```cpp +#pragma once +#include +namespace geopro::app { +class Column3DDataset; class Column2DDataset; class Column3DAnalysis; +// VTK视图左侧内嵌抽屉:三 tab(三维数据集/二维数据集/三维分析) + 折叠开关。 +class ColumnDrawer : public QWidget { + Q_OBJECT +public: + explicit ColumnDrawer(QWidget* parent = nullptr); + Column3DDataset* col3D() const { return col3D_; } + Column2DDataset* col2D() const { return col2D_; } + Column3DAnalysis* colAnalysis() const { return colAnalysis_; } +public slots: + void toggleCollapsed(); // 折叠/展开(宽度切换) +private: + Column3DDataset* col3D_ = nullptr; + Column2DDataset* col2D_ = nullptr; + Column3DAnalysis* colAnalysis_ = nullptr; + QWidget* body_ = nullptr; // QTabWidget 容器,折叠时隐藏 + bool collapsed_ = false; +}; +} +``` + +- [ ] **Step 2: 写实现** + +`QTabWidget` 三页(三维数据集/二维数据集/三维分析)放入 `body_`;旁边一个细长折叠按钮(◀/▶,调 `toggleCollapsed`)。`toggleCollapsed`:`collapsed_ = !collapsed_; body_->setVisible(!collapsed_);` 并切按钮箭头。固定展开宽度约 300(`setFixedWidth` 或 `setMaximumWidth`,折叠时设 0/隐藏 body)。 + +- [ ] **Step 3: 注册 CMake + 编译** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat app"` +Expected: 编译通过。 + +- [ ] **Step 4: 提交** +```bash +git add src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt +git commit -m "feat(vtk): ColumnDrawer 抽屉容器(三tab+折叠)" +``` + +--- + +## Task 7: main.cpp 装配——删三浮层/切换,挂抽屉,接信号,改名 [UI] + +这是核心整合。**一次性**替换,因 axisBar/sliceBar/layerPanel 的 connect 互相牵连,无法逐控件保持编译。改完一次编绿。 + +**Files:** +- Modify: `src/app/main.cpp`(多处,见下) + +- [ ] **Step 1: 删旧 UI 构造** + +删除: +- `layerPanel` 块(393-429)、`axisBar` 块(433-516)、`RightTopAnchor`(520)、`sliceBar` 块(525-556)、`BottomLeftAnchor`(556)。 +- 分段切换:`buildSegmentedHeader`/`viewHeader`/`act2D`/`act3D`(380-389)改为**简单标题头**(见 Step 3)。 +- `showLayerPanel` lambda(804-827)及其所有调用。 +- `updateSliceButtons`(559-569)、`addSlice`(572-575)、sliceBar 按钮 connect(576-590)。 +- 旧 connect:layer checkboxes(846-851)、axisBar 控件(857-889)、act2D/act3D(832-843)。 +- 保留 `interactionMgr` 创建(309-321)、`emptyState`、`sceneCtrl`、`vtkWidget`。 + +- [ ] **Step 2: 建 ColumnDrawer + 改 centerWidget 布局为 [抽屉 | GL]** + +在 `centerWidget` 构造处(374 一带)改为:顶部一个标题头(Step 3),下面一个 `QHBoxLayout` 装 `drawer` + `vtkWidget`: +```cpp +#include "app/panels/columns/ColumnDrawer.hpp" +#include "app/panels/columns/Column3DDataset.hpp" +#include "app/panels/columns/Column2DDataset.hpp" +#include "app/panels/columns/Column3DAnalysis.hpp" +// ... +auto* drawer = new geopro::app::ColumnDrawer(centerWidget); +auto* viewRow = new QHBoxLayout(); +viewRow->setContentsMargins(0,0,0,0); viewRow->setSpacing(0); +viewRow->addWidget(drawer); // 左侧抽屉 +viewRow->addWidget(vtkWidget, 1); // 右侧 GL 画布 +// centerLayout: [标题头] + [viewRow] +centerLayout->addWidget(viewHeader); // Step 3 的新标题头 +centerLayout->addLayout(viewRow, 1); +``` +> 设计:抽屉是 vtkWidget 的**布局兄弟**(非 GL 子浮层),规避 `main.cpp:397-399` 注释提到的原生 GL 浮层圆角/底色伪影。视觉等同原型(栏在左、画布在右)。 + +- [ ] **Step 3: 新标题头(含全屏按钮,Task 8 接线)** + +替换分段头为 `buildPanelHeader`: +```cpp +auto* viewHeader = geopro::app::buildPanelHeader( + geopro::app::Glyph::Map, QStringLiteral("VTK视图"), + {{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}}); +``` +(全屏按钮的 connect 在 Task 8。) + +- [ ] **Step 4: 接三维数据集栏信号 → VtkSceneController** +```cpp +auto* c3 = drawer->col3D(); +QObject::connect(c3, &geopro::app::Column3DDataset::axesModeChanged, sceneCtrl, &VtkSceneController::setAxesMode); +QObject::connect(c3, &geopro::app::Column3DDataset::axesUnitChanged, sceneCtrl, &VtkSceneController::setAxesUnit); +QObject::connect(c3, &geopro::app::Column3DDataset::verticalExaggerationChanged, sceneCtrl, &VtkSceneController::setVerticalExaggeration); +QObject::connect(c3, &geopro::app::Column3DDataset::viewRequested, sceneCtrl, &VtkSceneController::applyView); +QObject::connect(c3, &geopro::app::Column3DDataset::zoomInRequested, sceneCtrl, &VtkSceneController::zoomIn); +QObject::connect(c3, &geopro::app::Column3DDataset::zoomOutRequested, sceneCtrl, &VtkSceneController::zoomOut); +QObject::connect(c3, &geopro::app::Column3DDataset::fitRequested, sceneCtrl, &VtkSceneController::fit); +// O点位置/字体本期 stub:connect 到一个提示(可空 lambda)。 +``` +> 类型匹配:`Column3DDataset` 的枚举即 `geopro::controller::AxesMode/AxesUnit/ViewDir`(同 I3dSceneView.hpp),与 `setAxesMode/setAxesUnit/applyView` 形参一致,可直接连。 + +- [ ] **Step 5: 接三维分析栏「切片」→ InteractionManager** +```cpp +auto* ca = drawer->colAnalysis(); +QObject::connect(ca, &geopro::app::Column3DAnalysis::sliceRequested, vtkWidget, + [interactionMgr](geopro::render::interact::SliceAxis axis){ + interactionMgr->addSlice(axis); + }); +QObject::connect(ca, &geopro::app::Column3DAnalysis::detailRequested, &detailCtrl, + [&detailCtrl](const QString& dsId, const QString& ddCode, const QString& name){ + detailCtrl.openDataset(dsId, ddCode, name); + }); +// colorScale/visibility/slice CRUD 本期 stub:connect 到提示 lambda(如 statusBar 显"待实现")。 +``` + +- [ ] **Step 6: 编译(维度过滤接线在 Task 9,此处先空列表)** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"` +Expected: 全量编译通过,exe 刷新。 + +- [ ] **Step 7: 用户实测清单**(用户在其终端跑) + - [ ] app 启动,中央改名「VTK视图」,左侧出现三 tab 抽屉。 + - [ ] 旧「二维地图/三维视图」分段按钮已消失;左上/右上/左下三浮层消失。 + - [ ] 抽屉折叠开关:点 ◀ 收起、画布变宽;点 ▶ 展开。 + - [ ] 三维数据集栏工具条:坐标轴下拉/比例滑块/快捷视图 6 钮/缩放 3 钮可点(功能接通后续 Task 9 验,但点击不崩)。 + - [ ] 三维分析栏右键三维体 → 出「切片▸(上下/前后/左右/任意)/色阶/显隐/详情」;右键切片 → 出「保存/保存为/导出/删除/色阶/显隐/详情」。 + +- [ ] **Step 8: 提交** +```bash +git add src/app/main.cpp +git commit -m "refactor(vtk): 删三浮层+分段切换,改挂三栏抽屉,接信号,中央改名VTK视图" +``` + +--- + +## Task 8: dockState 版本 bump + 全屏按钮 [UI] + +**Files:** +- Modify: `src/app/main.cpp`(dockState 键 1428-1442;全屏 connect) + +- [ ] **Step 1: bump dock 布局版本** + +`main.cpp:1430` 与 1440:把 `ui/dockState_v2` 两处改为 `ui/dockState_v3`(dock 名/结构已变,旧布局须丢弃回落默认排布;遵循 1428-1430 注释)。 + +- [ ] **Step 2: 全屏切换实现** + +全屏 = 隐藏其余 dock,仅留目标 dock 充满 dock 区;再点还原。用 ADS `CDockWidget::toggleView(bool)`。加一个 lambda + 状态: +```cpp +// 全屏:隐藏其余 dock,仅留 target;再点还原。docks 列表见 hideDockTitleBars(733-740)。 +bool* vtkFs = new bool(false); // 或用 QObject property,避免裸 new:可挂到 window +auto makeFullscreen = [dockManager](ads::CDockWidget* target, const QList& others, bool on){ + for (ads::CDockWidget* d : others) d->toggleView(!on); // on→隐藏其余 + Q_UNUSED(target); +}; +``` +> 落地建议:用 `QToolButton::setCheckable(true)` 的全屏按钮 + `toggled(bool)` 切换;状态存按钮 checked,免裸指针。`others` = 除目标外的全部 dock(vtkDock 全屏时 others={leftDock,datasetDock,detailDock,rightDock,propDock};detailDock 全屏时 others={其余})。 + +- [ ] **Step 3: 接全屏按钮** + +用 `findHeaderAction(box, Glyph::Fullscreen)`(main.cpp:1016-1020 的 helper)取到 VTK视图标题头与 数据详情头里的全屏按钮,connect 到 `makeFullscreen`。VTK视图头是 Step3(Task7) 的 `viewHeader`;数据详情头是 `detailDock` 的 `wrapWithHeader`(654-663)——给它加 `{{Glyph::Fullscreen,"全屏"}}` action。 +```cpp +// 数据详情头加全屏 action(修改 654-663 的 wrapWithHeader 调用,加 actions 参数)。 +// 然后: +auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen); +auto* detFsBtn = findHeaderAction(detailHeaderBox, geopro::app::Glyph::Fullscreen); +// 各自 setCheckable(true) + connect(&QToolButton::toggled, ... makeFullscreen ...) +``` + +- [ ] **Step 4: 编译** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"` +Expected: 编译通过。 + +- [ ] **Step 5: 用户实测清单** + - [ ] 点 VTK视图标题栏右侧全屏按钮 → VTK视图充满工作区(其余 dock 隐藏);再点 → 还原。 + - [ ] 点 数据详情标题栏全屏按钮 → 同理。 + - [ ] 首次启动(旧布局丢弃)dock 排布为默认,无错位。 + +- [ ] **Step 6: 提交** +```bash +git add src/app/main.cpp +git commit -m "feat(vtk): dockState bump v3 + VTK视图/数据详情 全屏按钮(隐藏其余dock)" +``` + +--- + +## Task 9: 维度过滤接线——三栏数据集列表数据驱动 [UI+逻辑] + +把"勾选对象 → 取 ds → 按维度分三栏"接通,替换 `main.cpp:891-899` 的 "grid1" 假实现。 + +**Files:** +- Modify: `src/app/main.cpp` + +- [ ] **Step 1: 取勾选对象的 ds 行** + +现状:`checkedTmsChanged(QStringList tmIds)` → 假 "grid1"。改为:用 `WorkbenchNavController`/`repo_.loadRowsAsync` 对每个勾选 TM 取 `DsRow`,汇总。`nav` 已有 `datasetsLoaded(tmObjectId, rows, total, append)` 信号(WorkbenchNavController.hpp:52)。**最简路径**:复用 nav 的取数,但 nav 现按"单击对象"取数(selectObject),勾选多 TM 需逐个取并合并。 + +实现:在 `checkedTmsChanged` 槽里,对每个 tmId 调 `nav.selectObject(tmId, 2)` 不合适(会刷左下列表)。改为直接调 repo: +```cpp +// 汇总所有勾选 TM 的 ds,按维度分三栏。projectRepo 是 IAsyncProjectRepository。 +QObject::connect(objectTree, &geopro::app::ObjectTreePanel::checkedTmsChanged, &window, + [&projectRepo, drawer, sceneCtrl, emptyState, &window](const QStringList& tmIds){ + emptyState->setVisible(tmIds.isEmpty()); + auto acc = std::make_shared>(); + auto remaining = std::make_shared(tmIds.size()); + if (tmIds.isEmpty()) { + drawer->col3D()->setDatasets({}); + drawer->col2D()->setDatasets({}); + drawer->colAnalysis()->setDatasets({}); + sceneCtrl->setCheckedDatasets({}); + return; + } + for (const QString& tm : tmIds) { + // classifyType=3, pageNo=1, 大 pageSize 取整树(同 WorkbenchNavController kFetchAllPageSize) + geopro::data::NavRequest* req = projectRepo.loadRowsAsync( + currentProjectIdStdString, tm.toStdString(), /*parentConfType*/2, /*classifyType*/3, 1, 100000); + req->onDone = [acc, remaining, drawer](const geopro::data::DsPage& page){ + acc->insert(acc->end(), page.rows.begin(), page.rows.end()); + if (--(*remaining) == 0) { + geopro::app::DimBuckets b = geopro::app::splitByDimension(*acc); + drawer->col3D()->setDatasets(b.dim3D); + drawer->col2D()->setDatasets(b.dim2D); + drawer->colAnalysis()->setDatasets(b.analysis); + } + }; + // req->onFail 同样 --remaining 并在归零时刷新(避免一个失败卡死)。 + } + }); +#include "app/DatasetDimension.hpp" +``` +> 注:`NavRequest` 的回调字段真名见 `src/data/repo/IAsyncProjectRepository.hpp` / NavRequest 定义(onDone/onFail 或 done/failed)——落地按真实字段。`currentProjectIdStdString` 取当前项目 id(main.cpp 里已有项目 id 来源,搜 `currentProjectId`/`projectId`)。 + +- [ ] **Step 2: 勾选数据集 → 渲染** + +三栏列表勾选 → `setCheckedDatasets`。汇总三栏勾选的 dsId: +```cpp +auto pushChecked = [drawer, sceneCtrl]{ + QStringList ids; + // 收集三栏当前勾选(各栏暴露 checkedDatasetsChanged;此处也可各自直接连) + // 简化:各栏 checkedDatasetsChanged 直接 setCheckedDatasets(合并)。 +}; +QObject::connect(drawer->col3D(), &geopro::app::Column3DDataset::checkedDatasetsChanged, + sceneCtrl, &VtkSceneController::setCheckedDatasets); +// 若需三栏合并,改为聚合后再 setCheckedDatasets;本期可先只接 col3D(3D 渲染主路径)。 +``` +> 本期渲染主路径是 3D 数据集(帘面/体素/地形),故先接 `col3D` 的勾选 → `setCheckedDatasets`。2D/分析渲染随各自维度后续完善。 + +- [ ] **Step 3: 编译** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild"` +Expected: 编译通过。 + +- [ ] **Step 4: 用户实测清单** + - [ ] 勾选含 ds 的对象 → 三维数据集栏列表出现 3D 维度 ds(样本 "剖面网格数据1" dd_section)。 + - [ ] 勾选三维数据集栏里的 ds → 中央渲染帘面(原 grid1 路径效果)。 + - [ ] 取消全部勾选 → 三栏列表清空、中央清场、引导层 emptyState 显示。 + - [ ] (样本数据若无 2D/切片 ds,2D/分析栏为空属正常;可后续在 LocalSample 加样本演示。) + +- [ ] **Step 5: 提交** +```bash +git add src/app/main.cpp +git commit -m "feat(vtk): 三栏数据集列表按维度过滤数据驱动(替换grid1假实现)" +``` + +--- + +## Task 10: 全量回归 + 收尾 [逻辑] + +- [ ] **Step 1: 全量 build + ctest** + +Run: `cmd.exe /c "chcp 65001 >nul && cd /d D:\Git\lanbingtech\geopro && .\build.bat rebuild && .\build.bat test"` +Expected: 编译通过;ctest 全绿(≥ 223/223,含新 DatasetDimension.* 2 项)。 + +- [ ] **Step 2: 派 cpp-reviewer 审查本分支改动** + +对 `src/app/panels/columns/*`、`DatasetDimension.*`、`main.cpp` diff 跑 cpp-reviewer,修 CRITICAL/HIGH(重点:裸 new/生命周期、信号槽断连、ADS toggleView 还原正确性、role 常量硬编码)。 + +- [ ] **Step 3: 对照 spec 验收** + +逐条核对 `2026-06-16-vtk-3d-three-column-refactor-design.md` §0 IN 项全部落地、OUT 项为 stub/禁用。 + +- [ ] **Step 4: 用户最终实测**(完整走查实测清单 Task7/8/9) + +- [ ] **Step 5: 提交收尾(如有修改)** +```bash +git add -A && git commit -m "chore(vtk): 三栏重构 review 修复 + 回归" +``` + +--- + +## Self-Review(写计划后自查) + +**Spec 覆盖:** +- A1 三栏 → Task 3-7 ✅;单一 VTK视图/删切换/改名 → Task 7 ✅;三浮层收编 → Task 7 ✅;维度过滤列表 → Task 2+9 ✅;三维数据集 4 栏位 → Task 3 ✅;二维 地图/2D视图/自定义Z → Task 4 ✅;三维分析树+两右键菜单+切片接 SliceTool → Task 5+7 ✅;全屏 → Task 1+8 ✅;自定义Z绝对高程 → Task 4(spec 已记) ✅;dock 版本 bump → Task 8 ✅。 +- OUT 项(CRUD/色阶/底图/异常体/详情/任务)→ stub 信号(Task 5),不实现 ✅。 + +**占位符扫描:** 已用真实 API/行号;少数"读真实 role 常量/NavRequest 字段名"是**有意指向源文件**(避免硬编码错值),非 TODO。 + +**类型一致性:** `AxesMode/AxesUnit/ViewDir`(I3dSceneView.hpp) 跨 Task3/7 一致;`SliceAxis`(SlicePlaneMath.hpp) 跨 Task5/7 一致;`DsRow/DsPage/DimBuckets` 跨 Task2/3/9 一致;`splitByDimension` 签名 Task2 定义、Task9 使用一致。 + +**风险:** Task 9 的多 TM 异步汇总 + NavRequest 回调字段名是最大不确定点(落地前先读 IAsyncProjectRepository.hpp 确认);全屏 toggleView 还原需保证 dock 顺序/可见性正确(Task8 用户实测把关)。 diff --git a/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md b/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md new file mode 100644 index 0000000..7d4abd0 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-vtk-3d-volume-create-flow.md @@ -0,0 +1,138 @@ +# 实现计划:客户端「生成三维体」完整流程(#1) + +- 日期:2026-06-17 +- 分支:`feat/vtk-3d-view` +- 上位设计:`docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md`(§2.4 客户端创建、§6 拆解、§7 持久化策略) +- 决策:用户已拍板「完整生成三维体客户端流程」(非最小 mock)。 +- 原则:缺后端端点 → 内存 mock,保持 `I3dSceneRepository` 接口可替换;客户端能做的先做。 + +## 0. 现状取证(file:line) + +| 事实 | 证据 | +|---|---| +| 运行路径用 `Api3dRepository`(非 LocalSample) | `main.cpp:254` | +| `Api3dRepository::loadVolume` 是 stub | `Api3dRepository.cpp:57-60` | +| 控制器→loadVolume→addVolume 管线已接好,但 voxel 路径靠全局 `showVoxel_=false` 且无 UI 触发 | `VtkSceneController.cpp:70-89`、`VtkSceneController.hpp:70` | +| 取源散点最干净路径 | `dsRepo_.loadAsync("inversion.scatter", dsId)` → `core::ScatterPayload`(`ApiDatasetRepository.cpp:152-165`;散点含 projX/projY + z(hlist=高程) + v,`DatasetChartDto.cpp:39-46`) | +| 帘面/地形竖向 = +elevation(Z=+g.y) | 交接文档 §2;`CurtainActor.cpp` | +| LocalSample loadVolume 参考实现(散点→配准→GridSpec→IDW),但用 `-s.y` 深度且固定样本 | `LocalSample3dRepository.cpp:68-147` | +| 散点→GridSpec→IDW 在 LocalSample/Api 重复,已有 TODO 标记漂移风险 | `LocalSample3dRepository.cpp:31-37` | +| 数据集列表后端全量加载、每次勾选测线变化全量覆盖 | `main.cpp:626-665`;`DatasetListPanel.cpp:187-216`(append=false) | +| `DsRow` 字段 | `RepoTypes.hpp:10-15`(id/dsName/typeName/ddCode/parentId/...) | +| 维度分流 dd_voxel→Dim3D | `DatasetDimension.cpp:8-15`;`Api3dRepository::dimensionOf` | +| Column3DDataset 列表是 checkbox 勾选(渲染),无多选/右键 | `Column3DDataset.cpp:114-128` | +| col3D 信号接线 | `main.cpp:362-386` | + +## 1. 数据模型:VolumeBuildParams(设计 §7.4) + +新增 `src/data/repo/VolumeBuildParams.hpp`(贴近消费它的 `I3dSceneRepository`/`Api3dRepository`): +```cpp +struct VolumeBuildParams { + std::vector sourceDatasetIds; // 源数据集 id(≥1) + enum class Model { Idw, Kriging }; + Model interpModel = Model::Idw; + double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0; // interpParams + std::string colorScaleId; // 取哪个源的色阶(默认首个源) + double vmin = 0.0, vmax = 0.0; // 派生(缓存) + struct Measure { long points = 0; double volume = 0.0; } measure; // 派生(缓存) +}; +``` + +> **不冻结 gridSpec**(用户决策 2026-06-17):gridSpec 每次从源散点**确定性重算**(IDW 确定 + 源锁定 → 结果必然一致),不存为"冻结锚点"。 +> 切片/异常坐标的稳定性由**源 ds 锁定**不变式保证(见 §9),而非冻结派生网格。 +> 若三维体变了 ⇒ 源 ds 被动过 ⇒ 旧 gridSpec 本身已失效,冻结只会掩盖问题。 + +## 2. 共享管线:core::algo(解决 TODO 漂移) + +新增 `src/core/algo/VolumeBuilder.{hpp,cpp}`(纯 core,不碰 CRS): +```cpp +struct BuiltVolume { core::ScalarVolume vol; core::GridSpec spec; double vmin, vmax; }; +// 入参:世界局部米点集 + cell/power/maxDist。 +// 包络→GridSpec(角点对齐, clampDim)→IDW→ScalarVolume→vmin/vmax(优先外部色阶 stops,否则实测)。 +// 确定性:同点集同参数 → 同 GridSpec 同体(不需冻结,见计划 §1 决策)。 +BuiltVolume buildVolume(const core::PointSet& pts, double cellXY, double cellZ, + double power, double maxDist); +``` +- 把 `LocalSample3dRepository.cpp:95-137` 的「包络→GridSpec→IDW→vmin/vmax」逻辑提进来。 +- `LocalSample3dRepository::loadVolume` 改为调用它(行为不变,回归保护)。 +- `clampDim`/kMaxDim 一并移入。 + +## 3. Api3dRepository:体存储 + loadVolume + createVolume(已实现) + +构造注入:`Api3dRepository(IAsyncDatasetRepository& dsRepo, std::shared_ptr frame)`。 +- **只需 frame**(与帘面/底图同一共享 shared_ptr,含 reanchor);**不需 projectCrs/refElev**——因取源走 `loadSection` 的 `Grid`,其节点已带 lat/lon + 高程轴 g.y,无须 CRS 正变换。 + +内存 mock 存储(成员): +```cpp +struct StoredVolume { VolumeBuildParams params; std::string name; std::optional cachedGrid; }; +std::map volumes_; // dsId → 体 +int volumeCounter_ = 0; +``` + +新增公有方法(concrete,main.cpp 持具体指针调用): +- `std::string createVolume(VolumeBuildParams params, const std::string& name)`:生成 `vol-N` dsId、存入 `volumes_`、返回 id(不立即插值;插值在首次 loadVolume 惰性做 + 缓存)。 +- `std::vector volumeRows() const`:转 `DsRow{id, dsName=name, ddCode="dd_voxel", typeName="三维体"}`,供列表合并。 +- `bool isVolumeDataset(const std::string& dsId) const`:`volumes_.count`。 + +`loadVolume(dsId)` 实现(异步 N 源扇出,**复用 loadSection 保证与帘面同系对齐**): +1. 查 `volumes_[dsId]`;无 → onErr。 +2. 有 `cachedGrid` → 直接 onOk(明细命中)。 +3. 否则:对每个 sourceId 调 `this->loadSection(srcId)`(即 `inversion.grid` 路径),`shared_ptr` 聚合 N 个回调(全到齐再继续;任一 failed → 整体 onErr 一次)。 +4. `appendGridPoints`:对每个源 Grid 的**有限值**节点,按 CurtainActor 同口径定位 → + `frame.toLocal(g.lat[i], g.lon[i])` 作 (x,y);**世界 Z = g.y[j](高程)**;v = g.valueAt(i,j)。跳过 NaN 格(与帘面消隐一致)。 +5. 取首个到达源的色阶定 vmin/vmax(否则 buildVolume 数据实测)。 +6. `core::buildVolume(pts, cellXY/cellZ/power/maxDist)`(每次从源确定性重算 gridSpec)。 +7. `cachedGrid` 缓存;onOk(VolumeGrid)。 + +> 契约:onOk/onErr 主线程回调。`DetailLoad::done` 在主线程发,扇出聚合用主线程共享 `Agg`(无锁)。 +> 路径选择说明:弃用 `inversion.scatter` 端点(其 y=深度/z=高程语义是交接文档警告的坑); +> 改复用已正确的 `loadSection`(`inversion.grid`),与帘面**构造性对齐**,且免 CRS 正变换。 + +`I3dSceneRepository` 接口加纯虚 `virtual bool isVolumeDataset(const std::string& dsId) const = 0;`(LocalSample 恒 false)。 + +## 4. 控制器:按类型分流 curtain vs voxel + +`VtkSceneController::addDatasetAsync`(`VtkSceneController.cpp:49-90`)改: +- `if (sceneRepo_.isVolumeDataset(dsId))` → 走 loadVolume/addVolume 路径; +- `else` → 走 loadSection/addCurtain 路径。 +- 移除对全局 `showVoxel_`/`showCurtain_` 作为分流条件的依赖(保留成员或删,二者均不再 gating 单 ds 分流;图层开关语义后续再议)。 + +## 5. UI:Column3DDataset(源数据栏)多选 + 右键「生成三维体」 + +> 归属修正(2026-06-17,用户指出):**源选择**在三维数据集栏(剖面池);**生成的体**进**三维分析栏**(§7)。 + +`Column3DDataset.{hpp,cpp}`(源数据栏): +- 列表 `setSelectionMode(ExtendedSelection)`(多选高亮,独立于 checkbox 渲染勾选)。 +- `setContextMenuPolicy(CustomContextMenu)` + 槽:右键弹菜单「生成三维体」,仅当选中项 ≥1 且均为可作源的 ddCode(dd_section/dd_inversion_data)时启用。 +- 新信号 `void generateVolumeRequested(const QStringList& sourceDsIds);`(取选中项的 kDsIdRole)。 + +## 6. UI:插值参数对话框 + +新增 `src/app/VolumeParamsDialog.{hpp,cpp}`(QDialog,与现有对话框同放 app 根目录): +- 名称、插值模型(IDW;克里金项 disabled 占位)、cellXY/cellZ/power/maxDist(QDoubleSpinBox,默认同 §1)。 +- `accept` → `volumeName()` / `params()`(不含 sourceDatasetIds,由调用方填)。 + +## 7. 装配:main.cpp 接线(体→三维分析栏 + 两栏勾选聚合) + +- main.cpp 改 `Api3dRepository` 构造,传 `frame`(shared_ptr,§3 不需 projectCrs/refElev)。 +- **体归三维分析栏**:`lastAnalysisRows` 缓存后端 Analysis 行(dd_slice);`refreshAnalysis()` = `colAnalysis()->setDatasets(lastAnalysisRows + scene3dRepo->volumeRows())`。三维数据集栏(col3D)恢复直接 `setDatasets(b.dim3D)`(仅后端剖面,源池)。 +- **渲染勾选聚合**:`checkedProfiles`(col3D 勾选剖面) + `checkedAnalysis`(colAnalysis 勾选体/切片) → `pushChecked()` 并集下发 `setCheckedDatasets`(控制器全量 diff,必须并集,否则一栏清掉另一栏)。 +- 连接 `c3.generateVolumeRequested` → `VolumeParamsDialog` → 填 `params.sourceDatasetIds` → `scene3dRepo->createVolume` → `refreshAnalysis()`(新体出现在三维分析栏)。 +- 连接 `c3.checkedDatasetsChanged`→更新 checkedProfiles;`ca.checkedItemsChanged`→更新 checkedAnalysis;均 `pushChecked()`。 +- 后端加载回调:col3D 直接 `setDatasets(b.dim3D)`;analysis 经 `refreshAnalysis()`,保证体在测线重勾后仍驻留。 + +## 8. 阶段与验收(每阶段编译绿 = build.bat app) + +- **阶段 A(模型+管线+loadVolume)**:§1 §2 §3。可单测 `buildVolume`;loadVolume 单源/多源逻辑成形。无 UI,不可见但编译绿 + 回归 LocalSample 不变。**✅ 编译绿** +- **阶段 B(创建流 UI)**:§5 §6 §7。可见:数据集栏多选剖面→右键生成→参数→新体行出现在**三维分析栏**→勾选渲染体。**✅ 编译绿** +- **阶段 C(分流)**:§4。dd_voxel 渲染为体、dd_section 渲染为帘面,二者可共存。**✅ 编译绿** +- 验收:用户启动 app 实测(Claude 无法 GUI 验证 VTK,按交接铁律用 `build.bat app` 仅编译验证;渲染问题查 `%LOCALAPPDATA%\Geomative\Geopro3\logs`)。 + +## 9. 风险 / 待确认 + +- **散点端点对反演剖面是否真有 hlist(高程)**:若某些数据 z 为空,竖向退化。落地时加日志取证,必要时回退用 grid.elevation(loadSection 路径)。 +- **克里金**:UI 占位 disabled,仅 IDW 实现(设计 §1.1 列了克里金,但 core 仅 IdwInterpolator)。 +- **mock 持久化形态**:本期纯内存(重启丢失),符合设计 §5「次要待确认」;本地文件持久化留后续。 +- **源 ds 锁定不变式**(替代 gridSpec 冻结,用户决策):被三维体引用的源 ds **不可修改/删除**——保证 IDW 重算确定一致、切片/异常坐标稳定。 + - 本期:内存 mock,仅留 TODO(在源 ds 删除/编辑入口校验"是否被某三维体引用,是则禁止/告警")。 + - 推论:不冻结 gridSpec;若源真被改 ⇒ 体应失效重建,而非套旧锚点(旧锚点已是脏数据)。 diff --git a/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md b/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md new file mode 100644 index 0000000..14a32a6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-vtk-3d-anomaly.md @@ -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 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&)`(新增或改造 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`,实现上述。 + +## 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 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`);真实端点 `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,降耦合;可选接真实只读。 diff --git a/docs/superpowers/plans/2026-06-23-gpr-volume-poc.md b/docs/superpowers/plans/2026-06-23-gpr-volume-poc.md new file mode 100644 index 0000000..040828c --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-gpr-volume-poc.md @@ -0,0 +1,383 @@ +# GPR 三维体 POC(B & C 双方案)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 用真实 13G 雷达数据为 B(整卷上 GPU)与 C(分块+金字塔+核外)两套对等方案做 POC,验证技术可行性并挖出 spec 未预见的阻塞;POC 代码即生产地基与接口实现,不返工。 + +**Architecture:** 共用地基(解析/几何/结构化建体/int16 量化体/分块存储)+ `IVolumeRenderSource` 渲染接缝;`WholeVolumeSource`(B) 与 `OutOfCoreSource`(C) 是接口下两个永久并存实现,用户运行时按数据规模切换。落盘从第一天就分块,B 的裸分块格式是 C 金字塔/核外的基座。 + +**Tech Stack:** C++17, Qt6, VTK 9.6(`RenderingVolumeOpenGL2` GPU ray cast,自带 `vtkzlib`),GoogleTest,nlohmann-json(sidecar),现有 `src/{core,data,render,app}` 分层。 + +## Global Constraints + +- **dtype**:雷达体走 **int16**(`vtkShortArray`),不污染反演剖面的 double 主路径(`ScalarVolume` 保持 `std::vector`,`src/core/model/Field.hpp:8-26`)。 +- **量化**:物理值 ↔ int16 经 `scale/offset`,**必须贯穿**传递函数采样、色阶 LUT(`src/render/interact/SliceTool.cpp:37`)、取值/详情反量化(见 spec B §3.5)。 +- **落盘**:**不用 `vtkHDFWriter`**(VTK 9.6 写不了 `vtkImageData`,记忆 `vtk96-hdfwriter-no-imagedata`)。用裸 int16 分块 + sidecar(json) + 逐块 `vtkzlib`。 +- **渲染接缝**:上层(场景/切片)只面向 `IVolumeRenderSource`;B/C 是其两个实现。 +- **结构化建体**:X(沿线)/Z(深度) 规则落格,仅 Y(14 通道) 向 1D 插值;**不**对雷达用全 3D 散点 IDW(现有 `IdwInterpolator` 无空间索引暴力,`src/core/algo/IdwInterpolator.cpp:15-33`)。 +- **真实数据判定**:POC 用 `D:\Downloads\明星路`(450MHz/14通道/821采样/~45306道/线/20线/int16/13.6GB)。POC 过 = 在该真实数据上跑通并达标,不许避重就轻。 +- **测试数据头实证**:`.iprh` 文本键值(`SAMPLES/LAST TRACE/CHANNELS/TIMEWINDOW/SOIL VELOCITY/DISTANCE INTERVAL`);`.iprb` = `int16[samples × traces]`,`samples×traces×2 == 文件大小`;`.ord` = 通道横向偏移(14 有效)。 + +--- + +## 文件结构(决定分解与复用) + +``` +src/io/gpr/ ← 新增:雷达 IO(共用地基) + IprHeader.{hpp,cpp} 解析 .iprh → 结构体 + IprbReader.{hpp,cpp} 读 .iprb int16 B-scan(mmap/分块读) + GprGeometry.{hpp,cpp} .ord 通道偏移 + .gps/.cor 逐道经纬 + 深度轴 + GprSurvey.{hpp,cpp} 一个工区 = 线[]×通道[] + 几何(建体输入) +src/core/model/ + ScalarVolumeI16.hpp int16 体 + Quant{scale,offset} (新增,与 ScalarVolume 并列) +src/core/algo/ + GprVolumeBuilder.{hpp,cpp} 结构化建体:X/Z 落格 + Y 向 1D 插值 → ScalarVolumeI16 +src/data/store/ + ChunkedVolumeStore.{hpp,cpp} 分块 int16 + zlib + sidecar;读/写/按块取(B/C 共用,C 加金字塔) +src/render/source/ + IVolumeRenderSource.hpp 渲染接缝(接口) + WholeVolumeSource.{hpp,cpp} B:读全块 → 1 个 vtkImageData + OutOfCoreSource.{hpp,cpp} C:金字塔 + brick 分页 → 工作集 +src/render/actors/ + VoxelActor.cpp Modify:buildVoxel 增 int16 重载 + 量化域传函 +tests/io/gpr/ tests/core/ tests/data/store/ ← 对应测试 +tools/gpr_poc/ ← POC 度量台(建体/加载/显存/fps 探针 + CLI) +``` + +POC 度量统一进 `tools/gpr_poc`(建体耗时、输出维度、落盘体积/压缩比、加载耗时、显存、切片/体绘制 fps),B/C 用同一套指标对照。 + +> **POC vs 生产**:Task 1–6(地基)+ 7–8、10–11(接口/存储)是**生产代码,走 TDD、有完整代码**。Task 9、12–13 是**可行性探针**:给出明确实验、被测未知、通过/失败判据与度量,不预先杜撰我们正要验证的 VTK 核外内部实现——这是 POC 的本质,强行写"完整代码"等于造假。 + +--- + +## Phase 0 — 地基(共用,生产级 TDD) + +### Task 1: .iprh 头解析 + +**Files:** +- Create: `src/io/gpr/IprHeader.hpp`, `src/io/gpr/IprHeader.cpp` +- Test: `tests/io/gpr/test_ipr_header.cpp` + +**Interfaces:** +- Produces: `struct IprHeader { int samples; long lastTrace; int channels; double timeWindowNs; double soilVelocity; double distanceInterval; };` + `IprHeader parseIprHeader(const std::string& text);` + +- [ ] **Step 1: 写失败测试** +```cpp +#include "io/gpr/IprHeader.hpp" +#include +using geopro::io::gpr::parseIprHeader; +TEST(IprHeader, ParsesKeyFieldsFromRealSample) { + const std::string t = + "SAMPLES: 821\nLAST TRACE: 45305\nCHANNELS: 14\n" + "TIMEWINDOW: 160.352\nSOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.049084\n"; + auto h = parseIprHeader(t); + EXPECT_EQ(h.samples, 821); + EXPECT_EQ(h.lastTrace, 45305); + EXPECT_EQ(h.channels, 14); + EXPECT_DOUBLE_EQ(h.timeWindowNs, 160.352); + EXPECT_DOUBLE_EQ(h.soilVelocity, 100.0); + EXPECT_NEAR(h.distanceInterval, 0.049084, 1e-9); +} +``` +- [ ] **Step 2: 运行确认失败** — `ctest -R IprHeader`,预期 编译/链接失败(未定义)。 +- [ ] **Step 3: 最小实现** — `parseIprHeader` 逐行 `key: value` 拆分,按字段名填结构体;缺字段抛 `std::runtime_error`。 +- [ ] **Step 4: 运行确认通过** — `ctest -R IprHeader`,预期 PASS。 +- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): parse .iprh header fields"` + +### Task 2: .iprb B-scan 读取 + +**Files:** +- Create: `src/io/gpr/IprbReader.{hpp,cpp}` +- Test: `tests/io/gpr/test_iprb_reader.cpp` + +**Interfaces:** +- Consumes: `IprHeader` +- Produces: `struct BScan { int samples; long traces; std::vector data; /* [trace*samples + s] */ };` + `BScan readIprb(const std::string& path, const IprHeader& h);`(校验 `samples*traces*2 == fileSize`) + +- [ ] **Step 1: 写失败测试**(用临时文件造 4 道×3 采样 int16) +```cpp +TEST(IprbReader, ReadsInt16AndValidatesSize) { + // 写 tmp:samples=3, traces=4 → 24 bytes + std::vector raw{0,1,2, 10,11,12, 20,21,22, 30,31,32}; + auto path = writeTmp(raw); // helper + geopro::io::gpr::IprHeader h{}; h.samples=3; h.lastTrace=3; // traces=lastTrace+1=4 + auto b = geopro::io::gpr::readIprb(path, h); + EXPECT_EQ(b.samples, 3); EXPECT_EQ(b.traces, 4); + EXPECT_EQ(b.data[1*3 + 2], 12); // 第1道第2采样 +} +``` +- [ ] **Step 2: 运行确认失败**。 +- [ ] **Step 3: 最小实现** — `traces = lastTrace+1`;读全文件为 int16;不匹配大小抛错。 +- [ ] **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): read .iprb int16 b-scan with size check"` + +### Task 3: 几何(通道偏移 + 逐道经纬 + 深度轴) + +**Files:** +- Create: `src/io/gpr/GprGeometry.{hpp,cpp}` +- Test: `tests/io/gpr/test_gpr_geometry.cpp` + +**Interfaces:** +- Produces: + - `std::vector parseChannelXOffsets(const std::string& ordText);`(取第 4 列==1 的有效通道横偏,明星路应得 14 个 -0.686..+0.686) + - `double depthOfSample(int s, const IprHeader& h);`(`= s * (timeWindowNs/(samples-1)) * soilVelocity*1e-9/2`,单位米;soilVelocity 100 m/µs = 1e8 m/s) + +- [ ] **Step 1: 写失败测试** +```cpp +TEST(GprGeometry, ParsesActiveChannelOffsets) { + const std::string ord = "0 -0.686000 -1.5 1\n1 -0.581000 -1.5 1\n14 0 -1.5 0\n"; + auto xs = geopro::io::gpr::parseChannelXOffsets(ord); + EXPECT_EQ(xs.size(), 2u); // 仅 2 个有效(末列=1) + EXPECT_NEAR(xs[0], -0.686, 1e-6); +} +TEST(GprGeometry, DepthOfLastSampleMatchesPhysics) { + geopro::io::gpr::IprHeader h{}; h.samples=821; h.timeWindowNs=160.352; h.soilVelocity=1e8; + EXPECT_NEAR(geopro::io::gpr::depthOfSample(820, h), 8.0, 0.05); // ~8m +} +``` +> 注:`soilVelocity` 单位换算在 Task 1 读入时统一成 m/s(100 m/µs = 1e8 m/s),在此基础上测试。 +- [ ] **Step 2: 失败**。 +- [ ] **Step 3: 实现** — `.ord` 按空白拆列、末列=="1" 收集第 2 列;`depthOfSample` 按公式。 +- [ ] **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(gpr): channel offsets + depth axis geometry"` + +### Task 4: int16 量化体类型 + +**Files:** +- Create: `src/core/model/ScalarVolumeI16.hpp` +- Test: `tests/core/test_scalar_volume_i16.cpp` + +**Interfaces:** +- Produces: +```cpp +struct Quant { double scale = 1.0; double offset = 0.0; + int16_t toQ(double v) const; // round((v-offset)/scale),钳到[INT16_MIN+1,INT16_MAX] + double toPhys(int16_t q) const; }; // q*scale+offset +class ScalarVolumeI16 { // 行优先 idx=((k*ny+j)*nx+i),与 vtkImageData 一致 + ScalarVolumeI16(int nx,int ny,int nz); + int16_t& at(int i,int j,int k); int nx()const; ...; std::vector& data(); + static constexpr int16_t kBlank = INT16_MIN; }; // 空值哨兵→透明 +``` + +- [ ] **Step 1: 写失败测试**(量化往返 + 索引布局 + blank) +```cpp +TEST(ScalarVolumeI16, QuantRoundTripAndLayout) { + geopro::core::Quant q{0.5, -10.0}; + EXPECT_EQ(q.toQ(-10.0), 0); EXPECT_NEAR(q.toPhys(q.toQ(3.0)), 3.0, 0.25); + geopro::core::ScalarVolumeI16 v(2,2,2); + v.at(1,0,1) = 7; EXPECT_EQ(v.data()[(1*2+0)*2+1], 7); +} +``` +- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(core): int16 scalar volume + quantization"` + +### Task 5: 结构化建体 GprVolumeBuilder + +**Files:** +- Create: `src/core/algo/GprVolumeBuilder.{hpp,cpp}` +- Test: `tests/core/test_gpr_volume_builder.cpp` + +**Interfaces:** +- Consumes: `GprSurvey`(线×通道 BScan + 几何)、`GridSpec`(复用 `src/core/algo/IInterpolator.hpp:7-13`) +- Produces: `struct BuiltI16 { ScalarVolumeI16 vol; Quant quant; std::array origin, spacing; double vminPhys, vmaxPhys; };` + `BuiltI16 buildGprVolume(const GprSurvey& s, const GridSpec& spec);` +- **算法**:X(沿线)/Z(深度) 最近邻或线性落格(道已规则);**Y 向**对落在该 (x,z) 的 14 通道值做 1D 线性插值填充横向网格;maxDist/无覆盖 → `kBlank`。量化 scale/offset 由全体 min/max 定。 + +- [ ] **Step 1: 写失败测试**(2 通道、各 1 道×2 采样的人造 survey,验横向中点插值 + 维度) +```cpp +TEST(GprVolumeBuilder, InterpolatesAcrossChannelsOnly) { + auto s = makeTwoChannelSurvey(/*ch0 val=0, ch1 val=100, 横偏 0 和 1m*/); + geopro::core::GridSpec spec{/*nx=*/3,/*ny=*/1,/*nz=*/1, 0,0,0, 0.5,1,1, 2.0, 9.9}; + auto b = geopro::core::buildGprVolume(s, spec); + EXPECT_NEAR(b.quant.toPhys(b.vol.at(1,0,0)), 50.0, 1.0); // 横向中点≈50 +} +``` +- [ ] **Step 2: 失败** → **Step 3: 实现**(先单线程,循环结构留可并行)→ **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(core): structured GPR volume builder (Y-only interp)"` + +### Task 6: 分块存储 ChunkedVolumeStore(B/C 共用基座) + +**Files:** +- Create: `src/data/store/ChunkedVolumeStore.{hpp,cpp}` +- Test: `tests/data/store/test_chunked_volume_store.cpp` + +**Interfaces:** +- Produces: +```cpp +struct StoreMeta { int nx,ny,nz; int brick; // e.g. 64 + std::array origin, spacing; Quant quant; double vminPhys,vmaxPhys; }; +class ChunkedVolumeStore { + static void write(const std::string& dir, const BuiltI16& b, int brick=64); // 分块+zlib+sidecar.json + static StoreMeta readMeta(const std::string& dir); + std::vector readBrick(int bx,int by,int bz) const; // 解压单块 + // Task 10 追加:pyramid 层;Task 11 用 readBrick 做工作集 +}; +``` +- **格式**:`meta.json`(StoreMeta + 分块索引/偏移/压缩长度) + `data.bin`(逐块 zlib 压缩流)。zlib 用 VTK 自带 `vtkzlib` 或直接 zlib C API。 + +- [ ] **Step 1: 写失败测试**(write→readMeta→readBrick 往返 + 压缩后小于原始) +```cpp +TEST(ChunkedVolumeStore, RoundTripBrickAndCompresses) { + auto b = makeBuilt(128,128,128, /*可压缩模式*/); + geopro::data::ChunkedVolumeStore::write(tmpDir, b, 64); + auto m = geopro::data::ChunkedVolumeStore::readMeta(tmpDir); + EXPECT_EQ(m.nx, 128); EXPECT_EQ(m.brick, 64); + geopro::data::ChunkedVolumeStore s(tmpDir); + auto blk = s.readBrick(0,0,0); + EXPECT_EQ(blk.size(), 64u*64*64); + EXPECT_LT(fileSize(tmpDir+"/data.bin"), 128u*128*128*2); // 压缩生效 +} +``` +- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(data): chunked int16 volume store (zlib + sidecar)"` + +### Task 7: VoxelActor int16 重载 + 量化域传递函数 + +**Files:** +- Modify: `src/render/actors/VoxelActor.cpp`(增 int16 重载,参照现 double 版 `:41-79`) +- Test: `tests/render/test_voxel_i16_smoke.cpp`(无窗渲染冒烟 / 或断言 image 标量类型与传函控制点在量化域) + +**Interfaces:** +- Produces: `vtkSmartPointer buildVoxelI16(const ScalarVolumeI16& vol, const Quant& q, const ColorScale& cs, double ox,..,dz, vtkSmartPointer& outImage);` +- **要点**:`vtkShortArray` 填值;传函/不透明度在**量化域 qmin/qmax** 加点(`q.toQ(vminPhys)`..`q.toQ(vmaxPhys)`),`kBlank→0` 不透明;`vtkSmartVolumeMapper`。 + +- [ ] **Step 1: 写失败测试**(构造小 int16 体,断言 `outImage->GetScalarType()==VTK_SHORT` 且传函在量化域采样不崩)。 +- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(render): int16 voxel actor with quantized transfer fn"` + +--- + +## Phase 1 — POC-B(整卷上 GPU) + +### Task 8: IVolumeRenderSource + WholeVolumeSource(B) + +**Files:** +- Create: `src/render/source/IVolumeRenderSource.hpp`, `src/render/source/WholeVolumeSource.{hpp,cpp}` +- Test: `tests/render/test_whole_volume_source.cpp` + +**Interfaces:** +- Produces: +```cpp +class IVolumeRenderSource { public: virtual ~IVolumeRenderSource()=default; + virtual StoreMeta meta() const = 0; + virtual void update(const Camera& cam) = 0; // B:首次载全量;C:按相机换块 + virtual std::vector> currentProps() const = 0; // B:1 个;C:工作集 + virtual vtkImageData* sliceSource() const = 0; }; // 供 SliceTool reslice +class WholeVolumeSource : public IVolumeRenderSource { // 读全块拼 1 个 int16 vtkImageData + explicit WholeVolumeSource(const std::string& storeDir); }; +``` + +- [ ] **Step 1: 写失败测试**(从 Task 6 写出的 store 构造 WholeVolumeSource,`currentProps().size()==1`,维度==meta)。 +- [ ] **Step 2: 失败** → **Step 3: 实现**(遍历所有 brick 填进整卷 image)→ **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(render): IVolumeRenderSource + whole-volume source (B)"` + +### Task 9: POC-B 真实数据度量(探针,含通过判据) + +**Files:** +- Create: `tools/gpr_poc/main.cpp`(CLI:`gpr_poc build|renderB <明星路目录>`)、`tools/gpr_poc/Probe.{hpp,cpp}`(计时/显存/fps) +- 复用:Task 1–8 全部。 + +**被验证的未知 / 阻塞点:** ①int16 GPU ray cast 在真机正常出图;②真实建体耗时与输出体积;③整卷 5~10GB 加载耗时、显存峰值;④切片拖动 fps、体绘制 fps;⑤超显存时 `vtkSmartVolumeMapper` 的 `LowResResample`(`MaxMemoryInBytes`) 自动降质观感。 + +- [ ] **Step 1:** `gpr_poc build 明星路` → 跑 Task 1–6,输出:建体耗时、`nx×ny×nz`、落盘体积、压缩比。记录。 +- [ ] **Step 2:** `gpr_poc renderB` → WholeVolumeSource 上 `vtkSmartVolumeMapper`,量化传函着色,真窗口显示。 +- [ ] **Step 3:** Probe 采集:加载耗时、显存峰值、切片拖动 fps(脚本化相机/切片移动)、体绘制旋转 fps。 +- [ ] **Step 4:** 设 `MaxMemoryInBytes` 低于体大小,验证 `LowResResample` 自动降质路径出图。 +- [ ] **Step 5:** 写 `docs/superpowers/plans/poc-results-B.md`:指标表 + 结论。 +- **B 通过判据**:真实数据建体可完成且体积/耗时可接受;整卷在目标显存内出图;**切片拖动 ≥ 可用帧率(目标 ≥30fps)**;超显存时 LowRes 兜底可用。任一硬阻塞(如 GPU 不吃 short、显存必爆且 LowRes 不可接受)→ 记为 B 的落地风险并反馈 spec。 +- [ ] **Step 6: 提交** — `git commit -m "test(gpr): POC-B real-data metrics harness + results"` + +--- + +## Phase 2 — POC-C(分块+金字塔+核外,含最小真实分页器) + +### Task 10: 金字塔生成(ChunkedVolumeStore 增量) + +**Files:** +- Modify: `src/data/store/ChunkedVolumeStore.{hpp,cpp}`(加 LOD 层 + 每块 min/max) +- Test: `tests/data/store/test_pyramid.cpp` + +**Interfaces:** +- Produces: `void buildPyramid(int levels);`(逐级 2× 降采样,每层独立分块 + 每块 `int16 min,max` 存 meta);`std::vector readBrick(int level,int bx,int by,int bz);`;`std::pair brickRange(int level,int bx,int by,int bz);` + +- [ ] **Step 1: 写失败测试**(建 1 层金字塔,断言 level1 维度≈半、brickRange 命中真实 min/max)。 +- [ ] **Step 2: 失败** → **Step 3: 实现** → **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(data): volume pyramid + per-brick min/max"` + +### Task 11: brick 分页器(LRU 工作集,生产级 TDD) + +**Files:** +- Create: `src/render/source/BrickPager.{hpp,cpp}` +- Test: `tests/render/test_brick_pager.cpp` + +**Interfaces:** +- Produces: +```cpp +class BrickPager { // 内存恒定:驻留 ≤ budgetBricks 个解压块 + BrickPager(const ChunkedVolumeStore& store, size_t budgetBricks); + void requestVisible(const std::vector& visible, int level); // 载入缺失、LRU 淘汰 + const std::vector* get(BrickId id, int level) const; // 命中返回,未命中 nullptr + size_t residentCount() const; }; +``` + +- [ ] **Step 1: 写失败测试**(budget=4,请求 6 块 → residentCount==4,最早的被淘汰,命中/未命中正确)。 +- [ ] **Step 2: 失败** → **Step 3: 实现**(LRU + 从 store 解压载入)→ **Step 4: 通过**。 +- [ ] **Step 5: 提交** — `git commit -m "feat(render): bounded-memory brick pager (LRU)"` + +### Task 12: OutOfCoreSource(C)— 最高风险探针:核外体绘制 + +**Files:** +- Create: `src/render/source/OutOfCoreSource.{hpp,cpp}`(实现 `IVolumeRenderSource`) +- 复用:Task 10/11。 + +**被验证的未知 / 阻塞点(C 的命门,必须正面撞):** +1. **VTK 能否渲染"动态换入换出的块工作集"为体**——把 BrickPager 的工作集作为多个 `vtkImageData` 喂给 `vtkMultiBlockVolumeMapper`(注意其"试图全量加载"语义,须只喂视野块)或多个 `vtkVolume` 叠加;验证可行性与正确性。 +2. **块边接缝**:相邻 brick 渲染交界是否可见;试 `vtkMultiBlockVolumeMapper` 的抖动是否压得住。 +3. **LOD 切换**:相机拉远用粗层、拉近换细层,切换是否闪烁/可接受。 +4. **热路径解压**:拖动每帧换块时 zlib 解压是否拖垮帧率(CPU 瓶颈)。 + +> 本任务**不预写完整实现**——它就是要发现上面四点的真实结论。步骤是受控实验,产物是"能跑的最小版本 + 实测结论"。 + +- [ ] **Step 1:** `update(cam)` 内:算视野相交 brick + 选 LOD → `BrickPager::requestVisible` → `currentProps()` 返回工作集 image。 +- [ ] **Step 2:** 用 `vtkMultiBlockVolumeMapper`(或 N×`vtkVolume`)渲染工作集,量化传函复用 Task 7。先静态相机出图,确认正确。 +- [ ] **Step 3:** 接相机移动 → 动态换块;Probe 测 residentCount/内存恒定、换块 fps。 +- [ ] **Step 4:** 逐项记录未知 1–4 的实测结论(接缝截图、LOD 切换录屏指标、解压占帧时间)。 +- [ ] **Step 5:** 写 `poc-results-C.md`:四个未知的结论 + 是否构成阻塞 + 缓解手段。 +- **C 通过判据**:工作集体绘制能正确出图且**内存恒定**;接缝/闪烁/解压三项**各自有可接受方案或明确缓解**(不可接受则记为阻塞并反馈 spec C,这正是 POC 的目的)。 +- [ ] **Step 6: 提交** — `git commit -m "test(gpr): POC-C out-of-core volume render probe + results"` + +### Task 13: 切片核外 + B/C 切换贯通 + +**Files:** +- Modify: `src/render/source/OutOfCoreSource.cpp`(`sliceSource()`:只读切面相交块拼子体供 reslice) +- 复用现有 `SliceTool`(`src/render/interact/SliceTool.cpp`)对 source 给出的 image 切片。 + +**被验证的未知:** 切片只读相交块时的内存/fps;同一 `IVolumeRenderSource` 下 B↔C 运行时切换无缝。 + +- [ ] **Step 1:** `OutOfCoreSource::sliceSource()` 按当前切面算相交 brick → 拼最小子体 image。 +- [ ] **Step 2:** `SliceTool` 对该 image reslice,拖动切片测 fps、内存。 +- [ ] **Step 3:** POC 台加 `renderC` 与 `--source whole|ooc` 开关,验证同一上层代码切两实现。 +- [ ] **Step 4:** 补 `poc-results-C.md`:切片核外指标 + 切换验证。 +- [ ] **Step 5: 提交** — `git commit -m "feat(render): slice out-of-core + runtime B/C source switch"` + +--- + +## Self-Review(对照 spec 检查) + +**1. spec 覆盖** +- spec B:int16 路径(Task 4/7)、结构化建体(Task 5)、裸分块落盘(Task 6)、量化贯穿(Task 4/7/Global)、整卷渲染(Task 8/9)、LowResResample(Task 9) ✓ +- spec C:分块(Task 6)、金字塔+min/max(Task 10)、brick 分页(Task 11)、核外体绘制(Task 12)、切片核外(Task 13)、B/C 切换(Task 13) ✓ +- 共有地基:.iprb/.iprh 解析(Task 1/2)、几何/配准(Task 3)、GPR→体(Task 5) ✓ +- **缺口(POC 不覆盖,明确记录)**:ddCode 接入数据集树/UI、后端持久化对接、异常/色阶编辑器接线——POC 阶段不做(spec A §2 / B §6 列为成品阶段),不影响复用。 + +**2. 占位符扫描**:地基任务(1–8,10,11)均有完整测试代码与实现描述;POC 探针(9,12,13)按设计**有意不写杜撰实现**,代之以受控实验 + 通过判据(已在任务内注明本质)。无 "TODO/TBD/适当处理" 类空话。 + +**3. 类型一致性**:`ScalarVolumeI16`/`Quant`/`BuiltI16`/`StoreMeta`/`IVolumeRenderSource`/`BrickPager` 在定义任务与消费任务间签名一致;`buildGprVolume`/`buildVoxelI16`/`ChunkedVolumeStore::{write,readMeta,readBrick}`/`requestVisible` 全程同名。 + +--- + +## 关键风险(开工即知) + +- **Task 12 是月级生产工程的"最小探针"**:POC 只需证可行 + 撞出阻塞,不追求生产质量分页器;但若未知 1(VTK 渲染动态工作集)撞墙,是 C 的根本阻塞,须立刻反馈 spec C 并评估替代(OpenVDS / 自建 GL)。 +- **真实 13G 在合理分辨率下可能装得进显存** → B 顺过、C 的核外价值要靠"细分辨率/拼全路段大体"的真实配置才能压出(Task 9/12 的网格参数留 CLI 可调,用同一份真实数据调出超显存体来考验 C)。 +- **量化贯穿**漏一处即色阶/读数错(Global 约束 + Task 4/7),Self-Review 已盯。 diff --git a/docs/superpowers/plans/2026-06-24-vtk-category-view-refactor.md b/docs/superpowers/plans/2026-06-24-vtk-category-view-refactor.md new file mode 100644 index 0000000..e93fbf5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-vtk-category-view-refactor.md @@ -0,0 +1,1127 @@ +# VTK 三维分类视图重构 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 把 VTK 视图左侧从「三维数据集/二维数据集/三维分析」三 tab 重构为「按数据类型大类分组」的两 tab 视图,并改造对象树联动、装置类型筛选、VTK 画布工具条、请求体 DTO 组装。 + +**Architecture:** 自底向上分层:先建纯逻辑/服务层(DsRow 扩展、分类映射表 splitByCategory、请求体 DTO、装置字典服务、对象树三态状态机),全部 GoogleTest 单测;再建 UI 层(CategorySection 段组件、QScrollArea 容器、VtkViewToolbar 工具条),靠 cmake build + 手动验证;最后在 main.cpp 总成接线。沿用现有信号槽 + 仓储抽象 + DatasetListPanel 复用,退役 Column3DDataset/Column3DAnalysis。 + +**Tech Stack:** C++17 / Qt6(Widgets)/ VTK / CMake + CTest + GoogleTest。 + +设计依据:`docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md`(spec 各节在任务中以「spec §N」引用)。 + +## Global Constraints + +- C++17;类型 PascalCase、方法 camelCase、成员 `snake_case_` 尾下划线(随现有 `src/` 风格)。 +- 不可变优先;UI 组件单一职责、文件 <800 行。 +- 所有新增逻辑/服务必须有 GoogleTest 单测(`tests/` 下,CMake 注册);UI 组件以「cmake build 通过 + 现有 ctest 全绿 + 手动验证清单」为验收。 +- 后端基址 `http://tenant.geomative.cn/pop-api`;响应信封 `{code,msg,data}`,`code==200` 成功,列表在 `data.value`/`data.list`。 +- 大类分类键:电阻率=`ERT platform inversion data`、视电阻率=`visual resistivity data`、瞬变电磁=`DD TRANSIENT ELECTROMAGNETIC INVERSION`(dsTypeCode,三者 ddCode 同为 `dd_inversion_data`);三维体=`dd_voxel`、切片=`dd_slice`(ddCode)。 +- 层级 `structParentConfType`:1=GS/项目根,2=TM。 +- 装置类型只电阻率/视电阻率段有,瞬变电磁/三维体/切片无。 +- 三维体/切片/异常仍走 `Api3dRepository` mock(不切真实后端)。 +- 提交信息用 conventional commits(feat/refactor/test/docs);署名全局已禁用,勿加。 +- 构建/测试命令(Windows):配置 `cmake --preset ` 后,`cmake --build build` 构建、`ctest --test-dir build --output-on-failure -R ` 跑指定测试。若仓库用 `build.bat`,按其 rebuild。先确认 `tests/` 现有用例如何注册(参考 `tests/data/test_nav_dto.cpp` 与对应 `CMakeLists.txt`)。 + +--- + +## 文件结构(决策锁定) + +**新建:** +- `src/data/repo/CategoryConfig.hpp` — 大类映射表 + 段元数据(纯数据/纯函数)。 +- `src/app/DatasetCategory.hpp` / `.cpp` — `splitByCategory`(替代 `DatasetDimension` 的 `splitByDimension`)。 +- `src/data/dto/Vtk3dRequests.hpp` / `.cpp` — `VoxelGenerateRequest` / `SliceGenerateRequest` + `toJson`。 +- `src/data/repo/DatasetFieldDictionary.hpp` / `.cpp` — 按 dsType 缓存 `dynamicForm` 的 confFieldId↔fieldCode 映射 + 装置类型 value→中文字典。 +- `src/app/panels/columns/CategorySection.hpp` / `.cpp` — 单个类型段(段头筛选/操作 + 段体树)。 +- `src/app/panels/columns/CategoryAnalysisTab.hpp` / `.cpp` — 「三维分析」tab 容器(QScrollArea 堆叠 5 段)。 +- `src/app/VtkViewToolbar.hpp` / `.cpp` — VTK 画布竖排工具条。 +- `src/app/AxesSettingsDialog.hpp` / `.cpp` — 坐标轴设置对话框。 +- 测试:`tests/data/test_dataset_category.cpp`、`tests/data/test_vtk3d_requests.cpp`、`tests/data/test_dataset_field_dictionary.cpp`;扩 `tests/data/test_nav_dto.cpp`。 + +**修改:** +- `src/data/repo/RepoTypes.hpp` — `DsRow` 加 `dsTypeCode` + `properties`(原始 KV)。 +- `src/data/dto/NavDto.cpp` — `parseDsRows` 解析新字段。 +- `src/app/panels/ObjectTreePanel.{hpp,cpp}` — GS 三态状态机(停 AutoTristate)+ 右键 ds/tm + `checkedSourcesChanged` 信号。 +- `src/data/repo/RepoTypes.hpp` 或新文件 — `DataSource{id,confType}` 类型。 +- `src/app/panels/columns/ColumnDrawer.{hpp,cpp}` — 三 tab → 两 tab。 +- `src/data/api/Api3dRepository.{hpp,cpp}` — `createVolume/createSlice` 扩参 + DTO 组装。 +- `src/app/main.cpp` — 数据流接线(分流拉取/分类分发/勾选并集/生成入口归属)。 +- 相关 `CMakeLists.txt`(app 与 tests)。 + +**退役(功能迁出后删除引用):** `Column3DDataset`、`Column3DAnalysis`(拆分到 `CategorySection` / `VtkViewToolbar` / `AxesSettingsDialog` / 三维体段)。 + +**依赖顺序:** Phase 1(数据模型+分类)→ Phase 2(DTO)→ Phase 3(对象树)→ Phase 4(字典服务)→ Phase 5(段+容器)→ Phase 6(工具条)→ Phase 7(Api3d 扩参+段重组)→ Phase 8(main 接线总成)。 + +--- + +## Phase 1 — 数据模型与分类层 + +### Task 1: DsRow 扩展 + parseDsRows 解析 + +**Files:** +- Modify: `src/data/repo/RepoTypes.hpp`(`DsRow` 结构) +- Modify: `src/data/dto/NavDto.cpp:116-137`(`parseDsRows`) +- Test: `tests/data/test_nav_dto.cpp`(新增用例) + +**Interfaces:** +- Produces: + - `struct DsPropKV { std::string confFieldId, value; };` + - `DsRow` 新成员:`std::string dsTypeCode;`、`std::vector properties;` + - `parseDsRows` 填充上述字段(其余字段不变)。 + +- [ ] **Step 1: 写失败测试** — 在 `tests/data/test_nav_dto.cpp` 增用例(喂带 `dsTypeCode` + `properties` 数组的 ds 行 JSON): + +```cpp +TEST(NavDtoTest, ParseDsRowsExtractsTypeCodeAndProperties) { + const QString json = R"({"list":[{ + "id":"d1","dsName":"ERT1-WS","name":"电阻率数据", + "ddCode":"dd_inversion_data","dsTypeCode":"ERT platform inversion data", + "createTime":"2026-03-25 16:48:57","structParentId":"tm1","structParentConfType":2, + "properties":[ + {"confFieldId":"1450495001706500","value":"1429468249448449"}, + {"confFieldId":"1455083478786048","value":"2026-03-25 16:48:57"} + ] + }]})"; + const QJsonObject data = QJsonDocument::fromJson(json.toUtf8()).object(); + const auto page = geopro::data::dto::parseDsPage(data); + ASSERT_EQ(page.rows.size(), 1u); + const auto& r = page.rows[0]; + EXPECT_EQ(r.dsTypeCode, "ERT platform inversion data"); + EXPECT_EQ(r.structParentId, "tm1"); + EXPECT_EQ(r.structParentConfType, 2); + EXPECT_EQ(r.ddCode, "dd_inversion_data"); + ASSERT_EQ(r.properties.size(), 2u); + EXPECT_EQ(r.properties[0].confFieldId, "1450495001706500"); + EXPECT_EQ(r.properties[0].value, "1429468249448449"); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `ctest --test-dir build --output-on-failure -R NavDtoTest` +Expected: 编译失败(`dsTypeCode`/`properties` 成员不存在)。 + +- [ ] **Step 3: 扩 DsRow** — `src/data/repo/RepoTypes.hpp` 在 `DsRow` 上方加 KV 结构、`DsRow` 内加两成员: + +```cpp +// ds 属性键值(data/page 的 properties[] 项:confFieldId→value 原始对)。 +struct DsPropKV { std::string confFieldId, value; }; + +struct DsRow { + std::string id, dsName, typeName, ddCode, createTime; + std::string parentId; + std::string fileName, fileUrl; + long long fileSize = 0; + std::string dsTypeCode; // 大类分类主键(spec §5;ddCode 粒度不足以区分电阻率/视电阻率) + std::vector properties; // 原始 confFieldId→value;装置类型/采集时间经 DatasetFieldDictionary 解析 + std::string structParentId; // 上级节点 id(段体容器分组 + 生成三维体归属用) + int structParentConfType = 0; // 1=GS/项目根 2=TM +}; +``` + +- [ ] **Step 4: 扩 parseDsRows** — `src/data/dto/NavDto.cpp` 在 `d.ddCode = str(o, "ddCode");` 后加: + +```cpp + d.dsTypeCode = str(o, "dsTypeCode"); + d.structParentId = str(o, "structParentId"); + d.structParentConfType = o.value(QStringLiteral("structParentConfType")).toInt(); + const QJsonArray props = o.value(QStringLiteral("properties")).toArray(); + d.properties.reserve(static_cast(props.size())); + for (const QJsonValue& pv : props) { + const QJsonObject po = pv.toObject(); + d.properties.push_back( + {str(po, "confFieldId"), po.value(QStringLiteral("value")).toVariant().toString().toStdString()}); + } +``` + +> 注:`value` 用 `toVariant().toString()` 兼容字符串/数值/时间(与 `parseDynamicForm:208` 同口径)。`properties` 也可能是对象(接地电阻/文件型 ds,§3.2 实测),此处 `.toArray()` 对非数组安全返回空——分类不依赖这些类型的 properties,可接受。 + +- [ ] **Step 5: 跑测试确认通过** + +Run: `ctest --test-dir build --output-on-failure -R NavDtoTest` +Expected: PASS(全部 NavDtoTest 用例)。 + +- [ ] **Step 6: 提交** + +```bash +git add src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp tests/data/test_nav_dto.cpp +git commit -m "feat(data): DsRow 加 dsTypeCode/properties + parseDsRows 解析" +``` + +--- + +### Task 2: CategoryConfig 映射表 + splitByCategory + +**Files:** +- Create: `src/data/repo/CategoryConfig.hpp` +- Create: `src/app/DatasetCategory.hpp` / `src/app/DatasetCategory.cpp` +- Test: `tests/data/test_dataset_category.cpp` +- Modify: `src/app/CMakeLists.txt`(加 DatasetCategory.cpp)、`tests/.../CMakeLists.txt`(注册测试) + +**Interfaces:** +- Consumes: `DsRow`(Task 1)。 +- Produces: + - `struct CategorySpec { std::string id, title, dsTypeCode, ddCode; bool canGenerateVolume; bool hasArrayTypeFilter; };` + - `const std::vector& categoryConfigs();`(5 段有序) + - `struct CategoryBuckets { std::vector> segments; };`(与 configs 同序、同长) + - `CategoryBuckets splitByCategory(const std::vector& rows);` + +- [ ] **Step 1: 写失败测试** — `tests/data/test_dataset_category.cpp`: + +```cpp +#include +#include "DatasetCategory.hpp" +using geopro::data::DsRow; +using namespace geopro::app; + +static DsRow row(const std::string& id, const std::string& ddCode, const std::string& dsTypeCode) { + DsRow r; r.id = id; r.ddCode = ddCode; r.dsTypeCode = dsTypeCode; return r; +} + +TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) { + std::vector rows = { + row("a", "dd_inversion_data", "ERT platform inversion data"), // 电阻率 + row("b", "dd_inversion_data", "visual resistivity data"), // 视电阻率 + row("c", "dd_inversion_data", "DD TRANSIENT ELECTROMAGNETIC INVERSION"), // 瞬变 + row("v", "dd_voxel", ""), // 三维体(按 ddCode) + row("s", "dd_slice", ""), // 切片(按 ddCode) + row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃 + }; + const CategoryBuckets b = splitByCategory(rows); + ASSERT_EQ(b.segments.size(), categoryConfigs().size()); + EXPECT_EQ(b.segments[0].size(), 1u); EXPECT_EQ(b.segments[0][0].id, "a"); + EXPECT_EQ(b.segments[1].size(), 1u); EXPECT_EQ(b.segments[1][0].id, "b"); + EXPECT_EQ(b.segments[2].size(), 1u); EXPECT_EQ(b.segments[2][0].id, "c"); + EXPECT_EQ(b.segments[3].size(), 1u); EXPECT_EQ(b.segments[3][0].id, "v"); + EXPECT_EQ(b.segments[4].size(), 1u); EXPECT_EQ(b.segments[4][0].id, "s"); + // 接地电阻不进任何段 + std::size_t total = 0; for (auto& s : b.segments) total += s.size(); + EXPECT_EQ(total, 5u); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `ctest --test-dir build --output-on-failure -R SplitByCategory` +Expected: 编译失败(头文件/符号缺失)。 + +- [ ] **Step 3: 写 CategoryConfig.hpp** + +```cpp +#pragma once +#include +#include + +namespace geopro::app { + +// 一个数据类型大类段的配置(spec §5)。识别键二选一:dsTypeCode 优先;ddCode 用于三维体/切片。 +struct CategorySpec { + std::string id; // 段稳定 id + std::string title; // 段标题(UI 显示) + std::string dsTypeCode; // 主识别键(空=不按 dsTypeCode) + std::string ddCode; // 次识别键(dd_voxel/dd_slice;空=不按 ddCode) + bool canGenerateVolume; // 段内 GS/项目根是否提供「生成三维体」 + bool hasArrayTypeFilter; // 段头是否显示装置类型筛选(仅 ERT 类) +}; + +// 5 段固定有序(spec §5 表)。 +inline const std::vector& categoryConfigs() { + static const std::vector kCfg = { + {"resistivity", "电阻率数据", "ERT platform inversion data", "", true, true}, + {"apparent", "视电阻率数据", "visual resistivity data", "", true, true}, + {"transient", "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "", true, false}, + {"voxel", "三维体", "", "dd_voxel", false, false}, + {"slice", "切片", "", "dd_slice", false, false}, + }; + return kCfg; +} + +} // namespace geopro::app +``` + +- [ ] **Step 4: 写 DatasetCategory.hpp / .cpp** + +`DatasetCategory.hpp`: +```cpp +#pragma once +#include +#include "repo/RepoTypes.hpp" +#include "CategoryConfig.hpp" + +namespace geopro::app { + +struct CategoryBuckets { + std::vector> segments; // 与 categoryConfigs() 同序同长 +}; + +// 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配; +// 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。 +CategoryBuckets splitByCategory(const std::vector& rows); + +} // namespace geopro::app +``` + +`DatasetCategory.cpp`: +```cpp +#include "DatasetCategory.hpp" + +namespace geopro::app { + +CategoryBuckets splitByCategory(const std::vector& rows) { + const auto& cfg = categoryConfigs(); + CategoryBuckets b; + b.segments.resize(cfg.size()); + for (const auto& r : rows) { + int hit = -1; + // 先按 ddCode(三维体/切片)——它们无 dsTypeCode(来自 Api3dRepository mock 行)。 + for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) + if (!cfg[i].ddCode.empty() && r.ddCode == cfg[i].ddCode) hit = static_cast(i); + // 再按 dsTypeCode。 + for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) + if (!cfg[i].dsTypeCode.empty() && r.dsTypeCode == cfg[i].dsTypeCode) hit = static_cast(i); + if (hit >= 0) b.segments[static_cast(hit)].push_back(r); + } + return b; +} + +} // namespace geopro::app +``` + +- [ ] **Step 5: 注册到 CMake** — 把 `src/app/DatasetCategory.cpp` 加入 app 目标源;把 `tests/data/test_dataset_category.cpp` 按 `test_dataset_dimension`/`test_nav_dto` 同样式注册(确认现有测试在哪个 CMakeLists、用何宏注册,照抄)。 + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure -R SplitByCategory` +Expected: PASS。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/CategoryConfig.hpp src/app/DatasetCategory.hpp src/app/DatasetCategory.cpp tests/data/test_dataset_category.cpp src/app/CMakeLists.txt tests/data/CMakeLists.txt +git commit -m "feat(app): CategoryConfig 映射表 + splitByCategory 按 dsTypeCode 分大类" +``` + +--- + +## Phase 2 — 请求体 DTO + +### Task 3: VoxelGenerateRequest / SliceGenerateRequest + toJson + +**Files:** +- Create: `src/data/dto/Vtk3dRequests.hpp` / `.cpp` +- Test: `tests/data/test_vtk3d_requests.cpp` +- Modify: `src/data/CMakeLists.txt`(或 app)、tests CMake + +**Interfaces:** +- Produces(对齐 `docs/api/vtk-3d-openapi.json` schema): + - `struct VoxelGenerateRequest { std::string projectId, structParentId; int structParentConfType=1; std::string name; std::vector sourceDatasetIds; std::string interpModel="Idw"; double cellXY=1.0,cellZ=0.5,power=2.0,maxDist=4.0; std::string colorScaleId; QJsonObject toJson() const; };` + - `struct SliceGenerateRequest { std::string projectId, volumeDsId, name; int axis=3; std::array origin{},point1{},point2{}; std::string colorScaleId; QJsonObject toJson() const; };` + +- [ ] **Step 1: 写失败测试** — `tests/data/test_vtk3d_requests.cpp`: + +```cpp +#include +#include +#include "dto/Vtk3dRequests.hpp" +using namespace geopro::data; + +TEST(Vtk3dRequests, VoxelToJsonMatchesContract) { + VoxelGenerateRequest q; + q.projectId = "p1"; q.structParentId = "g1"; q.structParentConfType = 1; + q.name = "体A"; q.sourceDatasetIds = {"d1", "d2"}; + const QJsonObject j = q.toJson(); + EXPECT_EQ(j["projectId"].toString(), "p1"); + EXPECT_EQ(j["structParentId"].toString(), "g1"); + EXPECT_EQ(j["structParentConfType"].toInt(), 1); + EXPECT_EQ(j["name"].toString(), "体A"); + ASSERT_TRUE(j["sourceDatasetIds"].isArray()); + EXPECT_EQ(j["sourceDatasetIds"].toArray().size(), 2); + EXPECT_EQ(j["interpModel"].toString(), "Idw"); + EXPECT_DOUBLE_EQ(j["cellXY"].toDouble(), 1.0); +} + +TEST(Vtk3dRequests, SliceToJsonMatchesContract) { + SliceGenerateRequest q; + q.projectId = "p1"; q.volumeDsId = "v1"; q.name = "切片1"; q.axis = 3; + q.origin = {0, 0, -10}; q.point1 = {100, 0, -10}; q.point2 = {0, 50, -10}; + const QJsonObject j = q.toJson(); + EXPECT_EQ(j["volumeDsId"].toString(), "v1"); + EXPECT_EQ(j["axis"].toInt(), 3); + ASSERT_TRUE(j["origin"].isArray()); + EXPECT_EQ(j["origin"].toArray().size(), 3); + EXPECT_DOUBLE_EQ(j["point1"].toArray()[0].toDouble(), 100.0); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `ctest --test-dir build --output-on-failure -R Vtk3dRequests` +Expected: 编译失败(头缺失)。 + +- [ ] **Step 3: 写 Vtk3dRequests.hpp** + +```cpp +#pragma once +#include +#include +#include +#include + +namespace geopro::data { + +// 对齐 docs/api/vtk-3d-openapi.json VoxelGenerateRequest(spec §8 请求体组装)。 +struct VoxelGenerateRequest { + std::string projectId; + std::string structParentId; // GS/项目根容器节点 id + int structParentConfType = 1; // 1=GS/项目根 + std::string name; + std::vector sourceDatasetIds; + std::string interpModel = "Idw"; // Idw|Kriging + double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0; + std::string colorScaleId; // 空=取首源色阶 + QJsonObject toJson() const; +}; + +// 对齐 SliceGenerateRequest。 +struct SliceGenerateRequest { + std::string projectId; + std::string volumeDsId; // 所属三维体 dsObjectId + std::string name; + int axis = 3; // 0上下/1前后/2左右/3任意 + std::array origin{{0, 0, 0}}; + std::array point1{{0, 0, 0}}; + std::array point2{{0, 0, 0}}; + std::string colorScaleId; + QJsonObject toJson() const; +}; + +} // namespace geopro::data +``` + +- [ ] **Step 4: 写 Vtk3dRequests.cpp** + +```cpp +#include "dto/Vtk3dRequests.hpp" +#include +#include + +namespace geopro::data { + +namespace { +QJsonArray vec3(const std::array& v) { + return QJsonArray{v[0], v[1], v[2]}; +} +QString qs(const std::string& s) { return QString::fromStdString(s); } +} // namespace + +QJsonObject VoxelGenerateRequest::toJson() const { + QJsonArray ids; + for (const auto& s : sourceDatasetIds) ids.append(qs(s)); + QJsonObject o{ + {"projectId", qs(projectId)}, + {"structParentId", qs(structParentId)}, + {"structParentConfType", structParentConfType}, + {"name", qs(name)}, + {"sourceDatasetIds", ids}, + {"interpModel", qs(interpModel)}, + {"cellXY", cellXY}, {"cellZ", cellZ}, {"power", power}, {"maxDist", maxDist}, + }; + if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); + return o; +} + +QJsonObject SliceGenerateRequest::toJson() const { + QJsonObject o{ + {"projectId", qs(projectId)}, + {"volumeDsId", qs(volumeDsId)}, + {"name", qs(name)}, + {"axis", axis}, + {"origin", vec3(origin)}, {"point1", vec3(point1)}, {"point2", vec3(point2)}, + }; + if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); + return o; +} + +} // namespace geopro::data +``` + +- [ ] **Step 5: 注册 CMake + 跑测试** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure -R Vtk3dRequests` +Expected: PASS。 + +- [ ] **Step 6: 提交** + +```bash +git add src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_vtk3d_requests.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt +git commit -m "feat(data): VoxelGenerateRequest/SliceGenerateRequest DTO + toJson" +``` + +--- + +## Phase 3 — 对象树联动改造 + +### Task 4: GS 三态状态机(停 AutoTristate)+ 右键 ds/tm + +**Files:** +- Create: `src/app/panels/ObjectTreeSelection.hpp`(纯逻辑,可单测) +- Modify: `src/app/panels/ObjectTreePanel.cpp`(:123 停 AutoTristate、:174-191 itemChanged、:207-242 右键菜单)、`.hpp` +- Test: `tests/app/test_object_tree_selection.cpp` +- Modify: app/tests CMake + +**Interfaces:** +- Produces: + - `enum class GsCheck { Unchecked, Partial, Checked };` + - `GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount);` + - ObjectTreePanel 新增 UserRole:`kRoleGsDsOn`(GS 自身 ds 开关 bool)。 + +- [ ] **Step 1: 写失败测试** — `tests/app/test_object_tree_selection.cpp`: + +```cpp +#include +#include "panels/ObjectTreeSelection.hpp" +using namespace geopro::app; + +TEST(AggregateGsState, AllOnIsChecked) { + EXPECT_EQ(aggregateGsState(true, 3, 3), GsCheck::Checked); +} +TEST(AggregateGsState, AllOffIsUnchecked) { + EXPECT_EQ(aggregateGsState(false, 0, 3), GsCheck::Unchecked); +} +TEST(AggregateGsState, DsOnTmNoneIsPartial) { + EXPECT_EQ(aggregateGsState(true, 0, 3), GsCheck::Partial); // 只 GS 自身 ds +} +TEST(AggregateGsState, DsOffSomeTmIsPartial) { + EXPECT_EQ(aggregateGsState(false, 1, 3), GsCheck::Partial); // 部分子 TM +} +TEST(AggregateGsState, NoTmFallsBackToDsOnly) { + EXPECT_EQ(aggregateGsState(true, 0, 0), GsCheck::Checked); // 无子 TM → 仅看 ds 开关 + EXPECT_EQ(aggregateGsState(false, 0, 0), GsCheck::Unchecked); +} +``` + +- [ ] **Step 2: 跑测试确认失败** + +Run: `ctest --test-dir build --output-on-failure -R AggregateGsState` +Expected: 编译失败。 + +- [ ] **Step 3: 写 ObjectTreeSelection.hpp** + +```cpp +#pragma once +namespace geopro::app { + +enum class GsCheck { Unchecked, Partial, Checked }; + +// GS 复选框三态 = [自身 ds 开关] ∨ [子 TM 勾选] 的聚合(spec §6)。 +// 无子 TM(totalTmCount==0)时退化为仅看 dsOn。 +inline GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount) { + const bool anyOn = dsOn || checkedTmCount > 0; + if (!anyOn) return GsCheck::Unchecked; + const bool tmAll = (totalTmCount == 0) || (checkedTmCount == totalTmCount); + if (dsOn && tmAll) return GsCheck::Checked; + return GsCheck::Partial; +} + +} // namespace geopro::app +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure -R AggregateGsState` +Expected: PASS。 + +- [ ] **Step 5: 改 ObjectTreePanel —— 停用 AutoTristate + 手动三态** + +`ObjectTreePanel.cpp` 顶部常量区加 UserRole: +```cpp +constexpr int kRoleGsDsOn = Qt::UserRole + 7; // GS 自身 ds 开关(bool) +``` +`addNodes` 里 GS 分支(现 :122-125,`else { ... ItemIsAutoTristate ... }`)改为**不设 AutoTristate**、初始化 ds 开关: +```cpp +} else { + item->setData(0, kRoleConfType, kConfTypeGs); // GS + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); // 去掉 ItemIsAutoTristate + item->setData(0, kRoleGsDsOn, false); + item->setCheckState(0, Qt::Unchecked); + item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); +} +``` +新增私有方法 `void recomputeGsState(QTreeWidgetItem* gs)`(`.hpp` 声明,`.cpp` 实现): +```cpp +void ObjectTreePanel::recomputeGsState(QTreeWidgetItem* gs) { + if (!gs || gs->data(0, kRoleConfType).toInt() != kConfTypeGs) return; + const bool dsOn = gs->data(0, kRoleGsDsOn).toBool(); + int total = 0, checked = 0; + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + const GsCheck s = aggregateGsState(dsOn, checked, total); + const QSignalBlocker block(tree_); // 不再触发 itemChanged 递归 + gs->setCheckState(0, s == GsCheck::Checked ? Qt::Checked + : s == GsCheck::Partial ? Qt::PartiallyChecked : Qt::Unchecked); +} +``` +在现有 `itemChanged` 合并回调(:174-191)内,遍历收集后**对每个 GS 调 `recomputeGsState`**,再发 `checkedSourcesChanged`(见 Task 5)。点 GS 复选框的「任一开→全关 / 全关→全开」在 `itemClicked`(或复选框命中分支)里处理:读当前 `aggregateGsState`,若非 Unchecked → 置 dsOn=false + 子 TM 全 Unchecked;若 Unchecked → dsOn=true + 子 TM 全 Checked,然后 `recomputeGsState`。 + +- [ ] **Step 6: 右键 ds/tm 菜单** — 在右键菜单 GS 分支(现 :230-234 `if (isGs)`)加: +```cpp +if (isGs) { + QMenu* sel = menu.addMenu(QStringLiteral("选择")); + const bool dsOn = item->data(0, kRoleGsDsOn).toBool(); + bool hasOwnDs = /* 该 GS 是否有直挂 ds:由上层 setStructure 时标记,或暂以 true */ true; + int tmCount = 0; + for (int i = 0; i < item->childCount(); ++i) + if (item->child(i)->data(0, kRoleConfType).toInt() == kConfTypeTm) ++tmCount; + QAction* dsAct = sel->addAction(QStringLiteral("ds")); + dsAct->setCheckable(true); dsAct->setChecked(dsOn); dsAct->setEnabled(hasOwnDs); + QObject::connect(dsAct, &QAction::triggered, this, [this, item](bool on) { + item->setData(0, kRoleGsDsOn, on); recomputeGsState(item); emitCheckedSources(); + }); + QAction* tmAct = sel->addAction(QStringLiteral("tm")); + tmAct->setCheckable(true); + tmAct->setChecked(tmCount > 0 && allTmChecked(item)); // allTmChecked: 私有辅助 + tmAct->setEnabled(tmCount > 0); + QObject::connect(tmAct, &QAction::triggered, this, [this, item](bool on) { + setAllChildTmChecked(item, on); recomputeGsState(item); emitCheckedSources(); + }); + // 保留原「新建检测对象 / 新建方法对象」 + add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); + add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); +} +``` +(`allTmChecked` / `setAllChildTmChecked` / `emitCheckedSources` 为私有辅助,签名在 `.hpp` 声明;`emitCheckedSources` 见 Task 5。`hasOwnDs` 的真值来源:setStructure 时若 StructNode 标记了直挂 ds 则置 true,无标记暂保守 true——不影响正确性,仅菜单项是否灰显。) + +- [ ] **Step 7: build + 手动验证** + +Run: `cmake --build build` +手动清单(启动 app,进 VTK 视图,选有 GS 层级的项目): +1. 勾 GS 复选框 → 变全黑、其下 TM 全勾。 +2. 再点 GS → 变空、TM 全不勾。 +3. 右键 GS→选择▸ds 打勾 → GS 变灰(仅自身 ds)。 +4. 右键 GS→选择▸tm 打勾 → 子 TM 全勾、GS 若 ds 也开则变黑、否则灰。 +5. 单独勾一个子 TM → GS 变灰、tm 菜单对号消失。 +6. 无子 TM 的 GS:ds 开/关 → GS 黑/空(不出现灰)。 + +- [ ] **Step 8: 提交** + +```bash +git add src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp src/app/CMakeLists.txt tests/app/CMakeLists.txt +git commit -m "feat(tree): GS 三态状态机(停 AutoTristate)+右键 ds/tm 选择" +``` + +--- + +### Task 5: DataSource 去重 + checkedSourcesChanged 信号 + +**Files:** +- Modify: `src/data/repo/RepoTypes.hpp`(`DataSource`) +- Create: `src/app/panels/ObjectTreeSelection.hpp`(追加 `dedupeSources`) +- Modify: `src/app/panels/ObjectTreePanel.{hpp,cpp}`(`emitCheckedSources` + 信号) +- Test: `tests/app/test_object_tree_selection.cpp`(追加) + +**Interfaces:** +- Produces: + - `struct DataSource { std::string id; int confType; };`(confType: 1=GS/项目, 2=TM) + - `std::vector dedupeSources(std::vector in);`(按 {id,confType} 去重保序) + - `ObjectTreePanel` 信号 `void checkedSourcesChanged(const QList& sources);` + - 私有 `void emitCheckedSources();` + +- [ ] **Step 1: 写失败测试** — 追加到 `test_object_tree_selection.cpp`: + +```cpp +#include "repo/RepoTypes.hpp" +using geopro::data::DataSource; + +TEST(DedupeSources, RemovesDuplicateByIdAndConfType) { + std::vector in = {{"t1",2},{"g1",1},{"t1",2},{"g1",1},{"t2",2}}; + const auto out = geopro::app::dedupeSources(in); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0].id, "t1"); EXPECT_EQ(out[0].confType, 2); + EXPECT_EQ(out[1].id, "g1"); EXPECT_EQ(out[1].confType, 1); + EXPECT_EQ(out[2].id, "t2"); +} +TEST(DedupeSources, SameIdDifferentConfTypeKept) { + std::vector in = {{"x",1},{"x",2}}; + EXPECT_EQ(geopro::app::dedupeSources(in).size(), 2u); +} +``` + +- [ ] **Step 2: 跑确认失败** — `ctest -R DedupeSources` → 编译失败。 + +- [ ] **Step 3: 加 DataSource** — `RepoTypes.hpp`: +```cpp +// 对象树勾选产出的数据源(spec §6)。confType: 1=GS/项目根, 2=TM。 +struct DataSource { std::string id; int confType = 0; }; +``` +(若放 `QList` 过信号,需 `Q_DECLARE_METATYPE(geopro::data::DataSource)` + 注册,照 `DsPage` 现有 metatype 注册方式。) + +- [ ] **Step 4: 加 dedupeSources** — `ObjectTreeSelection.hpp` 追加: +```cpp +#include +#include "repo/RepoTypes.hpp" +namespace geopro::app { +inline std::vector dedupeSources(std::vector in) { + std::vector out; + for (const auto& s : in) { + bool dup = false; + for (const auto& o : out) if (o.id == s.id && o.confType == s.confType) { dup = true; break; } + if (!dup) out.push_back(s); + } + return out; +} +} // namespace geopro::app +``` + +- [ ] **Step 5: 跑确认通过** — `cmake --build build && ctest -R DedupeSources` → PASS。 + +- [ ] **Step 6: emitCheckedSources** — `ObjectTreePanel.cpp` 实现(替代旧 `checkedTmsChanged` 收集): +```cpp +void ObjectTreePanel::emitCheckedSources() { + std::vector src; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + const int ct = c->data(0, kRoleConfType).toInt(); + if (ct == kConfTypeTm && c->checkState(0) == Qt::Checked) + src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 2}); + if (ct == kConfTypeGs && c->data(0, kRoleGsDsOn).toBool()) + src.push_back({c->data(0, kRoleObjId).toString().toStdString(), 1}); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); + // 项目根直挂 ds 固定加入(根节点 id,confType=1)。 + if (tree_->topLevelItemCount() > 0) { + QTreeWidgetItem* root = tree_->topLevelItem(0); + if (root->data(0, kRoleIsRoot).toBool()) + src.push_back({root->data(0, kRoleObjId).toString().toStdString(), 1}); + } + const auto deduped = geopro::app::dedupeSources(std::move(src)); + QList list; + for (const auto& s : deduped) list.push_back(s); + emit checkedSourcesChanged(list); +} +``` +把现有 `itemChanged` 0ms 合并回调(:177-190)末尾的 `emit checkedTmsChanged(...)` 改为 `emitCheckedSources()`,并对涉及 GS 先 `recomputeGsState`。`setAllTmsChecked`/`invertTmChecks` 末尾同样改调 `emitCheckedSources`。`checkedTmsChanged` 信号删除(无其它消费者后;main.cpp 接线在 Task 12 改)。 + +- [ ] **Step 7: build + 提交** + +Run: `cmake --build build`(此时 main.cpp 仍连旧信号会编译错——可在 Task 12 一起绿;若分阶段,本 task 暂保留 `checkedTmsChanged` 与新信号并存,Task 12 删旧)。 + +> 决策:为保持每 task 可编译,**本 task 新增 `checkedSourcesChanged` 并存、不删 `checkedTmsChanged`**;Task 12 接线切换后再删旧信号。 + +```bash +git add src/data/repo/RepoTypes.hpp src/app/panels/ObjectTreeSelection.hpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp tests/app/test_object_tree_selection.cpp +git commit -m "feat(tree): checkedSourcesChanged 带 confType 源集合(去重并集)" +``` + +--- + +## Phase 4 — 装置类型 / 采集时间字典服务 + +### Task 6: DatasetFieldDictionary + +**Files:** +- Create: `src/data/repo/DatasetFieldDictionary.hpp` / `.cpp` +- Test: `tests/data/test_dataset_field_dictionary.cpp` +- Modify: data/tests CMake + +**Interfaces:** +- Produces: + - `struct DsTypeFields { std::string arrayTypeConfFieldId, collectTimeConfFieldId; std::map arrayTypeLabels; };` + - `DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData);`(纯函数,解析 formList) + - class `DatasetFieldDictionary`:`void ensureFor(dsTypeCode, sampleDsId, cb)`(异步拉 dynamicForm 缓存)、`const DsTypeFields* fields(dsTypeCode) const`、`std::string arrayValueOf(const DsRow&) const`(从 properties 取 arrayType 值)、`std::string arrayLabel(dsTypeCode, value) const`(value→中文,缺失回退原值)。 + +- [ ] **Step 1: 写失败测试** — `tests/data/test_dataset_field_dictionary.cpp`(喂真实 dynamicForm 结构): + +```cpp +#include +#include +#include "repo/DatasetFieldDictionary.hpp" +using namespace geopro::data; + +TEST(ParseFieldMapping, ExtractsArrayTypeAndCollectTimeConfFieldIds) { + const QString js = R"({"formList":[{"groupName":"基本信息","values":[ + {"confFieldId":"f_ct","fieldCode":"collectTime","fieldName":"采集时间","optionsObject":null}, + {"confFieldId":"f_at","fieldCode":"arrayType","fieldName":"装置类型","optionsObject":[ + {"label":"温纳-施伦贝尔排列","value":"v1"},{"label":"全梯度","value":"v2"}]} + ]}]})"; + const QJsonObject data = QJsonDocument::fromJson(js.toUtf8()).object(); + const DsTypeFields f = parseFieldMapping(data); + EXPECT_EQ(f.arrayTypeConfFieldId, "f_at"); + EXPECT_EQ(f.collectTimeConfFieldId, "f_ct"); + ASSERT_EQ(f.arrayTypeLabels.count("v1"), 1u); + EXPECT_EQ(f.arrayTypeLabels.at("v1"), "温纳-施伦贝尔排列"); +} +``` + +- [ ] **Step 2: 跑确认失败** — `ctest -R ParseFieldMapping` → 编译失败。 + +- [ ] **Step 3: 写 DatasetFieldDictionary.hpp** + +```cpp +#pragma once +#include +#include +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// 某 dsType 的字段映射(spec §10)。 +struct DsTypeFields { + std::string arrayTypeConfFieldId; // ds 行 properties 里装置类型项的 confFieldId + std::string collectTimeConfFieldId; // 采集时间项的 confFieldId + std::map arrayTypeLabels; // value→中文(来自 optionsObject) +}; + +// 纯函数:从 dsObject/dynamicForm 的 data 解析字段映射(formList → fieldCode==arrayType/collectTime)。 +DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData); + +// ds 行的装置类型原始值:properties 中 confFieldId==arrayTypeConfFieldId 的 value(缺=空)。 +std::string arrayValueOf(const DsRow& row, const DsTypeFields& f); +// ds 行的采集时间:properties 中 confFieldId==collectTimeConfFieldId 的 value(缺=空)。 +std::string collectTimeOf(const DsRow& row, const DsTypeFields& f); + +} // namespace geopro::data +``` + +- [ ] **Step 4: 写 DatasetFieldDictionary.cpp**(纯解析部分) + +```cpp +#include "repo/DatasetFieldDictionary.hpp" +#include + +namespace geopro::data { + +DsTypeFields parseFieldMapping(const QJsonObject& d) { + DsTypeFields f; + for (const QJsonValue& gv : d.value(QStringLiteral("formList")).toArray()) { + for (const QJsonValue& vv : gv.toObject().value(QStringLiteral("values")).toArray()) { + const QJsonObject fo = vv.toObject(); + const QString code = fo.value(QStringLiteral("fieldCode")).toString(); + const std::string cfid = fo.value(QStringLiteral("confFieldId")).toString().toStdString(); + if (code == QStringLiteral("arrayType")) { + f.arrayTypeConfFieldId = cfid; + for (const QJsonValue& ov : fo.value(QStringLiteral("optionsObject")).toArray()) { + const QJsonObject oo = ov.toObject(); + f.arrayTypeLabels[oo.value(QStringLiteral("value")).toString().toStdString()] = + oo.value(QStringLiteral("label")).toString().toStdString(); + } + } else if (code == QStringLiteral("collectTime")) { + f.collectTimeConfFieldId = cfid; + } + } + } + return f; +} + +static std::string propValue(const DsRow& row, const std::string& cfid) { + if (cfid.empty()) return {}; + for (const auto& kv : row.properties) if (kv.confFieldId == cfid) return kv.value; + return {}; +} +std::string arrayValueOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.arrayTypeConfFieldId); } +std::string collectTimeOf(const DsRow& row, const DsTypeFields& f) { return propValue(row, f.collectTimeConfFieldId); } + +} // namespace geopro::data +``` + +> 装置 value→中文:本 task 用 `optionsObject` 建 `arrayTypeLabels`。**已知风险(spec §11)**:实测某些 ds 的 arrayType 原始值不在 optionsObject 里——此时 `arrayLabel` 回退显示原值,待坐实正确字典源后只换 `arrayTypeLabels` 的数据来源、接口不变。 + +- [ ] **Step 5: 跑确认通过** — `cmake --build build && ctest -R ParseFieldMapping` → PASS。 + +- [ ] **Step 6: 异步缓存壳**(无独立单测,随 Task 7 集成)— 在同文件加按 dsTypeCode 缓存的 `DatasetFieldDictionary` 类:构造收 `IAsyncProjectRepository&`(或现有 `loadDatasetFormAsync` 提供者);`ensureFor(dsTypeCode, sampleDsId, cb)` 若未缓存则 `loadDatasetFormAsync(sampleDsId)` → `parseFieldMapping` → 存 `std::map` → cb。`fields(dsTypeCode)` 返回缓存指针或 nullptr。 + +- [ ] **Step 7: 提交** + +```bash +git add src/data/repo/DatasetFieldDictionary.hpp src/data/repo/DatasetFieldDictionary.cpp tests/data/test_dataset_field_dictionary.cpp src/data/CMakeLists.txt tests/data/CMakeLists.txt +git commit -m "feat(data): DatasetFieldDictionary 解析 arrayType/collectTime 映射+装置字典" +``` + +--- + +## Phase 5 — 类型段组件与容器 + +### Task 7: CategorySection(段头筛选 + 段体树 + 生成入口) + +**Files:** +- Create: `src/app/panels/columns/CategorySection.hpp` / `.cpp` +- Modify: app CMake + +**Interfaces:** +- Consumes: `CategorySpec`(Task 2)、`splitByCategory` 桶里的 `std::vector`、`DatasetListPanel::populateDatasetList`/`applyDatasetFilter`、`DatasetFieldDictionary`(Task 6)。 +- Produces: +```cpp +class CategorySection : public QWidget { + Q_OBJECT +public: + CategorySection(const geopro::app::CategorySpec& spec, + geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr); + void setDatasets(const std::vector& rows); +signals: + void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 + void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」→带勾选源 + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); +}; +``` + +**实现要点(`.cpp`,参照现有 `Column3DDataset.cpp` 的列表+右键 pattern):** +- 段头:标题 `QLabel`(`spec.title`)+ 折叠箭头;`spec.hasArrayTypeFilter` 为 true 才加装置类型 `QComboBox`;日期范围两个 `QDateEdit`。 +- 段体:`QTreeWidget` + `applyDatasetCardDelegate`;`setDatasets` 调 `populateDatasetList(tree_, rows, false)` + 让数据行可勾选(同现 Column3DDataset.cpp:170-185)。 +- 勾选 → `checkedDatasetsChanged`(同 Column3DDataset.cpp:128-135)。 +- 筛选:日期/装置类型变化 → 调 `applyDatasetFilter`(日期比较改 collectTime——经 dict 取值;装置类型按 `arrayValueOf` 过滤)。装置类型下拉项 = 当前数据经 `dict->arrayLabel` 去重集合。 +- 生成入口(仅 `spec.canGenerateVolume`):**段头**「+新增三维体」按钮;点击 → 收集本段当前勾选的源 ds → `emit generateVolumeRequested(spec.dsTypeCode, checkedSourceDsIds)`(归属「生成位置」与插值参数都在 main 的 `VolumeParamsDialog` 里选,见 Task 12)。 +- **段体树(核心实现)**:段体呈现「项目根 / GS / TM 容器节点 → ds 行」层级(spec §4 图)。`CategorySection` 加 `void setStructure(const std::vector& nodes)`(对象树同源的扁平 GS/TM 节点;main.cpp 传入、Task 8 经 CategoryAnalysisTab 转发、Task 12 接线)。`setDatasets` 据 structure 先建容器节点(项目根/GS;TM 作为 GS 子节点),再把每个 ds 按其 `structParentId`/`structParentConfType`(Task 1 已解析)挂到对应容器下;容器内 ds 间再按 `ds.parentId` 派生建树(复用 `populateDatasetList` 逻辑)。(生成三维体不在段体右键——改段头按钮,见上「生成入口」;归属由对话框「生成位置」选,与段体容器节点无关。) + +- [ ] **Step 1: 写 CategorySection.hpp**(按上 Interfaces 完整声明) +- [ ] **Step 2: 写 CategorySection.cpp**(按实现要点;段头/段体/筛选/右键) +- [ ] **Step 3: 注册 CMake** +- [ ] **Step 4: build** + +Run: `cmake --build build` +Expected: 编译通过(CategorySection 暂未接入 ColumnDrawer,仅编译)。 + +- [ ] **Step 5: 提交** + +```bash +git add src/app/panels/columns/CategorySection.hpp src/app/panels/columns/CategorySection.cpp src/data/repo/RepoTypes.hpp src/data/dto/NavDto.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): CategorySection 类型段组件(段头筛选+段体树+生成入口)" +``` + +--- + +### Task 8: CategoryAnalysisTab 容器 + ColumnDrawer 两 tab + +**Files:** +- Create: `src/app/panels/columns/CategoryAnalysisTab.hpp` / `.cpp` +- Modify: `src/app/panels/columns/ColumnDrawer.{hpp,cpp}` +- Modify: app CMake + +**Interfaces:** +- Produces: +```cpp +class CategoryAnalysisTab : public QWidget { // QScrollArea 堆叠 5 段 + Q_OBJECT +public: + CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent=nullptr); + void setBuckets(const geopro::app::CategoryBuckets& b); // 分发到 5 段 + CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 +signals: + void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选合并(并集)上抛 + void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); + void detailRequested(const QString&, const QString&, const QString&); +}; +``` +- `ColumnDrawer`:`col3D_/colAnalysis_` 两个旧成员替换为 `CategoryAnalysisTab* analysisTab_`;tabs 改为 `addTab(analysisTab_, "三维分析")` + `addTab(col2D_, "二维分析")`。`col3D()/colAnalysis()` 访问器替换为 `analysisTab()`。 + +**实现要点:** `CategoryAnalysisTab` 内 `QScrollArea` + `QVBoxLayout`,遍历 `categoryConfigs()` 建 5 个 `CategorySection`(存 `std::map`);`setBuckets` 把 `b.segments[i]` 给第 i 段;把各段 `checkedDatasetsChanged` 收集成并集再上抛(每段维护各自勾选集,合并)。 + +- [ ] **Step 1: 写 CategoryAnalysisTab.hpp/.cpp** +- [ ] **Step 2: 改 ColumnDrawer**(两 tab;删 col3D_/colAnalysis_,留 col2D_) +- [ ] **Step 3: 注册 CMake + build** + +Run: `cmake --build build` +Expected: ColumnDrawer 编译通过;main.cpp 仍引用旧 `col3D()/colAnalysis()` 会编译错——Task 12 切换。**为保持可编译**:本 task 暂保留旧 `col3D()/colAnalysis()` 返回 nullptr 或保留旧成员并存,Task 12 删。决策:保留 `col2D()` + 新增 `analysisTab()`,旧 `col3D()/colAnalysis()` 暂留空实现直到 Task 12。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/panels/columns/CategoryAnalysisTab.hpp src/app/panels/columns/CategoryAnalysisTab.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): CategoryAnalysisTab(QScrollArea 5段)+ColumnDrawer 两tab" +``` + +--- + +## Phase 6 — VTK 画布工具条 + +### Task 9: VtkViewToolbar + AxesSettingsDialog + +**Files:** +- Create: `src/app/VtkViewToolbar.hpp` / `.cpp`、`src/app/AxesSettingsDialog.hpp` / `.cpp` +- Modify: app CMake + +**Interfaces:** +- Produces: +```cpp +class VtkViewToolbar : public QWidget { // 竖排:设置/前后上下左右/放大缩小复位 + Q_OBJECT +public: + explicit VtkViewToolbar(QWidget* parent=nullptr); +signals: + void axesSettingsRequested(); // 设置→弹 AxesSettingsDialog + void viewRequested(geopro::controller::ViewDir dir); // 前后上下左右 + void zoomInRequested(); void zoomOutRequested(); void fitRequested(); // 复位=适配 +}; +struct AxisRange { bool show=true; double min=-500, max=500; }; +class AxesSettingsDialog : public QDialog { + Q_OBJECT +public: + AxesSettingsDialog(AxisRange x, AxisRange y, AxisRange z, QWidget* parent=nullptr); + AxisRange x() const; AxisRange y() const; AxisRange z() const; // 应用后读取 +}; +``` + +**实现要点:** 工具条三组按钮(QToolButton 竖排),图标用现有 `makeGlyph`/`Glyph`;信号直接转发。`AxesSettingsDialog`:三组(X/Y/深度)各 `QCheckBox 显示` + 两 `QDoubleSpinBox min/max`,底部 取消/应用。视图方向按钮文字「前/后/上/下/左/右」对应 `ViewDir::Front/Back/Top/Bottom/Left/Right`。 + +- [ ] **Step 1: 写 AxesSettingsDialog.hpp/.cpp** +- [ ] **Step 2: 写 VtkViewToolbar.hpp/.cpp** +- [ ] **Step 3: 注册 CMake + build** + +Run: `cmake --build build` +Expected: 编译通过(暂未接入中央视图)。 + +- [ ] **Step 4: 提交** + +```bash +git add src/app/VtkViewToolbar.hpp src/app/VtkViewToolbar.cpp src/app/AxesSettingsDialog.hpp src/app/AxesSettingsDialog.cpp src/app/CMakeLists.txt +git commit -m "feat(ui): VtkViewToolbar 画布工具条 + AxesSettingsDialog" +``` + +--- + +## Phase 7 — Api3dRepository 扩参 + 段重组 + +### Task 10: createVolume/createSlice 扩参 + 请求体 DTO 组装 + +**Files:** +- Modify: `src/data/api/Api3dRepository.{hpp,cpp}` +- Test: `tests/data/test_api3d_requests.cpp`(新增;验证组装的请求体) + +**Interfaces:** +- Changed: + - `std::string createVolume(const VoxelGenerateRequest& req);`(替代旧 `(VolumeBuildParams,name)`;内部 `StoredVolume` 同时存 `req` 与从 req 派生的 `VolumeBuildParams`) + - `void createSlice(const SliceSpec& spec, const std::string& name, const std::string& projectId, OnOk, OnErr);`(加 projectId) + - `const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const;`(测试/调试取回组装请求体) + +- [ ] **Step 1: 写失败测试** — `tests/data/test_api3d_requests.cpp`:构造 `Api3dRepository`(喂 stub dsRepo + frame),调 `createVolume(req)`,取回 `lastVoxelRequest(id)->toJson()` 断言含 structParentId/structParentConfType/sourceDatasetIds。 +(若 `Api3dRepository` 构造依赖重,改为只测「VolumeBuildParams ← VoxelGenerateRequest 的派生函数」纯函数 + toJson;把派生逻辑抽 `VolumeBuildParams fromRequest(const VoxelGenerateRequest&)` 纯函数单测。) + +```cpp +TEST(Api3dRequestAssembly, VolumeBuildParamsFromRequest) { + geopro::data::VoxelGenerateRequest q; + q.sourceDatasetIds = {"d1"}; q.cellXY = 2.0; q.power = 3.0; q.colorScaleId = "cs1"; + const auto p = geopro::data::fromRequest(q); + EXPECT_EQ(p.sourceDatasetIds.size(), 1u); + EXPECT_DOUBLE_EQ(p.cellXY, 2.0); + EXPECT_DOUBLE_EQ(p.power, 3.0); + EXPECT_EQ(p.colorScaleId, "cs1"); +} +``` + +- [ ] **Step 2: 跑确认失败** — `ctest -R Api3dRequestAssembly` → 失败。 +- [ ] **Step 3: 加 `fromRequest`**(`Vtk3dRequests.{hpp,cpp}` 或 Api3d):`VolumeBuildParams fromRequest(const VoxelGenerateRequest&)` 映射字段(interpModel 字符串→enum)。 +- [ ] **Step 4: 改 createVolume/createSlice 签名** — `createVolume(const VoxelGenerateRequest& req)`:`StoredVolume` 存 `req` + `fromRequest(req)`;返回 mock id。`createSlice` 加 projectId,内部组装 `SliceGenerateRequest`(`qDebug()` 打印 `toJson()`)。 +- [ ] **Step 5: 跑确认通过 + build** — `cmake --build build && ctest -R Api3dRequestAssembly` → PASS。 +- [ ] **Step 6: 提交** + +```bash +git add src/data/api/Api3dRepository.hpp src/data/api/Api3dRepository.cpp src/data/dto/Vtk3dRequests.hpp src/data/dto/Vtk3dRequests.cpp tests/data/test_api3d_requests.cpp tests/data/CMakeLists.txt +git commit -m "feat(data): createVolume/createSlice 扩参+请求体DTO组装(mock)" +``` + +--- + +### Task 11: 三维体段「体→切片/异常」三级树 + 异常按归属挂体/切片(spec §8) + +> **设计修订(2026-06-24)**:取消「独立异常区」的旧设计。异常不再单列、不再"随当前活动体"展示,而是作为叶子挂在它**归属的实体节点**(体 或 切片)下。归属按 `异常→所在切片→切片所属体` 链确定,挂载目标由「切片是否已保存成 dd_slice」决定(见 spec §8)。多体渲染本就支持(`VtkSceneView::dsProps_` 按 dsId 各存 actor);`volumeOwnerDs_`/`currentVolumeImage_` = 当前切片操作所基于的体(非"唯一可渲体")。 + +**Files:** +- Modify: `src/core/model/Anomaly.hpp`(`volumeDsId` → `remarkSourceId` + `remarkSourceType`) +- Modify: `src/data/api/Api3dRepository.{hpp,cpp}`(`saveAnomaly`/`loadAnomalyTree` mock 按 remarkSource 存/查;anomalyRows 供树注入) +- Modify: `src/app/main.cpp`(创建异常时判断所在切片是否已保存 → 设 remarkSourceId/Type;refreshAnomalies 改注入三维体段树) +- Modify: `src/app/panels/columns/CategorySection.cpp` / `CategoryAnalysisTab.{hpp,cpp}`(三维体段三级树 + 切片/异常勾选·详情·删除转发) +- Test: `tests/...`(remarkSource 归属判定纯函数 + Api3d 异常 mock 存查) + +**Interfaces / 数据模型:** +- `Anomaly`:`volumeDsId` 改名为 `std::string remarkSourceId;`(挂载实体 dsId = 体 or 切片)。**不加** type 字段——挂体/挂切片由 `remarkSourceId` 查 `isVolume/isSlice` 区分,展示树按 `parentId=remarkSourceId` 自动挂载。(⚠️ 后端 `remarkSourceType` 是标注几何形态 1-4 = `markType`,勿混。) +- 纯函数(可单测):`std::string resolveAnomalyMount(bool sliceIsSaved, const std::string& savedSliceDsId, const std::string& volumeDsId);` + —— 已保存切片→`savedSliceDsId`;否则→`volumeDsId`。返回挂载实体 dsId(= remarkSourceId)。 + +- [ ] **Step 1(逻辑层,可单测): Anomaly 模型 + resolveAnomalyMount** + 改 `Anomaly`(remarkSourceId/Type,全量改其引用点:VtkSceneView addAnomaly/removeAnomaly 按 id 跟踪不受影响,仅 main 创建处赋值变);写 `resolveAnomalyMount` 纯函数 + 单测(已保存切片挂切片 / 临时切片挂体 两例)。**build test 绿**。 +- [ ] **Step 2(逻辑层): Api3dRepository 异常 mock 按归属存查** + `StoredAnomaly` 按 remarkSourceId/Type 存;`loadAnomalyTree(sourceId)` 或新增 `anomalyRows(remarkSourceId)` 返回该体/切片下异常行(DsRow 形态,ddCode 自定如 `dd_anomaly`,parentId=remarkSourceId)供树注入;`saveAnomaly` 存 remarkSource。单测:挂体/挂切片分别能查回。 +- [ ] **Step 3: main 创建异常逻辑**(main:~502 区) + 画异常时从 `interactionMgr` 取当前选中切片状态:已保存切片→其 dsId(`selectSavedSlice`/`selectedSavedSliceId` 核实接口);临时切片→`volumeOwnerDs_`。调 `resolveAnomalyMount` 设 `a.remarkSourceId/remarkSourceType`。(**先核实 interactionMgr 如何区分"当前切片已保存 vs 临时"+取其 dsId**,不凭印象。) +- [ ] **Step 4(UI,需真实验证): 三维体段三级树** + `refreshAnomalies` 改:把异常行按 remarkSourceId 注入三维体段树——挂体异常作体节点子、挂切片异常作切片节点子;切片作体节点子(parentId=volumeDsId)。`CategoryAnalysisTab`/`CategorySection`(voxel 段)补:体/切片/异常三级建树 + 异常/切片勾选·详情·删除信号转发(迁 `Column3DAnalysis` 对应控件逻辑)。 +- [ ] **Step 5: build + 手动验证**(生成体→体节点;体上画异常(切片未存)→异常挂体下;保存切片→切片挂体下;切片上画异常→异常挂该切片下;勾选/详情/删除各级生效) +- [ ] **Step 6: 提交**(分逻辑层[Step1-3] 与 UI[Step4] 两 commit;前者可 build test 绿,后者 build app + 真实验证) + +> **波及**:`Anomaly.volumeDsId` 改名会触及现有所有读取点(VtkSceneView 渲染按 worldPts/plane,不读 volumeDsId;main saveAnomaly 赋值;Api3dRepository StoredAnomaly)——Step 1 一并改全。切片保存/关闭(main setItemChecked)随三级树勾选 API 一并迁。 + +--- + +## Phase 8 — main.cpp 接线总成 + +### Task 12: 数据流接线 + 退役旧栏 + +**Files:** +- Modify: `src/app/main.cpp`(:388-460 异常/勾选聚合、:1113-1225 对象树→分类分发) +- Modify: `src/app/panels/ObjectTreePanel.{hpp,cpp}`(删旧 `checkedTmsChanged`) +- Modify: `src/app/panels/columns/ColumnDrawer.{hpp,cpp}`(删旧 `col3D()/colAnalysis()`) +- Delete refs: `Column3DDataset` / `Column3DAnalysis`(源文件可留待后续清理,先解除 main 引用) + +**接线改动:** +1. 对象树勾选:`objectTree::checkedTmsChanged` → `checkedSourcesChanged(QList)`。回调内对每源 `loadRowsAsync(projId, src.id, src.confType, /*classify*/3, 1, 100000)`(第3参 `src.confType` 取代字面量 `2`,见 ApiProjectRepository.cpp:81-89 透传),汇总 `DsRow[]`(保留现有 `generation` 防陈旧 + 多源计数 finish)。 +2. 分类分发:`finish` 内 `splitByCategory(*acc)` → `drawer->analysisTab()->setBuckets(b)`(取代旧 `col3D/col2D/colAnalysis setDatasets` + `splitByDimension`)。二维数据(dd_trajectory_data 等)仍走 `drawer->col2D()->setDatasets(...)`——**注意**:splitByCategory 只产 5 个 3D 段,二维数据需单独分出:保留一个 `dim2D` 过滤(trajectory 类)喂 col2D。 +3. 渲染勾选并集:`analysisTab::checkedDatasetsChanged`(5 段并集,含帘面源=电阻率/视电阻率/瞬变 + 体素/切片)→ 并入 `checkedProfiles`/`checkedAnalysis` → `pushChecked()`(沿用现 :457-462 并集模型)。 +4. 生成三维体:`analysisTab::generateVolumeRequested(dsTypeCode, sourceDsIds)` → 弹 `VolumeParamsDialog`(**左侧**勾选源 ds 树·按 GS 分组·可二次增删确认;**右侧**参数含「生成位置」下拉 = 项目内 GS/TM 列表,**默认**源单 GS→该 GS、跨 GS→项目根)→ 用户确认 → 组装 `VoxelGenerateRequest{projectId, structParentId=所选生成位置, structParentConfType=1或2, name, sourceDatasetIds=对话框最终勾选, params}` → `scene3dRepo->createVolume(req)` → `refreshAnalysis()`。`VolumeParamsDialog` 需扩:左侧源列表(含二次增删)+ 「生成位置」下拉(项目 GS/TM 列表来自对象树结构,按默认规则预选)。 +5. 切片保存调用点补 `nav.currentProjectId()`(:581/716+ createSlice 调用)。 +6. 工具条接入中央视图:实例化 `VtkViewToolbar` 叠加在 QVTK 上,信号接 `sceneCtrl`(axesSettingsRequested→弹 AxesSettingsDialog→应用到坐标轴;viewRequested/zoom*/fit 接现有槽,迁自 Column3DDataset 接线 :898 等);`setVerticalExaggeration` 默认回灌迁到工具条对应控件。 +7. 删旧信号/访问器/`splitByDimension` 引用。 + +- [ ] **Step 1: 改对象树勾选接线**(confType 分流拉取 + splitByCategory 分发) +- [ ] **Step 2: 改渲染勾选并集**(analysisTab 并集→pushChecked) +- [ ] **Step 3: 改生成三维体接线**(组装 VoxelGenerateRequest) +- [ ] **Step 4: 接入工具条 + 坐标轴对话框** +- [ ] **Step 5: 切片保存补 projectId** +- [ ] **Step 6: 删旧信号/访问器/Column3D* 引用 + splitByDimension** +- [ ] **Step 7: build + 全量手动回归** + +Run: `cmake --build build && ctest --test-dir build --output-on-failure` +Expected: 全绿 + 编译通过。手动回归清单: +1. 勾对象树 GS/TM/项目根 → 对应数据进 5 个大类段(电阻率/视电阻率/瞬变/三维体/切片各就各位)。 +2. 勾电阻率某行 → 中央出帘面(原渲染不丢)。 +3. GS 节点右键「生成三维体」→ 弹参数对话框 → 生成体进三维体段 → 勾选出体素。 +4. 工具条:设置弹坐标轴对话框、前后上下左右切视图、放大/缩小/复位生效。 +5. 日期/装置类型筛选(电阻率段)生效;瞬变段无装置类型下拉。 +6. 二维分析 tab 不变(足迹照常)。 +7. 控制台可见 createVolume/createSlice 打印的真实请求体 JSON。 + +- [ ] **Step 8: 提交** + +```bash +git add src/app/main.cpp src/app/panels/ObjectTreePanel.hpp src/app/panels/ObjectTreePanel.cpp src/app/panels/columns/ColumnDrawer.hpp src/app/panels/columns/ColumnDrawer.cpp +git commit -m "refactor(app): main 接线总成-分类分发/勾选并集/生成归属/工具条+退役旧栏" +``` + +--- + +## 自审(spec 覆盖核对) + +| spec 节 | 覆盖任务 | +|---|---| +| §4 整体架构(两 tab/5 段/工具条) | Task 8, 9, 12 | +| §5 DsRow 扩展 + CategoryConfig + splitByCategory | Task 1, 2 | +| §6 对象树 GS 三态 + 信号 + 数据流 | Task 4, 5, 12 | +| §7 CategorySection(段头/段体/生成入口/勾选承接/双重过滤) | Task 7, 12 | +| §8 三维体/切片/异常段 + 请求体组装 | Task 10, 11, 12 | +| §9 VtkViewToolbar + AxesSettingsDialog + setVE 回灌 | Task 9, 12 | +| §10 DatasetFieldDictionary + collectTime 筛选 | Task 6, 7 | +| §11 装置 value→中文待坐实 | Task 6(回退原值 + 注明) | +| §12 组件/文件边界 | 全部 | + +**待坐实(不阻塞实施,实现中坐实):** 装置类型 value→中文 字典源(Task 6 注明,待属性面板截图)。 + +**Phase 间编译连续性:** Task 5/8 采用「新旧并存」过渡(保留旧信号/访问器),Task 12 统一切换并删旧——保证每次提交可编译。 + +--- + diff --git a/docs/superpowers/plans/poc-lod-shots/lod-fullres-local.png b/docs/superpowers/plans/poc-lod-shots/lod-fullres-local.png new file mode 100644 index 0000000..0fab346 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-fullres-local.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/lod-overview.png b/docs/superpowers/plans/poc-lod-shots/lod-overview.png new file mode 100644 index 0000000..8daf117 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-overview.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/lod-transition-mid.png b/docs/superpowers/plans/poc-lod-shots/lod-transition-mid.png new file mode 100644 index 0000000..c4e4811 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-transition-mid.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png b/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png new file mode 100644 index 0000000..67a68a0 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png b/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png new file mode 100644 index 0000000..7d079c9 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/polish-a-value.png b/docs/superpowers/plans/poc-lod-shots/polish-a-value.png new file mode 100644 index 0000000..453f9be Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/polish-a-value.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/polish-b-grad.png b/docs/superpowers/plans/poc-lod-shots/polish-b-grad.png new file mode 100644 index 0000000..32af6a5 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/polish-b-grad.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/polish-c-grad-shade.png b/docs/superpowers/plans/poc-lod-shots/polish-c-grad-shade.png new file mode 100644 index 0000000..3b32bf6 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/polish-c-grad-shade.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-default.png b/docs/superpowers/plans/poc-lod-shots/view-default.png new file mode 100644 index 0000000..e1670db Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-default.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-near.png b/docs/superpowers/plans/poc-lod-shots/view-near.png new file mode 100644 index 0000000..ff1feb8 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-near.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-var1.png b/docs/superpowers/plans/poc-lod-shots/view-var1.png new file mode 100644 index 0000000..53e5f45 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-var1.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-var2.png b/docs/superpowers/plans/poc-lod-shots/view-var2.png new file mode 100644 index 0000000..92afd81 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-var2.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-var3.png b/docs/superpowers/plans/poc-lod-shots/view-var3.png new file mode 100644 index 0000000..3a11c09 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-var3.png differ diff --git a/docs/superpowers/plans/poc-lod-shots/view-var4.png b/docs/superpowers/plans/poc-lod-shots/view-var4.png new file mode 100644 index 0000000..2e3b739 Binary files /dev/null and b/docs/superpowers/plans/poc-lod-shots/view-var4.png differ diff --git a/docs/superpowers/plans/poc-results-B.md b/docs/superpowers/plans/poc-results-B.md new file mode 100644 index 0000000..258e2b0 --- /dev/null +++ b/docs/superpowers/plans/poc-results-B.md @@ -0,0 +1,179 @@ +# POC-B 实测结果(gpr_poc headless 度量) + +工具:`tools/gpr_poc`(CLI),构建产物 `build/release/tools/gpr_poc/gpr_poc.exe`。 +执行机:Windows 11,MSVC(VS18 Community)+ Ninja,Release(/O2)。 +日期:2026-06-23。 + +整条地基链路: +`assembleGprSurvey → buildGprVolume → ChunkedVolumeStore::write → buildPyramid → WholeVolumeSource(load)`。 + +--- + +## 1. selftest(合成极小数据)—— PASS + +命令:`gpr_poc selftest` + +- 构造 2 通道合成 survey(samples=8,traces=12),写临时 `.iprb/.iprh/.ord`, + 走完整 `assembleGprSurvey → buildGprVolume → write(brick=4) → buildPyramid(1) → WholeVolumeSource`。 +- 断言:ntraces/samples/channels、channelY 升序、GridSpec `2x2x8`、建体维度、 + 金字塔层数==2、整卷维度一致、(0,0,0) 非 blank。 +- 结果:**PASS**(退出码 0)。 + +结论:除真实 `.iprb` 读入外,**整条地基管线在合成数据上端到端跑通** +(装配几何、建体量化、分块压缩落盘、金字塔降采样、整卷重组加载均正确)。 + +--- + +## 2. 真实数据(D:\Downloads\明星路,线 001)—— **PASS(实测)** + +> 更新(任务 9b,2026-06-23):先前 BLOCKED 的根因(`readIprb` 硬假设 +> `traces = lastTrace + 1`)已修复。`readIprb` 改为**以文件大小为权威** +> (`traces = fileBytes / (samples·2)`),真实数据装配通过。 + +命令: +``` +gpr_poc build "D:\Downloads\明星路" --line 001 --cellXY 0.2 --cellZ 0.05 --out --levels 2 +gpr_poc load +``` + +### 根因回顾(off-by-one:lastTrace+1 vs 真实道数) + +旧 `readIprb` 硬编码 `traces = h.lastTrace + 1` 并对文件字节做严格相等校验。 +真实明星路每个通道文件恰好含 `lastTrace` 条道(少 1 道),逐通道实测: + +| 通道 | 文件字节 | samples | LAST TRACE | 旧期望(=samples·(lastTrace+1)·2) | 实际道数(=bytes/(samples·2)) | +|------|----------|---------|------------|----------------------------------|------------------------------| +| A01 | 74390810 | 821 | 45305 | 74392452 | 45305 | +| A02 | 74394094 | 821 | 45307 | 74395736 | 45307 | +| A12 | 74392452 | 821 | 45306 | 74394094 | 45306 | + +**规律一致**:所有通道「实际道数 == LAST TRACE」。修复后 `readIprb` 不再用 `lastTrace` +决定道数,装配按各通道道数最小值对齐(min=45305)。 + +### build 实测指标(line 001, cellXY=0.2, cellZ=0.05, levels=2) + +| 指标 | 值 | +|------|-----| +| 发现通道数 | 14 | +| 装配后 ntraces / samples / channels | 45305 / 821 / 14 | +| dx / dz(米) | 0.049084 / **0.00977756** | +| GridSpec(nx×ny×nz) | **11120 × 8 × 162** | +| 体素数 | 14,411,520 | +| 原始体积(int16) | 28,823,040 B(27.49 MB) | +| 落盘 data.bin(含金字塔各级) | 15,317,628 B(14.61 MB) | +| 压缩比(原始/落盘) | **1.88×** | +| 装配耗时 | 12,551 ms | +| 建体耗时 | 1,926 ms | +| 落盘耗时 | 3,597 ms | +| 金字塔耗时 | 3,923 ms | +| build 端到端墙钟 | ≈22.6 s | +| 峰值内存 | **4,975 MB** | + +### load 实测指标 + +| 指标 | 值 | +|------|-----| +| 加载耗时 | 335 ms | +| 整卷维度 | 11120 × 8 × 162 | +| 整卷字节 | 28,823,040 B(27.49 MB) | +| 峰值内存 | 38 MB | + +无 OOM、无超时,未调粗 cellXY 即一次通过。 + +### 峰值内存说明(4.98 GB) + +峰值由**装配阶段**主导:同时持有 14 通道 BScan(14×74 MB ≈ 1 GB int16) ++ `GprSurvey.values` 以 **double** 存(14×45305×821×8 B ≈ 4.2 GB)。 +建体/落盘/加载本身很轻(load 仅 38 MB)。若后续要压内存,可让 survey.values +改存 int16 或流式装配,但当前规模单机可承受,POC 不做此优化。 + +--- + +## 3. 深度/Z 尺度诊断结论(任务 9b) + +先前 §3 预估「nz=1、深度量级 8e-6 m」是在 SOIL VELOCITY **未正确换算**时写下的 +(当时按 100 m/s 计算)。Task 1 已将 `SOIL VELOCITY`(头文件单位 m/µs)×1e6 存为 m/s, +本任务实测确认整条 Z 链路正确: + +- 头:SAMPLES=821,TIMEWINDOW=160.352 ns,SOIL VELOCITY=100(→ 1e8 m/s)。 +- `depthOfSample(820) = 1e8 × 160.352e-9 / 2 ≈ **8.018 m**`(深度跨度合理)。 +- `dz = depthOfSample(1) = 8.018/820 ≈ **0.009778 m**`(实测 0.00977756,吻合)。 +- 故 cellZ=0.05 下 `nz = ceil(8.018/0.05)+1 = **162**`(实测 162,非 1)。 + +**结论**:`assembler`/`GprGeometry`/CLI `specFromSurvey` 的 Z 计算**全部正确**, +无需改 CLI。先前的 nz=1 症状是 soilVelocity 换算缺失时代的遗留,现已不复存在。 +CLI 的 `specFromSurvey` 用的是 `survey.dz`(来自 `depthOfSample`),未误用原始 100,未漏乘。 + +--- + +## 4. 离屏 GPU 渲染基准(任务 9c,2026-06-23) + +工具新增子命令:`gpr_poc offscreen-smoke`(闸门)、`gpr_poc renderB [--frames N]`。 +执行机 GPU:**NVIDIA GeForce RTX 3060 Laptop GPU**,OpenGL 4.5.0 NVIDIA 555.97,硬件加速 True。 + +### 4.1 闸门:offscreen-smoke —— **OK(离屏 GL 可用)** + +命令:`gpr_poc offscreen-smoke` + +- 离屏 `vtkRenderWindow`(`SetOffScreenRendering(1)`+`SetShowWindow(false)`,256×256) + → 加 cube actor → `Render()` → `GetRGBACharPixelData` 读回。 +- 读回 65536 像素,非背景像素 **28224**(cube 正确画出)。 +- GL vendor=NVIDIA Corporation,renderer=RTX 3060 Laptop GPU,硬件加速 True。 +- **结论:离屏 GPU 渲染在本机可用**,继续真实基准(非编造)。 + +### 4.2 基准数据(line 001,更细一档 cellXY=0.05) + +命令:`gpr_poc build "D:\Downloads\明星路" --line 001 --cellXY 0.05 --cellZ 0.05 --out --levels 1` + +| 指标 | 值 | +|------|-----| +| 体维度(nx×ny×nz) | **44476 × 29 × 162** | +| 体素数 | 208,948,248(≈2.09 亿) | +| 整卷字节(int16,进显存判据) | 417,896,496 B(**398.54 MB**) | +| data.bin(含金字塔) | 199.43 MB(压缩比 2.00×) | +| build 峰值内存 | 4,830 MB(装配阶段 double survey 主导,同 §2.4) | +| 整卷加载耗时(renderB load) | ≈2.8–4.0 s | +| renderB 进程峰值内存 | **≈509 MB**(加载整卷 398 MB + 渲染管线) | + +无 build OOM,cellXY=0.05 一次通过,未调粗。 + +### 4.3 renderB 实测指标 —— **关键发现:整卷体绘制不可行** + +命令:`gpr_poc renderB --frames 120` + +| 指标 | 值 | +|------|-----| +| 离屏闸门复检 | OK | +| **体绘制 fps** | **INVALID(整卷超 3D 纹理上限)** | +| ├ raw_fps(空纹理渲染,不可信) | 295.6(仅作记录,非真实帧率) | +| ├ SmartVolumeMapper 渲染模式 | 2 = GPURenderMode | +| └ vtkVolumeTexture 报错 | `Invalid texture dimensions [44476, 29, 162]` | +| **切片扫描 fps** | **54.6 fps**(120 帧沿 Z 扫整卷,reslice+纹理) | +| 整卷进显存 | **否**(X=44476 > GL_MAX_3D_TEXTURE_SIZE=16384) | +| 降质重采样(LowRes) | 否(未触发;是直接纹理维度超限失败,非显存不足降质) | +| GPU 显存(NVX) | **N/A**(随包 VTK 安装未带 GLEW 头,无法直查 `NVX_gpu_memory_info`; + 但 GL 扩展列表确认该扩展存在,机器支持,仅本工具未链 GL loader) | +| 进程峰值内存 | ≈509 MB | + +#### 关键发现(务必看) + +1. **整卷体绘制在本机离屏下不可行**:测线 001 的 X 维(沿测线方向)= **44476**, + 远超本机 OpenGL `GL_MAX_3D_TEXTURE_SIZE = 16384`。`vtkSmartVolumeMapper` + 走 GPU 路径(mode=2)但底层 `vtkVolumeTexture` **无法将整卷上传为单张 3D 纹理**, + 报 `Invalid texture dimensions`。此时 `Render()` 实际未绘出体数据, + 故所谓 295 fps 是**空纹理渲染的假帧率,已如实标 INVALID,绝不上报为体绘制性能**。 +2. **切片扫描真实流畅**:切片走 `vtkImageReslice` 输出 2D 切面 + 2D 纹理着色, + **不受 3D 纹理维度上限约束**,实测 **54.6 fps ≥ 30fps 目标**,整卷切片交互流畅。 +3. **进显存判据**:整卷 398 MB 远小于 GPU 显存(RTX 3060 6GB),显存容量不是瓶颈; + 真正的瓶颈是**单轴纹理维度上限(16384)**,而非显存字节数。 + +#### 结论 + +- **切片**:✅ 本机离屏下整卷切片 ≥30fps(54.6fps),交互流畅,满足目标。 +- **整卷体绘制**:❌ 在「整卷成单张 3D 纹理」的朴素路径下**不可行**—— + 长测线 X 维超 GL 单轴上限。这正是 **Task 12(核外 / 分块 LOD / 体纹理分区 + `vtkOpenGLGPUVolumeRayCastMapper::SetPartitions`)** 必须解决的问题: + 要么沿 X 分区/分块上传,要么按视相机做 LOD 工作集。本任务(9c)按约束**不做核外**, + 仅如实记录此限制作为 Task 12 的硬性依据。 +- 该限制与显存容量无关,是 OpenGL 纹理维度硬上限;任何「整卷一次性 3D 纹理」方案 + 对长测线都会撞同一面墙。 diff --git a/docs/superpowers/plans/poc-results-C.md b/docs/superpowers/plans/poc-results-C.md new file mode 100644 index 0000000..46a5c00 --- /dev/null +++ b/docs/superpowers/plans/poc-results-C.md @@ -0,0 +1,72 @@ +# POC-C 单 mapper SetPartitions 整卷体绘制探针结果 + +## 体 +- 维度: 44476 x 29 x 162 (体素 208948248) +- 整卷字节: 417896496 B (398.537 MB, VTK_SHORT) +- store: D:\Git\lanbingtech\geopro\build\tmp\gpr_store_B_001 + +## 单 mapper SetPartitions +- mapper: vtkOpenGLGPUVolumeRayCastMapper (整卷单 image,不预切块) +- 分区数: SetPartitions(3, 1, 1) 每区上限 ≤16384 +- 纹理维度错误: 否 +- 渲出非空像素: 是 (非背景像素 1264) +- 体绘制 fps: 10.951667 +- 达交互级(≥15fps): 否 +- 进程峰值内存: 652.84 MB +- 源构造耗时: 2873.19 ms + +## 对照表 + +| 路径 | 是否渲出 | fps | +|---|---|---| +| renderB 整卷单 SmartVolumeMapper | INVALID(纹理墙) | — | +| renderC MultiBlock(每块一 mapper) | 渲出 | 9.5 静态/1.45 换页 | +| renderC-partitioned 单 mapper SetPartitions | 渲出 | 10.951667 | + +## 判据结论 +单 mapper SetPartitions 整卷体绘制【真渲出但未达交互级】(10.9517 fps <15)。VTK 这条路天花板暴露,需评估 OpenVDS/自建 GL。 + + + +# POC-C LOD-fps 探针结果(Task 12c) + +金字塔 store: tmp\store_lod_001(level0=44476x29x162,总 4 层) + +| 项 | 维度 | 结果 | +|---|---|---| +| (a) 粗层概览 fps | level2 11119x8x41 | 752.061589 fps (交互级) | +| (b) 全分辨率局部 fps | level0 局部 256x29x162 | 374.625725 fps (交互级) | +| (c) LOD 切换过渡 | 切换帧 60/120 | 平均 1.09062ms,切换帧 5.4629ms(尖峰 6.04704×),无可感知卡顿 | + +- 卡顿判据:切换帧绝对耗时 >33ms(2 个 60Hz 帧)才记可感知卡顿;16.7~33ms 记轻微抖动;亚毫秒基线下尖峰倍数大但绝对值低不算卡顿。 +- 双闸:纹理维度错误=否;三段均渲出非空像素=是(概览 1889 / 局部 167612 / 过渡 21924)。 +- 截图(人眼判“概览糊→拉近清晰”):docs/superpowers/plans/poc-lod-shots/lod-overview.png、lod-fullres-local.png、lod-transition-mid.png +- 进程峰值内存: 99.2266 MB + +## 判据结论 +粗层概览 + 全分辨率局部【都达交互级】且切换【无不可接受卡顿】→ LOD-based C 路线钉死可行。 + +**最低配未验声明**:本探针仅在本机(RTX 3060)跑得上限数字,最低配机器未验证,需用户在目标机跑或提供型号。 + + +# POC-C fps 预算探针结果(Task 12d ②) + +金字塔 store: tmp/store_lod_001(level0=44476x29x162,brick=64) + +递增 level0 局部窗口(沿线中段 brick 列)体绘制 fps: + +| brick段 | 体素数 | 体绘制 fps | ≥30fps | +|---|---|---|---| +| 4 | 1202688 | 218.251659 | 是 | +| 16 | 4810752 | 155.708373 | 是 | +| 64 | 19243008 | 240.948244 | 是 | +| 128 | 38486016 | 305.837001 | 是 | +| 256 | 76972032 | 329.654511 | 是 | +| 512 | 153944064 | INVALID | 否 | +| 695 | 208948248 | INVALID | 否 | + +- **每帧体素预算(fps≥30 上限)**: 76972032 体素(256 brick 列) +- 首个跌破 30 的窗口: 无(需更大 --bricks 段触达天花板) +- 双闸:纹理维度错误=是;每段均按非空像素校验。 +- production LOD 应把【每帧渲染的全分辨率块】卡在此预算以内。 +- **本机 RTX 3060 上限数;最低配需用户在目标机跑 fps-budget/view。** diff --git a/docs/superpowers/plans/poc-results-trackB.md b/docs/superpowers/plans/poc-results-trackB.md new file mode 100644 index 0000000..39f27d4 --- /dev/null +++ b/docs/superpowers/plans/poc-results-trackB.md @@ -0,0 +1,94 @@ +# Track B 总验收实测结果(build-stream 多线合并流式建体) + +工具:`tools/gpr_poc`(CLI),子命令 `build-stream`。 +执行机:Windows 11,MSVC(VS18 Community)+ Ninja,Release(/O2),开发机(RTX 3060 Laptop GPU 旁)。 +日期:2026-06-23。 + +整条流式链路(B1→B5 成果在 B6 串起来): +`assembleGprSurveySlab(道窗口)→ sampleGprPoint(结构化插值)→ StreamingVolumeWriter(增量 zlib 写 brick)→ buildPyramidStreaming(从盘 brick 逐级降采样)→ WholeVolumeSource(load)`。 + +> Track B 的核心命题:把建体管线改成沿 X 分段(slab)流式(逐段读道→插值→写 brick→释放), +> 使建「20 线合并大体」的峰值内存**有界、不随线数增长**、无 OOM,且产物可被 `load` 读回。 +> 对比基线:旧 double-survey 非流式方案,单线装配峰值 ≈4.2 GB,20 线 ≈84 GB → 装配阶段必然 OOM。 + +--- + +## 1. 验收命令 + +``` +gpr_poc build-stream "D:\Downloads\明星路" --cellXY 0.2 --cellZ 0.05 --out --levels 2 +gpr_poc load +``` + +- 合并方式:**沿 X 顺序排列**(退路近似)——各线按 brick 对齐的 X 偏移依次拼入一个连续 store。 + 真实 RTK 几何拼接(按各线实际平面坐标拼成路网大体)留 **Track D**。 +- 量化 scale/offset **全局一致**:先扫一遍全部 20 线得全局 min/max(流式下不能每 slab 各算), + 再以全局值域逐线逐 slab 写 brick。 + +--- + +## 2. build-stream 实测指标(20 线合并大体,cellXY=0.2,cellZ=0.05,levels=2) + +| 指标 | 值 | +|------|-----| +| 合并测线数 | **20** | +| 合并体维度(nx×ny×nz) | **156544 × 8 × 82** | +| 体素数 | **102,692,864**(≈1.03 亿) | +| 原始体积(int16,进显存判据) | **195.871 MB** | +| 落盘 data.bin(含金字塔各级) | **105.422 MB** | +| 压缩比(原始/落盘) | **1.858×** | +| 扫全局量化区间耗时 | **181,288 ms**(一次性读全 14 GB 原始道) | +| 建体(流式 slab→写 brick)耗时 | **89,927 ms** | +| 流式金字塔耗时 | **6,972 ms** | +| build-stream 端到端墙钟 | **278,788 ms(≈4.6 min)** | +| **峰值内存** | **246.164 MB** | + +### 峰值内存全程稳定(核心结论证据) + +20 线全程峰值内存稳在 **≈246 MB**(245.973 MB → 246.164 MB),**不随合并线数增长**: + +| 阶段 | 峰值内存 | +|------|----------| +| 扫全局量化 / 起始线 | 245.973 MB | +| 第 20 线写入完成 | 246.164 MB | + +即流式建体的峰值内存**有界**——只随单个 slab + 一行 brick 的工作集,与合并总量(线数/体素数)无关。 +对比旧 double-survey 非流式方案 20 线需 ≈84 GB,本方案以 246 MB 建出 1 亿体素的合并大体,**无 OOM**。 + +--- + +## 3. load 实测指标 —— 产物可读 ✓ + +命令:`gpr_poc load ` + +| 指标 | 值 | +|------|-----| +| 加载耗时 | **1,756 ms** | +| 整卷维度 | **156544 × 8 × 82** | +| 峰值内存 | **214.55 MB** | + +流式产出的合并大体 store 可被 `WholeVolumeSource` 完整加载、维度一致、**产物可读**。 + +--- + +## 4. Track B 总验收结论 + +- ✅ **峰值内存有界、与总量无关**:20 线合并大体建体全程峰值稳在 ≈246 MB + (245.973→246.164),不随线数增长。这是 Track B 的核心目标,达成。 +- ✅ **无 OOM**:旧非流式方案 20 线 ≈84 GB 必 OOM;流式方案 246 MB 即建出 1 亿体素合并体。 +- ✅ **产物可读**:`load` 在 1,756 ms 内读回,维度一致(156544×8×82),峰值 214.55 MB。 +- ✅ **压缩与落盘正常**:原始 195.871 MB → data.bin 105.422 MB(含金字塔各级),压缩比 1.858×。 + +### 已知约束 / 留待后续 + +1. **扫全局量化 181 s 是「读全 14 GB 原始道」的 I/O 开销**——为保证量化 scale/offset + 全局一致必须先全扫一遍 min/max。可后续优化(如复用建体阶段的单遍读、或用头估值域),本次不做。 +2. **合并几何用退路近似(沿 X 顺序排列,brick 对齐)**——非真实路网平面几何。 + 真实 RTK 几何拼接(按各线实际平面坐标拼成路网大体)留 **Track D**。 +3. **最低配未验**:仅在本机(RTX 3060 Laptop 开发机)实测;最低配机器内存/磁盘表现待补测。 + +### 与 Track C 的衔接 + +Track B 已证「大体能建(246 MB 有界)+ 能读(1.76 s 加载)」。Track C 在此大 store 上做 +视野自适应 LOD 体绘制(视锥裁剪+视距选层 → 视野区域单纹理重组 → 平滑切换+后台预取), +解决 POC-B §4 记录的「整卷 X 维超 GL 单轴纹理上限(16384)」问题。 diff --git a/docs/superpowers/specs/2026-06-15-vtk-3d-supplementary-design.md b/docs/superpowers/specs/2026-06-15-vtk-3d-supplementary-design.md new file mode 100644 index 0000000..2bc81a3 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-vtk-3d-supplementary-design.md @@ -0,0 +1,392 @@ +# VTK 三维视图「补充需求」设计 + +- 日期:2026-06-15 +- 分支建议:`feat/vtk-3d-view` +- 状态:**设计稿 v2(已纳入架构评审 + web 端实地分析)**。本轮只产出设计,实现分期另立 plan。 +- 需求来源:`D:\Projects\GEOPRO\Geopro3.0 需求表.xlsx` →「补充需求」页签(已逐行通读,§4 全文映射);交叉参考「DD类型」页签;原版 web 实地分析见 §1.5、记忆 [[web-3d-view-threetile]]。 +- 关键约束(用户已拍板): + 1. **后端 API 尚未就绪** —— 本轮用 `LocalSampleRepository` 静态数据驱动渲染与交互,**但仓储接口必须按真实后端形态设计好**(§6),将来后端到位只换实现、不动上层。 + 2. 三维相关 ddCode(`dd_Structual3D`/`dd_Property3D`/切片/异常体/任务记录)后端、客户端**都还没做**,是净增建。 + 3. **三栏结构 + 切片是客户端新需求**(web 端没有三栏,见 §1.5)。故这两块**以 xlsx 为规格、按 VTK 工程新设计**,不受"复刻须先实地学习"约束;而"参考 Geopro 1.0"的色阶/异常框(F26/F50)属复刻项、目前无 1.0 参考、先近似后精修。 + +--- + +## 0. TL;DR(一页结论) + +「补充需求」整页都是 **VTK 中央视图**的规格:把视图区拆成三个子列表栏(**三维数据集 / 二维数据集 / 三维分析**),并补齐坐标轴、相机预设、底图、**切片交互**、**异常体管理**、三维体详情、任务管理。 + +**当前真实状态(已源码核实,非推测):中央 VTK 是空壳。** +- `rebuildCentral`(`src/app/main.cpp:508-512`)只捕获 `showCurtain`,喂入**空 `sections`**;启动后中央区只有背景色 + 相机,不渲染任何数据。 +- 浮层勾选框 `chkVoxel/chkSlice/chkTerrain`(`main.cpp:603-617`)只改 `bool`,而 `rebuildCentralScene`(`src/app/CentralScene.cpp:16-47`)只处理 `is2D`/`showCurtain` 两支 —— 体素/切片/地形勾选**当前什么都不做**。 +- `slicePlane`(`vtkImagePlaneWidget`,`main.cpp:251`)声明后**全代码零使用**;`buildVoxelFromScatters/buildTerrain/loadVoxelScatters` 在 app 层**零调用**。 + +**好消息:render 层 actor 全部完整且有测试**(§5.1),数据源 `LocalSampleRepository` 也在。"之前用静态数据做的原型渲染"(git `7007619`/`9b77d07` 等)的**装配代码**在 `6241eb3`"CentralScene 数据驱动重构"时被摘除,但**渲染原语没丢**。 + +> 一句话:地基(actor 管线)稳固,缺的是「编排层 + 交互层 + 三栏 UI + 真实/样本数据接入」。 + +**评审已纳入的硬伤修正**(详见各节):① `Scene` 只能加 `vtkActor`、加不了体绘制 `vtkVolume`,"actor 零改动复活"对三维体不成立 → §5.2/§8 增 `vtkProp` 入口;② `I3dSceneRepository` 必须**异步**(项目已 ApiChain 异步化,同步签名会让"换实现不动上层"破产)→ §6;③ 任意切片**钉死 `vtkImageReslice`**(`vtkCutter` 切体素只出交线不出着色剖面)→ §9.1;④ 接口数据结构去裸 `double[]`/出参 → §6.2/§6.3。 + +--- + +## 1. 背景与问题 + +需求表「补充需求」页签定义的是 Geopro 1.0 风格的三维工作台 —— VTK 视图不再是"二维地图/三维视图"二选一的展示器,而是一个带**子列表 + 工具栏 + 右键菜单 + 切片/异常交互**的分析环境。 + +当前客户端(M1)的 VTK 现状与之差距: + +1. **结构不符**:现在是单一 `QVTKOpenGLStereoWidget` + 顶部「二维地图/三维视图」互斥分段按钮(`main.cpp:308-316`),左下一个统一数据集列表。需求要的是**视图内置三个子列表栏**。 +2. **不渲染数据**:见 §0,编排层喂空数据,所有图层勾选是死代码。 +3. **无坐标轴 / 无相机预设 / 无底图 / 无切片交互 / 无拾取联动 / 无 3D 异常体 / 无任务面板**:这些都是净增建。 +4. **后端缺接口**:已核对 `ApiClient` 端点(§6.0),只有 2D ERT 那套;三维体模型/切片保存/异常创建/任务记录端点客户端都没有,且后端也未做。 + +--- + +## 1.5 原版(web)实地分析结论(Playwright + JS 逆推,2026-06-15) + +实地分析 `http://tenant.geomative.cn/#/projectSpace/dataView`(详见记忆 [[web-3d-view-threetile]]): + +- **渲染技术栈**:web 3D 地球是 **ThreeTile(Three.js 瓦片地球)**——DOM 无 `cesium-widget`、单 WebGL2 canvas、`__THREE__` 在;Cesium 1.107 虽加载但本页未用。⇒ 桌面用 VTK 是**另一套实现**,三栏/切片是桌面新设计,不存在"web 怎么做桌面照搬"。 +- **底图多源已确证**(`threeMap` chunk):天地图/Google/Bing/高德/腾讯/ArcGIS/Mapbox/中科星图 ⇒ 坐实补充需求 C13 底图需求,桌面 VTK 需自建瓦片层。 +- **3D 数据通路已确证**(网络):勾选 ERT 对象 → 批量 `dd/ert/inversion/rows/{id}`(2D 反演剖面) + `lvl/colorGradation` → 在 3D 地理空间摆成**竖直帘面**(= 桌面 `CurtainActor` 目标)。 +- **3D 分析功能集已确证**(index chunk i18n `three*` 键):模型 选/移/转/缩放/恢复 + X·Y·Z 轴分散;剖切面 开/关/显/隐/平移/旋转;创建异常/异常列表;导出 dat/grd/bin/lvl。3D 模型从 URL 加载。⇒ 这些功能桌面要做,但**实现自定**(web 用 Three.js,桌面用 VTK)。 +- **web 不是三栏**:web 是 左(数据筛选+对象树)/中(地球+显示隐藏地图+滑块)/右(异常列表)。补充需求的三栏(三维数据集/二维数据集/三维分析)是**桌面新结构**。 +- **威立雅项目只有 2D 反演剖面(帘面),无真正三维体模型**;真三维体(`dd_Structual3D`,"地大展示")很可能在别的项目,待用户指认后再实地学习其切片/异常几何(不阻塞 P1–P3,因切片可对 LocalSample 体素开发)。 + +--- + +## 2. 目标与范围 + +### 2.1 总目标 +在现有 render actor 地基上,复活并扩展中央 VTK 为「补充需求」描述的三维分析工作台;**用静态样本数据驱动**,但所有数据出入口走**为真实后端预留的仓储接口**(§6)。 + +### 2.2 范围内(本设计覆盖) +- 三子列表栏 UI 结构(三维数据集 / 二维数据集 / 三维分析)。 +- 三维数据集栏:坐标轴设置、水平/垂直比例、快捷视图(前后左右上下)、Zoom(In/Out/Fit)、数据集勾选 → 渲染。 +- 二维数据集栏:底图(天地图/Google/隐藏)、2D 视图切面位置、数据集勾选 → 渲染。 +- 三维分析栏:体模型/切片树、右键菜单、切片交互工具、拾取选中联动、切片分析(含创建异常、保存为数据集、导出、正视/翻转/关闭)。 +- 三维体数据集详情、切片数据集详情。 +- 异常/异常体管理(树、删除、属性、VTK 联动、显示过滤、截图属性)。 +- 任务管理(任务记录 + 可使用任务列表)。 +- **仓储接口设计**(§6)—— 本设计的重头,决定将来接后端的成本。 + +### 2.3 范围外 / 后续 +- 后端真实接口实现(本轮仅设计接口 + LocalSample 实现)。 +- 克里金插值(IDW 已有;克里金作为插值模型选项后续补)。 +- 二维底图的高保真瓦片缓存/离线(本轮先打通"能显示底图")。 +- 与 Web 端完全像素级一致的色阶编辑器(沿用现有 colorBar 解析)。 + +### 2.4 设计原则 +- **render 层零业务**:actor 只吃 `core::*` 数据结构(`Grid/ScalarVolume/ScatterField/Anomaly`),不认 ds/API。复活编排即可,不改 actor。 +- **接口先行**:上层只依赖 `I3dSceneRepository`(§6),`LocalSample3dRepository` 与未来 `Api3dRepository` 可互换。 +- **交互独立成层**:新建 `src/render/interact/`(README 早有规划但目录不存在),切片/拾取/圈异常都是 `InteractionTool`。 + +--- + +## 3. 与既有架构的关系 + +``` + ┌─────────────────────────── 中央 VTK 工作台 ───────────────────────────┐ + ObjectTreePanel │ ┌ 子列表栏(三选一/并列)────────┐ ┌ VTK 视图区 ──────────────────┐ │ + (左上,勾选对象) │ │ [三维数据集] [二维数据集] [三维分析]│ │ Scene(renderer) + actors │ │ + │勾选 │ │ · 坐标轴/比例/快捷视图/Zoom 工具条 │ │ + CubeAxes + 底图层 │ │ + ▼ │ │ · 数据集勾选列表(按维度属性筛) │ │ + InteractionManager(切片/拾取)│ │ + VtkSceneController ─┼─→ I3dSceneRepository ─→ core::数据 ─→ render actors ─→ Scene │ │ + │ └────────────────────────────────┘ └──────────────────────────────┘ │ + └──────────────────────────────────────────────────────────────────────┘ + 右栏: 三维体/切片 详情 · 异常体树 · 任务管理(任务记录 + 可用任务列表) +``` + +- `VtkSceneController`(新)取代当前散落在 `main.cpp` 的 lambda 编排,订阅"勾选对象/数据集/图层/视图模式"变化,经 `I3dSceneRepository` 取数据,调 render actor 重建场景。 +- 现有 `WorkbenchNavController`(对象/数据集导航)、`DatasetDetailController`(详情)保持职责不变,新控制器与之并列。 + +--- + +## 4. 「补充需求」全文逐行映射(权威对照表) + +> 维度属性来源:需求称"ds 类型的显示属性为 3D/2D",即**维度属性挂在 ds 类型上**(非单条 ds)。`DsRow` 当前无此字段(`src/data/repo/RepoTypes.hpp:10`),须由 §6.1 的类型→维度映射补。 + +| 行 | 需求原文(摘) | 现状 | 落地方式 | +|---|---|---|---| +| A1 | VTK 提供三子列表栏:三维数据集/二维数据集/三维分析 | 单一 2D/3D 切换 | §7.1 三栏 UI | +| C3–I3 | 坐标轴显示方式:标准/三维立体/不显示 | 无坐标轴 | `vtkCubeAxesActor`(标准)/`vtkAxesActor`(立体)/隐藏;§7.2 | +| D4 | O 点位置 | 无 | 坐标轴原点配置(世界系原点 / 数据包围盒角)| +| D5–I5 | 刻度:无刻度/米/英尺/经纬度/字体 | 无 | 刻度单位换算 + 经纬度用 `GeoLocalFrame` 反算 + 字体设置 | +| C6 | 水平/垂直比例 | 硬编码 `kVerticalExaggeration=2.0`(`main.cpp:208`)| 做成可调,作用于全部 3D actor `SetScale(1,1,VE)` | +| C7 / D7–I7 | 快捷视图:前/后/左/右/上/下 | `CameraPreset` 仅 Top2D/Free3D | 新增 6 方向预设(§5.3)| +| C8 / D8–F8 | Zoom:In/Out/Fit | 仅 `ResetCamera`(=Fit) | In/Out 调 `camera->Zoom()`;Fit=ResetCamera | +| C9–D10 | 三维数据集列表:筛勾选对象中 ds 维度=3D;勾选多个显示 | sections 为空 | §6.1 维度筛选 + §8 编排复活 | +| C13–F13 | 地图:天地图/Google Map/隐藏 | 中央无底图 | §7.3 底图层(VTK 瓦片)| +| C14 | 2D 视图:关闭/Z=0/顶部/底部/自定义 Z | 无 | 切面高度控制 | +| C15–D16 | 二维数据集列表:ds 维度=2D | 同上 | §6.1 + §8 | +| C19–D20 | 三维分析列表:按 对象/三维体模型/切片 树 | 无 | §7.4 分析树 | +| D21,F22–G22 | 右键·三维体:上下切片 | 无 | §9 轴向切片工具(水平面)| +| F23 | 前后切片 | 无 | §9 轴向切片 | +| F24 | 左右切片 | 无 | §9 轴向切片 | +| F25 | 任意切片(初始 45°,可任意调整)| 无 | §9 任意角度切片(`vtkPlaneWidget` 控面 + **`vtkImageReslice`** 重采样着色,非 `vtkCutter`)| +| F26 | 色阶(参考 Geopro 1.0 优化)| 有 colorBar 解析 | 复用 `ColorScale` + 色阶编辑入口 | +| F27 | 显示/隐藏 | 无 | actor 可见性 | +| F28–G28 | 数据详情 → 详情栏 | 详情面板在 | §10 三维体详情类型 | +| E29,F30–F36 | 右键·切片数据集:保存/保存为/导出/删除/色阶/显隐/详情 | 无 | §6.3 切片 CRUD 接口 + §10 | +| C38,D39 | 选中三维体/切片为中心拖动旋转 | 无拾取/自定义 style | §9.3 拾取 + 自定义 InteractorStyle | +| D40 | 双击切片 → 正视该切片 | 无 | §9.3 双击→相机正视切面法向 | +| C44,D44 | 右键三维体创建切片工具 | 无 | §9 | +| D45 | 双击切片工具正视 | 无 | §9.3 | +| D46 | 选中切片滚轮 → 内外切片(对象=所属三维体)| 无 | §9.2 滚轮沿法向平移切面 | +| D47 | 切片任一位置可保存为新切片数据集 | 无 | §6.3 createSlice | +| D48,E49,F49–F50 | 创建异常:异常工具,光标拾取起点;结束保存弹框(确定截图大小/异常坐标,参考 1.0)| 有 2D `AnomalyActor`,无创建流 | §9.4 异常圈定工具 + §6.4 saveAnomaly + 截图 | +| E51–F51 | 保存为数据集 | 无 | §6.3 | +| E52–F52 | 导出为图片 | 无 | `vtkWindowToImageFilter` | +| E53–F53 | 导出到 dat | 无 | 切面采样导出 | +| E54–F54 | 正视图 | 无 | §9.3 | +| E55–F55 | 视图翻转(水平旋转 180°)| 无 | 相机 `Azimuth(180)` | +| E56–F56 | 关闭(取消当前切片)| 无 | 移除切片工具 | +| A58,B59–B65 | 三维体详情:源数据/切片数据/异常/插值模型(克里金,IDW)/插值参数/色阶参数/测量(点数,体积)| 详情引擎在,类型未做 | §10.1 | +| A67 | 切片详情:参照 dd_Section | `dd_section` 渲染已有 | §10.2 复用 | +| A69,B70,C71–D77 | 异常体列表树:异常体→分组→异常 | `ObjectExceptionPanel` 只读树在 | §11 扩展为可操作 | +| C79,D80–D81 | 操作:删除/删除分组 | 无 | §6.4 delete/deleteGroup | +| C83,C84 | 异常属性 + VTK↔列表 双向选中联动 | 无联动 | §9.3 + §11 | +| B86,C87–F87 | 异常体显示过滤:全部显示/随GS/随数据集/全部隐藏 | 无 | §11 过滤模式 | +| B88,C88 | 异常增加截图属性(保存标识时截图)| 无 | §6.4 截图字段 | +| A90,C90 | 任务管理:任务记录 + 可使用任务列表 | 有 `model/list`,无任务面板 | §12 | +| B91,C91 | 任务记录:当前数据集调用任务的记录 | 无 | §6.5 taskRecords | +| B92,C92 | 可使用任务列表:与 ds 类型相符的任务插件 | `ModelInfo`(model/list) 在 | §6.5 usableTasks(按 ddType 过滤) | + +--- + +## 5. 现状资产盘点(render 层,已源码核实) + +### 5.1 可直接复用的 actor(完整 + 有测试) +| 组件 | 文件 | 吃什么 | 产出 | 复用于补充需求 | +|---|---|---|---|---| +| ScatterActor | `src/render/actors/ScatterActor.*` | `ScatterField+ColorScale` | 散点 actor | 三维体源数据点 | +| GridContourActor | `actors/GridContourActor.*` | `Grid+ColorScale` | 色带+等值线 | 切片面着色 | +| VoxelActor | `actors/VoxelActor.*` | `ScalarVolume+ColorScale+原点/间距` | **GPU 体绘制 `vtkVolume`**,可导出内部 `vtkImageData` | 三维体模型渲染 + 切片输入 | +| VoxelFromScatters | `render/VoxelFromScatters.*` | 多剖面散点+CRS+frame | IDW→体绘制+image | 三维体(散点插值)| +| AnomalyActor | `actors/AnomalyActor.*` | `vector` | 点/折线/多边形(**2D 剖面**)| 切片上异常圈定(需扩 3D 异常体)| +| TerrainActor | `actors/TerrainActor.*` | DEM+影像 GeoTIFF | 纹理地形 | 三维场景地形图层 | +| CurtainActor | `actors/CurtainActor.*` | `Grid+ColorScale+frame` | 竖直帘面 | dd_section 三维帘面 | +| MapLineActor | `actors/MapLineActor.*` | `Grid+frame` | 俯视红线 | 二维数据集测线 | +| ElectrodeActor | `actors/ElectrodeActor.*` | `Grid` | 电极 ▼ | 剖面电极 | +| ColorLutBuilder / ContourBands | `render/ColorLutBuilder.*` `ContourBands.*` | colorBar | LUT / 离线等值线几何 | 色阶 / 导出 | + +### 5.2 缺口(净增建) +- **Scene 加不了体绘制**(评审 HIGH):`Scene::addActor(vtkActor*)`(`src/render/Scene.hpp`)只收 `vtkActor`,而 VoxelActor 产物 `vtkVolume` 是 `vtkProp3D`、**不是** `vtkActor`。三维体(核心需求)渲染必经此口 → P1 须给 Scene 加 `addViewProp(vtkProp*)`(或 `addVolume(vtkVolume*)`),5 行小改,但"actor 零改动复活"对三维体不成立。 +- **坐标轴**:无 `vtkCubeAxesActor`/`vtkAxesActor`。 +- **相机预设**:`CameraPreset.cpp` 仅 `applyTop2D`/`applyFree3D`,缺前后左右上下 + Zoom In/Out。 +- **底图**:无 VTK 瓦片底图层(README 提的 `ground/TileGroundLayer` 未实现)。 +- **切片交互**:`vtkImagePlaneWidget` 声明未用;无 reslice/cutter/任意角度切片;`src/render/interact/` 目录不存在。 +- **拾取/选中联动**:无 picker、无自定义 `vtkInteractorStyle`。 +- **3D 异常体**:`AnomalyActor` 仅 2D 剖面;无三维异常体 actor、无 VTK↔列表联动。 +- **截图**:无 `vtkWindowToImageFilter` 接线。 +- **编排层**:`rebuildCentralScene` 喂空数据;图层勾选死代码。 + +### 5.3 CameraPreset 扩展(小量、低风险,可独立先做) +新增 `applyView(vtkRenderer*, ViewDir)`,`ViewDir ∈ {Front,Back,Left,Right,Top,Bottom}`,设置 position/focalPoint/viewUp 后 `ResetCamera`;`zoomIn/zoomOut` 调 `camera->Zoom(1.2 / 0.83)` 后 `Render`。 + +--- + +## 6. 仓储接口设计(本设计重头 —— 用户要求"接口设计好") + +> 现状:`IDatasetRepository`(`src/data/repo/IDatasetRepository.hpp`)已有 2D 那套(loadGrid/Scatter/ColorScale/Anomalies)。本设计**新增并列接口 `I3dSceneRepository`**,承载三维体/切片/异常体/任务。本轮 `LocalSample3dRepository` 实现(读样本文件 + 内存态增删),将来 `Api3dRepository` 换实现。 + +> **接口必须异步(评审 HIGH)**:项目已 ApiChain/`IAsyncProjectRepository` 异步化。`I3dSceneRepository` 取数方法**一律走回调/Qt 信号**(如 `void loadVolume(dsId, std::function onOk, OnErr onErr)`,或返回 `ApiChain`),LocalSample 同步数据也包成异步壳。否则将来换 Api 实现会阻塞 UI、上层全要改,"换实现不动上层"破产。下文签名为**简化示意(省略回调形参)**,落地按异步范式。 + +### 6.0 后端端点现状(已核对,非推测) +现有 `ApiClient` 仅:`dd/ert/{grid,inversion,measurement,trajectory}`、`dsObject` CRUD/import/detail、`exception/queryException`(**只读**)、`model/list`、`lvl/colorGradation`、`templateExport`。 +**缺**:三维体模型数据、切片 CRUD、异常**创建/保存**、任务记录。→ 本轮接口为这些预留方法签名,LocalSample 内存实现。 + +### 6.1 维度属性(解决"ds 维度=3D/2D"筛选) +```cpp +enum class DsDimension { Dim2D, Dim3D, Analysis3D, Other }; +// 由 ds 类型(ddCode / dsTypeId)决定。LocalSample 用内置映射表; +// 未来后端可在 dsObject/getDetail 返回 dimension 字段。 +DsDimension dimensionOf(const DsRow& ds) const; // 列表筛选用 +``` +> LocalSample 映射(依据「DD类型」页签语义):`dd_Structual3D`/`dd_Property3D`/`dd_voxel` → `Dim3D`/`Analysis3D`;`dd_section`/`dd_inversion_data` 帘面可入 3D,俯视测线入 2D;`dd_trajectory_data` → `Dim2D`。 + +### 6.2 三维体模型数据集 +```cpp +struct VolumeModelMeta { + std::string dsId, name; + std::string interpMethod; // "IDW" / "Kriging" / ... + std::vector interpParams; // 插值参数(复用既有动态表单字段) + std::string colorScaleId; // 色阶参数引用 + double pointCount = 0, volume = 0; // 测量数据(点数/体积) + std::vector sourceDsIds; // 源数据集 + std::vector sliceDsIds; // 切片数据集 + std::vector anomalyBodyIds; // 异常体 +}; +// 规则体 + 原点/间距聚合返回(去 double& 出参,评审 MEDIUM) +struct VolumeGrid { + geopro::core::ScalarVolume vol; + std::array origin; // ox,oy,oz + std::array spacing; // dx,dy,dz + double vmin = 0, vmax = 0; +}; +VolumeModelMeta loadVolumeMeta(const std::string& dsId); // 异步示意 +VolumeGrid loadVolume(const std::string& dsId); // 异步示意 +``` +> LocalSample:用 `VoxelFromScatters` 把样本两条交叉剖面散点 IDW 成 `ScalarVolume` 当作一个三维体模型(产物即含 `vtkImageData`,切片直接复用)。 + +### 6.3 切片数据集(CRUD —— 后端缺,先内存实现) +```cpp +struct SliceSpec { + std::string volumeDsId; // 所属三维体 + std::array origin; // 切面一点(去裸数组,评审 MEDIUM) + std::array normal; // 切面法向(轴向/任意) + std::string colorScaleId; +}; +struct SliceDataset { std::string dsId, name; SliceSpec spec; geopro::core::Grid section; }; +// 落库 CRUD(进仓储): +std::string createSlice(const SliceSpec& spec, const std::string& name); // 保存为数据集→返回新 dsId +void saveSlice(const std::string& dsId, const SliceSpec& spec); // 保存 +void deleteSlice(const std::string& dsId); +// 导出:导图片/dat 由 UI 层用 vtkWindowToImageFilter / 采样写文件(不入仓储) +``` +> **实时切片不进仓储(评审 MEDIUM)**:交互拖切面时每帧重采样应直接走 VTK 管线(`vtkImageReslice` 挂在切片工具上,§9.1),**不**经 repository 回算 `Grid`(避免慢路径)。仓储只管"切片保存为数据集"这种持久化动作。 + +### 6.4 异常 / 异常体(树 + CRUD + 截图) +```cpp +struct AnomalyBody { // 对应"异常体"(树中间层) + std::string id, name, typeName; + std::vector members; // 组内异常(复用既有 2D Anomaly;3D 用包围几何) +}; +struct AnomalyTree { // 对象 → 异常体/分组 → 异常 + std::vector bodies; + std::vector loose; // 未分组异常 +}; +AnomalyTree loadAnomalyTree(const std::string& objectId); +std::string saveAnomaly(const core::Anomaly& a, const std::string& screenshotPngPath); // 截图属性 +void deleteAnomaly(const std::string& anomalyId); +void deleteAnomalyGroup(const std::string& bodyId); +// 显示过滤是 UI/渲染层状态,不进仓储:enum AnomalyFilter{All,FollowGs,FollowDs,None} +``` + +### 6.5 任务管理 +```cpp +struct TaskRecord { std::string id, taskName, status, createTime; }; // 任务记录 +struct UsableTask { std::string scriptId, scriptCode, name; int opType; }; // = 既有 ModelInfo +std::vector loadTaskRecords(const std::string& dsId); // 当前 ds 的调用记录(后端缺→空/样本) +std::vector loadUsableTasks(const std::string& ddCode); // 按 ds 类型过滤插件(复用 model/list) +``` + +--- + +## 7. UI 设计 + +### 7.1 三子列表栏(客户端新设计 —— web 无三栏) +> 已确认(§1.5):web 端**没有三栏**,三栏是补充需求为桌面定义的新结构,以 xlsx 为准设计。 + +在 VTK dock 内(或左侧新 dock)放三个可切换 tab/分段:**三维数据集 / 二维数据集 / 三维分析**。每栏顶部是该栏专属工具条,下方是数据集勾选列表(按 §6.1 维度筛选当前勾选对象的 ds)。 + +**职责切分(决策,不再是开放问题)**:保留现有 `ObjectTreePanel`(左上勾选对象)作为"对象勾选源";三栏列表只在被勾选对象范围内、按维度过滤显示 ds。**左下数据集列表服务"详情查看",三栏列表服务"VTK 渲染勾选"**——两条线并存。依据:web 端本身也是"对象树勾选 + 独立异常列表"的多列表结构,两条线与原版心智一致;且补充需求明确三栏服务于 VTK 渲染,与详情查看是不同动作。 + +### 7.2 三维数据集栏工具条 +坐标轴下拉(标准/立体/不显示)+ O 点 + 刻度(无/米/英尺/经纬度)+ 字体 | 水平/垂直比例(滑块/输入)| 快捷视图(前后左右上下 6 钮)| Zoom(In/Out/Fit)。 + +### 7.3 二维数据集栏 +底图下拉(天地图/Google/隐藏)+ 2D 视图位置(关闭/Z=0/顶部/底部/自定义 Z)。 + +**底图层(评审 HIGH:被低估,单列风险子项)**:web 用 ThreeTile 多源瓦片(§1.5 已确证:天地图/Google/Bing/高德/腾讯…)。桌面 VTK 无现成瓦片图层,需自建小型瓦片引擎:可视范围 → 瓦片行列号 → 异步拉 PNG → 贴 `vtkPlaneSource`+texture → 随相机换 LOD。**关键配准**:天地图瓦片是 EPSG:3857,须把每块瓦片范围反算到 `GeoLocalFrame` 局部米才能与帘面/体素对齐(复用 `TerrainActor` 已有的 3857→4326→frame 流程)。本轮先打通"天地图能显示+隐藏",Google/缓存/LOD 后续。**排 P5**(复杂度高、依赖天地图 token)。 + +### 7.4 三维分析栏 +树:对象 → 三维体模型数据集 → 切片。右键菜单按节点类型分派(三维体 / 切片,见 §4 行 D21–F36)。 + +--- + +## 8. 渲染编排层 `VtkSceneController`(复活原型) + +职责:聚合"勾选对象 + 当前栏 + 图层开关 + 视图模式"→ 经 `I3dSceneRepository` 取 `core::*` → 调 actor → `Scene`。**取代 `main.cpp` 里的 `rebuildCentral` lambda 与死掉的图层标志。** + +```cpp +class VtkSceneController : public QObject { + // 输入信号:勾选对象变 / 栏切换 / 图层勾选 / 比例变 / 快捷视图 / 切片增删 + // 内部:维护 ScalarVolume/Grid 缓存,按需重建;统一 SetScale(1,1,VE) + // 输出:scene.clear()+addActor()+renderWindow->Render() +}; +``` +落地第一步(最低风险、最快见效):把 `extractSlice`/`loadVolume`/`buildCurtain`/`buildVoxelFromScatters`/`buildTerrain` 用真实勾选 ds 接通 —— **render actor 零改动即复活**。 + +--- + +## 9. 交互层 `src/render/interact/`(新目录,最重模块) + +### 9.1 切片工具 +- **轴向切片**(上下/前后/左右):`vtkImagePlaneWidget` 贴体素 `vtkImageData`(VoxelActor 已能导出 image),沿固定轴向、自动重采样着色,角度不可调(符合 G22–G24)。 +- **任意切片**(F25):`vtkPlaneWidget`(控制面位姿)+ **`vtkImageReslice`**(按斜面对体素重采样出着色剖面),初始 45°,可任意旋转。**不用 `vtkCutter`**(评审 HIGH:cutter 切 `vtkImageData` 只产几何交线/多边形,得不到带标量的连续剖面图)。 +- 统一抽象 `SliceTool`:持切面位姿 + `vtkImageReslice`,直接产出贴 `vtkImageActor` 的着色剖面(避免回算 `core::Grid` 的绕路;仅"保存为数据集"时才转 Grid 落库)。 + +### 9.2 滚轮切片(D46) +选中切片 → 自定义 InteractorStyle 截 `OnMouseWheel` → 沿切面法向平移 origin → 重算切面。 + +### 9.3 拾取 + 选中联动(C38/D39/D40/C84) +`vtkPropPicker` + 自定义 `vtkInteractorStyle`: +- 选中三维体/切片 → 以其包围盒中心设相机 focalPoint(拖动绕其旋转)。 +- 双击切片 → 相机正视切面法向(D40/D45/E54)。 +- 拾取异常体 ↔ 异常树列表双向高亮(C84)。 + +### 9.4 异常圈定工具(D48/E49/F49–F50) +自定义 widget:光标拾取起点 → 连续描点成多边形(复用 `core::Anomaly` Polygon)→ 结束弹保存对话框(截图大小 / 异常坐标,参考 Geopro 1.0)→ `saveAnomaly(a, screenshot)`。截图用 `vtkWindowToImageFilter`。 + +> **异常几何 = 切片面上的多边形(决策,不再是开放问题)**:需求 F49"以光标拾取点为起点圈异常"+ web 同款(在切片上描点),即异常是**画在切片平面上的多边形**——本质 2D-in-3D,把多边形顶点存为切面局部坐标 + 切面位姿即可在 3D 中定位。**不需要真三维体几何**。故 `core::Anomaly`(2D 多边形) 复用成立,§6.4 `AnomalyBody.members` 用 `core::Anomaly` + 所属切面引用即可。截图属性(B88)即圈定时的视图截图。 + +--- + +## 10. 详情视图 + +### 10.1 三维体详情类型(新) +详情面板(`DatasetDetailPanel`,ddCode 驱动)新增三维体详情:源数据 / 切片数据 / 异常 / 插值模型(IDW·克里金) / 插值参数 / 色阶参数 / 测量(点数·体积)。数据来自 `VolumeModelMeta`(§6.2),多为表单/列表展示。 + +### 10.2 切片详情 +参照 `dd_section`(已有渲染),复用现有等值面+等值线引擎。 + +--- + +## 11. 异常体管理面板 + +扩展现有 `ObjectExceptionPanel`(当前只读树)为可操作: +- 树:对象 → 异常体 → 异常(含未分组异常)。 +- 操作:删除 / 删除分组(§6.4)。 +- 异常属性面板(选中异常显详情)。 +- VTK↔列表双向选中联动(§9.3)。 +- 显示过滤:全部显示 / 随 GS / 随数据集 / 全部隐藏(渲染层可见性状态)。 +- 异常截图属性(保存时存截图,§6.4)。 + +--- + +## 12. 任务管理面板(新) + +两段: +- **任务记录**:当前数据集的任务调用记录(`loadTaskRecords`,后端缺→样本/空态)。 +- **可使用任务列表**:按当前 ds 的 ddCode 过滤的任务插件(`loadUsableTasks`,复用 `model/list`)。 + +--- + +## 13. 开放问题 / 待确认 + +### 已决(评审 + 实地分析后关闭) +- ✅ **三栏 vs 左下列表**:两条线并存(三栏=渲染勾选、左下=详情查看),web 本身即多列表结构(§7.1)。 +- ✅ **异常 3D 表达**:异常 = 切片面上的 2D 多边形,复用 `core::Anomaly`,无需真三维体几何(§9.4)。 +- ✅ **任意切片技术**:`vtkImageReslice`,非 `vtkCutter`(§9.1)。 +- ✅ **接口异步 + 数据结构**:异步范式 + `VolumeGrid`/`std::array` 聚合(§6)。 + +### 仍开放(不阻塞 P1/P2,相关期开工前定) +1. **坐标系单位 / O 点**(§7.2,P2):刻度"经纬度"用 `GeoLocalFrame` 反算;"米/英尺"基于世界米。O 点默认世界原点还是数据包围盒角?→ P2 开工前定,倾向"数据包围盒角"。 +2. **切片"保存为数据集"落库形态**(§6.3,P3):本轮 LocalSample 内存态;是否持久化到本地文件以便重启可见? +3. **真三维体项目**(§1.5,P3/P4):威立雅无真三维体;需用户指认有 `dd_Structual3D` 的项目,实地学习其切片/异常几何与交互细节。**不阻塞 P3**(切片对 LocalSample 体素先开发)。 +4. **"参考 Geopro 1.0"**(F26 色阶 / F50 异常保存框,P4):目前无 1.0 参考 → 先用现有 colorBar 解析 + 标准保存框近似,拿到 1.0 后精修。 +5. **底图 token / Google 可用性**(§7.3,P5):天地图需 token;Google 国内可用性。本轮先天地图 + 隐藏。 + +--- + +## 14. 分期建议(详细 plan 另立,此处仅排序) + +1. **P1 复活渲染(地基,低风险)**:`VtkSceneController` + `I3dSceneRepository`/`LocalSample3dRepository` + 勾选 ds → 真实/样本数据 → actor。复活帘面/体素/地形/切面着色。 +2. **P2 三维数据集栏(纯前端,独立)**:坐标轴 + 水平/垂直比例 + 快捷视图 6 向 + Zoom。 +3. **P3 三维分析·切片交互(最重)**:`interact/` + 轴向/任意切片 + 滚轮 + 拾取联动 + 双击正视 + 正视/翻转/关闭 + 切片 CRUD + 导出。 +4. **P4 异常体 + 圈异常 + 详情 + 任务**:异常圈定工具 + 异常体树管理 + 三维体详情 + 任务面板(依赖 1.0 参考)。 +5. **P5 二维底图**:天地图瓦片层 + 2D 视图位置。 + +> 依赖:P1/P2/P3 均可用 xlsx + 现有代码 + LocalSample 开工(切片对 LocalSample 体素开发,§1.5)。P4 的色阶/异常框精修依赖 Geopro 1.0 参考(先近似);P3/P4 的真三维体细节待用户指认 3D 项目后实地补。P5 底图依赖天地图 token。 +> +> **可立即开工:P1(含 Scene 加 vtkProp 入口)+ P2。** 评审结论:spec 修订后可作为 plan 基础。 diff --git a/docs/superpowers/specs/2026-06-16-vtk-3d-three-column-refactor-design.md b/docs/superpowers/specs/2026-06-16-vtk-3d-three-column-refactor-design.md new file mode 100644 index 0000000..3911ca0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-vtk-3d-three-column-refactor-design.md @@ -0,0 +1,130 @@ +# VTK 三维视图「三栏结构重构」设计 + +- 日期:2026-06-16 +- 分支:`feat/vtk-3d-view` +- 状态:**设计稿(经高保真原型逐项对齐用户反馈后定稿)** +- 上位文档:`2026-06-15-vtk-3d-supplementary-design.md`(总设计 v2)。本文是其 **A1 三栏结构** 的实现增量设计,**就 UI 形态与若干控件细节,取代总 spec §7.1 中"左侧新 dock / tab 二选一"的开放表述**。 +- 高保真原型:`docs/superpowers/mockups/2026-06-16-three-column-layout.html`(深色令牌取自 `src/app/Theme.cpp`;默认即定稿方案)。 +- 需求来源:`Geopro3.0 需求表.xlsx`「补充需求」页签(已逐行精读,行号见下文)。 + +--- + +## 0. 目标与范围(用户已确认) + +把当前"旧二维/三维切换 + 三个浮层"的过渡态 UI,重组为需求 A1 的**三个子列表栏**,并接通到已有的渲染/交互能力。 + +### 本轮 IN +1. **三栏内嵌侧栏**(VTK 视图内左侧抽屉 + 三 tab + 可折叠);删除旧「二维地图/三维视图」互斥切换;中央 dock 改名 **「VTK视图」**。 +2. **收编三浮层**:左上 `layerPanel`(图层勾选)、右上 `axisBar`(P2 工具条)→ 并入「三维数据集」栏;左下 `sliceBar` → **删除**(切片改走三维分析右键菜单)。 +3. **三维数据集栏**:工具条 4 栏位接 P2 已实现控制器;数据集列表按 `dimensionOf` 过滤 3D ds + 勾选 → 接现有 `VtkSceneController` 渲染。 +4. **二维数据集栏**:列表按 2D 过滤;「地图 / 2D视图」控件做出来(底图瓦片渲染留 P5,本轮控件 UI + 2D 视图 Z 平面接通)。 +5. **三维分析栏**:对象→三维体模型→切片 树;两个右键菜单 UI 完整;右键「切片」(上下/前后/左右/任意) **接已有 `SliceTool`**(替代 sliceBar)。 +6. **VTK视图 + 数据详情** 标题栏右侧加**全屏按钮**。 + +### 本轮 OUT(菜单项可见但暂 stub/禁用,留 P4/P5) +- 切片 CRUD:保存 / 保存为 / 导出 / 删除(P4,`I3dSceneRepository` 接口已留位)。 +- 色阶编辑(F26 参考 Geopro 1.0,无参考,留 P4)。 +- 底图瓦片渲染(P5)、异常体管理 / 三维体详情 / 任务管理(P4)。 + +--- + +## 1. 核心架构决策(关键澄清) + +**全工作台只有一个共享的中央 VTK 视图**,三栏是叠在其上的「子列表栏」,各自把数据喂进同一视图。证据(需求交叉验证): +- 行 2「**VTK视图上**提供三个子列表栏」;行 11「显示在 VTK 中」;行 16「显示在 VTK 的 2D 视图」;行 19「显示在 vtk 的三维视图」——三处同指一个 VTK。 +- 需求行 36「VTK视图」挂在「三维分析」下,**只是把视图交互(选中拖动旋转 / 双击正视)归类描述**,不代表视图归三维分析独有。 + +**推论(本轮落地):** +- 旧「二维地图/三维视图」互斥分段按钮(`main.cpp:308-316` 一带)**删除**。 +- 2D 不再是独立视图模式,而是 VTK 视图里的一个 **2D 图层/平面**(底图 + 2D 数据,摆在某 Z 平面),由「二维数据集」栏控制。 +- 中央 dock 名 `二维地图/三维视图` → **`VTK视图`**(同时承载 2D/3D,且为需求原文叫法)。 + +--- + +## 2. 三栏物理形态(方案 C·视图内嵌侧栏) + +- 三栏 = **VTK 视图内左侧的抽屉式侧栏**,三个 tab:`三维数据集 / 二维数据集 / 三维分析`,一次显一栏。 +- 画布在侧栏右侧、**不被遮挡**;侧栏右缘有折叠开关(◀/▶),折叠后画布全宽。 +- 侧栏与画布同属 VTK 视图容器(侧栏是视图子控件,符合"VTK视图上提供")。 +- 左侧保留现有 `ObjectTreePanel`(「对象」dock)作为**筛选来源**:三栏列表只在"被勾选对象"范围内、按维度过滤显示 ds(需求行 10/15)。两级关系:勾对象 → 三栏按维度显示其 ds。 +- 现有左下「数据集」dock(详情查看)保留,与三栏列表并存(总 spec §7.1 已定的"两条线")。 + +> 取舍记录:曾考虑"左侧独立 dock + tab"(方案 A)与"竖向分段"(方案 B),均被否——需求「VTK视图**上**」明确栏在视图内;浮窗式(最初的 C)遮挡画面亦否,改为抽屉式。 + +--- + +## 3. 各栏内容(逐行对齐需求,含控件形态) + +### 3.1 三维数据集栏(行 3–11) +工具条 = **4 个分组栏位** + 数据集列表: + +1. **坐标轴设置**(行 4–6)——表单式,每项一行、左对齐: + - 显示方式:下拉(标准 / 三维立体 / 不显示) + - O点位置:按钮(弹框设原点) + - 刻度:下拉(无刻度 / 米 / 英尺 / 经纬度) + - 字体:按钮(设刻度文字字体) +2. **水平/垂直比例**(行 7)——**单个拖动滑块 + 数值**(如 2.0×,纵向放大系数;现有 `kVerticalExaggeration`)。**非两个独立控件**。 +3. **快捷视图**(行 8)——6 钮:前 / 后 / 左 / 右 / 上 / 下。 +4. **缩放 Zoom**(行 9)——3 钮:放大(In) / 缩小(Out) / 适配(Fit)。 +5. **数据集列表**(行 10–11)——`dimensionOf==Dim3D` 过滤勾选对象的 ds,勾选→渲染。 + +### 3.2 二维数据集栏(行 12–16)= 3 栏位 +1. **地图**(行 13)——下拉(天地图 / Google Map / 隐藏)。**底图瓦片渲染留 P5**。 +2. **2D视图**(行 14)——下拉(关闭 / Z=0 / 顶部 / 底部 / **自定义**);选「自定义」显数值输入框。 + - **「自定义 Z」= 世界绝对高程(米),向上为正**,与「Z=0/顶部/底部」同坐标系(`GeoLocalFrame` 世界 Z)。决策依据:同一下拉里「Z=0」即绝对值,「自定义」只是输入任意绝对 Z;与业界(Petrel/Leapfrog/ParaView)水平面高度用项目 CRS 绝对高程一致。 +3. **数据集列表**(行 15–16)——`dimensionOf==Dim2D` 过滤,勾选→渲染到 VTK 的 2D 视图。 + +### 3.3 三维分析栏(行 17–35) +- **数据集列表 = 树**(行 18):对象 → 三维体模型数据集 → 切片。可勾选三维体/切片 → 渲染(行 19)。 +- **右键菜单(按节点类型分派):** + + **三维体数据集**(行 20–27,**直接项,无"创建切片"父级,无删除**): + | 菜单项 | 说明 | 本轮 | + |---|---|---| + | 切片 ▸ 上下 / 前后 / 左右 / 任意 | 一级「切片」父菜单 + 二级方向(上下/前后/左右=固定角度;任意=初始 45°可调)| **接已有 SliceTool** | + | 色阶 | 参考 Geopro 1.0 | OUT(stub/禁用)| + | 显示 / 隐藏 | actor 可见性 | IN | + | 数据详情 | 详情栏显示 | IN(接现有详情)| + + > 注:需求字面是"上下切片/前后切片…"直接项;本轮按用户决策归入「切片」一级父菜单、二级去「切片」二字显「上下/前后/左右/任意」。 + + **切片数据集**(行 28–35,**有删除**): + | 菜单项 | 本轮 | + |---|---| + | 保存 / 保存为 / 导出 / 删除 | OUT(P4 CRUD,stub/禁用)| + | 色阶 | OUT | + | 显示 / 隐藏 | IN | + | 数据详情 | IN | + +- **VTK视图交互**(行 36–38)、**切片分析**(行 39–51,含视图内切片右键:创建异常/保存/导出图片/导出dat/正视图/视图翻转/关闭)——属交互层,多已在 P3 实现或留 P4,本轮不在树结构范围内。 + +--- + +## 4. 全屏功能(新需求) + +- 在 **「VTK视图」** 与 **「数据详情」** 两个 dock 标题栏右侧各加一个**全屏切换按钮**:点击→该视图充满工作区;再点→还原。 +- 理由:两视图含图形、内容多,常需全屏操作。 +- 实现方向(ADS):给 `CDockWidget` 标题栏插入自定义 `QToolButton`,切换时把该 dock 最大化覆盖 dock 管理区(隐藏同级 / 浮动后最大化,择一,落地时定)。 + +--- + +## 5. 代码触点(落地指引,细节进 plan) + +- `src/app/main.cpp::buildWorkbench`: + - 删 `layerPanel` / `axisBar` / `sliceBar` 三浮层及其锚定器(`RightTopAnchor`/`BottomLeftAnchor`)与 `showLayerPanel` 显隐逻辑。 + - 删旧「二维地图/三维视图」分段切换;`vtkDock` 改名「VTK视图」。 + - VTK 视图容器内新建**抽屉侧栏**(QTabWidget 或自绘,三 tab)+ 折叠开关;三栏工具条/列表迁入。 + - **dock 布局持久化版本号须 bump**(见 `main.cpp:1429` 附近注释:改 dock 名/结构要升版本,否则旧布局反序列化错位)。 +- 三维数据集工具条接 P2 已实现的 `VtkSceneController` 坐标轴/比例/快捷视图/zoom 槽(原 axisBar 的接线迁移,不重写控制器)。 +- 数据集列表:用 `I3dSceneRepository::dimensionOf` 过滤;勾选信号接 `VtkSceneController`(复用现 `checkedTmsChanged` 一路的编排)。 +- 三维分析树右键「切片」→ 调已有 `InteractionManager`/`SliceTool` 建切片(替代 sliceBar 原按钮路径)。 +- 全屏按钮:ADS 标题栏自定义按钮 + 最大化/还原。 + +--- + +## 6. 待定 / 风险 + +- 全屏在 ADS 的最大化实现方式(隐藏同级 vs 浮动最大化)落地时定。 +- 二维「地图」底图本轮仅控件,渲染 P5;需确保控件状态能持久化到 P5 不返工。 +- 三栏侧栏与 ADS dock 的交互(侧栏是 VTK 容器子控件,不是 dock)——确保折叠/全屏时布局正确。 +- dock 名变更后旧用户布局失效(bump 版本号后回落默认排布,可接受)。 diff --git a/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md b/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md new file mode 100644 index 0000000..aef0dfd --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-vtk-3d-volume-slice-anomaly-design.md @@ -0,0 +1,183 @@ +# VTK 三维:三维体 / 切片 / 异常 —— 数据模型与客户端交互设计 + +- 日期:2026-06-17 +- 范围:桌面客户端 VTK 三维视图里的「三维体模型(体素) / 切片 / 异常」三类数据及其交互。是补充需求页"三维分析"栏的落地设计。 +- 依据:①《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. 核心数据模型 + +三者都是**可持久化的数据集**,层级关系:`三维体 → 切片 → 异常`。 + +### 1.1 三维体(VolumeModel) +ddCode:`dd_voxel` / `dd_Structual3D` / `dd_Property3D`(需求行 245/290/291)。 +- `id` / `name` +- **`sourceDatasetIds[]`(源数据)**:一组输入数据集,用于插值生成本体(需求行 401)。 +- **`interpModel`**:插值模型,克里金 / IDW(需求行 404)。 +- **`interpParams`**:插值参数(cellXY/cellZ/power/maxDist 等,需求行 405)。 +- `colorScale`(色阶参数,行 406)。 +- `grid`:体素网格 = `ScalarVolume` + `origin/spacing/vmin/vmax`(= 现有 `VolumeGrid`)。 +- `measure`:测量数据,点数/体积(行 407,派生量)。 +- 派生:`slices[]`、`anomalies[]`(汇总该体下所有切片圈定的异常,行 403)。 + +**来源(行业经验)**:① 多条 2D 反演剖面/散点/钻孔点 → IDW/克里金插成 3D 体(最常见);② 直接 3D 采集(三维高密度等,本身即 3D,单数据集)。`sourceDatasetIds` 为 1 个或多个。 + +### 1.2 切片(Slice) +ddCode:`dd_slice`。 +- `id` / `name` +- **`parentVolumeId`**:所属三维体(行 389:切片对象=所属三维体模型)。 +- **`plane`**:切面 = `SliceSpec`(轴向/原点/法向/偏移)。 +- `colorScale`。 +- 生命周期:交互式"切片工具"是**临时**的(在体上实时切);**保存后才成为持久化的 `dd_slice` 数据集**,进入三维分析栏列表(行 390)。 + +### 1.3 异常(Anomaly) +- `id` / `name` +- **`parentSliceId`**:异常画在某切片平面上、附着于该切片(行 392 创建异常在切片右键)。 +- `polygon`:圈定多边形(切片平面内的坐标)。 +- `screenshot` + 元信息(截图大小、异常坐标,行 393)。 +- 向上归属:三维体详情的"异常"= 汇总该体下所有切片圈定的异常(行 403)。 + +--- + +## 2. 客户端交互流(需求"三维分析"栏 + VTK 视图右键) + +### 2.1 三维分析栏(列表) +- 按「**对象 / 三维体模型 / 切片**」三级树显示(行 366)。 +- 勾选一个或多个三维体或切片 → 显示在三维视图(行 367)。 +- **三维体右键**(行 368–375):切片▸(上下/前后/左右/任意)、色阶、显示/隐藏、数据详情。 +- **切片右键**(行 376–383):保存、保存为、导出、删除、色阶、显示/隐藏、数据详情。 + +### 2.2 切片工具(行 369–372, 387–389) +- 上下/前后/左右切片:在三维体中心或光标位置打开**水平切片工具,角度不可调**。 +- 任意切片:初始角度 = 当前视图 45°,可任意调整。 +- 选中切片滚轮 → 沿法向往内/外推进;切片对象 = 所属三维体(行 389)。 +- 双击切片 → 视角调正为正视该切片(行 386/388)。 + +### 2.3 VTK 视图里对切片的右键(行 391–399) +- **创建异常**:弹异常工具,以光标拾取点为起点圈定;结束保存时弹对话框(截图大小、异常坐标),参考 Geopro1.0。 +- **保存**:保存为数据集(切片数据集)。 +- **导出为图片** / **导出到 dat**。 +- **正视图** / **视图翻转(水平 180°)** / **关闭**。 + +### 2.4 创建三维体(**已定:客户端创建**) +产品确认创建三维体在**客户端**侧。 +- 「三维数据集」栏多选若干 3D 数据集(反演剖面)→ 菜单「生成三维体」→ 选插值模型/参数 → 客户端 `IdwInterpolator` 插值 → 三维体。 +- 持久化:后端**无三维体端点**(见 §3)→ 先本地 mock(内存/本地文件),端点就绪后切真实。 + +--- + +## 3. 后端依赖 vs 本地 mock(已核对 web 源码 `commercial-admin/src/apis`) + +| 能力 | 现有端点 | 方案 | +|---|---|---| +| 展示已有三维体(取网格) | ❌ 无(`/model/*` 只有 ResIPy 反演/雷达,无三维体网格) | **mock**:以源数据集散点 IDW 出体(`IdwInterpolator`) | +| 创建三维体(插值) | ❌ 无(无三维空间插值端点) | **客户端做**(IdwInterpolator);持久化 mock | +| 三维体/切片**持久化** | ❌ 无写端点 | **mock**:内存/本地存;端点就绪后切真实 | +| 切片创建/交互 | — 纯客户端 | ✅ 已有(`SliceTool`/`InteractionManager`) | +| 切片保存/另存/导出/删除 | ❌ 无切片端点 | 保存/删除 → mock(内存);导出图片/dat → **客户端做**(截图/写文件) | +| 异常**读取** | ✅ `POST /exception/queryExceptionTree`、`queryException`/`queryExceptionByTmObjectId` | **接真实** | +| 异常**新增** | ✅ `POST /business/exception` | **接真实** | +| 异常**更新** | ✅ `PUT /business/exception` | **接真实** | +| 异常**删除** | ✅ `DELETE /business/exception/{exceptionId}` | **接真实** | +| 异常类型 | ✅ `GET /exceptionType/queryExceptionTypeByProjectIdAndType/{projectId}/{remarkSourceType}`、`/exceptionType/getDetail/{id}` | **接真实** | +| 异常体(exceptionConsortium) | ✅ `POST/PUT /exceptionConsortium`、`/page`、`/export`、`/getDetail/{id}` | **接真实** | +| 任务记录/可用任务 | ❌(`/model/task/page` 是反演任务,非三维分析任务) | mock | + +### 异常新增请求体(web 实证 `datasetInfo/index.js:212`) +`POST /business/exception`: +``` +{ exceptionName, exceptionTypeId, location:{...圈定坐标...}, + projectId, remarkSourceId, remarkSourceType, remark } +``` +- `remarkSourceId` / `remarkSourceType`:异常所附着的对象(本场景=切片数据集 id + 其类型)。 +- `location`:圈定多边形坐标。截图:保存对话框含截图(行 393),上传方式参考 `exceptionInfos/modalNewException.vue`。 +- 更新:`PUT /business/exception` `{ id, exceptionName, remark }`;删除:`DELETE /business/exception/{exceptionId}`;详情:`GET /business/exception/getDetail/{exceptionId}`。 + +--- + +## 4. 代码现状(落点) + +- 维度归类:`LocalSample3dRepository::dimensionOf`(dd_voxel/Structual3D/Property3D/section/inversion → 3D;dd_slice → Analysis)。 +- 体素:`core::IdwInterpolator`(IDW 现成)、`render::buildVoxel`/`VoxelActor`(体绘制)、`VoxelFromScatters`(散点→体)。 +- 切片交互:`render::interact::SliceTool` + `InteractionManager`(已可在体素 image 上切,含上下/前后/左右/任意)。 +- 仓储接口:`I3dSceneRepository` 已定义 `loadVolume / createSlice / saveSlice / deleteSlice / loadAnomalyTree / saveAnomaly / deleteAnomaly / deleteAnomalyGroup / loadTaskRecords / loadUsableTasks`。 + - `LocalSample3dRepository`:以上多为**内存 mock**(slices_/anomalies_ map)。 + - `Api3dRepository`:以上多为 **stub(`kNotReady`)** — 真实数据未接。 +- UI:`Column3DAnalysis` 已定义信号(sliceRequested/colorScaleRequested/visibilityToggled/detailRequested/sliceSave|SaveAs|Export|Delete);`main.cpp` **仅接了 sliceRequested + detailRequested**,其余未连。 + +--- + +## 5. 待产品确认(更新 2026-06-17) + +1. **创建三维体在客户端还是平台?** → **已定:客户端**(§2.4)。 +2. **后端三维体/切片/任务端点时间表?** → 暂未知。当前 web 源码**无**三维体网格、三维插值、切片、三维分析任务端点 → 这些**先 mock**(保持 `I3dSceneRepository` 接口不变,端点就绪后仅换实现)。 +3. **异常写端点?** → **已找到**(§3):`POST/PUT/DELETE /business/exception` + 类型/异常体一整套 → 异常**接真实**,不 mock。 + +剩余待确认(次要): +- 异常保存对话框的**截图上传**方式/字段(参考 `exceptionInfos/modalNewException.vue`,落地时细化)。 +- 三维体/切片本地 mock 的持久化形态(纯内存 vs 本地文件,影响重启后是否还在)。 + +--- + +## 6. 实现拆解(对应"剩余工作" #2–#6,缺端点先 mock) + +按依赖与价值排序: + +1. **三维体(mock 渲染)**:把当前勾选/源数据反演剖面散点 → `IdwInterpolator` → `VolumeGrid` → `VoxelActor`。`Api3dRepository::loadVolume` 由 stub 改为"取源数据散点 + IDW"(mock)。→ 解锁三维体显示 + 切片有可切对象。 +2. **切片交互接通三维体**:体素就位后,三维体右键"切片▸"已能创建切片(现有 SliceTool)。补:选中切片滚轮推进、双击正视。 +3. **切片保存/另存/导出/删除**:保存/删除 → Api3dRepository 改内存 mock(同 LocalSample);导出图片(截图)/dat(写文件) → 客户端实现。VTK 视图切片右键菜单接线。 +4. **异常**:切片右键"创建异常"→ 圈定工具 + 保存对话框(截图/坐标)。**全套接真实端点**:新增 `POST /business/exception`、更新 `PUT /business/exception`、删除 `DELETE /business/exception/{id}`、读 `queryException*`/`queryExceptionTree`、类型 `exceptionType/*`、异常体 `exceptionConsortium/*`。`remarkSourceId/Type` 填切片数据集。 +5. **分析栏右键菜单接线**:色阶、显示/隐藏(纯客户端)、切片保存/另存/导出/删除(接 #3)。 +6. **三维体/切片/异常详情**:数据详情栏展示源数据/插值参数/色阶/测量(点数·体积)/异常列表。 + +> 每步:客户端能做的先做、缺端点的内存 mock 留出可替换缝(保持 `I3dSceneRepository` 接口不变,仅换实现)。 + +--- + +## 7. 三维体持久化策略与存储结构(2026-06-17 定) + +### 7.1 策略:参数为准 + 明细可选缓存 + 缺则惰性重算 +- **保存**: + - **必存**:插值参数(源数据引用 + 插值模型/参数 + 色阶)+ **网格规格 `GridSpec`**(origin/spacing/dims)。 + - **可选存**:网格明细值(体素标量,nx·ny·nz)。 +- **加载**: + - **有明细** → 直接渲染明细。 + - **无明细** → 按参数把值**重算填入已存的 `GridSpec`**(坐标系固定),再渲染。 +- **理由**:参数小且可复现(详情面板要展示);明细贵、但稳定一致;`GridSpec` 必存以**锚定切片/异常坐标**(它们定义在体网格坐标系上,重算必须落在同一规格里,否则错位)。 + +### 7.2 何时建议存明细 +| 体的情形 | 建议 | +|---|---| +| 带切片/异常 | **存明细**(与源数据生命周期解耦,重算变形风险归零) | +| 网格大 / 插值慢 | **存明细**(避免每次加载实时重算的卡顿) | +| 纯展示、源数据稳定、网格小 | 可只存参数(省存储),加载时重算 | + +### 7.3 重算路径的两个前提(UI/逻辑需兜住) +1. **源数据仍在且可取**:源数据集被删/改 → 重算失败或结果变 → 应回退提示 / 阻止。 +2. **算法确定性**:同参数同输入 → 同结果(IDW 确定);算法升级时旧体优先用已存明细,避免变形。 + +### 7.4 存储结构(字段定义,驱动实现) +**三维体元数据(必存,进数据集元数据)`VolumeBuildParams`**: +- `sourceDatasetIds[]`:源数据集 id 列表。 +- `interpModel`:枚举 IDW / Kriging。 +- `interpParams`:`{ cellXY, cellZ, power, maxDist, ... }`(按模型)。 +- `colorScaleId` 或内联色阶。 +- `gridSpec`:`{ ox, oy, oz, dx, dy, dz, nx, ny, nz }`(**必存**,锚定坐标)。 +- `vmin, vmax`、`measure{ points, volume }`(派生,可缓存)。 + +**三维体明细(可选,进数据集数据文件)**: +- `values`:`ScalarVolume`(nx·ny·nz 标量,maxDist 外为 NaN=空)。 + +**与现有代码的关系**: +- 现 `data::VolumeGrid = { ScalarVolume vol; origin[3]; spacing[3]; vmin; vmax }` ≈ "明细 + 规格(部分)"。 +- 落地改造:拆出 `VolumeBuildParams`(含 `GridSpec`)为必存元数据;`vol`(values)为可选。`I3dSceneRepository::loadVolume` 返回时:若有 values 直接给 `VolumeGrid`;若无,则用 `VolumeBuildParams` 现场 `IdwInterpolator` 重算出 `vol` 再给(坐标用存好的 `gridSpec`)。 +- mock 阶段:客户端 IDW 同时产出 params(含 spec) 与 values,两者本地存;接口签名不变,后端就绪后元数据走数据集属性、values 走数据文件。 diff --git a/docs/superpowers/specs/2026-06-17-web-embed-subpage-mount-design.md b/docs/superpowers/specs/2026-06-17-web-embed-subpage-mount-design.md new file mode 100644 index 0000000..84ebd8e --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-web-embed-subpage-mount-design.md @@ -0,0 +1,341 @@ +# 原版 Web 系统「子页面嵌入挂载」最小侵入改造设计 + +- 日期:2026-06-17 +- 状态:**设计稿 v2(已经 opus 双评审 + 代码实测修订,可据以实现)** +- 涉及仓库: + - **Web 端(被改造方)**:`D:\Git\lanbingtech\commercial-admin`(Vue3 + Vite + Arco,hash 路由) + - **客户端(接入方)**:`D:\Git\lanbingtech\geopro`(Qt + `QWebEngineView`) +- 需求背景:客户端需把原版 web 系统的**单个子功能页**(如"系统管理"下的组织/用户/角色,以及项目空间下的数据视图等)**裸挂**进客户端窗口,不带原系统的"标题栏 + 左菜单 + 页签"外壳,复用既有页面与鉴权。 +- 设计约束(用户已确认): + 1. **尽可能少改动原有代码**——既有函数保持行为零变化,优先"新增"而非"修改"。 + 2. token 注入:客户端用 `QWebEngineProfile` 预置 localStorage(首选)或 `loadStarted` 注入,token **不进 URL**。 + 3. 挂载范围:**同时支持租户空间页面(`space=2`)与项目空间页面(`space=3`,需 `projectId`)**。 + +> v2 修订摘要(详见 §10):EmbedLayout 必须写成**懒加载函数**(否则被 `cloneDeep` 拷坏);`generateEmbedRoutes` 必须定义在 **store 闭包内**;**独立 `QWebEngineProfile` 由建议升为强制**,并在生成路由前清理 pinia 持久化残留;token 注入首选 profile 预置。 + +--- + +## 0. 为什么不能直接挂 URL(约束根因,已对照代码) + +| 阻碍 | 证据 | 含义 | +|---|---|---| +| 所有业务页面都是 `Layout` 子路由 | `stores/modules/route.js` `transformComponentView`:`'layout'→Layout`;`layout/index.vue` = Asider+Header+Tabs+Main | 任何路径渲染都带整套外壳 | +| 业务路由运行时才动态注册 | `router/guard.js` `beforeEach` → `generateRoutes()`/`generateSpaceRoutes()` → `router.addRoute` | 不跑守卫,目标路由不存在,直接访问 404 | +| 进入需登录态 + 空间上下文 | `getToken()` 读 `localStorage['token']`;`space==2/3`;项目空间依赖 `projectStore.projectId`(`computed(projectItem.id)`) | 必须先备好 token / projectId 再生成路由 | + +> 结论:裸挂子页 = 必须同时解决 **①去外壳、②触发动态路由生成、③补登录态/projectId** 三件事。 + +--- + +## 1. 总体方案:新增「embed 引导入口 + EmbedLayout」,叠加而非改造 + +新增固定路由 `/embed` 作为引导页:读 URL 参数 → 备好上下文 → 调用**新增的** `generateEmbedRoutes`(用 `EmbedLayout` 包裹,无外壳)→ `router.replace` 到目标子页。正常登录链路**完全不经过**这些新增物。 + +### 1.1 嵌入 URL 规范(统一入口,参数驱动) + +hash 路由下统一入口为 `/#/embed`,目标子页靠 query 指定: + +``` +http:///#/embed?space=<2|3>&projectId=&target= +``` + +| 参数 | 必填 | 说明 | +|---|---|---| +| `space` | 是 | `2`=租户空间,`3`=项目空间 | +| `projectId` | 仅 `space=3` | 项目空间页面所属项目 id | +| `target` | 是 | 叶子菜单路由路径,经 `encodeURIComponent` 编码 | + +示例: +- 系统管理·用户列表(租户):`http:///#/embed?space=2&target=%2ForganiMange%2FuserList` +- 项目空间·数据视图:`http:///#/embed?space=3&projectId=123&target=%2FprojectSpace%2FdataView` + +> token 不进 URL(由客户端 profile 预置注入,见 §4)。`target` 即 `/organiMange/userList` 这类原叶子菜单路径,`encodeURIComponent` 后 `/`→`%2F`。 + +### 1.2 改造性质:一次性框架级,非逐页改造 + +§2 列的 5 处改动全部是**框架级**(路由层 + 一个引导页 + 一个空壳布局),**与任何具体业务页无关**。改造完成后: + +- **嵌入任意叶子菜单页 = 只改 URL 的 `target`/`space`/`projectId`**,业务页代码一行不动。 +- 不存在"为某个页面单独做嵌入适配"的工作。 + +唯一两个"非纯 URL"例外且均已收敛、非逐页: +- **D 类**(极少数读 `currentSpace` 的页):引导页对 `space=2` **统一**补 `getEnterpriseUserInfoFun`(§3.2),一处兜底覆盖,非逐页。 +- **详情页带参**:属行点击详情、**不在叶子菜单范围**(§11)。 + +--- + +## 2. 改动分级(核心:既有逻辑零修改) + +| 项 | 文件 | 性质 | 是否改既有逻辑 | +|---|---|---|---| +| ① 新增 `EmbedLayout.vue` | `commercial-admin/src/layout/EmbedLayout.vue` | 全新文件 | 否 | +| ② 新增引导页 `embed/index.vue` | `commercial-admin/src/views/embed/index.vue` | 全新文件 | 否 | +| ③ 注册 `/embed` 固定路由 | `commercial-admin/src/router/route.js` | 在 `constantRoutes` **插入一项(404 兜底之前)** | 否(不动既有项) | +| ④ 新增 `generateEmbedRoutes` | `commercial-admin/src/stores/modules/route.js` | **store 闭包内新增函数** + 给 `formatAsyncRoutes` 加 `export` | 既有函数体零修改 | +| ⑤ guard 顶部早返回 | `commercial-admin/src/router/guard.js` | **唯一的"修改"**:`beforeEach` 顶部加 `if(embed) return` | 既有分支原样保留 | +| ⑥ 客户端注入 token + 拼 URL + 独立 profile | `geopro` 客户端新增 `QWebEngineView` | 客户端侧新增 | 否 | + +> 唯一动到既有执行路径的是 ⑤,做成"顶部早返回"。验收基线:**不带 `/embed`、且 `EMBED_MODE` 未点亮时,执行路径与改造前一致**。 + +--- + +## 3. Web 端详细设计 + +### 3.1 ① `EmbedLayout.vue`(新文件) + +仅保留 `` + ``,**不引入** Asider/Header/Tabs/DkFooter,**也不要 keep-alive**(`cacheList` 由 Tabs 组件填充,embed 无 Tabs 故恒为空,keep-alive 是死代码,去掉更简单——见 §10 M2)。 + +```vue + + + +``` + +> `useI18n`/`arcoLocales` 是全局插件,裸挂可用(评审已核实)。 + +### 3.2 ② 引导页 `views/embed/index.vue`(新文件) + +无业务渲染(仅 loading/错误占位)。`onMounted` 内按序引导,**关键顺序**:先清理 pinia 持久化残留 → 写 `projectItem.id`(项目空间)→ 生成路由 → replace。 + +```js +onMounted(async () => { + try { + sessionStorage.setItem('EMBED_MODE', '1') // 点亮 embed 标志(仅本 profile/tab) + const { space, projectId, target } = route.query // token 已由客户端预置/注入 localStorage + const sp = Number(space) + + // ① 清理持久化残留,防止跨会话/复用 profile 时脏读(见 §10 S3/M3) + routeStore.$reset() + projectStore.$reset() + + // ② 设置空间标识(廉价、无害,避免子页/外壳读到错误值) + routeStore.isProjectSpace = (sp === 3) + + await userStore.getInfo() // 复用既有;401 时 /auth/user/info 被拦截器排除→仅 reject + + if (sp === 3) { + projectStore.projectItem.id = projectId // 先写!generateEmbedRoutes(3) 读 projectId=computed(id) + } else { + await appStore.getEnterpriseUserInfoFun() // space=2 默认补企业信息(写 currentSpace),覆盖"企业空间"类叶子页 + } + await routeStore.generateEmbedRoutes(sp) // 新增函数 + + router.replace(decodeURIComponent(target)) // 进入目标子页,EmbedLayout 生效 + } catch (e) { + console.error('[embed] bootstrap failed:', e) + // 显示错误占位,不主动跳登录(embed 无登录交互) + errorMsg.value = '页面加载失败,请检查登录态或权限' + } +}) +``` + +### 3.3 ③ 注册 `/embed`(`route.js`,插入 404 兜底之前) + +`constantRoutes` 中 `/:pathMatch(.*)*` 是兜底项,`/embed` 必须**插在它之前**(精确路由优先,避免匹配歧义): + +```js +export const constantRoutes = [ + { path: '/redirect', /* ...既有不动... */ }, + { + path: '/embed', + name: 'Embed', + component: () => import('@/views/embed/index.vue'), + meta: { hidden: false }, + }, + { path: '/:pathMatch(.*)*', /* 404 兜底,保持在最后 */ }, + { path: '/403', /* ... */ }, +] +``` + +### 3.4 ④ `generateEmbedRoutes`(`route.js` **store 闭包内**新增) + +复用既有 `formatAsyncRoutes`/`flatMultiLevelRoutes`,**仅把顶层路由的 `component` 由 `Layout` 换成 `EmbedLayout`**(动态路由顶层节点恒为外壳包裹层;`flatMultiLevelRoutes` 只展平 children、不动顶层 component——评审已核实机制成立)。 + +**两处必须遵守(否则跑不起来,见 §10 S1/必错-1):** +1. `EmbedLayout` 写成**懒加载函数**,与 `Layout` 一致。原因:`generateEmbedRoutes` 内沿用既有 `cloneDeep`,lodash 对**函数**按引用返回、对**组件 options 对象**会深拷贝并拷坏。静态 `import EmbedLayout` 是对象 → 必被拷坏。 +2. 函数定义在 `storeSetup()` **闭包内**(与 `generateRoutes`/`generateSpaceRoutes` 并列),否则 `setRoutes`/`router`/`projectStore` 未定义(它们是闭包内符号)。 + +```js +// 模块顶层:给既有 formatAsyncRoutes 加 export(纯导出,零行为变化) +// 并新增懒加载的 EmbedLayout(不要静态 import!) +const EmbedLayout = () => import('@/layout/EmbedLayout.vue') + +// —— 以下定义在 storeSetup() 内部,与 generateRoutes/generateSpaceRoutes 并列 —— +const generateEmbedRoutes = (space) => { + return new Promise((resolve, reject) => { + const p = space === 3 + ? getProjectRouteLimts({ projectId: projectStore.projectId }) + : getUserRoute() + p.then((res) => { + try { + const tree = space === 3 + ? toArrayTree(res.data) + : searchTree(res.data, (i) => Number.parseInt(i.clientType) === 2) + const asyncRoutes = formatAsyncRoutes(tree) // 既有函数,零修改 + asyncRoutes.forEach((r) => { r.component = EmbedLayout }) // ← 去外壳关键一步(顶层换壳) + setRoutes(asyncRoutes) + const flat = flatMultiLevelRoutes(cloneDeep(asyncRoutes)) // cloneDeep 对函数式组件按引用,安全 + for (const route of flat) { + if (!isHttp(route.path) && !router.hasRoute(route.name)) router.addRoute(route) + } + resolve() + } catch (e) { reject(e) } + }).catch(reject) + }) +} +// return { ...原有, generateEmbedRoutes } +``` + +> `generateRoutes`/`generateSpaceRoutes`/`transformComponentView` **函数体一行不动**,正常登录仍走它们(带 `Layout`)。 +> `router.hasRoute(route.name)` 是对既有 `generateSpaceRoutes` 里 `hasRoute(route)`(误传对象)的修正用法,仅用于新增函数内部,不影响既有。 + +### 3.5 ⑤ guard 顶部早返回(`guard.js` 唯一修改) + +`router.beforeEach` **最顶部**(`NProgress.start()` 之后)加: + +```js +// —— embed 嵌入模式:放行,鉴权 + 路由生成由 /embed 引导页自管 —— +if (to.path === '/embed' || sessionStorage.getItem('EMBED_MODE') === '1') { + NProgress.done() + return next() +} +``` + +既有所有分支(登录判断/空间检测/白名单/版本检测)**整段保留**。 +**注意(见 §10 S2)**:早返回是 embed 下唯一放行路径——`router.replace(target)` 的二次 `beforeEach` 靠 `EMBED_MODE==='1'` 兜住,跳过 `generateSpaceRoutes`(避免再生成 Layout 版路由覆盖 EmbedLayout 版)。因此 `EMBED_MODE` 的可靠性是硬约束,必须配合独立 profile(§4)与生成前 `$reset`(§3.2)。 + +--- + +## 4. 客户端 ↔ Web「嵌入契约」(geopro 侧) + +**强制:每个 embed 视图使用独立 `QWebEngineProfile`**,与系统浏览器登录态、与正常登录链路隔离,避免 pinia 持久化(routeStore/projectStore/userStore 均 `persist` 到 localStorage)跨链路污染。 + +token 注入**首选 profile 预置**(彻底消除时序竞态),`loadStarted` 注入为备选: + +```cpp +// 首选:独立 profile + 预置脚本,在文档创建早期写 localStorage +auto* profile = new QWebEngineProfile(QStringLiteral("geopro-embed"), parent); // 独立 profile +QWebEngineScript s; +s.setInjectionPoint(QWebEngineScript::DocumentCreation); +s.setWorldId(QWebEngineScript::MainWorld); +s.setSourceCode(QStringLiteral( + "localStorage.setItem('token','%1');" + "localStorage.setItem('refleshToken','%2');").arg(token, refleshToken)); +profile->scripts()->insert(s); +auto* page = new QWebEnginePage(profile, parent); +auto* view = new QWebEngineView(parent); +view->setPage(page); + +// 目标页/空间/项目id 走 query(非敏感);hash 路由下 query 在 # 之后 +// 租户(系统管理类): http:///#/embed?space=2&target=%2ForganiMange%2FuserList +// 项目空间(数据视图): http:///#/embed?space=3&projectId=&target=%2FprojectSpace%2FdataView +view->setUrl(QUrl(url)); +``` + +约定:`target` 用 `encodeURIComponent` 编码(含 `/`),引导页 `decodeURIComponent` 还原。 + +--- + +## 5. 关键边界与风险(已对照代码确认) + +1. **项目空间生成顺序**:`generateEmbedRoutes(3)` → `getProjectRouteLimts({projectId: projectStore.projectId})`,`projectId=computed(projectItem.id)`。**必须先 `projectItem.id=` 再生成**,否则拉空菜单 → `target` 404。`projectItem` 是 reactive,直接赋值即触发 computed(评审已背书)。 +2. **target 须在该账号权限内**:后端按角色过滤菜单,无权限页生成后仍无该路由 → 仍 404。属权限问题。 +3. **EMBED_MODE 隔离(安全关键)**:用 `sessionStorage`,**严禁持久化**;并**强制独立 profile**。否则普通访问可能误进 embed 分支、跳过鉴权(见 §3.5/§10 S3)。 +4. **pinia 持久化残留**:`routeStore`/`projectStore`/`userStore` 均 `persist`(localStorage)。引导页生成前必须 `$reset`,否则复用 profile 时回灌旧 projectId / 旧路由表(§10 M3)。 +5. **后端零改动**:复用 `getUserRoute`/`getProjectRouteLimts`,不碰接口。 +6. **token 时序**:profile 预置脚本在 DocumentCreation 注入,早于 Vue 初始化与首个守卫,无竞态(优于 `loadStarted`,见 §10 可能-1)。 +7. **token 失效行为**:`http.js:70` 仅对**非** `/auth/user/info` 的 401 弹"重新登录"并跳 `/login`。embed 下 token 失效会弹标准 401 框→无外壳的登录页;属可接受边界,由客户端决定是否重新引导。 +8. **外壳态依赖**:`isProjectSpace`/`currentSpace` 仅外壳组件与"企业空间"专页消费(已 grep 确认)。普通业务子页裸挂安全;目标若为企业空间专页(`enterpriseManage/enterpriseSpace`、`setting/profile` 等),引导页需补 `appStore.getEnterpriseUserInfoFun()`。 + +--- + +## 6. 非目标(本轮 OUT) + +- 不改造外壳组件本身;不为子页做独立 Vite 打包入口;不在 web 端做 token 刷新的嵌入式交互;不用"CSS 隐藏外壳"临时方案(外壳仍实例化、有副作用,已否决)。 + +--- + +## 7. 验收标准 + +1. **回归基线**:不带 `/embed` 且 `EMBED_MODE` 未点亮的所有访问行为与改造前一致;`generateRoutes`/`generateSpaceRoutes`/`transformComponentView`/`formatAsyncRoutes` 函数体未改(仅 `formatAsyncRoutes` 加 `export`)。 +2. **租户空间挂载**:`#/embed?space=2&target=%2ForganiMange%2FuserList` 渲染用户列表且**无外壳**,数据正常。 +3. **项目空间挂载**:`#/embed?space=3&projectId=&target=%2FprojectSpace%2FdataView` 正常渲染、projectId 正确。 +4. **token 不外泄**:URL/历史/日志无 token;localStorage token 由 profile 预置成功。 +5. **隔离**:普通浏览器新 tab 访问业务页,`EMBED_MODE` 不存在、守卫照常生效。 + +--- + +## 8. 回滚 + +新增物删除即回滚;guard 早返回分支删除即恢复。无数据/接口副作用。 + +--- + +## 9. 实现顺序 + +1. Web:EmbedLayout.vue → embed/index.vue → route.js 注册 + `generateEmbedRoutes`(闭包内、懒加载壳)→ guard 早返回。 +2. 浏览器手验 `space=2`(console 预置 `localStorage.token` + `sessionStorage.EMBED_MODE=1` 后访问 `#/embed?...`)。 +3. 验 `space=3`(有效 projectId)。 +4. 客户端独立 profile + 预置脚本接入,端到端联调。 + +--- + +## 10. 评审修正记录(opus 双评审 + commercial-admin 代码实测) + +**已采纳为硬性修正(不改跑不起来):** +- **S1 / 必错-2(闭包作用域)**:`generateEmbedRoutes` 定义在 `storeSetup()` 内(`route.js:103-183` 区间),否则 `setRoutes`/`router`/`projectStore` ReferenceError。→ 已落到 §3.4。 +- **必错-1(cloneDeep 拷坏组件)**:`EmbedLayout` 必须懒加载函数 `() => import()`。lodash `cloneDeep` 对作为对象属性的**函数按引用返回**(故原系统 `Layout=()=>import()` 正常),对**静态导入的组件 options 对象会深拷贝**并破坏 Vue 内部标识。→ 已落到 §3.4。 +- **S3(持久化污染 + 隔离)**:`routeStore/projectStore/userStore` 均 `persist`(localStorage)。EmbedLayout 路由会被持久化,复用 profile 时污染正常链路/反之。→ 独立 profile 升为**强制**(§4),生成前 `$reset`(§3.2)。 +- **M3(projectId 脏读)**:`dataView` setup 阶段快照式读 `projectId`(`views/projectSpace/dataView/index.vue:132`),叠加 persist 可能回灌旧值。→ `projectStore.$reset()` + 先赋值后生成(§3.2)。 +- **token 时序(可能-1)**:`loadStarted` 的 `runJavaScript` 异步排队,不保证早于首个守卫。→ 改用 profile 预置脚本 DocumentCreation 注入为首选(§4/§5.6)。 +- **/embed 插入位置(需确认-4)**:插在 `/:pathMatch(.*)*` 之前(§3.3)。 +- **keep-alive(M2)**:EmbedLayout 去掉 keep-alive(cacheList 恒空)(§3.1)。 + +**已核实成立、予以背书(原 spec 正确):** +- 顶层 `component` 换 EmbedLayout 去外壳机制成立(`formatAsyncRoutes` 顶层节点 component 即外壳,`flatMultiLevelRoutes` 不动顶层 component)。 +- `projectStore.projectItem.id = projectId` 触发 computed,先赋值后生成"必要且充分"。 +- `searchTree`/`toArrayTree` 用法与既有 `generateRoutes`/`generateSpaceRoutes` 一致。 +- `useI18n`/`arcoLocales` 全局可用。 +- 回归基线(仅 `formatAsyncRoutes` 加 `export` + guard 顶部分支)可验证。 + +**结论**:方向正确、工作量可控;上述硬性修正纳入 v2 后,**可据本 spec 进入实现**。"必错-1/必错-2"是两个不改连跑都跑不起来的点,实现时务必遵守 §3.4 两条约束。 + +**已核实安全(原列为"现场确认",实为静态可查,已查清):** +- 外壳 provide/inject 依赖:`grep` 确认 `src/layout` + `App.vue` **零 `provide()`**;外壳对事件总线**零 emit**。两个首批目标页 `organiMange/userList.vue`、`projectSpace/dataView/index.vue` 自身**既不 `inject()` 也不订阅任何 bus**。现存 `inject()` 仅在 `projectSpace/datasetInfo` 子树(colorLevel/contourLevel/GprHeader),是页面内部父子 provide,与外壳无关。`coustomEventBus`(`utils/event-bus.js`)定义后全仓无消费方。→ **裸挂这两页对外壳上下文依赖为 0,安全**。其他目标页若纳入,按同样三步静态核对(外壳 provide?页面 inject?总线发射方是否在外壳?)即可,无需等运行时。 + +**实现前必须用真实菜单接口数据核验(唯一未离线证实项):** +- **顶层菜单 component 恒为 `'layout'`**(见 §11 E 类):`generateEmbedRoutes` 换壳逻辑 `asyncRoutes.forEach(r => r.component = EmbedLayout)` 假设每个顶层路由都是外壳包裹层。需抓一次真实 `getUserRoute`(space=2)与 `getProjectRouteLimts`(space=3)响应,确认所有顶层节点 `component === 'layout'`(而非直接页面或 `ParentView`)。若存在非 layout 顶层,换壳会渲染空白 → 改为"仅替换 component 原为 'layout' 的顶层节点"。 + +**仍需留意:** +- embed 路由 name 与正常路由同名:独立 profile 下 embed SPA 不会生成 Layout 版路由,无冲突;若未来同一 SPA 既登录又 embed,需给 embed 路由 name 加前缀。 + +--- + +## 11. 覆盖范围:所有叶子菜单可导航页面(已代码实测) + +**结论:本方案完整覆盖"所有叶子菜单可导航到的页面"。** 因为叶子菜单导航 = 只给路径,进入所需上下文仅 `path + space + projectId`,恰为 embed 契约所提供;且这些页路由与正常登录一致生成,仅顶层外壳由 `Layout` 换为 `EmbedLayout`。 + +### 为何成立(实测依据) +- **叶子菜单页不带行参数**:靠 `?id=` 进入的详情页(`projectMange/projectConfiguration/details`、`templateManage/details`、`datasetInfo` 等)是从列表页**行点击 `router.push` 带参**进入的(证据:`projectMange/configuration.vue:91`、`projectSpace/templateManage/index.vue:105`),**不属于叶子菜单**,不在本范围内。 +- **项目空间叶子页自取数据**:`abnormalBody/List`、`dataMange`、`projectStructure` 等自调 `getGsTreeFun`/`queryProjectGsStruct`(证据:`grep` 命中 `src/views/projectSpace/*`),不依赖前序页面预加载的 store → 可独立裸挂。 +- **页内向自身详情下钻仍可用**:叶子页里行点击 push 到详情页时,该详情路由**也已在同空间一次性生成(EmbedLayout 版)**,故详情同样无外壳渲染。 + +### 边界(非阻断,已收敛) +- **D 类(极少数读外壳态的叶子页)**:如"企业空间"读 `currentSpace`。引导页对 `space=2` 默认补 `appStore.getEnterpriseUserInfoFun()`(§3.2 已落),`isProjectSpace` 亦在引导页设置 → 覆盖。 +- **C 类(仅当嵌入页内"跨空间/回首页")**:单个叶子页本身不受影响;只有离开本页、跨租户↔项目空间或回工作台的跳转会脱离 embed 语境。属"离开该叶子页"的范畴,非"该叶子页能否嵌"。 +- **E 类(唯一结构性前提)**:换壳假设顶层菜单 component 恒为 `'layout'`。本系统所有业务页现均带外壳,强烈暗示成立;但需用真实菜单接口数据核验(见 §10「实现前必须核验」)。 +- **F 类(不适合/无意义)**:login/redirect/error;以及"导航中枢"类(工作台、项目列表,职责即切空间/进项目,单独嵌无意义)。这些通常也不是普通叶子功能菜单。 diff --git a/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md b/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md new file mode 100644 index 0000000..85a5b82 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-vtk-3d-volume-slice-detail-dialog-design.md @@ -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 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`、`vmin0`、`pointCount>0`。 + - 非体 dsId:返回 false。 +- 对话框为纯只读 UI(无逻辑分支),不做单测,靠 GUI 实测(Claude 无法 GUI 验证,交用户)。 + +## 8. 影响面 / 不变量 + +- 接口 `I3dSceneRepository` 与 `LocalSample3dRepository` 零改动 → 真实后端就绪后切换不受影响。 +- `finalizeVolume` 仅多写一个 `pointCount`,不改插值/渲染行为。 +- 不与 VTK 三维视图交互(详情只读查阅,职责清晰)。 diff --git a/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md b/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md new file mode 100644 index 0000000..6a20a12 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-vtk-3d-color-scale-editor-design.md @@ -0,0 +1,111 @@ +# 三维体/切片 色阶编辑器(复刻原版 web「色阶配置」)— 设计 + +> 关联交接:`docs/superpowers/HANDOFF-vtk-3d.md` §「真实色阶编辑」候选项。 +> 原版参考:`commercial-admin/src/views/projectSpace/datasetInfo/components/comm/` +> (`colorLevel.vue` 外层 + `colorEditor.vue` 内层 + `contourLevel.vue`/`contourLine.vue` 子弹框 + `colorUtils.js` 算法)。 + +## 0. 目标与边界 + +把右键「色阶」占位(main.cpp `colorScaleRequested` → "色阶设置开发中")替换为可用的色阶编辑对话框, +1:1 复刻原版 web 数据集详情页「色阶配置」对话框(`colorLevel.vue`)的核心交互,应用后驱动 +体素 / 切片 / 帘面 重新着色。 + +后端 3D 色阶保存接口未就绪 → 持久化先走**会话级 mock**(控制器 `volumeScaleCache_`,再勾选命中缓存)。 + +## 1. 分期(P1 本期) + +| 期 | 范围 | 验收 | +|----|------|------| +| **P1 ✅** | 主表编辑器:表格(层级/颜色)+ 新增/删除 + 双击改值/改色 + 确定应用 | 编辑后体/切片即时变色 | +| **P2 ✅(层级⚙)** | 层级⚙(分层方式 normal/log/equalArea + 间隔↔层数联动 + 校验)→ 重算层级分布并按旧色阶插值取色 | 改分层后色带分布变化 | +| **P2 ✅(线形⚙ + 共享)** | 线形⚙(线型/线显/线色/标注显/标注色,复刻 contourLine.vue);编辑器做成 **2D 数据集视图 + 3D 共用**,输出 `{colorScale, lineConfig, labelConfig}` | 2D 数据集视图改线色/线型/标注色即时生效 | +| **P3 ✅(颜色⚙)** | 连续渐变画布(自绘 `GradientEditWidget` 替 fabric+d3)+ 预设方案 + 反向 + 整体透明度 → 按各层级位置采样回填颜色 | 改渐变/透明度后体素/切片/2D 变色(透明度影响 3D 体素观感) | +| **P4 ✅(文件 IO)** | 主表 `.lvl` 导入/导出 + 颜色⚙ `.clr` 导入/导出(纯函数 `ColorScaleIO`,与原系统互通,4 单测) | 导出再导入 1:1 还原 | +| P4 模板库 | 后端 `.lvl/.clr` 模板「另存/打开」需后端接口,本地无后端 → 暂以文件导入导出替代 | — | + +## P2(层级⚙)落地说明 + +- **子对话框** `ContourLevelDialog`(复刻 `contourLevel.vue`):分层方式下拉、最大/最小等值线、normal + 「间隔数↔层数」双向联动(`isAutoUpdating` 防递归)、对数「次要等值线数」、等积「层数+区间面积(自动)」、 + 恢复默认、确定校验(max>min、间隔>0、层数≤50、对数 max>0/min≥0、等积 count>0)。 +- **纯算法** `ContourLevels.{hpp,cpp}`(无 Qt/VTK):`generateContourLevels(params, samples)` 复刻 + `colorLevel.vue` case 'level' 的层级重算——normal 等距、log 10 的幂区间细分、equalArea 样本分位 + (样本不足退化等距线性,复刻原版失败兜底)。6 个单测覆盖。 +- **取色**:`ColorScaleConfigDialog::interpColor` 复刻 `colorUtils.js` mapColors,在旧断点上连续线性 + RGBA 插值给新层级上色;主表「层级⚙」按钮打开子对话框 → 重算 → 重填表,最终「确定」走 P1 应用链路。 +- **等积样本**:main.cpp 从当前体素 `vtkImageData` 标量抽取传入;无则等积退化线性。 +## P2(线形⚙ + 共享)落地说明 + +> 修正:编辑器是**与数据集视图(2D)共用**的组件。2D `ContourPlotItem` 真画等值线,故 线形⚙ 有渲染 +> 落点(先前"3D 无落点"仅对 3D 成立)。 + +- **子对话框** `ContourLineDialog`(复刻 `contourLine.vue`):线型(实线/虚线)、线显、线色、标注显、标注色。 +- **共享输出**:`ContourLineConfig {lineShow, lineColor, dashed, labelShow, labelColor}`; + `ColorScaleConfigDialog` 加「线形⚙」按钮 + `lineConfig()` getter,构造增 `lineInit` 入参。 +- **2D 接入**:`GridDataChartView`「色阶配置」按钮 → 打开共享编辑器(传 grid 色阶 + `grid.values()` + 样本 + 当前 `lineCfg_`)→ 确定后更新色阶/线形配置 + 重建 `ContourPlotItem`。 + `ContourPlotItem` 新增 `setLineColor/setLineDashed/setLabelColor`,`draw()` 等值线 pen 取色/虚实、 + 标注 pen 取色(默认黑实线,未编辑前行为不变)。 +- **3D 接入**:右键「色阶」打开同一编辑器,仅消费 `colorScale()`,线形/标注忽略(3D 帘面 banded + contour 不画独立线、体素/切片无线)。 +- **未接**:`RawDataChartView`(原始散点视图,无等值线网格)「色阶配置」仍占位;待真有需求再接。 + +## P3(颜色⚙)落地说明 + +- **自绘渐变控件** `GradientEditWidget`(替代原版 fabric.js+d3 画布):横向渐变条 + 棋盘透明底 + + 可拖拽三角手柄;单击条加点(色=该处采样)、拖拽移位、双击改色、选中后 Delete/右键删除(≥2)。 +- **`ColorGradientDialog`(颜色⚙)**:渐变控件 + 预设方案(含原版 17 段 GMT + 彩虹/蓝白红/灰度) + + 反向(pos→1-pos) + 整体透明度(0–1 滑块) + `.clr` 导入/导出。 +- **回填**:主表「颜色⚙」按钮 → 用当前断点归一化位置(lo..hi→0..1)作渐变初值 → 编辑确定后在新渐变上 + 按各层级位置连续采样回填颜色(复刻 `mapColors`),整体透明度<1 时覆盖 alpha(复刻 `addAlphaToColor`)。 + 透明度直接影响 3D 体素/切片观感(alpha 进 LUT/转移函数)。 + +## P4(文件 IO)落地说明 + +- **纯函数** `ColorScaleIO.{hpp,cpp}`(无 Qt):`parseLvl/generateLvl`(复刻 colorUtils.js `.lvl` LVL3 + 格式,行内扫 `R G B A` 令牌定位线色/填充色,免疫 `LStyle` 含空格的列错位) + + `parseClr/generateClr`(复刻 colorEditor.vue `.clr` `ColorMap n 0 6 2` 格式)。4 单测覆盖往返。 +- **接入**:主表「导入/导出」按钮 = `.lvl`;颜色⚙「导入/导出」按钮 = `.clr`。文件级互通替代后端模板库 + (原版「另存模板/打开模板库」走后端 `saveLvlTemplate/queryClrColorLevel`,本地无后端故省)。 +- **校正**:原版 `.clr` 导入读透明度取了行首 token(恒为 100)实为 bug,这里取真实透明度 token。 + +## 2. P1 数据模型映射 + +原版表格行 `{level, lineType, color}` ←→ 本工程 `core::ColorScale` 的 `(value, Rgba)` 升序断点。 + +- 原版 `generateColorScale(origincolors, dataMin, dataMax)` 把绝对 level 归一化为 pos;本工程 + `core::ColorScale` 直接存**绝对值**断点,无需归一化 → 表格「层级」= 绝对数据值,确定时逐行 + `addStop(value, rgba)`。 +- alpha:`Rgba.a`(0–255);颜色选择用 `QColorDialog`(启用 alpha 通道)保真。 +- 表格按层级**降序**显示(高值在上,对齐竖直色阶条直觉);内部仍升序 addStop。 + +## 3. P1 UI(`ColorScaleConfigDialog`,新增 `src/app/`) + +- 标题「色阶配置」,模态。 +- `QTableWidget` 两列:**层级**(数值,右对齐)|**颜色**(色块单元,整格填充该色)。 +- 双击「层级」格 → `QInputDialog::getDouble` 改该断点值;改后重排序并刷新色块插值。 +- 双击「颜色」格 → `QColorDialog`(`ShowAlphaChannel`)改该断点色。 +- 按钮:**新增**(在选中行上方插入,值取选中行与上一行中点、色取两端线性插值)、**删除** + (选中行;保留 ≥2 个断点)、**确定** / **取消**。 +- `colorScale()` getter:从表格行装配并返回新的 `core::ColorScale`。 + +## 4. P1 应用链路 + +`Column3DAnalysis::colorScaleRequested(dsId)` → main.cpp 处理: + +1. 校验 dsId 为当前已渲染三维体(`sceneView->currentVolumeDsId()==dsId && hasVolume()`),否则提示 + 「请先勾选该三维体使其渲染后再编辑色阶」。 +2. 用 `sceneView->currentColorScale()` + `currentVmin()/currentVmax()` 打开对话框。 +3. 确定 → `sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale())`。 + +新增 `VtkSceneController::setVolumeColorScale(dsId, cs)`: +- 更新 `volumeScaleCache_[dsId] = cs`(会话级 mock 持久)。 +- 若该体已渲染(isChecked + volumeCache 命中):`view_.removeDataset(dsId)` → `view_.addVolume(dsId, + grid, cs)`。`addVolume` 内部置 `currentColorScale_` 并触发 `onVolumeChanged` → InteractionManager + 以新色阶重建已勾选**切片**;末尾 `renderIncremental()`。 +- 帘面(源剖面)为独立数据集、各自源色阶,P1 不联动(仅体+切片,对齐 InteractionManager 单元)。 + +## 5. 不做(YAGNI / 边界) + +- 不动 2D `GridDataChartView`/`RawDataChartView` 的「色阶配置」按钮(共享同对话框,留 P 后续接线)。 +- 不接后端保存(mock);不做线形/标注、连续画布、模板 IO(P2–P4)。 diff --git a/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md new file mode 100644 index 0000000..455410a --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-2d-dataset-vtk-view-design.md @@ -0,0 +1,138 @@ +# 二维数据集视图(VTK 2D 渲染)设计 — 调研与取舍 + +> 日期 2026-06-22。分支 `feat/vtk-3d-view`。 +> 起因:需求"二维数据集视图 = 筛选勾选对象中、ds 类型显示属性为 2D 的数据集,勾选后显示在 VTK 的 2D 视图"。 +> **此面板是客户端独有功能,原版 web 无对应面板**(无可照抄的实现)。 + +--- + +## 1. 需求原文 + +``` +筛选对象列表中所勾选的对象中、ds 类型的显示属性为 2D 的数据集 +勾选一个或者多个数据集,可显示在 VTK 的 2D 视图 +``` + +经与用户确认:「VTK 的 2D 视图」= **2D 数据平铺进当前 3D 地图**(不另开正交视口),由二维数据集栏的 `view2DMode`(关闭 / Z=0 / 顶部 / 底部 / 自定义)+ `customZ` 控制摆放高度,叠在底图(天地图/Google)之上,多选可叠多张。共享同一 `GeoLocalFrame` 配准。 + +--- + +## 2. 关键调研结论:后端无「显示属性」字段(实测 API 确认) + +用真实 token 拉了 `项目列表 → 项目结构 → 数据集列表` 三级接口(`http://tenant.geomative.cn/pop-api`): + +- 数据集列表 `POST /business/dsObject/data/page`(**classifyType=3** 为数据,非 2)返回的每条 ds: + ``` + ddCode(如 dd_inversion_data)、dsTypeCode("ERT platform inversion data")、 + dsTypeId、name(类型名)、dsName、dsClassifyType=3、 + properties[](confFieldId→value:采集时间/CRS/点数/日期…)、parentId/source*… + ``` + **没有任何 2D/3D、dimension、display、显示 字段。** +- dataView 用的 `POST /business/projectWorkbench/queryProjectStruct` 只返回树节点(type 1/2/3),同样无维度。 +- `properties` 里的 confField 是采集元数据(时间/坐标系/点数),不是显示属性。 + +**结论**:「ds 类型的显示属性为 2D」**不是后端字段**,只能是**客户端按 `ddCode` 自定义的分类**。桌面端其实已有这张表 —— `Api3dRepository::dimensionOf` / `LocalSample3dRepository::dimensionOf`(标了 TODO),它**就是**「显示属性」的实现,只是当前不完整(仅 `dd_trajectory_data`→2D,其余 3D/Other)。 + +> 即:要做这个需求,等于**客户端定义/补全 ddCode→显示维度 映射**,后端给不了真值。 + +--- + +## 3. ddCode 形态台账(取自原版 datasetInfo 的 ddCode→详情组件映射 + 行业语义) + +| ddCode | 详情组件 / 含义 | 空间形态 | +|---|---|---| +| dd_trajectory_data | ElectrodeCom 电极/测线 | **线**(lat/lon 序列)| +| //dd_transient_electromagnetic_trajectory_data | BatteryComMapLineCom 瞬变电磁测线 | **线** | +| //dd_radar_channel_trajectory / dd_radar_rtk_trajectory | 雷达通道/RTK 轨迹 | **线** | +| dd_inversion_data | ContourCom 等值面 | **剖面** | +| dd_ert_measurement_data | ScattersCom 散点 | **原始数据**(电极对 × 视深,散点) | +| //dd_ert_measurement_gr_data | GroundResistanceCom 接地电阻 | 沿线序列 | +| //dd_gpr_channel_image / dd_radar / //dd_gpr_channel_detail | 雷达图像/成果,//重新分析 | **竖直 B-scan**(沿轨迹 × 时深)| +| dd_grid | DdGridCom 网格,//扩展支持接地电阻 | 网格(**面 or 剖面,依数据而定,存疑**)| +| dd_voxel / (dd_Structual3D) / //dd_Property3D | 体素 / (结构) / //属性 3D | **三维体** | +| //dd_3d_show | ThreeModelCom 3D 模型 | **三维模型** | +| dd_slice | 切片 | 三维分析 | +| ??dd_time_sensor | TimingSensor 时序传感器(折线图+数据表)| 时序曲线(非空间)**待确认** | +| ??dd_current_method_indicator | DdElectricDetectionCom 电流法指标(指标表+散点状态图)| 指标/状态(非空间)**待确认** | + +> `??` 标记:原版 datasetInfo 有此详情组件,但初版台账漏列,详情视图是否纳入客户端范围**待确认**(2026-06-22 补)。雷达/GPR 家族(`//` 标记)因客户端将重构、客户需求未定,整体搁置。 + +--- + +## 4. 专业 + 用户视角:哪些值得作为「2D 渲染到 VTK」(业务价值导向,不凑功能) + +中央 VTK 视图是**地理配准的 3D 地图**(地形 + 底图 + 共享 frame)。"2D 渲染进去"= 把数据**摆到地图上**。按数据的空间本质分三类: + +### 4.1 地理足迹型(线 / 点 / 面)—— 有真业务价值 ✅ +- **测线 / 轨迹**(dd_trajectory_data、各类 *_trajectory):本就是地面上一串 lat/lon = 物理测线/电极布设/雷达轨迹。 +- **电极 / 传感器点位**:地理点。 +- **真·面状栅格**(若存在:lat×lon 的面值网格,如地表某物性面)。 + +**价值**:回答"survey 做在**哪**、多条线/对象**空间怎么排布**、覆盖范围、异常相对地图/地形在**哪**"。对**没有三维体表示的纯 2D 数据(轨迹、点)**,地图平铺是它**唯一**的空间表达。daily 工程价值高。 + +### 4.2 竖直剖面型(反演剖面 / 雷达 B-scan / 拟剖面)—— 平铺进地图**没有业务价值** ❌ +- 这类数据是"距离 × **深度**"。把它**平铺**到水平地图上,等于让"深度"当地图的水平轴 —— **地理上无意义、且误导**。 +- 它们的正确表达是: + - **3D 视图**里沿测线**立成帘面**(地理竖直切片,已实现); + - **2D 详情面板**(`GridDataChartView` / Qwt)里**正视读图**(距离 × 深度,地球物理师标准看图方式,已实现)。 +- 再把剖面图平铺到地图 = **凑功能**,无增量价值。 + +### 4.3 三维体型(voxel / 结构 / 属性 / 3D 模型)—— 不属于 2D +走 3D 渲染(体素/模型),不在本面板范围。 + +### 4.4 用户视角佐证 +- **野外/项目工程师**:要"我测在哪、线怎么排、异常落在地图哪个位置" → **足迹型**每天用。 +- **解释员**:剖面要么正视读(详情面板)、要么看帘面(3D),**绝不**会想看一张平铺在地图上的剖面。 + +--- + +## 5. 建议(结论) + +**二维数据集视图的真实业务价值 = 地图上的「平面/足迹层」**:把**本质是平面/线、且没有有意义三维体表示**的数据集,按地理位置摆到 3D 地图上,提供空间上下文与覆盖总览。 + +- **纳入 2D 渲染(足迹)**:测线/轨迹类(线)、电极/传感器点位(点)、真·面状栅格(面,若有)。 +- **不纳入**:反演剖面、雷达 B-scan、拟剖面等**竖直剖面**——它们已有"3D 帘面 + 2D 详情正视图"两条更对的表达,平铺地图无价值且误导。 +- **dd_grid 存疑**:需确认其数据到底是"面状栅格"(→ 纳入)还是"剖面网格"(→ 不纳入)。 + +> 即 `dimensionOf` 的 2D 集合应当是**足迹型**,而非把所有非三维体都塞进来。 + +### 待产品确认 +1. 2D 集合是否就取"足迹型"(测线/轨迹 + 点位 + 面状栅格)? +2. `dd_inversion_data` 是否**坚持不进** 2D 面板(按 §4.2,建议不进;它进 3D 栏渲帘面 + 详情面板正视)? +3. `dd_grid` 的真实数据形态(面 / 剖面)? + +--- + +## 5.1 关键数据路径发现(影响实现规模) + +`VtkSceneController` 持**两个仓储**: +- `dsRepo_` = **`LocalSampleRepository`(样本数据)**:`grid(dsId)=dsRepo_.loadGrid` 只有内置样本网格(grid1 等)。**现有 Map2D 的 `addSurveyLine(grid(dsId))` 用的就是样本数据,不是真实后端**,且 Map2D 模式从不激活。 +- `sceneRepo_` = **`Api3dRepository`(真实后端,async)**:帘面 `loadSection` / 体 `loadVolume` 走真实 ERT 端点。 + +**结论**:渲染**真实** 2D 足迹**不能复用样本测线路径**,需在 `Api3dRepository` 加**真实异步足迹加载器**。轨迹线端点已存在:`GET /business/dd/ert/trajectory/line?dsObjectId={id}&frontCrsCode={crs}`(返回经纬度,`ApiDatasetRepository::makeTrajectoryMap` 已在用)。 + +**首切片范围(可交付、可测)**:先做**轨迹线**足迹(`dd_trajectory_data` 等轨迹类)——端点确定、数据形态确定(线)。点位/面状栅格待其端点与数据形态确认后续做。 + +## 6. 实现路径(确认 2D 集合后) + +1. **分类**:补全 `dimensionOf`(ddCode→维度),2D 集合 = 足迹型。DsRow 无需新字段(后端无此字段)。 +2. **渲染**: + - 线(测线/轨迹)→ 复用 `buildSurveyLine` / `addSurveyLine`(已存在),在**当前 3D 场景**画(不切 Map2D 模式)。 + - 点(电极/传感器)→ 复用 `ElectrodeActor` / 点 actor。 + - 面状栅格(若纳入)→ 收尾 `GridContourActor` 成水平等值面,着色复用帘面刚修正的「banded + 上界 stop 取色 + 满 RGB」共享函数。 +3. **Z 摆放**:`view2DModeChanged`(关闭/Z=0/顶部/底部/自定义)+ `customZChanged` → 控制 2D 层世界 Z。 +4. **接线**(main.cpp):`col2D()->checkedDatasetsChanged` → 新增 `setChecked2DDatasets`;Z 模式信号接入。底图已就绪。 +5. **配准**:共享 `GeoLocalFrame`,与帘面/测线同系。 + +--- + +## 7. 参考 / 实证 + +- 真实端点(base `http://tenant.geomative.cn/pop-api`,header `geomativeauthorization: Geomative `): + - 项目 `POST /business/my/profile/project/page` + - 结构 `GET /business/projectStruct/queryProjectStruct/{projectId}` + - 数据集 `POST /business/dsObject/data/page`(body 含 projectId/structParentId/structParentConfType/`classifyTypeList:[3]`/pageNo/pageSize) + - dataView 结构 `POST /business/projectWorkbench/queryProjectStruct` +- 桌面端:`Api3dRepository::dimensionOf`(待补全)、`VtkSceneController`(Map2D/View3D + addSurveyLine)、`Column2DDataset`(信号 checkedDatasetsChanged / view2DModeChanged / customZChanged 已定义、main.cpp 仅接 basemap)。 + + diff --git a/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md b/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md new file mode 100644 index 0000000..aed5edc --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-dataset-detail-interaction-replica-ledger.md @@ -0,0 +1,174 @@ +# 数据集详情视图 · 交互 100% 复刻台账 + +> 目标:详情视图(datasetInfo)逐交互 1:1 复刻原版 web(`D:\Git\lanbingtech\commercial-admin`)。 +> 原则:对照原版源码找全差距;凡已实现的视图,其后端接口均已具备(客户端只需新增对应调用)。 +> 范围:仅**已实现的 5 个 ddCode 视图**。雷达/GPR/电磁/3D 模型家族(客户端将重构、需求未定)整体搁置。 +> 日期:2026-06-22 立账。 + +## 0. 总览结论 + +| 视图 (ddCode) | 客户端类 | 复刻完成度 | 差距集中点 | +|---|---|---|---| +| 电极轨迹 dd_trajectory_data | TrajectoryStrategy / TrajectoryMapView 等 | ✅ **无差距** | 地图/列表/高程齐全;原版「导出」按钮原版自身亦未实现 | +| 接地电阻 dd_ert_measurement_gr_data | GrMeasurementStrategy / BarChartView | ✅ **无差距** | 柱状图/列表齐全;原版地面信息/模型/脚本/导出按钮原版自身亦未实现 | +| ERT 原始数据 dd_ert_measurement_data | MeasurementStrategy / RawDataChartView | ✅ **基本接通** | 工具条 1:1,写操作全接;仅 M14 框选后置(重型,已登记) | +| 反演等值面 dd_inversion_data | ErtInversionStrategy / RawDataChartView(原数据) + GridDataChartView(网格) | ✅ **基本接通** | 网格/白化/滤波/异常CRUD/自动标注/另存为/色阶均接;仅 I9 图上绘制、I14 富文本、I3 tmObjectId 透传后置 | +| 网格白化 dd_grid | GridStrategy / DataTableView | ✅ **已通** | 分页列表 + 「反演」功能按钮(载荷 functionList 驱动,复用 InversionFormDialog) | + +**架构事实**:客户端 `ApiDatasetRepository` 当前只有 load(读)操作,无写操作。所有反演/保存/过滤/白化/滤波/异常写接口需新增客户端调用,沿用 `ApiClient(get/postJsonAsync) → ApiBatch → ApiDetailLoad` 模式(或为写操作引入独立的 command 调用路径)。 + +> ⚠️ 实现前务必直接读原版 `src/apis/datasetInfo/index.js` 与对应 `.vue` 复核请求体字段名(本台账 API 字段来自探查 agent 摘录,端点/方法可信,个别字段名以源码为准)。 + +--- + +## 1. 共享基础设施(先建,多视图复用) + +这些组件被多个交互复用,应优先抽象,避免各视图各写一份。 + +### 1.1 反演动态表单对话框(InversionForm) +- **复用于**:measurement「反演运算」「生成视电阻率」、grid「反演」。 +- **流程**:① 查模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单字段 → ④ 填参 → ⑤ 提交。 +- **API**: + - 模型列表 `GET /business/outerInversion/query/script?dsObjectId=`(measurement/grid 通用) + - 动态表单 `POST /business/project/getDynamicForm` body `{projectId, type:6, typeId}` + - 提交反演 `POST /business/outerInversion/submitInversionTask` body `{dsId, properties, ...}` + - 生成视电阻率 `POST /business/dd/ert/measurement/createVisualResistivityData` body `{dsObjectId, scriptId, scriptParamListJsonStr}` +- **客户端现状**:无。需建 `InversionFormDialog`(动态字段渲染:分组卡片 + Select 为主)。 + +### 1.2 色阶配置编辑器(已存在,复用) +- **已有**:`ColorScaleConfigDialog`(被 GridDataChartView 使用,且与三维体右键色阶共用)。 +- **复用目标**:measurement 散点「色阶配置」、inversion 原数据散点「色阶配置」。 +- **API**:查 `POST /business/lvl/colorGradation/getDetail`,存 `POST /business/lvl/colorGradation`。 +- **注意**:measurement 走 `businessCode=R0, type=3`;inversion 原数据 `type=1`、网格 `type=2`。散点上色用 `colorSvc_`,编辑后需重建并重绘散点。 + +### 1.3 另存为对话框(SaveAs) +- **复用于**:measurement 另存为、inversion 另存为。 +- **measurement**:`POST /business/dd/ert/measurement/saveRawData` `{dsId, operationType(1新增/0覆盖), name?}`(含新增/覆盖单选) +- **inversion**:`POST /business/dd/ert/inversion/saveAsData` `{dsObjectId, name}`(仅名称) + +--- + +## 2. ERT 原始数据(measurement / RawDataChartView) + +客户端 measurement 工具条已 1:1 建出(`buildMeasurementToolbar`)。逐控件差距: + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| M1 | 显示/隐藏 | popconfirm→改全部点可见性(持久化) | `POST /business/dd/ert/measurement/saveDisplayStatus` `{dsObjectId, ids[], status}` | ✅ **已通**(onShowHide:确认→调接口→本地切换) | — | +| M2 | 表格行可见性 switch | 行级 popconfirm→改单点 | 同 M1(ids=[record.id],status 取反) | 🔸 **后置**(DataTableView 行级开关列交互重,源 saveDisplayStatus 已具备) | 见 §6 | +| M3 | 数据过滤 | 弹窗(直方图+min/max)→生成过滤后数据集 | 查 `GET /business/scatterPlotDataFilter/getDataFilterConfig?dsObjectId&vFieldCode`;应用 `POST /business/scatterPlotDataFilter` `{sourceDsObjectId, sourceVFieldCode, min, max}` | ✅ **已通**(ScatterFilterDialog:范围 min/max + 应用);直方图绘制后置 | 直方图见 §6 | +| M4 | X 轴下拉(平距/斜距) | 本地换列重绘 | 无 | ✅ 已通(replotForAxis) | — | +| M5 | Y 轴下拉(伪深度/+高程/层数) | 本地换列重绘 | 无 | ✅ 已通 | 层数为 no-op(原版亦无数据) | +| M6 | V 值下拉 | 重新请求散点+色阶 | `GET .../scatter/graph?vFieldCode=` + `POST .../getDetail{businessCode=新V}` | ✅ **已通**(reloadForVValue 带 vFieldCode 重载) | — | +| M7 | 值类型下拉(线性/倒数/对数) | 本地换显示 | 无 | ✅ **已通**(applyValueType 本地变换重上色) | — | +| M8 | 色阶配置 | 弹窗编辑+保存 | getDetail/colorGradation(见 1.2) | ✅ **已通**(复用 ColorScaleConfigDialog;properties 含 colorBar+lineConfig+labelConfig) | — | +| M9 | 生成视电阻率 | 反演弹窗(模型锁定视电阻率脚本) | createVisualResistivityData(见 1.1) | ✅ **已通**(InversionFormDialog::ApparentResistivity:下拉 disabled+锁 `script_visual_resistivity_data`,对齐原版) | — | +| M10 | 反演运算 | 反演弹窗 | submitInversionTask(见 1.1) | ✅ **已通**(InversionFormDialog::Inversion) | — | +| M11 | 另存为 | 新增/覆盖弹窗 | saveRawData(见 1.3) | ✅ **已通**(SaveAsDialog::RawData) | — | +| M12 | 导出 DAT | 下载 base64 | `GET /business/dd/ert/measurement/rs2d/export?dsId&electrodePosition=2&ipDataMark=0&typeMeasurement=0` | ✅ **已通**(exportDat,参数对齐原版) | — | +| M13 | [i] 信息 | 点选看 A/B/M/N/Pseu/Row | 无(本地) | ✅ **已通**(toggleInfoMode:信息模式点选散点看属性) | — | +| M14 | 框选/点选模式 | enter/exitSelectMode | 无(本地) | 🔸 **后置**(Qwt 橡皮筋框选+选区联动隐藏成本高,保留占位提示) | 见 §6 | + +--- + +## 3. 反演等值面(inversion) + +### 3.1 网格视图(GridDataChartView) + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| I1 | 网格(化) | 2步向导(选算法+参数)→网格化 | `GET .../queryAlgorithmModel/{ds}`;`GET .../getRawData/{ds}`;`POST /business/dd/ert/inversion/grid`(actionCode,x/y min/max,**xsize/ysize=点数**,xSpacing/ySpacing=间距,vmin/vmax,saveDataValueType) | ✅ **已通**(GridWizardDialog 2步;xsize=点数 xPoints/ySize=yPoints,间距走 xSpacing/ySpacing,对齐原版 toGridTheData) | — | +| I2 | 色阶配置 | 弹窗 | getDetail/colorGradation | ✅ **已通**(本地生效;网格视图色阶不持久化到后端,与原版网格路径一致) | — | +| I3 | 白化 | 弹窗(3种方式) | `POST /business/dd/ert/inversion/whitenedData`;文件列表 `POST /business/dsObject/queryWhitenedDataList` | ✅ **已通**(白化弹窗+提交);`tmObjectId` 暂兜底空串 | tmObjectId 透传见 §6 | +| I4 | 滤波处理 | 弹窗(滤波器树+矩阵) | 列表 `GET /business/filter/queryFilter`;增 `POST /business/filter`;删 `DELETE /business/filter/delete/{id}`;应用 `POST /business/dd/ert/inversion/filterData` | ✅ **已通**(滤波弹窗含滤波器 CRUD+应用) | — | +| I5 | 显示异常 | 本地显隐 | 无 | ✅ 已通 | — | +| I6 | 显示等值线标注 | 本地显隐 | 无 | ✅ 已通 | — | +| I7 | 显示等值线提示 tooltip | 本地显隐 | 无 | ✅ **已通**(chkContourTip 接 hover tooltip 显隐) | — | +| I8 | 简化容差滑块 | 防抖本地重算等值线(0~2,步0.1) | 无 | ✅ **已通**(防抖 applySimplify→setSimplifyTolerance 真生效) | — | +| I9 | 异常 创建 | 弹窗(类型/名称/备注)+图上绘形 | 类型 `GET .../queryExceptionTypeByProjectIdAndType/{pid}/{type}`;名建议 `POST /business/exception/getExceptionName`;新增 `POST /business/exception` | ✅ **表单已通**(ExceptionDialog:类型/名建议/备注+提交);🔸 **图上绘形后置** | 图上绘制见 §6 | +| I10 | 异常 删除 | 表格行删 | `DELETE /business/exception/{id}` | ✅ **已通**(AnomalyTablePanel deleteRequested) | — | +| I11 | 异常 详情/编辑 | 抽屉(名称/备注/样式) | `PUT /business/exception` | ✅ **已通**(ExceptionDetailDialog;只发 `{id, exceptionName, remark}`,对齐原版局部更新) | — | +| I12 | 异常 定位 | 本地高亮+缩放(防抖) | 无 | ✅ **已通**(AnomalyTablePanel locateRequested→图上定位) | — | +| I13 | 自动标注 | 弹窗(规则+预览) | 预演 `POST /business/exception/exception-mark/execute`;批量存 `POST /business/exception/batch/create` | ✅ **已通**(openAutoAnnotation 自动标注弹窗) | — | +| I14 | 富文本描述保存 | Quill→保存 | 存 `PUT /business/dsObject/updateDsObject/`;取 `GET /business/dsObject/getDetail/{ds}` | ✅ **保存链路已通**(DescriptionPanel saveRequested→saveDescription,取/存均通);🔸 **富文本降级为纯文本** | Quill 富文本见 §6 | +| I15 | 另存为 | 弹窗(名称) | `POST /business/dd/ert/inversion/saveAsData` | ✅ **已通**(SaveAsDialog) | — | + +### 3.2 原数据散点视图(RawDataChartView 默认工具条) + +| # | 控件 | 原版行为 | 客户端现状 | 实现要点 | +|---|---|---|---|---| +| O1 | 网格 | 同 I1 网格化向导 | ✅ **已通**(复用 GridWizardDialog) | — | +| O2 | 色阶配置 | 散点色阶(type1,businessCode 空) | ✅ **已通**(openInversionColorScale;properties 含 colorBar+lineConfig+labelConfig) | — | +| O3 | 另存为 | 同 I15 | ✅ **已通**(SaveAsDialog::Inversion) | — | +| O4 | 图形格式下拉 | 散点↔2D直方图等值线(原版 disabled) | ✅ 不做(原版自身禁用) | — | + +--- + +## 4. 网格白化数据(grid / DataTableView) + +| # | 控件 | 原版行为 | 原版 API | 客户端现状 | 实现要点 | +|---|---|---|---|---|---| +| G1 | 反演 | 反演弹窗 | submitInversionTask(见 1.1) | ✅ 已接 | DataTableView 顶部功能按钮行(载荷 functionList 驱动,仅 dd_grid 非空),点 inversion → 复用 InversionFormDialog(Mode::Inversion) | +| G2 | 分页 | 服务端分页 | grid/rows pageNo/pageSize | ✅ 已通 | — | + +--- + +## 5. 建议执行顺序(分阶段) + +1. **阶段 A · 共享基础设施** + - InversionFormDialog(模型列表+动态表单+提交)→ 一次解锁 M9/M10/G1。 + - SaveAs 弹窗(measurement + inversion 两形态)。 + - 色阶配置接入散点(复用 ColorScaleConfigDialog)→ M8/O2。 +2. **阶段 B · measurement 主交互** + - M1/M2 可见性持久化、M11 另存为、M12 导出、M6 V值重载、M7 值类型、M3 数据过滤。 + - M13/M14 信息/框选(交互重,最后)。 +3. **阶段 C · inversion 写操作** + - I1/O1 网格化向导、I15/O3 另存为。 + - I9~I13 异常 CRUD + 自动标注。 + - I3 白化、I4 滤波。 + - I7 tooltip、I8 简化容差真生效、I14 描述保存复核。 +4. **阶段 D · grid** + - G1 反演按钮(阶段 A 完成后顺带)。 + +> 每个交互按 TDD:先对 repository 写方法/解析写测试(mock ApiClient),再接 UI;UI 视觉对照原版。 + +--- + +## 6. 复刻收尾状态(2026-06-22) + +三份审查后的修正项已落地,build 通过 + 测试全过(285/285)。 + +### 6.1 已 100% 接通项 + +- **measurement(M1~M13)**:显隐持久化、数据过滤(范围)、X/Y/V/值类型下拉、色阶配置、生成视电阻率(下拉锁 `script_visual_resistivity_data`)、反演运算、另存为、导出 DAT、[i] 信息点选——全部接通并对齐原版。 +- **inversion 网格(I1~I8、I10~I13、I15)**:网格化向导(xsize/ysize=点数,对齐 toGridTheData)、色阶、白化、滤波(含滤波器 CRUD)、显示异常/标注/tooltip、简化容差真生效、异常删除/详情编辑/定位、自动标注、另存为——全部接通。 +- **inversion 原数据散点(O1~O3)**:网格化、色阶(type1)、另存为——全部接通;O4 原版自身禁用,不做。 +- **grid 白化(G1/G2)**:反演按钮(functionList 驱动)、分页——已通。 + +### 6.2 审查修正项(本轮) + +- **重复 connect(M13/M14)**:删除 `btnInfo`/`btnMarquee` 残留的 `clicked→showNotImplemented`,消除信息按钮多弹「暂未实现」、框选按钮单击弹两次;btnInfo 保留 checkable+toggled(信息模式),btnMarquee 保留单条占位提示。 +- **异常详情更新字段(I11)**:原版 `contourPage.vue onOk` 走 `PUT /business/exception` 局部更新,**仅发 `{id, exceptionName, remark}`**(线样式是另一条独立 PUT,且抽屉样式控件 disabled)。客户端 `ExceptionDetailDialog::onConfirm` 已对齐:不再附带/覆盖 `legend`。 +- **色阶 properties 补齐(M8/O2)**:原版散点路径 `newLvlColorLevel` 的 `properties` 含 `colorBar` + `lineConfig{showLines,color,lineType}` + `labelConfig{showLabels,color}`(battery/scatters 仅这三块,不含等值面专属的 lvlSchemeType/logLinesCount/equalAreaLayerCount)。客户端两处散点保存已补齐这三块(新增 `buildColorScaleProperties` 复用 `dlg.lineConfig()`)。`templateId` 原版非必需,客户端按原版处理。 + +### 6.3 self-check 结论(无需改) + +- **#4 视电阻率模型锁定**:`InversionFormDialog::ApparentResistivity` 已 `modelCombo_->setEnabled(false)` 且锁定 `code==script_visual_resistivity_data` 的项——与原版 `InversionDialog.vue`(静态 `disabled` + 锁脚本)一致。 +- **#5 网格 xsize/ysize 绑点数**:`GridWizardDialog` 的 `xSize_/ySize_` 是「X/Y点数」(1~300,默认 100),`buildGridToBody` 映射 `xsize←xSize`、间距走独立 `xSpacing←xSpacing_`——与原版 `GridDialog.vue toGridTheData`(`xsize:xPoints`、`xSpacing:xInterval`)一致。 + +### 6.4 收尾 6 项 —— 已全部接通(2026-06-23,commit ec4a7e8) + +§6.4 原列的 6 项后置/降级项已全部实现,build app + test 全绿(318/318)。 + +| 项 | 状态 | 实现 / 残留边界 | +|---|---|---| +| **M2 行级可见性 switch** | ✅ | DataTableView 载荷驱动可交互开关列(`toggleInteractive`+`rowIds`,仅 measurement 置位),行级 popconfirm → `saveDisplayStatus` | +| **M3 过滤直方图** | ✅ | 新增自绘 `ScatterHistogramView`(20 箱,选区高亮 + min/max 输入联动);拖拽刷选未做(原版用输入/滑块,非画布 brush) | +| **M14 框选/点选模式** | ✅ | `ScatterMarqueePicker` 橡皮筋矩形 → `ScatterPlotItem` 选中红边高亮;显示/隐藏对选中子集操作(无选区回退全部)。复刻 box-select 变体;原版单击逐点选未做 | +| **I9 异常图上绘形** | ✅ | `ContourDrawTool` 在等值面交互绘制 点/线/面/文字(先弹窗填类型/名称→图上绘制→`newException`);坐标表保留为兜底。文字类型无原版独立富文本样式编辑器 | +| **I14 Quill 富文本** | ✅(降级可用) | `DescriptionPanel` 升级富文本(粗体/斜体/下划线/字色/字号/标题/列表)+ `QuillDelta` 与 Quill Delta 常见格式往返。**Qt 无 Quill,不可字节级 1:1**:未知 attributes/嵌入对象容错降级(保文本、丢样式、不崩) | +| **I3 白化 tmObjectId** | ✅(待联调验证) | `openWhitening` 经 `getDsObjectDetail(dsId)` 取 `structParentId` 作 tmObjectId。**存疑**:未实证 getDetail 响应含 structParentId,若不含需转方案 B(经 openDataset 链路透传) | + +### 6.5 命名冲突修复 + +`ScatterHistogram` 名冲突(M3 widget 类 vs ScatterDataOps 分箱 struct)导致 desktop 目标曾无法链接(`build.bat test` 只建测试目标未暴露)→ widget 改名 `ScatterHistogramView`。**教训**:详情视图改动须 `build.bat all` 验证 app 链接,不能只 `build.bat test`。 diff --git a/docs/superpowers/specs/2026-06-23-gpr-volume-A-whole-texture-no-pyramid.md b/docs/superpowers/specs/2026-06-23-gpr-volume-A-whole-texture-no-pyramid.md new file mode 100644 index 0000000..b328bd0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-gpr-volume-A-whole-texture-no-pyramid.md @@ -0,0 +1,99 @@ +# GPR 三维体 · 方案 A:整卷上纹理,不用金字塔(复用现有管线,最简基线) + +- 日期:2026-06-23 +- 范围:把 GPR(探地雷达)阵列数据插值成三维体并在 VTK 中渲染/切片,**直接复用现有剖面三维体管线**,整卷一次性进显存,不做分块/金字塔/核外。 +- 定位:三选一中的**最小改动基线**。用于评估"现有架构原样接雷达,能做到什么、卡在哪"。 +- **⚠ 评审结论(2026-06-23,opus):A 不应作为独立交付步,建议并入 B。** 唯一值得从 A 单独先做的是"三方案共有的地基"(§2)。`double`+400³+暴力 IDW 三条硬约束使 A 产出的 GPR 体既无业务分辨率(沿线被强制粗化到 5.5m vs 物理 5cm)、又无法落盘秒开。详见 §5/§6。 +- 测试数据(明星路):450MHz 阵列 GPR,14 通道,每道 821 采样,单线 ~45306 道,20 线,int16,合计 13.6GB;路长 2223m,测幅 1.37m,深 ~8m。 + +--- + +## 1. 设计意图 + +不引入任何新渲染/存储机制。把雷达数据**喂进现有体素管线**,让它走和反演剖面三维体完全一样的路: + +``` +.iprb/.iprh → PointSet → core::IdwInterpolator → ScalarVolume(double) + → data::VolumeGrid → render::buildVoxel → vtkSmartVolumeMapper(整卷进显存) +切片:render::interact::SliceTool(vtkImagePlaneWidget / vtkImageReslice,CPU 重采样) +持久化:VolumeBuildParams(参数必存)+ 可选明细缓存(现内存 mock) +``` + +现有落点(实证): +- `core::ScalarVolume` = `std::vector`,行优先(`src/core/model/Field.hpp:8-26`)。 +- `render::buildVoxel` → `vtkImageData`+`vtkDoubleArray`+`vtkSmartVolumeMapper`,整卷上传(`src/render/actors/VoxelActor.cpp:41-79`)。 +- 体素维度上限 `kMaxVolumeDim = 400`(`src/core/algo/VolumeBuilder.hpp:8`)。 +- 切片 CPU 重采样(`src/render/interact/SliceTool.cpp:24-39`)。 +- 插值 `IdwInterpolator`,单线程三重循环,且**无空间索引——每体素全点集线性扫描,O(体素数×点数) 暴力**(`src/core/algo/IdwInterpolator.cpp:15-33`,评审实证)。雷达级点集下即便 400³ 也是分钟级甚至卡死,不只是"偏慢"。 +- 持久化 `Api3dRepository::StoredVolume`,纯内存(`src/data/api/Api3dRepository.hpp:112-119`),重算逻辑已就绪(`Api3dRepository.cpp:212-225`)。 +- **注意(评审)**:现有 `loadVolume` 的散点来源硬绑 `loadSection`/`appendGridPoints`(ERT 反演帘面,`Api3dRepository.cpp:146-171`),**雷达没有现成喂入路径**。"复用现有管线"实际仍须新写 GPR→`buildVolume` 接入(§2 已含),§1 流程图的"原样复用"措辞偏乐观。 + +--- + +## 2. 新增工作(雷达接入,三方案共有的地基) + +A/B/C 都绕不开这块,A 用最朴素实现: + +1. **`.iprb`/`.iprh` 解析器**(新):`.iprh` 文本头取 `SAMPLES/LAST TRACE/CHANNELS/TIMEWINDOW/SOIL VELOCITY/DISTANCE INTERVAL`;`.iprb` 读 int16 B-scan(`samples × traces`,校验 `samples×traces×2 == 文件大小`)。 +2. **地理配准**:`.ord` 取 14 通道横向偏移;`.gps`/`.cor` 取每道经纬度/RTK;深度 = `time × soilVelocity / 2`。 +3. **GPR→PointSet 适配器**:把"14 通道 × N 道 × 821 采样"摊成 `PointSet{x,y,z,v}`(局部坐标)。**注意横向只有 14 个真实样本**,是稀疏维。 +4. **数据集接入**:新增 ddCode(如 `dd_gpr_volume`),在维度分类(`Api3dRepository.cpp:30-45`、`LocalSample3dRepository.cpp:43-58`)归 3D;DTO 解析器放 `src/data/dto/`。 + +--- + +## 3. 关键约束与后果(A 的硬边界) + +现有管线是 **double + 400³ 上限**。这两条直接决定 A 能做什么: + +| 约束 | 数值 | 后果 | +|---|---|---| +| 标量 dtype | `double`(8 字节/体素) | 同样体素数,内存是 int16 的 **4 倍** | +| 维度上限 | 400³ | 整卷 ≤ 400³×8 ≈ **512MB**;放不下全路段 | +| 整卷进显存 | 一次性 | 体大小受限于显存 | +| IDW | 单线程 + 无空间索引暴力 | 大点集插值分钟级/卡死(明星路单线 ~5亿采样点级) | + +> **`fitAxis` 行为(评审实证 `VolumeBuilder.cpp:16-26`)**:格数超 400 时**不裁剪范围**,而是 `outCell=ext/(400-1)` 把 400 格摊满整个包络。所以 A 不是"丢掉远端",而是"强制粗化"——沿线细节被低分辨率抹平。 + +**全路段在 A 下做不到原始分辨率。** 明星路需 ~22000(沿线)×270(横)×400(深),远超 400³。在 A 下只能: +- **重度降采样到 ≤400³**:沿线 2223m/400 ≈ 5.5m 网格 → 沿线细节全毁;或 +- **按单条测线/短段分别建小体**(单线降到 400³ 仍偏粗),多体并排显示(类似现有 2D 足迹平铺)。**但**(评审):20 体 × 400³ × double ≈ 10GB 整卷同驻显存,比单大体更易爆显存;且各体独立 `GridSpec`/origin,**跨体的全路段连续切片做不到**(用户想沿全路一刀切无法实现)。 + +--- + +## 4. 持久化(沿用 2026-06-17 §7 策略) + +- **必存**:`VolumeBuildParams`(源数据引用 + 插值模型/参数 + 色阶)+ `GridSpec`(origin/spacing/dims,锚定切片/异常坐标)。 +- **可选明细**:`ScalarVolume`(double)。A 阶段仍是内存 mock(`StoredVolume.cachedGrid`),**未真实落盘**——这是 A 与用户"保存插值后体"诉求的**主要差距**。 +- 用户要的两种保存:①参数 ②插值后明细 —— A 的 ②目前只有内存缓存,需补一段最朴素的 double 体落盘(raw + sidecar)才算满足;但 double 全路段落盘巨大,不实用。 + +--- + +## 5. 评估 + +**优点** +- 改动最小:渲染/切片/异常/详情**全部现成**,只加雷达解析与适配。 +- 路径已验证,风险低,可最快出"雷达能进三维场景"的可见效果。 + +**缺点/限制** +- `double` + 400³ → **撑不起全路段原始分辨率**,只能粗览或分段小体。 +- 明细落盘不实用(double 体积过大),用户"算一次秒开"诉求难真正成立。 +- 单线程 IDW 在雷达量级偏慢。 +- 把结构化的雷达数据(沿线/深度本就规则)当无结构散点做 3D IDW,**算力浪费**(见方案 B 的结构化插值优化)。 + +**适用** +- 单条短测线 / 粗分辨率概览 / 快速打通链路的第一步。 +- **不适合**作为全路段完整体验的最终方案。 + +--- + +## 6. 工作量与落地顺序 + +1. `.iprb`/`.iprh` 解析 + 地理配准 + GPR→PointSet(地基,~中)。 +2. 雷达 ddCode 接入维度分类 + DTO(~小)。 +3. 直接复用 `IdwInterpolator`/`buildVoxel`/`SliceTool`,按 ≤400³ 降采样建体(~小)。 +4. (可选)double 明细落盘最朴素实现(~小,但不推荐用于全路段)。 + +**结论(修订,评审定)**:**no-go(作为独立交付步),并入 B。** A 没有任何 B 不需要的独立资产,渲染/切片/异常/持久化骨架 A、B 共享,地基(§2)也共享。`double`+400³+暴力 IDW 三条硬约束使 A 的 GPR 产物既无业务分辨率、又无法落盘秒开,连"最小基线该兑现的可用产物"都达不到。 +- **唯一抽出先做的独立里程碑 = §2 共有地基**(`.iprb`/`.iprh` 解析 + 14 通道配准 + GPR→PointSet + ddCode 接入),A/B/C 都要。 +- "复用 double+400³ 管线建退化体"这一步**不单独交付**,直接在 B 的 int16+结构化建体上落地,避免做一遍注定被 B 推翻的降级体。 +- **全路段完整体验走 B。** diff --git a/docs/superpowers/specs/2026-06-23-gpr-volume-B-fullroad-int16-whole-gpu.md b/docs/superpowers/specs/2026-06-23-gpr-volume-B-fullroad-int16-whole-gpu.md new file mode 100644 index 0000000..b87ba66 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-gpr-volume-B-fullroad-int16-whole-gpu.md @@ -0,0 +1,120 @@ +# GPR 三维体 · 方案 B:全路段 int16 整卷上 GPU(升级现有管线,推荐) + +- 日期:2026-06-23 +- 范围:把全路段 GPR 按**物理分辨率(5~10cm)**插值成**单个 int16 体(~5~10GB)**,整卷传成 GPU 3D 纹理,切片/体绘制都丝滑。**不做金字塔/核外**,靠"右尺寸 + int16"让单体进显存。 +- 定位(2026-06-23 用户定):**与 C 对等的两条已承诺路线之一,两者都做、用户运行时按需切换**。B 走"整卷进显存"路线(在现有管线上有针对性升级),适合能装进显存的体;超显存的体走 C。经同一 `IVolumeRenderSource` 接口切换。 +- **✅ 评审结论(2026-06-23,opus):Go(条件式)。** 体积测算、int16+结构化插值、渲染/切片复用均成立。**开工前必改 3 项**:①落盘方案(VTKHDF Writer 写不了 ImageData,必须改裸分块,见 §3);②量化贯穿传递函数/色阶/反量化(见 §3.5);③一个 vtkShortArray→GPU 体绘制的小验证 spike。 +- 测试数据(明星路):同方案 A。物理分辨率依据:450MHz、土速 0.1m/ns → 波长 λ≈0.22m、垂向分辨率 ≈5cm;网格细过 ~5cm 即过采样。 + +--- + +## 1. 设计意图 + +A 的瓶颈是 `double` + 400³ 上限,撑不起全路段。B 针对性拆掉这两条,**保持"整卷进显存"这一最省力的渲染架构不变**: + +``` +.iprb/.iprh → 结构化建体(仅横向插值)→ ScalarVolumeI16(int16) + → vtkImageData + vtkShortArray → vtkSmartVolumeMapper(整卷进显存) +切片:复用 SliceTool(reslice 对 int16 image 同样工作) +持久化:VolumeBuildParams + int16 明细【真实落盘 + 分块压缩】 +``` + +体积测算(明星路全路段,依据真实头文件): + +| 网格 (横×纵×深) | 体素数 | int16 体积 | 进显存? | +|---|---|---|---| +| 10cm×10cm×2cm | 22230×270×400 ≈ 2.4G | **4.8GB** | 12GB+ 显卡可 | +| 10cm×10cm×原生821 | ≈ 4.9G | **9.8GB** | 16~24GB 显卡可 | +| 5cm×5cm×5cm | 44460×540×160 ≈ 3.8G | **7.7GB** | 16GB+ 显卡可 | + +**关键:5~10GB 全部在单显卡可承载区间——不需要金字塔/核外。** 那个 39TB 是 cm 级横向过采样的产物,物理无意义。 + +--- + +## 2. 三处核心升级(相对 A) + +### 2.1 dtype:引入 int16 体(4× 内存削减) +- 现 `ScalarVolume` 全仓库是 `double`(`Field.hpp:8-26`),直接改全局风险大。**方案**:新增并行的 `ScalarVolumeI16`(`std::vector` + 同样行优先布局 + 量化标定 `scale/offset` 把物理值映射到 int16),雷达走 int16 路径,反演剖面仍走 double。 +- 渲染:`buildVoxel` 增加 int16 重载 → `vtkImageData` + `vtkShortArray`。**评审已证实** GPU 体绘制原生支持 short(`vtkSmartVolumeMapper`→`vtkOpenGLGPUVolumeRayCastMapper`→`vtkVolumeTexture` 走 GL 16-bit 整型纹理)。NaN/空值改用 int16 哨兵(如 `INT16_MIN`)+ 不透明度传递函数透明(与现 `VoxelActor.cpp:23-24,68-72` 同构)。 +- 收益:同体素数内存/显存/磁盘 = double 的 1/4,是"让全路段进显存"的关键杠杆。雷达原始本就是 int16,**无精度损失**。 +- **适配面比"加个重载"大(评审 HIGH)**:`ScalarVolume`(double) 被 `VolumeGrid`/`buildVoxel`/`finalizeVolume`/`Api3dRepository`(`StoredVolume.cachedGrid`、`loadVolume` 回调签名、`VolumeInfo` 统计) 一路引用。int16 体需让这些**要么模板化、要么并行一套带量化 meta 的变体**。隔离方向(雷达 int16 / 反演剖面仍 double)对,但工作量按"中"算偏乐观,§6 已上调。 + +### 2.2 维度上限:由物理分辨率决定,拆掉 400³ 死值 +- 移除/放宽 `kMaxVolumeDim=400`(`VolumeBuilder.hpp:8`),改为按 `cellXY/cellZ` 与场景范围算出 dims,并加**显存预算守卫**(建体前估算 `nx·ny·nz·2B`,**并留余量**:实际还要叠加传递函数纹理 + 颜色/深度 FBO,按裸标量算偏紧——评审 MEDIUM)。 +- **显存探测无可靠跨厂商 API(评审 MEDIUM)**:OpenGL 无统一"可用显存"查询。实践只能 try-upload-on-fail 或留保守阈值。 +- **免费兜底(评审发现,spec 原漏报)**:`vtkSmartVolumeMapper` 自带 `MaxMemoryInBytes` + `LowResResample`(`vtkSmartVolumeMapper.h:194-211,373-379`),体超显存时**自动降采样重采样到可容纳**——等于"概览体"免费实现。目标机显存小时优先用它 + 按区域细化,**仍在 B 框架内,不必转 C**。 +- 默认网格由雷达物理分辨率给(横 5~10cm、深 2~5cm),不让用户填出过采样网格。 + +### 2.3 插值:结构化建体,不做 3D 散点 IDW +- **重要架构洞察**:雷达数据沿测线(X)、深度(Z)**本就是规则密采样**,只有横向(Y)的 14 通道是稀疏的。所以"插值成体"≠ 3D 无结构散点插值,而是: + - X、Z 方向按道距/采样直接落格(重采样/最近邻,廉价); + - **只在 Y 方向对 14 通道做 1D 插值**填充横向空隙。 +- 这比 A 复用的全 3D IDW **快一两个数量级**,且单线程可接受;若仍慢,Y 向插值天然可并行(QtConcurrent/std::thread,按 X 切片并行)。 +- 保留 `IInterpolator` 抽象,新增 `GprStructuredBuilder` 实现,与 IDW 并列。 + +--- + +## 3. 持久化(真正满足"算一次、之后秒开") + +用户要两种保存,B 把第二种做实: + +- **方式一(参数档)**:`VolumeBuildParams`(源 .iprb 引用 + 建体参数 + 色阶 + 量化 scale/offset)+ `GridSpec`。小、可复算、详情面板展示。 +- **方式二(明细缓存,升级为真实落盘)**:int16 体 **分块写盘 + 逐块压缩**: + - **⚠ 不能用 `vtkHDFWriter`(评审 CRITICAL,两个评审独立证实)**:VTK 9.6 的 `vtkHDFWriter` **写不了 `vtkImageData`/规则体**——它只支持 PolyData/UnstructuredGrid/Partitioned/MultiBlock(`vtkHDFWriter.h:6-9,232-235`,无 ImageData 写重载)。`vtkHDFReader` 能**读** ImageData,但 Writer 不能**写**,读写不对称。"补个 IOHdf 组件就能 VTKHDF 原生落盘体"的说法**错误**。 + - **首选(改正后)**:**自定义 raw int16 分块 + sidecar(GridSpec/量化 scale·offset/vmin·vmax/分块索引) + 逐块 zlib(VTK 自带 `vtkzlib`,无需新依赖)**。分块布局从一开始就设计好,C 的"切片核外"可几乎免费复用同一格式。 + - 备选:直接用底层 `vtkhdf5` C API 自写 chunked dataset(绕过 `vtkHDFWriter`),获得 HDF5 生态兼容;成本高于 raw 分块。 + - 不引入独立 zstd/blosc(vcpkg 未含;如需更高压缩比再加)。 +- **加载**:有明细 → 读盘(可 mmap)→ 整卷上显存;无明细 → 按参数后台线程重算落缓存(复用现有重算逻辑 `Api3dRepository.cpp:212-225`,从 mock 升级为真实落盘)。 +- **后台重算不阻塞 UI(评审 MEDIUM)**:现 `loadVolume` 回调**在主线程**(mock 同步)。改为工作线程建体/重算后,回调要**跨线程编组**回 UI(Qt 信号 / `QMetaObject::invokeMethod`),这是线程模型改动,需显式设计。 +- **加载耗时别承诺"秒开"(评审 LOW)**:5~10GB 上传 GPU(约 1~5s) + 压缩明细解压,实际**约 10s 量级**。明星路单体压缩后约 2~6GB,读盘+解压秒~十秒级——比每次重算快得多,用户"算一次之后快读"诉求成立。 + +### 3.5 量化贯穿(评审 HIGH,正确性问题,必做) +int16 渲染标量是量化域 `q = round((v_phys - offset)/scale)`,不是物理值。必须把量化贯穿全链,否则色阶/读数全错: +- **传递函数 / 不透明度**:现 `VoxelActor.cpp:62-72` 用物理 `vmin/vmax` 加控制点 → int16 路径必须改成在**量化域 `qmin/qmax`** 采样。 +- **切片色阶 LUT**:`buildLut(cs,vmin,vmax)`(`SliceTool.cpp:37`)同理喂量化域。 +- **反量化显示**:取值光标 / 异常详情 / 数据详情面板展示给用户的值必须 `v_phys = q*scale + offset` 反量化回物理量。 +- `scale/offset` 存入 `VolumeBuildParams` 并随 `VolumeInfo` 传递。 + +--- + +## 4. 渲染与交互(基本复用,验证为主) + +- 整卷 `vtkSmartVolumeMapper`(现有),int16 image 直接喂;确认 9.6 的 GPU ray cast 对 short 标量正常。 +- 切片 `SliceTool`(`vtkImagePlaneWidget`/`vtkImageReslice`)对 int16 image 同样工作(CPU reslice 与 dtype 无关);丝滑度由"整卷已在显存"保证。 +- 异常/详情/色阶:复用现有 3D 分析栏链路。 + +--- + +## 5. 评估 + +**优点** +- **全路段完整连续体 + 最好体验**,切片/体绘制丝滑,且**不必上金字塔/核外**。 +- 复用现有渲染/切片/异常/详情,主要新增 = int16 路径 + 结构化建体 + 真实落盘。 +- int16 + 结构化插值同时解决"内存/显存/磁盘大"和"插值慢"。 +- 明细真实落盘,"算一次秒开"成立。 + +**缺点/风险(评审分级)** +- **CRITICAL(已在 §3 修正)**:`vtkHDFWriter` 写不了 ImageData → 落盘改裸 int16 分块+zlib。有现成退路,非方案推翻,但落盘是"自写格式"的中等工程,非"补组件"。 +- **HIGH**:量化未贯穿传递函数/色阶/反量化会导致颜色与读数错(§3.5 已补设计)。 +- **HIGH**:int16 适配面被低估(`VolumeGrid`/`loadVolume` 回调/`StoredVolume`/`VolumeInfo` 均需带量化 meta),非单点重载。 +- **MEDIUM**:后台重算从主线程改工作线程,跨线程回调编组需设计;显存无可靠查询、预算按裸标量偏紧;结构化落格假设道近似等距,GPS 抖动需沿弧长重采样。 +- **LOW**:5~10GB 加载约 10s 级,UX 别承诺"秒开"。 +- 单巨体无部分加载:打开即载全量。 +- int16 路径是对核心类型的扩展,需谨慎不污染 double 主路径(用并行类型隔离)。 + +**适用** +- 当前明星路这一档(及绝大多数单路段工程)。**这是默认推荐。** + +--- + +## 6. 工作量与落地顺序 + +0. **【开工前】验证 spike(~半天)**:`vtkShortArray` 填小 `vtkImageData` → `vtkSmartVolumeMapper` 跑通 GPU ray cast + 量化域传递函数,确认 GPU 路径与颜色正确。 +1. 地基(同 A §2):`.iprb`/`.iprh` 解析 + 配准 + 接入(~中)。 +2. `ScalarVolumeI16` + `buildVoxel` int16 重载 + 哨兵透明 + **量化贯穿传递函数/LUT/反量化(§3.5)**(~中大,评审上调:适配面比单点重载大)。 +3. `GprStructuredBuilder`(X/Z 落格 + Y 向插值,可并行;GPS 抖动需沿弧长重采样)替代全 3D IDW(~中)。 +4. 显存预算守卫(留 FBO/传函余量)+ `LowResResample` 概览兜底 + 物理分辨率默认网格(~小)。 +5. 明细真实落盘(**raw int16 分块 + sidecar + zlib,不用 vtkHDFWriter**)+ 后台重算(**含跨线程回调编组**)(~中大,评审上调:自写分块格式 + 线程模型)。 +6. 渲染/切片对 int16 的验证(~小)。 + +**结论(评审:Go 条件式 / 用户定:与 C 都做)**:B 用"右尺寸 + int16 + 结构化插值"在现有架构上拿到全路段完整体验。**前置条件**:§3 落盘章节已从 VTKHDF 改为裸分块、§3.5 量化设计已补、第 0 步 spike 通过——满足后即可进入实现。B 与 C 经同一 `IVolumeRenderSource` 并存,用户按数据规模在两者间切换(能进显存走 B、超显存走 C),落盘格式两者共用(B 的裸分块是 C 分块/金字塔的基座)。 diff --git a/docs/superpowers/specs/2026-06-23-gpr-volume-C-chunk-pyramid-outofcore.md b/docs/superpowers/specs/2026-06-23-gpr-volume-C-chunk-pyramid-outofcore.md new file mode 100644 index 0000000..ecfbed9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-gpr-volume-C-chunk-pyramid-outofcore.md @@ -0,0 +1,108 @@ +# GPR 三维体 · 方案 C:分块 + 金字塔 + 核外(应对超大数据量) + +- 日期:2026-06-23 +- 范围:当单个三维体在**合理分辨率下仍超显存/内存**时(几十公里测线、多工区合并、或必须超精网格),采用业界处理 TB 级体的标准架构:**分块(bricking) + 多分辨率金字塔(LOD) + 逐块压缩 + 核外按需加载(out-of-core)**。 +- 定位(2026-06-23 用户定):**与 B 对等的两条已承诺路线之一,两者都做、用户运行时按需切换**(不是 B 的兜底/预案)。对标地震(OpenVDS/ZGY)、数字病理/显微(OME-Zarr)。B 适合能整卷进显存的体、C 适合超显存/超大范围的体,由用户按数据选择,经同一 `IVolumeRenderSource` 接口切换。 +- POC(用户定):C 的 POC **含"最小但真实的核外分页器"**,正面验证最高风险点(分页器在 VTK 上可行性、块边接缝、LOD 闪烁、热路径解压),"POC 过 ⇒ 可落地"。 +- 前置:地基(`.iprb` 解析/配准/接入) 与 int16 + 结构化建体 与方案 B 共用。 +- **⚠ 评审结论(2026-06-23,opus)+ 用户决策**:**Go——C 是已承诺路线,与 B 都做。** 架构对标业界无误。开工注意:①`vtkHDFWriter` 写不了规则体(与 B 同源 CRITICAL,§2.2 已改正为裸 HDF5/分块);②整卷核外分页器无 VTK 开箱基础(CRITICAL,月级,**POC 即用最小真实分页器正面验证**);③`vtkSmartVolumeMapper` 自带 `LowResResample` 仅作 C 内的降质兜底手段,不替代 C。落地顺序:裸分块格式 → 切片核外 → 整卷核外分页器。 + +--- + +## 1. C 的适用场景(用户在 B/C 间按需选择的依据,非"门槛") + +C 与 B 并存,用户对某个体选 C 而非 B,典型是: +- 合理分辨率(5~10cm)下单体 int16 体积 **超过本机显存**; +- 测线长一个数量级(几十 km)或多工区拼接成连续大体; +- 需要在内存/显存恒定下浏览任意大的体。 + +B 与 C 同为成品、经同一 `IVolumeRenderSource` 切换;小体走 B(整卷最省力)、大体走 C(核外不爆内存),**由用户按数据选**。 + +--- + +## 2. 架构 + +``` +建体(int16) → 分块(brick 64³/128³) → 逐块压缩 + 每块 min/max + → 多分辨率金字塔(全分辨率 / 1/2 / 1/4 / 1/8 …) + → 写入分块格式文件(离线/后台一次) +渲染 → 核外分页器:按相机视野 + LOD 选块 → 解压载入显存 → 相机移动换入换出 + 内存/显存只驻留当前所需块(数 GB),与总体积无关 +切片 → 只读切面相交的块(最便宜的子集);等值面靠每块 min/max 剔除 +交互 → 拖动用粗 LOD,停下加载全分辨率;沿拖动方向预取 +``` + +### 2.1 存储格式(分块 + 金字塔 + 压缩) +- **⚠ 不能依赖 `vtkHDFWriter`(评审 CRITICAL,与 B 同源)**:VTK 9.6 的 `vtkHDFWriter` **写不了 `vtkImageData`/规则体**(`vtkHDFWriter.h:6-9,232-235`,仅 PolyData/UnstructuredGrid/composite);且**无规则体的多分辨率 overview**(头文件唯一多级机制是 AMR 层级 `vtkHDFReader.h:203-209`,非金字塔)。所以"补 IOHdf 组件即可 VTKHDF 落盘 + overview"**不成立**——原 spec 的"待验证"实为"基本不支持"。 +- **首选(改正后)**:**裸 `vtkhdf5` C API 自写 chunked dataset**(HDF5 原生 chunking + zlib 逐块压缩 + 随机访问),金字塔层作为多个 HDF5 dataset **自行组织**;或自定义 brick 文件(裸分块 + 索引),每块 zlib + 头存 min/max + LOD 偏移表。**与 B 的落盘层统一**(B 本就要改成裸分块,正好一并设计成可分块/可多级)。 +- 不引入独立 zstd/blosc(vcpkg 未含);如压缩比不足再评估加入。 + +### 2.2 渲染端核外分页(C 的真正难点) +- **VTK 不开箱提供大体的 out-of-core GPU 体绘制(评审证实)。** 注意两个相关但不够用的开箱件: + - `vtkMultiBlockVolumeMapper` **存在但不是分页器**(`vtkMultiBlockVolumeMapper.h:14-21`:试图"同时加载所有块",仅 GPU 分配失败才退化逐块重载)——它给的是"多块同时渲染 + 抖动抗块边接缝",**没有按视野换入换出/LOD 选块/预取**。要复用它做渲染层,须在喂数据前自己完成"只放视野块"的筛选。 + - `vtkSmartVolumeMapper` 的 `LowResResample`(见 B §2.2)是"自动降质看全貌",**不是核外**——它是 C 之前的免费兜底,不是 C 的实现。 +- 整卷核外分页器须**从零自建**(选块/LOD/LRU/解压/换入换出/预取)。三条路: + 1. **切片优先(推荐先做)**:切片只需读相交的块,复用 `vtkImageReslice` 对"当前块集合"重采样。**这条最易落地**,能先拿到"超大体看切片"的能力,不碰整卷核外。 + 2. **自建 LOD + brick 分页**:在 `vtkSmartVolumeMapper` 之上,把视野内块按 LOD 作为多个 `vtkImageData`/`vtkMultiBlockDataSet` 动态加载/淘汰。整卷透明体绘制走这条,**工作量最大**。 + 3. **集成 OpenVDS**(地震库):能力最全但**重依赖**(vcpkg 未含,需自带 + 适配 VTK),适配成本高。 +- 建议:**先做 1(切片核外),整卷体绘制核外(2)列为后续**。 + +### 2.3 建体/金字塔流水线 +- 复用方案 B 的 int16 + 结构化建体产出全分辨率体 → 分块 → 逐级降采样建金字塔 → 逐块压缩落盘。 +- **离线/后台执行一次**,结果即持久化产物(C 的"保存插值后体"天然就是这个分块金字塔文件)。 + +--- + +## 3. 持久化 + +- C 的存储格式**本身就是方式二(明细缓存)**:分块 + 金字塔 + 压缩,是"算一次、之后秒开"的载体,且读取只碰视野块、内存可控。 +- 方式一(参数档 `VolumeBuildParams`+`GridSpec`)仍保留,用于复算/详情/校验。 +- 切片/异常坐标仍锚定 `GridSpec`(与 A/B 一致),保证跨 LOD 一致。 + +--- + +## 4. 评估 + +**优点** +- **不设规模上限**:任意大小(几十 GB ~ TB)皆可,内存/显存恒定在数 GB。 +- 切片只读相交块、等值面块级剔除、拖动 LOD 降级 —— 大体交互可做到流畅。 +- 与业界(地震/医学)成熟路径同构,可借鉴现成设计。 + +**缺点/风险(评审分级)** +- **CRITICAL**:`vtkHDFWriter` 写不了规则体 → 落盘须自写裸 HDF5/分块(§2.1 已改),是中大件,非"补组件"。 +- **CRITICAL**:整卷核外分页器**无 VTK 开箱基础**,从零自建(选块/LOD/LRU/预取),月级且风险集中;或集成重依赖 OpenVDS。 +- **HIGH**:VTKHDF 无规则体多分辨率 overview,金字塔须全自管。 +- **HIGH**:压缩块解压在**交互热路径**(拖动每帧换块解压),CPU 可能成瓶颈——不止 IO 放大。 +- **MEDIUM**:LOD 降采样的半像素偏移 → 异常拾取**跨层落点漂移**("GridSpec 锚定保证一致"未覆盖此情形)。 +- **MEDIUM**:块边接缝(MultiBlock 抖动可部分缓解)/ LOD 切换闪烁(需 morphing/淡入自解决)。 +- 复杂度高 → 维护成本与缺陷面大。 +- **依赖断点(评审)**:C 声称"复用 B 的落盘",而 B 那层须先从 VTKHDF 改成裸分块 HDF5——C 的"地基已就绪"前提依赖 B 先这么做。 + +**适用** +- 仅当数据真正超出方案 B 的单显卡承载。**当前明星路用不到。** + +--- + +## 5. 工作量与落地顺序(仅在需要时启动) + +1. 地基 + int16 + 结构化建体(与 B 共用,若已做则复用)。 +2. 分块格式 + 逐块压缩 + 每块 min/max(VTKHDF 或自定义)(~中大)。 +3. 多分辨率金字塔生成流水线(后台一次)(~中)。 +4. **切片核外**:按切面读相交块重采样(~中)—— 先交付这条。 +5. 整卷体绘制核外分页器 + LOD 拖动降级 + 预取(~大,后续)。 +6. 显存/内存缓存与淘汰策略(~中)。 + +--- + +## 6. 与 A/B 的关系 + +- 能力:A ⊂ B ⊂ C;成本同序递增。 +- A、B 共享渲染/切片现有架构;**C 是不同的存储+渲染架构**(分块+核外),是 B 撞到显存天花板后的演进,不是平行替代。 +- **推荐策略**:现在做 B 满足明星路与多数工程;把 C 的"分块格式 + 切片核外"作为**预案**,待出现真正超大数据再启动;整卷体绘制核外列为最后一档。 + +> **切片核外"最易落地"有隐藏前提(评审)**:它要求分块格式**已做完**才能"读相交块"。所以"先交付切片核外"≠ 可跳过分块——§5 顺序(先分块格式再切片核外)正确,别误读为切片核外是独立小工程。 + +**结论(用户定:Go,C 与 B 都做)**:C 的总体架构成立、对标 OpenVDS/ZGY/OME-Zarr 无误,是与 B 对等的已承诺成品;工程最重、渲染端整卷核外非开箱、落盘须裸 HDF5(已改)。 +- **落盘与 B 统一**:裸分块格式(不依赖 vtkHDFWriter),B 落盘本就改裸分块,C 的分块/切片核外在同一格式上增量获得。 +- **整卷核外分页器**是 C 的最高风险件(两个 CRITICAL + 热路径解压 HIGH,月级),**POC 即以"最小真实分页器"正面验证**,确保"过了能落地"。 +- **B/C 切换**:经 `IVolumeRenderSource` 运行时切换,用户按数据选;`LowResResample` 是 C 内的降质手段,不替代 C 也不替代用户选择。 diff --git a/docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md b/docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md new file mode 100644 index 0000000..8b5d7b7 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-vtk-category-view-refactor-design.md @@ -0,0 +1,202 @@ +# VTK 三维分析视图重构设计(按数据类型分组 + 对象树联动) + +> 日期 2026-06-24 · 分支 `feat/vtk-3d-view` · 状态:设计待评审 +> 配套后端契约见 [`HANDOFF-vtk-3d-backend-api.md`](../HANDOFF-vtk-3d-backend-api.md);持久化定论见记忆 `vtk-3d-persistence-structure`。 + +## 1. 背景与目标 + +现状 VTK 视图左侧 `ColumnDrawer` 是三个固定 tab(三维数据集 / 二维数据集 / 三维分析),`splitByDimension` 仅按 `ddCode` 把数据集分到 3 个维度桶。 + +新需求:把「三维数据集」并入「三维分析」,改成**按数据类型大类分组**的视图(电阻率 / 视电阻率 / 瞬变电磁 / 三维体 / 切片),每类有各自的筛选条与操作;对象树勾选与 VTK 列表联动,且支持 GS / 项目层级直挂的数据集;全局视图控制移到 VTK 画布工具条。 + +**目标**:在保持现有渲染/交互能力的前提下,完成上述信息架构与交互重构,分类与筛选数据全部走已查实的真实字段,不引入臆测。 + +## 2. 范围 + +**含**: +- 「三维数据集 + 三维分析」合并为按大类分组的「三维分析」tab。 +- 分类层由 `ddCode`(3 维)改为 `dsTypeCode`(大类)映射表驱动。 +- 对象树联动改造:非根 GS 三态复选框 + 右键 ds/tm;项目根直挂数据固定显示;按 `structParentConfType` 分流拉取。 +- 装置类型 / 采集时间筛选落地(客户端可做,不依赖后端补字段)。 +- 全局视图控制(坐标轴 / 比例 / 快捷视图 / 缩放)移到中央 VTK 画布工具条。 +- 三维体/切片生成(`createVolume/createSlice`)按契约 DTO 组装出**真实 `VoxelGenerateRequest`/`SliceGenerateRequest` 请求体结构**(仍走 mock 存储、假 id),供后端联调、并为将来切真实端点铺序列化路径。 + +**不含**: +- 「二维分析」tab(现 `Column2DDataset`)本次不改。 +- 三维体 / 切片 / 异常的持久化切真实(仍走 `Api3dRepository` mock,后端就绪后另行切换,见配套 handoff)。 +- 后端接口改动(装置类型值→中文字典源待坐实,见 §11)。 + +## 3. 实测依据(关键事实,已通过真实接口核实) + +后端基址 `http://tenant.geomative.cn/pop-api`,样本项目「演示项目(高密度+瞬变)」`1438889436225536`。 + +1. **大类必须按 `dsTypeCode` 分**,不能按 `ddCode`——电阻率 / 视电阻率 / 瞬变电磁反演剖面**三者 `ddCode` 同为 `dd_inversion_data`**,仅 `dsTypeCode`/`name` 不同: + + | 大类 | ds 行 `name` | `dsTypeCode` | + |---|---|---| + | 电阻率数据 | 电阻率数据 | `ERT platform inversion data` | + | 视电阻率数据 | 视电阻率数据 | `visual resistivity data` | + | 瞬变电磁数据 | 瞬变电磁反演剖面 | `DD TRANSIENT ELECTROMAGNETIC INVERSION` | + +2. **装置类型与采集时间是 ds 的结构化属性**:`dsObject/dynamicForm/{id}` 的 `formList` 定义字段 `arrayType`(装置类型,下拉)、`collectTime`(采集时间);ds 行 `properties` 按 confFieldId 携带其值。装置类型只 ERT 类(电阻率/视电阻率)有,瞬变电磁没有。 +3. **装置类型枚举**:`GET /business/script/arrayTypeList` → `[{itemValue,name}]`(温纳排列(α)…施伦贝谢尔…共 15 项)。 +4. **层级拉取**:`dsObject/data/page` 按 `structParentId` + `structParentConfType`(1=GS/项目,2=TM)查;客户端 `loadRowsAsync` 第 3 参即 `parentConfType`,现状写死 2。 +5. **遗留**:`arrayType` 值是 id(如 `1429468249448449`),实测**不在**该字段 `optionsObject`(另一套 id 体系)里,value→中文字典源待坐实(§11)。 + +## 4. 整体架构 + +`ColumnDrawer` 改为两 tab:「三维分析」「二维分析」。二维分析不动。 + +**「三维分析」tab = `QScrollArea` 纵向堆叠 5 个类型段:** + +``` +┌─ 三维分析 ──────────── 二维分析 ─┐ +│ ▼ 电阻率数据 [日期▾][装置类型▾]│ 段头:类型级筛选 +│ ▸ 演示项目(根·直挂ds固定·无复选框) │ +│ ▾ ☑ ERT1 (GS·三态) [右键:生成体]│ +│ ☑ ERT1-WN (数据行·勾选=渲染)│ +│ ☑ ERT1-WS │ +│ ▾ ☐ ERT2 (GS) [右键:生成体]│ +│ ▼ 视电阻率数据 [日期▾][装置类型▾]│ +│ ▼ 瞬变电磁数据 [日期▾] │ 无装置类型筛选 +│ ▼ 三维体 [日期▾] │ 体→[切片们 + 直接挂体的异常]三级树 +│ ▾ 体A │ +│ ▾ 切片S1(挂体A) │ +│ 异常a1(挂S1) │ +│ 异常a2(挂体A,临时切片上画) │ +│ ▼ 切片 [日期▾] │ 已保存切片(挂父体下,与三维体段切片同源) +└──────────────────────────────────────┘ +``` + +**全局视图控制移到中央 VTK 画布竖排工具条:** 设置(⚙→坐标轴设置对话框) / 快捷视图(前后上下左右) / 放大·缩小·复位(=现"适配")。 + +## 5. 数据模型与分类层 + +**`DsRow` 扩展**(`RepoTypes.hpp`):现解析 `id/dsName/typeName(=name)/ddCode/createTime/parentId/file*`,新增: +- `dsTypeCode` —— 大类分类主键。 +- `arrayType` —— 装置类型值(从 ds 行 `properties[]` 按 confFieldId 取)。 +- `collectTime` —— 采集时间(同上)。 + +`NavDto::parseDsRows` 补这三个字段的解析。**两接口 `properties` 形态不同(实测)**:`dsObject/data/page` 的 ds 行 `properties` 是 **`[{confFieldId,value}]` 数组**(按 confFieldId 取值);`dsObject/dynamicForm/{id}` 的 `properties` 是 **`{fieldCode:value}` map**(现 `parseDynamicForm` 用 fieldCode 取值,正确、不冲突)。DsRow 解析的是前者,故需配合 §10 的 confFieldId↔fieldCode 映射定位 arrayType / collectTime。 + +**分类配置表 `CategoryConfig`**(新文件,集中一处,开闭原则)——每段一条,带元数据: + +| 段序 | 段名 | 识别键 | 可生成三维体 | 装置类型筛选 | +|---|---|---|:--:|:--:| +| 1 | 电阻率数据 | `dsTypeCode = ERT platform inversion data` | ✓ | ✓ | +| 2 | 视电阻率数据 | `dsTypeCode = visual resistivity data` | ✓ | ✓ | +| 3 | 瞬变电磁数据 | `dsTypeCode = DD TRANSIENT ELECTROMAGNETIC INVERSION` | ✓ | ✗ | +| 4 | 三维体 | `ddCode = dd_voxel` | — | ✗ | +| 5 | 切片 | `ddCode = dd_slice` | — | ✗ | + +**`splitByCategory(rows) -> CategoryBuckets`**:替代 `splitByDimension`,按配置表把 `DsRow` 分入有序大类桶;不在表内的 dsTypeCode(接地电阻/原始数据/白化/坐标等)丢弃。 + +## 6. 对象树联动(`ObjectTreePanel` 改造) + +**节点交互模型:** + +| 节点 | 复选框 | 右键新增项 | +|---|---|---| +| 项目根 | 无(不可勾,直挂 ds 固定显示) | —(生成三维体改在段头,见 §7) | +| 非根 GS | 三态 | 选择 ▸ ds / tm(带对号) | +| TM 叶子 | 普通二态 | — | + +**GS 三态语义**(标准 tristate 聚合):GS 复选框 = `[GS 自身 ds]` + `[所有子 TM 勾选]` 的聚合;都有=Checked,都无=Unchecked,部分=PartiallyChecked。 +- 右键「选择 ▸ ds」= 切换「GS 自身 ds」开关(GS 自身 ds 在树中无独立复选框载体,故必须由此控制)。 +- 右键「选择 ▸ tm」= 一键全选/全不选所有子 TM(子 TM 仍可单独勾,此项是批量便捷)。 +- 点 GS 复选框:任一开 → 全关;全关 → 全开。 +- 菜单项按有无动态禁用(无直挂 ds 禁 ds 项,无 TM 禁 tm 项)。 +- **实现约束(必须)**:**停用现有 `Qt::ItemIsAutoTristate`**(`ObjectTreePanel.cpp:123`)——它只聚合子项 checkState、看不到「GS 自身 ds 开关」这第二维度,直接套用会产出错误聚合态。改为:用一个 `UserRole` 在 GS 节点存「自身 ds 开关」布尔,`itemChanged` 里手动按「ds 开关 ∨ 子 TM 勾选」计算父三态并 `setCheckState`(复用现有 0ms 合并防重入 pattern,避免级联多次触发)。 + +**信号扩展**:`checkedTmsChanged(QStringList)` → `checkedSourcesChanged(QList)`,每个 `DataSource = {id, confType}`: +- 勾选 TM → `{tmId, 2}`;GS 自身 ds 开关开 → `{gsId, 1}`;项目根直挂(固定)→ `{rootId, 1}`。 +- 集合为**按 `{id,confType}` 去重的并集**:一条 TM 既被 GS 聚合又被单独勾时只算一次;`confType=1`(GS/项目)拉该节点**直挂 ds**、`confType=2`(TM)拉 TM 下 ds,二者物理数据不重叠,不会重复进桶。 + +**数据流**(`main.cpp` 接线): +``` +checkedSourcesChanged + → 对每源 loadRowsAsync(projId, src.id, src.confType, classify=3, …) // 第3参按源传(1/2) + → 汇总 DsRow[] → splitByCategory → 各 CategorySection.setDatasets +``` + +## 7. 类型段组件 `CategorySection` + +一个可参数化的类型段(单一职责,高内聚),由 `CategoryConfig` 一条配置驱动: +- **段头**:标题 + 折叠开关 + 日期范围筛选 + 装置类型下拉(仅 `装置类型筛选=✓` 的段显示)。 +- **段体**:项目根 / GS / TM 树 + 数据行,复用 `DatasetListPanel::populateDatasetList`(按 parentId 建树)与卡片委托;数据行可勾选 = 渲染。 +- **渲染勾选链承接(必须)**:CategorySection 暴露 `checkedDatasetsChanged`,**接管退役的 `Column3DDataset` 原有「剖面勾选→帘面渲染」主链**——main.cpp 把 5 段(电阻率/视电阻率/瞬变=帘面,三维体/切片=体素/切片)的勾选并集后下发 `pushChecked`(沿用现有 `checkedProfiles`/`checkedAnalysis` 并集模型),否则帘面渲染整体失联。 +- **生成三维体入口**:仅 `可生成三维体=✓` 的段(电阻率/视电阻率/瞬变电磁)**段头**有「+新增三维体」按钮。**源数据集 = 三维分析中当前勾选的同类型 ds**(本段类型,天然按段隔离、可跨 GS)。点击 → `VolumeParamsDialog`(页面中心弹窗):**左侧·数据列表** 树状(按 GS 分组)展示这些已勾选源,每项可勾选/取消供**确认或二次修改**;**右侧·插值参数**:名称、**生成位置**(下拉)、插值模型、水平/竖向间距、IDW 幂次、最大影响距离。**生成位置(归属)规则**:默认 = 源同属单 GS→该 GS、源跨 GS→项目根;用户可改为**项目内任意 GS / TM**(故归属 `structParentConfType` 可为 1 或 2,与源数据解耦)。提交 → 组装 `VoxelGenerateRequest` → `createVolume`。 +- **筛选**:复用并扩展 `applyDatasetFilter`,日期比较字段由 `createTime` 改为 `collectTime`(§10)。 + +「三维分析」tab 容器(替代原 Column3DDataset/Column3DAnalysis 在 tab 中的位置):`QScrollArea` + 垂直布局,按配置表实例化 5 个 `CategorySection`。 + +## 8. 三维体 / 切片 / 异常段 + +复用现有 `Api3dRepository`(mock)与 `refreshAnalysis` 合并注入机制,仅重新组织到段: + +> 三维体归属由「生成位置」选择决定:默认单 GS→该 GS、跨 GS→项目根,用户可改为项目内任意 **GS / TM**(`structParentConfType` 可 1 或 2)。后端契约 `docs/api/vtk-3d-openapi.json` 已同步至 v0.6-draft(`structParentConfType` 放开 1/2 + 默认规则);客户端 `createVolume` 接真实端点时需补 `structParentId/structParentConfType`,并新增按归属实体 id 查异常的 `queryException/{remarkSourceId}` 调用。 + +**三维体段是「体 → 切片 / 异常」三级树**(**取消独立异常区**——异常不再单列,而是作为叶子挂在它归属的实体节点下): + +- **三维体段**:列已生成的体(客户端 mock + 后端 `dd_voxel`),按归属(项目/GS/TM)分组。(**「正在生成…」状态**:现 `createVolume` 同步登记、首次 `loadVolume` 惰性插值,本期**不引入异步生成态机**、体即时出行。)体节点下挂:① 基于该体生成的**切片**子节点;② **直接挂体的异常**(见归属规则)。多体可同时勾选渲染(`dsProps_` 按 dsId 各存 actor),切片/异常操作针对「当前激活体」`volumeOwnerDs_`(=切片源体 `currentVolumeImage_`)。 +- **异常归属(核心规则)**:异常**必基于切片**(在某切片平面上画),切片**必基于体**(`SliceSpec.volumeDsId`)。查找链 `异常 → 所在切片 → 切片所属体`。挂载目标按**该切片是否已保存成 `dd_slice`** 决定: + - 切片**已保存**(是 `dd_slice` 实体)→ 异常挂**该切片**(`remarkSourceId=切片dsId`)。 + - 切片**未保存**(临时圈定平面)→ 异常挂**切片所属体**(`remarkSourceId=体dsId=volumeOwnerDs_`)。 + - 数据模型:`Anomaly` 的 `volumeDsId` 改名为 `remarkSourceId`(= 挂载实体 dsId,体 or 切片;对齐后端 `remarkSourceId=dsObjectId`)。挂体/挂切片由 `remarkSourceId` 指向的实体类型区分(查 `isVolumeDataset`/`isSliceDataset`),展示树按 `parentId=remarkSourceId` 自动挂到对应节点——**不引入新 type 字段**。⚠️ 后端 `remarkSourceType` 已是**标注几何形态**(1点/2线/3面/4文字 = `Anomaly.markType`),勿与"挂体/挂切片"混淆。仍 mock 存储。 +- **切片段**:列已保存切片(`dd_slice`),按父体分组(`parentId` = 所属体)。与三维体段内的切片子节点同源(同一批 `sliceRows`)。 + +体素 / 切片 / 异常的渲染、生成、保存路径不变(`VtkSceneController` / `InteractionManager` / `Api3dRepository`),只改列表的承载位置。 + +**请求体组装(本次新增)**:`createVolume` 扩参为 `(projectId, structParentId, structParentConfType, params, name)`——归属来自对话框「生成位置」选择(GS/项目根/TM,默认单GS→该GS、跨GS→项目根);`createSlice` 补 `projectId`。两者内部按新增客户端 DTO `VoxelGenerateRequest`/`SliceGenerateRequest`(对齐 `docs/api/vtk-3d-openapi.json` schema)组装出**完整请求体**并提供 `toJson` 序列化;mock 路径存内存 +(debug)打印请求体,将来切真实端点只把「存」改「发」、组装逻辑原样复用。如此重构一落地即可从 UI 真实产出请求体(值为 mock、结构与字段为真)。`createSlice` 补 `projectId` 会波及 main.cpp 切片保存调用点(切片右键/列表保存路径),须一并传 `nav.currentProjectId()`。 + +## 9. VTK 画布工具条 `VtkViewToolbar` + +从 `Column3DDataset` 抽出全局视图控制,做成中央 VTK 画布上的竖排工具条(新组件): +- **设置(⚙)** → 弹「坐标轴设置」对话框 `AxesSettingsDialog`:X 轴 / Y 轴 / 深度(m) 各带「显示」开关 + 最小值 / 最大值,取消 / 应用。 +- **快捷视图**:前、后、上、下、左、右(按钮文字即此六字,沿用现有 `ViewDir` 与功能)。 +- **缩放**:放大 / 缩小 / 复位(复位 = 现「适配」)。 + +信号沿用现有 `Column3DDataset` 已接的控制器槽(`axesModeChanged`/`verticalExaggerationChanged`/`viewRequested`/`zoom*`/`fitRequested` 等),仅迁移承载控件。水平/垂直比例的承载位置随工具条一并迁移(或并入坐标轴设置对话框,实现时取最贴合者);`setVerticalExaggeration` 默认值回灌(main.cpp:902)也要迁到新工具条,避免默认夸张值不同步。 + +## 10. 装置类型 / 采集时间筛选落地 + +**字段映射服务 `DatasetFieldDictionary`**(新组件,按 dsType 缓存)——**为何必要**:ds 列表行 `properties` 只给 `[{confFieldId,value}]`(数字 confFieldId、无字段名),要判定哪个 confFieldId 是 arrayType/collectTime,必须从 `dynamicForm` 的 `formList`(含 confFieldId↔fieldCode 对应)取映射。 +- 对每个 dsType 拉一次 `dsObject/dynamicForm`,缓存:① `arrayType` / `collectTime` 字段的 `confFieldId`(用于从 ds 行 `properties` 取值);② 装置类型选项字典(value→中文)。 +- 列表行装置类型 = ds 行 `properties` 中 `confFieldId == arrayType 的 confFieldId` 那项的 value,经字典翻中文展示与分组。 +- 段头装置类型下拉 = 该段当前数据出现过的装置类型集合;选中按值过滤。 + +**采集时间**:日期筛选按 `collectTime`;三维体 / 切片段无此字段 → 回退 `createTime`。 + +## 11. 待坐实 / 风险 + +- **装置类型 value→中文字典源**:实测 `arrayType` 值不在该字段 `optionsObject` 里(另一套 id),`parseDynamicForm` 也不翻译;但客户端属性面板据称显中文。需在实现前坐实正确字典源(候选:`fieldConfigJsonObject.fieldDataRadius` 指向的全局字典 / `script/arrayTypeList`)——**请提供客户端数据集属性面板截图**以最快定位现有翻译路径。不阻塞其余设计。 +- **三维体 / 切片 / 异常仍 mock**:后端就绪后切真实(接口 `I3dSceneRepository` 留缝),见配套 handoff。 +- **跨 GS 生成体**:本次以「单容器(项目根/GS)范围」为主;跨多 GS 拼大体暂不做。 + +## 12. 组件 / 文件边界 + +| 类别 | 组件 | 说明 | +|---|---|---| +| 新增 | `CategoryConfig` | dsTypeCode/ddCode→大类映射表 + 段元数据 | +| 新增 | `splitByCategory` | 替代 `splitByDimension` | +| 新增 | `CategorySection` | 单个类型段(段头筛选/操作 + 段体树) | +| 新增 | 三维分析 tab 容器 | QScrollArea 堆叠 5 段 | +| 新增 | `VtkViewToolbar` + `AxesSettingsDialog` | VTK 画布工具条 + 坐标轴设置 | +| 新增 | `DatasetFieldDictionary` | 按 dsType 缓存 arrayType/collectTime 映射 + 装置字典 | +| 改造 | `ObjectTreePanel` | GS 三态 + 右键 ds/tm + 信号扩展为带 confType 源集合 | +| 改造 | `DsRow` + `NavDto::parseDsRows` | 补 dsTypeCode/arrayType/collectTime | +| 改造 | `ColumnDrawer` | 三 tab → 两 tab | +| 改造 | `main.cpp` | 数据流按 confType 分流拉取 + 段接线 + 生成入口传容器归属 | +| 新增 | `VoxelGenerateRequest`/`SliceGenerateRequest` DTO + `toJson` | 对齐 openapi schema 的客户端请求体结构 + 序列化 | +| 改造 | `Api3dRepository::createVolume/createSlice` | 扩参带归属/projectId,内部组装请求体 DTO(mock 存 / 将来发) | +| 复用 | `DatasetListPanel` | populateDatasetList / 卡片委托 / applyDatasetFilter | +| 复用 | `Api3dRepository` / `refreshAnalysis` | 三维体/切片/异常 mock 与合并注入 | +| 复用 | `Column2DDataset` | 二维分析 tab,不动 | +| 退役 | `Column3DDataset` / `Column3DAnalysis` | 功能拆分到 CategorySection / VtkViewToolbar / 三维体段 | + +## 13. 非目标 + +- 不改二维分析。 +- 不改后端接口、不新增后端字段(装置类型走客户端已有数据)。 +- 不切换三维体/切片/异常持久化为真实后端。 +- 不做跨 GS 拼体、不做装置类型以外的新筛选维度。 diff --git a/docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md b/docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md new file mode 100644 index 0000000..c41f51e --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-2d-analysis-topdown-elevation.md @@ -0,0 +1,101 @@ +# 二维分析:锁定俯视相机 + 内容显隐 + 高程拖动 — Spec(2026-06-26) + +## 0. 一句话目标 +把「二维分析」从"另一套平面地图"改为**同一个 3D 地形场景的一个锁定近俯视视角**:切 tab 只切相机+翻另一方数据集的可见标志(不清空),二维内容(轨迹/栅格)落在带高程的地形上,且可选中后沿高程上下拖动分离。 + +--- + +## 1. 背景与现状 +- 三维分析栏(`CategoryAnalysisTab`)与二维分析栏(`Column2DDataset` / `col2D`)**共用同一个 `VtkSceneView` / `InteractionManager` / `renderWindow`**。现状:两栏勾选的 actor 叠在同一场景,切 tab 不切相机、不区分内容。 +- 二维内容现状:`col2D` 勾选 → `loadMapLine`(`dd/ert/trajectory/line`)→ `MapLineActor`(lat/lon 折线);有 `view2DModeChanged` 信号已接到 `sceneCtrl`(2D 视图模式钩子,本 spec 在其上扩展)。 +- 地形 + 底图:场景已有地形(`buildTerrain`,带高程)+ 天地图底图(`TileBasemap`,按经纬贴)。 +- 维度分类:`DatasetDimension::dimOf` + `Api3dRepository/LocalSample3dRepository::dimensionOf`,已对齐数据字典 DD0623(2D = `dd_trajectory_data`,commit c1a824e)。 + +**用户确认的认知**:二维分析"只是 3D 的固定视角",底图不是平面图、是**带高程的地形图**;内容沿用不清空。 + +--- + +## 2. 核心设计:一个场景 + 两种相机 +- **不分两个场景**,只有一个 3D 地形场景(地形 + 底图 + 全部已勾选数据),两栏区别仅在**相机**与**哪类数据集可见**。 + +| | 三维分析 | 二维分析 | +|---|---|---| +| 相机 | 自由透视(可旋转/倾斜/平移/缩放) | **锁定近俯视**(不可旋转,仅平移+缩放)| +| 可见数据集 | 3D 数据集(体/切片/剖面…)可见;2D 数据集隐藏 | 2D 数据集(轨迹/栅格)可见;3D 数据集隐藏 | +| 地形 + 底图 | 常驻可见 | 常驻可见(同一地形,俯视看即"地形图")| + +--- + +## 3. 二维分析的相机:锁定近俯视 +- 切到二维分析 → 相机切到**近俯视固定角(约 75–80°,非绝对正俯视)**:理由——绝对正俯视在正交/小透视下高程变化不可见,留一点倾斜以便看出高低(§5 拖动反馈需要)。 +- 锁定:**禁用旋转**(interactor style 不响应旋转/倾斜),仅保留**平移 + 缩放**。 +- 切回三维分析 → 恢复自由透视相机(恢复切走前的视角或合理默认)。 +- 实现锚点:扩展 `view2DModeChanged` 钩子 → `VtkSceneController` 切相机模式 + 切 interactor style(或在 style 内按模式禁旋转)。 + +--- + +## 4. 内容显隐:切 tab 翻可见标志(**不清空**) +- 切 tab 时,对"另一方"的数据集 actor 用 `SetVisibility(false)`,切回 `SetVisibility(true)`。**不移除 actor、不重建**。 +- 性能:VTK 渲染跳过不可见 actor → 隐藏内容**不参与绘制、不耗 FPS**;切换瞬时(无重插值/重传 GPU);唯一代价是内存/显存驻留(数据本已加载,无新增加载)。 +- **禁止用清空**:重体素(GPR/ERT)每次切回要重插值+重传 GPU,必卡。 +- 地形 + 底图两边都不隐藏。 +- 实现锚点:`VtkSceneView` 按数据集维度(`dimensionOf`/记录每 actor 的维度)批量翻可见标志;切 tab 时调用。 + +--- + +## 5. 高程拖动(C1):选中 2D 内容沿 Z 上下移 +- 二维分析里,**拾取选中已渲染的 2D 内容**(轨迹/栅格),支持**单选 / 多选**。 +- 选中后**竖向拖动 → 仅改其高程 Z(离地高度)**,**锁死 X/Y**(不动地理位置)。用于把叠在一起的 2D 层分离、看清。 +- 拖动时**实时显示当前高程数值**(屏幕浮层读数)。 +- 近俯视固定角(§3)使高低可见。 +- 实现锚点:新增/复用一个 2D 拾取-拖动交互(类似切片 widget 但只允许 Z 平移 + 多选);actor 的 Z 偏移持久(切走再回保留)。 +- 待定:高程是否需要随数据保存(暂定仅会话内 actor 变换,不落库;接真实端点再议)。 + +--- + +## 6. dd_raster:二维栅格过滤 + 渲染(本期新增) +- 数据字典 DD0623:`dd_raster`(栅格/遥感影像,**本次新增**,展示模式 2D,形态=栅格)。 +- 纳入 2D 过滤:`dimOf`/`dimensionOf` 增 `dd_raster` → `Dim2D`;但 col2D 勾选渲染须**按 ddCode 分派**——轨迹走 `loadMapLine`,栅格走**栅格加载**(不可让栅格走轨迹端点)。 +- 渲染:取栅格的**地理范围(四至/仿射)+ 像素**,作为**地理配准的纹理平面贴到地形上**(带高程,可被 §5 高程拖动),类似底图瓦片按经纬度定位。 +- 依赖:dd_raster 的**数据端点**(返回像素 + 四至/投影)——**待确认**,未明确前 §6 不落地(先做 §3–§5)。 + +--- + +## 7. 维度过滤口径(对齐数据字典 DD0623,已部分落地) +- 2D(足迹/栅格):`dd_trajectory_data`(统一通用轨迹,"保留",已并入 dd_radar_rtk_trajectory)+ `dd_raster`(本期新增,随 §6)。 +- 已删除、不再单列:`dd_radar_rtk_trajectory` / `dd_transient_electromagnetic_trajectory_data` / `dd_radar_channel_trajectory`(字典均"删除")。已清理:commit c1a824e。 +- `dd_radar_2d` / `dd_radar_3d`:字典为 `展示模式=3D`(通道剖面 / 三维插值模型)→ **属三维分析,不进 2D 过滤**。 + +--- + +## 8. 与雷达客户反馈的边界(本 spec **不含**) +- 雷达 TM 模型(单/双/多频,每频一个 `dd_radar_2d`/`dd_radar_3d`,共用一个 `dd_trajectory_data` 轨迹)→ 数据模型,与本 spec 无冲突。 +- 雷达**数据在 3D 视图的渲染**(二维雷达=线/curtain、三维雷达=切面,按 trace 坐标,带打标 hover tip)→ **三维分析的另立任务**。 +- 详情页用 trace 坐标校准异常 + 剖面打标 → 详情页另立任务。 +- **2D 视图只显示轨迹线、打标暂不在 2D 展示**(客户 #6 修正)→ 与本 spec 的"2D 显示轨迹足迹"一致,无新增 2D 工作。 +- 雷达轨迹就是 `dd_trajectory_data`,本 spec 的 2D 分析按统一轨迹处理,无需特判。 + +--- + +## 9. 实现分期 +- **A. 一场景两相机**:切 tab → 锁定近俯视/恢复自由相机 + 翻另一方数据集可见标志(§3、§4)。基础,先做。 +- **B. 高程拖动**:2D 拾取单/多选 + 仅 Z 拖动 + 锁 XY + 实时读数(§5)。 +- **C. dd_raster**:过滤纳入 + 按 ddCode 分派渲染 + 栅格地理配准贴地形(§6)。依赖栅格数据端点确认。 + +--- + +## 10. 风险 / 待定 +- 近俯视角度(75–80°)需实机调;用户若坚持绝对正俯视,则 §5 高程反馈改为纯数值(不直观)。 +- §4 翻可见标志需可靠区分每个 actor 的维度归属(按数据集 ddCode 记录维度 → actor)。 +- §5 高程是否持久化/落库待定(暂会话内)。 +- §6 dd_raster 数据端点(像素 + 四至/投影)未确认 → C 期阻塞点。 +- 切相机/可见标志切换需与现有 `view2DModeChanged`、底图、地形 VE(垂直夸张)逻辑兼容,勿互相打架。 + +--- + +## 11. 验收 +- 切到二维分析:相机变近俯视、**不能旋转**,只能平移+缩放;3D 数据集(体/切片/剖面)不可见,轨迹+地形+底图可见。 +- 切回三维分析:恢复自由相机;3D 数据集重新可见,2D 轨迹隐藏。切换**瞬时无卡顿**(无重建)。 +- 二维分析里选中一条/多条轨迹,竖向拖动→只改高程、地理位置不动、实时显示高程;叠在一起的层能被拉开。 +- (C 期)勾选 dd_raster → 栅格按地理范围贴在地形上、可被高程拖动;轨迹与栅格各走各的加载路径、互不串。 +- 维度过滤与数据字典 DD0623 一致(2D=trajectory_data+raster;radar_2d/3d 归 3D)。 diff --git a/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md b/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md new file mode 100644 index 0000000..9e2bed9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md @@ -0,0 +1,210 @@ +# GPR 多通道三维体渲染性能问题 — 分析文档(供外部专家评审) + +> 自包含技术文档。读者无需了解本代码库内部,只需具备 GPU 体绘制 / VTK 基础。 +> 目的:把"探地雷达(GPR)多通道阵列数据渲成可交互三维体"遇到的性能问题、已试方案、实测数据、 +> 待定关键点完整呈现,供外部专家判断方向。 + +--- + +## 1. 背景与系统 + +- 桌面端 C++ 应用(Qt6 + **VTK 9.6.2**),渲染探地雷达(GPR)采集的地下三维数据,要求**可交互**(旋转/缩放)。 +- 渲染用 VTK 的 `vtkGPUVolumeRayCastMapper`(OpenGL GPU 光线投射体绘制)。 +- 当前测试机 GPU:32 个着色器纹理单元(典型独显/中端)。 + +## 2. 数据特征(关键,决定一切) + +多通道阵列 GPR,一次采集一条"测线(line)": +- **道(trace)**:一个位置一根天线的垂直回波,深度方向 **821 采样**。 +- **通道(channel)**:阵列横向并排的多对天线,本数据 **14 通道**;**相邻通道横向间距 ≈ 10.5cm**(来自 `.ord` 文件真实偏移 -0.686…+0.686m,跨度 1.37m)。 +- **沿测线道间距 ≈ 4.9cm**(比横向通道间距细 ~2 倍)。 +- 一条测线:沿路 **~45305 道**,覆盖 **~2.2km**(一条南北向道路)。 +- **共 20 条测线 = 同一条路来回扫 20 趟**(车载,每趟阵列覆盖约 1.4m 宽,多趟铺横向)。 + +**单条线 = 一个三维体**:X=沿测线(~45305)、Y=通道(14)、Z=深度(821)。 +**关键业务约束(来自现场专家)**: +- 通道太稀(10.5cm)→ 需**线内通道间插值**加密(相邻真实天线之间插,物理成立); +- **绝不做"测线之间"的插值**(车与车之间是真实物理空隙,插出来"信号全是假的",工程上不可接受); +- 多条测线"分开各自插值,渲染可以合到一起"。 + +GPR 数据的统计特征(实测,见 §6):**~91% 体素近零(反射层之间是空的),但反射层横贯整个深度分布**(不是集中一坨)。 + +## 3. 已建成并验证可用的功能(不是问题所在) + +1. **线内通道插值**:读 `.ord` 真实横向偏移,规则化到 2.5cm 网格、相邻通道线性插值(不跨线)。 + 实测 Y 由 14 加密到 **56**。有单元测试。 +2. **多体单遍合成**:20 条独立体(各自插值)作为一个 `vtkGPUVolumeRayCastMapper` 的多个端口注册进 + `vtkMultiVolume`,**单遍 ray-cast 合成**(而非每条体一遍)。已验证。 +3. **纹理单元上限自动退避**:单个 multi-volume 同时挂的体数受 GPU 每着色器纹理单元上限制约 + (每体约吃 4 个单元 → 32 单元机上**一个包最多 7 体**,第 8 体报错并丢体)。已实现"渲一帧→报错则 + 每包减 1 重建重渲"的自动退避(强制 K=12 → 自动退避到 7,无丢体)。 +4. **运行时换贴图边界**(确定性测试结论):给某端口**就地换贴图**——若**保持包围盒不变**(同范围、 + 只改 Y 密度)则 multi-volume 算得对;若**改包围盒**(任意子区域、origin/范围/spacing 变)则破坏 + 其缓存 `TexToBBox` → 体断开/消失。 +5. 通道维 LOD、统一传函、色标图例等。 + +## 4. 核心问题:性能 + +- **20 条密体(Y=56)总览,交互极卡**:静止 ~1.7 fps,旋转/缩放掉到 < 1 fps(GPU 100%)。 +- 渲染**视觉正确**(雷达剖面纹理清晰、横向连续、合成无误),纯属性能。 +- 现有提速手段都是**"交互时降质"**方向(降屏幕分辨率、加粗采样步长)——**损可见质量**,治标。 + 用户明确要求:"有没有不损可见质量的根本性提速(业界最佳实践)?" + +## 5. 已分析/已试的所有方案(含理由与状态) + +| # | 方案 | 理由 | 状态 / 结论 | +|---|---|---|---| +| A | multi-volume 单遍合成 | 把"N 体=N 遍 ray-cast"降到分包遍数 | ✅ 已实现。但单遍内每步仍要在重叠体里逐个采样 | +| B | 纹理单元自动退避分包 | 绕开 GPU 纹理单元上限、不丢体 | ✅ 已实现(K=7/包,20 条=3 包)。代价:**跨包重叠合成不正确(接缝)** | +| C | 交互降屏幕采样(ImageSampleDistance) | VTK AutoAdjust 标准手段 | ✅ 已做。**损质量**;且 AutoAdjust 只降屏幕、不降沿光线步长 | +| D | 交互手动加粗沿光线步长(SampleDistance) | 通道插值后 Y 密→自动步长极细→巨卡;这才是大头 | ✅ 已实现(`--sampleDist`/`--dragSampleMul`)。**损质量;且用户尚未实测**(见 §7 待定) | +| E | 通道维 LOD(远疏近密换 Y 平面子集)| 保包围盒换贴图(#4 验证安全) | ✅ 已实现。但**只减纹理内存、不减每步重叠体采样次数 → 对此瓶颈几乎无效** | +| F | **装箱单体(binning)**:各线先逐线插值,再把**真实道**摆进一个总览网格体(空隙透明、不跨线插值)| 一个体一遍、每步采 1 次 → ~20×;真实数据无假信号 | ⚠️ 技术可行、合规,但**用户否决**:装箱合并后**总览里分不出各线**,而用户要"一起渲染时仍能逐线区分/查看"→ 合并即失去意义 | +| G | **空体素跳过 ESS(换 OSPRay/ANARI 后端)**:跳过透明背景块 | 业界对稀疏体的头号提速、不损质量 | ❌ **实测对本数据收益有限**(见 §6):保质量阈值下仅 ~2×,且**ESS 跳的是空区、不解决"重叠"**。VTK 库存 mapper 无自动 ESS;OSPRay 本环境未编、vcpkg 无包 | +| H | 减少同屏体数(只渲选中 ≤7 条)| 真实工作流本就是选几条,1 包 1 遍 | ✅ 免费、永远有效(使用方式,非技术) | + +## 6. 关键实测数据 + +### 6.1 ESS(空体素跳过)潜力——零依赖实测,决定要不要上 OSPRay +对一条真实测线密体(5702×56×789),按块算 min/max,统计"整块落在近零透明带"的占比(=ESS 可跳块): + +| 透明带半宽(相对半值域) | 8³ 块可跳占比 | 理论提速上限 1/(1−占比) | 说明 | +|---|---|---|---| +| 5% | 8% | 1.1× | 极保守 | +| **10%(保质量,不丢弱反射)** | **52%** | **~2.1×** | — | +| 20% | 80% | ~4.9× | 开始把弱反射当背景丢 | +| 30%(激进)| 90% | ~10× | 明显损质量 | + +- 体素层面 **91% 近零**,但块层面(ESS 实际粒度)保质量阈值下**只能跳 ~52% → 理论 ~2×**。 +- 原因:**反射层横贯整个深度分布**,多数块里总混着信号、跳不掉。要 10× 须用激进阈值(损质量)。 +- **更关键**:ESS 跳"空区",**不减少"重叠"**——在有信号的块里仍要逐个采样所有重叠体。 + +### 6.2 其它实测 +- multi-volume 纹理单元上限:本机 7 体/包(32 单元),第 8 体报 "Hardware does not support the number of textures"。 +- 体维度示例:coarse 4 → ~11000×56×793/线;coarse 32 → ~1400×56×780/线。 +- 全 20 条 dense(coarse 32):底图 level 0、Y=56、分 3 包,**渲染正确**;交互 ~1.7fps(**未加 D 方案的旧构建**)。 + +## 7. 关键点——已实测,结论修正(原假设两条都被推翻) + +### 7.1 "重叠几层"——实测:**平均 ~8.7 层、最大 15 层(不是 ~2–3,也不是 20)** +纯几何测(各线世界 AABB 投到 X-Y 俯视 footprint、细网格统计每格覆盖层数,`--overlapStat`): +- footprint:横向 X≈37m、沿路 Y≈2.2km; +- **有体覆盖处平均重叠 8.74 层,最大 15 层**;穿 12–14 个体的格子占 ~42%。 +- **结论修正**:原"~2–3 层"假设**错**(开发团队和外部专家都猜偏了);**重叠是真实的大瓶颈**。 +- **且这 9 层是冗余**:20 趟是同一条路反复扫,同一地下点被测了 ~9 次 → 这 9 个重叠体在该处**都非空** + (同一地下结构)→ **ESS 在重叠区一个都跳不掉**(再次印证 ESS 不解决重叠)。 + +### 7.2 "采样 vs 重叠谁是大头"——实测:**采样瓶颈,fps 线性正比于步长** +20 条密体、静止近景、离屏(步长越大越快越糙): + +| sampleDist | fps | 相对 | +|---|---|---| +| 0.2(≈自动细)| 1.3 | 1× | +| 0.5 | 3.2 | 2.5× | +| 1.0 | 5.9 | 4.5× | +| 2.0 | 11.3 | 8.7× | +| 4.0 | 20.9 | 16× | + +- **步长翻倍→fps 翻倍 → GPU 是采样瓶颈**。总开销 ≈ 光线数 × (光线长/步长) × ~9 个重叠体。 +- D 方案(手动步长)**确实直接、强力提速**;但**保质量的步长(≈ Nyquist,0.5×体素)下仍只 ~2 fps** + ——因为 **9× 冗余重叠**把它乘了回去。要到交互级(10+fps) 得把步长粗到 ~2.0(欠采样、损 Z 薄层)。 + +### 7.3 合并诊断(两测合起来) +**慢 = 采样密度 × ~9 倍冗余重叠,两者都真实。** +- D 方案(粗化采样):提速强,但保质量步长下被 9× 重叠压回 ~2fps;要交互须损质量。 +- **唯一"保质量又快"的,是去掉那 9× 冗余重叠**(同路重扫的同一地下点):合并/装箱(取真实道、不跨线 + 插值)→ 一个体一遍 → ~9× 提速、且 Nyquist 步长下也能交互、**零质量损失**(冗余测量本就该合并降噪)。 +- **但这与用户"保持 20 条可区分"直接冲突**——而这 9 层在物理上是**冗余测量**(同一地下结构扫了 9 遍), + 保持它们"可区分"的工程价值存疑。 + +### 7.4 CPU OSPRay vs GPU(仍未测) +ESS 对本数据 ~2× 且不解决 9× 重叠;OSPRay 主要 CPU、对手是 GPU 数千核。**很可能换 OSPRay 比现状还慢**, +且为 ~2× 重编整个 VTK 投入产出极差。不建议在去掉冗余重叠之前考虑。 + +### 7.5 "多线为何卡"的根因确诊(passcost,决定架构)——结论:**不是固定开销,是没用 LOD** +> 背景:最初 P11/P12 是"各线独立 mapper + 视野 LOD",实测仍 0.5fps。需确诊卡在三个嫌疑哪个: +> ①LOD 选区没削小 ②N 遍固定开销 ③重叠没摊掉。`passcost` 命令:N 个独立 GPU mapper 各渲一个 64³ 小体 +> (模拟 LOD 削过的小区),分"铺开/不重叠"与"叠在一起/重叠",测离屏稳态 fps vs N。 + +| N | 铺开(不重叠) fps | 叠加(重叠) fps | +|---|---|---| +| 1 | 177 | 204 | +| 5 | 162 | 43 | +| 10 | 144 | 22 | +| **20** | **78** | **11** | + +**判读(决定性):** +- **嫌疑 ②(N 遍固定开销)排除**:20 个独立 mapper 铺开仍 **78fps**(177→78,远非线性)。 + → **各线独立 mapper 架构上完全可行,固定开销温和。"multi-volume 单遍 ⊥ 视野 LOD"这个不可兼得不致命**—— + 放弃单遍、回独立 mapper 并不慢。 +- **嫌疑 ③(重叠)真实但小体下可控**:叠加随 N ~1/N(每条光线乘 N),但 **20 层 64³ 叠加仍 11fps**(可用), + 再叠屏幕降采样更快。 +- **嫌疑 ①(选区没削小)= 真凶**:passcost 小体 20 层叠加=11fps,而真实 view-all 只 1.7fps——差距全在 + **贴图大小**:当前渲的是**整卷底图**(~11000×56×200 ≈ 上亿体素/条),**根本没用视野 LOD 把它削成小区**。 + 这是**最好结局:可修,不动地基,只需真正用上 LOD**。 + +**对架构的直接含义**:本会话引入的 **multi-volume 单遍是错误取舍**——为"单遍"关掉了 LOD、改固定整卷贴图, +导致大贴图 × 9 层重叠 = 1.7fps。而 passcost 证明独立 mapper 够快,**根本不必为单遍牺牲 LOD**。 + +## 8. 部署约束(硬件不确定,跨厂商) + +客户机配置未知(可能无独显,或 N卡/A卡/Intel)。**没有任何单一渲染器能在 N 卡和 A 卡上都做"GPU+ESS"**—— +GPU 体光追渲染器全厂商锁定(N 卡→NVIDIA VisRTX/OptiX/IndeX;Intel→OSPRay-GPU;A 卡→基本无成熟方案)。 +跨厂商唯一通用的是 **OSPRay-CPU(免显卡、任意 x86)** 或 **OpenGL(任意 GPU、但无 ESS=现状)**。 +若上多后端,需"OSPRay-CPU 保底 + 探测到 N卡/Intel独显时升对应 GPU 后端 + OpenGL 终极兜底"。 + +## 9. 关键问题——大多已被实测回答 + +1. ~~有没有不损质量的根本性提速法~~ **有,且是通用解:LOD(视野自适应多分辨率)**——让 GPU 单帧实际 + ray-cast 的体素量**与数据总量解耦、只与"屏幕能看清的量"挂钩**(Task 12c 单体已验证 752/380fps)。 + §7.5 passcost 证明它**对多线也成立**(独立 mapper 开销温和,20 条铺开 78fps)。 +2. ~~"卡"的主因~~ **已确诊(§7.5):不是 N 遍固定开销(排除),是【当前根本没用 LOD、渲整卷大贴图】(嫌疑①)。** + 本会话引入的 multi-volume 单遍为"单遍"关掉了 LOD → 大贴图 × 9 层重叠 → 1.7fps。 +3. **9 层重叠的正确定位**(外部专家纠偏 + passcost 印证):它只是**这批数据(同路重扫 20 趟)的特例倍数**, + **不是渲染本质问题**。本质是"单帧采样量 > GPU 吞吐",通用解是 LOD(扛任意大数据:无重叠但更大也能扛)。 + 9 层重叠在 LOD 之上降级为"一个被摊薄的常数因子"(passcost:20 层小体叠加仍 11fps)。 + **不要让渲染架构围绕这个特例设计。** +4. ESS/OSPRay/多后端:**继续埋掉**——ESS 对本数据 ~2× 且不解决重叠、CPU 对手是 GPU,且**它解决的是 LOD + 已经解决的通用问题**,投入产出差。 + +## 10. 最终结论(passcost 确诊后,架构清晰) +- **渲染架构 = LOD 中心(视野自适应、单帧量与总量解耦)。** 这是扛"任意大数据"的通用根本解, + Task 12c 单体已验证、§7.5 passcost 多线也成立。 +- **本会话的 multi-volume 单遍是错误取舍**:为"单遍合成"牺牲了 LOD、改固定整卷大贴图,正是当前 1.7fps 的 + 直接原因。passcost 证明独立 mapper 开销温和(20 条 78fps)→ **根本不必为单遍弃 LOD**。 +- **正解 = 各线独立 mapper + 视野 LOD(逐线用 Task 12c 引擎)+ 停手才重建**(不每帧重建,避免 P11/P12 + 那种"20 条每帧重建上传"的 thrash——那才是 0.5fps 的另一半原因,与稳态 ray-cast 无关,已被 P13 思路解决)。 + 让每条线只渲视野内小区 → 即使 9 层叠加也可用。 +- **9 层重叠 = LOD 之上的可选应用层优化**(对同路重扫冗余可"合并/降噪",顺带省 9×),**不进渲染地基**。 + 用户要逐条区分就不合并(靠 LOD 摊薄),要纯总览就合并。 +- **采样步长(D 方案)= LOD 框架内的质量旋钮**,非独立根本解。 +- **ESS/OSPRay/多后端:不做**(不解决 LOD 已解决的通用问题,对本数据收益差)。 + +→ **下一步(确诊已完成,可开工)**:把多线总览从"multi-volume 单遍固定整卷"改回"各线独立 mapper + + 视野 LOD + 停手重建",让单帧渲染量随视野走、与 20 条总量解耦;实测多线总览是否达交互级。 + 这是顺着通用 LOD 框架、被 passcost 数据支撑的明确方向——不再围着 9 层重叠这个特例转。 + +--- + +### 附:相关已落地代码 / 诊断工具(如专家要复现) +- 通道插值:`src/io/gpr/GprGeometry.cpp::planChannelInterpolation` + `Gpr3dvVolumeBridge.cpp` +- 多体合成/退避/质量控制/通道 LOD:`tools/gpr_poc/main.cpp::cmdViewAll` +- **诊断命令**(`tools/gpr_poc/main.cpp`,可直接跑复现 §6/§7 的数): + - `gpr_poc ess-stat `:ESS 空块潜力(§6.1) + - `gpr_poc view-all --overlapStat`:实测重叠层数(§7.1) + - `gpr_poc view-all --sampleDist D`:步长↔fps(§7.2) + - `gpr_poc passcost --size 64 --overlap 0|1`:N 遍开销 vs 重叠 隔离测(§7.5) +- 数据/插值口径 spec:`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md` +- 多后端 ESS 架构 spec(**结论:不做,见本文 §10**):`docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md` + +--- + +## 摘要(一页结论,供决策) +1. **现象**:20 条通道插值密体总览,~1.7fps、交互更卡。视觉正确,纯性能。 +2. **确诊**(passcost 隔离测):**不是 N 遍固定开销**(20 独立 mapper 铺开 78fps,排除); + 是**当前根本没用视野 LOD、在渲整卷大贴图**(× 9 层重叠)。本会话的 multi-volume 单遍为"单遍" + 牺牲了 LOD,是直接原因。 +3. **通用根本解 = LOD**(单帧渲染量与数据总量解耦),扛任意大数据;Task 12c 单体 752fps、passcost 多线 + 也成立。9 层重叠只是**本批数据的特例倍数**,是 LOD 之上一个可摊薄/可选合并的因子,**不是架构核心**。 +4. **正解**:各线独立 mapper + 视野 LOD + 停手才重建(弃 multi-volume 单遍)。 +5. **明确否定**:ESS/OSPRay/多后端(对本数据 ~2×、不解决重叠、解决的是 LOD 已解决的通用问题)。 diff --git a/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md b/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md new file mode 100644 index 0000000..fb7a6d0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-26-gpr-multibackend-ess-rendering.md @@ -0,0 +1,132 @@ +# ⚠️ 本 spec 已被实测推翻,勿照此实现 + +> **结论(见 `2026-06-26-gpr-3d-render-perf-ANALYSIS-for-review.md` §10):ESS/OSPRay/多后端【不做】。** +> 实测:ESS 对本数据 ~2× 且不解决重叠;passcost 确诊"多线卡"的真因是【没用视野 LOD、渲整卷大贴图】, +> 不是固定开销。**正解 = LOD 中心(各线独立 mapper + 视野 LOD + 停手重建),见下方实现计划。** +> 本文余下"多后端/ESS"内容仅作历史记录。 + +--- + +## 实现计划(LOD 中心多线总览 — 已确诊、可执行) + +**目标**:把 `cmdViewAll` 从"multi-volume 单遍 + 固定整卷大贴图"改为"各线独立 mapper + 视野 LOD + +停手才重建",使单帧渲染量随视野走、与 20 条总量解耦。 + +**改动步骤(`tools/gpr_poc/main.cpp::cmdViewAll`)**: +1. `PlacedSource` 加回**每线自己的 `vtkSmartVolumeMapper`**(GPU 模式);删 multi-volume 用法 + (multiMapper/multiVol/port 不再需要,但可暂留不碍)。 +2. **装配**:删 `buildBundles` + 退避(无 multi-volume 即无纹理单元上限);改为逐线 + `mapper->SetInputData(baseImage)` → `volume->SetMapper(mapper)` → `ren->AddVolume(volume)`, + 各线 mapper 收进 `mappers` 向量(供质量控制)。 +3. **开 LOD**:`gViewAllBaseOnly = false`(启用引擎选区换图);引擎换的是"改包围盒的子区域", + 各线独立 mapper 下**安全**(无 multi-volume 可破坏)。 +4. **关 channel LOD**:`gChanLod = false`——引擎金字塔已逐级降 Y,无需单独抽 Y 平面。 +5. `viewAllPickOneLine`:`ps.mapper->SetInputData(ps.currentImg); ps.mapper->Update();`(非 multiMapper 端口)。 +6. **停手才重建(已有,确认接线)**:拖动中(`viewAllOnInteracting`)只降质+重置裁剪、**不提交引擎目标**; + 松手(`viewAllOnInteract`)/定时器 idle 才 `viewAllSubmitTargets`(提交 LOD 目标)+ `viewAllPickOneLine` + (拉就绪区域换上)。避免 P11/P12"每帧 20 条重建上传"thrash。 +7. **质量旋钮保留**:`--sampleDist`/`--maxImgSample`/`--dragSampleMul` 作 LOD 框架内的交互降质兜底。 +8. **总览级别**:引擎 `selectLod` 按屏幕像素选层——拉远时全路映射到少量像素 → 自动选粗层(小贴图)→ + 20 条小贴图即可用;拉近 → 小区域细层。**确认 selectLod 多线下确实选到粗层**(若没有,调 + selectLod 的屏幕像素阈值——这是 §7.5 嫌疑①的修复点)。 + +**验收**:离屏看各线底图 level 随相机距离变(远→粗/小、近→细/小区);真窗口测 20 条总览交互级 fps、 +拖动跟手、松手清晰、过档位无明显卡顿(外部专家提示重点盯"停手重建过渡手感")。 + +--- + +# GPR 三维体渲染:多后端 + 空体素跳过(ESS) 加速架构 — Spec(2026-06-26,已废) + +> 解决"20 个重叠密体在 VTK 库存 OpenGL mapper 上又卡又只能降质"的根本性方案。 +> 关联:`docs/superpowers/specs/2026-06-25-gpr-line-channel-interpolation-and-multivolume.md`(数据/插值口径)。 + +--- + +## 0. 一句话目标 +用**空体素跳过(ESS)** 这一不损可见质量的业界技术,把"逐线分开的多个密体合并渲染"做到**不卡**; +并以**多渲染后端 + 自动适配**覆盖客户侧未知/多厂商硬件(含无显卡)。 + +--- + +## 1. 问题与根因 +- 现状渲染 = VTK `vtkGPUVolumeRayCastMapper`(OpenGL)。**任意 GPU 可跑(最通用),但无 ESS**。 +- 20 个重叠半透明密体:每条光线每步在 20 体各采一次、光线又长 → 几十亿次纹理查找/帧 → 1.7fps、交互更卡。 +- 通道插值后 Y 加密到 2.5cm,使自动沿光线步长更细 → 更卡。 +- **已尝试的"交互降质"(屏幕降采样 + 沿光线步长加粗)是治标**,损可见质量。 + +## 2. 根本方案:ESS(不损质量) +GPR 体 ~90% 是近零背景(反射层之间空)。ESS 用 min/max 加速结构**跳过"在传函里全透明"的块**, +对稀疏数据常 5–50× 提速、**零质量损失**。但 **VTK 库存 GPU mapper 不做自动 ESS**(仅有受限的 +`UseDepthPass` 等高线跳过)。→ 真 ESS 必须**换专业体渲染后端**(其底层自带 ESS + 正确合成多重叠体)。 + +## 3. 关键事实:跨厂商 GPU 加速不存在单一方案 +GPU 体光追渲染器全是**厂商锁定**: + +| 后端 | 硬件 | ESS | 跨厂商 | 角色 | +|---|---|---|---|---| +| OpenGL(vtkGPUVolumeRayCastMapper,现状)| 任意 GPU(N/A/Intel) | ❌ | ✅ | **终极兜底** | +| OSPRay(CPU,Embree/ISPC)| 任意 x86 CPU(免显卡)| ✅ | ✅ | **通用基线** | +| OSPRay-GPU(SYCL/oneAPI)| 仅 Intel Arc/数据中心卡 | ✅ | ❌ | Intel 独显 | +| ANARI + VisRTX(OptiX)| 仅 NVIDIA | ✅ | ❌ | N 卡 | +| AMD GPU 体渲染+ESS | — | — | ❌ 无成熟方案 | — | + +**结论**:没有"同时 N/A 卡的 GPU-ESS"。**面向未知客户机,OSPRay-CPU 是最稳通用选择**(免显卡、ESS、质量不降)。 + +## 4. 渲染后端架构(多后端 + 自动适配 + 手动覆盖 + 兼容灰掉) + +### 4.1 用户可见选项(按硬件/结果命名,不暴露库名) +| 用户看到 | 背后实现 | 适配硬件 | +|---|---|---| +| **自动(推荐)** | 探测后选下面之一 | — | +| GPU 加速(N卡)| ANARI + VisRTX | NVIDIA 独显 | +| GPU 加速(Intel)| OSPRay-GPU | Intel Arc 独显 | +| CPU(通用,免显卡)| OSPRay-CPU | 任意 CPU | +| 通用 GPU(兼容)| OpenGL(现状 mapper)| 任意 GPU(兜底)| + +### 4.2 自动探测逻辑 +``` +if NVIDIA 独显: → VisRTX(GPU) +elif Intel Arc 独显: → OSPRay-GPU +elif AMD(独显/核显) 或 Intel 核显 或 无显卡 或 探测失败: + → OSPRay-CPU(默认) // 核显一律走 CPU:弱+共享内存+多不被GPU后端支持 +// 强力 A 卡可手动选"通用 OpenGL"用其 GPU(无 ESS),但不作默认 +``` +- **手动覆盖**:用户可自选,但**只列出与当前硬件兼容的项**(不兼容灰掉,如 A 卡上禁 VisRTX)。 +- **集显建议**:一律 OSPRay-CPU(CPU+ESS 比让弱核显硬渲更稳更快)。 + +### 4.3 部署策略(一句话) +**OSPRay-CPU 保底通用;探测到 N卡/Intel Arc 时升对应 GPU 后端;A 卡/核显吃 CPU 基线;OpenGL 终极兜底。** + +## 5. 上 ESS 后端后,废弃哪些 +- ❌ **装箱单体(binning)**:当初为绕 OpenGL 无 ESS 才提(代价=丢"逐线分开")。ESS 后端让**逐线分开 + 的多体也快** → 不需要装箱。**逐线分开 + 不造假 + 不卡,三者兼得。** +- ❌ **交互降质权宜**(屏幕/沿光线步长加粗):ESS 后端有自带的自适应/渐进式细化,基本不用;保留作任何后端的兜底。 +- ❌ 自写 ESS shader / 预积分 / UseDepthPass:后端自带,无需自行实现。 +- ✅ **保留**:"选几条 ds(≤7)" 是使用方式(非技术),永远有效。 + +## 6. 实现要点(工程) +- VTK 需**重编**带:`RenderingRayTracing`(OSPRay)、`RenderingAnari`(ANARI/VisRTX) 模块 + 依赖 + (OSPRay/Embree/ISPC/OpenVKL;ANARI-SDK/VisRTX)。用户已确认工程量无所谓。 +- 渲染层抽象一个 `IVolumeRenderBackend`,运行时按 §4 选具体 mapper: + `vtkGPUVolumeRayCastMapper`(OpenGL) / `vtkOSPRayPass`+volume / `vtkAnariPass`+volume。 +- **数据不变**:逐线密体(含通道插值,spec 前一份)原样喂各后端;多体合成由后端负责(无 K=7 分包)。 +- 硬件探测:GL_VENDOR / 平台 API(DXGI 枚举适配器)判 N/A/Intel + 独显/核显。 + +## 7. POC 计划(先验"CPU+ESS 够不够快"——最通用、风险最低) +1. **POC-1(先做)**:最小程序,用 **OSPRay-CPU** 渲一个 GPR 密体(tmp/lines_all_dense 里一条/几条), + **实测普通 CPU 上对多体/密体的 fps + ESS 提速比**,对照现状 OpenGL。先确认 OSPRay 在本环境 + 可编可跑、CPU+ESS 实际够快——这是整套方案值不值得上的关键闸门。 +2. **POC-2**:若有 N 卡,ANARI+VisRTX 渲同一体,对照 GPU 提速。 +3. POC 通过 → 才动手重编 VTK + 接后端抽象层。 + +## 8. 风险 / 待定 +- OSPRay-GPU(Intel)较新、不如 CPU 路成熟;ANARI/VisRTX 需 NVIDIA 驱动 + VisRTX 库。 +- 各后端传函/外观与 OpenGL 有差异,需重新调一致。 +- 本环境能否编出带光追/ANARI 的 VTK(vcpkg/手动依赖)待 POC-1 验证。 +- CPU+ESS 在低核机上的实际帧率待实测。 + +## 9. 验收 +1. 客户机无论有无显卡/何种显卡,自动选到可跑的后端并出图(OSPRay-CPU 永远兜底)。 +2. 20 条密体总览:逐线分开、不造假、**ESS 后端下不卡**(目标交互 ≥ 可用帧率,质量不降)。 +3. 手动选项只列兼容项;集显默认 CPU。 +4. 数据层(通道插值密体)零改动复用。 diff --git a/external/gpr3dviewer/CMakeLists.txt b/external/gpr3dviewer/CMakeLists.txt new file mode 100644 index 0000000..4066343 --- /dev/null +++ b/external/gpr3dviewer/CMakeLists.txt @@ -0,0 +1,58 @@ +# ===================================================================== +# geopro_gpr3dv —— vendored 3DGPRViewer 数据生成链(原样拷贝,算法零改动) +# +# 链路:多通道 _Axx.iprh/.iprb +# → IprhParser::loadImpulseMultiChannel → GPRDataModel(traces) +# → GPRDataModel::buildVolumeData → volumeData[ch][trace][sample] +# → RadarProcessor::runPipeline → 处理后 GPRDataModel +# +# 版权属用户(lanbingtech 自有),可拷入本仓库 vendored 隔离。 +# 仅链 Qt6::Core(QVector/QString/QFile/QVector3D/QJson)+ OpenMP + kissfft。 +# 不含 UI/Sql/Network(这些在原版属 GPRWidget/mainwindow,未拷)。 +# ===================================================================== + +# 根工程仅 enable CXX;kissfft 是 C 源(kiss_fft.c/kiss_fftr.c),需显式启用 C 语言, +# 否则 .c 文件被 CMake 忽略,导致 kiss_fftr/kiss_fftr_alloc 链接缺失。 +enable_language(C) + +find_package(OpenMP) + +add_library(geopro_gpr3dv STATIC + IprhParser.cpp + ImpulseMultiChannelConverter.cpp + Rd3Parser.cpp + RadarProcessor.cpp + PerformanceLogger.cpp + # P8 测绘级精确坐标:原样拷自 3DGPRViewer(算法零改动,diff 逐字节一致)。 + # CoordinateTransform —— CGCS2000 高斯-克吕格 3°带正反算(零 Qt)。 + # TrajectoryCalculator —— RTK GPS + 天线几何 → 每通道每道 CGCS 世界坐标。 + # CScanGridder —— 世界坐标逐深度 IDW 网格化 C-scan(堆成世界对齐体)。 + # PosParser —— .pos / center.ccc GPS 解析。 + CoordinateTransform.cpp + TrajectoryCalculator.cpp + CScanGridder.cpp + PosParser.cpp + third_party/kissfft/kiss_fft.c + third_party/kissfft/kiss_fftr.c +) + +# 头文件目录:本目录(GPRDataModel.h 等)+ kissfft(RadarProcessor.cpp 内 #include "kiss_fftr.h")。 +target_include_directories(geopro_gpr3dv PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/third_party/kissfft +) + +# Qt6::Gui 为 QVector3D(GPRDataModel.h 的道三维位置)所需;Core 提供 QVector/QString/QFile/QJson。 +target_link_libraries(geopro_gpr3dv PUBLIC Qt6::Core Qt6::Gui) + +if(OpenMP_CXX_FOUND) + target_link_libraries(geopro_gpr3dv PUBLIC OpenMP::OpenMP_CXX) +endif() + +target_compile_features(geopro_gpr3dv PRIVATE cxx_std_17) + +# vendored 第三方代码:关 Qt 自动工具(无 QObject/moc 需求),并放宽告警(不改算法、不为告警动代码)。 +set_target_properties(geopro_gpr3dv PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) +if(MSVC) + target_compile_options(geopro_gpr3dv PRIVATE /W0) +endif() diff --git a/external/gpr3dviewer/CScanGridder.cpp b/external/gpr3dviewer/CScanGridder.cpp new file mode 100644 index 0000000..cd4cd8d --- /dev/null +++ b/external/gpr3dviewer/CScanGridder.cpp @@ -0,0 +1,336 @@ +#include "CScanGridder.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +struct CScanSamplePoint { + double x = 0.0; + double y = 0.0; + float amplitude = 0.0f; + int line = -1; + int channel = -1; + int trace = -1; +}; + +struct BinKey { + int x = 0; + int y = 0; + + bool operator==(const BinKey &other) const + { + return x == other.x && y == other.y; + } +}; + +uint qHash(const BinKey &key, uint seed = 0) +{ + return ::qHash(key.x, seed) ^ (::qHash(key.y, seed + 0x9e3779b9U) << 1); +} + +static double distanceSquared(double ax, double ay, double bx, double by) +{ + const double dx = ax - bx; + const double dy = ay - by; + return dx * dx + dy * dy; +} + +static int gridIndex(int x, int y, int width) +{ + return y * width + x; +} + +static QVector gaussianKernel(double sigma) +{ + sigma = std::max(0.1, sigma); + const int radius = std::max(1, static_cast(std::ceil(sigma * 3.0))); + QVector kernel(radius * 2 + 1); + double sum = 0.0; + for (int i = -radius; i <= radius; ++i) { + const double v = std::exp(-(i * i) / (2.0 * sigma * sigma)); + kernel[i + radius] = static_cast(v); + sum += v; + } + if (sum > 0.0) { + for (float &v : kernel) v = static_cast(v / sum); + } + return kernel; +} + +static void smoothMasked(CScanGridResult &grid, double sigma) +{ + if (!grid.valid || grid.width <= 0 || grid.height <= 0) return; + + const QVector kernel = gaussianKernel(sigma); + const int radius = kernel.size() / 2; + const int count = grid.width * grid.height; + + QVector tmpValues(count, 0.0f); + QVector tmpWeights(count, 0.0f); + + for (int y = 0; y < grid.height; ++y) { + for (int x = 0; x < grid.width; ++x) { + double sum = 0.0; + double weight = 0.0; + for (int k = -radius; k <= radius; ++k) { + const int sx = x + k; + if (sx < 0 || sx >= grid.width) continue; + const int idx = gridIndex(sx, y, grid.width); + if (!grid.validMask[idx]) continue; + const double w = kernel[k + radius]; + sum += grid.values[idx] * w; + weight += w; + } + const int outIdx = gridIndex(x, y, grid.width); + if (weight > 1e-6) { + tmpValues[outIdx] = static_cast(sum / weight); + tmpWeights[outIdx] = static_cast(weight); + } + } + } + + QVector outValues(count, 0.0f); + QVector outMask(count, 0); + + for (int y = 0; y < grid.height; ++y) { + for (int x = 0; x < grid.width; ++x) { + double sum = 0.0; + double weight = 0.0; + for (int k = -radius; k <= radius; ++k) { + const int sy = y + k; + if (sy < 0 || sy >= grid.height) continue; + const int idx = gridIndex(x, sy, grid.width); + if (tmpWeights[idx] <= 1e-6f) continue; + const double w = kernel[k + radius] * tmpWeights[idx]; + sum += tmpValues[idx] * w; + weight += w; + } + const int outIdx = gridIndex(x, y, grid.width); + if (weight > 1e-4) { + outValues[outIdx] = static_cast(sum / weight); + outMask[outIdx] = 1; + } + } + } + + grid.values = std::move(outValues); + grid.validMask = std::move(outMask); +} + +static float percentile(QVector values, double percent) +{ + if (values.isEmpty()) return 0.0f; + std::sort(values.begin(), values.end()); + percent = std::clamp(percent, 0.0, 100.0); + const double pos = percent / 100.0 * (values.size() - 1); + const int lo = static_cast(std::floor(pos)); + const int hi = static_cast(std::ceil(pos)); + if (lo == hi) return values[lo]; + const double t = pos - lo; + return static_cast(values[lo] * (1.0 - t) + values[hi] * t); +} + +static void updateDisplayRange(CScanGridResult &grid, double lowPercent, double highPercent) +{ + QVector validValues; + validValues.reserve(grid.values.size()); + for (int i = 0; i < grid.values.size(); ++i) { + if (i < grid.validMask.size() && grid.validMask[i]) { + validValues.append(grid.values[i]); + } + } + + if (validValues.isEmpty()) { + grid.displayMin = 0.0f; + grid.displayMax = 1.0f; + return; + } + + grid.displayMin = percentile(validValues, lowPercent); + grid.displayMax = percentile(validValues, highPercent); + if (std::abs(grid.displayMax - grid.displayMin) < 1e-6f) { + grid.displayMax = grid.displayMin + 1.0f; + } +} + +} // namespace + +CScanGridResult CScanGridder::build(const QVector &surveyLines, CScanGridOptions options) +{ + CScanGridResult result; + + options.cellSizeM = std::max(0.005, options.cellSizeM); + options.searchRadiusM = std::max(options.cellSizeM, options.searchRadiusM); + options.idwPower = std::max(0.1, options.idwPower); + options.smoothingSigmaCells = std::clamp(options.smoothingSigmaCells, 0.1, 5.0); + if (options.clipLowPercent >= options.clipHighPercent) { + options.clipLowPercent = 2.0; + options.clipHighPercent = 98.0; + } + options.maxGridWidth = std::max(64, options.maxGridWidth); + options.maxGridHeight = std::max(64, options.maxGridHeight); + options.maxGridCells = std::max(4096, options.maxGridCells); + + QVector samples; + double minX = std::numeric_limits::max(); + double minY = std::numeric_limits::max(); + double maxX = std::numeric_limits::lowest(); + double maxY = std::numeric_limits::lowest(); + + for (int li = 0; li < surveyLines.size(); ++li) { + const SurveyLine &line = surveyLines[li]; + if (!line.hasValidTrajectories()) continue; + + const GPRDataModel &data = line.data; + + const int channelCount = std::min(static_cast(line.channelTrajectories.size()), data.getChannelCount()); + const int traceCount = data.getTraceCountPerChannel(); + const int sampleCount = data.getSampleCount(); + if (channelCount <= 0 || traceCount <= 0 || sampleCount <= 0) continue; + + const int sampleIndex = qBound(0, options.sampleIndex, sampleCount - 1); + for (int c = 0; c < channelCount; ++c) { + const QVector &traj = line.channelTrajectories[c]; + const int n = std::min(traceCount, static_cast(traj.size())); + for (int t = 0; t < n; ++t) { + const RadarTrace *trace = data.getTrace(c, t); + if (!trace || sampleIndex >= trace->amplitudes.size()) continue; + + const QVector3D &pos = traj[t]; + const float amp = static_cast(trace->amplitudes[sampleIndex]); + // Grid X uses CGCS easting (Y), grid Y uses CGCS northing (X), + // so the generated C-scan image aligns with map screen axes. + samples.append({pos.y(), pos.x(), amp, li, c, t}); + minX = std::min(minX, static_cast(pos.y())); + minY = std::min(minY, static_cast(pos.x())); + maxX = std::max(maxX, static_cast(pos.y())); + maxY = std::max(maxY, static_cast(pos.x())); + } + } + } + + if (samples.isEmpty() || maxX <= minX || maxY <= minY) { + return result; + } + + result.dataMinX = minX; + result.dataMinY = minY; + result.dataMaxX = maxX; + result.dataMaxY = maxY; + + minX -= options.searchRadiusM; + minY -= options.searchRadiusM; + maxX += options.searchRadiusM; + maxY += options.searchRadiusM; + + double cellSize = options.cellSizeM; + int width = static_cast(std::ceil((maxX - minX) / cellSize)) + 1; + int height = static_cast(std::ceil((maxY - minY) / cellSize)) + 1; + while ((width > options.maxGridWidth || height > options.maxGridHeight + || static_cast(width) * height > options.maxGridCells) && cellSize < 10.0) { + cellSize *= 1.5; + width = static_cast(std::ceil((maxX - minX) / cellSize)) + 1; + height = static_cast(std::ceil((maxY - minY) / cellSize)) + 1; + } + + if (width <= 0 || height <= 0 || width > options.maxGridWidth || height > options.maxGridHeight + || static_cast(width) * height > options.maxGridCells) { + return result; + } + + result.valid = true; + result.originX = minX; + result.originY = minY; + result.cellSizeM = cellSize; + result.width = width; + result.height = height; + + const int cellCount = width * height; + result.values.fill(0.0f, cellCount); + result.validMask.fill(0, cellCount); + result.nearestLine.fill(-1, cellCount); + result.nearestChannel.fill(-1, cellCount); + result.nearestTrace.fill(-1, cellCount); + + const double binSize = options.searchRadiusM; + QHash> bins; + bins.reserve(samples.size()); + for (int i = 0; i < samples.size(); ++i) { + const CScanSamplePoint &p = samples[i]; + const BinKey key{static_cast(std::floor((p.x - minX) / binSize)), + static_cast(std::floor((p.y - minY) / binSize))}; + bins[key].append(i); + } + + const double radiusSq = options.searchRadiusM * options.searchRadiusM; + const int searchBins = std::max(1, static_cast(std::ceil(options.searchRadiusM / binSize))); + + for (int y = 0; y < height; ++y) { + const double cy = minY + y * cellSize; + const int by = static_cast(std::floor((cy - minY) / binSize)); + for (int x = 0; x < width; ++x) { + const double cx = minX + x * cellSize; + const int bx = static_cast(std::floor((cx - minX) / binSize)); + + double sum = 0.0; + double weightSum = 0.0; + double bestDistSq = std::numeric_limits::max(); + int bestIndex = -1; + bool exact = false; + float exactValue = 0.0f; + + for (int oy = -searchBins; oy <= searchBins; ++oy) { + for (int ox = -searchBins; ox <= searchBins; ++ox) { + const BinKey key{bx + ox, by + oy}; + auto it = bins.constFind(key); + if (it == bins.constEnd()) continue; + + for (int sampleIdx : it.value()) { + const CScanSamplePoint &p = samples[sampleIdx]; + const double d2 = distanceSquared(cx, cy, p.x, p.y); + if (d2 > radiusSq) continue; + if (d2 < bestDistSq) { + bestDistSq = d2; + bestIndex = sampleIdx; + } + if (d2 < 1e-8) { + exact = true; + exactValue = p.amplitude; + bestIndex = sampleIdx; + break; + } + const double w = 1.0 / std::pow(std::sqrt(d2), options.idwPower); + sum += p.amplitude * w; + weightSum += w; + } + if (exact) break; + } + if (exact) break; + } + + const int idx = gridIndex(x, y, width); + if (exact || weightSum > 0.0) { + result.values[idx] = exact ? exactValue : static_cast(sum / weightSum); + result.validMask[idx] = 1; + if (bestIndex >= 0) { + const CScanSamplePoint &p = samples[bestIndex]; + result.nearestLine[idx] = p.line; + result.nearestChannel[idx] = p.channel; + result.nearestTrace[idx] = p.trace; + } + } + } + } + + if (options.smoothingEnabled) { + smoothMasked(result, options.smoothingSigmaCells); + } + updateDisplayRange(result, options.clipLowPercent, options.clipHighPercent); + + return result; +} diff --git a/external/gpr3dviewer/CScanGridder.h b/external/gpr3dviewer/CScanGridder.h new file mode 100644 index 0000000..aada1e6 --- /dev/null +++ b/external/gpr3dviewer/CScanGridder.h @@ -0,0 +1,48 @@ +#ifndef CSCANGRIDDER_H +#define CSCANGRIDDER_H + +#include "GPRDataModel.h" + +#include + +struct CScanGridOptions { + int sampleIndex = 0; + double cellSizeM = 0.05; + double searchRadiusM = 0.50; + double idwPower = 2.0; + bool smoothingEnabled = true; + double smoothingSigmaCells = 0.9; + int maxGridWidth = 2048; + int maxGridHeight = 2048; + int maxGridCells = 250000; + double clipLowPercent = 2.0; + double clipHighPercent = 98.0; +}; + +struct CScanGridResult { + bool valid = false; + double originX = 0.0; + double originY = 0.0; + double dataMinX = 0.0; + double dataMinY = 0.0; + double dataMaxX = 0.0; + double dataMaxY = 0.0; + double cellSizeM = 0.05; + int width = 0; + int height = 0; + QVector values; + QVector validMask; + QVector nearestLine; + QVector nearestChannel; + QVector nearestTrace; + float displayMin = 0.0f; + float displayMax = 1.0f; +}; + +class CScanGridder +{ +public: + static CScanGridResult build(const QVector &surveyLines, CScanGridOptions options); +}; + +#endif // CSCANGRIDDER_H diff --git a/external/gpr3dviewer/CoordinateTransform.cpp b/external/gpr3dviewer/CoordinateTransform.cpp new file mode 100644 index 0000000..be8141e --- /dev/null +++ b/external/gpr3dviewer/CoordinateTransform.cpp @@ -0,0 +1,219 @@ +#include "CoordinateTransform.h" +#include + +// 静态辅助:预计算的子午线弧长系数 +static inline double computeA0(double e2) { + return 1.0 + 3.0/4.0 * e2 + 45.0/64.0 * e2*e2 + 175.0/256.0 * e2*e2*e2 + + 11025.0/16384.0 * e2*e2*e2*e2; +} +static inline double computeA2(double e2) { + return 3.0/4.0 * e2 + 15.0/16.0 * e2*e2 + 525.0/512.0 * e2*e2*e2 + + 2205.0/2048.0 * e2*e2*e2*e2; +} +static inline double computeA4(double e2) { + return 15.0/64.0 * e2*e2 + 105.0/256.0 * e2*e2*e2 + + 2205.0/4096.0 * e2*e2*e2*e2; +} +static inline double computeA6(double e2) { + return 35.0/512.0 * e2*e2*e2 + 315.0/2048.0 * e2*e2*e2*e2; +} +static inline double computeA8(double e2) { + return 315.0/16384.0 * e2*e2*e2*e2; +} + +int CoordinateTransform::detectZoneFromCgcsY(double cgcsY) +{ + // 中国范围内 CGCS2000 坐标 Y 通常为正值且包含带号 + // 格式:zone * 1,000,000 + 500,000 + easting + double absY = std::abs(cgcsY); + if (absY > 1e6) { + int zone = static_cast(std::floor(absY / 1e6)); + if (zone >= 1 && zone <= 60) + return zone; + } + // 如果无法推断,返回 0(表示需要手动指定) + return 0; +} + +double CoordinateTransform::centralMeridianFromZone(int zone) +{ + // 3度带:中央经线 = 带号 * 3° + return zone * 3.0; +} + +double CoordinateTransform::meridianArc(double latRad) +{ + const double e2 = E2; + const double a = A; + + const double A0 = computeA0(e2); + const double A2 = computeA2(e2); + const double A4 = computeA4(e2); + const double A6 = computeA6(e2); + const double A8 = computeA8(e2); + + double s2 = std::sin(2.0 * latRad); + double s4 = std::sin(4.0 * latRad); + double s6 = std::sin(6.0 * latRad); + double s8 = std::sin(8.0 * latRad); + + return a * (1.0 - e2) * (A0 * latRad - A2/2.0 * s2 + A4/4.0 * s4 + - A6/6.0 * s6 + A8/8.0 * s8); +} + +double CoordinateTransform::meridianArcInverse(double x) +{ + const double e2 = E2; + const double a = A; + + const double A0 = computeA0(e2); + const double A2 = computeA2(e2); + const double A4 = computeA4(e2); + const double A6 = computeA6(e2); + const double A8 = computeA8(e2); + + const double c0 = a * (1.0 - e2) * A0; + + double bf = x / c0; + for (int i = 0; i < 8; ++i) { + double s2 = std::sin(2.0 * bf); + double s4 = std::sin(4.0 * bf); + double s6 = std::sin(6.0 * bf); + double s8 = std::sin(8.0 * bf); + + double fx = a * (1.0 - e2) * (A0 * bf - A2/2.0 * s2 + A4/4.0 * s4 + - A6/6.0 * s6 + A8/8.0 * s8) - x; + double fpx = a * (1.0 - e2) * (A0 - A2 * std::cos(2.0 * bf) + + A4 * std::cos(4.0 * bf) + - A6 * std::cos(6.0 * bf) + + A8 * std::cos(8.0 * bf)); + + double delta = fx / fpx; + bf -= delta; + if (std::abs(delta) < 1e-12) + break; + } + return bf; +} + +void CoordinateTransform::cgcs2000ToWgs84(double cgcsX, double cgcsY, int zone, + double &latRad, double &lonRad) +{ + const double a = A; + const double e2 = E2; + const double e12 = E12; + + // 提取东偏移(去掉带号和 500km 常数) + double easting = cgcsY; + if (zone > 0) { + easting = cgcsY - zone * 1e6 - FALSE_EASTING; + } else { + easting = cgcsY - FALSE_EASTING; + } + double northing = cgcsX; + double l0 = centralMeridianFromZone(zone) * DEG2RAD; + + // 底点纬度 + double bf = meridianArcInverse(northing); + + double sinBf = std::sin(bf); + double cosBf = std::cos(bf); + double tanBf = std::tan(bf); + + double Nf = a / std::sqrt(1.0 - e2 * sinBf * sinBf); + double etaf2 = e12 * cosBf * cosBf; + double tf = tanBf; + double Vf2 = 1.0 + etaf2; + + double y = easting; + double y2 = y * y; + double y4 = y2 * y2; + double y6 = y4 * y2; + + double Nf2 = Nf * Nf; + double Nf4 = Nf2 * Nf2; + double Nf6 = Nf4 * Nf2; + + // 纬度修正 + double dB = tf / Vf2 * ( + y2 / (2.0 * Nf2) * (1.0 + etaf2) + - y4 / (24.0 * Nf4) * (5.0 + 3.0 * tf*tf + 6.0 * etaf2 + - 6.0 * tf*tf * etaf2 + - 3.0 * etaf2*etaf2 + - 9.0 * tf*tf * etaf2*etaf2) + + y6 / (720.0 * Nf6) * (61.0 + 90.0 * tf*tf + 45.0 * tf*tf*tf*tf) + ); + + latRad = bf - dB; + + // 经差 + double dl = y / (Nf * cosBf) * ( + 1.0 + - y2 / (6.0 * Nf2) * (1.0 + 2.0 * tf*tf + etaf2) + + y4 / (120.0 * Nf4) * (5.0 + 28.0 * tf*tf + 24.0 * tf*tf*tf*tf + + 6.0 * etaf2 + 8.0 * tf*tf * etaf2) + - y6 / (5040.0 * Nf6) * (61.0 + 662.0 * tf*tf + 1320.0 * tf*tf*tf*tf + + 720.0 * tf*tf*tf*tf*tf*tf) + ); + + lonRad = l0 + dl; +} + +void CoordinateTransform::wgs84ToCgcs2000(double latRad, double lonRad, int zone, + double &cgcsX, double &cgcsY) +{ + const double a = A; + const double e2 = E2; + const double e12 = E12; + + double l0 = centralMeridianFromZone(zone) * DEG2RAD; + double l = lonRad - l0; + + double sinB = std::sin(latRad); + double cosB = std::cos(latRad); + double tanB = std::tan(latRad); + + double N = a / std::sqrt(1.0 - e2 * sinB * sinB); + double t2 = tanB * tanB; + double t4 = t2 * t2; + double eta2 = e12 * cosB * cosB; + double eta4 = eta2 * eta2; + + double l2 = l * l; + double l4 = l2 * l2; + double l6 = l4 * l2; + + // 子午线弧长 + double X = meridianArc(latRad); + + // 北坐标 + cgcsX = X + N / 2.0 * sinB * cosB * l2 * ( + 1.0 + l2 / 12.0 * (5.0 - t2 + 9.0 * eta2 + 4.0 * eta4) + + l4 / 360.0 * (61.0 - 58.0 * t2 + t4) + ); + + // 东坐标(自然值,不含带号和 500km) + double yNatural = N * cosB * l * ( + 1.0 + l2 / 6.0 * (1.0 - t2 + eta2) + + l4 / 120.0 * (5.0 - 18.0 * t2 + t4 + 14.0 * eta2 - 58.0 * t2 * eta2) + ); + + // 加上带号和 500km 常数 + if (zone > 0) { + cgcsY = zone * 1e6 + FALSE_EASTING + yNatural; + } else { + cgcsY = FALSE_EASTING + yNatural; + } +} + +void CoordinateTransform::wgs84ToWebMercator(double latRad, double lonRad, double &mx, double &my) +{ + mx = EARTH_RADIUS * lonRad; + my = EARTH_RADIUS * std::log(std::tan(PI / 4.0 + latRad / 2.0)); +} + +void CoordinateTransform::webMercatorToWgs84(double mx, double my, double &latRad, double &lonRad) +{ + lonRad = mx / EARTH_RADIUS; + latRad = 2.0 * std::atan(std::exp(my / EARTH_RADIUS)) - PI / 2.0; +} diff --git a/external/gpr3dviewer/CoordinateTransform.h b/external/gpr3dviewer/CoordinateTransform.h new file mode 100644 index 0000000..cfca21b --- /dev/null +++ b/external/gpr3dviewer/CoordinateTransform.h @@ -0,0 +1,48 @@ +#ifndef COORDINATETRANSFORM_H +#define COORDINATETRANSFORM_H + +#include + +class CoordinateTransform { +public: + // CGCS2000 椭球参数 + static constexpr double A = 6378137.0; // 长半轴 (m) + static constexpr double F = 1.0 / 298.257222101; // 扁率 + static constexpr double E2 = 2.0 * F - F * F; // 第一偏心率平方 + static constexpr double E12 = E2 / (1.0 - E2); // 第二偏心率平方 + static constexpr double FALSE_EASTING = 500000.0; // 东偏移 (m) + + static constexpr double EARTH_RADIUS = 6378137.0; // Web Mercator 地球半径 + static constexpr double PI = 3.14159265358979323846; + static constexpr double DEG2RAD = PI / 180.0; + static constexpr double RAD2DEG = 180.0 / PI; + + // 从 CGCS2000 东向坐标(Y)自动推断 3° 带带号 + // 假设 Y 格式为:zone*1e6 + 500000 + easting,或纯自然值 + static int detectZoneFromCgcsY(double cgcsY); + + // 带号 → 中央经线(度) + static double centralMeridianFromZone(int zone); + + // CGCS2000 平面坐标 → WGS84 经纬度(弧度) + // cgcsX: 北向坐标(m), cgcsY: 东向坐标(m, 可含带号) + static void cgcs2000ToWgs84(double cgcsX, double cgcsY, int zone, + double &latRad, double &lonRad); + + // WGS84 经纬度(弧度) → CGCS2000 平面坐标 + static void wgs84ToCgcs2000(double latRad, double lonRad, int zone, + double &cgcsX, double &cgcsY); + + // WGS84 经纬度(弧度) ↔ Web Mercator 平面坐标(米) + static void wgs84ToWebMercator(double latRad, double lonRad, double &mx, double &my); + static void webMercatorToWgs84(double mx, double my, double &latRad, double &lonRad); + +private: + // 子午线弧长(从赤道到纬度 B 的弧长) + static double meridianArc(double latRad); + + // 子午线弧长反解:已知弧长 X,求底点纬度 Bf + static double meridianArcInverse(double x); +}; + +#endif // COORDINATETRANSFORM_H diff --git a/external/gpr3dviewer/GPRDataModel.h b/external/gpr3dviewer/GPRDataModel.h new file mode 100644 index 0000000..664eb16 --- /dev/null +++ b/external/gpr3dviewer/GPRDataModel.h @@ -0,0 +1,297 @@ +#ifndef GPRDATAMODEL_H +#define GPRDATAMODEL_H + +#include +#include +#include +#include +#include +#include +#include "SurveyGeometry.h" + +struct RadarTrace { + QVector amplitudes; // 【重要】改为 short (16-bit),之前是 float + double startTime = 0.0; + double timeStep = 0.0; + // 三维位置信息 + QVector3D position; // 该道的三维空间位置 + int channelNumber = 0; // 通道号 + + RadarTrace() = default; + RadarTrace(const RadarTrace& other) + : amplitudes(QVector(other.amplitudes.cbegin(), other.amplitudes.cend())) + , startTime(other.startTime) + , timeStep(other.timeStep) + , position(other.position) + , channelNumber(other.channelNumber) + {} + RadarTrace& operator=(const RadarTrace& other) { + if (this != &other) { + amplitudes = QVector(other.amplitudes.cbegin(), other.amplitudes.cend()); + startTime = other.startTime; + timeStep = other.timeStep; + position = other.position; + channelNumber = other.channelNumber; + } + return *this; + } + RadarTrace(RadarTrace&& other) noexcept + : amplitudes(std::move(other.amplitudes)) + , startTime(other.startTime) + , timeStep(other.timeStep) + , position(other.position) + , channelNumber(other.channelNumber) + {} + RadarTrace& operator=(RadarTrace&& other) noexcept { + if (this != &other) { + amplitudes = std::move(other.amplitudes); + startTime = other.startTime; + timeStep = other.timeStep; + position = other.position; + channelNumber = other.channelNumber; + } + return *this; + } +}; + +class GPRDataModel { +public: + struct Header { + int numTraces = 0; // LAST TRACE + int samplesPerTrace = 0; // SAMPLES + double timeWindowNs = 0.0; // TIMEWINDOW (ns) + double distanceInc = 0.0; // DISTANCE INTERVAL (m) + double antennaFreq = 0.0; // FREQUENCY (MHz) + QString antennaType; // ANTENNAS + QString date; // DATE + QString timeStr; // TIME + // 三维相关参数 + int numberOfChannels = 1; // NUMBER_OF_CH + QVector chXOffsets; // CH_X_OFFSETS + QVector chYOffsets; // CH_Y_OFFSETS + QString units; // UNITS + double startPosition = 0.0; // START POSITION + double stopPosition = 0.0; // STOP POSITION + double waveVelocity = 0.1; // 波速,用于深度计算 + double timeIntervalNs = 0.0; // TIME INTERVAL (ns),用于计算时窗 + + // 原始映射,用于调试或扩展 + QMap rawParams; + }; + + Header header; + QVector traces; + + // 三维数据相关属性 + int tracesPerChannel = 0; // 每个通道的道数 + int channels = 0; // 实际通道数 + double totalDistance = 0.0; // 总距离 + + // 三维数据体 - 存储为[channel][trace][sample]格式 + QVector>> volumeData; // [channel][trace][sample] + + bool isEmpty() const { return traces.isEmpty(); } + void clear() { + traces.clear(); + header = Header{}; + tracesPerChannel = 0; + channels = 0; + totalDistance = 0.0; + volumeData.clear(); + } + + // 获取指定通道的道数 + int getTracesPerChannel() const { + if (header.numTraces <= 0) { + return 0; + } + if (header.numberOfChannels > 0) { + return header.numTraces / header.numberOfChannels; + } + return header.numTraces; + } + + int getTraceIndex(int channel, int traceInChannel) const { + const int nChannels = header.numberOfChannels > 0 ? header.numberOfChannels : 1; + if (channel < 0 || channel >= nChannels || + traceInChannel < 0 || traceInChannel >= getTracesPerChannel()) { + return -1; + } + return traceInChannel * nChannels + channel; + } + + // 获取指定通道和道号的数据 + const RadarTrace* getTrace(int channel, int traceInChannel) const { + const int index = getTraceIndex(channel, traceInChannel); + if (index >= 0 && index < traces.size()) { + return &traces[index]; + } + return nullptr; + } + + // 计算深度(米) + double calculateDepth(int sampleIndex) const { + if (header.samplesPerTrace > 0) { + // 使用默认波速0.1m/ns,除以2是因为往返时间 + double timePerSample = header.timeWindowNs / header.samplesPerTrace; + double timeNs = sampleIndex * timePerSample; + return timeNs * header.waveVelocity / 2.0; // 深度 = 时间 * 波速 / 2 + } + return 0.0; + } + + // 计算距离(米) + double calculateDistance(int traceIndex) const { + if (header.distanceInc > 0) { + return traceIndex * header.distanceInc; + } + return 0.0; + } + + // 构建三维数据体 + void buildVolumeData() { + if (traces.isEmpty()) return; + + int nChannels = header.numberOfChannels > 0 ? header.numberOfChannels : 1; + int nTracesPerChannel = getTracesPerChannel(); + int nSamples = header.samplesPerTrace; + + volumeData.resize(nChannels); + for (int c = 0; c < nChannels; ++c) { + volumeData[c].resize(nTracesPerChannel); + for (int t = 0; t < nTracesPerChannel; ++t) { + volumeData[c][t].resize(nSamples); + } + } + + // 填充数据 + for (int i = 0; i < traces.size(); ++i) { + int channel = i % nChannels; + int traceInChannel = i / nChannels; + + if (channel < volumeData.size() && traceInChannel < volumeData[channel].size()) { + for (int s = 0; s < traces[i].amplitudes.size() && s < nSamples; ++s) { + volumeData[channel][traceInChannel][s] = traces[i].amplitudes[s]; + } + } + } + } + + // 获取三维数据点 + short getVolumeValue(int channel, int trace, int sample) const { + if (channel < 0 || channel >= volumeData.size() || + trace < 0 || trace >= volumeData[channel].size() || + sample < 0 || sample >= volumeData[channel][trace].size()) { + return 0; + } + return volumeData[channel][trace][sample]; + } + + // 获取全局最小最大值 + void getGlobalMinMax(short& minVal, short& maxVal) const { + if (volumeData.isEmpty()) { + minVal = 0; + maxVal = 0; + return; + } + + minVal = 32767; + maxVal = -32768; + + for (const auto& channelData : volumeData) { + for (const auto& traceData : channelData) { + for (short val : traceData) { + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } + } + } + } + + // 获取通道数 + int getChannelCount() const { + if (!volumeData.isEmpty()) return volumeData.size(); + return qMax(1, header.numberOfChannels); + } + + // 获取每通道的迹数 + int getTraceCountPerChannel() const { + if (!volumeData.isEmpty() && !volumeData[0].isEmpty()) return volumeData[0].size(); + int nCh = qMax(1, header.numberOfChannels); + return header.numTraces / nCh; + } + + // 获取每迹的样本数 + int getSampleCount() const { + if (!volumeData.isEmpty() && !volumeData[0].isEmpty()) return volumeData[0][0].size(); + return header.samplesPerTrace; + } + + GPRDataModel() = default; + GPRDataModel(const GPRDataModel&) = default; + GPRDataModel& operator=(const GPRDataModel&) = default; + GPRDataModel(GPRDataModel&&) = default; + GPRDataModel& operator=(GPRDataModel&&) = default; +}; + +struct TrajectoryEditPoint { + QVector3D rawPosition; + QVector3D editedPosition; + bool enabled = true; + bool manuallyEdited = false; + bool distanceOutlier = false; + bool speedOutlier = false; + bool angleOutlier = false; +}; + +struct TrajectoryEditSettings { + bool distanceFilterEnabled = true; + double maxSegmentDistanceM = 1.0; + bool speedFilterEnabled = false; + double maxSpeedMps = 5.0; + double traceIntervalSec = 0.05; + bool angleFilterEnabled = true; + double maxTurnAngleDeg = 60.0; + int interpolationMode = 0; // 0 linear, 1 spline + bool preserveManualEdits = true; +}; + +// 单条测线结构(含GPS) +struct SurveyLine { + QString name; // 测线编号,如 "_000", "_001" + QString displayName; // 显示名称,如 "碧新路_000" + QString radFilePath; // .rad 文件完整路径 + GPRDataModel data; // 雷达数据 + QVector gpsPositions; // 每个trace的GPS坐标 (X,Y,Z) + QVector trajectoryEditPoints; // 可编辑中心线轨迹点 + TrajectoryEditSettings trajectoryEditSettings; + + // 三维轨迹相关(新增) + SurveyGeometry geometry; // 天线几何参数 + QVector> channelTrajectories; // [channel][trace] 绝对坐标 + + bool hasGps() const { return !gpsPositions.isEmpty(); } + bool hasValidTrajectories() const { return !channelTrajectories.isEmpty() && !channelTrajectories[0].isEmpty(); } + bool isEmpty() const { return data.isEmpty(); } + void clear() { + name.clear(); + displayName.clear(); + radFilePath.clear(); + data.clear(); + gpsPositions.clear(); + trajectoryEditPoints.clear(); + trajectoryEditSettings = TrajectoryEditSettings{}; + geometry = SurveyGeometry{}; + channelTrajectories.clear(); + } + + SurveyLine() = default; + SurveyLine(const SurveyLine&) = default; + SurveyLine& operator=(const SurveyLine&) = default; + SurveyLine(SurveyLine&&) = default; + SurveyLine& operator=(SurveyLine&&) = default; +}; + +Q_DECLARE_METATYPE(SurveyLine) + +#endif // GPRDATAMODEL_H \ No newline at end of file diff --git a/external/gpr3dviewer/ImpulseMultiChannelConverter.cpp b/external/gpr3dviewer/ImpulseMultiChannelConverter.cpp new file mode 100644 index 0000000..1527992 --- /dev/null +++ b/external/gpr3dviewer/ImpulseMultiChannelConverter.cpp @@ -0,0 +1,406 @@ +#include "ImpulseMultiChannelConverter.h" + +#include "IprhParser.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +bool ImpulseMultiChannelConverter::isMultiChannelImpulseHeader(const QString &headerPath, + QString *dirPath, + QString *baseName) +{ + QFileInfo fi(headerPath); + QRegularExpression reMultiChannel(QStringLiteral("^(.*)_A(\\d+)$"), + QRegularExpression::CaseInsensitiveOption); + QRegularExpressionMatch match = reMultiChannel.match(fi.completeBaseName()); + if (!match.hasMatch()) + return false; + + const QString surveyBase = match.captured(1); + QDir dir(fi.absolutePath()); + const QStringList channels = dir.entryList(QStringList() << surveyBase + QStringLiteral("_A*.iprh"), + QDir::Files); + if (channels.size() <= 1) + return false; + + if (dirPath) + *dirPath = fi.absolutePath(); + if (baseName) + *baseName = surveyBase; + return true; +} + +bool ImpulseMultiChannelConverter::buildPlan(const QString &dirPath, + const QString &baseName, + ConversionPlan &plan, + QString *errorMessage) +{ + plan = ConversionPlan{}; + plan.dirPath = dirPath; + plan.baseName = baseName; + + QDir dir(dirPath); + dir.setFilter(QDir::Files | QDir::NoDotAndDotDot); + + QRegularExpression reChannel(QStringLiteral("^%1_A(\\d+)\\.iprh$").arg(QRegularExpression::escape(baseName)), + QRegularExpression::CaseInsensitiveOption); + QMap channelHeaders; + for (const QFileInfo &fi : dir.entryInfoList()) { + QRegularExpressionMatch match = reChannel.match(fi.fileName()); + if (!match.hasMatch()) + continue; + + const int chNum = match.captured(1).toInt(); + const QString iprhPath = fi.absoluteFilePath(); + QString iprbPath = iprhPath; + iprbPath.replace(QStringLiteral(".iprh"), QStringLiteral(".iprb"), Qt::CaseInsensitive); + if (QFile::exists(iprbPath)) { + channelHeaders.insert(chNum, iprhPath); + } else { + qDebug() << "Missing .iprb for" << iprhPath; + } + } + + if (channelHeaders.isEmpty()) { + if (errorMessage) + *errorMessage = QStringLiteral("未找到 Impulse 多通道文件:%1").arg(baseName); + return false; + } + + GPRDataModel::Header masterHeader; + if (!IprhParser::parseHeaderOnly(channelHeaders.first(), masterHeader)) { + if (errorMessage) + *errorMessage = QStringLiteral("无法解析主通道头文件:%1").arg(channelHeaders.first()); + return false; + } + + QVector xOffsets; + QVector yOffsets; + xOffsets.reserve(channelHeaders.size()); + yOffsets.reserve(channelHeaders.size()); + + qint64 minTraceCount = std::numeric_limits::max(); + double antennaSeparation = 0.0; + + for (auto it = channelHeaders.begin(); it != channelHeaders.end(); ++it) { + ChannelInfo info; + info.channelNumber = it.key(); + info.iprhPath = it.value(); + info.iprbPath = info.iprhPath; + info.iprbPath.replace(QStringLiteral(".iprh"), QStringLiteral(".iprb"), Qt::CaseInsensitive); + + if (!IprhParser::parseHeaderOnly(info.iprhPath, info.header)) { + if (errorMessage) + *errorMessage = QStringLiteral("无法解析通道头文件:%1").arg(info.iprhPath); + return false; + } + + if (info.header.samplesPerTrace != masterHeader.samplesPerTrace) { + if (errorMessage) + *errorMessage = QStringLiteral("多通道 SAMPLES 不一致:%1").arg(info.iprhPath); + return false; + } + if (!qFuzzyCompare(info.header.timeIntervalNs, masterHeader.timeIntervalNs) && + info.header.timeIntervalNs > 0 && masterHeader.timeIntervalNs > 0) { + qDebug() << "Warning: Inconsistent TIME INTERVAL across channels" << info.iprhPath; + } + if (!qFuzzyCompare(info.header.distanceInc, masterHeader.distanceInc) && + info.header.distanceInc > 0 && masterHeader.distanceInc > 0) { + qDebug() << "Warning: Inconsistent DISTANCE INTERVAL across channels" << info.iprhPath; + } + + if (info.header.rawParams.contains(QStringLiteral("CH_X_OFFSET"))) { + info.xOffset = info.header.rawParams.value(QStringLiteral("CH_X_OFFSET")).toFloat(); + } else if (info.header.rawParams.contains(QStringLiteral("CH_OFFSET_X"))) { + info.xOffset = info.header.rawParams.value(QStringLiteral("CH_OFFSET_X")).toFloat(); + } else if (!info.header.chXOffsets.isEmpty()) { + info.xOffset = info.header.chXOffsets.first(); + } + if (info.header.rawParams.contains(QStringLiteral("CH_Y_OFFSET"))) { + info.yOffset = info.header.rawParams.value(QStringLiteral("CH_Y_OFFSET")).toFloat(); + } else if (info.header.rawParams.contains(QStringLiteral("CH_OFFSET_Y"))) { + info.yOffset = info.header.rawParams.value(QStringLiteral("CH_OFFSET_Y")).toFloat(); + } else if (!info.header.chYOffsets.isEmpty()) { + info.yOffset = info.header.chYOffsets.first(); + } + xOffsets.append(info.xOffset); + yOffsets.append(info.yOffset); + + if (info.header.rawParams.contains(QStringLiteral("ANTENNA SEPARATION"))) { + antennaSeparation = info.header.rawParams.value(QStringLiteral("ANTENNA SEPARATION")).toDouble(); + } + + const qint64 traceBytes = static_cast(info.header.samplesPerTrace) * sizeof(qint16); + if (traceBytes <= 0) { + if (errorMessage) + *errorMessage = QStringLiteral("通道采样数无效:%1").arg(info.iprhPath); + return false; + } + info.traceCount = QFileInfo(info.iprbPath).size() / traceBytes; + if (info.traceCount <= 0) { + if (errorMessage) + *errorMessage = QStringLiteral("通道无有效数据:%1").arg(info.iprbPath); + return false; + } + minTraceCount = std::min(minTraceCount, info.traceCount); + plan.channels.append(info); + } + + if (plan.channels.isEmpty() || minTraceCount <= 0 || minTraceCount == std::numeric_limits::max()) { + if (errorMessage) + *errorMessage = QStringLiteral("Impulse 多通道没有有效 trace:%1").arg(baseName); + return false; + } + + for (const ChannelInfo &info : plan.channels) { + if (info.traceCount != minTraceCount) { + qDebug() << "Warning: Inconsistent trace count across channels. Using minimum:" + << minTraceCount << "channel has:" << info.traceCount << info.iprhPath; + } + } + + bool allZeroOffsets = true; + for (float value : xOffsets) { + if (!qFuzzyIsNull(value)) { + allZeroOffsets = false; + break; + } + } + if (allZeroOffsets && antennaSeparation > 1e-6 && plan.channels.size() > 1) { + const double totalWidth = (plan.channels.size() - 1) * antennaSeparation; + const double startX = -totalWidth / 2.0; + for (int i = 0; i < xOffsets.size(); ++i) { + xOffsets[i] = static_cast(startX + i * antennaSeparation); + plan.channels[i].xOffset = xOffsets[i]; + } + qDebug() << "Computed symmetric X offsets from antenna separation:" << antennaSeparation; + } + + plan.channelCount = plan.channels.size(); + plan.tracesPerChannel = static_cast(minTraceCount); + plan.samplesPerTrace = masterHeader.samplesPerTrace; + plan.traceByteSize = plan.samplesPerTrace * static_cast(sizeof(qint16)); + plan.totalOutputTraces = static_cast(plan.tracesPerChannel) * plan.channelCount; + plan.outputHeader = masterHeader; + plan.outputHeader.numberOfChannels = plan.channelCount; + plan.outputHeader.numTraces = static_cast(plan.totalOutputTraces); + plan.outputHeader.chXOffsets = xOffsets; + plan.outputHeader.chYOffsets = yOffsets; + if (plan.outputHeader.timeWindowNs <= 0.0 && plan.outputHeader.timeIntervalNs > 0.0) { + plan.outputHeader.timeWindowNs = plan.outputHeader.samplesPerTrace * plan.outputHeader.timeIntervalNs; + } + + const QString basePath = dir.absoluteFilePath(baseName + QStringLiteral("_mala_converted")); + plan.outputRadPath = basePath + QStringLiteral(".rad"); + plan.outputRd3Path = basePath + QStringLiteral(".rd3"); + return true; +} + +bool ImpulseMultiChannelConverter::convertStreaming(const ConversionPlan &plan, + const Options &options, + QString *radFilePath, + QString *errorMessage, + CancelFn cancel, + ProgressFn progress) +{ + if (plan.channelCount <= 0 || plan.tracesPerChannel <= 0 || plan.traceByteSize <= 0) { + if (errorMessage) + *errorMessage = QStringLiteral("Impulse 转换计划无效"); + return false; + } + + if (options.reuseExistingIfValid && convertedFilesAreValid(plan)) { + if (radFilePath) + *radFilePath = plan.outputRadPath; + return true; + } + + if (!options.overwriteExisting && (QFile::exists(plan.outputRadPath) || QFile::exists(plan.outputRd3Path))) { + if (errorMessage) + *errorMessage = QStringLiteral("转换输出文件已存在:%1").arg(plan.outputRadPath); + return false; + } + + const QString tmpRd3Path = plan.outputRd3Path + QStringLiteral(".tmp"); + const QString tmpRadPath = plan.outputRadPath + QStringLiteral(".tmp"); + QFile::remove(tmpRd3Path); + QFile::remove(tmpRadPath); + + QVector> inputFiles; + inputFiles.reserve(plan.channels.size()); + for (const ChannelInfo &channel : plan.channels) { + auto file = QSharedPointer::create(channel.iprbPath); + if (!file->open(QIODevice::ReadOnly)) { + if (errorMessage) + *errorMessage = QStringLiteral("无法读取 Impulse 通道数据:%1").arg(channel.iprbPath); + return false; + } + inputFiles.append(file); + } + + QFile rd3File(tmpRd3Path); + if (!rd3File.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (errorMessage) + *errorMessage = QStringLiteral("无法写入转换后的 RD3 文件:%1").arg(tmpRd3Path); + return false; + } + + const qint64 bytesPerTracePosition = plan.traceByteSize * plan.channelCount; + const qint64 chunkBudget = qMax(bytesPerTracePosition, options.maxChunkBytes); + const int chunkTraces = qMax(1, chunkBudget / bytesPerTracePosition); + QVector channelBuffers(plan.channelCount); + + qint64 tracesDone = 0; + while (tracesDone < plan.tracesPerChannel) { + if (cancel && cancel()) { + rd3File.close(); + QFile::remove(tmpRd3Path); + QFile::remove(tmpRadPath); + if (errorMessage) + *errorMessage = QStringLiteral("Impulse 多通道转换已取消"); + return false; + } + + const int currentChunk = qMin(chunkTraces, plan.tracesPerChannel - tracesDone); + const qint64 expectedChannelBytes = currentChunk * plan.traceByteSize; + for (int ch = 0; ch < plan.channelCount; ++ch) { + channelBuffers[ch] = inputFiles[ch]->read(expectedChannelBytes); + if (channelBuffers[ch].size() != expectedChannelBytes) { + rd3File.close(); + QFile::remove(tmpRd3Path); + if (errorMessage) { + *errorMessage = QStringLiteral("读取通道数据不完整:%1").arg(plan.channels[ch].iprbPath); + } + return false; + } + } + + for (int localTrace = 0; localTrace < currentChunk; ++localTrace) { + const qint64 offset = localTrace * plan.traceByteSize; + for (int ch = 0; ch < plan.channelCount; ++ch) { + const qint64 written = rd3File.write(channelBuffers[ch].constData() + offset, plan.traceByteSize); + if (written != plan.traceByteSize) { + rd3File.close(); + QFile::remove(tmpRd3Path); + if (errorMessage) + *errorMessage = QStringLiteral("写入转换后的 RD3 文件失败:%1").arg(tmpRd3Path); + return false; + } + } + } + + tracesDone += currentChunk; + if (progress) { + progress(Progress{tracesDone, plan.tracesPerChannel, + QStringLiteral("正在转换 Impulse 多通道 %1/%2") + .arg(tracesDone) + .arg(plan.tracesPerChannel)}); + } + } + + rd3File.close(); + if (rd3File.error() != QFile::NoError) { + QFile::remove(tmpRd3Path); + if (errorMessage) + *errorMessage = QStringLiteral("保存转换后的 RD3 文件失败:%1").arg(rd3File.errorString()); + return false; + } + + ConversionPlan tmpPlan = plan; + tmpPlan.outputRadPath = tmpRadPath; + if (!writeRadHeader(tmpRadPath, tmpPlan, errorMessage)) { + QFile::remove(tmpRd3Path); + QFile::remove(tmpRadPath); + return false; + } + + QFile::remove(plan.outputRd3Path); + QFile::remove(plan.outputRadPath); + if (!QFile::rename(tmpRd3Path, plan.outputRd3Path)) { + QFile::remove(tmpRd3Path); + QFile::remove(tmpRadPath); + if (errorMessage) + *errorMessage = QStringLiteral("无法替换转换后的 RD3 文件:%1").arg(plan.outputRd3Path); + return false; + } + if (!QFile::rename(tmpRadPath, plan.outputRadPath)) { + QFile::remove(plan.outputRd3Path); + QFile::remove(tmpRadPath); + if (errorMessage) + *errorMessage = QStringLiteral("无法替换转换后的 RAD 文件:%1").arg(plan.outputRadPath); + return false; + } + + if (radFilePath) + *radFilePath = plan.outputRadPath; + + qDebug() << "Impulse multi-channel streaming conversion OK:" << plan.outputRadPath << plan.outputRd3Path; + return true; +} + +bool ImpulseMultiChannelConverter::writeRadHeader(const QString &radFilePath, + const ConversionPlan &plan, + QString *errorMessage) +{ + QFile radFile(radFilePath); + if (!radFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + if (errorMessage) + *errorMessage = QStringLiteral("无法写入转换后的 RAD 文件:%1").arg(radFilePath); + return false; + } + + QTextStream rad(&radFile); + const auto &header = plan.outputHeader; + rad << "# Converted from Impulse multi-channel survey: " << plan.baseName << '\n'; + rad << "SAMPLES: " << header.samplesPerTrace << '\n'; + rad << "LAST TRACE: " << plan.totalOutputTraces << '\n'; + rad << "TIMEWINDOW: " << QLocale::c().toString(header.timeWindowNs, 'g', 12) << '\n'; + rad << "TIME INTERVAL: " << QLocale::c().toString(header.timeIntervalNs, 'g', 12) << '\n'; + rad << "DISTANCE INTERVAL: " << QLocale::c().toString(header.distanceInc, 'g', 12) << '\n'; + rad << "ANTENNAS: " << QLocale::c().toString(header.antennaFreq, 'g', 12) << '\n'; + if (!header.antennaType.isEmpty()) rad << "ANTENNA: " << header.antennaType << '\n'; + if (!header.date.isEmpty()) rad << "DATE: " << header.date << '\n'; + if (!header.timeStr.isEmpty()) rad << "TIME: " << header.timeStr << '\n'; + rad << "NUMBER_OF_CH: " << qMax(1, header.numberOfChannels) << '\n'; + rad << "CH_X_OFFSETS: " << formatFloatList(header.chXOffsets) << '\n'; + rad << "CH_Y_OFFSETS: " << formatFloatList(header.chYOffsets) << '\n'; + if (!header.units.isEmpty()) rad << "UNITS: " << header.units << '\n'; + rad << "START POSITION: " << QLocale::c().toString(header.startPosition, 'g', 12) << '\n'; + const double stopPosition = header.stopPosition > header.startPosition + ? header.stopPosition + : header.startPosition + plan.tracesPerChannel * header.distanceInc; + rad << "STOP POSITION: " << QLocale::c().toString(stopPosition, 'g', 12) << '\n'; + radFile.close(); + return true; +} + +bool ImpulseMultiChannelConverter::convertedFilesAreValid(const ConversionPlan &plan) +{ + QFileInfo radInfo(plan.outputRadPath); + QFileInfo rd3Info(plan.outputRd3Path); + if (!radInfo.exists() || !rd3Info.exists()) + return false; + + const qint64 expectedBytes = plan.totalOutputTraces * plan.traceByteSize; + return rd3Info.size() == expectedBytes; +} + +QString ImpulseMultiChannelConverter::formatFloatList(const QVector &values) +{ + QStringList parts; + parts.reserve(values.size()); + for (float value : values) { + parts.append(QLocale::c().toString(value, 'g', 10)); + } + return parts.join(' '); +} diff --git a/external/gpr3dviewer/ImpulseMultiChannelConverter.h b/external/gpr3dviewer/ImpulseMultiChannelConverter.h new file mode 100644 index 0000000..dde2fa9 --- /dev/null +++ b/external/gpr3dviewer/ImpulseMultiChannelConverter.h @@ -0,0 +1,75 @@ +#ifndef IMPULSEMULTICHANNELCONVERTER_H +#define IMPULSEMULTICHANNELCONVERTER_H + +#include "GPRDataModel.h" + +#include +#include +#include + +class ImpulseMultiChannelConverter { +public: + struct ChannelInfo { + int channelNumber = 0; + QString iprhPath; + QString iprbPath; + GPRDataModel::Header header; + qint64 traceCount = 0; + float xOffset = 0.0f; + float yOffset = 0.0f; + }; + + struct ConversionPlan { + QString dirPath; + QString baseName; + QVector channels; + GPRDataModel::Header outputHeader; + int channelCount = 0; + int tracesPerChannel = 0; + qint64 totalOutputTraces = 0; + qint64 samplesPerTrace = 0; + qint64 traceByteSize = 0; + QString outputRadPath; + QString outputRd3Path; + }; + + struct Options { + qint64 maxChunkBytes = 32 * 1024 * 1024; + bool overwriteExisting = true; + bool reuseExistingIfValid = false; + }; + + struct Progress { + qint64 tracesDone = 0; + qint64 tracesTotal = 0; + QString message; + }; + + using CancelFn = std::function; + using ProgressFn = std::function; + + static bool isMultiChannelImpulseHeader(const QString &headerPath, + QString *dirPath = nullptr, + QString *baseName = nullptr); + + static bool buildPlan(const QString &dirPath, + const QString &baseName, + ConversionPlan &plan, + QString *errorMessage = nullptr); + + static bool convertStreaming(const ConversionPlan &plan, + const Options &options, + QString *radFilePath = nullptr, + QString *errorMessage = nullptr, + CancelFn cancel = {}, + ProgressFn progress = {}); + +private: + static bool writeRadHeader(const QString &radFilePath, + const ConversionPlan &plan, + QString *errorMessage); + static bool convertedFilesAreValid(const ConversionPlan &plan); + static QString formatFloatList(const QVector &values); +}; + +#endif // IMPULSEMULTICHANNELCONVERTER_H diff --git a/external/gpr3dviewer/IprhParser.cpp b/external/gpr3dviewer/IprhParser.cpp new file mode 100644 index 0000000..608f6ca --- /dev/null +++ b/external/gpr3dviewer/IprhParser.cpp @@ -0,0 +1,625 @@ +#include "IprhParser.h" +#include "PerformanceLogger.h" + +#include "ImpulseMultiChannelConverter.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief 加载 Impulse 系列雷达 IPRH 数据(iprh头文件 + iprb纯二进制波形) + * @param iprhFilePath .iprh 文本配置头文件路径 + * @param model 输出 GPR 全局数据模型 + * @return true 加载解析成功;false 失败 + * @note 存储结构:.iprh 存储全部仪器/测线参数;.iprb 无文件头,从头到尾全是 short16 振幅采样 + */ +bool IprhParser::loadFromIprh(const QString &iprhFilePath, GPRDataModel &model) { + SCOPED_PERF_TIMER("Parser", "IprhParser::loadFromIprh"); + + model.clear(); + model.header = GPRDataModel::Header{}; + + // 第一步:解析 iprh 文本头 + if (!parseIprhHeader(iprhFilePath, model.header)) { + qDebug() << "Error: Failed to parse .iprh header file:" << iprhFilePath; + return false; + } + + // 自动匹配同目录同名二进制数据文件 .iprb + QFileInfo iprhInfo(iprhFilePath); + QString iprbPath = iprhInfo.absolutePath() + "/" + iprhInfo.completeBaseName() + ".iprb"; + + if (!QFile::exists(iprbPath)) { + qDebug() << "Error: Matching .iprb binary file not found at:" << iprbPath; + return false; + } + + // 如果头文件没有 LAST TRACE,从二进制文件大小推算 + if (model.header.numTraces <= 0) { + QFile binaryFile(iprbPath); + if (binaryFile.open(QIODevice::ReadOnly)) { + qint64 fileSize = binaryFile.size(); + qint64 traceBytes = static_cast(model.header.samplesPerTrace) * sizeof(short); + if (traceBytes > 0) { + model.header.numTraces = static_cast(fileSize / traceBytes); + qDebug() << "Inferred numTraces from binary size:" << model.header.numTraces; + } + binaryFile.close(); + } + } + if (model.header.timeWindowNs <= 0.0 && model.header.timeIntervalNs > 0.0) { + model.header.timeWindowNs = model.header.samplesPerTrace * model.header.timeIntervalNs; + qDebug() << "Inferred timeWindowNs from samples * timeInterval:" << model.header.timeWindowNs; + } + + if (model.header.numTraces <= 0) { + qDebug() << "Error: Unable to determine numTraces from header or binary file"; + return false; + } + + // 第二步:读取纯二进制波形 + return loadIprbBinary(iprbPath, model); +} + +/** + * @brief 解析 IPRH 文本头文件,提取雷达采集关键参数 + * @param iprhFilePath iprh 文本文件路径 + * @param header 待填充头部参数结构体 + * @return 解析成功返回 true + */ +bool IprhParser::parseHeaderOnly(const QString &iprhFilePath, GPRDataModel::Header &header) +{ + return parseIprhHeader(iprhFilePath, header); +} + +bool IprhParser::parseIprhHeader(const QString &iprhFilePath, GPRDataModel::Header &header) { + SCOPED_PERF_TIMER("Parser", "IprhParser::parseIprhHeader"); + + QFile file(iprhFilePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "Error: Cannot open iprh file for read"; + return false; + } + + QTextStream in(&file); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty()) continue; + + int sepPos = line.indexOf(':'); + if (sepPos == -1) continue; + + QString key = line.left(sepPos).trimmed(); + QString value = line.mid(sepPos + 1).trimmed(); + + header.rawParams[key] = value; + + if (key == "SAMPLES") { + header.samplesPerTrace = extractInt(value); + } else if (key == "LAST TRACE") { + header.numTraces = extractInt(value); + } else if (key == "TIMEWINDOW") { + header.timeWindowNs = extractDouble(value); + } else if (key == "DISTANCE INTERVAL") { + header.distanceInc = extractDouble(value); + } else if (key == "TIME INTERVAL") { + header.timeIntervalNs = extractDouble(value); + } else if (key == "ANTENNAS") { + header.antennaFreq = extractDouble(value); + } else if (key == "ANTENNA") { + header.antennaType = value; + } else if (key == "DATE") { + header.date = value; + } else if (key == "START TIME" || key == "TIME") { + header.timeStr = value; + } else if (key == "CHANNELS" || key == "NUMBER_OF_CH") { + header.numberOfChannels = extractInt(value); + } else if (key == "CH_X_OFFSET" || key == "CH_OFFSET_X") { + // 单通道偏移量(Impulse 单通道文件) + if (!value.isEmpty()) { + header.chXOffsets.append(value.toFloat()); + } + } else if (key == "CH_Y_OFFSET" || key == "CH_OFFSET_Y") { + if (!value.isEmpty()) { + header.chYOffsets.append(value.toFloat()); + } + } else if (key == "CH_X_OFFSETS") { + // 兼容 Mala Mira 风格多值空格分隔 + QStringList offsets = value.split(' ', Qt::SkipEmptyParts); + for (const QString &offset : offsets) { + header.chXOffsets.append(offset.toFloat()); + } + } else if (key == "CH_Y_OFFSETS") { + QStringList offsets = value.split(' ', Qt::SkipEmptyParts); + for (const QString &offset : offsets) { + header.chYOffsets.append(offset.toFloat()); + } + } else if (key == "UNITS") { + header.units = value; + } else if (key == "START POSITION") { + header.startPosition = extractDouble(value); + } else if (key == "STOP POSITION") { + header.stopPosition = extractDouble(value); + } + } + + file.close(); + + if (header.samplesPerTrace <= 0) { + qDebug() << "Error: Invalid SAMPLES value in iprh file"; + return false; + } + + header.waveVelocity = 0.1; + + qDebug() << "==== IPRH Header Parse Complete ====" + << "\nSamples per trace:" << header.samplesPerTrace + << "\nTotal traces:" << header.numTraces + << "\nTime window(ns):" << header.timeWindowNs + << "\nChannel count:" << header.numberOfChannels + << "\nX offset array size:" << header.chXOffsets.size() + << "\nY offset array size:" << header.chYOffsets.size(); + + return true; +} + +/** + * @brief 读取纯 IPRB 二进制数据(无任何文件头,全连续 short16 振幅) + * @param iprbFilePath iprb 波形文件路径 + * @param model 绑定头部参数,填充完整 traces 波形数组 + * @return 读取成功 true + */ +bool IprhParser::loadIprbBinary(const QString &iprbFilePath, GPRDataModel &model) { + SCOPED_PERF_TIMER("Parser", "IprhParser::loadIprbBinary"); + + QFile file(iprbFilePath); + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Error: Open iprb binary failed:" << iprbFilePath; + return false; + } + + const int samplesPerTrace = model.header.samplesPerTrace; + const int totalTraceCount = model.header.numTraces; + + const qint64 dataStartOffset = 0; + file.seek(dataStartOffset); + + const qint64 singleTraceByteSize = samplesPerTrace * sizeof(short); + const qint64 fullExpectedBytes = totalTraceCount * singleTraceByteSize; + const qint64 realFileBytes = file.size(); + + if (realFileBytes < fullExpectedBytes) { + qDebug() << "Warning: IPRB file size smaller than theoretical data size!" + << "Expected:" << fullExpectedBytes << "Actual:" << realFileBytes; + } + + model.traces.reserve(totalTraceCount); + + QByteArray traceBuffer; + traceBuffer.resize(singleTraceByteSize); + + const int channelCnt = model.header.numberOfChannels > 0 ? model.header.numberOfChannels : 1; + model.channels = channelCnt; + model.tracesPerChannel = totalTraceCount / channelCnt; + + if (model.header.distanceInc > 1e-6) { + model.totalDistance = static_cast(model.tracesPerChannel * model.header.distanceInc); + } else { + model.totalDistance = static_cast(model.header.stopPosition - model.header.startPosition); + } + + for (int traceGlobalIdx = 0; traceGlobalIdx < totalTraceCount; ++traceGlobalIdx) { + if (file.atEnd()) { + qDebug() << "Warning: File ended early at global trace index" << traceGlobalIdx; + break; + } + + qint64 readBytes = file.read(traceBuffer.data(), traceBuffer.size()); + if (readBytes != traceBuffer.size()) { + qDebug() << "Warning: Trace" << traceGlobalIdx << "incomplete byte read"; + break; + } + + RadarTrace oneTrace; + oneTrace.amplitudes.resize(samplesPerTrace); + const short* rawShortBuf = reinterpret_cast(traceBuffer.constData()); + + for (int s = 0; s < samplesPerTrace; s++) { + oneTrace.amplitudes[s] = rawShortBuf[s]; + } + + int chNo = traceGlobalIdx % channelCnt; + int traceInChIdx = traceGlobalIdx / channelCnt; + oneTrace.channelNumber = chNo; + + float xOff = 0.0f; + float yOff = 0.0f; + if (chNo < model.header.chXOffsets.size()) xOff = model.header.chXOffsets[chNo]; + if (chNo < model.header.chYOffsets.size()) yOff = model.header.chYOffsets[chNo]; + + float lineDist = static_cast(model.header.startPosition + traceInChIdx * model.header.distanceInc); + oneTrace.position = QVector3D(xOff, lineDist - yOff, 0.0f); + + model.traces.append(std::move(oneTrace)); + } + + file.close(); + + if (model.traces.isEmpty()) { + qDebug() << "Error: No valid traces loaded from iprb"; + return false; + } + + qDebug() << "IPRB Binary Load OK, total valid traces:" << model.traces.size(); + return true; +} + +/** + * @brief 加载 Impulse 多通道数据(每个通道一个 .iprh + .iprb) + * @param dirPath 数据所在目录 + * @param baseName 测线基础名(如 "明星路_001") + * @param model 输出合并后的 GPR 数据模型 + * @return true 加载合并成功 + * + * 文件命名约定:baseName_A01.iprh / .iprb ... baseName_A14.iprh / .iprb + * 合并后 traces 按 Mala Mira 格式交错:trace0=ch0_pos0, trace1=ch1_pos0, ... + */ +bool IprhParser::convertImpulseMultiChannelToMala(const QString &dirPath, + const QString &baseName, + QString *radFilePath, + QString *errorMessage) +{ + ImpulseMultiChannelConverter::ConversionPlan plan; + if (!ImpulseMultiChannelConverter::buildPlan(dirPath, baseName, plan, errorMessage)) { + if (errorMessage && errorMessage->isEmpty()) { + *errorMessage = QStringLiteral("多通道 Impulse 数据合并失败:%1").arg(baseName); + } + return false; + } + + ImpulseMultiChannelConverter::Options options; + options.overwriteExisting = true; + options.reuseExistingIfValid = false; + if (!ImpulseMultiChannelConverter::convertStreaming(plan, options, radFilePath, errorMessage)) { + return false; + } + + qDebug() << "Impulse multi-channel converted to Mala Mira files:" << plan.outputRadPath << plan.outputRd3Path; + return true; +} + +/** + * @brief 加载 Impulse 多通道数据(每个通道一个 .iprh + .iprb) + * @param dirPath 数据所在目录 + * @param baseName 测线基础名(如 "明星路_001") + * @param model 输出合并后的 GPR 数据模型 + * @return true 加载合并成功 + * + * 文件命名约定:baseName_A01.iprh / .iprb ... baseName_A14.iprh / .iprb + * 合并后 traces 按 Mala Mira 格式交错:trace0=ch0_pos0, trace1=ch1_pos0, ... + */ +bool IprhParser::loadImpulseMultiChannel(const QString &dirPath, + const QString &baseName, + GPRDataModel &model) +{ + SCOPED_PERF_TIMER("Parser", "IprhParser::loadImpulseMultiChannel"); + + model.clear(); + model.header = GPRDataModel::Header{}; + + // 1. 发现所有通道文件并排序 + QDir dir(dirPath); + dir.setFilter(QDir::Files | QDir::NoDotAndDotDot); + + QRegularExpression reChannel(QStringLiteral("^%1_A(\\d+)\\.iprh$").arg(QRegularExpression::escape(baseName))); + QMap channelHeaders; // channelNum -> iprh path + + for (const QFileInfo &fi : dir.entryInfoList()) { + QRegularExpressionMatch match = reChannel.match(fi.fileName()); + if (match.hasMatch()) { + int chNum = match.captured(1).toInt(); + QString iprhPath = fi.absoluteFilePath(); + QString iprbPath = iprhPath; + iprbPath.replace(".iprh", ".iprb"); + if (QFile::exists(iprbPath)) { + channelHeaders.insert(chNum, iprhPath); + } else { + qDebug() << "Missing .iprb for" << iprhPath; + } + } + } + + if (channelHeaders.isEmpty()) { + qDebug() << "No multi-channel .iprh/.iprb found for baseName:" << baseName; + return false; + } + + const int channelCount = channelHeaders.size(); + qDebug() << "Impulse multi-channel: found" << channelCount << "channels for" << baseName; + + // 2. 解析第一个通道的头文件作为 master + auto it = channelHeaders.begin(); + GPRDataModel::Header masterHeader; + if (!parseIprhHeader(it.value(), masterHeader)) { + qDebug() << "Failed to parse master header:" << it.value(); + return false; + } + + QVector xOffsets; + QVector yOffsets; + xOffsets.reserve(channelCount); + yOffsets.reserve(channelCount); + + // 3. 验证各通道一致性并收集偏移量 + QVector channelTracesPerChannel; + channelTracesPerChannel.reserve(channelCount); + + double antennaSeparation = 0.0; + + for (auto cit = channelHeaders.begin(); cit != channelHeaders.end(); ++cit) { + int chNum = cit.key(); + QString iprhPath = cit.value(); + + GPRDataModel::Header chHeader; + if (!parseIprhHeader(iprhPath, chHeader)) { + qDebug() << "Failed to parse channel header:" << iprhPath; + return false; + } + + // 验证关键参数一致性 + if (chHeader.samplesPerTrace != masterHeader.samplesPerTrace) { + qDebug() << "Inconsistent SAMPLES across channels"; + return false; + } + if (qFuzzyCompare(chHeader.timeIntervalNs, masterHeader.timeIntervalNs) == false && + chHeader.timeIntervalNs > 0 && masterHeader.timeIntervalNs > 0) { + qDebug() << "Warning: Inconsistent TIME INTERVAL across channels"; + } + if (qFuzzyCompare(chHeader.distanceInc, masterHeader.distanceInc) == false && + chHeader.distanceInc > 0 && masterHeader.distanceInc > 0) { + qDebug() << "Warning: Inconsistent DISTANCE INTERVAL across channels"; + } + + // 收集单通道偏移量 + float xOff = 0.0f, yOff = 0.0f; + if (chHeader.rawParams.contains("CH_X_OFFSET")) { + xOff = chHeader.rawParams.value("CH_X_OFFSET").toFloat(); + } else if (chHeader.rawParams.contains("CH_OFFSET_X")) { + xOff = chHeader.rawParams.value("CH_OFFSET_X").toFloat(); + } else if (!chHeader.chXOffsets.isEmpty()) { + xOff = chHeader.chXOffsets.first(); + } + if (chHeader.rawParams.contains("CH_Y_OFFSET")) { + yOff = chHeader.rawParams.value("CH_Y_OFFSET").toFloat(); + } else if (chHeader.rawParams.contains("CH_OFFSET_Y")) { + yOff = chHeader.rawParams.value("CH_OFFSET_Y").toFloat(); + } else if (!chHeader.chYOffsets.isEmpty()) { + yOff = chHeader.chYOffsets.first(); + } + xOffsets.append(xOff); + yOffsets.append(yOff); + + if (chHeader.rawParams.contains("ANTENNA SEPARATION")) { + antennaSeparation = chHeader.rawParams.value("ANTENNA SEPARATION").toDouble(); + } + + // 从 .iprb 大小计算该通道的道数 + QString iprbPath = iprhPath; + iprbPath.replace(".iprh", ".iprb"); + QFile iprbFile(iprbPath); + int tracesInThisChannel = 0; + if (iprbFile.open(QIODevice::ReadOnly)) { + qint64 fileSize = iprbFile.size(); + qint64 traceBytes = static_cast(chHeader.samplesPerTrace) * sizeof(short); + if (traceBytes > 0) { + tracesInThisChannel = static_cast(fileSize / traceBytes); + } + iprbFile.close(); + } + channelTracesPerChannel.append(tracesInThisChannel); + } + + // 4. 以最小道数为准截断读取,容忍各通道微小差异 + int tracesPerChannel = channelTracesPerChannel.isEmpty() ? 0 : *std::min_element(channelTracesPerChannel.begin(), channelTracesPerChannel.end()); + for (int tc : channelTracesPerChannel) { + if (tc != tracesPerChannel) { + qDebug() << "Warning: Inconsistent trace count across channels. Using minimum:" << tracesPerChannel << "channel has:" << tc; + } + } + if (tracesPerChannel <= 0) { + qDebug() << "Error: No valid traces in any channel"; + return false; + } + + // 5. 如果偏移量全部为零,基于 ANTENNA SEPARATION 计算对称分布 + bool allZeroOffsets = true; + for (float v : xOffsets) { if (qFuzzyIsNull(v) == false) { allZeroOffsets = false; break; } } + if (allZeroOffsets && antennaSeparation > 1e-6 && channelCount > 1) { + double totalWidth = (channelCount - 1) * antennaSeparation; + double startX = -totalWidth / 2.0; + for (int i = 0; i < channelCount; ++i) { + xOffsets[i] = static_cast(startX + i * antennaSeparation); + } + qDebug() << "Computed symmetric X offsets from antenna separation:" << antennaSeparation; + } + + // 6. 组装合并后的 Header + model.header = masterHeader; + model.header.numberOfChannels = channelCount; + model.header.numTraces = tracesPerChannel * channelCount; + model.header.chXOffsets = xOffsets; + model.header.chYOffsets = yOffsets; + if (model.header.timeWindowNs <= 0.0 && model.header.timeIntervalNs > 0.0) { + model.header.timeWindowNs = model.header.samplesPerTrace * model.header.timeIntervalNs; + } + + // 7. 预分配 traces + model.traces.reserve(model.header.numTraces); + + // 8. 读取每个通道的 .iprb 到临时数组 + QVector> channelTraceArrays; + channelTraceArrays.resize(channelCount); + + int chIdx = 0; + for (auto cit = channelHeaders.begin(); cit != channelHeaders.end(); ++cit, ++chIdx) { + QString iprhPath = cit.value(); + QString iprbPath = iprhPath; + iprbPath.replace(".iprh", ".iprb"); + + QFile file(iprbPath); + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Error: Cannot open" << iprbPath; + return false; + } + + const int samplesPerTrace = model.header.samplesPerTrace; + const qint64 singleTraceByteSize = samplesPerTrace * sizeof(short); + QByteArray traceBuffer; + traceBuffer.resize(singleTraceByteSize); + + channelTraceArrays[chIdx].reserve(tracesPerChannel); + + for (int t = 0; t < tracesPerChannel; ++t) { + if (file.atEnd()) break; + qint64 readBytes = file.read(traceBuffer.data(), traceBuffer.size()); + if (readBytes != traceBuffer.size()) break; + + RadarTrace oneTrace; + oneTrace.amplitudes.resize(samplesPerTrace); + const short* rawShortBuf = reinterpret_cast(traceBuffer.constData()); + for (int s = 0; s < samplesPerTrace; s++) { + oneTrace.amplitudes[s] = rawShortBuf[s]; + } + channelTraceArrays[chIdx].append(std::move(oneTrace)); + } + file.close(); + + if (channelTraceArrays[chIdx].size() != tracesPerChannel) { + qDebug() << "Warning: Channel" << cit.key() << "has fewer traces than expected"; + } + } + + // 9. 按 Mala Mira 格式交错合并 + model.channels = channelCount; + model.tracesPerChannel = tracesPerChannel; + for (int pos = 0; pos < tracesPerChannel; ++pos) { + for (int ch = 0; ch < channelCount; ++ch) { + if (pos < channelTraceArrays[ch].size()) { + RadarTrace trace = std::move(channelTraceArrays[ch][pos]); + trace.channelNumber = ch; + float xOff = (ch < xOffsets.size()) ? xOffsets[ch] : 0.0f; + float yOff = (ch < yOffsets.size()) ? yOffsets[ch] : 0.0f; + float lineDist = static_cast(model.header.startPosition + pos * model.header.distanceInc); + trace.position = QVector3D(xOff, lineDist - yOff, 0.0f); + model.traces.append(std::move(trace)); + } + } + } + + if (model.header.distanceInc > 1e-6) { + model.totalDistance = static_cast(model.tracesPerChannel * model.header.distanceInc); + } else { + model.totalDistance = static_cast(model.header.stopPosition - model.header.startPosition); + } + + qDebug() << "Impulse multi-channel load OK. Total traces:" << model.traces.size() + << "Channels:" << channelCount << "Traces/Channel:" << tracesPerChannel; + return !model.traces.isEmpty(); +} + +bool IprhParser::writeMalaFiles(const QString &radFilePath, + const QString &rd3FilePath, + const GPRDataModel &model, + const QString &sourceBaseName, + QString *errorMessage) +{ + QFile rd3File(rd3FilePath); + if (!rd3File.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + if (errorMessage) { + *errorMessage = QStringLiteral("无法写入转换后的 RD3 文件:%1").arg(rd3FilePath); + } + return false; + } + + QDataStream out(&rd3File); + out.setByteOrder(QDataStream::LittleEndian); + const int samplesPerTrace = model.header.samplesPerTrace; + for (const RadarTrace &trace : model.traces) { + for (int s = 0; s < samplesPerTrace; ++s) { + const qint16 sample = static_cast(s < trace.amplitudes.size() ? trace.amplitudes[s] : 0); + out << sample; + } + } + rd3File.close(); + + QFile radFile(radFilePath); + if (!radFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + if (errorMessage) { + *errorMessage = QStringLiteral("无法写入转换后的 RAD 文件:%1").arg(radFilePath); + } + QFile::remove(rd3FilePath); + return false; + } + + QTextStream rad(&radFile); + rad << "# Converted from Impulse multi-channel survey: " << sourceBaseName << '\n'; + rad << "SAMPLES: " << model.header.samplesPerTrace << '\n'; + rad << "LAST TRACE: " << model.traces.size() << '\n'; + rad << "TIMEWINDOW: " << QLocale::c().toString(model.header.timeWindowNs, 'g', 12) << '\n'; + rad << "TIME INTERVAL: " << QLocale::c().toString(model.header.timeIntervalNs, 'g', 12) << '\n'; + rad << "DISTANCE INTERVAL: " << QLocale::c().toString(model.header.distanceInc, 'g', 12) << '\n'; + rad << "ANTENNAS: " << QLocale::c().toString(model.header.antennaFreq, 'g', 12) << '\n'; + if (!model.header.antennaType.isEmpty()) rad << "ANTENNA: " << model.header.antennaType << '\n'; + if (!model.header.date.isEmpty()) rad << "DATE: " << model.header.date << '\n'; + if (!model.header.timeStr.isEmpty()) rad << "TIME: " << model.header.timeStr << '\n'; + rad << "NUMBER_OF_CH: " << qMax(1, model.header.numberOfChannels) << '\n'; + rad << "CH_X_OFFSETS: " << formatFloatList(model.header.chXOffsets) << '\n'; + rad << "CH_Y_OFFSETS: " << formatFloatList(model.header.chYOffsets) << '\n'; + if (!model.header.units.isEmpty()) rad << "UNITS: " << model.header.units << '\n'; + rad << "START POSITION: " << QLocale::c().toString(model.header.startPosition, 'g', 12) << '\n'; + const double stopPosition = model.header.stopPosition > model.header.startPosition + ? model.header.stopPosition + : model.header.startPosition + model.tracesPerChannel * model.header.distanceInc; + rad << "STOP POSITION: " << QLocale::c().toString(stopPosition, 'g', 12) << '\n'; + radFile.close(); + + return true; +} + +QString IprhParser::formatFloatList(const QVector &values) +{ + QStringList parts; + parts.reserve(values.size()); + for (float value : values) { + parts.append(QLocale::c().toString(value, 'g', 10)); + } + return parts.join(' '); +} + +QString IprhParser::uniqueConvertedBasePath(const QString &dirPath, const QString &baseName) +{ + return QDir(dirPath).absoluteFilePath(baseName + QStringLiteral("_mala_converted")); +} + +double IprhParser::extractDouble(const QString &value) { + bool ok = false; + double res = value.toDouble(&ok); + return ok ? res : 0.0; +} + +int IprhParser::extractInt(const QString &value) { + bool ok = false; + int res = value.toInt(&ok); + return ok ? res : 0; +} diff --git a/external/gpr3dviewer/IprhParser.h b/external/gpr3dviewer/IprhParser.h new file mode 100644 index 0000000..7ae7add --- /dev/null +++ b/external/gpr3dviewer/IprhParser.h @@ -0,0 +1,41 @@ +#ifndef IPRHPARSER_H +#define IPRHPARSER_H + +#include "GPRDataModel.h" +#include +#include + +class IprhParser { +public: + // 主入口:传入 .iprh 文件路径,自动寻找同名的 .iprb 文件 + static bool loadFromIprh(const QString &iprhFilePath, GPRDataModel &model); + + // 多通道 Impulse 数据合并入口:一个文件夹下有多个 _A01.iprh/.iprb ... _A14.iprh/.iprb + static bool loadImpulseMultiChannel(const QString &dirPath, + const QString &baseName, + GPRDataModel &model); + + // 将多通道 Impulse 数据转换为本地 Mala Mira 风格 .rad/.rd3,再走现有 Mala Mira 解析流程 + static bool convertImpulseMultiChannelToMala(const QString &dirPath, + const QString &baseName, + QString *radFilePath, + QString *errorMessage = nullptr); + + static bool parseHeaderOnly(const QString &iprhFilePath, GPRDataModel::Header &header); + +private: + static bool parseIprhHeader(const QString &iprhFilePath, GPRDataModel::Header &header); + static bool loadIprbBinary(const QString &iprbFilePath, GPRDataModel &model); + static bool writeMalaFiles(const QString &radFilePath, + const QString &rd3FilePath, + const GPRDataModel &model, + const QString &sourceBaseName, + QString *errorMessage); + static QString formatFloatList(const QVector &values); + static QString uniqueConvertedBasePath(const QString &dirPath, const QString &baseName); + + static double extractDouble(const QString &value); + static int extractInt(const QString &value); +}; + +#endif // IPRHPARSER_H diff --git a/external/gpr3dviewer/PerformanceLogger.cpp b/external/gpr3dviewer/PerformanceLogger.cpp new file mode 100644 index 0000000..4c75e71 --- /dev/null +++ b/external/gpr3dviewer/PerformanceLogger.cpp @@ -0,0 +1,122 @@ +#include "PerformanceLogger.h" + +PerformanceLogger &PerformanceLogger::instance() +{ + static PerformanceLogger logger; + return logger; +} + +void PerformanceLogger::beginSession(const QString &sessionName) +{ + QMutexLocker locker(&m_mutex); + m_sessionName = sessionName; + m_sessionActive = true; +} + +void PerformanceLogger::endSession() +{ + QMutexLocker locker(&m_mutex); + m_sessionActive = false; +} + +void PerformanceLogger::log(const QString &category, const QString &name, qint64 elapsedMs) +{ + QMutexLocker locker(&m_mutex); + Record rec; + rec.category = category; + rec.name = name; + rec.elapsedMs = elapsedMs; + rec.timestamp = QDateTime::currentDateTime(); + m_records.append(rec); +} + +QVector PerformanceLogger::records() const +{ + QMutexLocker locker(&m_mutex); + return m_records; +} + +void PerformanceLogger::clear() +{ + QMutexLocker locker(&m_mutex); + m_records.clear(); +} + +QString PerformanceLogger::reportString() const +{ + QMutexLocker locker(&m_mutex); + QString report; + QTextStream ts(&report); + + ts << "==================================================\n"; + ts << "Performance Report"; + if (!m_sessionName.isEmpty()) + ts << " - " << m_sessionName; + ts << "\n"; + ts << "Generated: " << QDateTime::currentDateTime().toString("yyyy-MM-dd hh:mm:ss") << "\n"; + ts << "==================================================\n"; + + if (m_records.isEmpty()) { + ts << "No records.\n"; + return report; + } + + // Group by category + QString currentCategory; + qint64 categoryTotal = 0; + for (const auto &rec : m_records) { + if (rec.category != currentCategory) { + if (!currentCategory.isEmpty()) { + ts << " [Category Total: " << categoryTotal << " ms]\n\n"; + } + currentCategory = rec.category; + categoryTotal = 0; + ts << "[" << currentCategory << "]\n"; + } + ts << " " << rec.name << ": " << rec.elapsedMs << " ms\n"; + categoryTotal += rec.elapsedMs; + } + if (!currentCategory.isEmpty()) { + ts << " [Category Total: " << categoryTotal << " ms]\n"; + } + + ts << "\n==================================================\n"; + ts << "Grand Total: "; + qint64 grandTotal = 0; + for (const auto &rec : m_records) + grandTotal += rec.elapsedMs; + ts << grandTotal << " ms\n"; + ts << "==================================================\n"; + + return report; +} + +void PerformanceLogger::printReport() const +{ + const QString report = reportString(); + qDebug().noquote() << report; +} + +void PerformanceLogger::saveToFile(const QString &filePath) const +{ + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { + qDebug() << "PerformanceLogger: failed to open" << filePath; + return; + } + QTextStream ts(&file); + ts << reportString(); + file.close(); +} + +ScopedPerfTimer::ScopedPerfTimer(const QString &category, const QString &name) + : m_category(category), m_name(name) +{ + m_timer.start(); +} + +ScopedPerfTimer::~ScopedPerfTimer() +{ + const qint64 elapsed = m_timer.elapsed(); + PerformanceLogger::instance().log(m_category, m_name, elapsed); +} diff --git a/external/gpr3dviewer/PerformanceLogger.h b/external/gpr3dviewer/PerformanceLogger.h new file mode 100644 index 0000000..fbb237c --- /dev/null +++ b/external/gpr3dviewer/PerformanceLogger.h @@ -0,0 +1,60 @@ +#ifndef PERFORMANCELOGGER_H +#define PERFORMANCELOGGER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class PerformanceLogger +{ +public: + struct Record + { + QString category; + QString name; + qint64 elapsedMs = 0; + QDateTime timestamp; + }; + + static PerformanceLogger &instance(); + + void beginSession(const QString &sessionName = QString()); + void endSession(); + + void log(const QString &category, const QString &name, qint64 elapsedMs); + void printReport() const; + void saveToFile(const QString &filePath) const; + + QString reportString() const; + QVector records() const; + void clear(); + +private: + PerformanceLogger() = default; + mutable QMutex m_mutex; + QVector m_records; + QString m_sessionName; + bool m_sessionActive = false; +}; + +class ScopedPerfTimer +{ +public: + ScopedPerfTimer(const QString &category, const QString &name); + ~ScopedPerfTimer(); + +private: + QString m_category; + QString m_name; + QElapsedTimer m_timer; +}; + +#define SCOPED_PERF_TIMER(category, name) ScopedPerfTimer _perfTimer(category, name) + +#endif // PERFORMANCELOGGER_H diff --git a/external/gpr3dviewer/PosParser.cpp b/external/gpr3dviewer/PosParser.cpp new file mode 100644 index 0000000..0538978 --- /dev/null +++ b/external/gpr3dviewer/PosParser.cpp @@ -0,0 +1,73 @@ +#include "PosParser.h" +#include +#include +#include +#include + +bool PosParser::loadPosFile(const QString &posFilePath, QVector &outPositions) { + outPositions.clear(); + + QFile file(posFilePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "PosParser: Cannot open pos file:" << posFilePath; + return false; + } + + QTextStream in(&file); + QRegularExpression reLineComment("^\\s*%"); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty()) continue; + if (reLineComment.match(line).hasMatch()) continue; // 跳过注释行 + + QStringList parts = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() < 4) continue; + + bool okX = false, okY = false, okZ = false; + // 列顺序: 道号(忽略) X Y Z + double x = parts[1].toDouble(&okX); + double y = parts[2].toDouble(&okY); + double z = parts[3].toDouble(&okZ); + + if (okX && okY && okZ) { + outPositions.append(QVector3D(static_cast(x), static_cast(y), static_cast(z))); + } + } + + file.close(); + qDebug() << "PosParser: Loaded" << outPositions.size() << "GPS points from" << posFilePath; + return !outPositions.isEmpty(); +} + +bool PosParser::loadCenterCcc(const QString &cccFilePath, double &outLat, double &outLon) { + QFile file(cccFilePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "PosParser: Cannot open center.ccc:" << cccFilePath; + return false; + } + + QTextStream in(&file); + QRegularExpression reLineComment("^\\s*%"); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty()) continue; + if (reLineComment.match(line).hasMatch()) continue; + + QStringList parts = line.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() < 2) continue; + + bool okLat = false, okLon = false; + outLat = parts[0].toDouble(&okLat); + outLon = parts[1].toDouble(&okLon); + + if (okLat && okLon) { + file.close(); + return true; + } + } + + file.close(); + return false; +} diff --git a/external/gpr3dviewer/PosParser.h b/external/gpr3dviewer/PosParser.h new file mode 100644 index 0000000..cd1353c --- /dev/null +++ b/external/gpr3dviewer/PosParser.h @@ -0,0 +1,17 @@ +#ifndef POSPARSER_H +#define POSPARSER_H + +#include +#include +#include + +class PosParser { +public: + // 加载 .pos 文件(4列:道号 X Y Z) + static bool loadPosFile(const QString &posFilePath, QVector &outPositions); + + // 加载 center.ccc 项目中心坐标(2列:纬度 经度) + static bool loadCenterCcc(const QString &cccFilePath, double &outLat, double &outLon); +}; + +#endif // POSPARSER_H diff --git a/external/gpr3dviewer/RadarProcessor.cpp b/external/gpr3dviewer/RadarProcessor.cpp new file mode 100644 index 0000000..c809429 --- /dev/null +++ b/external/gpr3dviewer/RadarProcessor.cpp @@ -0,0 +1,1148 @@ +/* + * RadarProcessor.cpp + * GPR探地雷达预处理算法实现源文件 + * 配套头文件 RadarProcessor.h + * 依赖库:kiss_fftr(快速傅里叶变换)、Qt容器、标准数学库 + * 整体流水线顺序:零时校正 → 零漂消除 → 背景压制 → TVG深度增益 → 带通滤波 → 剖面平滑 → 道内AGC → 道间均衡 → 希尔伯特包络/相位 + * 数据存储格式:short 16位整型振幅,全程运算double浮点,截断回short防止溢出 + */ +#include "RadarProcessor.h" +#include "GPRDataModel.h" +#include "PerformanceLogger.h" +#include +#include +#include +#include +#include +#include "kiss_fftr.h" +#include + +// 匿名命名空间:仅内部工具函数,对外不可见 +namespace { + +/** + * @brief 将浮点运算结果限制在short量程 [-32768,32767] + * @param value 运算后浮点振幅值 + * @return 钳位取整后的16位短整型 + */ +short clampToShort(double value) { + if (value > 32767.0) return 32767; + if (value < -32768.0) return -32768; + return static_cast(std::lround(value)); +} + +/** + * @brief 计算单道波形整体RMS均方根振幅 + * @param trace 单道雷达波形数据 + * @return 该道RMS能量值 + */ +double traceRms(const RadarTrace &trace) { + if (trace.amplitudes.isEmpty()) return 0.0; + double sumSquares = 0.0; + for (short amp : trace.amplitudes) { + double v = amp; + sumSquares += v * v; + } + return std::sqrt(sumSquares / trace.amplitudes.size()); +} + +/** + * @brief 全局整份数据所有采样点总RMS + * @param model 完整GPR多道数据集 + * @return 全局平均能量 + */ +double globalRms(const GPRDataModel &model) { + double sumSq = 0.0; + int cnt = 0; + for (const auto& tr : model.traces) { + for (short a : tr.amplitudes) { + double v = a; + sumSq += v * v; + cnt++; + } + } + if (cnt == 0) return 0.0; + return std::sqrt(sumSq / cnt); +} + +/** + * @brief 安全窗口尺寸限制,防止窗口超出数据长度 + * @param req 请求窗口大小 + * @param limit 最大允许长度 + * @return 合法区间[1,limit]内窗口值 + */ +int safeWindowSize(int req, int limit) { + if (limit <= 0) return 0; + return std::clamp(req, 1, limit); +} + +/** + * @brief 单道自动识别直达波零点起跳位置 + * @param trace 单道波形 + * @param frontWindow 仅在道前N个采样内搜索零点 + * @param sigmaMultiple 噪声标准差倍数起跳阈值 + * @return 零点对应的采样下标,-1代表识别失败 + * 逻辑:取道最前端小段计算基线噪声,使用正负极性对称的振幅+梯度条件判定起跳点 + */ +int detectSingleTraceZeroPoint(const RadarTrace &trace, int frontWindow, double sigmaMultiple) +{ + const int sampleCount = trace.amplitudes.size(); + const int searchEnd = std::min(frontWindow, sampleCount); + // 搜索区间太短无法判定基线噪声,直接返回失败 + if (searchEnd < 10) return -1; + + // 取搜索窗前1/4且不少于10点作为安静基线,避免固定10点对噪声估计过敏 + const int baselineLength = std::clamp(searchEnd / 4, 10, std::min(searchEnd, 40)); + double mean = 0.0; + for (int i = 0; i < baselineLength; ++i) { + mean += trace.amplitudes[i]; + } + mean /= baselineLength; + + // 计算基线方差、标准差 + double variance = 0.0; + for (int i = 0; i < baselineLength; ++i) { + const double diff = trace.amplitudes[i] - mean; + variance += diff * diff; + } + const double sigma = std::sqrt(variance / baselineLength); + const double threshold = std::max(1.0, sigmaMultiple * std::max(sigma, 1.0)); + const double gradientThreshold = std::max(1.0, 0.5 * threshold); + + // 逐点扫描梯度+振幅,正负极性直达波均可识别 + for (int sample = 1; sample < searchEnd; ++sample) { + const double current = trace.amplitudes[sample] - mean; + const double previous = trace.amplitudes[sample - 1] - mean; + const double gradient = current - previous; + if (std::abs(gradient) > gradientThreshold && std::abs(current) > threshold) { + return sample; + } + } + return -1; +} + +/** + * @brief 升余弦过渡带频域响应函数(带通滤波器窗函数) + * @param freq 当前频率点Hz + * @param low 通带下限Hz + * @param high 通带上限Hz + * @param transition 高低频过渡带宽Hz + * @return 频率增益系数[0,1] + * 作用:平缓滚降,消除矩形窗吉布斯振荡,滤波波形畸变更小 + */ +double bandpassResponse(double freq, double low, double high, double transition) +{ + // 升余弦渐入:低频侧从阻带到通带平滑抬升 + auto raisedCosineFadeIn = [](double t) { + return 0.5 * (1.0 - std::cos(M_PI * t)); + }; + // 升余弦渐出:高频侧从通带到阻带平滑衰减 + auto raisedCosineFadeOut = [](double t) { + return 0.5 * (1.0 + std::cos(M_PI * t)); + }; + + // 完全阻带,增益0 + if (freq < low - transition || freq > high + transition) return 0.0; + // 完全通带,增益1 + if (freq >= low + transition && freq <= high - transition) return 1.0; + + // 低频过渡区 + if (freq < low + transition) { + double t = (freq - (low - transition)) / (2.0 * transition); + t = std::clamp(t, 0.0, 1.0); + return raisedCosineFadeIn(t); + } + // 高频过渡区 + else { + double t = (freq - (high - transition)) / (2.0 * transition); + t = std::clamp(t, 0.0, 1.0); + return raisedCosineFadeOut(t); + } +} + +} // end 匿名命名空间 + +/** + * @brief 处理器构造函数,无资源初始化 + */ +RadarProcessor::RadarProcessor() +{ +} + +//===================================================================== +// 步骤0:零时自动/手动裁切 +//===================================================================== +/** + * @brief 切除每道零点前无效采样,统一道起始位置 + * @param input 原始输入数据 + * @param param 零时校正配置参数 + * @return 裁切后数据集 + * 自动模式:统计所有道零点中位数作为统一裁切长度;手动模式直接使用配置cutSamples + */ +GPRDataModel RadarProcessor::cutTimeZero(const GPRDataModel& input, const TimeZeroCutParams& param) +{ + SCOPED_PERF_TIMER("Processing", "cutTimeZero"); + // 开关未开启直接返回原数据 + if (!param.enable) return input; + GPRDataModel out = input; + int cutN = std::max(0, param.cutSamples); + + // 自动零点识别模式 + if (param.autoDetect) { + QVector zeroPoints; + zeroPoints.reserve(out.traces.size()); + const int nChannels = std::max(1, out.header.numberOfChannels); + QVector> channelZeroPoints(nChannels); + + // 逐道检测零点,失败值(-1)不参与统计,避免把全局中位数拉向0 + for (int ti = 0; ti < out.traces.size(); ++ti) { + const int zeroPoint = detectSingleTraceZeroPoint(out.traces[ti], param.frontSearchWindow, param.noiseSigmaMultiple); + if (zeroPoint <= 0) continue; + zeroPoints.append(zeroPoint); + channelZeroPoints[ti % nChannels].append(zeroPoint); + } + + QVector channelMedians; + channelMedians.reserve(nChannels); + for (QVector &points : channelZeroPoints) { + if (points.isEmpty()) continue; + std::sort(points.begin(), points.end()); + channelMedians.append(points[points.size() / 2]); + } + + if (!channelMedians.isEmpty()) { + // 多通道数据先按通道求中位数,再对通道中位数取中位数,避免通道间耦合差异互相污染 + std::sort(channelMedians.begin(), channelMedians.end()); + cutN = channelMedians[channelMedians.size() / 2]; + } else if (!zeroPoints.isEmpty()) { + std::sort(zeroPoints.begin(), zeroPoints.end()); + cutN = zeroPoints[zeroPoints.size() / 2]; + } + } + + // 无需裁切 + if (cutN <= 0) return out; + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : out.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + + // 每道截断前面cutN个采样点 + RadarTrace *traces = out.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < out.traces.size(); ++ti) + { + RadarTrace &trace = traces[ti]; + int total = trace.amplitudes.size(); + if (cutN >= total) continue; + trace.amplitudes = trace.amplitudes.mid(cutN); + } + // 更新文件头每道采样总数 + if (!out.traces.isEmpty()) + { + out.header.samplesPerTrace = out.traces.first().amplitudes.size(); + } + return out; +} + +//===================================================================== +// 简易整道去直流(零漂DC模式底层实现) +//===================================================================== +/** + * @brief 扣除单道整体直流均值基线 + * @param input 输入数据 + * @param param 开关配置 + * @return 去直流后数据 + */ +GPRDataModel RadarProcessor::removeDCShift(const GPRDataModel &input, const DcShiftParams& param) +{ + SCOPED_PERF_TIMER("Processing", "removeDCShift"); + if (!param.enable) return input; + GPRDataModel output = input; + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int i = 0; i < output.traces.size(); ++i) { + RadarTrace &trace = traces[i]; + if (trace.amplitudes.isEmpty()) continue; + // 计算单道整体均值 + double sum = 0; + for (auto amp : trace.amplitudes) sum += amp; + double mean = sum / trace.amplitudes.size(); + // 逐采样减去均值并钳位short + short *amps = trace.amplitudes.data(); + int sampleCount = trace.amplitudes.size(); + for (int s = 0; s < sampleCount; ++s) { + amps[s] = clampToShort(amps[s] - mean); + } + } + return output; +} + +//===================================================================== +// 零漂消除:DC全局均值 / Sliding滑动窗口基线 +//===================================================================== +/** + * @brief 消除波形基线漂移 + * @param input 输入数据 + * @param param 模式与窗口参数 + * @return 校正后数据 + * DC模式直接复用removeDCShift;滑动窗口用前缀和快速计算局部均值基线 + */ +GPRDataModel RadarProcessor::removeZeroDrift(const GPRDataModel &input, const ZeroDriftParams& param) +{ + SCOPED_PERF_TIMER("Processing", "removeZeroDrift"); + if (!param.enable) return input; + // 直流均值模式,调用简易去直流接口 + if (param.mode == ZeroDriftMode::DC) { + DcShiftParams dc; + dc.enable = true; + return removeDCShift(input, dc); + } + + GPRDataModel output = input; + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < output.traces.size(); ++ti) { + RadarTrace &trace = traces[ti]; + const QVector source = trace.amplitudes; + const int sampleCount = source.size(); + if (sampleCount == 0) continue; + + const int window = safeWindowSize(param.slidingWindowSamples, sampleCount); + const int halfWindow = window / 2; + // 前缀和数组,O(1)区间求和加速滑动窗口均值 + QVector prefix(sampleCount + 1, 0.0); + for (int sample = 0; sample < sampleCount; ++sample) { + prefix[sample + 1] = prefix[sample] + source[sample]; + } + + // 逐采样计算窗口内局部基线并扣除 + short *amps = trace.amplitudes.data(); + for (int sample = 0; sample < sampleCount; ++sample) { + const int start = std::max(0, sample - halfWindow); + const int end = std::min(sampleCount, start + window); + const int adjStart = std::max(0, end - window); + const int count = end - adjStart; + const double localMean = count > 0 ? (prefix[end] - prefix[adjStart]) / count : 0.0; + amps[sample] = clampToShort(source[sample] - localMean); + } + } + return output; +} + +//===================================================================== +// 全局统一倍率增益 +//===================================================================== +/** + * @brief 整份数据统一放大缩小倍数 + * @param input 输入数据 + * @param param 增益系数与开关 + * @return 缩放后数据 + */ +GPRDataModel RadarProcessor::applyGain(const GPRDataModel &input, const GlobalGainParams& param) +{ + SCOPED_PERF_TIMER("Processing", "applyGain"); + if (!param.enable) return input; + GPRDataModel output = input; + double f = param.factor; + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < output.traces.size(); ++ti) { + RadarTrace &tr = traces[ti]; + short *amps = tr.amplitudes.data(); + int sampleCount = tr.amplitudes.size(); + for (int s = 0; s < sampleCount; ++s) { + amps[s] = clampToShort(amps[s] * f); + } + } + return output; +} + +//===================================================================== +// TVG 球面扩散+介质吸收深度增益补偿 +//===================================================================== +/** + * @brief 随深度自动补偿电磁波衰减 + * @param input 原始数据 + * @param params 速度、吸收系数、最大增益限制等配置 + * @return 增益补偿后剖面 + * 公式:增益 = 球面扩散项 × 指数吸收项,上限maxGain防止噪声爆炸放大 + */ +GPRDataModel RadarProcessor::sphericalTvg(const GPRDataModel &input, const SphericalTvgParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "sphericalTvg"); + if (!params.enable) return input; + GPRDataModel output = input; + if (output.traces.isEmpty() || output.header.samplesPerTrace <= 0 || output.header.timeWindowNs <= 0.0) return output; + + // 波速优先级:参数指定 > 文件头读取 > 兜底0.1 m/ns + const double velocity = params.velocityMPerNs > 0.0 ? params.velocityMPerNs : (output.header.waveVelocity > 0.0 ? output.header.waveVelocity : 0.1); + const double referenceDepth = std::max(params.referenceDepthM, std::numeric_limits::epsilon()); + const double exponent = std::max(params.exponent, 0.0); + const double maxGain = params.maxGain > 0.0 ? params.maxGain : 1.0; + // 单采样时间间隔ns + const double dtNs = output.header.timeWindowNs / output.header.samplesPerTrace; + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < output.traces.size(); ++ti) { + RadarTrace &trace = traces[ti]; + short *amps = trace.amplitudes.data(); + const int sampleCount = trace.amplitudes.size(); + for (int sample = 0; sample < sampleCount; ++sample) { + const double timeNs = sample * dtNs; + // 单程深度 = 往返时间 × 波速 / 2 + const double depthM = timeNs * velocity / 2.0; + double gain = 1.0; + // 球面扩散补偿 (r/r0)^n + if (params.enableSpherical) { + gain *= std::pow(std::max(depthM, referenceDepth) / referenceDepth, exponent); + } + // 介质指数吸收补偿 exp(β·t) + if (params.enableAbsorption && params.absorptionBeta > 0.0) { + gain *= std::exp(params.absorptionBeta * timeNs); + } + // 增益封顶 + gain = std::min(gain, maxGain); + amps[sample] = clampToShort(amps[sample] * gain); + } + } + return output; +} + +//===================================================================== +// 二维剖面平滑:垂直(深度) + 水平(测线道)均值平滑 +//===================================================================== +/** + * @brief 时空二维滑动均值降噪 + * @param input 输入数据集 + * @param params 窗口半宽、横竖平滑开关 + * @return 平滑后剖面 + * 先深度方向逐道平滑,再道方向同通道横向平滑;横向使用原始未平滑数据做基准避免二次模糊 + */ +GPRDataModel RadarProcessor::profileSmooth(const GPRDataModel &input, const ProfileSmoothParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "profileSmooth"); + if (!params.enable) return input; + GPRDataModel output = input; + if (output.traces.isEmpty()) return output; + + const int nChannels = output.header.numberOfChannels > 0 ? output.header.numberOfChannels : 1; + const int tracesPerChannel = output.getTracesPerChannel(); + const int winHalf = std::max(0, params.smoothWindow); + if (winHalf == 0) return output; + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + + // 第一步:垂直深度方向单道内平滑 + if (params.verticalSmooth) { + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < output.traces.size(); ++ti) { + RadarTrace &trace = traces[ti]; + const QVector source = trace.amplitudes; + const int sampleCount = source.size(); + short *amps = trace.amplitudes.data(); + for (int sample = 0; sample < sampleCount; ++sample) { + // 窗口左右边界钳位 + const int start = std::max(0, sample - winHalf); + const int end = std::min(sampleCount - 1, sample + winHalf); + double sum = 0.0; + for (int idx = start; idx <= end; ++idx) { + sum += source[idx]; + } + amps[sample] = clampToShort(sum / (end - start + 1)); + } + } + } + + // 第二步:水平测线方向同通道多道平滑,使用原始模型做参考源 + if (params.horizontalSmooth && tracesPerChannel > 1) { + const GPRDataModel sourceModel = output; + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int globalIndex = 0; globalIndex < output.traces.size(); ++globalIndex) { + int channel = globalIndex % nChannels; + int traceInChannel = globalIndex / nChannels; + + RadarTrace &targetTrace = traces[globalIndex]; + const int sampleCount = targetTrace.amplitudes.size(); + const int startTrace = std::max(0, traceInChannel - winHalf); + const int endTrace = std::min(tracesPerChannel - 1, traceInChannel + winHalf); + // 每个采样点横向多道平均 + for (int sample = 0; sample < sampleCount; ++sample) { + double sum = 0.0; + int count = 0; + for (int neighbor = startTrace; neighbor <= endTrace; ++neighbor) { + const int neighborIndex = sourceModel.getTraceIndex(channel, neighbor); + if (neighborIndex < 0 || neighborIndex >= sourceModel.traces.size()) continue; + const QVector &s = sourceModel.traces[neighborIndex].amplitudes; + if (sample >= amps.size()) continue; + sum += amps[sample]; + ++count; + } + if (count > 0) { + short *tgtAmps = targetTrace.amplitudes.data(); + tgtAmps[sample] = clampToShort(sum / count); + } + } + } + } + return output; +} + +//===================================================================== +// 道内滑动窗口AGC振幅均衡 +//===================================================================== +/** + * @brief 单道内部深浅振幅自适应均衡 + * @param input 原始数据 + * @param params 窗口大小、目标RMS、最大增益限制 + * @return AGC均衡后数据 + * 前缀平方和快速计算局部RMS,增益=目标RMS/局部RMS,封顶maxGain防止噪声放大 + */ +GPRDataModel RadarProcessor::traceInnerAgc(const GPRDataModel &input, const TraceInnerAgcParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "traceInnerAgc"); + if (!params.enable) return input; + GPRDataModel output = input; + if (output.traces.isEmpty()) return output; + + // 目标RMS:0则自动取整份数据全局RMS + const double targetRms = params.targetRms > 0.0 ? params.targetRms : globalRms(input); + if (targetRms <= 0.0) return output; + const double epsilon = params.epsilon > 0.0 ? params.epsilon : 1.0; + const double maxGain = params.maxGain > 0.0 ? params.maxGain : 1.0; + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + const RadarTrace *inputTraces = input.traces.data(); + RadarTrace *outputTraces = output.traces.data(); + #pragma omp parallel for + for (int traceIndex = 0; traceIndex < input.traces.size(); ++traceIndex) { + const RadarTrace &sourceTrace = inputTraces[traceIndex]; + RadarTrace &targetTrace = outputTraces[traceIndex]; + const int sampleCount = sourceTrace.amplitudes.size(); + if (sampleCount == 0) continue; + + const int window = safeWindowSize(params.windowSamples, sampleCount); + const int halfWindow = window / 2; + // 平方前缀和数组,快速区间RMS计算 + QVector prefixSquares(sampleCount + 1, 0.0); + const short *srcAmps = sourceTrace.amplitudes.data(); + for (int s = 0; s < sampleCount; s++) { + double v = srcAmps[s]; + prefixSquares[s+1] = prefixSquares[s] + v * v; + } + // 逐采样计算局部增益并缩放振幅 + short *tgtAmps = targetTrace.amplitudes.data(); + for (int s = 0; s < sampleCount; s++) { + int start = std::max(0, s - halfWindow); + int end = std::min(sampleCount, start + window); + int adjStart = std::max(0, end - window); + int cnt = end - adjStart; + double sumSq = prefixSquares[end] - prefixSquares[adjStart]; + double localRms = cnt > 0 ? std::sqrt(sumSq / cnt) : 0.0; + // 防除零极小值epsilon + double gain = std::min(targetRms / std::max(localRms, epsilon), maxGain); + tgtAmps[s] = clampToShort(srcAmps[s] * gain); + } + } + return output; +} + +//===================================================================== +// 道间AGC:整条测线道与道振幅均衡 +//===================================================================== +/** + * @brief 横向测线多道之间整体能量拉平 + * @param input 输入数据 + * @param params 全局/局部滑动窗口模式 + * @return 均衡后剖面 + * Global:全部道共用一个基准RMS;Local:相邻窗口道平均RMS做基准 + */ +GPRDataModel RadarProcessor::traceToTraceEqualization(const GPRDataModel &input, const TraceToTraceAgcParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "traceToTraceEqualization"); + if (!params.enable) return input; + GPRDataModel output = input; + int traceCnt = input.traces.size(); + if (traceCnt == 0) return output; + + const int nChannels = std::max(1, input.header.numberOfChannels); + const int tracesPerChannel = input.getTracesPerChannel(); + + // 预计算每道整体RMS能量 + QVector trRms(traceCnt, 0.0); + for (int i = 0; i < traceCnt; i++) trRms[i] = traceRms(input.traces[i]); + double targetRms = params.targetRms > 0 ? params.targetRms : globalRms(input); + if (targetRms <= 0) return output; + + double eps = params.epsilon > 0 ? params.epsilon : 1.0; + double maxG = params.maxGain > 0 ? params.maxGain : 1.0; + int win = safeWindowSize(params.horizontalWindowTraces, std::max(1, tracesPerChannel)); + int halfWin = win / 2; + + QVector> channelPrefixSq(nChannels); + if (params.mode == TraceToTraceMode::Local && tracesPerChannel > 0) { + for (int ch = 0; ch < nChannels; ++ch) { + channelPrefixSq[ch].resize(tracesPerChannel + 1); + channelPrefixSq[ch][0] = 0.0; + for (int tr = 0; tr < tracesPerChannel; ++tr) { + const int idx = input.getTraceIndex(ch, tr); + const double rms = (idx >= 0 && idx < traceCnt) ? trRms[idx] : 0.0; + channelPrefixSq[ch][tr + 1] = channelPrefixSq[ch][tr] + rms * rms; + } + } + } + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < traceCnt; ti++) + { + double base = trRms[ti]; + // 局部模式只沿同一通道的测线方向取滑动窗口RMS,避免多通道交错数据互相污染 + if (params.mode == TraceToTraceMode::Local && tracesPerChannel > 0) + { + const int channel = ti % nChannels; + const int traceInChannel = ti / nChannels; + if (channel >= 0 && channel < channelPrefixSq.size() && traceInChannel >= 0 && traceInChannel < tracesPerChannel) { + int st = std::max(0, traceInChannel - halfWin); + int ed = std::min(tracesPerChannel, st + win); + int adjSt = std::max(0, ed - win); + int c = ed - adjSt; + double sq = channelPrefixSq[channel][ed] - channelPrefixSq[channel][adjSt]; + base = c > 0 ? std::sqrt(sq / c) : base; + } + } + // 计算整道统一增益 + double g = std::min(targetRms / std::max(base, eps), maxG); + // 整道所有采样同倍率缩放 + short *amps = traces[ti].amplitudes.data(); + int sampleCount = traces[ti].amplitudes.size(); + for (int s = 0; s < sampleCount; ++s) + { + amps[s] = clampToShort(amps[s] * g); + } + } + return output; +} + +//===================================================================== +// 希尔伯特变换:振幅包络 / 正交虚部 +//===================================================================== +/** + * @brief 时域直接希尔伯特变换(简易实现,适合中小采样长度) + * @param input 输入波形 + * @param params 输出包络/正交分量开关 + * @return 变换后数据 + * 公式:虚部h[n] = Σ(2/(π·(n-k)))·x[k];包络=√(实部²+虚部²) + */ +GPRDataModel RadarProcessor::hilbertTransform(const GPRDataModel &input, const HilbertParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "hilbertTransform"); + if (!params.enable) return input; + GPRDataModel output = input; + if (output.traces.isEmpty()) return output; + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : output.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + RadarTrace *traces = output.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < output.traces.size(); ++ti) { + RadarTrace &trace = traces[ti]; + const QVector source = trace.amplitudes; + const int sampleCount = source.size(); + if (sampleCount < 3) continue; + + QVector hilbert(sampleCount, 0.0); + // 时域卷积计算希尔伯特虚部 + for (int n = 0; n < sampleCount; ++n) { + double sum = 0.0; + for (int k = 0; k < sampleCount; ++k) { + const int diff = n - k; + // 差分0、偶数项系数为0,只累加奇数差分 + if (diff == 0 || diff % 2 == 0) continue; + sum += (2.0 / (M_PI * diff)) * source[k]; + } + hilbert[n] = sum; + } + + // 输出模式分支 + short *amps = trace.amplitudes.data(); + for (int sample = 0; sample < sampleCount; ++sample) { + if (params.computeEnvelope) { + // 成像常用:振幅包络 + const double real = source[sample]; + amps[sample] = clampToShort(std::sqrt(real * real + hilbert[sample] * hilbert[sample])); + } else { + // 相位分析:仅输出正交虚部 + amps[sample] = clampToShort(hilbert[sample]); + } + } + } + return output; +} + +//===================================================================== +// FFT频域带通滤波(kiss_fftr快速实数FFT) +//===================================================================== +/** + * @brief 频域升余弦窗带通滤波 + * @param input 原始数据 + * @param params 自动/手动频带配置 + * @return 滤波后波形 + * 流程:时域→FFT频域乘增益响应→逆FFT回时域;补零2倍长度避免循环卷积混叠 + */ +GPRDataModel RadarProcessor::bandpassFilter(const GPRDataModel &input, const BandpassParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "bandpassFilter"); + if (!params.enable) return input; + GPRDataModel out = input; + if (out.traces.isEmpty() || out.header.samplesPerTrace <= 0) return out; + + const double dtNs = out.header.timeWindowNs / out.header.samplesPerTrace; + if (dtNs <= 0.0) return out; + const double fs = 1e9 / dtNs; + + double lowFreq = params.lowFreqHz; + double highFreq = params.highFreqHz; + if (params.autoFreq && params.antennaFreqMHz > 0.0) { + const double fcenter = params.antennaFreqMHz * 1e6; + lowFreq = fcenter * 0.1; + highFreq = fcenter * 2.5; + } + lowFreq = std::max(lowFreq, 0.0); + highFreq = std::min(highFreq, fs * 0.5); + if (lowFreq >= highFreq) return out; + + const double transition = std::max(50.0, (highFreq - lowFreq) * 0.1); + const int sampleCount = out.header.samplesPerTrace; + + int nfft = 1; + while (nfft < sampleCount * 2) nfft <<= 1; + + std::vector filterResponse(nfft / 2 + 1); + for (int k = 0; k <= nfft / 2; ++k) { + const double freq = k * fs / nfft; + filterResponse[k] = static_cast(bandpassResponse(freq, lowFreq, highFreq, transition)); + } + + for (auto &trace : out.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + + #pragma omp parallel + { + kiss_fftr_cfg fwd = kiss_fftr_alloc(nfft, 0, nullptr, nullptr); + kiss_fftr_cfg inv = kiss_fftr_alloc(nfft, 1, nullptr, nullptr); + std::vector timedata(nfft, 0.0f); + std::vector freqdata(nfft / 2 + 1); + + RadarTrace *traces = out.traces.data(); + if (fwd && inv) { + #pragma omp for + for (int ti = 0; ti < out.traces.size(); ++ti) { + RadarTrace &trace = traces[ti]; + if (trace.amplitudes.size() < 4) continue; + + short *amps = trace.amplitudes.data(); + for (int i = 0; i < sampleCount; ++i) { + timedata[i] = static_cast(amps[i]); + } + for (int i = sampleCount; i < nfft; ++i) { + timedata[i] = 0.0f; + } + + kiss_fftr(fwd, timedata.data(), freqdata.data()); + + for (int k = 0; k <= nfft / 2; ++k) { + freqdata[k].r *= filterResponse[k]; + freqdata[k].i *= filterResponse[k]; + } + + kiss_fftri(inv, freqdata.data(), timedata.data()); + + const double scale = 1.0 / nfft; + for (int i = 0; i < sampleCount; ++i) { + amps[i] = clampToShort(timedata[i] * scale); + } + } + } + + if (fwd) kiss_fftr_free(fwd); + if (inv) kiss_fftr_free(inv); + } + return out; +} + +//===================================================================== +// 背景杂波去除:均值法 / 奇异值过滤法 +//===================================================================== +/** + * @brief 多道平均背景相减,压制固定耦合、地层静态杂波 + * @param input 原始剖面 + * @param params 模式、平均窗口道数、奇异阈值 + * @return 去除背景后数据 + * MeanAverage:全部道参与背景平均;SingularityFilter:过滤高能异常道再平均,保护空洞/管线强反射 + */ +GPRDataModel RadarProcessor::backgroundRemoval(const GPRDataModel &input, const BackgroundParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "backgroundRemoval"); + if (!params.enable) return input; + GPRDataModel out = input; + const int traceTotal = out.traces.size(); + if (traceTotal < 3) return out; + + const int nChannels = std::max(1, out.header.numberOfChannels); + const int tracesPerChannel = out.getTracesPerChannel(); + if (tracesPerChannel < 3) return out; + + QVector> validTraceMask(nChannels); + for (int ch = 0; ch < nChannels; ++ch) { + validTraceMask[ch].fill(true, tracesPerChannel); + } + + // 奇异滤波模式:每个通道内部筛掉能量过高的异常道,只用平稳道算背景 + if (params.mode == BackgroundMode::SingularityFilter) { + for (int ch = 0; ch < nChannels; ++ch) { + QVector energies; + energies.reserve(tracesPerChannel); + for (int tr = 0; tr < tracesPerChannel; ++tr) { + const int idx = input.getTraceIndex(ch, tr); + if (idx >= 0 && idx < traceTotal) { + energies.append(traceRms(input.traces[idx])); + } + } + if (energies.isEmpty()) continue; + + QVector sortedEnergies = energies; + std::sort(sortedEnergies.begin(), sortedEnergies.end()); + const double median = sortedEnergies[sortedEnergies.size() / 2]; + const double limit = median * std::max(params.singularityThreshold, 1.0); + + for (int tr = 0; tr < tracesPerChannel; ++tr) { + const int idx = input.getTraceIndex(ch, tr); + validTraceMask[ch][tr] = idx >= 0 && idx < traceTotal && traceRms(input.traces[idx]) <= limit; + } + } + } + + // 预先detach所有trace的amplitudes,避免OpenMP多线程竞争隐式共享容器的detach + for (auto &trace : out.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + + // 滑动半道窗口只沿同一通道的测线方向取背景,避免多通道交错数据互相污染 + const int halfWindow = std::max(1, params.averageTraceCount) / 2; + RadarTrace *outTraces = out.traces.data(); + const RadarTrace *inTraces = input.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < traceTotal; ++ti) + { + const int channel = ti % nChannels; + const int traceInChannel = ti / nChannels; + if (channel < 0 || channel >= validTraceMask.size() || traceInChannel < 0 || traceInChannel >= tracesPerChannel) continue; + + const int left = std::max(0, traceInChannel - halfWindow); + const int right = std::min(tracesPerChannel - 1, traceInChannel + halfWindow); + const int sampleCount = outTraces[ti].amplitudes.size(); + QVector background(sampleCount, 0.0); + int backgroundCount = 0; + + for (int tr = left; tr <= right; ++tr) { + if (!validTraceMask[channel][tr]) continue; + const int idx = input.getTraceIndex(channel, tr); + if (idx < 0 || idx >= traceTotal) continue; + const RadarTrace &bgTrace = inTraces[idx]; + const short *bgAmps = bgTrace.amplitudes.data(); + int bgSize = bgTrace.amplitudes.size(); + for (int sample = 0; sample < sampleCount && sample < bgSize; ++sample) { + background[sample] += bgAmps[sample]; + } + ++backgroundCount; + } + if (backgroundCount == 0) continue; + + RadarTrace ¤tTrace = outTraces[ti]; + const QVector &source = inTraces[ti].amplitudes; + short *curAmps = currentTrace.amplitudes.data(); + const short *srcAmps = source.data(); + int srcSize = source.size(); + for (int sample = 0; sample < sampleCount && sample < srcSize; ++sample) + { + const double averageBackground = background[sample] / backgroundCount; + curAmps[sample] = clampToShort(srcAmps[sample] - averageBackground); + } + } + return out; +} + +//===================================================================== +// Kirchhoff偏移:x-t域双曲线求和 +//===================================================================== +/** + * @brief Kirchhoff偏移在x-t域实现 + * @param input 输入剖面数据 + * @param params 求和宽度(两侧道数)与波速参数 + * @return 偏移后数据 + * 算法流程: + * 1. 对原始剖面逐道时间方向微分 + * 2. 对每个输出点(t0, x0)沿双曲线 t = sqrt(t0^2 + (dx*n/v)^2) 求和 + * 3. 加权因子采用1/t几何扩散补偿 + * 4. sumWidth < 128时预计算双曲线查表,避免重复sqrt运算 + */ +GPRDataModel RadarProcessor::kirchhoffMigration(const GPRDataModel &input, const MigrationParams ¶ms) +{ + SCOPED_PERF_TIMER("Processing", "kirchhoffMigration"); + if (!params.enable) return input; + GPRDataModel out = input; + int traceTotal = out.traces.size(); + if (traceTotal < 3) return out; + int sampleCount = out.header.samplesPerTrace; + if (sampleCount < 3) return out; + + const double dtNs = out.header.timeWindowNs / sampleCount; + double dx = out.header.distanceInc; + if (dx <= 0.0) dx = 0.01; + double v = params.velocityMPerNs > 0.0 ? params.velocityMPerNs : out.header.waveVelocity; + if (v <= 0.0) v = 0.1; + + const int sumWidth = std::max(1, params.sumWidth); + + // 1. 逐道时间方向微分,结果存为double浮点数组 + QVector> diffData(traceTotal); + for (int ti = 0; ti < traceTotal; ++ti) { + diffData[ti].resize(sampleCount); + const QVector &src = out.traces[ti].amplitudes; + if (sampleCount > 0) diffData[ti][0] = 0.0; + for (int s = 1; s < sampleCount; ++s) { + diffData[ti][s] = static_cast(src[s]) - static_cast(src[s - 1]); + } + } + + // 2. 当求和宽度小于128时,预计算双曲线采样位置查表(只进行一次) + QVector> hyperbolaTable; + if (sumWidth < 128) { + hyperbolaTable.resize(sampleCount); + for (int t0 = 0; t0 < sampleCount; ++t0) { + hyperbolaTable[t0].resize(sumWidth * 2 + 1); + const double t0Ns = t0 * dtNs; + for (int k = -sumWidth; k <= sumWidth; ++k) { + const double x = k * dx; + const double tNs = std::sqrt(t0Ns * t0Ns + (x / v) * (x / v)); + hyperbolaTable[t0][k + sumWidth] = tNs / dtNs; + } + } + } + + // 预先detach所有trace的amplitudes + for (auto &trace : out.traces) { + if (!trace.amplitudes.isEmpty()) + trace.amplitudes.data(); + } + + // 3. 对每个输出点沿双曲线加权求和 + RadarTrace *traces = out.traces.data(); + #pragma omp parallel for + for (int ti = 0; ti < traceTotal; ++ti) { + QVector result(sampleCount, 0); + for (int s = 0; s < sampleCount; ++s) { + double sum = 0.0; + int validCount = 0; + for (int k = -sumWidth; k <= sumWidth; ++k) { + const int srcTrace = ti + k; + if (srcTrace < 0 || srcTrace >= traceTotal) continue; + + double srcSampleD; + if (sumWidth < 128 && !hyperbolaTable.isEmpty()) { + srcSampleD = hyperbolaTable[s][k + sumWidth]; + } else { + const double t0Ns = s * dtNs; + const double x = k * dx; + const double tNs = std::sqrt(t0Ns * t0Ns + (x / v) * (x / v)); + srcSampleD = tNs / dtNs; + } + + int srcSample = static_cast(srcSampleD); + double frac = srcSampleD - srcSample; + if (srcSample < 0) { + srcSample = 0; + frac = 0.0; + } + if (srcSample >= sampleCount - 1) { + srcSample = sampleCount - 1; + frac = 0.0; + } + + double amp = diffData[srcTrace][srcSample]; + if (frac > 0.0 && srcSample < sampleCount - 1) { + amp += frac * (diffData[srcTrace][srcSample + 1] - diffData[srcTrace][srcSample]); + } + + // 1/t 几何扩散加权(t>0时) + const double tNs = s * dtNs; + if (tNs > 1e-12) { + amp /= tNs; + } + + sum += amp; + ++validCount; + } + if (validCount > 0) { + result[s] = clampToShort(sum); + } + } + traces[ti].amplitudes = std::move(result); + } + + return out; +} + +//===================================================================== +// 流水线总调度入口:按步骤队列顺序串行执行所有开启算法 +//===================================================================== +/** + * @brief 批量串行执行整套预处理流水线 + * @param rawData 未处理原始GPR数据 + * @param pipeline 有序步骤配置容器 + * @return 逐步骤处理完成后的成品数据 + */ +GPRDataModel RadarProcessor::runPipeline(const GPRDataModel& rawData, const ProcPipeline& pipeline) +{ + return runPipeline(rawData, pipeline, nullptr); +} + +GPRDataModel RadarProcessor::runPipeline(const GPRDataModel& rawData, + const ProcPipeline& pipeline, + const PipelineRuntimeContext *context) +{ + SCOPED_PERF_TIMER("Processing", "runPipeline(Total)"); + auto canceled = [context]() { + return context && context->isCanceled && context->isCanceled(); + }; + auto stepName = [](ProcStepType type) -> QString { + switch (type) { + case ProcStepType::StepTimeZeroCut: return QStringLiteral("Set Time Zero"); + case ProcStepType::StepZeroDrift: return QStringLiteral("Zero Drift"); + case ProcStepType::StepRemoveDC: return QStringLiteral("Remove DC"); + case ProcStepType::StepBackgroundRemove: return QStringLiteral("Background Removal"); + case ProcStepType::StepSphericalTVG: return QStringLiteral("Spherical TVG"); + case ProcStepType::StepBandpassFilter: return QStringLiteral("Bandpass Filter"); + case ProcStepType::StepProfileSmooth: return QStringLiteral("Profile Smooth"); + case ProcStepType::StepInnerAGC: return QStringLiteral("Trace Inner AGC"); + case ProcStepType::StepTraceToTraceAGC: return QStringLiteral("Trace-to-Trace AGC"); + case ProcStepType::StepHilbertTransform: return QStringLiteral("Hilbert Transform"); + case ProcStepType::StepGlobalGain: return QStringLiteral("Global Gain"); + case ProcStepType::StepMigration: return QStringLiteral("Migration"); + default: return QStringLiteral("Unknown Step"); + } + }; + + GPRDataModel current = rawData; + // 处理流水线只操作 traces,volumeData 不参与任何算法步骤 + // 提前清除可显著减少各步骤内部 GPRDataModel out = input 时的深拷贝开销 + current.volumeData.clear(); + // 严格按照steps数组存储顺序依次执行 + const int stepCount = pipeline.steps.size(); + for (int i = 0; i < pipeline.steps.size(); ++i) + { + if (canceled()) + break; + + const auto& unit = pipeline.steps[i]; + qDebug() << "[RadarProcessor] Step" << (i + 1) << "/" << stepCount << "starting:" << stepName(unit.type) + << "| traces=" << current.traces.size() << "samples=" << current.header.samplesPerTrace; + + switch (unit.type) + { + case ProcStepType::StepTimeZeroCut: + current = cutTimeZero(current, unit.zeroCut); + break; + case ProcStepType::StepZeroDrift: + current = removeZeroDrift(current, unit.zeroDrift); + break; + case ProcStepType::StepSphericalTVG: + current = sphericalTvg(current, unit.tvg); + break; + case ProcStepType::StepBandpassFilter: + current = bandpassFilter(current, unit.band); + break; + case ProcStepType::StepProfileSmooth: + current = profileSmooth(current, unit.smooth); + break; + case ProcStepType::StepBackgroundRemove: + current = backgroundRemoval(current, unit.bg); + break; + case ProcStepType::StepRemoveDC: + current = removeDCShift(current, unit.dc); + break; + case ProcStepType::StepInnerAGC: + current = traceInnerAgc(current, unit.innerAgc); + break; + case ProcStepType::StepTraceToTraceAGC: + current = traceToTraceEqualization(current, unit.traceAgc); + break; + case ProcStepType::StepHilbertTransform: + current = hilbertTransform(current, unit.hilbert); + break; + case ProcStepType::StepGlobalGain: + current = applyGain(current, unit.gain); + break; + case ProcStepType::StepMigration: + current = kirchhoffMigration(current, unit.migration); + break; + default: + // 未知步骤类型跳过 + break; + } + + qDebug() << "[RadarProcessor] Step" << (i + 1) << "finished:" << stepName(unit.type); + + if (context && context->reportProgress) + context->reportProgress(i + 1, stepCount, stepName(unit.type)); + + if (canceled()) + break; + } + return current; +} \ No newline at end of file diff --git a/external/gpr3dviewer/RadarProcessor.h b/external/gpr3dviewer/RadarProcessor.h new file mode 100644 index 0000000..6983f5a --- /dev/null +++ b/external/gpr3dviewer/RadarProcessor.h @@ -0,0 +1,346 @@ +/* + * RadarProcessor.h + * 探地雷达GPR数据预处理算法核心类头文件 + * 功能:封装全套雷达道数据校正、滤波、增益、均衡、希尔伯特包络算法 + * 架构:模块化单步骤参数结构体 + 流水线顺序调度执行 + * 对接:MainWindow UI界面参数绑定、GPRDataModel三维道集数据模型 + * 适配:RAD/RD3格式多通道、多测线原始雷达数据 + */ +#ifndef RADARPROCESSOR_H +#define RADARPROCESSOR_H + +#include "GPRDataModel.h" +#include +#include +#include + +class RadarProcessor +{ +public: + /** + * @brief 所有可执行预处理步骤枚举标识 + * 流水线严格按照枚举顺序业务逻辑排布 + */ + enum class ProcStepType + { + StepTimeZeroCut, // 0:零时校正(切除直达波零点) + StepZeroDrift, // 1:道基线零漂消除 + StepRemoveDC, // 2:简易整道直流偏移扣除 + StepBackgroundRemove, // 3:剖面背景均值压制 + StepSphericalTVG, // 4:球面扩散+介质吸收TVG深度增益 + StepBandpassFilter, // 5:FIR带通滤波剔除高低频噪声 + StepProfileSmooth, // 6:时空剖面平滑降噪 + StepInnerAGC, // 7:单道内自适应振幅均衡AGC + StepTraceToTraceAGC, // 8:道与道之间整体振幅均衡 + StepHilbertTransform, // 9:希尔伯特变换(包络/正交分量) + StepGlobalGain, // 10:全局统一倍率增益放大 + StepMigration // 11:Kirchhoff偏移 + }; + + //========================================================================= + // 1. 零时切除参数结构体 StepTimeZeroCut + // 作用:定位直达波起跳零点,裁掉零点前无效采样点,统一道起始位置 + //========================================================================= + struct TimeZeroCutParams + { + bool autoDetect = true; // true=自动识别零点;false=手动固定裁掉前N个采样 + int cutSamples = 30; // 手动模式裁切采样点数 + int frontSearchWindow = 180; // 零点搜索窗口:只在道前180个采样内找起跳点 + double noiseSigmaMultiple = 3.0; // 起跳判定阈值:噪声标准差倍数,大于判定为有效直达波 + bool enable = false; // 流水线开关:是否启用本步骤 + }; + + /** + * @brief 零漂消除模式枚举 + */ + enum class ZeroDriftMode + { + DC, // 模式1:整道均值直流偏移一次性扣除(速度快) + Sliding // 模式2:滑动窗口逐段扣除基线(适合长时漂移严重数据) + }; + struct ZeroDriftParams + { + ZeroDriftMode mode = ZeroDriftMode::Sliding; + int slidingWindowSamples = 100; // 滑动窗口采样点数,仅Sliding模式生效 + bool enable = false; + }; + + //========================================================================= + // TVG球面深度增益 StepSphericalTVG + // 物理原理:电磁波随传播距离球面扩散衰减+介质吸收衰减 + //========================================================================= + struct SphericalTvgParams { + bool enableSpherical = true; // 开启球面扩散补偿 + bool enableAbsorption = true; // 开启介质吸收衰减补偿 + double velocityMPerNs = 0.12; // 雷达波速(m/ns),从GPRDataHeader自动读取 + double referenceDepthM = 0.01; // 参考基准深度(增益归一化基准) + double exponent = 1.5; // 扩散指数:空气1.0、土体/混凝土常用1.0~2.0 + double absorptionBeta = 1.0; // 吸收系数 β (dB/m),干土小、湿土大 + double maxGain = 30.0; // 最大增益上限,防止深层噪声过度放大 + bool enable = false; + }; + + //========================================================================= + // 带通滤波 StepBandpassFilter + // 滤除天线主频外低频漂移、高频仪器白噪声 + //========================================================================= + struct BandpassParams + { + bool autoFreq = true; // true自动根据天线主频计算通带;false手动填高低频 + double lowFreqHz = 1000; // 通带下限(Hz) + double highFreqHz = 100; // 通带上限(Hz) + double antennaFreqMHz = 200.0; // 天线中心频率(MHz),自动模式读取头文件 + bool enable = false; + }; + + //========================================================================= + // 剖面二维平滑 StepProfileSmooth + // 垂直=深度方向平滑;水平=测线道方向平滑 + //========================================================================= + struct ProfileSmoothParams + { + int smoothWindow = 2; // 半窗口大小,实际总窗口=2*window+1 + bool verticalSmooth = true; // 深度方向平滑开关 + bool horizontalSmooth = true; // 测线道方向平滑开关 + bool enable = false; + }; + + //========================================================================= + // 背景去除 StepBackgroundRemove + // 压制整剖面静态杂波、地面耦合固定反射、钢筋/管线持续干扰 + //========================================================================= + enum class BackgroundMode + { + MeanAverage, // 均值法:多道平均作为背景相减,通用稳定 + SingularityFilter // 奇异值滤波:保留强异常,压制平缓背景(空洞/管线优选) + }; + struct BackgroundParams + { + BackgroundMode mode = BackgroundMode::MeanAverage; + int averageTraceCount = 301; // 参与平均背景的同通道道数量 + double singularityThreshold = 1.8; // 奇异判定阈值,越大越不容易抹掉小异常 + bool enable = false; + }; + + //========================================================================= + // 道内AGC StepInnerAGC + // 同一道内深浅振幅均衡,消除天然深度衰减(TVG补充均衡) + //========================================================================= + struct TraceInnerAgcParams { + int windowSamples = 120; // 道内滑动RMS统计窗口 + double targetRms = 0.0; // 目标RMS值;0=自适应均衡 + double maxGain = 6.0; // 单窗口最大增益限制 + double epsilon = 1.0; // 防除零极小值 + bool enable = false; + }; + + //========================================================================= + // 道间AGC StepTraceToTraceAGC + // 整条测线所有道之间振幅拉平,消除收发耦合波动、行走速度不均 + //========================================================================= + enum class TraceToTraceMode + { + Global, // 全局:全部道统一均衡基准 + Local // 局部:滑动窗口相邻道均衡(起伏大路面优选) + }; + struct TraceToTraceAgcParams { + TraceToTraceMode mode = TraceToTraceMode::Local; + int horizontalWindowTraces = 31; // 局部模式滑动道窗口 + double targetRms = 0.0; + double maxGain = 4.0; + double epsilon = 1.0; + bool enable = false; + }; + + //========================================================================= + // 希尔伯特变换 StepHilbertTransform + // 输出振幅包络(成像常用)或正交虚部(相位分析) + //========================================================================= + struct HilbertParams + { + bool computeEnvelope = true; // true=振幅包络;false=正交相位分量 + bool enable = false; + }; + + // 全局倍率增益 + struct GlobalGainParams + { + double factor = 1.0; // 放大倍数,0.1~10区间常用 + bool enable = false; + }; + + // 简易去直流(轻量零漂) + struct DcShiftParams + { + bool enable = false; + }; + + //========================================================================= + // Kirchhoff偏移参数 + //========================================================================= + struct MigrationParams + { + int sumWidth = 64; // 两侧各求和的迹线数量 + double velocityMPerNs = 0.12; // 雷达波速(m/ns) + bool enable = false; + }; + + //========================================================================= + // 流水线单步单元:存储步骤类型 + 全套独立参数 + //========================================================================= + struct ProcStepUnit + { + ProcStepType type; + + // 全部算法参数容器,运行时仅type对应的结构体生效 + TimeZeroCutParams zeroCut; + SphericalTvgParams tvg; + BandpassParams band; + ProfileSmoothParams smooth; + BackgroundParams bg; + ZeroDriftParams zeroDrift; + TraceInnerAgcParams innerAgc; + TraceToTraceAgcParams traceAgc; + HilbertParams hilbert; + GlobalGainParams gain; + DcShiftParams dc; + MigrationParams migration; + }; + + //========================================================================= + // 完整预处理流水线:有序步骤队列 + 默认标准流程 + //========================================================================= + struct ProcPipeline + { + QVector steps; + + /** + * @brief 加载工业通用标准处理流水线(道路检测默认) + * 顺序:零时→零漂→背景→TVG增益→带通→平滑→内AGC→道间AGC→包络 + */ + void setDefaultFlow() + { + steps.clear(); + + // 1. 零时校正 + ProcStepUnit s0; + s0.type = ProcStepType::StepTimeZeroCut; + s0.zeroCut.autoDetect = true; + s0.zeroCut.cutSamples = 30; + s0.zeroCut.frontSearchWindow = 180; + s0.zeroCut.noiseSigmaMultiple = 3.0; + s0.zeroCut.enable = true; + steps.append(s0); + + // 2. 零漂去除 + ProcStepUnit s1; + s1.type = ProcStepType::StepZeroDrift; + s1.zeroDrift.mode = ZeroDriftMode::Sliding; + s1.zeroDrift.slidingWindowSamples = 100; + s1.zeroDrift.enable = true; + steps.append(s1); + + // 3. 背景压制 + ProcStepUnit s2; + s2.type = ProcStepType::StepBackgroundRemove; + s2.bg.mode = BackgroundMode::MeanAverage; + s2.bg.averageTraceCount = 301; + s2.bg.enable = true; + steps.append(s2); + + // 4. TVG深度增益 + ProcStepUnit s3; + s3.type = ProcStepType::StepSphericalTVG; + s3.tvg.enableSpherical = true; + s3.tvg.enableAbsorption = true; + s3.tvg.exponent = 1.0; + s3.tvg.maxGain = 20.0; + s3.tvg.absorptionBeta = 0.002; + s3.tvg.enable = true; + steps.append(s3); + + // 5. 带通滤波 + ProcStepUnit s4; + s4.type = ProcStepType::StepBandpassFilter; + s4.band.autoFreq = true; + s4.band.lowFreqHz = 100; + s4.band.highFreqHz = 1500; + s4.band.enable = true; + steps.append(s4); + + // 6. 剖面平滑 + ProcStepUnit s5; + s5.type = ProcStepType::StepProfileSmooth; + s5.smooth.smoothWindow = 2; + s5.smooth.verticalSmooth = true; + s5.smooth.horizontalSmooth = true; + s5.smooth.enable = true; + steps.append(s5); + + // 7. 道内AGC + ProcStepUnit s6; + s6.type = ProcStepType::StepInnerAGC; + s6.innerAgc.windowSamples = 120; + s6.innerAgc.maxGain = 6.0; + s6.innerAgc.enable = true; + steps.append(s6); + + // 8. 道间均衡AGC + ProcStepUnit s7; + s7.type = ProcStepType::StepTraceToTraceAGC; + s7.traceAgc.mode = TraceToTraceMode::Local; + s7.traceAgc.horizontalWindowTraces = 31; + s7.traceAgc.maxGain = 4.0; + s7.traceAgc.enable = true; + steps.append(s7); + } + }; + +public: + struct PipelineRuntimeContext { + std::function isCanceled; + std::function reportProgress; + }; + + RadarProcessor(); + + //==================== 单算法静态处理函数 ==================== + /// 简易去除整道直流偏移 + static GPRDataModel removeDCShift(const GPRDataModel &input, const DcShiftParams& param); + /// 零漂基线校正 + static GPRDataModel removeZeroDrift(const GPRDataModel &input, const ZeroDriftParams& param); + /// 全局统一倍率增益 + static GPRDataModel applyGain(const GPRDataModel &input, const GlobalGainParams& param); + /// TVG球面+吸收深度增益补偿 + static GPRDataModel sphericalTvg(const GPRDataModel &input, const SphericalTvgParams ¶ms); + /// 二维剖面均值平滑 + static GPRDataModel profileSmooth(const GPRDataModel &input, const ProfileSmoothParams ¶ms); + /// 道内滑动AGC均衡 + static GPRDataModel traceInnerAgc(const GPRDataModel &input, const TraceInnerAgcParams ¶ms); + /// 道与道之间振幅均衡 + static GPRDataModel traceToTraceEqualization(const GPRDataModel &input, const TraceToTraceAgcParams ¶ms); + /// 希尔伯特变换包络/相位 + static GPRDataModel hilbertTransform(const GPRDataModel &input, const HilbertParams ¶ms); + + /// 零点自动/手动裁切算法实现 + static GPRDataModel cutTimeZero(const GPRDataModel& input, const TimeZeroCutParams& param); + /// FIR带通滤波实现 + static GPRDataModel bandpassFilter(const GPRDataModel &input, const BandpassParams ¶ms); + /// 背景杂波去除实现(均值/奇异滤波双模式) + static GPRDataModel backgroundRemoval(const GPRDataModel &input, const BackgroundParams ¶ms); + /// Kirchhoff偏移(x-t域双曲线求和) + static GPRDataModel kirchhoffMigration(const GPRDataModel &input, const MigrationParams ¶ms); + + /** + * @brief 流水线总执行入口 + * @param rawData 原始未处理GPR三维道集数据 + * @param pipeline 有序步骤流水线配置 + * @return 逐步骤处理完成后的成品数据 + */ + static GPRDataModel runPipeline(const GPRDataModel& rawData, const ProcPipeline& pipeline); + static GPRDataModel runPipeline(const GPRDataModel& rawData, + const ProcPipeline& pipeline, + const PipelineRuntimeContext *context); +}; + +#endif // RADARPROCESSOR_H \ No newline at end of file diff --git a/external/gpr3dviewer/RadarTypes.h b/external/gpr3dviewer/RadarTypes.h new file mode 100644 index 0000000..a9e069d --- /dev/null +++ b/external/gpr3dviewer/RadarTypes.h @@ -0,0 +1,9 @@ +#ifndef RADARTYPES_H +#define RADARTYPES_H + +enum class RadarType { + Mala_Mira, // .rad + .rd3 + Impulse // .iprh + .iprb +}; + +#endif // RADARTYPES_H diff --git a/external/gpr3dviewer/Rd3Parser.cpp b/external/gpr3dviewer/Rd3Parser.cpp new file mode 100644 index 0000000..3990795 --- /dev/null +++ b/external/gpr3dviewer/Rd3Parser.cpp @@ -0,0 +1,300 @@ +#include "Rd3Parser.h" +#include "PerformanceLogger.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief 加载整套Mala Mira系列雷达RD3数据(rad头文件 + rd3纯二进制波形) + * @param radFilePath .rad文本配置头文件路径 + * @param model 输出GPR全局数据模型 + * @return true 加载解析成功;false 失败 + * @note 存储结构:.rad存储全部仪器/测线参数;.rd3无文件头,从头到尾全是short16振幅采样 + */ +bool Rd3Parser::loadFromRad(const QString &radFilePath, GPRDataModel &model) { + SCOPED_PERF_TIMER("Parser", "Rd3Parser::loadFromRad"); + + // 清空旧数据与头部信息 + model.clear(); + model.header = GPRDataModel::Header{}; + + // 第一步:解析rad文本头,填充全部采集参数 + if (!parseRadHeader(radFilePath, model.header)) { + qDebug() << "Error: Failed to parse .rad header file:" << radFilePath; + return false; + } + + // 自动匹配同目录同名二进制数据文件 .rd3 + QFileInfo radInfo(radFilePath); + QString binaryPath = radInfo.absolutePath() + "/" + radInfo.completeBaseName() + ".rd3"; + + if (!QFile::exists(binaryPath)) { + qDebug() << "Error: Matching binary file not found at:" << binaryPath; + return false; + } + + // 推算后仍无有效道数则失败 + if (model.header.numTraces <= 0) { + qDebug() << "Error: Unable to determine numTraces from header"; + return false; + } + + // 第二步:读取无头部的纯二进制波形 + return loadRd3Binary(binaryPath, model); +} + +/** + * @brief 解析RAD文本头文件,提取雷达采集关键参数存入Header结构体 + * @param radFilePath rad文本文件路径 + * @param header 待填充头部参数结构体 + * @return 解析成功返回true + */ +bool Rd3Parser::parseRadHeader(const QString &radFilePath, GPRDataModel::Header &header) { + SCOPED_PERF_TIMER("Parser", "Rd3Parser::parseRadHeader"); + + QFile file(radFilePath); + // 只读文本模式打开 + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + qDebug() << "Error: Cannot open rad file for read"; + return false; + } + + QTextStream in(&file); + + // 逐行读取键值对 KEY: VALUE 或 KEY=VALUE + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + if (line.isEmpty()) continue; + + int sepPos = line.indexOf(':'); + if (sepPos == -1) sepPos = line.indexOf('='); + if (sepPos == -1) continue; + + QString key = line.left(sepPos).trimmed(); + QString value = line.mid(sepPos + 1).trimmed(); + + // 原始字符串参数全存入map备用 + header.rawParams[key] = value; + + // 识别核心业务参数并类型转换赋值 + if (key == "SAMPLES") { + header.samplesPerTrace = extractInt(value); + } else if (key == "LAST TRACE") { + header.numTraces = extractInt(value); + } else if (key == "TIMEWINDOW") { + header.timeWindowNs = extractDouble(value); + } else if (key == "DISTANCE INTERVAL") { + header.distanceInc = extractDouble(value); + } else if (key == "ANTENNAS") { + header.antennaFreq = extractDouble(value); + } else if (key == "ANTENNAS") { + header.antennaType = value; + } else if (key == "DATE") { + header.date = value; + } else if (key == "TIME") { + header.timeStr = value; + } else if (key == "NUMBER_OF_CH") { + header.numberOfChannels = extractInt(value); + } else if (key == "CH_X_OFFSETS") { + // 多通道天线水平X偏移量 + QStringList offsets = value.split(' ', Qt::SkipEmptyParts); + for (const QString &offset : offsets) { + header.chXOffsets.append(offset.toFloat()); + } + } else if (key == "CH_Y_OFFSETS") { + // 多通道天线行进Y偏移量 + QStringList offsets = value.split(' ', Qt::SkipEmptyParts); + for (const QString &offset : offsets) { + header.chYOffsets.append(offset.toFloat()); + } + } else if (key == "UNITS") { + header.units = value; + } else if (key == "START POSITION") { + header.startPosition = extractDouble(value); + } else if (key == "STOP POSITION") { + header.stopPosition = extractDouble(value); + } else if (key == "TIME INTERVAL") { + header.timeIntervalNs = extractDouble(value); + } + } + + file.close(); + + // 基础合法性校验:单道采样数必须大于0;总道数允许从二进制文件推算 + if (header.samplesPerTrace <= 0) { + qDebug() << "Error: Invalid SAMPLES value in rad file"; + return false; + } + + // 设置地层电磁波默认速度 0.1 m/ns(空气近似值,后续界面可手动修改) + header.waveVelocity = 0.1; + + qDebug() << "==== RAD Header Parse Complete ====" + << "\nSamples per trace:" << header.samplesPerTrace + << "\nTotal traces:" << header.numTraces + << "\nTime window(ns):" << header.timeWindowNs + << "\nChannel count:" << header.numberOfChannels + << "\nX offset array size:" << header.chXOffsets.size() + << "\nY offset array size:" << header.chYOffsets.size(); + + return true; +} + +/** + * @brief 读取纯RD3二进制数据(无任何文件头,全连续short16振幅) + * @param rd3FilePath rd3波形文件路径 + * @param model 绑定头部参数,填充完整traces波形数组 + * @return 读取成功true + * @detail 存储格式: + * 道0采样0,道0采样1...道0采样N | 道1采样0...道1采样N | ...循环所有通道所有道 + * 数据类型:小端序 short 16位有符号整型 + */ +bool Rd3Parser::loadRd3Binary(const QString &rd3FilePath, GPRDataModel &model) { + SCOPED_PERF_TIMER("Parser", "Rd3Parser::loadRd3Binary"); + + QFile file(rd3FilePath); + if (!file.open(QIODevice::ReadOnly)) { + qDebug() << "Error: Open rd3 binary failed:" << rd3FilePath; + return false; + } + + const int samplesPerTrace = model.header.samplesPerTrace; + const int totalTraceCount = model.header.numTraces; + + // ========== 关键修正:rd3无头部偏移,直接从文件0位置开始读波形 ========== + const qint64 dataStartOffset = 0; + file.seek(dataStartOffset); + + // 理论完整字节大小校验 + const qint64 singleTraceByteSize = samplesPerTrace * sizeof(short); + const qint64 fullExpectedBytes = totalTraceCount * singleTraceByteSize; + const qint64 realFileBytes = file.size(); + + if (realFileBytes < fullExpectedBytes) { + qDebug() << "Warning: RD3 file size smaller than theoretical data size!" + << "Expected:" << fullExpectedBytes << "Actual:" << realFileBytes; + } + + // 预分配内存容器,减少动态扩容开销 + model.traces.reserve(totalTraceCount); + + // 单道读取缓冲区 + QByteArray traceBuffer; + traceBuffer.resize(singleTraceByteSize); + + // 通道数量兜底 + const int channelCnt = model.header.numberOfChannels > 0 ? model.header.numberOfChannels : 1; + model.channels = channelCnt; + // 每通道独立道数量 + model.tracesPerChannel = totalTraceCount / channelCnt; + + // 计算整条测线总行进距离 + if (model.header.distanceInc > 1e-6) { + model.totalDistance = static_cast(model.tracesPerChannel * model.header.distanceInc); + } else { + model.totalDistance = static_cast(model.header.stopPosition - model.header.startPosition); + } + + // 循环逐道读取二进制波形 + for (int traceGlobalIdx = 0; traceGlobalIdx < totalTraceCount; ++traceGlobalIdx) { + + if (file.atEnd()) { + qDebug() << "Warning: File ended early at global trace index" << traceGlobalIdx; + break; + } + + // 读取一整道所有采样字节 + qint64 readBytes = file.read(traceBuffer.data(), traceBuffer.size()); + if (readBytes != traceBuffer.size()) { + qDebug() << "Warning: Trace" << traceGlobalIdx << "incomplete byte read"; + break; + } + + RadarTrace oneTrace; + oneTrace.amplitudes.resize(samplesPerTrace); + // 二进制指针强转short(Windows平台原生小端序,雷达标准字节序) + const short* rawShortBuf = reinterpret_cast(traceBuffer.constData()); + + // 填充单道振幅值 + for (int s = 0; s < samplesPerTrace; s++) { + oneTrace.amplitudes[s] = rawShortBuf[s]; + } + + // 分配所属通道、通道内道序号 + int chNo = traceGlobalIdx % channelCnt; + int traceInChIdx = traceGlobalIdx / channelCnt; + oneTrace.channelNumber = chNo; + + // 计算空间三维坐标 X(天线横向偏移) Y(行进里程) Z=0地面平面 + float xOff = 0.0f; + float yOff = 0.0f; + if (chNo < model.header.chXOffsets.size()) xOff = model.header.chXOffsets[chNo]; + if (chNo < model.header.chYOffsets.size()) yOff = model.header.chYOffsets[chNo]; + + float lineDist = static_cast(model.header.startPosition + traceInChIdx * model.header.distanceInc); + oneTrace.position = QVector3D(xOff, lineDist - yOff, 0.0f); + + model.traces.append(std::move(oneTrace)); + } + + file.close(); + + if (model.traces.isEmpty()) { + qDebug() << "Error: No valid traces loaded from rd3"; + return false; + } + + qDebug() << "RD3 Binary Load OK, total valid traces:" << model.traces.size(); + return true; +} + +/** + * @brief 安全字符串转double数值,优先提取字符串开头的数字,转换失败返回0 + * @param value 原始字符串(如:200 MHz shielded) + * @return 浮点结果 + */ +double Rd3Parser::extractDouble(const QString& value) { + bool ok = false; + double res = 0.0; + + // 1. 遍历字符串,截取开头连续的数字/小数点部分 + int numLength = 0; + while (numLength < value.length()) { + QChar ch = value.at(numLength); + // 只保留 数字 和 小数点(支持小数,如 200.5 MHz) + if (ch.isDigit() || ch == '.') { + numLength++; + } + else { + // 遇到非数字/小数点,停止截取 + break; + } + } + + // 2. 截取有效数字字符串并转换 + if (numLength > 0) { + QString numStr = value.left(numLength); + res = numStr.toDouble(&ok); + } + + // 3. 转换成功返回数值,失败返回0.0 + return ok ? res : 0.0; +} + +/** + * @brief 安全字符串转int整型,转换失败返回0 + * @param value 原始rad文件字符串数值 + * @return 整型结果 + */ +int Rd3Parser::extractInt(const QString &value) { + bool ok = false; + int res = value.toInt(&ok); + return ok ? res : 0; +} + diff --git a/external/gpr3dviewer/Rd3Parser.h b/external/gpr3dviewer/Rd3Parser.h new file mode 100644 index 0000000..a21c990 --- /dev/null +++ b/external/gpr3dviewer/Rd3Parser.h @@ -0,0 +1,21 @@ +#ifndef RD3PARSER_H +#define RD3PARSER_H + +#include "GPRDataModel.h" +#include + +class Rd3Parser { +public: + // 主入口:传入 .rad 文件路径,它会自动寻找同名的 .rd3 文件 + static bool loadFromRad(const QString &radFilePath, GPRDataModel &model); + +private: + static bool parseRadHeader(const QString &radFilePath, GPRDataModel::Header &header); + static bool loadRd3Binary(const QString &rd3FilePath, GPRDataModel &model); + + // 辅助:从 .rad 的字符串中提取数值 + static double extractDouble(const QString &value); + static int extractInt(const QString &value); +}; + +#endif // RD3PARSER_H \ No newline at end of file diff --git a/external/gpr3dviewer/SurveyGeometry.h b/external/gpr3dviewer/SurveyGeometry.h new file mode 100644 index 0000000..deeb6f1 --- /dev/null +++ b/external/gpr3dviewer/SurveyGeometry.h @@ -0,0 +1,127 @@ +#ifndef SURVEYGEOMETRY_H +#define SURVEYGEOMETRY_H + +#include +#include +#include + +struct SurveyGeometry { + double rtkOffsetX = 0.0; // RTK相对天线中心的设备x轴偏移(m),头文件无对应字段,保留给用户输入 + double rtkOffsetY = 0.0; // RTK相对天线中心的设备y轴偏移(m),来自CH_Y_OFFSETS + double ch1XRel = 0.0; // 第1通道相对天线中心的x坐标(m),由CH_X_OFFSETS首尾值推导 + QVector channelXRel; // 各通道相对天线中心的x坐标(m),来自CH_X_OFFSETS归一到天线中心 + int channelCount = 1; // 通道总数,来自NUMBER_OF_CH + int cgcsZone = 0; // CGCS2000 3度带带号,0表示自动检测 + double centralMeridianDeg = 0.0; // 中央经线(度),自动推导 + + bool operator==(const SurveyGeometry &other) const { + return rtkOffsetX == other.rtkOffsetX && + rtkOffsetY == other.rtkOffsetY && + ch1XRel == other.ch1XRel && + channelXRel == other.channelXRel && + channelCount == other.channelCount && + cgcsZone == other.cgcsZone && + centralMeridianDeg == other.centralMeridianDeg; + } + + void applyHeaderOffsets(int numberOfChannels, + const QVector &chXOffsets, + const QVector &chYOffsets) + { + const int offsetChannelCount = qMax(chXOffsets.size(), chYOffsets.size()); + channelCount = qMax(1, numberOfChannels > 0 ? numberOfChannels : offsetChannelCount); + rtkOffsetY = chYOffsets.isEmpty() ? 0.0 : static_cast(chYOffsets.first()); + + channelXRel.clear(); + channelXRel.reserve(channelCount); + + if (chXOffsets.size() >= 2) { + const double centerX = (static_cast(chXOffsets.first()) + chXOffsets.last()) / 2.0; + for (int i = 0; i < chXOffsets.size() && i < channelCount; ++i) { + channelXRel.append(static_cast(chXOffsets[i]) - centerX); + } + ch1XRel = channelXRel.isEmpty() ? 0.0 : channelXRel.first(); + } else { + ch1XRel = 0.0; + } + + if (channelXRel.size() < channelCount) { + const int existing = channelXRel.size(); + channelXRel.resize(channelCount); + if (existing == 0) { + for (int c = 0; c < channelCount; ++c) { + channelXRel[c] = 0.0; + } + } else if (channelCount > 1) { + const double lastXRel = -ch1XRel; + for (int c = existing; c < channelCount; ++c) { + channelXRel[c] = ch1XRel + (lastXRel - ch1XRel) * c / (channelCount - 1.0); + } + } + } + } + + static SurveyGeometry fromHeaderOffsets(int numberOfChannels, + const QVector &chXOffsets, + const QVector &chYOffsets) + { + SurveyGeometry g; + g.applyHeaderOffsets(numberOfChannels, chXOffsets, chYOffsets); + return g; + } + + QJsonObject toJson() const { + QJsonObject obj; + obj["rtkOffsetX"] = rtkOffsetX; + obj["rtkOffsetY"] = rtkOffsetY; + obj["ch1XRel"] = ch1XRel; + QJsonArray channelXRelArray; + for (double x : channelXRel) { + channelXRelArray.append(x); + } + obj["channelXRel"] = channelXRelArray; + obj["channelCount"] = channelCount; + obj["cgcsZone"] = cgcsZone; + obj["centralMeridianDeg"] = centralMeridianDeg; + return obj; + } + + static SurveyGeometry fromJson(const QJsonObject &obj) { + SurveyGeometry g; + g.rtkOffsetX = obj.value("rtkOffsetX").toDouble(0.0); + g.rtkOffsetY = obj.value("rtkOffsetY").toDouble(0.0); + g.ch1XRel = obj.value("ch1XRel").toDouble(0.0); + g.channelCount = qMax(1, obj.value("channelCount").toInt(1)); + g.cgcsZone = obj.value("cgcsZone").toInt(0); + g.centralMeridianDeg = obj.value("centralMeridianDeg").toDouble(0.0); + + const QJsonArray channelXRelArray = obj.value("channelXRel").toArray(); + for (const QJsonValue &value : channelXRelArray) { + g.channelXRel.append(value.toDouble(0.0)); + } + + if (g.channelXRel.isEmpty() && obj.contains("ch16XRel")) { + const double legacyLastXRel = obj.value("ch16XRel").toDouble(-g.ch1XRel); + g.channelXRel.resize(g.channelCount); + for (int c = 0; c < g.channelCount; ++c) { + g.channelXRel[c] = (g.channelCount > 1) + ? g.ch1XRel + (legacyLastXRel - g.ch1XRel) * c / (g.channelCount - 1.0) + : g.ch1XRel; + } + } else if (g.channelXRel.isEmpty()) { + g.channelXRel.resize(g.channelCount); + const double lastXRel = -g.ch1XRel; + for (int c = 0; c < g.channelCount; ++c) { + g.channelXRel[c] = (g.channelCount > 1) + ? g.ch1XRel + (lastXRel - g.ch1XRel) * c / (g.channelCount - 1.0) + : g.ch1XRel; + } + } else if (g.channelXRel.size() != g.channelCount) { + g.channelCount = qMax(1, g.channelXRel.size()); + } + + return g; + } +}; + +#endif // SURVEYGEOMETRY_H diff --git a/external/gpr3dviewer/TrajectoryCalculator.cpp b/external/gpr3dviewer/TrajectoryCalculator.cpp new file mode 100644 index 0000000..2516454 --- /dev/null +++ b/external/gpr3dviewer/TrajectoryCalculator.cpp @@ -0,0 +1,242 @@ +#include "TrajectoryCalculator.h" +#include +#include + +namespace { + +double planarDistance(const QVector3D &a, const QVector3D &b) +{ + const double dx = static_cast(a.x()) - b.x(); + const double dy = static_cast(a.y()) - b.y(); + return std::sqrt(dx * dx + dy * dy); +} + +QVector3D lerpPoint(const QVector3D &a, const QVector3D &b, double t) +{ + return a * static_cast(1.0 - t) + b * static_cast(t); +} + +QVector3D catmullRom(const QVector3D &p0, const QVector3D &p1, const QVector3D &p2, const QVector3D &p3, double t) +{ + const float tf = static_cast(t); + const float t2 = tf * tf; + const float t3 = t2 * tf; + return 0.5f * ((2.0f * p1) + (-p0 + p2) * tf + (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + + (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3); +} + +} // namespace + +double TrajectoryCalculator::headingAt(int traceIdx, const QVector &gpsPositions) +{ + const int n = gpsPositions.size(); + if (n <= 1) return 0.0; + + if (traceIdx < n - 1) { + double deltaX = gpsPositions[traceIdx + 1].x() - gpsPositions[traceIdx].x(); // 北向变化 + double deltaY = gpsPositions[traceIdx + 1].y() - gpsPositions[traceIdx].y(); // 东向变化 + return std::atan2(deltaY, deltaX); + } else { + // 最后一点复用前一点方向 + double deltaX = gpsPositions[traceIdx].x() - gpsPositions[traceIdx - 1].x(); + double deltaY = gpsPositions[traceIdx].y() - gpsPositions[traceIdx - 1].y(); + return std::atan2(deltaY, deltaX); + } +} + +bool TrajectoryCalculator::computeTrajectories(SurveyLine &line, const SurveyGeometry &geom) +{ + const QVector &gps = line.gpsPositions; + const int nRtk = gps.size(); + if (nRtk <= 0) return false; + + const int chCount = geom.channelCount; + if (chCount <= 0) return false; + + // 通道相对x偏移优先使用头文件CH_X_OFFSETS推导出的逐通道数组;旧工程无数组时按首通道对称补齐。 + QVector chXRel = geom.channelXRel; + if (chXRel.size() != chCount) { + chXRel.resize(chCount); + const double lastXRel = -geom.ch1XRel; + for (int c = 0; c < chCount; ++c) { + chXRel[c] = (chCount > 1) + ? geom.ch1XRel + (lastXRel - geom.ch1XRel) * c / (chCount - 1.0) + : geom.ch1XRel; + } + } + + // 预分配 channelTrajectories + line.channelTrajectories.resize(chCount); + for (int c = 0; c < chCount; ++c) { + line.channelTrajectories[c].resize(nRtk); + } + + // 遍历每个 RTK 点 + for (int i = 0; i < nRtk; ++i) { + double thetaY = headingAt(i, gps); + double thetaX = thetaY + M_PI / 2.0; + + // 旋转矩阵 R = [cos(theta_x), cos(theta_y); sin(theta_x), sin(theta_y)] + // 作用:将设备相对坐标系(x=右, y=前)转换为绝对坐标系(X=北, Y=东) + double r11 = std::cos(thetaX); + double r12 = std::cos(thetaY); + double r21 = std::sin(thetaX); + double r22 = std::sin(thetaY); + + // RTK 相对天线中心的偏移向量(设备坐标系) + double rtkLocalX = geom.rtkOffsetX; // 横向偏移 + double rtkLocalY = geom.rtkOffsetY; // 纵向偏移(前方为正) + + // 转换为绝对坐标系偏移 + double rtkAbsOffsetX = r11 * rtkLocalX + r12 * rtkLocalY; + double rtkAbsOffsetY = r21 * rtkLocalX + r22 * rtkLocalY; + + // 天线中心绝对坐标 = RTK - 偏移量 + double antX = gps[i].x() - rtkAbsOffsetX; + double antY = gps[i].y() - rtkAbsOffsetY; + double antZ = gps[i].z(); + + // 遍历每个通道 + for (int c = 0; c < chCount; ++c) { + // 通道相对坐标(设备坐标系) + double chLocalX = chXRel[c]; + double chLocalY = 0.0; // MATLAB 中 ch_y_rel = zeros(...) + + // 转换为绝对坐标系偏移 + double chAbsOffsetX = r11 * chLocalX + r12 * chLocalY; + double chAbsOffsetY = r21 * chLocalX + r22 * chLocalY; + + // 通道绝对坐标 + double chX = antX + chAbsOffsetX; + double chY = antY + chAbsOffsetY; + double chZ = antZ; + + line.channelTrajectories[c][i] = QVector3D(static_cast(chX), + static_cast(chY), + static_cast(chZ)); + } + } + + // 同步更新 GPRDataModel 中 traces 的 position + // 数据存储顺序:trace0_ch0, trace0_ch1, ... trace0_chN, trace1_ch0, ... + // 即 globalIdx = traceInChannel * chCount + channel + const int tracesPerChannel = line.data.getTracesPerChannel(); + for (int c = 0; c < chCount; ++c) { + for (int t = 0; t < tracesPerChannel && t < nRtk; ++t) { + int globalIdx = t * chCount + c; + if (globalIdx >= 0 && globalIdx < line.data.traces.size()) { + line.data.traces[globalIdx].position = line.channelTrajectories[c][t]; + } + } + } + + return true; +} + +QVector TrajectoryCalculator::resampleTrajectoryLinear(const QVector &input, int targetCount) +{ + QVector output; + if (targetCount <= 0 || input.isEmpty()) return output; + if (input.size() == 1 || targetCount == 1) { + output.resize(targetCount); + std::fill(output.begin(), output.end(), input.first()); + return output; + } + + output.reserve(targetCount); + const double scale = static_cast(input.size() - 1) / qMax(1, targetCount - 1); + for (int i = 0; i < targetCount; ++i) { + const double src = i * scale; + const int i0 = qBound(0, static_cast(std::floor(src)), input.size() - 1); + const int i1 = qBound(0, i0 + 1, input.size() - 1); + output.append(lerpPoint(input[i0], input[i1], src - i0)); + } + return output; +} + +QVector TrajectoryCalculator::resampleTrajectorySpline(const QVector &input, int targetCount) +{ + if (input.size() < 4) return resampleTrajectoryLinear(input, targetCount); + QVector output; + if (targetCount <= 0) return output; + if (targetCount == 1) return QVector{input.first()}; + + output.reserve(targetCount); + const double scale = static_cast(input.size() - 1) / qMax(1, targetCount - 1); + for (int i = 0; i < targetCount; ++i) { + const double src = i * scale; + const int i1 = qBound(0, static_cast(std::floor(src)), input.size() - 1); + const int i2 = qBound(0, i1 + 1, input.size() - 1); + const int i0 = qBound(0, i1 - 1, input.size() - 1); + const int i3 = qBound(0, i2 + 1, input.size() - 1); + output.append(catmullRom(input[i0], input[i1], input[i2], input[i3], src - i1)); + } + return output; +} + +TrajectoryFilterResult TrajectoryCalculator::filterAndInterpolateTrajectory(const QVector &input, + const TrajectoryFilterOptions &options, + int targetCount) +{ + TrajectoryFilterResult result; + const int n = input.size(); + result.keepMask = QVector(n, true); + result.distanceOutlierMask = QVector(n, false); + result.speedOutlierMask = QVector(n, false); + result.angleOutlierMask = QVector(n, false); + if (n <= 0 || targetCount <= 0) return result; + + for (int i = 1; i < n; ++i) { + const double dist = planarDistance(input[i - 1], input[i]); + if (options.distanceFilterEnabled && dist > options.maxSegmentDistanceM) { + result.distanceOutlierMask[i] = true; + result.keepMask[i] = false; + } + if (options.speedFilterEnabled && options.traceIntervalSec > 1e-9 && dist / options.traceIntervalSec > options.maxSpeedMps) { + result.speedOutlierMask[i] = true; + result.keepMask[i] = false; + } + } + + if (options.angleFilterEnabled) { + const double thresholdRad = options.maxTurnAngleDeg * M_PI / 180.0; + for (int i = 1; i + 1 < n; ++i) { + const double d0 = planarDistance(input[i - 1], input[i]); + const double d1 = planarDistance(input[i], input[i + 1]); + if (d0 < 1e-6 || d1 < 1e-6) continue; + const double ax = input[i].x() - input[i - 1].x(); + const double ay = input[i].y() - input[i - 1].y(); + const double bx = input[i + 1].x() - input[i].x(); + const double by = input[i + 1].y() - input[i].y(); + const double cosv = std::clamp((ax * bx + ay * by) / (d0 * d1), -1.0, 1.0); + const double turn = std::acos(cosv); + if (turn > thresholdRad) { + result.angleOutlierMask[i] = true; + result.keepMask[i] = false; + } + } + } + + if (options.preserveEndpoints && n > 0) { + result.keepMask[0] = true; + result.keepMask[n - 1] = true; + } + + QVector kept; + kept.reserve(n); + for (int i = 0; i < n; ++i) { + if (result.keepMask.value(i, true)) kept.append(input[i]); + } + if (kept.size() < 2) { + kept = input; + result.warnings.append(QStringLiteral("过滤后有效轨迹点过少,已使用原始轨迹插值。")); + } + + if (options.interpolationMode == TrajectoryFilterOptions::InterpolationMode::Spline) { + if (kept.size() < 4) result.warnings.append(QStringLiteral("样条插值点数不足,已回退到线性插值。")); + result.outputPositions = resampleTrajectorySpline(kept, targetCount); + } else { + result.outputPositions = resampleTrajectoryLinear(kept, targetCount); + } + return result; +} diff --git a/external/gpr3dviewer/TrajectoryCalculator.h b/external/gpr3dviewer/TrajectoryCalculator.h new file mode 100644 index 0000000..07aeedc --- /dev/null +++ b/external/gpr3dviewer/TrajectoryCalculator.h @@ -0,0 +1,49 @@ +#ifndef TRAJECTORYCALCULATOR_H +#define TRAJECTORYCALCULATOR_H + +#include "GPRDataModel.h" +#include "SurveyGeometry.h" +#include +#include + +struct TrajectoryFilterOptions { + bool distanceFilterEnabled = true; + double maxSegmentDistanceM = 1.0; + bool speedFilterEnabled = false; + double maxSpeedMps = 60.0; + double traceIntervalSec = 0.05; + bool angleFilterEnabled = true; + double maxTurnAngleDeg = 60.0; + enum class InterpolationMode { Linear, Spline }; + InterpolationMode interpolationMode = InterpolationMode::Linear; + bool preserveEndpoints = true; + bool preserveManualEdits = true; +}; + +struct TrajectoryFilterResult { + QVector outputPositions; + QVector keepMask; + QVector distanceOutlierMask; + QVector speedOutlierMask; + QVector angleOutlierMask; + QStringList warnings; +}; + +class TrajectoryCalculator { +public: + // 计算第 traceIdx 个 RTK 点的行进方向角(弧度) + // 返回 theta_y = atan2(dY, dX),其中 X=北向, Y=东向 + static double headingAt(int traceIdx, const QVector &gpsPositions); + + // 根据 RTK 轨迹和天线几何参数,计算所有通道的绝对坐标 + // 结果写入 line.channelTrajectories 和 line.data.traces[].position + static bool computeTrajectories(SurveyLine &line, const SurveyGeometry &geom); + + static QVector resampleTrajectoryLinear(const QVector &input, int targetCount); + static QVector resampleTrajectorySpline(const QVector &input, int targetCount); + static TrajectoryFilterResult filterAndInterpolateTrajectory(const QVector &input, + const TrajectoryFilterOptions &options, + int targetCount); +}; + +#endif // TRAJECTORYCALCULATOR_H diff --git a/external/gpr3dviewer/third_party/kissfft/_kiss_fft_guts.h b/external/gpr3dviewer/third_party/kissfft/_kiss_fft_guts.h new file mode 100644 index 0000000..f4b6832 --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/_kiss_fft_guts.h @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2003-2010, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +/* kiss_fft.h + defines kiss_fft_scalar as either short or a float type + and defines + typedef struct { kiss_fft_scalar r; kiss_fft_scalar i; }kiss_fft_cpx; */ + +#ifndef _kiss_fft_guts_h +#define _kiss_fft_guts_h + +#include "kiss_fft.h" +#include "kiss_fft_log.h" +#include + +#define MAXFACTORS 32 +/* e.g. an fft of length 128 has 4 factors + as far as kissfft is concerned + 4*4*4*2 + */ + +struct kiss_fft_state{ + int nfft; + int inverse; + int factors[2*MAXFACTORS]; + kiss_fft_cpx twiddles[1]; +}; + +/* + Explanation of macros dealing with complex math: + + C_MUL(m,a,b) : m = a*b + C_FIXDIV( c , div ) : if a fixed point impl., c /= div. noop otherwise + C_SUB( res, a,b) : res = a - b + C_SUBFROM( res , a) : res -= a + C_ADDTO( res , a) : res += a + * */ +#ifdef FIXED_POINT +#include +#if (FIXED_POINT==32) +# define FRACBITS 31 +# define SAMPPROD int64_t +#define SAMP_MAX INT32_MAX +#define SAMP_MIN INT32_MIN +#else +# define FRACBITS 15 +# define SAMPPROD int32_t +#define SAMP_MAX INT16_MAX +#define SAMP_MIN INT16_MIN +#endif + +#if defined(CHECK_OVERFLOW) +# define CHECK_OVERFLOW_OP(a,op,b) \ + if ( (SAMPPROD)(a) op (SAMPPROD)(b) > SAMP_MAX || (SAMPPROD)(a) op (SAMPPROD)(b) < SAMP_MIN ) { \ + KISS_FFT_WARNING("overflow (%d " #op" %d) = %ld", (a),(b),(SAMPPROD)(a) op (SAMPPROD)(b)); } +#endif + + +# define smul(a,b) ( (SAMPPROD)(a)*(b) ) +# define sround( x ) (kiss_fft_scalar)( ( (x) + (1<<(FRACBITS-1)) ) >> FRACBITS ) + +# define S_MUL(a,b) sround( smul(a,b) ) + +# define C_MUL(m,a,b) \ + do{ (m).r = sround( smul((a).r,(b).r) - smul((a).i,(b).i) ); \ + (m).i = sround( smul((a).r,(b).i) + smul((a).i,(b).r) ); }while(0) + +# define DIVSCALAR(x,k) \ + (x) = sround( smul( x, SAMP_MAX/k ) ) + +# define C_FIXDIV(c,div) \ + do { DIVSCALAR( (c).r , div); \ + DIVSCALAR( (c).i , div); }while (0) + +# define C_MULBYSCALAR( c, s ) \ + do{ (c).r = sround( smul( (c).r , s ) ) ;\ + (c).i = sround( smul( (c).i , s ) ) ; }while(0) + +#else /* not FIXED_POINT*/ + +# define S_MUL(a,b) ( (a)*(b) ) +#define C_MUL(m,a,b) \ + do{ (m).r = (a).r*(b).r - (a).i*(b).i;\ + (m).i = (a).r*(b).i + (a).i*(b).r; }while(0) +# define C_FIXDIV(c,div) /* NOOP */ +# define C_MULBYSCALAR( c, s ) \ + do{ (c).r *= (s);\ + (c).i *= (s); }while(0) +#endif + +#ifndef CHECK_OVERFLOW_OP +# define CHECK_OVERFLOW_OP(a,op,b) /* noop */ +#endif + +#define C_ADD( res, a,b)\ + do { \ + CHECK_OVERFLOW_OP((a).r,+,(b).r)\ + CHECK_OVERFLOW_OP((a).i,+,(b).i)\ + (res).r=(a).r+(b).r; (res).i=(a).i+(b).i; \ + }while(0) +#define C_SUB( res, a,b)\ + do { \ + CHECK_OVERFLOW_OP((a).r,-,(b).r)\ + CHECK_OVERFLOW_OP((a).i,-,(b).i)\ + (res).r=(a).r-(b).r; (res).i=(a).i-(b).i; \ + }while(0) +#define C_ADDTO( res , a)\ + do { \ + CHECK_OVERFLOW_OP((res).r,+,(a).r)\ + CHECK_OVERFLOW_OP((res).i,+,(a).i)\ + (res).r += (a).r; (res).i += (a).i;\ + }while(0) + +#define C_SUBFROM( res , a)\ + do {\ + CHECK_OVERFLOW_OP((res).r,-,(a).r)\ + CHECK_OVERFLOW_OP((res).i,-,(a).i)\ + (res).r -= (a).r; (res).i -= (a).i; \ + }while(0) + + +#ifdef FIXED_POINT +# define KISS_FFT_COS(phase) floor(.5+SAMP_MAX * cos (phase)) +# define KISS_FFT_SIN(phase) floor(.5+SAMP_MAX * sin (phase)) +# define HALF_OF(x) ((x)>>1) +#elif defined(USE_SIMD) +#if defined(HAVE_LASX) +#define KISS_FFT_COS(phase) ({ \ + float __cos_val = cosf(phase); \ + (__m256)(__lasx_xvldrepl_w(&__cos_val, 0)); \ +}) +#define KISS_FFT_SIN(phase) ({ \ + float __sin_val = sinf(phase); \ + (__m256)(__lasx_xvldrepl_w(&__sin_val, 0)); \ +}) +#define HALF_OF(x) ((x) * (__m256)(__lasx_xvreplgr2vr_w(0x3F000000))) // 0.5f +#elif defined(HAVE_LSX) +#define KISS_FFT_COS(phase) ({ \ + float __cos_val = cosf(phase); \ + (__m128)(__lsx_vldrepl_w(&__cos_val, 0)); \ +}) +#define KISS_FFT_SIN(phase) ({ \ + float __sin_val = sinf(phase); \ + (__m128)(__lsx_vldrepl_w(&__sin_val, 0)); \ +}) +#define HALF_OF(x) ((x) * (__m128)(__lsx_vreplgr2vr_w(0x3F000000))) // 0.5f +#else +# define KISS_FFT_COS(phase) _mm_set1_ps( cos(phase) ) +# define KISS_FFT_SIN(phase) _mm_set1_ps( sin(phase) ) +# define HALF_OF(x) ((x)*_mm_set1_ps(.5)) +#endif +#else +# define KISS_FFT_COS(phase) (kiss_fft_scalar) cos(phase) +# define KISS_FFT_SIN(phase) (kiss_fft_scalar) sin(phase) +# define HALF_OF(x) ((x)*((kiss_fft_scalar).5)) +#endif + +#define kf_cexp(x,phase) \ + do{ \ + (x)->r = KISS_FFT_COS(phase);\ + (x)->i = KISS_FFT_SIN(phase);\ + }while(0) + + +/* a debugging function */ +#define pcpx(c)\ + KISS_FFT_DEBUG("%g + %gi\n",(double)((c)->r),(double)((c)->i)) + + +#ifdef KISS_FFT_USE_ALLOCA +// define this to allow use of alloca instead of malloc for temporary buffers +// Temporary buffers are used in two case: +// 1. FFT sizes that have "bad" factors. i.e. not 2,3 and 5 +// 2. "in-place" FFTs. Notice the quotes, since kissfft does not really do an in-place transform. +#include +#define KISS_FFT_TMP_ALLOC(nbytes) alloca(nbytes) +#define KISS_FFT_TMP_FREE(ptr) +#else +#define KISS_FFT_TMP_ALLOC(nbytes) KISS_FFT_MALLOC(nbytes) +#define KISS_FFT_TMP_FREE(ptr) KISS_FFT_FREE(ptr) +#endif + +#endif /* _kiss_fft_guts_h */ + diff --git a/external/gpr3dviewer/third_party/kissfft/kiss_fft.c b/external/gpr3dviewer/third_party/kissfft/kiss_fft.c new file mode 100644 index 0000000..aba63e0 --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/kiss_fft.c @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2003-2010, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +#include +#include "_kiss_fft_guts.h" +/* The guts header contains all the multiplication and addition macros that are defined for + fixed or floating point complex numbers. It also delares the kf_ internal functions. + */ + +static void kf_bfly2( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m + ) +{ + kiss_fft_cpx * Fout2; + kiss_fft_cpx * tw1 = st->twiddles; + kiss_fft_cpx t; + Fout2 = Fout + m; + do{ + C_FIXDIV(*Fout,2); C_FIXDIV(*Fout2,2); + + C_MUL (t, *Fout2 , *tw1); + tw1 += fstride; + C_SUB( *Fout2 , *Fout , t ); + C_ADDTO( *Fout , t ); + ++Fout2; + ++Fout; + }while (--m); +} + +static void kf_bfly4( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + const size_t m + ) +{ + kiss_fft_cpx *tw1,*tw2,*tw3; + kiss_fft_cpx scratch[6]; + size_t k=m; + const size_t m2=2*m; + const size_t m3=3*m; + + + tw3 = tw2 = tw1 = st->twiddles; + + do { + C_FIXDIV(*Fout,4); C_FIXDIV(Fout[m],4); C_FIXDIV(Fout[m2],4); C_FIXDIV(Fout[m3],4); + + C_MUL(scratch[0],Fout[m] , *tw1 ); + C_MUL(scratch[1],Fout[m2] , *tw2 ); + C_MUL(scratch[2],Fout[m3] , *tw3 ); + + C_SUB( scratch[5] , *Fout, scratch[1] ); + C_ADDTO(*Fout, scratch[1]); + C_ADD( scratch[3] , scratch[0] , scratch[2] ); + C_SUB( scratch[4] , scratch[0] , scratch[2] ); + C_SUB( Fout[m2], *Fout, scratch[3] ); + tw1 += fstride; + tw2 += fstride*2; + tw3 += fstride*3; + C_ADDTO( *Fout , scratch[3] ); + + if(st->inverse) { + Fout[m].r = scratch[5].r - scratch[4].i; + Fout[m].i = scratch[5].i + scratch[4].r; + Fout[m3].r = scratch[5].r + scratch[4].i; + Fout[m3].i = scratch[5].i - scratch[4].r; + }else{ + Fout[m].r = scratch[5].r + scratch[4].i; + Fout[m].i = scratch[5].i - scratch[4].r; + Fout[m3].r = scratch[5].r - scratch[4].i; + Fout[m3].i = scratch[5].i + scratch[4].r; + } + ++Fout; + }while(--k); +} + +static void kf_bfly3( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + size_t m + ) +{ + size_t k=m; + const size_t m2 = 2*m; + kiss_fft_cpx *tw1,*tw2; + kiss_fft_cpx scratch[5]; + kiss_fft_cpx epi3; + epi3 = st->twiddles[fstride*m]; + + tw1=tw2=st->twiddles; + + do{ + C_FIXDIV(*Fout,3); C_FIXDIV(Fout[m],3); C_FIXDIV(Fout[m2],3); + + C_MUL(scratch[1],Fout[m] , *tw1); + C_MUL(scratch[2],Fout[m2] , *tw2); + + C_ADD(scratch[3],scratch[1],scratch[2]); + C_SUB(scratch[0],scratch[1],scratch[2]); + tw1 += fstride; + tw2 += fstride*2; + + Fout[m].r = Fout->r - HALF_OF(scratch[3].r); + Fout[m].i = Fout->i - HALF_OF(scratch[3].i); + + C_MULBYSCALAR( scratch[0] , epi3.i ); + + C_ADDTO(*Fout,scratch[3]); + + Fout[m2].r = Fout[m].r + scratch[0].i; + Fout[m2].i = Fout[m].i - scratch[0].r; + + Fout[m].r -= scratch[0].i; + Fout[m].i += scratch[0].r; + + ++Fout; + }while(--k); +} + +static void kf_bfly5( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m + ) +{ + kiss_fft_cpx *Fout0,*Fout1,*Fout2,*Fout3,*Fout4; + int u; + kiss_fft_cpx scratch[13]; + kiss_fft_cpx * twiddles = st->twiddles; + kiss_fft_cpx *tw; + kiss_fft_cpx ya,yb; + ya = twiddles[fstride*m]; + yb = twiddles[fstride*2*m]; + + Fout0=Fout; + Fout1=Fout0+m; + Fout2=Fout0+2*m; + Fout3=Fout0+3*m; + Fout4=Fout0+4*m; + + tw=st->twiddles; + for ( u=0; ur += scratch[7].r + scratch[8].r; + Fout0->i += scratch[7].i + scratch[8].i; + + scratch[5].r = scratch[0].r + S_MUL(scratch[7].r,ya.r) + S_MUL(scratch[8].r,yb.r); + scratch[5].i = scratch[0].i + S_MUL(scratch[7].i,ya.r) + S_MUL(scratch[8].i,yb.r); + + scratch[6].r = S_MUL(scratch[10].i,ya.i) + S_MUL(scratch[9].i,yb.i); + scratch[6].i = -S_MUL(scratch[10].r,ya.i) - S_MUL(scratch[9].r,yb.i); + + C_SUB(*Fout1,scratch[5],scratch[6]); + C_ADD(*Fout4,scratch[5],scratch[6]); + + scratch[11].r = scratch[0].r + S_MUL(scratch[7].r,yb.r) + S_MUL(scratch[8].r,ya.r); + scratch[11].i = scratch[0].i + S_MUL(scratch[7].i,yb.r) + S_MUL(scratch[8].i,ya.r); + scratch[12].r = - S_MUL(scratch[10].i,yb.i) + S_MUL(scratch[9].i,ya.i); + scratch[12].i = S_MUL(scratch[10].r,yb.i) - S_MUL(scratch[9].r,ya.i); + + C_ADD(*Fout2,scratch[11],scratch[12]); + C_SUB(*Fout3,scratch[11],scratch[12]); + + ++Fout0;++Fout1;++Fout2;++Fout3;++Fout4; + } +} + +/* perform the butterfly for one stage of a mixed radix FFT */ +static void kf_bfly_generic( + kiss_fft_cpx * Fout, + const size_t fstride, + const kiss_fft_cfg st, + int m, + int p + ) +{ + int u,k,q1,q; + kiss_fft_cpx * twiddles = st->twiddles; + kiss_fft_cpx t; + int Norig = st->nfft; + + kiss_fft_cpx * scratch = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC(sizeof(kiss_fft_cpx)*p); + if (scratch == NULL){ + KISS_FFT_ERROR("Memory allocation failed."); + return; + } + + for ( u=0; u=Norig) twidx-=Norig; + C_MUL(t,scratch[q] , twiddles[twidx] ); + C_ADDTO( Fout[ k ] ,t); + } + k += m; + } + } + KISS_FFT_TMP_FREE(scratch); +} + +static +void kf_work( + kiss_fft_cpx * Fout, + const kiss_fft_cpx * f, + const size_t fstride, + int in_stride, + int * factors, + const kiss_fft_cfg st + ) +{ + kiss_fft_cpx * Fout_beg=Fout; + const int p=*factors++; /* the radix */ + const int m=*factors++; /* stage's fft length/p */ + const kiss_fft_cpx * Fout_end = Fout + p*m; + +#ifdef _OPENMP + // use openmp extensions at the + // top-level (not recursive) + if (fstride==1 && p<=5 && m!=1) + { + int k; + + // execute the p different work units in different threads +# pragma omp parallel for + for (k=0;k floor_sqrt) + p = n; /* no more factors, skip to end */ + } + n /= p; + *facbuf++ = p; + *facbuf++ = n; + } while (n > 1); +} + +/* + * + * User-callable function to allocate all necessary storage space for the fft. + * + * The return value is a contiguous block of memory, allocated with malloc. As such, + * It can be freed with free(), rather than a kiss_fft-specific function. + * */ +kiss_fft_cfg kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem ) +{ + KISS_FFT_ALIGN_CHECK(mem) + + kiss_fft_cfg st=NULL; + // check for overflow condition {memneeded > SIZE_MAX}. + if (nfft >= (SIZE_MAX - 2*sizeof(struct kiss_fft_state))/sizeof(kiss_fft_cpx)) + return NULL; + + size_t memneeded = KISS_FFT_ALIGN_SIZE_UP(sizeof(struct kiss_fft_state) + + sizeof(kiss_fft_cpx)*(nfft-1)); /* twiddle factors*/ + + if ( lenmem==NULL ) { + st = ( kiss_fft_cfg)KISS_FFT_MALLOC( memneeded ); + }else{ + if (mem != NULL && *lenmem >= memneeded) + st = (kiss_fft_cfg)mem; + *lenmem = memneeded; + } + if (st) { + int i; + st->nfft=nfft; + st->inverse = inverse_fft; + + for (i=0;iinverse) + phase *= -1; + kf_cexp(st->twiddles+i, phase ); + } + + kf_factor(nfft,st->factors); + } + return st; +} + + +void kiss_fft_stride(kiss_fft_cfg st,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int in_stride) +{ + if (fin == fout) { + //NOTE: this is not really an in-place FFT algorithm. + //It just performs an out-of-place FFT into a temp buffer + if (fout == NULL){ + KISS_FFT_ERROR("fout buffer NULL."); + return; + } + + kiss_fft_cpx * tmpbuf = (kiss_fft_cpx*)KISS_FFT_TMP_ALLOC( sizeof(kiss_fft_cpx)*st->nfft); + if (tmpbuf == NULL){ + KISS_FFT_ERROR("Memory allocation error."); + return; + } + + + + kf_work(tmpbuf,fin,1,in_stride, st->factors,st); + memcpy(fout,tmpbuf,sizeof(kiss_fft_cpx)*st->nfft); + KISS_FFT_TMP_FREE(tmpbuf); + }else{ + kf_work( fout, fin, 1,in_stride, st->factors,st ); + } +} + +void kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout) +{ + kiss_fft_stride(cfg,fin,fout,1); +} + + +void kiss_fft_cleanup(void) +{ + // nothing needed any more +} + +int kiss_fft_next_fast_size(int n) +{ + while(1) { + int m=n; + while ( (m%2) == 0 ) m/=2; + while ( (m%3) == 0 ) m/=3; + while ( (m%5) == 0 ) m/=5; + if (m<=1) + break; /* n is completely factorable by twos, threes, and fives */ + n++; + } + return n; +} diff --git a/external/gpr3dviewer/third_party/kissfft/kiss_fft.h b/external/gpr3dviewer/third_party/kissfft/kiss_fft.h new file mode 100644 index 0000000..51b6306 --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/kiss_fft.h @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2003-2010, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +#ifndef KISS_FFT_H +#define KISS_FFT_H + +#include +#include +#include +#include + +// Define KISS_FFT_SHARED macro to properly export symbols +#ifdef KISS_FFT_SHARED +# ifdef _WIN32 +# ifdef KISS_FFT_BUILD +# define KISS_FFT_API __declspec(dllexport) +# else +# define KISS_FFT_API __declspec(dllimport) +# endif +# else +# define KISS_FFT_API __attribute__ ((visibility ("default"))) +# endif +#else +# define KISS_FFT_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* + ATTENTION! + If you would like a : + -- a utility that will handle the caching of fft objects + -- real-only (no imaginary time component ) FFT + -- a multi-dimensional FFT + -- a command-line utility to perform ffts + -- a command-line utility to perform fast-convolution filtering + + Then see kfc.h kiss_fftr.h kiss_fftnd.h fftutil.c kiss_fastfir.c + in the tools/ directory. +*/ + +/* User may override KISS_FFT_MALLOC and/or KISS_FFT_FREE. */ +#ifdef USE_SIMD +#ifdef HAVE_LASX +# include +# define kiss_fft_scalar __m256 +# ifndef KISS_FFT_MALLOC +# define KISS_FFT_MALLOC(nbytes) aligned_alloc(32, KISS_FFT_ALIGN_SIZE_UP(nbytes)) +# define KISS_FFT_ALIGN_CHECK(ptr) +# define KISS_FFT_ALIGN_SIZE_UP(size) ((size + 31UL) & ~0x1FUL) +# endif +# ifndef KISS_FFT_FREE +# define KISS_FFT_FREE free +# endif +#elif defined(HAVE_LSX) +# include +# define kiss_fft_scalar __m128 +# ifndef KISS_FFT_MALLOC +# define KISS_FFT_MALLOC(nbytes) aligned_alloc(16, KISS_FFT_ALIGN_SIZE_UP(nbytes)) +# define KISS_FFT_ALIGN_CHECK(ptr) +# define KISS_FFT_ALIGN_SIZE_UP(size) ((size + 15UL) & ~0xFUL) +# endif +# ifndef KISS_FFT_FREE +# define KISS_FFT_FREE free +# endif +#else +# include +# define kiss_fft_scalar __m128 +# ifndef KISS_FFT_MALLOC +# define KISS_FFT_MALLOC(nbytes) _mm_malloc(nbytes,16) +# define KISS_FFT_ALIGN_CHECK(ptr) +# define KISS_FFT_ALIGN_SIZE_UP(size) ((size + 15UL) & ~0xFUL) +# endif +# ifndef KISS_FFT_FREE +# define KISS_FFT_FREE _mm_free +# endif +#endif +#else +# define KISS_FFT_ALIGN_CHECK(ptr) +# define KISS_FFT_ALIGN_SIZE_UP(size) (size) +# ifndef KISS_FFT_MALLOC +# define KISS_FFT_MALLOC malloc +# endif +# ifndef KISS_FFT_FREE +# define KISS_FFT_FREE free +# endif +#endif + + +#ifdef FIXED_POINT +#include +# if (FIXED_POINT == 32) +# define kiss_fft_scalar int32_t +# else +# define kiss_fft_scalar int16_t +# endif +#else +# ifndef kiss_fft_scalar +/* default is float */ +# define kiss_fft_scalar float +# endif +#endif + +typedef struct { + kiss_fft_scalar r; + kiss_fft_scalar i; +}kiss_fft_cpx; + +typedef struct kiss_fft_state* kiss_fft_cfg; + +/* + * kiss_fft_alloc + * + * Initialize a FFT (or IFFT) algorithm's cfg/state buffer. + * + * typical usage: kiss_fft_cfg mycfg=kiss_fft_alloc(1024,0,NULL,NULL); + * + * The return value from fft_alloc is a cfg buffer used internally + * by the fft routine or NULL. + * + * If lenmem is NULL, then kiss_fft_alloc will allocate a cfg buffer using malloc. + * The returned value should be free()d when done to avoid memory leaks. + * + * The state can be placed in a user supplied buffer 'mem': + * If lenmem is not NULL and mem is not NULL and *lenmem is large enough, + * then the function places the cfg in mem and the size used in *lenmem + * and returns mem. + * + * If lenmem is not NULL and ( mem is NULL or *lenmem is not large enough), + * then the function returns NULL and places the minimum cfg + * buffer size in *lenmem. + * */ + +kiss_fft_cfg KISS_FFT_API kiss_fft_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem); + +/* + * kiss_fft(cfg,in_out_buf) + * + * Perform an FFT on a complex input buffer. + * for a forward FFT, + * fin should be f[0] , f[1] , ... ,f[nfft-1] + * fout will be F[0] , F[1] , ... ,F[nfft-1] + * Note that each element is complex and can be accessed like + f[k].r and f[k].i + * */ +void KISS_FFT_API kiss_fft(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout); + +/* + A more generic version of the above function. It reads its input from every Nth sample. + * */ +void KISS_FFT_API kiss_fft_stride(kiss_fft_cfg cfg,const kiss_fft_cpx *fin,kiss_fft_cpx *fout,int fin_stride); + +/* If kiss_fft_alloc allocated a buffer, it is one contiguous + buffer and can be simply free()d when no longer needed*/ +#define kiss_fft_free KISS_FFT_FREE + +/* + Cleans up some memory that gets managed internally. Not necessary to call, but it might clean up + your compiler output to call this before you exit. +*/ +void KISS_FFT_API kiss_fft_cleanup(void); + + +/* + * Returns the smallest integer k, such that k>=n and k has only "fast" factors (2,3,5) + */ +int KISS_FFT_API kiss_fft_next_fast_size(int n); + +/* for real ffts, we need an even size */ +#define kiss_fftr_next_fast_size_real(n) \ + (kiss_fft_next_fast_size( ((n)+1)>>1)<<1) + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/external/gpr3dviewer/third_party/kissfft/kiss_fft_log.h b/external/gpr3dviewer/third_party/kissfft/kiss_fft_log.h new file mode 100644 index 0000000..5012474 --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/kiss_fft_log.h @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2003-2010, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +#ifndef kiss_fft_log_h +#define kiss_fft_log_h + +#define ERROR 1 +#define WARNING 2 +#define INFO 3 +#define DEBUG 4 + +#define STRINGIFY(x) #x +#define TOSTRING(x) STRINGIFY(x) + +#if defined(NDEBUG) +# define KISS_FFT_LOG_MSG(severity, ...) ((void)0) +#else +# define KISS_FFT_LOG_MSG(severity, ...) \ + fprintf(stderr, "[" #severity "] " __FILE__ ":" TOSTRING(__LINE__) " "); \ + fprintf(stderr, __VA_ARGS__); \ + fprintf(stderr, "\n") +#endif + +#define KISS_FFT_ERROR(...) KISS_FFT_LOG_MSG(ERROR, __VA_ARGS__) +#define KISS_FFT_WARNING(...) KISS_FFT_LOG_MSG(WARNING, __VA_ARGS__) +#define KISS_FFT_INFO(...) KISS_FFT_LOG_MSG(INFO, __VA_ARGS__) +#define KISS_FFT_DEBUG(...) KISS_FFT_LOG_MSG(DEBUG, __VA_ARGS__) + + + +#endif /* kiss_fft_log_h */ diff --git a/external/gpr3dviewer/third_party/kissfft/kiss_fftr.c b/external/gpr3dviewer/third_party/kissfft/kiss_fftr.c new file mode 100644 index 0000000..08b6d7b --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/kiss_fftr.c @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2003-2004, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +#include "kiss_fftr.h" +#include "_kiss_fft_guts.h" + +struct kiss_fftr_state{ + kiss_fft_cfg substate; + kiss_fft_cpx * tmpbuf; + kiss_fft_cpx * super_twiddles; +#ifdef USE_SIMD + void * pad; +#endif +}; + +kiss_fftr_cfg kiss_fftr_alloc(int nfft,int inverse_fft,void * mem,size_t * lenmem) +{ + KISS_FFT_ALIGN_CHECK(mem) + + int i; + kiss_fftr_cfg st = NULL; + size_t subsize = 0, memneeded; + + if (nfft & 1) { + KISS_FFT_ERROR("Real FFT optimization must be even."); + return NULL; + } + nfft >>= 1; + + kiss_fft_alloc (nfft, inverse_fft, NULL, &subsize); + memneeded = sizeof(struct kiss_fftr_state) + subsize + sizeof(kiss_fft_cpx) * ( nfft * 3 / 2); + + if (lenmem == NULL) { + st = (kiss_fftr_cfg) KISS_FFT_MALLOC (memneeded); + } else { + if (*lenmem >= memneeded) + st = (kiss_fftr_cfg) mem; + *lenmem = memneeded; + } + if (!st) + return NULL; + + st->substate = (kiss_fft_cfg) (st + 1); /*just beyond kiss_fftr_state struct */ + st->tmpbuf = (kiss_fft_cpx *) (((char *) st->substate) + subsize); + st->super_twiddles = st->tmpbuf + nfft; + kiss_fft_alloc(nfft, inverse_fft, st->substate, &subsize); + + for (i = 0; i < nfft/2; ++i) { + double phase = + -3.14159265358979323846264338327 * ((double) (i+1) / nfft + .5); + if (inverse_fft) + phase *= -1; + kf_cexp (st->super_twiddles+i,phase); + } + return st; +} + +void kiss_fftr(kiss_fftr_cfg st,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata) +{ + /* input buffer timedata is stored row-wise */ + int k,ncfft; + kiss_fft_cpx fpnk,fpk,f1k,f2k,tw,tdc; + + if ( st->substate->inverse) { + KISS_FFT_ERROR("kiss fft usage error: improper alloc"); + return;/* The caller did not call the correct function */ + } + + ncfft = st->substate->nfft; + + /*perform the parallel fft of two real signals packed in real,imag*/ + kiss_fft( st->substate , (const kiss_fft_cpx*)timedata, st->tmpbuf ); + /* The real part of the DC element of the frequency spectrum in st->tmpbuf + * contains the sum of the even-numbered elements of the input time sequence + * The imag part is the sum of the odd-numbered elements + * + * The sum of tdc.r and tdc.i is the sum of the input time sequence. + * yielding DC of input time sequence + * The difference of tdc.r - tdc.i is the sum of the input (dot product) [1,-1,1,-1... + * yielding Nyquist bin of input time sequence + */ + + tdc.r = st->tmpbuf[0].r; + tdc.i = st->tmpbuf[0].i; + C_FIXDIV(tdc,2); + CHECK_OVERFLOW_OP(tdc.r ,+, tdc.i); + CHECK_OVERFLOW_OP(tdc.r ,-, tdc.i); + freqdata[0].r = tdc.r + tdc.i; + freqdata[ncfft].r = tdc.r - tdc.i; +#ifdef USE_SIMD +#ifdef HAVE_LASX + freqdata[0].i = (__m256)(__lasx_xvreplgr2vr_w(0)); + freqdata[ncfft].i = freqdata[0].i; +#elif defined(HAVE_LSX) + freqdata[0].i = (__m128)(__lsx_vreplgr2vr_w(0)); + freqdata[ncfft].i = freqdata[0].i; +#else + freqdata[ncfft].i = freqdata[0].i = _mm_set1_ps(0); +#endif +#else + freqdata[ncfft].i = freqdata[0].i = 0; +#endif + + for ( k=1;k <= ncfft/2 ; ++k ) { + fpk = st->tmpbuf[k]; + fpnk.r = st->tmpbuf[ncfft-k].r; + fpnk.i = - st->tmpbuf[ncfft-k].i; + C_FIXDIV(fpk,2); + C_FIXDIV(fpnk,2); + + C_ADD( f1k, fpk , fpnk ); + C_SUB( f2k, fpk , fpnk ); + C_MUL( tw , f2k , st->super_twiddles[k-1]); + + freqdata[k].r = HALF_OF(f1k.r + tw.r); + freqdata[k].i = HALF_OF(f1k.i + tw.i); + freqdata[ncfft-k].r = HALF_OF(f1k.r - tw.r); + freqdata[ncfft-k].i = HALF_OF(tw.i - f1k.i); + } +} + +void kiss_fftri(kiss_fftr_cfg st,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata) +{ + /* input buffer timedata is stored row-wise */ + int k, ncfft; + + if (st->substate->inverse == 0) { + KISS_FFT_ERROR("kiss fft usage error: improper alloc"); + return;/* The caller did not call the correct function */ + } + + ncfft = st->substate->nfft; + + st->tmpbuf[0].r = freqdata[0].r + freqdata[ncfft].r; + st->tmpbuf[0].i = freqdata[0].r - freqdata[ncfft].r; + C_FIXDIV(st->tmpbuf[0],2); + + for (k = 1; k <= ncfft / 2; ++k) { + kiss_fft_cpx fk, fnkc, fek, fok, tmp; + fk = freqdata[k]; + fnkc.r = freqdata[ncfft - k].r; + fnkc.i = -freqdata[ncfft - k].i; + C_FIXDIV( fk , 2 ); + C_FIXDIV( fnkc , 2 ); + + C_ADD (fek, fk, fnkc); + C_SUB (tmp, fk, fnkc); + C_MUL (fok, tmp, st->super_twiddles[k-1]); + C_ADD (st->tmpbuf[k], fek, fok); + C_SUB (st->tmpbuf[ncfft - k], fek, fok); +#ifdef USE_SIMD +#ifdef HAVE_LASX + __m256 neg_one = (__m256)__lasx_xvreplgr2vr_w(0xBF800000); // -1.0f + st->tmpbuf[ncfft - k].i = __lasx_xvfmul_s(st->tmpbuf[ncfft - k].i, neg_one); +#elif defined(HAVE_LSX) + __m128 neg_one = (__m128)__lsx_vreplgr2vr_w(0xBF800000); // -1.0f + st->tmpbuf[ncfft - k].i = __lsx_vfmul_s(st->tmpbuf[ncfft - k].i, neg_one); +#else + st->tmpbuf[ncfft - k].i *= _mm_set1_ps(-1.0); +#endif +#else + st->tmpbuf[ncfft - k].i *= -1; +#endif + } + kiss_fft (st->substate, st->tmpbuf, (kiss_fft_cpx *) timedata); +} diff --git a/external/gpr3dviewer/third_party/kissfft/kiss_fftr.h b/external/gpr3dviewer/third_party/kissfft/kiss_fftr.h new file mode 100644 index 0000000..7fd73d2 --- /dev/null +++ b/external/gpr3dviewer/third_party/kissfft/kiss_fftr.h @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2003-2004, Mark Borgerding. All rights reserved. + * This file is part of KISS FFT - https://github.com/mborgerding/kissfft + * + * SPDX-License-Identifier: BSD-3-Clause + * See COPYING file for more information. + */ + +#ifndef KISS_FTR_H +#define KISS_FTR_H + +#include "kiss_fft.h" +#ifdef __cplusplus +extern "C" { +#endif + + +/* + + Real optimized version can save about 45% cpu time vs. complex fft of a real seq. + + + + */ + +typedef struct kiss_fftr_state *kiss_fftr_cfg; + + +kiss_fftr_cfg KISS_FFT_API kiss_fftr_alloc(int nfft,int inverse_fft,void * mem, size_t * lenmem); +/* + nfft must be even + + If you don't care to allocate space, use mem = lenmem = NULL +*/ + + +void KISS_FFT_API kiss_fftr(kiss_fftr_cfg cfg,const kiss_fft_scalar *timedata,kiss_fft_cpx *freqdata); +/* + input timedata has nfft scalar points + output freqdata has nfft/2+1 complex points +*/ + +void KISS_FFT_API kiss_fftri(kiss_fftr_cfg cfg,const kiss_fft_cpx *freqdata,kiss_fft_scalar *timedata); +/* + input freqdata has nfft/2+1 complex points + output timedata has nfft scalar points +*/ + +#define kiss_fftr_free KISS_FFT_FREE + +#ifdef __cplusplus +} +#endif +#endif diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..3dd0c15 --- /dev/null +++ b/installer/README.md @@ -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_<版本>-.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/` — 最终安装包 diff --git a/installer/build_installer.ps1 b/installer/build_installer.ps1 new file mode 100644 index 0000000..36a4b9b --- /dev/null +++ b/installer/build_installer.ps1 @@ -0,0 +1,183 @@ +<# +.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_-.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 } + } + # 中文化:windeployqt --no-translations 不带翻译,单独拷 Qt 自带 zh_CN(QMessageBox/QFileDialog + # 等标准按钮中文化;app 启动按 exe 旁 translations\ 加载)。 + $qtZh = Join-Path $QtBin '..\translations\qtbase_zh_CN.qm' + if (Test-Path $qtZh) { + $stageTr = Join-Path $StageDir 'translations' + New-Item -ItemType Directory -Force $stageTr | Out-Null + Copy-Item $qtZh $stageTr -Force + } else { + Warn "未找到 qtbase_zh_CN.qm($qtZh)—部署版标准按钮可能仍为英文" + } +} + +# --- 5.5 随包数据:本地样本演示数据 + PROJ 数据(exe 旁布局,运行时相对定位)------- +# 客户端启动会同步加载本地样本数据构建工作台;PROJ 数据供 3D 体素 CRS 配准。 +# 二者缺失会导致登录后无界面/3D 退化,故随包到 exe 旁(sampledata\ 与 proj\)。 +Info '随包数据:复制样本演示数据 + PROJ 数据到 staging...' +# 用目录内 ASCII 标志文件 dem.tif 定位样本目录,避免在脚本里写中文路径(编码风险)。 +$sampleSrc = Get-ChildItem (Join-Path $RepoRoot 'docs') -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'dem.tif') } | Select-Object -First 1 +if (-not $sampleSrc) { Die '未找到本地样本数据目录(docs 下含 dem.tif 的目录)' } +robocopy $sampleSrc.FullName (Join-Path $StageDir 'sampledata') /E /NFL /NDL /NJH /NJS /MT:8 | Out-Null +if ($LASTEXITCODE -ge 8) { Die "robocopy 样本数据失败 (exit $LASTEXITCODE)" } + +$projSrc = Join-Path $RepoRoot 'build\release\vcpkg_installed\x64-windows\share\proj' +if (-not (Test-Path (Join-Path $projSrc 'proj.db'))) { Die "未找到 PROJ 数据: $projSrc\proj.db" } +robocopy $projSrc (Join-Path $StageDir 'proj') /E /NFL /NDL /NJH /NJS /MT:8 | Out-Null +if ($LASTEXITCODE -ge 8) { Die "robocopy PROJ 数据失败 (exit $LASTEXITCODE)" } + +# --- 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\)" +} diff --git a/installer/geopro.iss b/installer/geopro.iss new file mode 100644 index 0000000..adffe0f --- /dev/null +++ b/installer/geopro.iss @@ -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; diff --git a/installer/lang/ChineseSimplified.isl b/installer/lang/ChineseSimplified.isl new file mode 100644 index 0000000..d6a11c4 --- /dev/null +++ b/installer/lang/ChineseSimplified.isl @@ -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您要继续吗? diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 46b32b6..10d163c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -9,6 +9,7 @@ # add_subdirectory(controller) # 联动编排 # add_subdirectory(core) +add_subdirectory(io) add_subdirectory(data) add_subdirectory(net) add_subdirectory(render) diff --git a/src/app/AnomalyPropertiesDialog.cpp b/src/app/AnomalyPropertiesDialog.cpp new file mode 100644 index 0000000..ccfafa2 --- /dev/null +++ b/src/app/AnomalyPropertiesDialog.cpp @@ -0,0 +1,78 @@ +#include "AnomalyPropertiesDialog.hpp" + +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" + +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("异常属性")); + + formkit::DetailForm form; + form.group(QStringLiteral("异常")) + .row(QStringLiteral("名称"), orDash(a.name)) + .row(QStringLiteral("类型"), orDash(a.typeName)) + .row(QStringLiteral("标记类型"), markTypeLabel(a.markType)) + .row(QStringLiteral("归属"), orDash(a.remarkSourceId)) + .row(QStringLiteral("异常体"), a.consortiumId.empty() + ? QStringLiteral("(未分组)") + : QString::fromStdString(a.consortiumId)); + + // 顶点世界坐标(只读列表,x/y/z 每行一个点)—— 作为附加只读区,复用统一分组标题样式。 + auto* vertexBox = new QWidget(this); + auto* vlay = new QVBoxLayout(vertexBox); + vlay->setContentsMargins(0, 0, 0, 0); + vlay->setSpacing(geopro::app::space::kSm); + formkit::addSection(vlay, QStringLiteral("顶点坐标(%1 个)").arg(a.worldPts.size()), vertexBox, + false); + auto* pts = new QPlainTextEdit(vertexBox); + pts->setReadOnly(true); + pts->setFixedHeight(geopro::app::scaledPx(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); + vlay->addWidget(pts); + + // 备注(只读)。 + auto* remarkBox = new QWidget(this); + auto* rlay = new QVBoxLayout(remarkBox); + rlay->setContentsMargins(0, 0, 0, 0); + rlay->setSpacing(geopro::app::space::kSm); + formkit::addSection(rlay, QStringLiteral("备注"), remarkBox, false); + auto* remark = new QPlainTextEdit(remarkBox); + remark->setReadOnly(true); + remark->setFixedHeight(geopro::app::scaledPx(60)); + remark->setPlainText(QString::fromStdString(a.remark)); + rlay->addWidget(remark); + + formkit::buildDetailDialog(this, form.build(), {vertexBox, remarkBox}); +} + +} // namespace geopro::app diff --git a/src/app/AnomalyPropertiesDialog.hpp b/src/app/AnomalyPropertiesDialog.hpp new file mode 100644 index 0000000..4377700 --- /dev/null +++ b/src/app/AnomalyPropertiesDialog.hpp @@ -0,0 +1,17 @@ +#pragma once +#include + +#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 diff --git a/src/app/AnomalySaveDialog.cpp b/src/app/AnomalySaveDialog.cpp new file mode 100644 index 0000000..3b68b16 --- /dev/null +++ b/src/app/AnomalySaveDialog.cpp @@ -0,0 +1,157 @@ +#include "AnomalySaveDialog.hpp" + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +AnomalySaveDialog::AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, + geopro::data::IDatasetCommandRepository* cmdRepo, + const QString& projectId, int remarkSourceType, + QWidget* parent) + : QDialog(parent), cmdRepo_(cmdRepo), remarkSourceType_(remarkSourceType) { + setWindowTitle(QStringLiteral("保存异常")); + setModal(true); + + auto* root = formkit::dialogRoot(this); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + + auto* form = formkit::makeEditForm(); + name_ = new QLineEdit(QStringLiteral("异常")); + formkit::capField(name_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); + + type_ = new EmptyAwareComboBox(); + type_->setPlaceholderText(QStringLiteral("请选择异常类型")); // 空(如该形态平台无类型)时显灰占位+「暂无数据」 + formkit::capField(type_); + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), type_); + // 样式预览:选中类型的 legend 派生样式可视化(点=色球/线=线型/面=描边矩形)。 + stylePreview_ = new QLabel(QStringLiteral("—")); + stylePreview_->setMinimumWidth(geopro::app::scaledPx(92)); + form->addRow(formkit::editLabel(QStringLiteral("样式")), stylePreview_); + // 选中类型变化 → 拉其平台样式(legend),使保存的异常按平台类型样式渲染 + 刷新预览。 + connect(type_, qOverload(&QComboBox::currentIndexChanged), this, + [this](int) { loadStyleForCurrent(); }); + loadTypes(cmdRepo, projectId, remarkSourceType); // 异步拉平台异常类型填充(与平台一致) + + remark_ = new QPlainTextEdit(); + remark_->setFixedHeight(geopro::app::scaledPx(60)); + formkit::capField(remark_); + form->addRow(formkit::editLabel(QStringLiteral("备注")), remark_); + cardLay->addLayout(form); + + // 截图预览 + 大小(R50「确定截图大小」)。 + if (!screenshotPath.isEmpty()) { + cardLay->addWidget(new QLabel(QStringLiteral("截图(%1 × %2)").arg(shotW).arg(shotH))); + QPixmap pm(screenshotPath); + if (!pm.isNull()) { + auto* img = new QLabel(); + img->setPixmap(pm.scaledToWidth(geopro::app::scaledPx(320), Qt::SmoothTransformation)); + cardLay->addWidget(img); + } + } + + root->addWidget(card); + + formkit::addDialogButtons(root, this); +} + +void AnomalySaveDialog::loadTypes(geopro::data::IDatasetCommandRepository* cmdRepo, + const QString& projectId, int remarkSourceType) { + if (cmdRepo == nullptr || projectId.isEmpty()) return; // 无仓储/项目 → 下拉留空(空态提示) + QPointer self(this); + cmdRepo->listExceptionTypes( + projectId, QString::number(remarkSourceType), + [self](bool ok, QJsonArray list, const QString&) { + if (!self || !ok) return; // 对话框已关 / 失败 → 留空 + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + // 平台响应项:{label:类型名, value:类型id}(实测扁平 data 数组,net 层已归一 value)。 + self->type_->addItem(o.value(QStringLiteral("label")).toString(), + o.value(QStringLiteral("value")).toString()); + } + self->loadStyleForCurrent(); // 首项自动选中 → 预取其样式 + }); +} + +void AnomalySaveDialog::loadStyleForCurrent() { + if (cmdRepo_ == nullptr) return; + const QString typeId = type_->currentData().toString(); + if (typeId.isEmpty()) return; + QPointer self(this); + cmdRepo_->getExceptionTypeDetail(typeId, [self](bool ok, QJsonObject detail, const QString&) { + if (!self || !ok) return; + const QJsonObject lg = detail.value(QStringLiteral("legend")).toObject(); + // 按形态(1点/2线/3面)从 legend 派生样式:点用 pointColor;线/面用 polyline*。 + if (self->remarkSourceType_ == 1) { + self->styleColor_ = lg.value(QStringLiteral("pointColor")).toString(); + } else { + self->styleColor_ = lg.value(QStringLiteral("polylineColor")).toString(); + self->styleWidth_ = lg.value(QStringLiteral("polylineWidth")).toDouble(); + self->styleDashed_ = + lg.value(QStringLiteral("polylineShape")).toString().contains(QStringLiteral("dash")); + } + self->updateStylePreview(); + }); +} + +void AnomalySaveDialog::updateStylePreview() { + if (stylePreview_ == nullptr) return; + const QColor col(styleColor_); + if (!col.isValid()) { // 未取到样式 → 占位 + stylePreview_->setPixmap(QPixmap()); + stylePreview_->setText(QStringLiteral("—")); + return; + } + const int w = geopro::app::scaledPx(92), h = geopro::app::scaledPx(22); + QPixmap pm(w, h); + pm.fill(Qt::transparent); + QPainter p(&pm); + p.setRenderHint(QPainter::Antialiasing, true); + QPen pen(col); + pen.setWidthF(std::clamp(styleWidth_ > 0.0 ? styleWidth_ : 2.0, 1.0, 4.0)); + if (styleDashed_) pen.setStyle(Qt::DashLine); + if (remarkSourceType_ == 1) { // 点:实心色球 + p.setPen(Qt::NoPen); + p.setBrush(col); + p.drawEllipse(QPointF(w / 2.0, h / 2.0), h * 0.3, h * 0.3); + } else if (remarkSourceType_ == 2) { // 线:按线宽/虚实画线 + p.setPen(pen); + p.drawLine(QPointF(w * 0.1, h / 2.0), QPointF(w * 0.9, h / 2.0)); + } else { // 面:描边矩形 + 淡填充 + p.setPen(pen); + p.setBrush(QColor(col.red(), col.green(), col.blue(), 40)); + p.drawRect(QRectF(w * 0.12, h * 0.22, w * 0.76, h * 0.56)); + } + p.end(); + stylePreview_->setText(QString()); + stylePreview_->setPixmap(pm); +} + +QString AnomalySaveDialog::anomalyName() const { + 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 diff --git a/src/app/AnomalySaveDialog.hpp b/src/app/AnomalySaveDialog.hpp new file mode 100644 index 0000000..1e97c30 --- /dev/null +++ b/src/app/AnomalySaveDialog.hpp @@ -0,0 +1,59 @@ +#pragma once +#include +#include + +class QLineEdit; +class QComboBox; +class QPlainTextEdit; +class QLabel; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 异常保存对话框(#4b,需求 R50):名称 + 异常类型 + 备注 + 截图预览/大小。 +// 异常类型从平台按标注形态(remarkSourceType)异步拉取,与平台保持一致。accept 后取 name/typeName/typeId/remark。 +class AnomalySaveDialog : public QDialog { + Q_OBJECT +public: + // screenshotPath:圈定结束截图的本地路径(为空则不显示预览);w/h:截图像素尺寸(R50「确定截图大小」)。 + // cmdRepo/projectId:异步拉取平台异常类型填充下拉(与平台一致);remarkSourceType:标注形态 1点/2线/3面, + // 决定查询哪一类平台异常类型。cmdRepo 为空则下拉留空(空态由 EmptyAwareComboBox 提示)。 + AnomalySaveDialog(const QString& screenshotPath, int shotW, int shotH, + geopro::data::IDatasetCommandRepository* cmdRepo, const QString& projectId, + int remarkSourceType, QWidget* parent = nullptr); + + QString anomalyName() const; + QString typeName() const; + QString typeId() const; + QString remark() const; + + // 选中类型的平台样式(从 legend 按形态派生,与平台一致)。styleColor 空 = 未取到,调用方用默认样式。 + QString styleColor() const { return styleColor_; } + double styleWidth() const { return styleWidth_; } + bool styleDashed() const { return styleDashed_; } + +private: + // 异步拉平台异常类型(label→显示, value→id)填充下拉;空/失败时下拉留空(EmptyAwareComboBox 提示)。 + void loadTypes(geopro::data::IDatasetCommandRepository* cmdRepo, const QString& projectId, + int remarkSourceType); + // 拉当前选中类型的详情 legend → 按形态(点/线/面)派生 styleColor/Width/Dashed。 + void loadStyleForCurrent(); + // 据当前样式按形态画预览(点=色球/线=线型/面=描边矩形),画到 stylePreview_。 + void updateStylePreview(); + + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + int remarkSourceType_ = 3; + QString styleColor_; // legend 派生线/点色(空=未取到) + double styleWidth_ = 0.0; // legend.polylineWidth + bool styleDashed_ = false; // legend.polylineShape 含 "dash" + + QLineEdit* name_ = nullptr; + QComboBox* type_ = nullptr; + QLabel* stylePreview_ = nullptr; // 选中类型样式预览(色块/线型) + QPlainTextEdit* remark_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/AxesSettingsDialog.cpp b/src/app/AxesSettingsDialog.cpp new file mode 100644 index 0000000..74001bb --- /dev/null +++ b/src/app/AxesSettingsDialog.cpp @@ -0,0 +1,59 @@ +#include "AxesSettingsDialog.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +constexpr double kSpinAbsLimit = 1e6; +} + +AxesSettingsDialog::AxesSettingsDialog(AxisRange x, AxisRange y, AxisRange z, QWidget* parent) + : QDialog(parent) { + setWindowTitle(QStringLiteral("坐标轴设置")); + auto* grid = new QGridLayout(this); + grid->addWidget(new QLabel(QStringLiteral("轴")), 0, 0); + grid->addWidget(new QLabel(QStringLiteral("显示")), 0, 1); + grid->addWidget(new QLabel(QStringLiteral("最小")), 0, 2); + grid->addWidget(new QLabel(QStringLiteral("最大")), 0, 3); + + auto build = [&](int gridRow, const QString& label, const AxisRange& r, Row& out) { + grid->addWidget(new QLabel(label), gridRow, 0); + out.show = new QCheckBox(this); + out.show->setChecked(r.show); + grid->addWidget(out.show, gridRow, 1); + out.lo = new QDoubleSpinBox(this); + out.lo->setRange(-kSpinAbsLimit, kSpinAbsLimit); + out.lo->setValue(r.min); + grid->addWidget(out.lo, gridRow, 2); + out.hi = new QDoubleSpinBox(this); + out.hi->setRange(-kSpinAbsLimit, kSpinAbsLimit); + out.hi->setValue(r.max); + grid->addWidget(out.hi, gridRow, 3); + }; + build(1, QStringLiteral("X"), x, x_); + build(2, QStringLiteral("Y"), y, y_); + build(3, QStringLiteral("深度"), z, z_); + + auto* btns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + btns->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用")); + btns->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消")); + connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); + grid->addWidget(btns, 4, 0, 1, 4); +} + +static AxisRange readRow(QCheckBox* show, QDoubleSpinBox* lo, QDoubleSpinBox* hi) { + return {show->isChecked(), lo->value(), hi->value()}; +} + +AxisRange AxesSettingsDialog::x() const { return readRow(x_.show, x_.lo, x_.hi); } +AxisRange AxesSettingsDialog::y() const { return readRow(y_.show, y_.lo, y_.hi); } +AxisRange AxesSettingsDialog::z() const { return readRow(z_.show, z_.lo, z_.hi); } + +} // namespace geopro::app diff --git a/src/app/AxesSettingsDialog.hpp b/src/app/AxesSettingsDialog.hpp new file mode 100644 index 0000000..aa1ea36 --- /dev/null +++ b/src/app/AxesSettingsDialog.hpp @@ -0,0 +1,34 @@ +#pragma once +#include + +class QCheckBox; +class QDoubleSpinBox; + +namespace geopro::app { + +// 单轴显示设置:是否显示 + 取值范围(spec §9)。 +struct AxisRange { + bool show = true; + double min = -500; + double max = 500; +}; + +// 坐标轴设置对话框(spec §9):X / Y / 深度 三轴各「显示 + min/max」,应用后读取。 +class AxesSettingsDialog : public QDialog { + Q_OBJECT +public: + AxesSettingsDialog(AxisRange x, AxisRange y, AxisRange z, QWidget* parent = nullptr); + AxisRange x() const; + AxisRange y() const; + AxisRange z() const; + +private: + struct Row { + QCheckBox* show = nullptr; + QDoubleSpinBox* lo = nullptr; + QDoubleSpinBox* hi = nullptr; + }; + Row x_, y_, z_; +}; + +} // namespace geopro::app diff --git a/src/app/AxesSettingsPanel.cpp b/src/app/AxesSettingsPanel.cpp new file mode 100644 index 0000000..c4844a0 --- /dev/null +++ b/src/app/AxesSettingsPanel.cpp @@ -0,0 +1,200 @@ +#include "AxesSettingsPanel.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +// 点击轨道直接定位到点击处(默认 QSlider 点轨道只按 pageStep 步进;用户要求点哪跳哪)。 +class ClickJumpSlider : public QSlider { +public: + using QSlider::QSlider; + +protected: + void mousePressEvent(QMouseEvent* e) override { + if (e->button() == Qt::LeftButton && orientation() == Qt::Horizontal) { + QStyleOptionSlider opt; + initStyleOption(&opt); + const QRect handle = + style()->subControlRect(QStyle::CC_Slider, &opt, QStyle::SC_SliderHandle, this); + if (!handle.contains(e->pos())) { // 点在轨道(非手柄) → 跳到点击位置 + const int span = width() - handle.width(); + const int x = e->pos().x() - handle.width() / 2; + setValue(QStyle::sliderValueFromPosition(minimum(), maximum(), x, span)); + e->accept(); + return; + } + } + QSlider::mousePressEvent(e); // 点手柄 → 正常拖动 + } +}; +} // namespace + +AxesSettingsPanel::AxesSettingsPanel(QWidget* parent) : QFrame(parent) { + setFrameShape(QFrame::StyledPanel); + applyTokenizedStyleSheet( + this, QStringLiteral("QFrame{background:{{bg/panel}};border:1px solid {{border/default}};" + "border-radius:8px;}")); // radius/lg=8(规范§3.2 画布浮窗) + setFixedWidth(320); + + auto* v = new QVBoxLayout(this); + v->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd); + v->setSpacing(space::kSm); + + // 标题行:坐标轴设置 + × 关闭。 + auto* titleRow = new QHBoxLayout(); + auto* title = new QLabel(QStringLiteral("坐标轴设置"), this); + title->setStyleSheet(QStringLiteral("font-weight:600;font-size:15px;border:none;")); + auto* close = new QPushButton(QStringLiteral("✕"), this); + close->setFixedSize(24, 24); + close->setCursor(Qt::PointingHandCursor); + // 显式覆盖全局 QPushButton 的 padding(6px 14px)/border——否则 24×24 容不下 padding,× 被挤出不可见。 + // 颜色走 token(避免深色模式失效)。 + applyTokenizedStyleSheet( + close, QStringLiteral( + "QPushButton{border:none;background:transparent;padding:0;margin:0;font-size:16px;" + "color:{{text/secondary}};}" + "QPushButton:hover{color:{{accent/primary}};}")); + connect(close, &QPushButton::clicked, this, &AxesSettingsPanel::closed); + titleRow->addWidget(title); + titleRow->addStretch(1); + titleRow->addWidget(close); + v->addLayout(titleRow); + + // 坐标轴单位。 + auto* unitLbl = new QLabel(QStringLiteral("坐标轴单位"), this); + unitLbl->setStyleSheet(QStringLiteral("border:none;")); + v->addWidget(unitLbl); + unit_ = new QComboBox(this); + unit_->addItems({QStringLiteral("米"), QStringLiteral("英尺")}); + v->addWidget(unit_); + + // X / Y / Z 三轴(各:显示开关 + 最小值/最大值)。与上方单位组留间距。 + v->addSpacing(space::kSm); + x_ = addAxisRow(v, QStringLiteral("X轴"), {true, -500, 500}); + y_ = addAxisRow(v, QStringLiteral("Y轴"), {true, -500, 500}); + z_ = addAxisRow(v, QStringLiteral("Z轴"), {true, 0, 200}); + + // 放大系数(=垂直夸张):滑块 1~10×。仅改面板内数值/标签,点「应用」才统一生效(不再实时)。 + // 与上方坐标轴组留出间距(用户反馈:滑块离上面项目太近)。 + v->addSpacing(space::kMd); + auto* scaleRow = new QHBoxLayout(); + auto* scaleLbl = new QLabel(QStringLiteral("放大系数"), this); + scaleLbl->setStyleSheet(QStringLiteral("border:none;")); + scaleRow->addWidget(scaleLbl); + scaleSlider_ = new ClickJumpSlider(Qt::Horizontal, this); + scaleSlider_->setMinimum(1); + scaleSlider_->setMaximum(10); + scaleSlider_->setValue(1); + scaleSlider_->setSingleStep(1); + scaleSlider_->setPageStep(1); // 点击轨道按 1 步移动(默认 pageStep=10 → 点一下直接跳到 10 的 bug) + scaleLabel_ = new QLabel(QStringLiteral("1.0×"), this); + applyTokenizedStyleSheet(scaleLabel_, + QStringLiteral("border:none;color:{{text/secondary}};min-width:36px;")); + connect(scaleSlider_, &QSlider::valueChanged, this, + [this](int v) { scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); }); + scaleRow->addWidget(scaleSlider_, 1); + scaleRow->addWidget(scaleLabel_); + v->addLayout(scaleRow); + + // 取消 / 应用。 + auto* btns = new QHBoxLayout(); + auto* cancel = new QPushButton(QStringLiteral("取消"), this); + auto* apply = new QPushButton(QStringLiteral("应用"), this); + apply->setDefault(true); // 主按钮:走全局 QPushButton:default 样式(accent/primary,随主题),不再硬编码蓝 + connect(cancel, &QPushButton::clicked, this, &AxesSettingsPanel::closed); + connect(apply, &QPushButton::clicked, this, [this] { + auto rd = [](const Row& r) { + return AxisRange{r.show->isChecked(), r.lo->value(), r.hi->value()}; + }; + emit applied(rd(x_), rd(y_), rd(z_), unit_->currentIndex(), + static_cast(scaleSlider_->value())); + }); + btns->addStretch(1); + btns->addWidget(cancel); + btns->addWidget(apply); + v->addLayout(btns); +} + +AxesSettingsPanel::Row AxesSettingsPanel::addAxisRow(QVBoxLayout* col, const QString& title, + const AxisRange& def) { + Row r; + auto* head = new QHBoxLayout(); + auto* lbl = new QLabel(title, this); + lbl->setStyleSheet(QStringLiteral("font-weight:600;border:none;")); + head->addWidget(lbl); + head->addStretch(1); + auto* showLbl = new QLabel(QStringLiteral("显示"), this); + showLbl->setStyleSheet(QStringLiteral("border:none;")); + head->addWidget(showLbl); + r.show = new QCheckBox(this); + r.show->setChecked(def.show); + head->addWidget(r.show); + col->addLayout(head); + + auto* range = new QHBoxLayout(); + auto* loCol = new QVBoxLayout(); + auto* loLbl = new QLabel(QStringLiteral("最小值"), this); + applyTokenizedStyleSheet(loLbl, QStringLiteral("border:none;color:{{text/tertiary}};")); + loCol->addWidget(loLbl); + r.lo = new QDoubleSpinBox(this); + r.lo->setRange(-1e6, 1e6); + r.lo->setValue(def.min); + loCol->addWidget(r.lo); + range->addLayout(loCol); + auto* hiCol = new QVBoxLayout(); + auto* hiLbl = new QLabel(QStringLiteral("最大值"), this); + applyTokenizedStyleSheet(hiLbl, QStringLiteral("border:none;color:{{text/tertiary}};")); + hiCol->addWidget(hiLbl); + r.hi = new QDoubleSpinBox(this); + r.hi->setRange(-1e6, 1e6); + r.hi->setValue(def.max); + hiCol->addWidget(r.hi); + range->addLayout(hiCol); + col->addLayout(range); + + // 取消「显示」时禁用该轴的最小/最大值编辑(隐藏的轴改范围无意义)。 + auto* lo = r.lo; + auto* hi = r.hi; + connect(r.show, &QCheckBox::toggled, this, [lo, hi](bool on) { + lo->setEnabled(on); + hi->setEnabled(on); + }); + r.lo->setEnabled(def.show); + r.hi->setEnabled(def.show); + return r; +} + +void AxesSettingsPanel::setValues(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale) { + auto wr = [](const Row& r, const AxisRange& a) { + r.show->setChecked(a.show); + r.lo->setValue(a.min); + r.hi->setValue(a.max); + r.lo->setEnabled(a.show); // 回灌时同步禁用态(隐藏轴 → 禁编辑) + r.hi->setEnabled(a.show); + }; + wr(x_, x); + wr(y_, y); + wr(z_, z); + unit_->setCurrentIndex(unitIdx); + const int v = std::max(1, std::min(10, static_cast(scale + 0.5))); + QSignalBlocker block(scaleSlider_); // 回灌不触发 sliderReleased + scaleSlider_->setValue(v); + scaleLabel_->setText(QStringLiteral("%1.0×").arg(v)); +} + +} // namespace geopro::app diff --git a/src/app/AxesSettingsPanel.hpp b/src/app/AxesSettingsPanel.hpp new file mode 100644 index 0000000..2e40816 --- /dev/null +++ b/src/app/AxesSettingsPanel.hpp @@ -0,0 +1,45 @@ +#pragma once +#include + +#include "AxesSettingsDialog.hpp" // AxisRange + +class QCheckBox; +class QComboBox; +class QDoubleSpinBox; +class QVBoxLayout; +class QSlider; +class QLabel; + +namespace geopro::app { + +// 坐标轴设置抽屉面板(原型):工具条右侧滑出、非模态。 +// 内容:坐标轴单位(米/英尺) → X/Y/Z 三轴(显示开关 + 最小值/最大值) → 放大系数(0.1/0.5/1/2/5) → 取消/应用。 +class AxesSettingsPanel : public QFrame { + Q_OBJECT +public: + explicit AxesSettingsPanel(QWidget* parent = nullptr); + + // 打开前回灌当前值(单位 0=米/1=英尺;scale=放大系数=垂直夸张)。 + void setValues(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale); + +signals: + // 「应用」一次性下发:坐标轴(显示方式/单位/per-axis 可见性·范围) + 放大系数(scale=垂直夸张)。 + // 放大系数滑块仅改面板内数值,点「应用」才统一生效(与面板其余项一致,不再实时)。 + void applied(AxisRange x, AxisRange y, AxisRange z, int unitIdx, double scale); + void closed(); // × 或 取消 + +private: + struct Row { + QCheckBox* show = nullptr; + QDoubleSpinBox* lo = nullptr; + QDoubleSpinBox* hi = nullptr; + }; + Row addAxisRow(QVBoxLayout* col, const QString& title, const AxisRange& def); + + QComboBox* unit_ = nullptr; // 坐标轴单位:米 / 英尺 + QSlider* scaleSlider_ = nullptr; // 放大系数(=垂直夸张)滑块 1~10× + QLabel* scaleLabel_ = nullptr; // 滑块当前值标签(如「3.0×」) + Row x_, y_, z_; +}; + +} // namespace geopro::app diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 4190b53..77ab369 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -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) @@ -20,7 +21,10 @@ find_package(Qt6 REQUIRED COMPONENTS WebEngineWidgets WebEngineQuick) add_executable(geopro_desktop WIN32 main.cpp Theme.cpp + FormKit.cpp + EmptyAwareComboBox.cpp TopBar.cpp + ToastOverlay.cpp Glyphs.cpp PanelHeader.cpp Credential.cpp @@ -28,21 +32,43 @@ add_executable(geopro_desktop WIN32 panels/AnomalyListPanel.cpp panels/DatasetListPanel.cpp panels/ObjectTreePanel.cpp + panels/KeyValueView.cpp panels/DynamicFormView.cpp panels/DynamicFormEditor.cpp panels/ObjectAttrPanel.cpp panels/DatasetAttrPanel.cpp panels/ObjectExceptionPanel.cpp panels/DescriptionPanel.cpp + panels/QuillDelta.cpp panels/chart/RawDataChartView.cpp + panels/chart/InversionFormDialog.cpp + panels/chart/ScatterDataOps.cpp + panels/chart/SaveAsDialog.cpp + panels/chart/ScatterFilterDialog.cpp + panels/chart/ScatterHistogram.cpp + panels/chart/RangeSlider.cpp + panels/chart/InversionProcessOps.cpp + panels/chart/GridWizardDialog.cpp + panels/chart/WhiteningDialog.cpp + panels/chart/FilterDialog.cpp + panels/chart/ExceptionDialog.cpp + panels/chart/ExceptionTypeDialog.cpp + panels/chart/ExceptionTextDialog.cpp + panels/chart/ExceptionDetailDialog.cpp + panels/chart/AutoAnnotationDialog.cpp + panels/chart/ContourSimplify.cpp + panels/chart/ContourHoverTip.cpp panels/chart/GridDataChartView.cpp panels/chart/DataTableView.cpp panels/chart/TablePager.cpp panels/chart/BarChartView.cpp panels/chart/LineChartView.cpp panels/chart/TrajectoryMapView.cpp + panels/web/ProjectWebView.cpp panels/chart/DetailViewFactory.cpp resources/map/map.qrc + resources/keys.qrc + resources/icons.qrc panels/chart/ChartTheme.cpp panels/chart/ColorMapService.cpp panels/chart/ColorBarWidget.cpp @@ -50,24 +76,51 @@ add_executable(geopro_desktop WIN32 panels/chart/ContourPlotItem.cpp panels/chart/LivePanner.cpp panels/chart/ScatterHoverTip.cpp + panels/chart/ChartPickGeometry.cpp + panels/chart/ScatterMarqueePicker.cpp + panels/chart/ContourDrawTool.cpp + panels/columns/Column2DDataset.cpp + panels/columns/CategorySection.cpp + panels/columns/CategoryAnalysisTab.cpp + panels/columns/DateRangeEdit.cpp + panels/columns/ColumnDrawer.cpp panels/AnomalyTablePanel.cpp panels/LoadingOverlay.cpp panels/DatasetDetailPage.cpp panels/DatasetDetailPanel.cpp - CentralScene.cpp + VtkSceneView.cpp ProjectListDialog.cpp ObjectFormDialog.cpp ImportDatasetDialog.cpp ExportDatasetDialog.cpp + AnomalySaveDialog.cpp + AnomalyPropertiesDialog.cpp + ColorGradientDialog.cpp + ColorScaleConfigDialog.cpp + ColorScaleIO.cpp + ContourLevelDialog.cpp + ContourLevels.cpp + ContourLineDialog.cpp + GradientEditWidget.cpp SettingsDialog.cpp - Logging.cpp) + SliceExport.cpp + SlicePropertiesDialog.cpp + VolumeParamsDialog.cpp + VolumePropertiesDialog.cpp + Logging.cpp + DatasetDimension.cpp + DatasetCategory.cpp + VtkViewToolbar.cpp + AxesSettingsDialog.cpp + AxesSettingsPanel.cpp + TileBasemap.cpp) target_include_directories(geopro_desktop PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) # QtKeychain 经 FetchContent 接入,头文件不随 target 传播,显式加源/构建目录(含生成的 export 头)。 target_include_directories(geopro_desktop PRIVATE ${qtkeychain_SOURCE_DIR} ${qtkeychain_BINARY_DIR}) target_link_libraries(geopro_desktop PRIVATE - Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg + Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Svg Qt6::Network Qt6::WebEngineWidgets Qt6::WebEngineQuick ${VTK_LIBRARIES} ads::qt6advanceddocking diff --git a/src/app/CentralScene.cpp b/src/app/CentralScene.cpp deleted file mode 100644 index faefd85..0000000 --- a/src/app/CentralScene.cpp +++ /dev/null @@ -1,49 +0,0 @@ -#include "CentralScene.hpp" - -#include -#include -#include - -#include "CameraPreset.hpp" -#include "Scene.hpp" -#include "Theme.hpp" -#include "actors/CurtainActor.hpp" -#include "actors/MapLineActor.hpp" -#include "geo/GeoLocalFrame.hpp" - -namespace geopro::app { - -void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, - vtkRenderWindow* renderWindow, ViewMode mode, - const std::vector& sections, bool showCurtain, - const geopro::core::GeoLocalFrame& frame, double verticalExaggeration) { - scene.clear(); - const bool is2D = (mode == ViewMode::Map2D); - (void)is2D; - // 背景永远深色(规范§0.5 视图区常深,不随明暗切换),让色阶数据更突出。 - double bgR, bgG, bgB; - geopro::app::vtkBackground(bgR, bgG, bgB); - renderer->SetBackground(bgR, bgG, bgB); - - for (const auto& s : sections) { - if (is2D) { - auto line = geopro::render::buildSurveyLine(s.grid, frame); - if (line) scene.addActor(line); - } else if (showCurtain) { - auto curtain = geopro::render::buildCurtain(s.grid, s.colorScale, frame); - if (curtain) { - curtain->SetScale(1.0, 1.0, verticalExaggeration); // 纵向夸张成墙 - scene.addActor(curtain); - } - } - } - - if (is2D) - geopro::render::applyTop2D(renderer); - else - geopro::render::applyFree3D(renderer); - renderer->ResetCamera(); - renderWindow->Render(); -} - -} // namespace geopro::app diff --git a/src/app/CentralScene.hpp b/src/app/CentralScene.hpp deleted file mode 100644 index 1e75032..0000000 --- a/src/app/CentralScene.hpp +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once -#include - -#include "model/ColorScale.hpp" -#include "model/Field.hpp" - -namespace geopro::core { class GeoLocalFrame; } -namespace geopro::render { class Scene; } -class vtkRenderer; -class vtkRenderWindow; - -namespace geopro::app { - -// 中央视图模式:二维地图(测线红线俯视)/ 三维视图(断面墙)。 -enum class ViewMode { Map2D, View3D }; - -// 一个待渲染剖面:grid(2D 测线 / 3D 帘面都用)+ colorScale(3D 帘面上色)。 -struct SectionInput { - geopro::core::Grid grid; - geopro::core::ColorScale colorScale; -}; - -// 中央场景重建(脱离对象树,按显式 sections 渲染): -// 2D = 每个 section 的 buildSurveyLine;3D = 每个 section 的 buildCurtain(受 showCurtain)。 -// 下一轮接真实 DS:构建 sections 后调用本函数即可,render 层零改动。 -void rebuildCentralScene(geopro::render::Scene& scene, vtkRenderer* renderer, - vtkRenderWindow* renderWindow, ViewMode mode, - const std::vector& sections, bool showCurtain, - const geopro::core::GeoLocalFrame& frame, double verticalExaggeration); - -} // namespace geopro::app diff --git a/src/app/ColorGradientDialog.cpp b/src/app/ColorGradientDialog.cpp new file mode 100644 index 0000000..b8d3125 --- /dev/null +++ b/src/app/ColorGradientDialog.cpp @@ -0,0 +1,433 @@ +#include "ColorGradientDialog.hpp" + +#include +#include + +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ColorScaleIO.hpp" +#include "FormKit.hpp" +#include "Theme.hpp" +#include "repo/IColorTemplateRepository.hpp" + +namespace geopro::app { + +namespace { +using Stop = GradientEditWidget::Stop; +using geopro::core::Rgba; + +Rgba hx(unsigned r, unsigned g, unsigned b) { + return Rgba{static_cast(r), static_cast(g), + static_cast(b), 255}; +} + +QColor toQ(const Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } +Rgba fromQ(const QColor& c) { + return Rgba{static_cast(c.red()), static_cast(c.green()), + static_cast(c.blue()), static_cast(c.alpha())}; +} + +// 解析后端色阶 color 字符串("#RRGGBB" / "rgb(r,g,b)" / "rgba(r,g,b,a)")→ Rgba。 +Rgba parseColorString(const QString& s) { + const QString t = s.trimmed(); + if (t.startsWith(QLatin1Char('#')) && t.size() >= 7) { + bool ok = false; + const unsigned r = t.mid(1, 2).toUInt(&ok, 16); + const unsigned g = t.mid(3, 2).toUInt(&ok, 16); + const unsigned b = t.mid(5, 2).toUInt(&ok, 16); + return hx(r, g, b); + } + static const QRegularExpression re( + QStringLiteral("rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)")); + const auto m = re.match(t); + if (m.hasMatch()) + return hx(m.captured(1).toUInt(), m.captured(2).toUInt(), m.captured(3).toUInt()); + return hx(0, 0, 0); +} + +// 生成配色方案预览色条(下拉用),复刻 generateColorPreview。 +QPixmap previewPixmap(const std::vector& stops, int w = 100, int h = 16) { + QPixmap pm(w, h); + pm.fill(Qt::white); + if (stops.size() >= 2) { + QPainter p(&pm); + QLinearGradient grad(0, 0, w, 0); + for (const auto& s : stops) grad.setColorAt(std::clamp(s.pos, 0.0, 1.0), toQ(s.color)); + p.fillRect(QRect(0, 0, w, h), grad); + } + return pm; +} + +} // namespace + +ColorGradientDialog::ColorGradientDialog(const std::vector& init, double minValue, + double maxValue, double originMin, double originMax, + std::vector samples, double opacity, + geopro::data::IColorTemplateRepository* tplRepo, + QString projectId, QWidget* parent) + : QDialog(parent), + originMin_(originMin), + originMax_(originMax), + opacity_(opacity), + tplRepo_(tplRepo), + projectId_(std::move(projectId)) { + setWindowTitle(QStringLiteral("色阶编辑器")); + setModal(true); + + auto* root = new QVBoxLayout(this); + + // ── 顶部两列 grid(grid-template-columns: 50% 50%) ────────────────────── + auto* grid = new QGridLayout(); + grid->setHorizontalSpacing(geopro::app::space::kLg); // formkit 标准列距 + grid->setVerticalSpacing(geopro::app::space::kMd); // formkit 标准行距 + int rowIdx = 0; + + // 配色方案(下拉带预览色条)。 + schemeCombo_ = new EmptyAwareComboBox(this); + schemeCombo_->setIconSize(QSize(100, 16)); + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("配色方案:"))); + cell->addWidget(schemeCombo_, 1); + grid->addLayout(cell, rowIdx, 0); + } + + // 分布方式(disabled, 默认线性)+ 反向。 + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("分布方式:"))); + auto* distCombo = new EmptyAwareComboBox(this); + distCombo->addItem(QStringLiteral("线性"), QStringLiteral("linear")); + distCombo->addItem(QStringLiteral("对数"), QStringLiteral("log")); + distCombo->setCurrentIndex(0); + distCombo->setEnabled(false); + cell->addWidget(distCombo, 1); + auto* reverseBtn = new QPushButton(QStringLiteral("反转"), this); + cell->addWidget(reverseBtn); + connect(reverseBtn, &QPushButton::clicked, this, [this] { gradient_->reverse(); }); + grid->addLayout(cell, rowIdx++, 1); + } + + // 数值范围(只读:originMin~originMax,各保留 6 位)。 + rangeEdit_ = new QLineEdit(this); + rangeEdit_->setReadOnly(true); + rangeEdit_->setText(QStringLiteral("%1~%2") + .arg(QString::number(originMin_, 'f', 6)) + .arg(QString::number(originMax_, 'f', 6))); + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("数值范围:"))); + cell->addWidget(rangeEdit_, 1); + grid->addLayout(cell, rowIdx, 0); + } + + // 最小值/最大值(可编辑)。 + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("最小值:"))); + minSpin_ = new QDoubleSpinBox(this); + minSpin_->setDecimals(6); + minSpin_->setRange(-1e12, 1e12); + minSpin_->setValue(minValue); + cell->addWidget(minSpin_, 1); + cell->addWidget(formkit::editLabel(QStringLiteral("最大值:"))); + maxSpin_ = new QDoubleSpinBox(this); + maxSpin_->setDecimals(6); + maxSpin_->setRange(-1e12, 1e12); + maxSpin_->setValue(maxValue); + cell->addWidget(maxSpin_, 1); + grid->addLayout(cell, rowIdx++, 1); + } + + // 当前数据。 + curDataLabel_ = new QLabel(QStringLiteral("-"), this); + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("当前数据值:"))); + cell->addWidget(curDataLabel_, 1); + grid->addLayout(cell, rowIdx, 0); + } + + // 当前位置。 + curPosLabel_ = new QLabel(QStringLiteral("-"), this); + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("当前数据位置:"))); + cell->addWidget(curPosLabel_, 1); + grid->addLayout(cell, rowIdx++, 1); + } + + // 当前颜色(色块按钮,仅选中手柄时可用)。 + curColorBtn_ = new QPushButton(this); + curColorBtn_->setFixedSize(48, 22); + curColorBtn_->setEnabled(false); + { + auto* cell = new QHBoxLayout(); + cell->addWidget(formkit::editLabel(QStringLiteral("当前颜色:"))); + cell->addWidget(curColorBtn_); + cell->addStretch(1); + grid->addLayout(cell, rowIdx++, 0); + } + root->addLayout(grid); + + // ── 渐变画布 ─────────────────────────────────────────────────────────── + gradient_ = new GradientEditWidget(this); + gradient_->setMinimumHeight(400); + gradient_->setMinMax(minValue, maxValue); + gradient_->setSamples(std::move(samples)); + if (init.size() >= 2) gradient_->setStops(init); + root->addWidget(gradient_); + + // ── 整体透明度滑块(0~1, step 0.01) ─────────────────────────────────── + { + auto* opRow = new QHBoxLayout(); + opRow->addWidget(new QLabel(QStringLiteral("整体透明度:"))); + opacitySlider_ = new QSlider(Qt::Horizontal, this); + opacitySlider_->setRange(0, 100); + opacitySlider_->setValue(static_cast(opacity_ * 100 + 0.5)); + opacityLabel_ = new QLabel(QString::number(opacity_, 'f', 2), this); + opRow->addWidget(opacitySlider_, 1); + opRow->addWidget(opacityLabel_); + root->addLayout(opRow); + } + + // ── 底部按钮:左 导入/导出/新建色阶;右 取消/应用 ────────────────────── + { + auto* btns = new QDialogButtonBox(this); + auto* importBtn = btns->addButton(QStringLiteral("导入"), QDialogButtonBox::ActionRole); + auto* exportBtn = btns->addButton(QStringLiteral("导出"), QDialogButtonBox::ActionRole); + newSchemeBtn_ = btns->addButton(QStringLiteral("新建色阶"), QDialogButtonBox::ActionRole); + newSchemeBtn_->setEnabled(tplRepo_ != nullptr && !projectId_.isEmpty()); + btns->addButton(QStringLiteral("取消"), QDialogButtonBox::RejectRole); + btns->addButton(QStringLiteral("应用"), QDialogButtonBox::AcceptRole); + root->addWidget(btns); + + connect(importBtn, &QPushButton::clicked, this, &ColorGradientDialog::importClr); + connect(exportBtn, &QPushButton::clicked, this, &ColorGradientDialog::exportClr); + connect(newSchemeBtn_, &QPushButton::clicked, this, &ColorGradientDialog::newScheme); + connect(btns, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(btns, &QDialogButtonBox::rejected, this, &QDialog::reject); + } + + // ── 信号连接 ─────────────────────────────────────────────────────────── + connect(schemeCombo_, QOverload::of(&QComboBox::activated), this, + [this](int i) { applyScheme(i); }); + connect(gradient_, &GradientEditWidget::handleSelected, this, + &ColorGradientDialog::onHandleSelected); + connect(gradient_, &GradientEditWidget::selectionCleared, this, + &ColorGradientDialog::onSelectionCleared); + connect(curColorBtn_, &QPushButton::clicked, this, &ColorGradientDialog::pickCurrentColor); + connect(minSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { onMinMaxChanged(); }); + connect(maxSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { onMinMaxChanged(); }); + connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { + opacity_ = v / 100.0; + opacityLabel_->setText(QString::number(opacity_, 'f', 2)); + }); + + // 配色方案:内置预设打底,再异步拉取后端 .clr 列表(若有 api)。 + buildBuiltinSchemes(); + reloadSchemeCombo(); + if (tplRepo_ != nullptr && !projectId_.isEmpty()) queryClrSchemes(); +} + +std::vector ColorGradientDialog::stops() const { + return gradient_->stops(); +} + +// ── 配色方案 ───────────────────────────────────────────────────────────────── +void ColorGradientDialog::buildBuiltinSchemes() { + schemes_.clear(); + // 默认 GMT 17 档(与 colorEditor.vue defaultColorScale 一致)。 + schemes_.push_back( + {QStringLiteral("默认 (GMT)"), + {{0.0, hx(0x00, 0x00, 0xAA)}, {0.0625, hx(0x00, 0x00, 0xD3)}, + {0.125, hx(0x00, 0x00, 0xFF)}, {0.1875, hx(0x00, 0x80, 0xFF)}, + {0.25, hx(0x00, 0xFF, 0xFF)}, {0.3125, hx(0x00, 0xC0, 0x80)}, + {0.375, hx(0x00, 0xFF, 0x00)}, {0.4375, hx(0x00, 0x80, 0x00)}, + {0.5, hx(0x80, 0xC0, 0x00)}, {0.5625, hx(0xFF, 0xFF, 0x00)}, + {0.625, hx(0xBF, 0x80, 0x00)}, {0.6875, hx(0xFF, 0x80, 0x00)}, + {0.75, hx(0xFF, 0x00, 0x00)}, {0.8125, hx(0xD3, 0x00, 0x00)}, + {0.875, hx(0x84, 0x00, 0x40)}, {0.9375, hx(0x60, 0x00, 0x45)}, + {1.0, hx(0x30, 0x00, 0x30)}}}); + schemes_.push_back({QStringLiteral("彩虹"), + {{0.0, hx(0, 0, 255)}, {0.25, hx(0, 255, 255)}, {0.5, hx(0, 255, 0)}, + {0.75, hx(255, 255, 0)}, {1.0, hx(255, 0, 0)}}}); + schemes_.push_back( + {QStringLiteral("蓝白红"), + {{0.0, hx(0, 0, 255)}, {0.5, hx(255, 255, 255)}, {1.0, hx(255, 0, 0)}}}); + schemes_.push_back({QStringLiteral("灰度"), {{0.0, hx(0, 0, 0)}, {1.0, hx(255, 255, 255)}}}); +} + +void ColorGradientDialog::reloadSchemeCombo() { + const QSignalBlocker block(schemeCombo_); + schemeCombo_->clear(); + for (const auto& s : schemes_) + schemeCombo_->addItem(QIcon(previewPixmap(s.stops)), s.name); +} + +void ColorGradientDialog::applyScheme(int index) { + if (index < 0 || index >= static_cast(schemes_.size())) return; + gradient_->setStops(schemes_[index].stops); + onSelectionCleared(); +} + +// ── 当前手柄读出 ───────────────────────────────────────────────────────────── +void ColorGradientDialog::onHandleSelected(const QString& colorHex, const QString& valueText, + const QString& percentText) { + curDataLabel_->setText(valueText); + curPosLabel_->setText(percentText); + curColor_ = parseColorString(colorHex); + curColorBtn_->setEnabled(true); + curColorBtn_->setStyleSheet( + QStringLiteral("background-color: %1;").arg(toQ(curColor_).name())); +} + +void ColorGradientDialog::onSelectionCleared() { + curDataLabel_->setText(QStringLiteral("-")); + curPosLabel_->setText(QStringLiteral("-")); + curColorBtn_->setEnabled(false); + curColorBtn_->setStyleSheet(QString()); +} + +void ColorGradientDialog::onMinMaxChanged() { + gradient_->setMinMax(minSpin_->value(), maxSpin_->value()); +} + +void ColorGradientDialog::pickCurrentColor() { + if (!gradient_->hasSelection()) return; + const QColor picked = + QColorDialog::getColor(toQ(curColor_), this, QStringLiteral("当前颜色")); + if (!picked.isValid()) return; + curColor_ = fromQ(picked); + curColor_.a = 255; + gradient_->setSelectedColor(curColor_); + curColorBtn_->setStyleSheet( + QStringLiteral("background-color: %1;").arg(toQ(curColor_).name())); +} + +// ── .clr 导入/导出(复刻 importColorLevel / explortColorLevel) ─────────────── +void ColorGradientDialog::importClr() { + const QString path = QFileDialog::getOpenFileName(this, QStringLiteral("导入 .clr"), {}, + QStringLiteral("色阶文件 (*.clr)")); + if (path.isEmpty()) return; + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::warning(this, QStringLiteral("导入"), QStringLiteral("无法打开文件。")); + return; + } + const ClrData clr = parseClr(f.readAll().toStdString()); + if (clr.stops.size() < 2) { + QMessageBox::warning(this, QStringLiteral("导入"), + QStringLiteral("文件格式不正确或色阶不足。")); + return; + } + std::vector st; + for (const auto& [pos, c] : clr.stops) st.push_back({pos, c}); + gradient_->setStops(st); + onSelectionCleared(); + opacity_ = clr.opacity; + opacitySlider_->setValue(static_cast(opacity_ * 100 + 0.5)); +} + +void ColorGradientDialog::exportClr() { + const QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出 .clr"), + QStringLiteral("色阶配置.clr"), + QStringLiteral("色阶文件 (*.clr)")); + if (path.isEmpty()) return; + ClrData clr; + clr.opacity = opacity_; + for (const auto& s : gradient_->stops()) clr.stops.emplace_back(s.pos, s.color); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("无法写入文件。")); + return; + } + const std::string out = generateClr(clr); + if (f.write(out.c_str(), static_cast(out.size())) < 0) + QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。")); +} + +// ── 后端接线 ───────────────────────────────────────────────────────────────── +void ColorGradientDialog::newScheme() { + if (tplRepo_ == nullptr || projectId_.isEmpty()) return; + bool ok = false; + const QString name = QInputDialog::getText(this, QStringLiteral("新建色阶"), + QStringLiteral("色阶名称:"), QLineEdit::Normal, + QStringLiteral("默认色阶"), &ok); + if (!ok || name.trimmed().isEmpty()) return; + + // 领域装配(colorscale 串/位置)留在对话框;仓储只负责传输。 + QJsonArray scale; + for (const auto& s : gradient_->stops()) + scale.append(QJsonObject{{QStringLiteral("pos"), s.pos}, + {QStringLiteral("color"), toQ(s.color).name().toUpper()}, + {QStringLiteral("colorId"), QString()}}); + const QJsonObject properties{{QStringLiteral("name"), name}, + {QStringLiteral("colorscale"), scale}}; + + QPointer self(this); + tplRepo_->newClrScheme(projectId_, properties, [self](bool ok, QString) { + if (!self) return; + if (ok) { + QMessageBox::information(self, QStringLiteral("新建色阶"), + QStringLiteral("保存成功。")); + self->queryClrSchemes(); // 刷新下拉 + } else { + QMessageBox::warning(self, QStringLiteral("新建色阶"), + QStringLiteral("保存失败。")); + } + }); +} + +void ColorGradientDialog::queryClrSchemes() { + if (tplRepo_ == nullptr || projectId_.isEmpty()) return; + QPointer self(this); + tplRepo_->listClrSchemes(projectId_, [self](bool ok, QJsonArray arr, QString) { + if (!self || !ok) return; + if (arr.isEmpty()) return; + // 领域解析(properties.name/colorscale)留在对话框。 + self->buildBuiltinSchemes(); // 重置为内置,再追加后端 + for (const auto& v : arr) { + const QJsonObject props = v.toObject().value(QStringLiteral("properties")).toObject(); + const QString name = props.value(QStringLiteral("name")).toString(); + const QJsonArray cs = props.value(QStringLiteral("colorscale")).toArray(); + if (name.isEmpty() || cs.size() < 2) continue; + std::vector st; + for (const auto& c : cs) { + const QJsonObject o = c.toObject(); + st.push_back({o.value(QStringLiteral("pos")).toDouble(), + parseColorString(o.value(QStringLiteral("color")).toString())}); + } + if (st.size() >= 2) self->schemes_.push_back({name, std::move(st)}); + } + self->reloadSchemeCombo(); + }); +} + +} // namespace geopro::app diff --git a/src/app/ColorGradientDialog.hpp b/src/app/ColorGradientDialog.hpp new file mode 100644 index 0000000..215fa7c --- /dev/null +++ b/src/app/ColorGradientDialog.hpp @@ -0,0 +1,82 @@ +#pragma once +#include + +#include +#include + +#include "GradientEditWidget.hpp" + +class QComboBox; +class QDoubleSpinBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSlider; + +namespace geopro::data { +class IColorTemplateRepository; +} + +namespace geopro::app { + +// 颜色⚙ 连续色阶编辑对话框(1:1 复刻 colorEditor.vue): +// 两列 grid(配色方案/分布方式+反向/数值范围/最小值最大值/当前数据·位置·颜色) + +// 渐变画布 GradientEditWidget + 整体透明度滑块 + 导入/导出/新建色阶 + 取消/应用。 +// 输出契约保持归一化:stops()(升序 pos∈[0,1]) + opacity(),由调用方按层级位置回填颜色。 +class ColorGradientDialog : public QDialog { + Q_OBJECT +public: + ColorGradientDialog(const std::vector& init, + double minValue, double maxValue, // 当前色阶范围(可编辑) + double originMin, double originMax, // 数据原始范围(数值范围只读+直方图域) + std::vector samples, // 直方图样本 + double opacity, + geopro::data::IColorTemplateRepository* tplRepo = nullptr, + QString projectId = {}, + QWidget* parent = nullptr); + + std::vector stops() const; // accept() 后有效,升序 + double opacity() const { return opacity_; } + +private: + struct Scheme { + QString name; + std::vector stops; + }; + + void buildBuiltinSchemes(); // 内置预设(GMT17 + 彩虹 + 蓝白红 + 灰度) + void reloadSchemeCombo(); // 重填配色方案下拉(含预览色条) + void applyScheme(int index); // 切换配色方案 → updateColorScale + void onHandleSelected(const QString& colorHex, const QString& valueText, + const QString& percentText); + void onSelectionCleared(); + void onMinMaxChanged(); + void pickCurrentColor(); // 点「当前颜色」色块 → QColorDialog → 改当前手柄色 + void importClr(); + void exportClr(); + void newScheme(); // 新建色阶 → POST /business/clr/colorGradation + void queryClrSchemes(); // 列表 → GET .../queryCLRColorGradation/{projectId} + + GradientEditWidget* gradient_ = nullptr; + QComboBox* schemeCombo_ = nullptr; + QLineEdit* rangeEdit_ = nullptr; + QDoubleSpinBox* minSpin_ = nullptr; + QDoubleSpinBox* maxSpin_ = nullptr; + QLabel* curDataLabel_ = nullptr; + QLabel* curPosLabel_ = nullptr; + QPushButton* curColorBtn_ = nullptr; + QSlider* opacitySlider_ = nullptr; + QLabel* opacityLabel_ = nullptr; + QPushButton* newSchemeBtn_ = nullptr; + + std::vector schemes_; + geopro::core::Rgba curColor_{255, 255, 255, 255}; + + double originMin_ = 0.0; + double originMax_ = 100.0; + double opacity_ = 1.0; + geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; + QString projectId_; +}; + +} // namespace geopro::app diff --git a/src/app/ColorScaleConfigDialog.cpp b/src/app/ColorScaleConfigDialog.cpp new file mode 100644 index 0000000..8954879 --- /dev/null +++ b/src/app/ColorScaleConfigDialog.cpp @@ -0,0 +1,565 @@ +#include "ColorScaleConfigDialog.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "ColorGradientDialog.hpp" +#include "ColorScaleIO.hpp" +#include "ContourLevelDialog.hpp" +#include "ContourLevels.hpp" +#include "ContourLineDialog.hpp" +#include "repo/IColorTemplateRepository.hpp" + +namespace geopro::app { + +namespace { +QColor toQColor(const geopro::core::Rgba& c) { + return QColor(c.r, c.g, c.b, c.a); +} +geopro::core::Rgba fromQColor(const QColor& c) { + return geopro::core::Rgba{static_cast(c.red()), + static_cast(c.green()), + static_cast(c.blue()), + static_cast(c.alpha())}; +} +// 两端按比例 t∈[0,1] 线性插值(含 alpha),供「新增」取中间色。 +geopro::core::Rgba lerp(const geopro::core::Rgba& a, const geopro::core::Rgba& b, double t) { + auto mix = [t](unsigned char x, unsigned char y) { + return static_cast(x + (y - x) * t + 0.5); + }; + return geopro::core::Rgba{mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), mix(a.a, b.a)}; +} +// core::Rgba → 颜色串:不透明用 #RRGGBB,半透明用 rgba(r,g,b,a∈0..1)(与后端 colorBar 互通)。 +QString rgbaToCss(const geopro::core::Rgba& c) { + if (c.a >= 255) + return QStringLiteral("#%1%2%3") + .arg(c.r, 2, 16, QLatin1Char('0')) + .arg(c.g, 2, 16, QLatin1Char('0')) + .arg(c.b, 2, 16, QLatin1Char('0')) + .toUpper(); + return QStringLiteral("rgba(%1, %2, %3, %4)") + .arg(c.r) + .arg(c.g) + .arg(c.b) + .arg(QString::number(c.a / 255.0, 'g', 3)); +} +// 颜色串 → core::Rgba:支持 #RRGGBB / #AARRGGBB / rgb()/rgba()(alpha 0..1)/命名黑。 +geopro::core::Rgba parseCssColor(const QString& s) { + const QString t = s.trimmed(); + if (t.startsWith('#')) { + const QColor q(t); // QColor 识别 #RRGGBB / #AARRGGBB + if (q.isValid()) return fromQColor(q); + } + static const QRegularExpression re( + QStringLiteral("rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([0-9.]+))?\\)"), + QRegularExpression::CaseInsensitiveOption); + const auto m = re.match(t); + if (m.hasMatch()) { + const int r = m.captured(1).toInt(); + const int g = m.captured(2).toInt(); + const int b = m.captured(3).toInt(); + double a = m.captured(4).isEmpty() ? 1.0 : m.captured(4).toDouble(); + if (a > 1.0) a = a / 255.0; // 容错:偶有 0..255 alpha + return geopro::core::Rgba{static_cast(r), static_cast(g), + static_cast(b), + static_cast(a * 255.0 + 0.5)}; + } + return geopro::core::Rgba{0, 0, 0, 255}; +} +} // namespace + +ColorScaleConfigDialog::ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, + double vmax, std::vector samples, + const ContourLineConfig& lineInit, + geopro::data::IColorTemplateRepository* tplRepo, + QString projectId, QString lvlTemplateId, + QWidget* parent) + : QDialog(parent), + vmin_(vmin), + vmax_(vmax), + samples_(std::move(samples)), + lineCfg_(lineInit), + tplRepo_(tplRepo), + projectId_(std::move(projectId)), + lvlTemplateId_(std::move(lvlTemplateId)) { + setWindowTitle(QStringLiteral("色阶配置")); + setModal(true); + resize(560, 420); + + // 用初始色阶的升序断点填模型;空色阶兜底成 vmin/vmax 两端蓝红。 + for (const auto& [value, color] : init.stops()) rows_.push_back({value, color}); + if (rows_.empty()) { + rows_.push_back({vmin_, geopro::core::Rgba{0, 0, 255, 255}}); + rows_.push_back({vmax_, geopro::core::Rgba{255, 0, 0, 255}}); + } + + auto* root = new QVBoxLayout(this); + auto* mid = new QHBoxLayout(); + root->addLayout(mid, 1); + + // 左:三列表格(层级 / 线形 / 颜色),每列表头带 ⚙,点击表头打开对应子对话框。 + table_ = new QTableWidget(this); + table_->setColumnCount(3); + table_->setHorizontalHeaderLabels( + {QStringLiteral("层级 ⚙"), QStringLiteral("线形 ⚙"), QStringLiteral("颜色 ⚙")}); + table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + table_->horizontalHeader()->setSectionsClickable(true); + table_->verticalHeader()->setVisible(false); + table_->setSortingEnabled(false); + table_->setSelectionBehavior(QAbstractItemView::SelectRows); + table_->setSelectionMode(QAbstractItemView::SingleSelection); + table_->setEditTriggers(QAbstractItemView::NoEditTriggers); // 改值/改色走双击 + connect(table_, &QTableWidget::cellDoubleClicked, this, + &ColorScaleConfigDialog::onCellDoubleClicked); + connect(table_->horizontalHeader(), &QHeaderView::sectionClicked, this, [this](int section) { + if (section == 0) + onLevelScheme(); + else if (section == 1) + onLineScheme(); + else + onColorScheme(); + }); + mid->addWidget(table_, 1); + + // 右:竖排按钮 新增 / 删除 / 另存为 / 导出 / 导入 / 打开(复刻 colorLevel.vue 操作列)。 + auto* rightCol = new QVBoxLayout(); + auto* btnAdd = new QPushButton(QStringLiteral("新增"), this); + auto* btnDel = new QPushButton(QStringLiteral("删除"), this); + btnSaveOther_ = new QPushButton(QStringLiteral("另存"), this); + auto* btnExport = new QPushButton(QStringLiteral("导出"), this); + auto* btnImport = new QPushButton(QStringLiteral("导入"), this); + btnOpen_ = new QPushButton(QStringLiteral("打开"), this); + connect(btnAdd, &QPushButton::clicked, this, &ColorScaleConfigDialog::onAdd); + connect(btnDel, &QPushButton::clicked, this, &ColorScaleConfigDialog::onRemove); + connect(btnSaveOther_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onSaveOther); + connect(btnExport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onExportLvl); + connect(btnImport, &QPushButton::clicked, this, &ColorScaleConfigDialog::onImportLvl); + connect(btnOpen_, &QPushButton::clicked, this, &ColorScaleConfigDialog::onOpen); + for (auto* b : {btnAdd, btnDel, btnSaveOther_, btnExport, btnImport, btnOpen_}) + rightCol->addWidget(b); + rightCol->addStretch(); + mid->addLayout(rightCol); + + // 「另存为 / 打开」依赖后端 lvl 模板库(走仓储),无仓储/无项目时禁用。 + const bool hasBackend = tplRepo_ != nullptr && !projectId_.isEmpty(); + btnSaveOther_->setEnabled(hasBackend); + btnOpen_->setEnabled(hasBackend); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用")); + buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消")); + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + root->addWidget(buttons); + + rebuildTable(); +} + +void ColorScaleConfigDialog::rebuildTable() { + const int n = static_cast(rows_.size()); + table_->setRowCount(n); + const QString solid = QStringLiteral("——————"); + const QString dashed = QStringLiteral("- - - - - - - - -"); + const QColor lineQc = toQColor(lineCfg_.lineColor); + // 升序显示:低值在上(复刻原版 tableData 自然数组序)。 + for (int r = 0; r < n; ++r) { + const Row& row = rows_[static_cast(r)]; + auto* valItem = new QTableWidgetItem(QString::number(row.value, 'g', 6)); + valItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + table_->setItem(r, 0, valItem); + + auto* lineItem = new QTableWidgetItem(lineCfg_.dashed ? dashed : solid); + lineItem->setForeground(lineQc); + lineItem->setTextAlignment(Qt::AlignCenter); + table_->setItem(r, 1, lineItem); + + auto* colItem = new QTableWidgetItem(); + colItem->setBackground(toQColor(row.color)); + table_->setItem(r, 2, colItem); + } +} + +int ColorScaleConfigDialog::selectedModelIndex() const { + const int r = table_->currentRow(); + if (r < 0 || r >= static_cast(rows_.size())) return -1; + return r; // 升序显示,行号即模型下标 +} + +void ColorScaleConfigDialog::onCellDoubleClicked(int row, int col) { + if (row < 0 || row >= static_cast(rows_.size())) return; + const int idx = row; + + if (col == 0) { // 改层级值(复刻 handleLevelDblClick) + bool ok = false; + const double v = QInputDialog::getDouble(this, QStringLiteral("修改层级值"), + QStringLiteral("数据值"), rows_[idx].value, -1e12, + 1e12, 6, &ok); + if (!ok) return; + const geopro::core::Rgba color = rows_[idx].color; + rows_[idx].value = v; + std::sort(rows_.begin(), rows_.end(), + [](const Row& a, const Row& b) { return a.value < b.value; }); + rebuildTable(); + for (int i = 0; i < static_cast(rows_.size()); ++i) { + const Row& ri = rows_[static_cast(i)]; + if (ri.value == v && ri.color.r == color.r && ri.color.g == color.g && + ri.color.b == color.b && ri.color.a == color.a) { + table_->selectRow(i); + break; + } + } + } else if (col == 2) { // 改颜色(复刻 handleColorDblClick) + const QColor cur = toQColor(rows_[idx].color); + const QColor picked = QColorDialog::getColor(cur, this, QStringLiteral("选择颜色"), + QColorDialog::ShowAlphaChannel); + if (!picked.isValid()) return; + rows_[idx].color = fromQColor(picked); + rebuildTable(); + table_->selectRow(row); + } + // 线形列(col==1)双击无动作,复刻原版(线形改动走表头 ⚙)。 +} + +void ColorScaleConfigDialog::onAdd() { + // 复刻 handleAdd:选中行上方插入中点断点;未选中则提示。 + const int idx = selectedModelIndex(); + if (idx < 0) { + QMessageBox::warning(this, QStringLiteral("新增"), QStringLiteral("请先选择要插入的行。")); + return; + } + const Row& sel = rows_[static_cast(idx)]; + double newLevel = sel.value; + if (idx > 0) // 升序:上一行(idx-1)为更低值,取两者中点 + newLevel = (rows_[static_cast(idx - 1)].value + sel.value) / 2.0; + rows_.insert(rows_.begin() + idx, Row{newLevel, sel.color}); + rebuildTable(); + table_->selectRow(idx); // 选中新插入行 +} + +void ColorScaleConfigDialog::onRemove() { + // 复刻 handleDelete:未选中提示;至少保留 2 行。 + const int idx = selectedModelIndex(); + if (idx < 0) { + QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("请先选择要删除的行。")); + return; + } + if (rows_.size() <= 2) { + QMessageBox::warning(this, QStringLiteral("删除"), QStringLiteral("至少需要保留两行数据。")); + return; + } + rows_.erase(rows_.begin() + idx); + rebuildTable(); + table_->clearSelection(); // 复刻 handleDelete:删除后清空选中 +} + +geopro::core::Rgba ColorScaleConfigDialog::interpColor(double value) const { + // 复刻 colorUtils.js mapColors:升序断点上钳位 + 找区间 + 线性 RGBA 插值。 + if (rows_.empty()) return geopro::core::Rgba{0, 0, 0, 255}; + const double ysMin = rows_.front().value, ysMax = rows_.back().value; + if (value <= ysMin) return rows_.front().color; + if (value >= ysMax) return rows_.back().color; + std::size_t i = 0; + while (i + 1 < rows_.size() && value > rows_[i + 1].value) ++i; + const double x0 = rows_[i].value, x1 = rows_[i + 1].value; + const double ratio = (x1 > x0) ? (value - x0) / (x1 - x0) : 0.0; + return lerp(rows_[i].color, rows_[i + 1].color, ratio); +} + +void ColorScaleConfigDialog::onLevelScheme() { + // 由当前断点推导 contourLevel 初值(复刻 colorLevel.vue case 'level')。 + ContourLevelParams init; + init.method = ContourLevelParams::Method::Normal; + if (lvlSchemeType_ == QStringLiteral("logarithmic")) + init.method = ContourLevelParams::Method::Logarithmic; + else if (lvlSchemeType_ == QStringLiteral("equalArea")) + init.method = ContourLevelParams::Method::EqualArea; + init.minValue = rows_.front().value; + init.maxValue = rows_.back().value; + init.layerCount = static_cast(rows_.size()); + init.interval = + (rows_.size() >= 2) ? std::abs(rows_[1].value - rows_[0].value) : (vmax_ - vmin_); + init.logLinesCount = logLinesCount_; + init.equalAreaLayerCount = equalAreaLayerCount_; + const double totalArea = + samples_.empty() ? 1000.0 : static_cast(samples_.size()); // 等积「区间面积」分母 + ContourLevelDialog dlg(init, vmin_, vmax_, totalArea, this); + if (dlg.exec() != QDialog::Accepted) return; + const ContourLevelParams p = dlg.params(); + + // 记录方案字段(另存为 properties 透传,复刻原版)。 + switch (p.method) { + case ContourLevelParams::Method::Logarithmic: + lvlSchemeType_ = QStringLiteral("logarithmic"); + break; + case ContourLevelParams::Method::EqualArea: + lvlSchemeType_ = QStringLiteral("equalArea"); + break; + default: + lvlSchemeType_ = QStringLiteral("normal"); + break; + } + logLinesCount_ = p.logLinesCount; + equalAreaLayerCount_ = p.equalAreaLayerCount; + + // 1) 按分层方式生成新层级(纯算法)。 2) 旧色阶上插值取色(mapColors),重建表。 + const std::vector levels = generateContourLevels(p, samples_); + std::vector next; + next.reserve(levels.size()); + for (double lv : levels) next.push_back({lv, interpColor(lv)}); + if (next.size() < 2) return; // 退化保护 + std::sort(next.begin(), next.end(), + [](const Row& a, const Row& b) { return a.value < b.value; }); + rows_ = std::move(next); + rebuildTable(); +} + +void ColorScaleConfigDialog::onLineScheme() { + ContourLineDialog dlg(lineCfg_, this); + if (dlg.exec() == QDialog::Accepted) { + lineCfg_ = dlg.config(); + rebuildTable(); // 线形列文字/颜色随之刷新 + } +} + +void ColorScaleConfigDialog::onColorScheme() { + if (rows_.size() < 2) return; // 防御:front/back + // 用当前断点归一化位置作渐变初值(lo..hi → 0..1)。 + const double lo = rows_.front().value, hi = rows_.back().value; + const double span = (hi > lo) ? (hi - lo) : 1.0; + std::vector seed; + for (const auto& r : rows_) seed.push_back({(r.value - lo) / span, r.color}); + + ColorGradientDialog dlg(seed, lo, hi, vmin_, vmax_, samples_, 1.0, tplRepo_, projectId_, this); + if (dlg.exec() != QDialog::Accepted) return; + + const auto grad = dlg.stops(); + if (grad.size() < 2) return; + const double opacity = dlg.opacity(); + const unsigned char alpha = static_cast(opacity * 255.0 + 0.5); + + // 在新渐变上按各层级位置连续采样回填颜色(复刻 mapColors + addAlphaToColor 整体透明度)。 + auto sampleGrad = [&](double pos) -> geopro::core::Rgba { + if (pos <= grad.front().pos) return grad.front().color; + if (pos >= grad.back().pos) return grad.back().color; + std::size_t i = 0; + while (i + 1 < grad.size() && pos > grad[i + 1].pos) ++i; + const double x0 = grad[i].pos, x1 = grad[i + 1].pos; + const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0; + return lerp(grad[i].color, grad[i + 1].color, t); + }; + for (auto& r : rows_) { + geopro::core::Rgba c = sampleGrad((r.value - lo) / span); + if (opacity < 1.0) c.a = alpha; // 整体透明度覆盖 alpha + r.color = c; + } + rebuildTable(); +} + +void ColorScaleConfigDialog::onImportLvl() { + const QString path = QFileDialog::getOpenFileName(this, QStringLiteral("导入 .lvl"), {}, + QStringLiteral("色阶层级文件 (*.lvl)")); + if (path.isEmpty()) return; + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + QMessageBox::warning(this, QStringLiteral("导入"), QStringLiteral("无法打开文件。")); + return; + } + const std::vector parsed = parseLvl(f.readAll().toStdString()); + if (parsed.size() < 2) { + QMessageBox::warning(this, QStringLiteral("导入"), + QStringLiteral("文件格式不正确或层级不足。")); + return; + } + rows_.clear(); + for (const auto& lr : parsed) rows_.push_back({lr.level, lr.color}); + std::sort(rows_.begin(), rows_.end(), + [](const Row& a, const Row& b) { return a.value < b.value; }); + lineCfg_.dashed = parsed.front().dashed; // 线形从首行带入 + lineCfg_.lineColor = parsed.front().lineColor; + rebuildTable(); +} + +void ColorScaleConfigDialog::onExportLvl() { + const QString path = QFileDialog::getSaveFileName(this, QStringLiteral("导出 .lvl"), + QStringLiteral("等值线配置.lvl"), + QStringLiteral("色阶层级文件 (*.lvl)")); + if (path.isEmpty()) return; + std::vector out; + for (const auto& r : rows_) + out.push_back({r.value, r.color, lineCfg_.dashed, lineCfg_.lineColor}); + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("无法写入文件。")); + return; + } + const std::string text = generateLvl(out); + if (f.write(text.c_str(), static_cast(text.size())) < 0) + QMessageBox::warning(this, QStringLiteral("导出"), QStringLiteral("写入失败。")); +} + +void ColorScaleConfigDialog::loadColorBar( + const std::vector>& bar) { + if (bar.size() < 2) return; + rows_.clear(); + for (const auto& [level, color] : bar) rows_.push_back({level, color}); + std::sort(rows_.begin(), rows_.end(), + [](const Row& a, const Row& b) { return a.value < b.value; }); + rebuildTable(); + if (!rows_.empty()) table_->selectRow(0); // 复刻 handleOpen:载入后默认选中首行 +} + +void ColorScaleConfigDialog::onSaveOther() { + if (tplRepo_ == nullptr || projectId_.isEmpty()) return; + + // 自定义另存为弹窗(复刻 handleSaveOther):名称输入 + 覆盖复选框。 + // 「覆盖」仅当有来源模板 id(lvlTemplateId_ 非空)时可勾选,对照原版 props.data.lvlTemplateId。 + QDialog askDlg(this); + askDlg.setWindowTitle(QStringLiteral("另存模板配置")); + askDlg.setModal(true); + auto* askRoot = new QVBoxLayout(&askDlg); + auto* nameRow = new QHBoxLayout(); + nameRow->addWidget(new QLabel(QStringLiteral("模板名称:"), &askDlg)); + auto* nameEdit = new QLineEdit(QStringLiteral("等值线配置.lvl"), &askDlg); + nameRow->addWidget(nameEdit, 1); + askRoot->addLayout(nameRow); + auto* overwriteCheck = new QCheckBox(QStringLiteral("覆盖原模板"), &askDlg); + overwriteCheck->setEnabled(!lvlTemplateId_.isEmpty()); // 无来源模板 → 禁用覆盖 + askRoot->addWidget(overwriteCheck); + auto* askBtns = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &askDlg); + askBtns->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用")); + askBtns->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消")); + connect(askBtns, &QDialogButtonBox::accepted, &askDlg, &QDialog::accept); + connect(askBtns, &QDialogButtonBox::rejected, &askDlg, &QDialog::reject); + askRoot->addWidget(askBtns); + if (askDlg.exec() != QDialog::Accepted) return; + const QString name = nameEdit->text().trimmed(); + if (name.isEmpty()) return; + const bool overwrite = overwriteCheck->isChecked() && !lvlTemplateId_.isEmpty(); + + // 组装 properties(复刻 handleSaveOther)。 + QJsonArray colorBar; + for (const auto& r : rows_) + colorBar.append(QJsonArray{QString::number(r.value, 'f', 2), rgbaToCss(r.color)}); + QJsonObject lineConfig{{QStringLiteral("showLines"), lineCfg_.lineShow}, + {QStringLiteral("color"), rgbaToCss(lineCfg_.lineColor)}, + {QStringLiteral("lineType"), + lineCfg_.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}}; + QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg_.labelShow}, + {QStringLiteral("color"), rgbaToCss(lineCfg_.labelColor)}}; + QJsonObject properties{{QStringLiteral("lineConfig"), lineConfig}, + {QStringLiteral("labelConfig"), labelConfig}, + {QStringLiteral("lvlSchemeType"), lvlSchemeType_}, + {QStringLiteral("logLinesCount"), logLinesCount_}, + {QStringLiteral("equalAreaLayerCount"), equalAreaLayerCount_}, + {QStringLiteral("colorBar"), colorBar}}; + + // 走仓储传输;回调里用 QPointer 守卫 this(模态对话框可能已关)。 + // 勾选覆盖 → PUT 更新来源模板(updateLvlTemplate);否则 → POST 新建(saveLvlTemplate)。 + QPointer self(this); + auto onDone = [self, overwrite](bool ok, QString msg) { + if (!self) return; + if (ok) + QMessageBox::information( + self, QStringLiteral("另存"), + overwrite ? QStringLiteral("更新成功。") : QStringLiteral("另存成功。")); + else + QMessageBox::warning(self, QStringLiteral("另存"), + QStringLiteral("另存失败:%1").arg(msg)); + }; + if (overwrite) + tplRepo_->updateLvlTemplate(lvlTemplateId_, name, properties, std::move(onDone)); + else + tplRepo_->saveLvlTemplate(projectId_, name, properties, std::move(onDone)); +} + +void ColorScaleConfigDialog::onOpen() { + if (tplRepo_ == nullptr || projectId_.isEmpty()) return; + QPointer self(this); + tplRepo_->listLvlTemplates(projectId_, [self](bool ok, QJsonArray list, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("打开"), + QStringLiteral("获取色阶列表失败:%1").arg(msg)); + return; + } + if (list.isEmpty()) { + QMessageBox::information(self, QStringLiteral("打开"), + QStringLiteral("暂无可用色阶模板。")); + return; + } + QStringList names; + for (const auto& it : list) + names << it.toObject().value(QStringLiteral("templateName")).toString(); + bool picked = false; + const QString chosen = QInputDialog::getItem( + self, QStringLiteral("引用色阶"), QStringLiteral("请选择色阶:"), names, 0, + false, &picked); + if (!picked) return; + const int sel = names.indexOf(chosen); + if (sel < 0) return; + const QJsonObject props = + list[sel].toObject().value(QStringLiteral("properties")).toObject(); + const QJsonArray colorBar = props.value(QStringLiteral("colorBar")).toArray(); + std::vector> bar; + for (const auto& e : colorBar) { + const QJsonArray pair = e.toArray(); + if (pair.size() < 2) continue; + bar.emplace_back(pair[0].toVariant().toDouble(), + parseCssColor(pair[1].toString())); + } + if (bar.size() < 2) { + QMessageBox::warning(self, QStringLiteral("打开"), + QStringLiteral("色阶数据无效。")); + return; + } + // 透传方案字段。 + self->lvlSchemeType_ = + props.value(QStringLiteral("lvlSchemeType")).toString(QStringLiteral("normal")); + self->logLinesCount_ = + props.value(QStringLiteral("logLinesCount")).toInt(8); + self->equalAreaLayerCount_ = + props.value(QStringLiteral("equalAreaLayerCount")).toInt(10); + const QJsonObject lc = props.value(QStringLiteral("lineConfig")).toObject(); + if (!lc.isEmpty()) { + self->lineCfg_.lineShow = lc.value(QStringLiteral("showLines")).toBool(true); + self->lineCfg_.dashed = + lc.value(QStringLiteral("lineType")).toString() == QStringLiteral("dashed"); + self->lineCfg_.lineColor = + parseCssColor(lc.value(QStringLiteral("color")).toString()); + } + self->loadColorBar(bar); + }); +} + +geopro::core::ColorScale ColorScaleConfigDialog::colorScale() const { + geopro::core::ColorScale cs; + for (const auto& row : rows_) cs.addStop(row.value, row.color); // 内部按 value 升序 + return cs; +} + +} // namespace geopro::app diff --git a/src/app/ColorScaleConfigDialog.hpp b/src/app/ColorScaleConfigDialog.hpp new file mode 100644 index 0000000..f8b6aba --- /dev/null +++ b/src/app/ColorScaleConfigDialog.hpp @@ -0,0 +1,90 @@ +#pragma once +#include + +#include +#include + +#include "ContourLineDialog.hpp" // ContourLineConfig(线形/标注配置,共享输出) +#include "model/ColorScale.hpp" + +class QTableWidget; +class QPushButton; + +namespace geopro::data { +class IColorTemplateRepository; +} + +namespace geopro::app { + +// 色阶配置对话框(1:1 复刻原版 web「色阶配置」colorLevel.vue): +// 左侧三列表格(层级 / 线形 / 颜色),每列表头带 ⚙ 点击打开对应子对话框 +// (层级⚙→ContourLevel 重算层级;线形⚙→ContourLine 改线形/标注;颜色⚙→ColorGradient 连续渐变); +// 右侧竖排按钮 新增 / 删除 / 另存为 / 导出 / 导入 / 打开。 +// 内部以 (value,Rgba) 升序断点建模,与 core::ColorScale 一一对应;行按层级升序显示(低值在上)。 +// 只负责编辑:确定后由调用方取 colorScale() / lineConfig() 应用到 2D/3D 渲染。 +// 「另存为 / 打开」接真实后端(lvl 模板库);无 api 时这两个按钮禁用。 +class ColorScaleConfigDialog : public QDialog { + Q_OBJECT +public: + // init:当前色阶(升序断点填表);vmin/vmax:数据原始范围(层级/颜色子对话框 + 新增外推用); + // samples:数据原始标量(等积分层 + 颜色编辑器直方图用,空则等积退化为线性); + // lineInit:线形/标注初值(2D 传当前态,3D 用默认); + // tplRepo/projectId:lvl 模板库仓储句柄(可空 → 另存为/打开 禁用); + // lvlTemplateId:当前色阶来源模板 id(可空,对照原版 props.data.lvlTemplateId)。 + // 非空时「另存为」弹窗的「覆盖」复选框可勾选 → 走 PUT 更新该模板;3D/无模板场景不传即可。 + ColorScaleConfigDialog(const geopro::core::ColorScale& init, double vmin, double vmax, + std::vector samples = {}, + const ContourLineConfig& lineInit = {}, + geopro::data::IColorTemplateRepository* tplRepo = nullptr, + QString projectId = {}, QString lvlTemplateId = {}, + QWidget* parent = nullptr); + + // 由表格当前断点装配的新色阶(按层级升序 addStop)。 + geopro::core::ColorScale colorScale() const; + // 线形/标注配置(线形⚙ 编辑后;2D 消费,3D 忽略)。 + ContourLineConfig lineConfig() const { return lineCfg_; } + +private: + struct Row { + double value; + geopro::core::Rgba color; + }; + + void rebuildTable(); // 按 rows_ 重填表格(升序显示:低值在上) + void onCellDoubleClicked(int row, int col); + void onAdd(); // 新增:选中行上方插入中点断点(复刻 handleAdd) + void onRemove(); // 删除:移除选中断点(保留 ≥2) + void onLevelScheme(); // 层级⚙:ContourLevelDialog → 重算层级分布 + void onLineScheme(); // 线形⚙:ContourLineDialog → 更新线形/标注配置 + void onColorScheme(); // 颜色⚙:ColorGradientDialog 连续渐变 → 按层级位置回填颜色 + void onImportLvl(); // 导入 .lvl 文件 + void onExportLvl(); // 导出 .lvl 文件 + void onSaveOther(); // 另存为:保存命名 lvl 模板到后端 + void onOpen(); // 打开:从后端 lvl 模板库选取载入 + int selectedModelIndex() const; // 选中表格行 → rows_ 下标(升序,无选中返回 -1) + + // 在当前断点(升序)上做连续线性 RGBA 插值取色(复刻 colorUtils.js mapColors)。 + geopro::core::Rgba interpColor(double value) const; + // 用模板/打开载入的 colorBar([level,colorString]) 重填 rows_。 + void loadColorBar(const std::vector>& bar); + + QTableWidget* table_ = nullptr; + std::vector rows_; // 始终按 value 升序维护 + double vmin_ = 0.0; + double vmax_ = 0.0; + std::vector samples_; // 数据原始标量(等积分层 + 直方图) + ContourLineConfig lineCfg_; // 线形/标注配置(线形⚙ 编辑) + + geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; // lvl 模板库仓储(可空) + QString projectId_; + QString lvlTemplateId_; // 当前色阶来源模板 id(可空 → 另存为弹窗禁用「覆盖」) + // 随子对话框更新、写入另存为 properties(复刻原版透传字段)。 + QString lvlSchemeType_ = QStringLiteral("normal"); + int logLinesCount_ = 8; + int equalAreaLayerCount_ = 10; + + QPushButton* btnSaveOther_ = nullptr; // 无 api 时禁用 + QPushButton* btnOpen_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/ColorScaleIO.cpp b/src/app/ColorScaleIO.cpp new file mode 100644 index 0000000..368e4f6 --- /dev/null +++ b/src/app/ColorScaleIO.cpp @@ -0,0 +1,151 @@ +#include "ColorScaleIO.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +using geopro::core::Rgba; + +// 外部文件内容不可信:stod 对非数字/超界 token 会抛异常,统一兜成 nullopt。 +std::optional safeStod(const std::string& s) noexcept { + try { + return std::stod(s); + } catch (...) { + return std::nullopt; + } +} + +unsigned char clampByte(double v) { + if (v < 0) v = 0; + if (v > 255) v = 255; + return static_cast(v + 0.5); +} + +std::vector splitLines(const std::string& s) { + std::vector out; + std::string line; + std::istringstream is(s); + while (std::getline(is, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); // CRLF 容错 + out.push_back(line); + } + return out; +} +} // namespace + +// ── .lvl ──────────────────────────────────────────────────────────────────── +std::vector parseLvl(const std::string& content) { + std::vector rows; + const auto lines = splitLines(content); + if (lines.empty() || lines[0].rfind("LVL", 0) != 0) return rows; // 头校验 + + // 行内扫描 "R G B[ A]":第 1 个=线色(LColor),最后 1 个=填充色(FFGColor)。 + // 此法对 LStyle 含空格(".1 in. Dash")导致的列错位免疫。正则只编译一次(构造代价高)。 + static const std::regex rgbaRe(R"(R(\d+)\s+G(\d+)\s+B(\d+)(?:\s+A(\d+))?)"); + static const std::regex numRe(R"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)"); + + for (std::size_t i = 2; i < lines.size(); ++i) { // 跳过 头 + 列名 两行 + const std::string& ln = lines[i]; + if (ln.find_first_not_of(" \t") == std::string::npos) continue; + + std::smatch m; + if (!std::regex_search(ln, m, numRe)) continue; // 首数字 = level + const auto lvl = safeStod(m.str()); + if (!lvl) continue; + LvlRow row; + row.level = *lvl; + row.dashed = ln.find("Dash") != std::string::npos; + + std::vector colors; + for (auto it = std::sregex_iterator(ln.begin(), ln.end(), rgbaRe); + it != std::sregex_iterator(); ++it) { + const auto& mm = *it; // R/G/B/A 来自正则 \d+,stod 仅超长才异常 → 仍兜底 + const auto r = safeStod(mm[1]), gg = safeStod(mm[2]), b = safeStod(mm[3]); + if (!r || !gg || !b) continue; + const unsigned char a = + mm[4].matched && safeStod(mm[4]) ? clampByte(*safeStod(mm[4])) + : static_cast(255); + colors.push_back(Rgba{clampByte(*r), clampByte(*gg), clampByte(*b), a}); + } + if (colors.empty()) continue; // 无可识别颜色 → 跳过该行 + row.lineColor = colors.front(); + row.color = colors.back(); // 仅 1 个时线色=填充色 + rows.push_back(row); + } + return rows; +} + +std::string generateLvl(const std::vector& rows) { + std::ostringstream os; + os.precision(std::numeric_limits::max_digits10); // 层级值全精度往返不截断 + os << "LVL3\n'Level Flags LColor LStyle LWidth FVersion FFGColor FBGColor FPattern " + "OffsetX OffsetY ScaleX ScaleY Angle Coverage"; + auto rgba = [](const Rgba& c) { + std::ostringstream s; + s << "\"R" << int(c.r) << " G" << int(c.g) << " B" << int(c.b) << " A" << int(c.a) << '"'; + return s.str(); + }; + for (const auto& r : rows) { + os << '\n' + << r.level << " 1 " << rgba(r.lineColor) << ' ' + << (r.dashed ? "\".1 in. Dash\"" : "\"Solid\"") << " 0 1 " << rgba(r.color) + << " \"Black\" \"Solid\" 0 0 1 1 0 0"; + } + return os.str(); +} + +// ── .clr ──────────────────────────────────────────────────────────────────── +ClrData parseClr(const std::string& content) { + ClrData out; + auto lines = splitLines(content); + if (!lines.empty() && lines.back().empty()) lines.pop_back(); + if (lines.size() < 4) return out; // 头 + ≥1 色 + 2 透明度 + if (!std::regex_match(lines[0], std::regex(R"(ColorMap\s+\d+\s+\d+\s+\d+\s+\d+\s*)"))) return out; + + auto tokens = [](const std::string& s) { + std::vector t; + std::istringstream is(s); + std::string w; + while (is >> w) t.push_back(w); + return t; + }; + // 颜色行:索引 1 .. size-3(末两行为透明度)。非数字 token 跳过该行(外部文件不可信)。 + for (std::size_t i = 1; i + 2 < lines.size(); ++i) { + const auto t = tokens(lines[i]); + if (t.size() < 4) continue; + const auto pos = safeStod(t[0]), r = safeStod(t[1]), g = safeStod(t[2]), b = safeStod(t[3]); + if (!pos || !r || !g || !b) continue; + out.stops.emplace_back(*pos / 100.0, Rgba{clampByte(*r), clampByte(*g), clampByte(*b), + static_cast(255)}); + } + // 透明度取末行的第 2 个值(原版读 token[0] 实为 bug,这里取真实透明度)。 + const auto last = tokens(lines.back()); + if (last.size() >= 2) + if (const auto op = safeStod(last[1])) out.opacity = *op / 100.0; + return out; +} + +std::string generateClr(const ClrData& data) { + std::ostringstream body; + body.setf(std::ios::fixed); + body.precision(8); + int n = 0; + for (const auto& [pos, c] : data.stops) { + body << pos * 100.0 << ' ' << int(c.r) << ' ' << int(c.g) << ' ' << int(c.b) << " 255\n"; + ++n; + } + const double op = data.opacity * 100.0; + body << "0.00000000 " << op << '\n' << "100.00000000 " << op; + std::ostringstream os; + os << "ColorMap " << (n + 2) << " 0 6 2\n" << body.str(); + return os.str(); +} + +} // namespace geopro::app diff --git a/src/app/ColorScaleIO.hpp b/src/app/ColorScaleIO.hpp new file mode 100644 index 0000000..0d2404f --- /dev/null +++ b/src/app/ColorScaleIO.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +#include +#include + +#include "model/ColorScale.hpp" // core::Rgba + +namespace geopro::app { + +// .lvl 文件一行(复刻 colorUtils.js parseLvlFile / generateLvlContent)。 +struct LvlRow { + double level = 0.0; + geopro::core::Rgba color{0, 0, 0, 255}; // FFGColor(填充色) + bool dashed = false; // LStyle: solid / .1 in. Dash + geopro::core::Rgba lineColor{0, 0, 0, 255}; // LColor(线色) +}; + +// 解析 .lvl 文本 → 行列表(头部校验失败/空 → 空)。 +std::vector parseLvl(const std::string& content); +// 生成 .lvl 文本(LVL3 头 + 每行 14 列),与原系统互通。 +std::string generateLvl(const std::vector& rows); + +// .clr 连续色阶(复刻 colorEditor.vue import/export)。pos ∈ [0,1] 升序,opacity ∈ [0,1]。 +struct ClrData { + std::vector> stops; + double opacity = 1.0; +}; + +// 解析 .clr 文本(头 `ColorMap n 0 6 2` + `pos*100 r g b a` 行 + 末两行透明度)。失败 → stops 空。 +ClrData parseClr(const std::string& content); +// 生成 .clr 文本。 +std::string generateClr(const ClrData& data); + +} // namespace geopro::app diff --git a/src/app/ContourLevelDialog.cpp b/src/app/ContourLevelDialog.cpp new file mode 100644 index 0000000..afd7a25 --- /dev/null +++ b/src/app/ContourLevelDialog.cpp @@ -0,0 +1,269 @@ +#include "ContourLevelDialog.hpp" + +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" + +namespace geopro::app { + +namespace { +constexpr int kMaxLayers = 50; // 原版上限:分层层数 ≤ 50 +} // namespace + +ContourLevelDialog::ContourLevelDialog(const ContourLevelParams& init, double originMin, + double originMax, double totalArea, QWidget* parent) + : QDialog(parent), originMin_(originMin), originMax_(originMax), totalArea_(totalArea) { + setWindowTitle(QStringLiteral("等值线层级")); + setModal(true); + + auto* root = formkit::dialogRoot(this); + auto* form = formkit::makeEditForm(); + root->addLayout(form); + + // 数据范围(原始,只读展示)。 + form->addRow(formkit::editLabel(QStringLiteral("数据范围")), + new QLabel(QStringLiteral("%1 ~ %2").arg(originMin_).arg(originMax_))); + + // 分层方式。 + methodCombo_ = new EmptyAwareComboBox(this); + methodCombo_->addItem(QStringLiteral("一般的"), 0); + methodCombo_->addItem(QStringLiteral("对数"), 1); + methodCombo_->addItem(QStringLiteral("等积"), 2); + methodCombo_->setCurrentIndex(static_cast(init.method)); + formkit::capField(methodCombo_); + form->addRow(formkit::editLabel(QStringLiteral("分层方式")), methodCombo_); + + auto* validator = new QDoubleValidator(this); + validator->setNotation(QDoubleValidator::StandardNotation); + validator->setLocale(QLocale::c()); // 锁定 C locale:与 toDouble() 一致,避免中文系统逗号歧义 + + // 最大/最小等值线(equalArea 时整行隐藏)。 + minEdit_ = new QLineEdit(QString::number(init.minValue), this); + maxEdit_ = new QLineEdit(QString::number(init.maxValue), this); + minEdit_->setValidator(validator); + maxEdit_->setValidator(validator); + formkit::capField(minEdit_); + formkit::capField(maxEdit_); + rangeRow_ = new QWidget(this); + auto* rangeForm = formkit::makeEditForm(); + rangeForm->setContentsMargins(0, 0, 0, 0); + rangeForm->addRow(formkit::editLabel(QStringLiteral("最大等值线")), maxEdit_); + rangeForm->addRow(formkit::editLabel(QStringLiteral("最小等值线")), minEdit_); + rangeRow_->setLayout(rangeForm); + root->addWidget(rangeRow_); + + // normal:间隔数 + 层数(双向联动)。 + intervalEdit_ = new QLineEdit(QString::number(init.interval), this); + layerCountEdit_ = new QLineEdit(QString::number(init.layerCount), this); + intervalEdit_->setValidator(validator); + formkit::capField(intervalEdit_); + formkit::capField(layerCountEdit_); + normalRow_ = new QWidget(this); + auto* normalForm = formkit::makeEditForm(); + normalForm->setContentsMargins(0, 0, 0, 0); + normalForm->addRow(formkit::editLabel(QStringLiteral("数值间隔")), intervalEdit_); + normalForm->addRow(formkit::editLabel(QStringLiteral("层数")), layerCountEdit_); + normalRow_->setLayout(normalForm); + root->addWidget(normalRow_); + + // logarithmic:每数量级次要等值线数。 + logLinesEdit_ = new QLineEdit(QString::number(init.logLinesCount), this); + formkit::capField(logLinesEdit_); + logRow_ = new QWidget(this); + auto* logForm = formkit::makeEditForm(); + logForm->setContentsMargins(0, 0, 0, 0); + logForm->addRow(formkit::editLabel(QStringLiteral("每数量级次要等值线数")), logLinesEdit_); + logRow_->setLayout(logForm); + root->addWidget(logRow_); + + // equalArea:等积分层层数 + 区间面积(只读,自动算)。 + equalAreaCountEdit_ = new QLineEdit(QString::number(init.equalAreaLayerCount), this); + formkit::capField(equalAreaCountEdit_); + intervalAreaLabel_ = new QLabel(this); + equalAreaRow_ = new QWidget(this); + auto* eaForm = formkit::makeEditForm(); + eaForm->setContentsMargins(0, 0, 0, 0); + eaForm->addRow(formkit::editLabel(QStringLiteral("层数")), equalAreaCountEdit_); + eaForm->addRow(formkit::editLabel(QStringLiteral("区间面积")), intervalAreaLabel_); + equalAreaRow_->setLayout(eaForm); + root->addWidget(equalAreaRow_); + + auto* reset = new QPushButton(QStringLiteral("恢复默认值"), this); + auto* resetRow = new QHBoxLayout(); + resetRow->addStretch(); + resetRow->addWidget(reset); + root->addLayout(resetRow); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("应用")); + buttons->button(QDialogButtonBox::Cancel)->setText(QStringLiteral("取消")); + root->addWidget(buttons); + + // 联动接线(normal 双向;equalArea 区间面积随层数)。 + connect(methodCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onMethodChanged(); }); + connect(intervalEdit_, &QLineEdit::textEdited, this, + [this](const QString&) { recalcLayerCountFromInterval(); }); + connect(layerCountEdit_, &QLineEdit::textEdited, this, + [this](const QString&) { recalcIntervalFromLayerCount(); }); + connect(minEdit_, &QLineEdit::textEdited, this, + [this](const QString&) { recalcLayerCountFromInterval(); }); + connect(maxEdit_, &QLineEdit::textEdited, this, + [this](const QString&) { recalcLayerCountFromInterval(); }); + connect(equalAreaCountEdit_, &QLineEdit::textEdited, this, + [this](const QString&) { updateIntervalArea(); }); + connect(reset, &QPushButton::clicked, this, &ContourLevelDialog::onReset); + connect(buttons, &QDialogButtonBox::accepted, this, &ContourLevelDialog::onAccept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + + updateIntervalArea(); + updateVisibility(); // 初始仅按方式显隐(不重算,保留传入初值) +} + +double ContourLevelDialog::parsed(const QLineEdit* e, double fallback) const { + bool ok = false; + const double v = e->text().toDouble(&ok); + return ok ? v : fallback; +} + +void ContourLevelDialog::updateVisibility() { + const int m = methodCombo_->currentIndex(); + const bool equalArea = (m == 2); + rangeRow_->setVisible(!equalArea); // 最大/最小只在 normal/log 显示 + normalRow_->setVisible(m == 0); + logRow_->setVisible(m == 1); + equalAreaRow_->setVisible(equalArea); + adjustSize(); +} + +void ContourLevelDialog::onMethodChanged() { + updateVisibility(); + if (methodCombo_->currentIndex() == 0) + recalcIntervalFromLayerCount(); // 切回一般时按层数刷间隔(复刻 watch) +} + +void ContourLevelDialog::recalcLayerCountFromInterval() { + if (autoUpdating_ || methodCombo_->currentIndex() != 0) return; + const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN); + const double iv = parsed(intervalEdit_, NAN); + if (std::isfinite(mn) && std::isfinite(mx) && std::isfinite(iv) && iv > 0 && mx > mn) { + autoUpdating_ = true; + layerCountEdit_->setText(QString::number(static_cast(std::ceil((mx - mn) / iv)))); + autoUpdating_ = false; + } +} + +void ContourLevelDialog::recalcIntervalFromLayerCount() { + if (autoUpdating_ || methodCombo_->currentIndex() != 0) return; + const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN); + bool ok = false; + const int cnt = layerCountEdit_->text().toInt(&ok); + if (std::isfinite(mn) && std::isfinite(mx) && ok && cnt > 0 && mx > mn) { + autoUpdating_ = true; + intervalEdit_->setText(QString::number((mx - mn) / cnt, 'f', 2)); + autoUpdating_ = false; + } +} + +void ContourLevelDialog::updateIntervalArea() { + bool ok = false; + const int cnt = equalAreaCountEdit_->text().toInt(&ok); + if (ok && cnt > 0) + intervalAreaLabel_->setText(QString::number(totalArea_ / cnt, 'f', 2)); + else + intervalAreaLabel_->setText(QStringLiteral("0")); +} + +void ContourLevelDialog::onReset() { + // 复刻 handleReset:统一回 normal + 原始范围 + 间隔=(max-min)/20。 + methodCombo_->setCurrentIndex(0); + minEdit_->setText(QString::number(originMin_)); + maxEdit_->setText(QString::number(originMax_)); + // 常值场(max==min)默认间隔取 1,避免间隔=0 导致"恢复默认→应用即报错"。 + const double span = originMax_ - originMin_; + intervalEdit_->setText(QString::number(span > 0 ? span / 20.0 : 1.0, 'f', 2)); + logLinesEdit_->setText(QStringLiteral("8")); + equalAreaCountEdit_->setText(QStringLiteral("10")); + recalcLayerCountFromInterval(); + updateIntervalArea(); +} + +void ContourLevelDialog::onAccept() { + const int m = methodCombo_->currentIndex(); + const double mn = parsed(minEdit_, NAN), mx = parsed(maxEdit_, NAN); + + if (m != 2 && !(mx > mn)) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("最大值必须大于最小值")); + return; + } + ContourLevelParams r; + r.method = static_cast(m); + r.minValue = mn; + r.maxValue = mx; + + if (m == 0) { // normal + const double iv = parsed(intervalEdit_, NAN); + if (!(iv > 0)) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("数据间隔必须大于0")); + return; + } + const int layers = static_cast(std::ceil((mx - mn) / iv)); + if (layers > kMaxLayers) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("数据间隔设置过小,分层层数不能超过50")); + return; + } + r.interval = iv; + r.layerCount = layers; + } else if (m == 1) { // logarithmic + bool ok = false; + const int lc = logLinesEdit_->text().toInt(&ok); + if (!ok || lc <= 0) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("每数量级次要等值线数必须大于0")); + return; + } + if (!(mx > 0)) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("对数分层方式下,最大等值线必须大于0")); + return; + } + if (mn < 0) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("对数分层方式下,最小等值线必须大于0")); + return; + } + r.logLinesCount = lc; + } else { // equalArea + bool ok = false; + const int cnt = equalAreaCountEdit_->text().toInt(&ok); + if (!ok || cnt <= 0) { + QMessageBox::warning(this, QStringLiteral("等值线层级"), + QStringLiteral("层数必须大于0")); + return; + } + r.equalAreaLayerCount = cnt; + } + + result_ = r; + accept(); +} + +} // namespace geopro::app diff --git a/src/app/ContourLevelDialog.hpp b/src/app/ContourLevelDialog.hpp new file mode 100644 index 0000000..25d8cc2 --- /dev/null +++ b/src/app/ContourLevelDialog.hpp @@ -0,0 +1,58 @@ +#pragma once +#include + +#include "ContourLevels.hpp" // ContourLevelParams(纯数据,与算法同源) + +class QComboBox; +class QLabel; +class QLineEdit; +class QWidget; + +namespace geopro::app { + +// 层级⚙ 子对话框(复刻 contourLevel.vue):分层方式 normal/logarithmic/equalArea, +// 最大/最小等值线,normal 下「间隔数↔层数」双向联动,对数/等积各自参数,恢复默认, +// 确定时按原版校验(max>min、间隔>0、层数≤50、对数 max>0/min≥0、等积 count>0)。 +// 只产出参数;层级的实际重算 + 颜色插值由 ColorScaleConfigDialog 完成。 +class ContourLevelDialog : public QDialog { + Q_OBJECT +public: + // init:当前层级状态(来自主表);originMin/Max:数据原始范围(仅展示+恢复默认用); + // totalArea:等积「区间面积」展示用的分母总量(3D 体取样本数,无则给原版默认 1000)。 + ContourLevelDialog(const ContourLevelParams& init, double originMin, double originMax, + double totalArea, QWidget* parent = nullptr); + + ContourLevelParams params() const { return result_; } // accept() 后有效 + +private: + void updateVisibility(); // 仅按分层方式显隐各行(不触发重算) + void onMethodChanged(); // 用户切换方式:显隐 + normal 下按层数刷间隔 + void recalcLayerCountFromInterval(); // normal:层数 = ceil((max-min)/间隔) + void recalcIntervalFromLayerCount(); // normal:间隔 = (max-min)/层数 + void updateIntervalArea(); // equalArea:区间面积 = totalArea/层数 + void onReset(); + void onAccept(); // 校验后填 result_ 并 accept + + double parsed(const QLineEdit* e, double fallback) const; + + QComboBox* methodCombo_ = nullptr; + QWidget* rangeRow_ = nullptr; // 最大/最小(equalArea 时隐藏) + QLineEdit* minEdit_ = nullptr; + QLineEdit* maxEdit_ = nullptr; + QWidget* normalRow_ = nullptr; // 间隔数 + 层数 + QLineEdit* intervalEdit_ = nullptr; + QLineEdit* layerCountEdit_ = nullptr; + QWidget* logRow_ = nullptr; // 对数:次要等值线数 + QLineEdit* logLinesEdit_ = nullptr; + QWidget* equalAreaRow_ = nullptr; // 等积:层数 + 区间面积 + QLineEdit* equalAreaCountEdit_ = nullptr; + QLabel* intervalAreaLabel_ = nullptr; + + double originMin_ = 0.0; + double originMax_ = 1.0; + double totalArea_ = 1000.0; + bool autoUpdating_ = false; // 防双向联动无限递归(复刻 isAutoUpdating) + ContourLevelParams result_; +}; + +} // namespace geopro::app diff --git a/src/app/ContourLevels.cpp b/src/app/ContourLevels.cpp new file mode 100644 index 0000000..3f47284 --- /dev/null +++ b/src/app/ContourLevels.cpp @@ -0,0 +1,82 @@ +#include "ContourLevels.hpp" + +#include +#include + +namespace geopro::app { + +namespace { +constexpr int kMaxLevels = 100000; // 纯函数自身的 OOM 兜底(UI 另有 ≤50 校验) + +std::vector normalLevels(double mn, double mx, double interval) { + std::vector levels; + if (!std::isfinite(mn) || !std::isfinite(mx) || !std::isfinite(interval)) return levels; + if (!(interval > 0) || !(mx > mn)) return levels; + const double lend = std::ceil((mx - mn) / interval); + if (lend > kMaxLevels) return levels; // 极小间隔 → 防爆内存 + const int len = static_cast(lend); + for (int i = 0; i < len; ++i) levels.push_back(mn + interval * i); + return levels; +} + +std::vector logarithmicLevels(double mn, double mx, int logLines) { + std::vector levels; + if (!std::isfinite(mn) || !std::isfinite(mx)) return levels; // 防 +Inf 致死循环 + if (logLines <= 0 || !(mx > 0)) return levels; + auto nextPow10 = [](double v) { return std::pow(10.0, std::ceil(std::log10(v))); }; + const double A = (mn <= 0) ? 0.1 : std::max(0.1, nextPow10(mn) / 10.0); + const double H = nextPow10(mx); + std::vector powers; + for (double cur = A; cur <= H; cur *= 10.0) powers.push_back(cur); + levels.push_back(mn); + for (std::size_t i = 0; i + 1 < powers.size(); ++i) { + const double start = powers[i], end = powers[i + 1]; + const double step = (end - start) / logLines; + for (int j = 1; j < logLines; ++j) { + const double v = start + step * j; + if (v <= mx) levels.push_back(v); + } + if (end <= mx) levels.push_back(end); + } + if (!powers.empty() && powers.back() < mx) levels.push_back(mx); + std::sort(levels.begin(), levels.end()); + return levels; +} + +std::vector equalAreaLevels(double mn, double mx, int cnt, + const std::vector& samples) { + std::vector levels; + if (cnt <= 0) return levels; + std::vector z; + z.reserve(samples.size()); + for (double v : samples) + if (std::isfinite(v)) z.push_back(v); + if (static_cast(z.size()) >= cnt) { // 分位 + std::sort(z.begin(), z.end()); + const int chunk = static_cast(z.size()) / cnt; + for (int i = 0; i < cnt - 1; ++i) + levels.push_back( + z[static_cast(std::min(i * chunk, static_cast(z.size()) - 1))]); + if (levels.empty() || levels.back() < z.back()) levels.push_back(z.back()); + } else { // 兜底:等距 cnt 段(线性) + const double step = (mx - mn) / cnt; + for (int i = 0; i < cnt; ++i) levels.push_back(mn + step * i); + } + return levels; +} +} // namespace + +std::vector generateContourLevels(const ContourLevelParams& p, + const std::vector& samples) { + switch (p.method) { + case ContourLevelParams::Method::Normal: + return normalLevels(p.minValue, p.maxValue, p.interval); + case ContourLevelParams::Method::Logarithmic: + return logarithmicLevels(p.minValue, p.maxValue, p.logLinesCount); + case ContourLevelParams::Method::EqualArea: + return equalAreaLevels(p.minValue, p.maxValue, p.equalAreaLayerCount, samples); + } + return {}; +} + +} // namespace geopro::app diff --git a/src/app/ContourLevels.hpp b/src/app/ContourLevels.hpp new file mode 100644 index 0000000..86b84c7 --- /dev/null +++ b/src/app/ContourLevels.hpp @@ -0,0 +1,26 @@ +#pragma once +#include + +namespace geopro::app { + +// 层级(分层方式)参数,复刻原版 contourLevel.vue 的 emit。纯数据,无 Qt 依赖。 +struct ContourLevelParams { + enum class Method { Normal, Logarithmic, EqualArea }; + Method method = Method::Normal; + double minValue = 0.0; // 最小等值线(normal/log) + double maxValue = 1.0; // 最大等值线(normal/log) + double interval = 0.1; // 间隔数(normal) + int layerCount = 10; // 层数(normal) + int logLinesCount = 8; // 每数量级次要等值线数(log) + int equalAreaLayerCount = 10; // 等积分层层数(equalArea) +}; + +// 按分层方式生成升序层级值(复刻 colorLevel.vue case 'level' 的层级重算): +// normal —— len=ceil((max-min)/间隔),levels = min + 间隔*i +// logarithmic —— 10 的幂区间细分(每数量级 logLinesCount 条次要线) +// equalArea —— 原始样本分位;样本不足 cnt 个时退化为等距 cnt 段(复刻原版失败兜底) +// samples 仅 equalArea 用(其余忽略)。返回的颜色由调用方在旧色阶上插值。 +std::vector generateContourLevels(const ContourLevelParams& p, + const std::vector& samples); + +} // namespace geopro::app diff --git a/src/app/ContourLineDialog.cpp b/src/app/ContourLineDialog.cpp new file mode 100644 index 0000000..7402df1 --- /dev/null +++ b/src/app/ContourLineDialog.cpp @@ -0,0 +1,110 @@ +#include "ContourLineDialog.hpp" + +#include +#include +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include + +#include "FormKit.hpp" + +namespace geopro::app { + +namespace { +QColor toQColor(const geopro::core::Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } +geopro::core::Rgba fromQColor(const QColor& c) { + return geopro::core::Rgba{static_cast(c.red()), + static_cast(c.green()), + static_cast(c.blue()), + static_cast(c.alpha())}; +} +} // namespace + +ContourLineDialog::ContourLineDialog(const ContourLineConfig& init, QWidget* parent) + : QDialog(parent), cfg_(init) { + setWindowTitle(QStringLiteral("等值线修改")); + setModal(true); + + auto* root = formkit::dialogRoot(this); + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + auto* form = formkit::makeEditForm(); + + // 复刻 contourLine.vue:选项顺序「虚线」在前、「实线」在后。 + lineTypeCombo_ = new EmptyAwareComboBox(this); + lineTypeCombo_->addItem(QStringLiteral("- - - - - - - - -"), true); // dashed + lineTypeCombo_->addItem(QStringLiteral("——————"), false); // solid + lineTypeCombo_->setCurrentIndex(cfg_.dashed ? 0 : 1); + formkit::capField(lineTypeCombo_); + form->addRow(formkit::editLabel(QStringLiteral("线形:")), lineTypeCombo_); + + lineShowChk_ = new QCheckBox(this); + lineShowChk_->setChecked(cfg_.lineShow); + form->addRow(formkit::editLabel(QStringLiteral("显示线段:")), lineShowChk_); + + lineColorBtn_ = new QPushButton(this); + paintSwatch(lineColorBtn_, cfg_.lineColor); // 专用色块控件:样式表保留 + connect(lineColorBtn_, &QPushButton::clicked, this, &ContourLineDialog::pickLineColor); + form->addRow(formkit::editLabel(QStringLiteral("线段颜色:")), lineColorBtn_); + + labelShowChk_ = new QCheckBox(this); + labelShowChk_->setChecked(cfg_.labelShow); + form->addRow(formkit::editLabel(QStringLiteral("显示标注:")), labelShowChk_); + + labelColorBtn_ = new QPushButton(this); + paintSwatch(labelColorBtn_, cfg_.labelColor); // 专用色块控件:样式表保留 + connect(labelColorBtn_, &QPushButton::clicked, this, &ContourLineDialog::pickLabelColor); + form->addRow(formkit::editLabel(QStringLiteral("标注颜色:")), labelColorBtn_); + + cardLay->addLayout(form); + root->addWidget(card); + + auto* buttons = formkit::addDialogButtons(root, this, QStringLiteral("应用"), + QStringLiteral("取消")); + // addDialogButtons 默认 accepted→accept();本对话框须先在 onAccept 写回 cfg_ 再 accept。 + disconnect(buttons, &QDialogButtonBox::accepted, this, nullptr); + connect(buttons, &QDialogButtonBox::accepted, this, &ContourLineDialog::onAccept); +} + +void ContourLineDialog::paintSwatch(QPushButton* btn, const geopro::core::Rgba& c) { + const QColor q = toQColor(c); + btn->setText(q.name(QColor::HexArgb)); + btn->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);") + .arg(c.r) + .arg(c.g) + .arg(c.b) + .arg(c.a)); +} + +void ContourLineDialog::pickLineColor() { + const QColor picked = QColorDialog::getColor(toQColor(cfg_.lineColor), this, + QStringLiteral("线色"), + QColorDialog::ShowAlphaChannel); + if (!picked.isValid()) return; + cfg_.lineColor = fromQColor(picked); + paintSwatch(lineColorBtn_, cfg_.lineColor); +} + +void ContourLineDialog::pickLabelColor() { + const QColor picked = QColorDialog::getColor(toQColor(cfg_.labelColor), this, + QStringLiteral("标注色"), + QColorDialog::ShowAlphaChannel); + if (!picked.isValid()) return; + cfg_.labelColor = fromQColor(picked); + paintSwatch(labelColorBtn_, cfg_.labelColor); +} + +void ContourLineDialog::onAccept() { + cfg_.dashed = lineTypeCombo_->currentData().toBool(); + cfg_.lineShow = lineShowChk_->isChecked(); + cfg_.labelShow = labelShowChk_->isChecked(); + // 线色/标注色已在 pick* 即时写入 cfg_。 + accept(); +} + +} // namespace geopro::app diff --git a/src/app/ContourLineDialog.hpp b/src/app/ContourLineDialog.hpp new file mode 100644 index 0000000..3467d6f --- /dev/null +++ b/src/app/ContourLineDialog.hpp @@ -0,0 +1,42 @@ +#pragma once +#include + +#include "model/ColorScale.hpp" // core::Rgba + +class QCheckBox; +class QComboBox; +class QPushButton; + +namespace geopro::app { + +// 线形/标注配置(复刻 contourLine.vue 的 emit)。共享:2D 等值线渲染消费;3D 无等值线,忽略。 +struct ContourLineConfig { + bool lineShow = true; + geopro::core::Rgba lineColor{0, 0, 0, 255}; + bool dashed = false; // false=实线 solid,true=虚线 dashed + bool labelShow = true; + geopro::core::Rgba labelColor{0, 0, 0, 255}; +}; + +// 线形⚙ 子对话框(复刻 contourLine.vue):线型(实线/虚线)、线显、线色、标注显、标注色。 +class ContourLineDialog : public QDialog { + Q_OBJECT +public: + ContourLineDialog(const ContourLineConfig& init, QWidget* parent = nullptr); + ContourLineConfig config() const { return cfg_; } // accept() 后有效 + +private: + void pickLineColor(); + void pickLabelColor(); + void paintSwatch(QPushButton* btn, const geopro::core::Rgba& c); + void onAccept(); + + QComboBox* lineTypeCombo_ = nullptr; + QCheckBox* lineShowChk_ = nullptr; + QPushButton* lineColorBtn_ = nullptr; + QCheckBox* labelShowChk_ = nullptr; + QPushButton* labelColorBtn_ = nullptr; + ContourLineConfig cfg_; +}; + +} // namespace geopro::app diff --git a/src/app/DatasetCategory.cpp b/src/app/DatasetCategory.cpp new file mode 100644 index 0000000..bca33cc --- /dev/null +++ b/src/app/DatasetCategory.cpp @@ -0,0 +1,22 @@ +#include "DatasetCategory.hpp" + +namespace geopro::app { + +CategoryBuckets splitByCategory(const std::vector& rows) { + const auto& cfg = categoryConfigs(); + CategoryBuckets b; + b.segments.resize(cfg.size()); + for (const auto& r : rows) { + int hit = -1; + // 先按 ddCode(三维体/切片)——它们无 dsTypeCode(来自 Api3dRepository mock 行)。 + for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) + if (!cfg[i].ddCode.empty() && r.ddCode == cfg[i].ddCode) hit = static_cast(i); + // 再按 dsTypeCode。 + for (std::size_t i = 0; i < cfg.size() && hit < 0; ++i) + if (!cfg[i].dsTypeCode.empty() && r.dsTypeCode == cfg[i].dsTypeCode) hit = static_cast(i); + if (hit >= 0) b.segments[static_cast(hit)].push_back(r); + } + return b; +} + +} // namespace geopro::app diff --git a/src/app/DatasetCategory.hpp b/src/app/DatasetCategory.hpp new file mode 100644 index 0000000..519db3f --- /dev/null +++ b/src/app/DatasetCategory.hpp @@ -0,0 +1,16 @@ +#pragma once +#include +#include "repo/CategoryConfig.hpp" +#include "repo/RepoTypes.hpp" + +namespace geopro::app { + +struct CategoryBuckets { + std::vector> segments; // 与 categoryConfigs() 同序同长 +}; + +// 按 CategoryConfig 把 ds 分入大类段:先判 ddCode 白名单(三维体/切片),否则按 dsTypeCode 匹配; +// 不在表内的丢弃(接地电阻/原始数据/白化/坐标等)。保留原顺序。 +CategoryBuckets splitByCategory(const std::vector& rows); + +} // namespace geopro::app diff --git a/src/app/DatasetDimension.cpp b/src/app/DatasetDimension.cpp new file mode 100644 index 0000000..91d85db --- /dev/null +++ b/src/app/DatasetDimension.cpp @@ -0,0 +1,31 @@ +#include "DatasetDimension.hpp" + +namespace geopro::app { + +namespace { +// 与 LocalSample3dRepository::dimensionOf 同一映射(spec §6.1)。 +enum class Dim { D3, D2, Analysis, Other }; +Dim dimOf(const std::string& c) { + if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || + c == "dd_section" || c == "dd_inversion_data") + return Dim::D3; + if (c == "dd_slice") return Dim::Analysis; + if (c == "dd_trajectory_data") return Dim::D2; + return Dim::Other; +} +} // namespace + +DimBuckets splitByDimension(const std::vector& rows) { + DimBuckets b; + for (const auto& r : rows) { + switch (dimOf(r.ddCode)) { + case Dim::D3: b.dim3D.push_back(r); break; + case Dim::D2: b.dim2D.push_back(r); break; + case Dim::Analysis: b.analysis.push_back(r); break; + case Dim::Other: break; + } + } + return b; +} + +} // namespace geopro::app diff --git a/src/app/DatasetDimension.hpp b/src/app/DatasetDimension.hpp new file mode 100644 index 0000000..846ce57 --- /dev/null +++ b/src/app/DatasetDimension.hpp @@ -0,0 +1,17 @@ +#pragma once +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::app { + +struct DimBuckets { + std::vector dim3D; + std::vector dim2D; + std::vector analysis; +}; + +// 按 ddCode 把 ds 分流到 三维数据集 / 二维数据集 / 三维分析 三栏。 +// Other 维度不入任何栏(保留原顺序)。 +DimBuckets splitByDimension(const std::vector& rows); + +} // namespace geopro::app diff --git a/src/app/EmptyAwareComboBox.cpp b/src/app/EmptyAwareComboBox.cpp new file mode 100644 index 0000000..c2463b7 --- /dev/null +++ b/src/app/EmptyAwareComboBox.cpp @@ -0,0 +1,67 @@ +#include "EmptyAwareComboBox.hpp" + +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +// 临时「暂无数据」项用 UserRole 打标,便于 realItemCount/hidePopup 精准识别与移除。 +constexpr int kEmptyHintRole = Qt::UserRole + 9001; +const char* kEmptyHintText = "暂无数据"; +} // namespace + +EmptyAwareComboBox::EmptyAwareComboBox(QWidget* parent) : QComboBox(parent) { + // 不在此强设 currentIndex。占位语义由 setPlaceholderText 的「添加项前调用」时机决定: + // - 经 formkit::comboBox(placeholder,...) 建的(占位在添加项前设好)→ 即便后续异步加入 + // 数据项,Qt 仍维持 currentIndex=-1 显示占位,不自动选首项(Arco ASelect 占位语义)。 + // - 不带占位直接 new 的静态下拉 → addItem 首项后 Qt 自动选中索引 0(保持既有默认选中行为)。 +} + +int EmptyAwareComboBox::realItemCount() const { + int n = 0; + auto* m = model(); + for (int i = 0; i < count(); ++i) { + // 排除临时「暂无数据」占位项。 + if (itemData(i, kEmptyHintRole).toBool()) continue; + // 排除不可选项(禁用 / NoItemFlags)。用 model()->flags() 正确取项标志—— + // 原 itemData(i, UserRole-1) 不是 Qt 的 flags 角色,对正常项恒返回不可选 → + // realItemCount 恒 0 → 有真实项也误插「暂无数据」(用户实测:异常区下方多一条暂无数据)。 + if (m && !(m->flags(m->index(i, modelColumn())) & Qt::ItemIsSelectable)) continue; + ++n; + } + return n; +} + +void EmptyAwareComboBox::showPopup() { + // 无真实可选项时,临时插入一条灰色禁用「暂无数据」,让弹窗不再空白(同 Arco)。 + if (realItemCount() == 0 && !emptyHintInserted_) { + addItem(QString::fromUtf8(kEmptyHintText)); + const int idx = count() - 1; + setItemData(idx, true, kEmptyHintRole); // 打标,关闭时按标移除 + setItemData(idx, Qt::AlignCenter, Qt::TextAlignmentRole); + // 灰色禁用观感:取项目 token 色(text/disabled),与 Arco 空态一致。 + setItemData(idx, tokenColor("text/disabled"), Qt::ForegroundRole); + if (auto* m = qobject_cast(model())) { + if (auto* it = m->item(idx)) it->setFlags(Qt::NoItemFlags); // 不可选/不可聚焦 + } + emptyHintInserted_ = true; + } + QComboBox::showPopup(); +} + +void EmptyAwareComboBox::hidePopup() { + QComboBox::hidePopup(); + // 移除临时「暂无数据」项,恢复纯净数据(取值/计数不受污染)。临时项是禁用不可选的, + // 用户无法选中它,故移除后 currentIndex 自然维持原值(占位组合仍为 -1,无需强设)。 + if (emptyHintInserted_) { + for (int i = count() - 1; i >= 0; --i) + if (itemData(i, kEmptyHintRole).toBool()) removeItem(i); + emptyHintInserted_ = false; + } +} + +} // namespace geopro::app diff --git a/src/app/EmptyAwareComboBox.hpp b/src/app/EmptyAwareComboBox.hpp new file mode 100644 index 0000000..b97c2c8 --- /dev/null +++ b/src/app/EmptyAwareComboBox.hpp @@ -0,0 +1,34 @@ +#pragma once + +// EmptyAwareComboBox —— 空态感知下拉框(对齐原版 web Arco ASelect 观感)。 +// +// 历史问题:数据驱动的下拉(白化文件、异常类型、反演模型……)异步加载,加载前/无数据时: +// 1) 裸 QComboBox 会自动选中首项或留空,无「请选择 X」灰色占位提示; +// 2) 弹窗里一片空白,用户不知是「加载中」还是「真的没有」。 +// Arco ASelect 的标准行为是:未选时显示灰色占位文案,无数据时弹窗显示一条灰色「暂无数据」。 +// 本类把这两点收敛到唯一实现,全局通过 formkit::comboBox(...) 建下拉即自动获得一致观感。 + +#include + +namespace geopro::app { + +// QComboBox 子类:未选→占位文案(Qt6 自带 placeholderText,currentIndex=-1 时显示); +// 无真实可选项→点开弹窗时临时插入一条禁用的灰色「暂无数据」,弹窗关闭后移除(不污染数据/取值)。 +class EmptyAwareComboBox : public QComboBox { + Q_OBJECT +public: + explicit EmptyAwareComboBox(QWidget* parent = nullptr); + + // 点开弹窗:若无真实可选项(排除占位/禁用项),临时插入一条禁用「暂无数据」再弹出。 + void showPopup() override; + // 关闭弹窗:移除临时「暂无数据」项,保证数据/取值不被污染。 + void hidePopup() override; + +private: + // 统计「真实可选条目数」:排除占位项与不可选(NoItemFlags/禁用)项。 + int realItemCount() const; + + bool emptyHintInserted_ = false; // 当前是否插入了临时「暂无数据」项 +}; + +} // namespace geopro::app diff --git a/src/app/ExportDatasetDialog.cpp b/src/app/ExportDatasetDialog.cpp index 2311c67..8311c2d 100644 --- a/src/app/ExportDatasetDialog.cpp +++ b/src/app/ExportDatasetDialog.cpp @@ -3,16 +3,16 @@ #include #include +#include #include -#include #include #include #include #include #include #include -#include +#include "FormKit.hpp" #include "Theme.hpp" #include "api/NavLoads.hpp" #include "api/NavRequest.hpp" @@ -29,31 +29,31 @@ ExportDatasetDialog::ExportDatasetDialog(geopro::data::IAsyncProjectRepository& setWindowTitle(QStringLiteral("导出数据集")); setMinimumWidth(geopro::app::scaledPx(400)); - auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, - geopro::app::space::kLg, geopro::app::space::kMd); - lay->setSpacing(geopro::app::space::kMd); + auto* root = formkit::dialogRoot(this); - auto* fl = new QFormLayout(); - templateCombo_ = new QComboBox(this); - fl->addRow(QStringLiteral("导出模板"), templateCombo_); - lay->addLayout(fl); + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + + auto* fl = formkit::makeEditForm(); + // 空态感知下拉:数据驱动(异步 loadTemplates),未选显占位、无数据弹「暂无数据」。 + templateCombo_ = formkit::comboBox(QStringLiteral("请选择导出模板"), this); + formkit::capField(templateCombo_); + fl->addRow(formkit::editLabel(QStringLiteral("导出模板")), templateCombo_); + cardLay->addLayout(fl); status_ = new QLabel(QStringLiteral("加载模板…"), this); geopro::app::applyTokenizedStyleSheet(status_, QStringLiteral("color:{{text/disabled}};")); - lay->addWidget(status_); + cardLay->addWidget(status_); - auto* btnRow = new QHBoxLayout(); - btnRow->addStretch(); - auto* cancel = new QPushButton(QStringLiteral("取消"), this); - okBtn_ = new QPushButton(QStringLiteral("导出"), this); + root->addWidget(card); + + auto* buttons = + formkit::addDialogButtons(root, this, QStringLiteral("导出"), QStringLiteral("取消")); + // Ok 不直接 accept:需先 onConfirm 校验/异步导出,成功后才 accept。断开默认 accepted→accept。 + QObject::disconnect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + okBtn_ = buttons->button(QDialogButtonBox::Ok); okBtn_->setDefault(true); okBtn_->setEnabled(false); - btnRow->addWidget(cancel); - btnRow->addWidget(okBtn_); - lay->addLayout(btnRow); - - QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject); QObject::connect(okBtn_, &QPushButton::clicked, this, &ExportDatasetDialog::onConfirm); loadTemplates(); diff --git a/src/app/FormKit.cpp b/src/app/FormKit.cpp new file mode 100644 index 0000000..d12e93d --- /dev/null +++ b/src/app/FormKit.cpp @@ -0,0 +1,145 @@ +#include "FormKit.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include "Theme.hpp" +#include "panels/KeyValueView.hpp" + +namespace geopro::app::formkit { + +QComboBox* comboBox(const QString& placeholder, QWidget* parent) { + auto* cb = new EmptyAwareComboBox(parent); + // 有占位文案:设占位 + 维持 currentIndex=-1(构造已置 -1),未选时显灰字占位。 + // 无占位文案:留空,addItem 后自动选首项(保持静态下拉的既有默认选中行为)。 + if (!placeholder.isEmpty()) cb->setPlaceholderText(placeholder); + return cb; +} + +DetailForm& DetailForm::group(const QString& name) { + geopro::data::DynamicFormGroup g; + g.name = name.toStdString(); + form_.groups.push_back(std::move(g)); + return *this; +} + +DetailForm& DetailForm::row(const QString& key, const QString& value) { + if (form_.groups.empty()) group(QString()); // 无显式分组时落入匿名组 + geopro::data::DynamicFormField f; + f.name = key.toStdString(); + f.value = value.toStdString(); + form_.groups.back().fields.push_back(std::move(f)); + return *this; +} + +QVBoxLayout* dialogRoot(QDialog* dlg) { + auto* root = new QVBoxLayout(dlg); + root->setContentsMargins(space::kLg, space::kLg, space::kLg, space::kLg); + root->setSpacing(space::kMd); + return root; +} + +QFrame* formCard(QWidget* parent) { + auto* card = new QFrame(parent); + card->setObjectName(QStringLiteral("formCard")); + applyTokenizedStyleSheet( + card, QStringLiteral("#formCard { background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}}; border-radius:%1px; }") + .arg(radius::kMd)); + auto* lay = new QVBoxLayout(card); + lay->setContentsMargins(space::kLg, space::kLg, space::kLg, space::kLg); + lay->setSpacing(space::kMd); + return card; +} + +QVBoxLayout* cardBody(QFrame* card) { + return qobject_cast(card->layout()); +} + +QDialogButtonBox* addDialogButtons(QVBoxLayout* root, QDialog* dlg, const QString& okText, + const QString& cancelText) { + auto* box = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, dlg); + if (!okText.isEmpty()) box->button(QDialogButtonBox::Ok)->setText(okText); + if (!cancelText.isEmpty()) box->button(QDialogButtonBox::Cancel)->setText(cancelText); + QObject::connect(box, &QDialogButtonBox::accepted, dlg, &QDialog::accept); + QObject::connect(box, &QDialogButtonBox::rejected, dlg, &QDialog::reject); + root->addWidget(box); + return box; +} + +void buildDetailDialog(QDialog* dlg, const geopro::data::DynamicForm& form, + const QList& extras) { + dlg->setModal(true); + auto* root = dialogRoot(dlg); + + // 卡片:与属性面板/编辑对话框同款,内嵌唯一只读渲染器 KeyValueView——随内容自适应大小。 + auto* card = formCard(dlg); + auto* cardLay = cardBody(card); + + auto* kv = new KeyValueView(card); + kv->setForm(form); + cardLay->addWidget(kv); + + for (QWidget* w : extras) + if (w) cardLay->addWidget(w); + + root->addWidget(card); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Close, dlg); + QObject::connect(buttons, &QDialogButtonBox::rejected, dlg, &QDialog::reject); + QObject::connect(buttons, &QDialogButtonBox::accepted, dlg, &QDialog::accept); + root->addWidget(buttons); + + const int minW = scaledPx(440); + if (dlg->minimumWidth() < minW) dlg->setMinimumWidth(minW); +} + +QFormLayout* makeEditForm() { + auto* fl = new QFormLayout(); + fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); + fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + fl->setHorizontalSpacing(space::kLg); // 标签↔字段 + fl->setVerticalSpacing(space::kMd); // 行距 + return fl; +} + +QLabel* editLabel(const QString& text, QWidget* parent, bool richText) { + auto* lbl = new QLabel(text, parent); + if (richText) lbl->setTextFormat(Qt::RichText); // 允许必填星号红色 span + lbl->setFixedWidth(scaledPx(space::kFormLabelCol)); // 定宽右标签列,跨表单对齐 + return lbl; +} + +void capField(QWidget* field) { + if (field) field->setMaximumWidth(scaledPx(space::kFormFieldMax)); // §7.0.2「不要拉满」 +} + +void addSection(QBoxLayout* into, const QString& title, QWidget* parent, bool topGap) { + if (topGap) into->addSpacing(space::kLg); + // 唯一的分组标题样式(编辑态与只读态共用):kTitle 半粗 + 次级色 + 标题下 1px divider。 + // 字号走 scaledPx 以随系统字号缩放。 + auto* sec = new QLabel(title, parent); + applyTokenizedStyleSheet( + sec, QStringLiteral("color:{{text/secondary}};font-size:%1px;font-weight:%2;" + "padding-bottom:%3px;") + .arg(scaledPx(type::kTitle)) + .arg(type::kWeightSemibold) + .arg(space::kXs)); + into->addWidget(sec); + auto* rule = new QFrame(parent); + rule->setFrameShape(QFrame::HLine); + rule->setFixedHeight(1); + applyTokenizedStyleSheet(rule, QStringLiteral("background:{{divider}};border:none;")); + into->addWidget(rule); +} + +} // namespace geopro::app::formkit diff --git a/src/app/FormKit.hpp b/src/app/FormKit.hpp new file mode 100644 index 0000000..34d6391 --- /dev/null +++ b/src/app/FormKit.hpp @@ -0,0 +1,72 @@ +#pragma once + +// FormKit —— 表单渲染的「单一真相」层。 +// +// 历史问题:每个表单/对话框各自手搭布局(裸 QFormLayout + 裸 QLabel、各写边距/标签列宽), +// 规范文档(§6.4/§7.0)只是文字约束、无法强制,于是同类控件在不同位置长得不一样。 +// 本模块把「只读键值详情」与「可编辑表单」的视觉度量收敛到唯一实现,所有表单必须经此产出, +// 一致性由「代码复用」强制,而非「人工遵守文档」。 + +#include // editLabel/formCard 返回 QLabel*/QFrame*;调用方常把结果直接传给 +#include // addRow/addWidget(QWidget*),需在调用点见到完整类型(QLabel/QFrame 派生自 QWidget)。 +#include +#include + +#include "repo/RepoTypes.hpp" + +class QBoxLayout; +class QComboBox; +class QDialog; +class QDialogButtonBox; +class QFormLayout; +class QVBoxLayout; +class QWidget; + +namespace geopro::app::formkit { + +// ── 下拉框:全局建下拉的标准入口(返回空态感知下拉 EmptyAwareComboBox)────────────── +// placeholder 非空时设占位文案 + currentIndex=-1(未选显灰字占位,对齐 Arco ASelect); +// placeholder 为空时保持 QComboBox 默认行为(addItem 后自动选首项),不强加占位。 +// 无真实可选项时点开弹窗会显示一条灰色「暂无数据」(实现于 EmptyAwareComboBox)。 +QComboBox* comboBox(const QString& placeholder = QString(), QWidget* parent = nullptr); + +// ── 只读详情:唯一键值模型构建器 ───────────────────────────────────────────── +// 链式 group()/row() 产出 data::DynamicForm,喂给唯一的 §6.4 渲染器 DynamicFormView。 +// 三维体/切片/异常等「数据详情」对话框共用,杜绝裸 QFormLayout 漂移。 +class DetailForm { +public: + DetailForm& group(const QString& name); // 开新分组 + DetailForm& row(const QString& key, const QString& value); // 向当前组追加键值行 + const geopro::data::DynamicForm& build() const { return form_; } + +private: + geopro::data::DynamicForm form_; +}; + +// 给只读详情对话框铺设标准骨架:DynamicFormView(§6.4 卡片)+ 底部 Close + 统一边距。 +// extras 追加在卡片下方、按钮上方(异常详情的顶点坐标/备注走此口),与主键值表共享节奏。 +void buildDetailDialog(QDialog* dlg, const geopro::data::DynamicForm& form, + const QList& extras = {}); + +// ── 对话框外壳:统一所有对话框的边距/卡片/按钮栏(编辑态与只读态共用 → 外观一致)────── +// 标准根布局:统一外边距 space/lg + 行距 space/md。 +QVBoxLayout* dialogRoot(QDialog* dlg); +// 标准表单卡片:bg/panel-subtle + 1px 边框 + 中圆角 + 统一内距。把表单/分组/键值包成 +// 与「数据详情 / 属性面板」同款的卡片面。返回 QFrame;其 layout() 即 QVBoxLayout,向内 addSection/addLayout。 +QFrame* formCard(QWidget* parent); +QVBoxLayout* cardBody(QFrame* card); // 取 formCard 的内层 QVBoxLayout(便捷器) +// 标准底部按钮栏:QDialogButtonBox(Ok|Cancel),已接 accept/reject。 +// 默认中文「确定/取消」(不依赖 Qt 翻译是否就位);调用方可覆盖(如「生成/取消」)。 +QDialogButtonBox* addDialogButtons(QVBoxLayout* root, QDialog* dlg, + const QString& okText = QStringLiteral("确定"), + const QString& cancelText = QStringLiteral("取消")); + +// ── 可编辑表单:§7.0 统一度量(DynamicFormEditor 与各参数对话框共用,单一真相)────── +QFormLayout* makeEditForm(); // 右对齐标签 + 标准行距/列距 +QLabel* editLabel(const QString& text, QWidget* parent = nullptr, + bool richText = false); // 定宽右标签列(kFormLabelCol) +void capField(QWidget* field); // 字段最大宽上限(kFormFieldMax) +// 分组标题(heading 字号 + 半粗 + 次级色)+ 标题下 1px divider;topGap=true 时上方留 space/lg。 +void addSection(QBoxLayout* into, const QString& title, QWidget* parent, bool topGap); + +} // namespace geopro::app::formkit diff --git a/src/app/Glyphs.cpp b/src/app/Glyphs.cpp index 53b619b..b9ab474 100644 --- a/src/app/Glyphs.cpp +++ b/src/app/Glyphs.cpp @@ -65,6 +65,22 @@ QString svgPathFor(Glyph t) ""); case Glyph::Collapse: return QStringLiteral(""); + case Glyph::Fullscreen: + return QStringLiteral( + ""); + case Glyph::ChevronLeft: + return QStringLiteral(""); + case Glyph::ChevronRight: + return QStringLiteral(""); + case Glyph::WorkArea: // 工区:地图定位标记(Lucide map-pin) + return QStringLiteral( + ""); + case Glyph::SurveyLine: // 测线:折线(Lucide spline / line-chart 简化) + return QStringLiteral( + "" + ""); case Glyph::Workspace: return QStringLiteral( "" @@ -93,6 +109,12 @@ QString svgPathFor(Glyph t) "2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 " ".73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 " "1-1-1.73V4a2 2 0 0 0-2-2z'/>"); + case Glyph::Minus: + return QStringLiteral(""); + case Glyph::Fit: + return QStringLiteral( + ""); } return QString(); } diff --git a/src/app/Glyphs.hpp b/src/app/Glyphs.hpp index 6ded8fd..6d0c262 100644 --- a/src/app/Glyphs.hpp +++ b/src/app/Glyphs.hpp @@ -25,13 +25,21 @@ enum class Glyph { Filter, // 筛选(漏斗) Upload, // 上传 Download, // 导出/下载 - Collapse, // 折叠(双箭头) + Collapse, // 折叠(双箭头) + Fullscreen, // 全屏 / 最大化 + ChevronLeft, // 折叠抽屉(向左) + ChevronRight, // 展开抽屉(向右) + // 对象树类型图标(§6.1:GS 工区 / TM 测线) + WorkArea, // GS 检测对象(工区,地图定位标记) + SurveyLine, // TM 方法对象(测线,折线) // 顶部应用栏图标 Workspace, // 工作空间(2x2 宫格) Folder, // 项目(文件夹) Help, // 帮助(?) Bell, // 通知(铃铛) Gear, // 设置(齿轮) + Minus, // 缩小(减号) + Fit, // 适配/复位(四角框) }; // 「图标+文字」按钮的图标→文字间距补丁:Fusion 内置约 4px,本值补到规范 §6.7 的 6px。 diff --git a/src/app/GradientEditWidget.cpp b/src/app/GradientEditWidget.cpp new file mode 100644 index 0000000..d9656c4 --- /dev/null +++ b/src/app/GradientEditWidget.cpp @@ -0,0 +1,333 @@ +#include "GradientEditWidget.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace geopro::app { + +namespace { +// ── 复刻 colorLevelConfigurator.js 常量 ────────────────────────────────────── +constexpr double kRectSize = 9.0; // RECT_SIZE 手柄方块边长 +const QColor kLineColor(0x1E, 0x1E, 0x1E); // LINE_COLOR 滑轨黑线 +constexpr double kLineWidth = 2.0; // LINE_WIDTH +const QColor kHighlightColor(0xF4, 0xF0, 0x65); // HIGHLIGHT_COLOR 选中描边 +constexpr double kHighlightStroke = 2.0; // HIGHLIGHT_STROKE_WIDTH +constexpr double kMarginTop = 40.0; // MARGIN.top +constexpr double kHistogramHeight = 100.0; // HISTOGRAM_HEIGHT +const QColor kHistogramColor(0xC0, 0xC5, 0xCF); // HISTOGRAM_COLOR 灰柱 +constexpr double kHistMarginTop = 5.0; // HISTOGRAM_MARGIN.top +constexpr double kHistMarginBottom = 5.0; // HISTOGRAM_MARGIN.bottom +constexpr double kColorBarHeight = 20.0; // COLORBAR_HEIGHT +constexpr double kElementsGap = 10.0; // ELEMENTS_GAP +constexpr int kHistogramBins = 80; // 直方图桶数 + +QColor toQ(const geopro::core::Rgba& c) { return QColor(c.r, c.g, c.b, c.a); } + +QString toHex(const geopro::core::Rgba& c) { + return QStringLiteral("#%1%2%3") + .arg(c.r, 2, 16, QLatin1Char('0')) + .arg(c.g, 2, 16, QLatin1Char('0')) + .arg(c.b, 2, 16, QLatin1Char('0')) + .toUpper(); +} +} // namespace + +GradientEditWidget::GradientEditWidget(QWidget* parent) : QWidget(parent) { + setFocusPolicy(Qt::StrongFocus); + // 默认蓝→红两端,避免空控件。 + nodes_ = {{0.0, geopro::core::Rgba{0, 0, 255, 255}, nextId_++}, + {1.0, geopro::core::Rgba{255, 0, 0, 255}, nextId_++}}; +} + +// ── 数据接口 ───────────────────────────────────────────────────────────────── +void GradientEditWidget::setStops(const std::vector& stops) { + nodes_.clear(); + for (const auto& s : stops) + nodes_.push_back({std::clamp(s.pos, 0.0, 1.0), s.color, nextId_++}); + if (nodes_.size() < 2) { + nodes_ = {{0.0, geopro::core::Rgba{0, 0, 255, 255}, nextId_++}, + {1.0, geopro::core::Rgba{255, 0, 0, 255}, nextId_++}}; + } + sortNodes(); + selectedId_ = -1; + update(); +} + +std::vector GradientEditWidget::stops() const { + std::vector sorted = nodes_; + std::sort(sorted.begin(), sorted.end(), + [](const Node& a, const Node& b) { return a.pos < b.pos; }); + std::vector out; + out.reserve(sorted.size()); + for (const auto& n : sorted) out.push_back({n.pos, n.color}); + return out; +} + +void GradientEditWidget::reverse() { + // 复刻 reverseColors:保持各断点 pos 不变,仅反转颜色序列。 + sortNodes(); + std::vector cols; + cols.reserve(nodes_.size()); + for (const auto& n : nodes_) cols.push_back(n.color); + std::reverse(cols.begin(), cols.end()); + for (std::size_t i = 0; i < nodes_.size(); ++i) nodes_[i].color = cols[i]; + emitChangedRepaint(); +} + +void GradientEditWidget::setMinMax(double minValue, double maxValue) { + minValue_ = minValue; + maxValue_ = maxValue; + update(); // 直方图域随之变化 + // 选中态读出值也随域刷新。 + if (selectedId_ >= 0) { + for (const auto& n : nodes_) + if (n.id == selectedId_) { emitHandleInfo(n); break; } + } +} + +void GradientEditWidget::setSamples(std::vector samples) { + samples_ = std::move(samples); + update(); +} + +void GradientEditWidget::setSelectedColor(const geopro::core::Rgba& color) { + if (selectedId_ < 0) return; + for (auto& n : nodes_) + if (n.id == selectedId_) { n.color = color; break; } + emitChangedRepaint(); +} + +// ── 几何布局(与 JS 坐标一致) ─────────────────────────────────────────────── +double GradientEditWidget::histTop() const { return kMarginTop; } +double GradientEditWidget::colorBarTop() const { + return kMarginTop + kHistogramHeight + kElementsGap; +} +double GradientEditWidget::sliderLineY() const { + return kMarginTop + kHistogramHeight + kElementsGap + kColorBarHeight + kElementsGap; +} +double GradientEditWidget::trackLeft() const { return kRectSize; } +double GradientEditWidget::trackWidth() const { + return std::max(1.0, width() - kRectSize * 2); +} +double GradientEditWidget::posToX(double pos) const { return trackLeft() + pos * trackWidth(); } +double GradientEditWidget::xToPos(double x) const { + return std::clamp((x - trackLeft()) / trackWidth(), 0.0, 1.0); +} + +void GradientEditWidget::sortNodes() { + std::sort(nodes_.begin(), nodes_.end(), + [](const Node& a, const Node& b) { return a.pos < b.pos; }); +} + +void GradientEditWidget::emitChangedRepaint() { + update(); + emit changed(); +} + +void GradientEditWidget::emitHandleInfo(const Node& n) { + const double value = minValue_ + (maxValue_ - minValue_) * n.pos; + emit handleSelected(toHex(n.color), QString::number(value, 'f', 2), + QStringLiteral("%1%").arg(QString::number(n.pos * 100.0, 'f', 1))); +} + +// 色带连续插值取色(复刻 d3.scaleLinear domain=pos range=color)。 +geopro::core::Rgba GradientEditWidget::sample(double pos) const { + std::vector s = nodes_; + std::sort(s.begin(), s.end(), [](const Node& a, const Node& b) { return a.pos < b.pos; }); + if (s.empty()) return geopro::core::Rgba{0, 0, 0, 255}; + if (pos <= s.front().pos) return s.front().color; + if (pos >= s.back().pos) return s.back().color; + std::size_t i = 0; + while (i + 1 < s.size() && pos > s[i + 1].pos) ++i; + const double x0 = s[i].pos, x1 = s[i + 1].pos; + const double t = (x1 > x0) ? (pos - x0) / (x1 - x0) : 0.0; + auto mix = [t](unsigned char a, unsigned char b) { + return static_cast(a + (b - a) * t + 0.5); + }; + const auto& c0 = s[i].color; + const auto& c1 = s[i + 1].color; + return geopro::core::Rgba{mix(c0.r, c1.r), mix(c0.g, c1.g), mix(c0.b, c1.b), mix(c0.a, c1.a)}; +} + +bool GradientEditWidget::isEndpoint(int id) const { + std::vector s = nodes_; + std::sort(s.begin(), s.end(), [](const Node& a, const Node& b) { return a.pos < b.pos; }); + if (s.empty()) return true; + return id == s.front().id || id == s.back().id; +} + +bool GradientEditWidget::isOnSliderLine(const QPointF& p) const { + return std::abs(p.y() - sliderLineY()) < 10.0; // 复刻 isOnSliderLine 容差 10 +} + +int GradientEditWidget::hitHandle(const QPointF& p) const { + // 手柄为落在滑轨线上的 9px 方块;命中判定取方块包围盒。 + const double top = sliderLineY() - kRectSize / 2.0; + int best = -1; + double bestDx = kRectSize; // 优先取最近 + for (const auto& n : nodes_) { + const double left = posToX(n.pos); + if (p.x() >= left - 1 && p.x() <= left + kRectSize + 1 && p.y() >= top - 1 && + p.y() <= top + kRectSize + 1) { + const double dx = std::abs(p.x() - (left + kRectSize / 2.0)); + if (dx <= bestDx) { + bestDx = dx; + best = n.id; + } + } + } + return best; +} + +// ── 绘制 ───────────────────────────────────────────────────────────────────── +void GradientEditWidget::paintEvent(QPaintEvent*) { + QPainter g(this); + g.fillRect(rect(), Qt::white); // backgroundColor #ffffff + + const double tLeft = trackLeft(); + const double tWidth = trackWidth(); + + // 1) 直方图:80 等宽桶统计 [min,max] 内样本频数,灰柱按最大频数线性缩放。 + if (!samples_.empty() && maxValue_ > minValue_) { + std::vector bins(kHistogramBins, 0); + const double span = maxValue_ - minValue_; + for (double v : samples_) { + if (v < minValue_ || v > maxValue_) continue; // 仅统计域内样本 + int idx = static_cast((v - minValue_) / span * kHistogramBins); + if (idx >= kHistogramBins) idx = kHistogramBins - 1; // 右端归入末桶 + if (idx < 0) idx = 0; + ++bins[idx]; + } + int maxLen = 0; + for (int c : bins) maxLen = std::max(maxLen, c); + if (maxLen > 0) { + const double binW = tWidth / kHistogramBins; + const double drawH = kHistogramHeight - kHistMarginTop - kHistMarginBottom; + const double baseY = histTop() + kHistogramHeight - kHistMarginBottom; + g.setPen(Qt::NoPen); + g.setBrush(kHistogramColor); + for (int i = 0; i < kHistogramBins; ++i) { + if (bins[i] <= 0) continue; + const double h = drawH * bins[i] / maxLen; + const double x = tLeft + i * binW; + g.drawRect(QRectF(x, baseY - h, std::max(0.0, binW - 1.0), h)); + } + } + } + + // 2) 色带:按断点 pos 画横向线性渐变。 + const QRectF bar(tLeft, colorBarTop(), tWidth, kColorBarHeight); + QLinearGradient grad(bar.left(), 0, bar.right(), 0); + for (const auto& n : nodes_) grad.setColorAt(std::clamp(n.pos, 0.0, 1.0), toQ(n.color)); + g.setPen(Qt::NoPen); + g.fillRect(bar, grad); + + // 3) 滑轨黑线。 + const double ly = sliderLineY(); + g.setPen(QPen(kLineColor, kLineWidth)); + g.drawLine(QPointF(tLeft, ly), QPointF(tLeft + tWidth, ly)); + + // 4) 手柄:9px 方块落在滑轨线上,填充=断点色;选中描边高亮。 + const double top = ly - kRectSize / 2.0; + for (const auto& n : nodes_) { + const QRectF h(posToX(n.pos), top, kRectSize, kRectSize); + g.setBrush(toQ(n.color)); + if (n.id == selectedId_) + g.setPen(QPen(kHighlightColor, kHighlightStroke)); + else + g.setPen(Qt::NoPen); + g.drawRect(h); + } +} + +// ── 交互 ───────────────────────────────────────────────────────────────────── +void GradientEditWidget::mousePressEvent(QMouseEvent* e) { + const QPointF p = e->position(); + const int hit = hitHandle(p); + + if (e->button() == Qt::RightButton) { + // 右键删除中间手柄(首尾不可删)。 + if (hit >= 0 && !isEndpoint(hit) && nodes_.size() > 2) { + nodes_.erase(std::remove_if(nodes_.begin(), nodes_.end(), + [hit](const Node& n) { return n.id == hit; }), + nodes_.end()); + selectedId_ = -1; + emit selectionCleared(); + emitChangedRepaint(); + } + return; + } + + if (hit >= 0) { + if (isEndpoint(hit)) { // 首尾锁定:不可选、不可拖 + return; + } + selectedId_ = hit; + dragging_ = true; + for (const auto& n : nodes_) + if (n.id == hit) { emitHandleInfo(n); break; } + update(); + return; + } + + // 点击空白:清除选中(复刻 handleMouseDown else 分支)。 + if (selectedId_ >= 0) { + selectedId_ = -1; + emit selectionCleared(); + update(); + } +} + +void GradientEditWidget::mouseDoubleClickEvent(QMouseEvent* e) { + // 双击滑轨线空白处 → 在该位置加断点,色=该处色带采样。 + const QPointF p = e->position(); + if (!isOnSliderLine(p)) return; + if (hitHandle(p) >= 0) return; // 命中已有手柄不加点 + const double pos = xToPos(p.x()); + nodes_.push_back({pos, sample(pos), nextId_++}); + sortNodes(); + emitChangedRepaint(); +} + +void GradientEditWidget::mouseMoveEvent(QMouseEvent* e) { + if (!dragging_ || selectedId_ < 0) return; + const double pos = xToPos(e->position().x()); + for (auto& n : nodes_) + if (n.id == selectedId_) { + n.pos = pos; + emitHandleInfo(n); // 拖动实时发更新 + break; + } + update(); + emit changed(); +} + +void GradientEditWidget::mouseReleaseEvent(QMouseEvent*) { + if (dragging_) { + dragging_ = false; + sortNodes(); + update(); + } +} + +void GradientEditWidget::keyPressEvent(QKeyEvent* e) { + if ((e->key() == Qt::Key_Delete || e->key() == Qt::Key_Backspace) && selectedId_ >= 0 && + !isEndpoint(selectedId_) && nodes_.size() > 2) { + const int sel = selectedId_; + nodes_.erase(std::remove_if(nodes_.begin(), nodes_.end(), + [sel](const Node& n) { return n.id == sel; }), + nodes_.end()); + selectedId_ = -1; + emit selectionCleared(); + emitChangedRepaint(); + return; + } + QWidget::keyPressEvent(e); +} + +} // namespace geopro::app diff --git a/src/app/GradientEditWidget.hpp b/src/app/GradientEditWidget.hpp new file mode 100644 index 0000000..4f0f91d --- /dev/null +++ b/src/app/GradientEditWidget.hpp @@ -0,0 +1,87 @@ +#pragma once +#include + +#include +#include + +#include "model/ColorScale.hpp" // core::Rgba + +namespace geopro::app { + +// 连续渐变编辑画布(1:1 复刻 colorLevelConfigurator.js):从上到下依次为 +// 顶部留白(40) → 直方图(高100,灰柱) → 间距10 → 色带(高20) → 间距10 → +// 滑轨黑线 → 手柄(9px方块,落在滑轨上)。 +// 交互:双击滑轨空白加断点(色=该处采样);拖动中间手柄改位置;选中后 Delete/右键删除; +// 首尾手柄锁定(不可拖/删/选)。断点 pos∈[0,1],导出按 pos 升序。 +class GradientEditWidget : public QWidget { + Q_OBJECT +public: + struct Stop { + double pos; + geopro::core::Rgba color; + }; + + explicit GradientEditWidget(QWidget* parent = nullptr); + + void setStops(const std::vector& stops); // pos 升序,至少 2 个 + std::vector stops() const; // 升序导出 + void reverse(); // 反向:保持各 pos 不变,仅反转颜色序列 + + void setMinMax(double minValue, double maxValue); // 直方图域 + 读出域 + void setSamples(std::vector samples); // 直方图样本 + + // 改当前选中手柄颜色(复刻 updateColorById)。无选中则无操作。 + void setSelectedColor(const geopro::core::Rgba& color); + bool hasSelection() const { return selectedId_ >= 0; } + + QSize sizeHint() const override { return QSize(560, 220); } + +signals: + void changed(); + // 选中/拖动时携带当前手柄信息(复刻 onHandleClick / onHandleMove + getHandleInfo)。 + // colorHex 形如 "#RRGGBB";valueText=值.toFixed(2);percentText="xx.x%"。 + void handleSelected(QString colorHex, QString valueText, QString percentText); + void selectionCleared(); // 点击空白清除选中 + +protected: + void paintEvent(QPaintEvent*) override; + void mousePressEvent(QMouseEvent*) override; + void mouseMoveEvent(QMouseEvent*) override; + void mouseDoubleClickEvent(QMouseEvent*) override; + void mouseReleaseEvent(QMouseEvent*) override; + void keyPressEvent(QKeyEvent*) override; + +private: + struct Node { + double pos; + geopro::core::Rgba color; + int id; + }; + + int hitHandle(const QPointF& p) const; // 命中手柄 id,否则 -1 + bool isEndpoint(int id) const; // 首尾(按 pos 升序) + bool isOnSliderLine(const QPointF& p) const; // 落在滑轨线附近 + double posToX(double pos) const; // 手柄左边缘 x + double xToPos(double x) const; + geopro::core::Rgba sample(double pos) const; // 色带连续插值取色 + void sortNodes(); + void emitChangedRepaint(); + void emitHandleInfo(const Node& n); // 发选中/拖动信息信号 + + double histTop() const; // 直方图顶 y + double colorBarTop() const; + double sliderLineY() const; + double trackLeft() const; // 轨道左 x = RECT_SIZE + double trackWidth() const; // 轨道宽 = width - RECT_SIZE*2 + + std::vector nodes_; + int nextId_ = 0; + int selectedId_ = -1; + bool dragging_ = false; + + double minValue_ = 0.0; + double maxValue_ = 100.0; + std::vector samples_; +}; + +} // namespace geopro::app diff --git a/src/app/ImportDatasetDialog.cpp b/src/app/ImportDatasetDialog.cpp index 402f624..cd1e320 100644 --- a/src/app/ImportDatasetDialog.cpp +++ b/src/app/ImportDatasetDialog.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -15,9 +16,9 @@ #include #include #include -#include #include +#include "FormKit.hpp" #include "Theme.hpp" #include "api/NavLoads.hpp" #include "api/NavRequest.hpp" @@ -39,29 +40,28 @@ ImportDatasetDialog::ImportDatasetDialog(geopro::data::IAsyncProjectRepository& setWindowTitle(QStringLiteral("导入数据集")); setMinimumSize(geopro::app::scaledPx(480), geopro::app::scaledPx(520)); - auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(0); + auto* root = formkit::dialogRoot(this); status_ = new QLabel(QStringLiteral("加载数据类型…"), this); status_->setAlignment(Qt::AlignCenter); geopro::app::applyTokenizedStyleSheet(status_, QStringLiteral("color:{{text/disabled}};padding:12px;")); - lay->addWidget(status_); + root->addWidget(status_); - auto* form = new QWidget(this); - auto* fl = new QFormLayout(form); - fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, - geopro::app::space::kLg, 0); - fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); - fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); - typeCombo_ = new QComboBox(form); - fl->addRow(QStringLiteral("数据类型"), typeCombo_); - scriptCombo_ = new QComboBox(form); - fl->addRow(QStringLiteral("导入脚本"), scriptCombo_); + auto* fl = formkit::makeEditForm(); - auto* fileRow = new QWidget(form); + // 空态感知下拉:数据类型/导入脚本均数据驱动(异步加载),未选显占位、无数据弹「暂无数据」。 + typeCombo_ = formkit::comboBox(QStringLiteral("请选择数据类型"), card); + formkit::capField(typeCombo_); + fl->addRow(formkit::editLabel(QStringLiteral("数据类型")), typeCombo_); + scriptCombo_ = formkit::comboBox(QStringLiteral("请选择导入脚本"), card); + formkit::capField(scriptCombo_); + fl->addRow(formkit::editLabel(QStringLiteral("导入脚本")), scriptCombo_); + + auto* fileRow = new QWidget(card); auto* fileLay = new QHBoxLayout(fileRow); fileLay->setContentsMargins(0, 0, 0, 0); fileEdit_ = new QLineEdit(fileRow); @@ -70,36 +70,28 @@ ImportDatasetDialog::ImportDatasetDialog(geopro::data::IAsyncProjectRepository& auto* browse = new QPushButton(QStringLiteral("浏览…"), fileRow); fileLay->addWidget(fileEdit_, 1); fileLay->addWidget(browse); - fl->addRow(QStringLiteral("文件"), fileRow); - lay->addWidget(form); + fl->addRow(formkit::editLabel(QStringLiteral("文件")), fileRow); + cardLay->addLayout(fl); - auto* paramLabel = new QLabel(QStringLiteral("脚本参数"), this); - geopro::app::applyTokenizedStyleSheet( - paramLabel, QStringLiteral("color:{{text/secondary}};padding:%1px %2px 0;") - .arg(geopro::app::space::kSm) - .arg(geopro::app::space::kLg)); - lay->addWidget(paramLabel); + formkit::addSection(cardLay, QStringLiteral("脚本参数"), card, true); - auto* scroll = new QScrollArea(this); + auto* scroll = new QScrollArea(card); scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); paramEditor_ = new DynamicFormEditor(); scroll->setWidget(paramEditor_); - lay->addWidget(scroll, 1); + cardLay->addWidget(scroll, 1); - auto* btnRow = new QHBoxLayout(); - btnRow->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm, - geopro::app::space::kLg, geopro::app::space::kMd); - btnRow->addStretch(); - auto* cancel = new QPushButton(QStringLiteral("取消"), this); - okBtn_ = new QPushButton(QStringLiteral("导入"), this); + root->addWidget(card, 1); + + auto* buttons = + formkit::addDialogButtons(root, this, QStringLiteral("导入"), QStringLiteral("取消")); + // Ok 不直接 accept:需先 onConfirm 校验/异步导入,成功后才 accept。断开默认 accepted→accept。 + QObject::disconnect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + okBtn_ = buttons->button(QDialogButtonBox::Ok); okBtn_->setDefault(true); okBtn_->setEnabled(false); - btnRow->addWidget(cancel); - btnRow->addWidget(okBtn_); - lay->addLayout(btnRow); - QObject::connect(cancel, &QPushButton::clicked, this, &QDialog::reject); QObject::connect(browse, &QPushButton::clicked, this, &ImportDatasetDialog::chooseFile); QObject::connect(okBtn_, &QPushButton::clicked, this, &ImportDatasetDialog::onConfirm); QObject::connect(typeCombo_, qOverload(&QComboBox::currentIndexChanged), this, diff --git a/src/app/ObjectFormDialog.cpp b/src/app/ObjectFormDialog.cpp index 20018a1..d4a481c 100644 --- a/src/app/ObjectFormDialog.cpp +++ b/src/app/ObjectFormDialog.cpp @@ -3,6 +3,8 @@ #include #include + +#include "EmptyAwareComboBox.hpp" #include #include #include @@ -13,6 +15,7 @@ #include #include +#include "FormKit.hpp" #include "Theme.hpp" #include "api/NavLoads.hpp" // Q_DECLARE_METATYPE(EditableForm) / GsTypeOption #include "api/NavRequest.hpp" @@ -121,13 +124,17 @@ void ObjectFormDialog::buildTopFields() { delete old; } - auto* fl = new QFormLayout(topBox_); - fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, + auto* fl = formkit::makeEditForm(); + // body 内距:对话框用 space/lg 环绕顶部固定字段区(与统一外壳一致)。 + fl->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, geopro::app::space::kLg, 0); - fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); - fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); + + // 等宽右标签列(formkit::editLabel)+ 字段最大宽上限(formkit::capField),与动态表单对齐。 + auto addRow = [&](const QString& text, QWidget* field) { + formkit::capField(field); + fl->addRow(formkit::editLabel(text, topBox_), field); + }; + topBox_->setLayout(fl); const bool isCreate = objectId_.isEmpty(); @@ -135,8 +142,8 @@ void ObjectFormDialog::buildTopFields() { // 新建 GS/TM:类型下拉(数据源 gsList / tmList,选择后重载动态表单)。 const QString label = confType_ == kConfTm ? QStringLiteral("方法类型") : QStringLiteral("对象类型"); - typeCombo_ = new QComboBox(topBox_); - fl->addRow(label, typeCombo_); + typeCombo_ = new EmptyAwareComboBox(topBox_); + addRow(label, typeCombo_); QObject::connect(typeCombo_, qOverload(&QComboBox::currentIndexChanged), this, [this](int) { const QString tid = typeCombo_->currentData().toString(); @@ -150,18 +157,18 @@ void ObjectFormDialog::buildTopFields() { typeNameLabel_ = new QLabel(topBox_); geopro::app::applyTokenizedStyleSheet(typeNameLabel_, QStringLiteral("color:{{text/secondary}};")); - fl->addRow(QStringLiteral("类型"), typeNameLabel_); + addRow(QStringLiteral("类型"), typeNameLabel_); } nameEdit_ = new QLineEdit(topBox_); nameEdit_->setPlaceholderText(QStringLiteral("名称")); if (!isCreate) nameEdit_->setEnabled(false); // 编辑态名称禁用 - fl->addRow(QStringLiteral("名称"), nameEdit_); + addRow(QStringLiteral("名称"), nameEdit_); if (confType_ == kConfGs) { responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); - fl->addRow(QStringLiteral("负责人"), responsibleEdit_); + addRow(QStringLiteral("负责人"), responsibleEdit_); } } @@ -271,11 +278,13 @@ QJsonObject ObjectFormDialog::buildBody() const { void ObjectFormDialog::onConfirm() { if (nameEdit_ && nameEdit_->text().trimmed().isEmpty()) { + if (nameEdit_->isEnabled()) nameEdit_->setFocus(Qt::OtherFocusReason); // 聚焦名称 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写名称")); return; } QString missing; if (!editor_->validateRequired(&missing)) { + editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写必填项:%1").arg(missing)); return; diff --git a/src/app/PanelHeader.cpp b/src/app/PanelHeader.cpp index 3566ef0..598d1fe 100644 --- a/src/app/PanelHeader.cpp +++ b/src/app/PanelHeader.cpp @@ -16,11 +16,12 @@ namespace geopro::app { namespace { -// ── 专业图标/字号尺寸(统一放大)── -constexpr int kHeaderHeight = 42; -constexpr int kTitleIcon = 20; // 表头标题图标 -constexpr int kActionIcon = 19; // 表头操作按钮图标 -constexpr int kTabIcon = 19; // Tab 图标 +// ── 表头图标/尺寸(规范 §4.3:高 36px、标题图标 14px、操作按钮 24×24 含 16px 图标)── +constexpr int kHeaderHeight = 36; // 表头高度(§4.3)。标准/Tab/分段表头共用,保持一致。 +constexpr int kTitleIcon = 14; // 表头标题图标(§4.3「14px 图标」) +constexpr int kActionIcon = 16; // 操作按钮内图标(§9「按钮内 16px」) +constexpr int kActionButton = 24; // 操作按钮命中区 24×24(§4.3 / §12 无障碍 ≥24×24) +constexpr int kTabIcon = 19; // Tab 图标 // 表头统一样式(标准表头 + Tab 表头共用)。字号/字重引用 Theme 排版令牌: // 面板标题=title(15)、徽标=caption(12)、Tab 文本=body(13),加粗统一 semibold。 @@ -37,7 +38,7 @@ QString headerQss() " padding:1px 7px; font-size:%2px; font-weight:%3; }" "#panelBadgeWarn { background:{{status/warning-bg}}; color:{{status/warning}}; border-radius:9px;" " padding:1px 7px; font-size:%2px; font-weight:%3; }" - "QToolButton#panelAction { border:none; border-radius:7px; padding:5px; }" + "QToolButton#panelAction { border:none; border-radius:%5px; padding:0px; }" "QToolButton#panelAction:hover { background:{{bg/hover}}; }" "QToolButton#tabBtn { border:none; border-bottom:2px solid transparent; color:{{text/secondary}};" " padding:8px 6px; font-size:%4px; }" @@ -47,7 +48,8 @@ QString headerQss() .arg(scaledPx(type::kTitle)) // %1 标题字号 .arg(scaledPx(type::kCaption)) // %2 徽标字号 .arg(type::kWeightSemibold) // %3 字重(多处) - .arg(scaledPx(type::kBody)); // %4 页签字号 + .arg(scaledPx(type::kBody)) // %4 页签字号 + .arg(radius::kSm); // %5 操作按钮悬停底圆角(§3.2) } // 数量徽标(默认隐藏,调用方 setText+setVisible 显示)。 @@ -68,7 +70,9 @@ QWidget* makeActionButton(QWidget* parent, const HeaderAction& a) btn->setObjectName(QStringLiteral("panelAction")); btn->setProperty("glyphId", static_cast(a.first)); // 供调用方按图标定位并连接真实功能 setThemedGlyph(btn, a.first, kActionIcon); - btn->setIconSize(QSize(kActionIcon, kActionIcon)); + btn->setIconSize(QSize(scaledPx(kActionIcon), scaledPx(kActionIcon))); + // 命中区固定 24×24(§4.3 / §12 无障碍):16px 图标居中、四周自然留出内距。 + btn->setFixedSize(QSize(scaledPx(kActionButton), scaledPx(kActionButton))); btn->setCursor(Qt::PointingHandCursor); btn->setToolTip(a.second + QStringLiteral("(占位)")); btn->setAutoRaise(true); @@ -81,7 +85,7 @@ QWidget* buildPanelHeader(Glyph icon, const QString& title, const QVector
setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* lay = new QHBoxLayout(header); @@ -114,7 +118,7 @@ TabbedPanel buildTabbedPanel(const QVector& tabs, const QVector
setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* hlay = new QHBoxLayout(header); hlay->setContentsMargins(10, 0, 8, 0); @@ -169,7 +173,7 @@ SegmentedHeader buildSegmentedHeader(const QVector& segments, { auto* header = new QWidget(); header->setObjectName(QStringLiteral("panelHeader")); - header->setFixedHeight(kHeaderHeight); + header->setFixedHeight(scaledPx(kHeaderHeight)); geopro::app::applyTokenizedStyleSheet(header, headerQss()); auto* hlay = new QHBoxLayout(header); diff --git a/src/app/ProjectListDialog.cpp b/src/app/ProjectListDialog.cpp index 0324196..21b0b1e 100644 --- a/src/app/ProjectListDialog.cpp +++ b/src/app/ProjectListDialog.cpp @@ -3,6 +3,8 @@ #include #include #include + +#include "EmptyAwareComboBox.hpp" #include #include #include @@ -54,7 +56,7 @@ ProjectListDialog::ProjectListDialog(data::IAsyncProjectRepository& repo, QWidge filter->addWidget(nameEdit_); filter->addSpacing(8); filter->addWidget(new QLabel(QStringLiteral("项目类型"), this)); - typeCombo_ = new QComboBox(this); + typeCombo_ = new EmptyAwareComboBox(this); typeCombo_->setFixedWidth(160); filter->addWidget(typeCombo_); filter->addSpacing(8); diff --git a/src/app/SettingsDialog.cpp b/src/app/SettingsDialog.cpp index d10f106..7aed352 100644 --- a/src/app/SettingsDialog.cpp +++ b/src/app/SettingsDialog.cpp @@ -1,53 +1,71 @@ #include "SettingsDialog.hpp" #include + +#include "EmptyAwareComboBox.hpp" #include #include #include #include #include #include +#include #include #include #include #include +#include "FormKit.hpp" +#include "Glyphs.hpp" #include "Theme.hpp" namespace geopro::app { namespace { -// 「标签 + 控件」一行(标签定宽左对齐,控件右随)。 -QWidget* makeRow(const QString& label, QWidget* control) { +// 设置项行(§7.10):左 = 标题(text/body) + 可选说明(text/caption · text/tertiary) 竖叠; +// 右 = 控件(开关/下拉/输入)。caption 为空则只显标题。 +QWidget* makeRow(const QString& label, QWidget* control, const QString& caption = QString()) { auto* row = new QWidget(); auto* lay = new QHBoxLayout(row); lay->setContentsMargins(0, 0, 0, 0); - lay->setSpacing(12); - auto* lbl = new QLabel(label, row); - lbl->setMinimumWidth(96); - lay->addWidget(lbl); - lay->addWidget(control, 1); - return row; -} + lay->setSpacing(geopro::app::space::kLg); -// 区段标题。 -QLabel* sectionTitle(const QString& text, QWidget* parent) { - auto* t = new QLabel(text, parent); - t->setStyleSheet(QStringLiteral("font-size:%1px; font-weight:600;") - .arg(geopro::app::scaledPx(geopro::app::type::kHeading))); - return t; + // 左侧标题列:标题在上,说明(可选)在下。 + auto* labelCol = new QWidget(row); + auto* lv = new QVBoxLayout(labelCol); + lv->setContentsMargins(0, 0, 0, 0); + lv->setSpacing(geopro::app::space::kXxs); + auto* lbl = new QLabel(label, labelCol); + geopro::app::applyTokenizedStyleSheet( + lbl, QStringLiteral("color:{{text/primary}}; font-size:%1px;") + .arg(geopro::app::scaledPx(geopro::app::type::kBody))); + lv->addWidget(lbl); + if (!caption.isEmpty()) { + auto* cap = new QLabel(caption, labelCol); + cap->setWordWrap(true); + geopro::app::applyTokenizedStyleSheet( + cap, QStringLiteral("color:{{text/tertiary}}; font-size:%1px;") + .arg(geopro::app::scaledPx(geopro::app::type::kCaption))); + lv->addWidget(cap); + } + labelCol->setMinimumWidth(geopro::app::scaledPx(160)); + + lay->addWidget(labelCol); + lay->addWidget(control, 1, Qt::AlignTop); + return row; } QWidget* buildAppearancePage() { auto* page = new QWidget(); auto* v = new QVBoxLayout(page); - v->setContentsMargins(24, 20, 24, 20); - v->setSpacing(16); - v->addWidget(sectionTitle(QStringLiteral("外观"), page)); + v->setContentsMargins(geopro::app::space::kXxl, geopro::app::scaledPx(20), + geopro::app::space::kXxl, geopro::app::scaledPx(20)); + v->setSpacing(geopro::app::space::kXl); + geopro::app::formkit::addSection(v, QStringLiteral("外观"), page, false); // 主题:跟随系统 / 浅色 / 深色(热切)。 - auto* themeCombo = new QComboBox(page); + auto* themeCombo = new EmptyAwareComboBox(page); themeCombo->addItem(QStringLiteral("跟随系统"), QStringLiteral("system")); themeCombo->addItem(QStringLiteral("浅色"), QStringLiteral("light")); themeCombo->addItem(QStringLiteral("深色"), QStringLiteral("dark")); @@ -56,23 +74,26 @@ QWidget* buildAppearancePage() { QObject::connect(themeCombo, &QComboBox::activated, page, [themeCombo](int) { geopro::app::setThemeModePreference(themeCombo->currentData().toString()); }); - v->addWidget(makeRow(QStringLiteral("主题"), themeCombo)); + v->addWidget(makeRow(QStringLiteral("主题"), themeCombo, + QStringLiteral("跟随系统 / 浅色 / 深色,切换即时生效"))); // 界面字号:小/标准/大/特大(重启生效)。 - auto* fontCombo = new QComboBox(page); + auto* fontCombo = new EmptyAwareComboBox(page); fontCombo->addItem(QStringLiteral("小"), 90); fontCombo->addItem(QStringLiteral("标准"), 100); fontCombo->addItem(QStringLiteral("大"), 115); fontCombo->addItem(QStringLiteral("特大"), 130); const int curScale = geopro::app::fontScalePreference(); fontCombo->setCurrentIndex(fontCombo->findData(curScale) >= 0 ? fontCombo->findData(curScale) : 1); - v->addWidget(makeRow(QStringLiteral("界面字号"), fontCombo)); + v->addWidget(makeRow(QStringLiteral("界面字号"), fontCombo, + QStringLiteral("小 / 标准 / 大 / 特大,重启后生效"))); // 字号改动:持久化 + 提示重启(提供立即重启)。 auto* restartRow = new QWidget(page); auto* rlay = new QHBoxLayout(restartRow); - rlay->setContentsMargins(96 + 12, 0, 0, 0); // 与控件列对齐 - rlay->setSpacing(10); + rlay->setContentsMargins(geopro::app::scaledPx(160) + geopro::app::space::kLg, 0, 0, + 0); // 与控件列对齐 + rlay->setSpacing(geopro::app::space::kMl); auto* hint = new QLabel(QStringLiteral("界面字号将在重启后生效"), restartRow); geopro::app::applyTokenizedStyleSheet( hint, QStringLiteral("color:{{text/secondary}}; font-size:%1px;") @@ -101,9 +122,10 @@ QWidget* buildAppearancePage() { QWidget* buildAboutPage() { auto* page = new QWidget(); auto* v = new QVBoxLayout(page); - v->setContentsMargins(24, 20, 24, 20); - v->setSpacing(12); - v->addWidget(sectionTitle(QStringLiteral("关于"), page)); + v->setContentsMargins(geopro::app::space::kXxl, geopro::app::scaledPx(20), + geopro::app::space::kXxl, geopro::app::scaledPx(20)); + v->setSpacing(geopro::app::space::kLg); + geopro::app::formkit::addSection(v, QStringLiteral("关于"), page, false); auto* ver = new QLabel(QStringLiteral("Geopro 3.0 — 项目分析视图 (M1)"), page); ver->setStyleSheet(QStringLiteral("font-size:%1px; font-weight:600;") @@ -129,18 +151,42 @@ QWidget* buildAboutPage() { SettingsDialog::SettingsDialog(QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("设置")); - resize(720, 480); + resize(geopro::app::scaledPx(720), geopro::app::scaledPx(480)); auto* root = new QHBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); - // 左:分类列表。 + // 左:分类导航(§7.10)。分类项高 32px,图标 + 名称;选中 = bg/selected 底 + 左 2px 竖条。 + // 左竖条用「选中项 2px 左边框 + accent/primary」实现,非选中项留同宽透明左边框防文字跳动。 auto* sidebar = new QListWidget(this); sidebar->setObjectName(QStringLiteral("settingsSidebar")); - sidebar->setFixedWidth(150); - sidebar->addItem(QStringLiteral("外观")); - sidebar->addItem(QStringLiteral("关于")); + sidebar->setFixedWidth(geopro::app::scaledPx(160)); + sidebar->setIconSize(QSize(geopro::app::scaledPx(16), geopro::app::scaledPx(16))); + geopro::app::applyTokenizedStyleSheet( + sidebar, + QStringLiteral( + "QListWidget#settingsSidebar{background:{{bg/panel}}; border:none;" + " border-right:1px solid {{divider}}; outline:none;}" + "QListWidget#settingsSidebar::item{min-height:%1px; padding-left:%2px;" + " border-left:2px solid transparent; color:{{text/primary}}; font-size:%3px;}" + "QListWidget#settingsSidebar::item:hover{background:{{bg/hover}};}" + "QListWidget#settingsSidebar::item:selected{background:{{bg/selected}};" + " border-left:2px solid {{accent/primary}}; color:{{text/primary}};}") + .arg(geopro::app::scaledPx(32)) + .arg(geopro::app::space::kLg) + .arg(geopro::app::scaledPx(geopro::app::type::kBody))); + + auto* appearanceItem = new QListWidgetItem( + geopro::app::makeGlyph(geopro::app::Glyph::Gear, geopro::app::tokenColor("text/secondary"), + geopro::app::scaledPx(16)), + QStringLiteral("外观"), sidebar); + auto* aboutItem = new QListWidgetItem( + geopro::app::makeGlyph(geopro::app::Glyph::Property, + geopro::app::tokenColor("text/secondary"), geopro::app::scaledPx(16)), + QStringLiteral("关于"), sidebar); + Q_UNUSED(appearanceItem); + Q_UNUSED(aboutItem); root->addWidget(sidebar); // 右:分页。 diff --git a/src/app/SliceExport.cpp b/src/app/SliceExport.cpp new file mode 100644 index 0000000..92d85e4 --- /dev/null +++ b/src/app/SliceExport.cpp @@ -0,0 +1,251 @@ +#include "SliceExport.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +bool exportSliceImagePng(vtkImageData* colorImage, const std::string& path) { + if (colorImage == nullptr || path.empty()) return false; + vtkNew 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 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 writer; + writer->SetFileName(path.c_str()); + writer->SetInputConnection(w2i->GetOutputPort()); + writer->Write(); + return writer->GetErrorCode() == 0; +} + +bool captureFramedRegionPng(vtkRenderWindow* win, const double regionBounds[6], double padFactor, + double minExtent, const std::string& path, int& outW, int& outH) { + outW = outH = 0; + if (win == nullptr || path.empty()) return false; + vtkRenderer* ren = + win->GetRenderers() ? win->GetRenderers()->GetFirstRenderer() : nullptr; + vtkCamera* cam = ren ? ren->GetActiveCamera() : nullptr; + if (ren == nullptr || cam == nullptr) + return captureRenderWindowPng(win, path, outW, outH); // 无渲染器 → 退回整窗 + + // 1) 区域包围盒:minExtent 兜底(点零体积/共面零厚度) → padFactor 以中心外扩留边距。 + double b[6]; + for (int i = 0; i < 3; ++i) { + const double lo = regionBounds[2 * i], hi = regionBounds[2 * i + 1]; + const double c = 0.5 * (lo + hi); + double half = 0.5 * (hi - lo); + if (2.0 * half < minExtent) half = 0.5 * minExtent; // 退化轴兜底 + half *= padFactor; // 外扩边距 + b[2 * i] = c - half; + b[2 * i + 1] = c + half; + } + + // 2) 存相机现场(ResetCamera 改 position/focalPoint/clipping/parallelScale)。 + double pos[3], fp[3], up[3], clip[2]; + cam->GetPosition(pos); + cam->GetFocalPoint(fp); + cam->GetViewUp(up); + cam->GetClippingRange(clip); + const double va = cam->GetViewAngle(); + const double ps = cam->GetParallelScale(); + + // 3) 重构图:保持视角方向,仅推近/缩放框住外扩区域。 + ren->ResetCamera(b); + + // 4) 截图(后台缓冲 + 关交换 → 屏幕不闪)。 + vtkNew 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 writer; + writer->SetFileName(path.c_str()); + writer->SetInputConnection(w2i->GetOutputPort()); + writer->Write(); + const bool ok = writer->GetErrorCode() == 0; + + // 5) 还原相机 + 重绘回原视图。 + cam->SetPosition(pos); + cam->SetFocalPoint(fp); + cam->SetViewUp(up); + cam->SetViewAngle(va); + cam->SetParallelScale(ps); + cam->SetClippingRange(clip); + win->Render(); + return ok; +} + +bool captureAnomalyShotFromSlice(vtkImageData* colorImg, const double o[3], const double p1[3], + const double p2[3], + const std::vector>& worldPts, int markType, + const std::string& outlineHex, const std::string& path, int& outW, + int& outH) { + outW = outH = 0; + if (colorImg == nullptr || worldPts.empty() || path.empty()) return false; + int dims[3]; + colorImg->GetDimensions(dims); + const int nx = dims[0], ny = dims[1]; + if (nx < 2 || ny < 2) return false; + + // 平面两轴(image i↔e1=p1-o, j↔e2=p2-o);世界点 → 归一(u,v) → 像素(QImage 顶左原点,需翻 j)。 + const double e1[3] = {p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]}; + const double e2[3] = {p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]}; + const double L1 = e1[0] * e1[0] + e1[1] * e1[1] + e1[2] * e1[2]; + const double L2 = e2[0] * e2[0] + e2[1] * e2[1] + e2[2] * e2[2]; + if (L1 < 1e-12 || L2 < 1e-12) return false; + QPolygonF poly; + poly.reserve(static_cast(worldPts.size())); + for (const auto& P : worldPts) { + const double d[3] = {P[0] - o[0], P[1] - o[1], P[2] - o[2]}; + const double u = (d[0] * e1[0] + d[1] * e1[1] + d[2] * e1[2]) / L1; + const double v = (d[0] * e2[0] + d[1] * e2[1] + d[2] * e2[2]) / L2; + poly << QPointF(u * (nx - 1), (ny - 1) - v * (ny - 1)); // 翻 j 到 QImage 坐标 + } + + // 缓冲半径:异常包围盒对角的 15%,最小取图长边 4%(点/小异常也有可见外扩)。 + const QRectF pb = poly.boundingRect(); + const double diag = std::hypot(pb.width(), pb.height()); + const double buffer = std::max(0.04 * std::max(nx, ny), 0.15 * diag); + + // 按形态构 buffer 后的裁剪形状:点→圆、线→胶囊带、面→外扩多边形(填充 ∪ 描边)。 + QPainterPath shape; + if (markType == 1 || poly.size() == 1) { + shape.addEllipse(poly.first(), buffer, buffer); + } else if (markType == 2) { + QPainterPath line; + line.moveTo(poly.first()); + for (int i = 1; i < poly.size(); ++i) line.lineTo(poly[i]); + QPainterPathStroker st; + st.setWidth(2.0 * buffer); + st.setCapStyle(Qt::RoundCap); + st.setJoinStyle(Qt::RoundJoin); + shape = st.createStroke(line); + } else { + QPainterPath fill; + fill.addPolygon(poly); + fill.closeSubpath(); + QPainterPath outline = fill; + QPainterPathStroker st; + st.setWidth(2.0 * buffer); + st.setJoinStyle(Qt::RoundJoin); + shape = fill.united(st.createStroke(outline)); // 向外扩 buffer + } + + // 裁剪区 = 形状包围盒(夹到图内)。 + const QRect crop = shape.boundingRect().toAlignedRect().intersected(QRect(0, 0, nx, ny)); + if (crop.width() < 1 || crop.height() < 1) return false; + + // 切片着色图(vtk, j=0 在底) → QImage(顶左原点,翻行)。RGBA 保留外区透明(消除血缘外蓝边)。 + const int comps = colorImg->GetNumberOfScalarComponents(); + const bool rgba = comps >= 4; + QImage src(nx, ny, rgba ? QImage::Format_RGBA8888 : QImage::Format_RGB888); + for (int j = 0; j < ny; ++j) { + uchar* row = src.scanLine(ny - 1 - j); + for (int i = 0; i < nx; ++i) { + const auto* px = static_cast(colorImg->GetScalarPointer(i, j, 0)); + if (rgba) { + row[i * 4] = px[0]; + row[i * 4 + 1] = px[1]; + row[i * 4 + 2] = px[2]; + row[i * 4 + 3] = px[3]; + } else { + row[i * 3] = px[0]; + row[i * 3 + 1] = px[1]; + row[i * 3 + 2] = px[2]; + } + } + } + + // 输出:buffer 形状内贴剖面像素(外透明),再描异常轮廓。 + QImage out(crop.size(), QImage::Format_ARGB32); + out.fill(Qt::transparent); + QPainter pr(&out); + pr.setRenderHint(QPainter::Antialiasing, true); + pr.translate(-crop.topLeft()); + pr.setClipPath(shape); + pr.drawImage(0, 0, src); + pr.setClipping(false); + QColor oc(QString::fromStdString(outlineHex)); + if (!oc.isValid()) oc = QColor(255, 48, 48); + QPen pen(oc); + pen.setWidthF(2.0); + pr.setPen(pen); + pr.setBrush(Qt::NoBrush); + if (markType == 1 || poly.size() == 1) + pr.drawEllipse(poly.first(), 4.0, 4.0); // 点:小标记 + else if (markType == 2) + pr.drawPolyline(poly); + else + pr.drawPolygon(poly); + pr.end(); + + if (!out.save(QString::fromStdString(path), "PNG")) return false; + outW = out.width(); + outH = out.height(); + return true; +} + +bool exportSliceDat(vtkImageData* slice, const std::string& path) { + 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(j) * nx + i; + out << arr->GetComponent(id, 0) << (i + 1 < nx ? ' ' : '\n'); + } + } + return static_cast(out); +} + +} // namespace geopro::app diff --git a/src/app/SliceExport.hpp b/src/app/SliceExport.hpp new file mode 100644 index 0000000..d2d9504 --- /dev/null +++ b/src/app/SliceExport.hpp @@ -0,0 +1,40 @@ +#pragma once +#include +#include +#include + +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); + +// 「相机重构图」截图(方案A):把相机临时重新取景到 regionBounds(圈定范围)外扩后的区域, +// 使异常框在画面中央带周边语境,再截图、还原相机。业界 frame/zoom-to-fit selection 范式。 +// regionBounds: {xmin,xmax,ymin,ymax,zmin,zmax} 世界系圈定包围盒; +// padFactor: 以盒中心外扩的倍数(1.4≈异常占画面~70%); +// minExtent: 退化兜底(点=零体积、线/面共面=某轴零厚度)时各轴的最小世界尺寸。 +// 视角方向不变(仅推近/缩放);屏幕无闪(后台缓冲+关交换)。失败回退整窗截图。 +bool captureFramedRegionPng(vtkRenderWindow* win, const double regionBounds[6], double padFactor, + double minExtent, const std::string& path, int& outW, int& outH); + +// 异常截图(正确做法):**只从切片那张 2D 剖面彩图**上,按异常几何**向外缓冲(buffer)一圈后裁剪**输出。 +// 业界范式 = GIS「几何缓冲 + 按掩膜裁剪栅格」:点→圆、线→胶囊带、面→外扩多边形;缓冲外透明。 +// colorImg:selectedSliceColorImage() 的剖面 RGB 图;o/p1/p2:该切片平面三点(image i↔p1-o, j↔p2-o); +// worldPts:异常顶点(世界系,落在该平面);markType:1点/2线/3面;outlineHex:在裁图上描异常轮廓的颜色。 +// 成功返回 true,填回输出像素宽高。失败(无图/几何退化)返回 false,调用方可回退。 +bool captureAnomalyShotFromSlice(vtkImageData* colorImg, const double o[3], const double p1[3], + const double p2[3], + const std::vector>& worldPts, int markType, + const std::string& outlineHex, const std::string& path, int& outW, + int& outH); + +// 把切片重采样 2D 标量影像写为 .dat 文本网格(行=j、列=i,空格分隔,每格取标量首分量);成功返回 true。 +bool exportSliceDat(vtkImageData* slice, const std::string& path); + +} // namespace geopro::app diff --git a/src/app/SlicePropertiesDialog.cpp b/src/app/SlicePropertiesDialog.cpp new file mode 100644 index 0000000..958e63d --- /dev/null +++ b/src/app/SlicePropertiesDialog.cpp @@ -0,0 +1,52 @@ +#include "SlicePropertiesDialog.hpp" + +#include + +#include "FormKit.hpp" + +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& 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("切片属性")); + + formkit::DetailForm form; + form.group(QStringLiteral("切片")) + .row(QStringLiteral("名称"), name.isEmpty() ? QStringLiteral("—") : name) + .row(QStringLiteral("所属三维体"), spec.volumeDsId.empty() + ? QStringLiteral("—") + : QString::fromStdString(spec.volumeDsId)) + .row(QStringLiteral("轴向"), axisLabel(spec.axis)) + .row(QStringLiteral("Origin"), pointLabel(spec.origin)) + .row(QStringLiteral("Point1"), pointLabel(spec.point1)) + .row(QStringLiteral("Point2"), pointLabel(spec.point2)) + .row(QStringLiteral("色阶来源"), + spec.colorScaleId.empty() ? QStringLiteral("首个源数据集") + : QString::fromStdString(spec.colorScaleId)); + + formkit::buildDetailDialog(this, form.build()); +} + +} // namespace geopro::app diff --git a/src/app/SlicePropertiesDialog.hpp b/src/app/SlicePropertiesDialog.hpp new file mode 100644 index 0000000..c89af47 --- /dev/null +++ b/src/app/SlicePropertiesDialog.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +#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 diff --git a/src/app/Theme.cpp b/src/app/Theme.cpp index 5354e2c..e0231b3 100644 --- a/src/app/Theme.cpp +++ b/src/app/Theme.cpp @@ -216,7 +216,7 @@ QPushButton { background: {{bg/panel}}; color: {{text/primary}}; border: 1px solid {{border/strong}}; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ padding: 6px 14px; } QPushButton:hover { @@ -239,23 +239,52 @@ QPushButton:disabled { color: {{text/disabled}}; border-color: {{border/default}}; } +/* 输入框(规范 §7.1):默认 border/default、hover border/strong、focus border/focus。 + 高 ~28px = 字号 13px + 上下 padding 6px + 边框 1px×2 ≈ min-height 16px。 + 注意:Qt QSS 不支持 box-shadow,规范的 focus「外发光」无法实现,仅用 border/focus 近似。 */ QLineEdit { background: {{bg/panel}}; color: {{text/primary}}; - border: 1px solid {{border/strong}}; - border-radius: 6px; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ padding: 6px 8px; + min-height: 16px; selection-background-color: {{accent/primary}}; selection-color: {{text/on-primary}}; } +QLineEdit:hover { + border-color: {{border/strong}}; +} QLineEdit:focus { - border: 1px solid {{accent/primary}}; + border: 1px solid {{border/focus}}; } QLineEdit:disabled { background: {{bg/app}}; color: {{text/disabled}}; } +/* 多行文本(备注/描述):与输入框同款 box(边框/底色/圆角/内距),仅不设 min-height + (高度由控件自定)。避免多行框沿用 Fusion 默认边框、与单行输入观感不一。 */ +QPlainTextEdit, QTextEdit { + background: {{bg/panel}}; + color: {{text/primary}}; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ + padding: 4px 8px; + selection-background-color: {{accent/primary}}; + selection-color: {{text/on-primary}}; +} +QPlainTextEdit:hover, QTextEdit:hover { + border-color: {{border/strong}}; +} +QPlainTextEdit:focus, QTextEdit:focus { + border: 1px solid {{border/focus}}; +} +QPlainTextEdit:disabled, QTextEdit:disabled { + background: {{bg/app}}; + color: {{text/disabled}}; +} + /* ── 滚动条:纤细现代(无需图片资源)───────────────────────── */ QScrollBar:vertical { background: transparent; @@ -330,7 +359,7 @@ QMenuBar { QMenuBar::item { background: transparent; padding: 6px 12px; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ } QMenuBar::item:selected { background: {{bg/hover}}; @@ -343,11 +372,12 @@ QMenu { background: {{bg/panel}}; color: {{text/primary}}; border: 1px solid {{border/default}}; + border-radius: 6px; /* radius/md(浮层容器) */ padding: 4px; } QMenu::item { padding: 6px 24px 6px 14px; - border-radius: 6px; + border-radius: 4px; /* radius/sm */ } QMenu::item:selected { background: {{bg/hover}}; @@ -360,27 +390,97 @@ QMenu::separator { } /* ── 下拉框(按需出现时也与主题一致)──────────────────────── */ +/* 下拉框(规范 §7.2):外观对齐输入框 —— 默认 border/default、hover border/strong、 + focus border/focus、radius/sm。弹窗用 radius/md(浮层),项 hover bg/hover。 */ QComboBox { background: {{bg/panel}}; color: {{text/primary}}; - border: 1px solid {{border/strong}}; - border-radius: 6px; - padding: 6px 10px; - min-height: 18px; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ + padding: 6px 8px; /* 与 QLineEdit 完全一致 */ + min-height: 16px; /* 与 QLineEdit 完全一致 → 同高(可编辑/不可编辑均如此)*/ } QComboBox:hover { - border-color: {{accent/primary}}; + border-color: {{border/strong}}; } QComboBox:focus { - border-color: {{accent/primary}}; + border-color: {{border/focus}}; } -QComboBox::drop-down { +QComboBox:disabled { + background: {{bg/app}}; + color: {{text/disabled}}; +} +/* 数字框/日期/时间编辑器:与输入框/下拉框同款外观。QSpinBox/QDoubleSpinBox/QDateEdit/ + QTimeEdit/QDateTimeEdit 均派生自 QAbstractSpinBox,一处统一其 box(高度/边框/圆角/内距), + 避免数字框比下拉框/输入框矮(历史坑:只给了 QLineEdit/QComboBox/Date 没给 SpinBox)。 */ +QAbstractSpinBox { + background: {{bg/panel}}; + color: {{text/primary}}; + border: 1px solid {{border/default}}; + border-radius: 4px; /* radius/sm */ + padding: 6px 8px; + min-height: 16px; /* 与 QLineEdit/QComboBox 完全一致 → 同高 */ +} +QAbstractSpinBox:hover { + border-color: {{border/strong}}; +} +QAbstractSpinBox:focus { + border-color: {{border/focus}}; +} +QAbstractSpinBox:disabled { + background: {{bg/app}}; + color: {{text/disabled}}; +} +/* 下拉按钮平面化(去 Fusion 原生斜角/分隔)+ 统一的扁平 chevron 箭头(qrc 内嵌 SVG)。 + 覆写 ::drop-down 必须同时提供 ::down-arrow 图,否则箭头消失(历史坑)。 */ +QComboBox::drop-down, QDateEdit::drop-down, QTimeEdit::drop-down, QDateTimeEdit::drop-down { + subcontrol-origin: padding; + subcontrol-position: center right; + width: 20px; border: none; - width: 22px; + background: transparent; +} +QComboBox::down-arrow, QDateEdit::down-arrow, QTimeEdit::down-arrow, QDateTimeEdit::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 12px; + height: 12px; +} +/* 数字框上下按钮:平面化 + 扁平 chevron(与下拉箭头同族)。覆写 up/down-button 须同时给 + up/down-arrow 图,否则 Fusion 原生箭头消失。日期/时间用日历下拉(::drop-down),不在此列。 */ +QSpinBox::up-button, QDoubleSpinBox::up-button { + subcontrol-origin: border; + subcontrol-position: top right; + width: 18px; + border: none; + border-left: 1px solid {{border/default}}; + background: transparent; +} +QSpinBox::down-button, QDoubleSpinBox::down-button { + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 18px; + border: none; + border-left: 1px solid {{border/default}}; + background: transparent; +} +QSpinBox::up-button:hover, QDoubleSpinBox::up-button:hover, +QSpinBox::down-button:hover, QDoubleSpinBox::down-button:hover { + background: {{bg/hover}}; +} +QSpinBox::up-arrow, QDoubleSpinBox::up-arrow { + image: url(:/icons/chevron-up.svg); + width: 10px; + height: 10px; +} +QSpinBox::down-arrow, QDoubleSpinBox::down-arrow { + image: url(:/icons/chevron-down.svg); + width: 10px; + height: 10px; } QComboBox QAbstractItemView { background: {{bg/panel}}; border: 1px solid {{border/default}}; + border-radius: 6px; /* radius/md(浮层容器) */ outline: none; padding: 2px; } @@ -424,6 +524,36 @@ QProgressBar::chunk { border-radius: 6px; } +/* ── 滑块(规范 §6.13,如简化容差):4px 轨道 + 已填充段强调色 + 14px 白圆手柄 ── + handle margin -5px 使 14px 手柄在 4px 轨道上垂直居中。仅横向(app 用横向滑块)。 */ +QSlider::groove:horizontal { + height: 4px; + background: {{border/default}}; + border-radius: 2px; +} +QSlider::sub-page:horizontal { + background: {{accent/primary}}; + border-radius: 2px; +} +QSlider::add-page:horizontal { + background: {{border/default}}; + border-radius: 2px; +} +QSlider::handle:horizontal { + width: 14px; + height: 14px; + margin: -5px 0; + border-radius: 7px; + background: {{bg/panel}}; + border: 1px solid {{border/strong}}; +} +QSlider::handle:horizontal:hover { + border-color: {{accent/primary}}; +} + +/* 对话框容器圆角/投影(规范 §7.5 radius/lg + shadow/dialog):Qt 顶层原生窗口忽略 + QSS 的 border-radius,且 QSS 无法绘制窗口外阴影——此处刻意不做,留待原生/QGraphicsEffect。 */ + /* ── ADS 停靠:标题栏做成「固定面板表头」(对齐原型)────────────── 面板已锁定(无关闭/浮动/拖动),每个区只一个标签即表头:统一浅色头 + 蓝色下划线强调 + 深色加粗标题。各区一致,读起来像固定子窗口表头。 */ diff --git a/src/app/Theme.hpp b/src/app/Theme.hpp index 5148b72..23da2a0 100644 --- a/src/app/Theme.hpp +++ b/src/app/Theme.hpp @@ -3,11 +3,9 @@ // 全局视觉主题(浅色专业方向):Fusion 风格 + 浅色 QPalette + 结构化 QSS。 // 仅外观——不改任何信号槽 / 渲染 / 数据逻辑。在 QApplication 构造后、弹登录窗前调用一次。 // -// 设计令牌(与登录窗、视图详情浮层共享,保证全项目一脉相承): -// 外壳底 #F4F6FA 面板白 #FFFFFF 抬升/表头 #EDF1F7 -// 强调 #2D6CB5 悬停 #2862A6 按下 #234F87 选中行 #DCE9F8 -// 文字 #1F2A3D 次要文字 #5A6B85 边框 #D5DBE5 强边框 #C2CCDA -// 危险 #C0392B +// 设计令牌唯一事实来源 = Theme.cpp 的 kTokens 表(规范 §1.5)。组件只引语义 token +// (token()/{{token}}),禁止在此散落硬编码 hex。速查:bg/app #F7F8FA · accent/primary +// #3B73EC · text/primary #272C35 · border/default #E3E6EB · status/danger #E5484D。 #include #include @@ -56,6 +54,10 @@ inline constexpr int kWeightMedium = 500; inline constexpr int kWeightSemibold = 600; inline constexpr int kWeightBold = 700; +// 等宽字族(规范 §2.1):坐标/数值/编号/深度刻度用,保证逐列对齐。 +// 仅在此暴露,组件阶段(数值/坐标/ID 标签)按需引用,不在全局 QSS 强加。 +inline constexpr const char* kMonoFamily = "Cascadia Code, JetBrains Mono, Consolas, monospace"; + } // namespace type // ── 间距令牌(全项目唯一间距阶)────────────────────────────────── @@ -75,33 +77,31 @@ inline constexpr int kXl = 16; // 区块内边距 inline constexpr int kXxl = 24; // 区块间距、表单纵向边距 inline constexpr int kXxxl = 32; // 页面级留白(登录窗左右边距) +// 可编辑表单标签列宽(规范 §7.0.2:固定 100px,同表单内等宽对齐右标签)。 +// 唯一事实来源——DynamicFormEditor / 各参数对话框经 formkit::editLabel 统一引此值。 +inline constexpr int kFormLabelCol = 100; + +// 只读键值表(§6.4)键列定宽。与可编辑标签列分档(只读两列布局更紧凑)—— +// 唯一事实来源,DynamicFormView 引此值;规范 §7.0.10 列为精确常量,禁止区间/魔数。 +inline constexpr int kDetailKeyCol = 72; + +// 可编辑字段最大宽(规范 §7.0.2:宽对话框中「不要拉满」,单字段最大约 360px)。 +// 窄属性面板里该上限大于面板宽,故字段仍填满——符合规范。多行/长文本不受此限。 +inline constexpr int kFormFieldMax = 360; + } // namespace space -// ── 圆角令牌(统一原先 4/5/6/7/8/9 共 6 档为 3 档)──────────────── +// ── 圆角令牌(规范 §3.2)──────────────────────────────────────── // 圆形元素(头像等)用 直径/2 单独写字面量,不入档。 namespace radius { -inline constexpr int kSm = 6; // 按钮·输入·菜单项·滚动条·进度条 -inline constexpr int kMd = 8; // 卡片·面板·对话框·菜单·分组框 -inline constexpr int kPill = 9; // 数量徽标胶囊 +inline constexpr int kSm = 4; // 按钮·输入框·标签 +inline constexpr int kMd = 6; // 卡片·列表项·浮层·菜单 +inline constexpr int kLg = 8; // 对话框·画布浮窗·分组框 +inline constexpr int kPill = 999; // 胶囊标签·开关·计数徽标 } // namespace radius -// ── 语义色令牌(状态/反馈,产品语境:只在承载含义处用,不作装饰)────────── -// 文字值均针对白底面板(#FFFFFF)选深色,对比度 ≥4.5:1(正文级);与冷调中性 -// 调色板调和。danger 沿用既有红,避免引入第二种红。 -namespace semantic { - -inline constexpr const char* kInfo = "#2D6CB5"; // 信息·进行中(= 品牌蓝) -inline constexpr const char* kSuccess = "#15803D"; // 成功·已完成(深绿) -inline constexpr const char* kWarning = "#B45309"; // 警告·需注意(深琥珀) -inline constexpr const char* kDanger = "#C0392B"; // 危险·错误(沿用既有红) - -// 浅色填充(徽标/标签底色,配同族深色文字使用)。 -inline constexpr const char* kWarningFill = "#FBEAD2"; // 警告底纹(配 kWarning 文字) - -} // namespace semantic - // 应用专业主题(Fusion + 调色板 + 全局样式表)。dark=true 走暗色(P2 主题桥用)。 // 暗色复用同一 QSS 结构,颜色全由 kTokens 双值(fillTokens/tokenHex)驱动;幂等,可随主题切换重复调用。 void applyThemeMode(QApplication& app, bool dark); diff --git a/src/app/TileBasemap.cpp b/src/app/TileBasemap.cpp new file mode 100644 index 0000000..943b83d --- /dev/null +++ b/src/app/TileBasemap.cpp @@ -0,0 +1,573 @@ +#include "TileBasemap.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Scene.hpp" +#include "geo/GeoLocalFrame.hpp" +#include "ground/TileMath.hpp" + +namespace geopro::app { + +namespace { +// 天地图 WMTS 令牌(与轨迹图 trajectory_map.html 同源)。 +const char* kTk = "aca91d8c9f59a4f779f39061b8a07737"; +constexpr int kRootZoom = 9; // 四叉树根层级(单块~78km,±1 覆盖~234km 到天边) +constexpr double kTargetPx = 384.0; // 瓦片屏幕像素阈值:超过则细分(越小越清晰但块更多) +constexpr int kMaxLeaves = 200; // 一次覆盖的叶瓦片上限(安全兜底,防细分爆炸) +// 底图最大距离按剖面范围动态定:半径×倍数,夹在[下限,上限]。随勾选增删自动伸缩;无数据时用下限。 +constexpr double kRangeFactor = 10.0; +constexpr double kRangeFloor = 2000.0; // 至少 2km(小剖面也有足够地理背景) +constexpr double kRangeCeil = 30000.0; // 最多 30km(防远裁剪面失控) +constexpr int kMaxConcurrent = 12; // 瓦片请求最大并发(天地图 8 子域+Mapbox,适度提高吞吐) +constexpr int kMinZoom = 3; +constexpr int kMaxZoom = 18; +constexpr double kGroundZ = 0.0; // 底图置于 z=0 地面参考(剖面深度向下为负,落其下) +constexpr double kZEps = 0.02; // 每层级 Z 微偏移:高层级压上面,避免共面瓦片 z-fighting +constexpr int kHardCap = 400; // 瓦片硬上限:超过则即便未落地也强制清理,兜底内存 +constexpr double kPi = 3.14159265358979323846; +constexpr double kTerrainOpacity = 0.55; // 地形半透明:地下剖面可从任意角度透过地面看到(不再被遮挡) + +// 地面起伏:Mapbox terrain-RGB DEM 瓦片(原版 web 同款源,全球 CDN,比 AWS Terrarium 快)。 +// 公式 elev(米) = -10000 + (R*65536 + G*256 + B)*0.1。数据到 z15,更高层级取祖先块。 +// kMapboxToken:原版 commercial-admin 的 Mapbox 公开 token(pk.*,客户端用,同 天地图 tk 性质)。 +const char* kMapboxToken = + "pk.eyJ1IjoidGJ1c2FuIiwiYSI6ImNtZjY2emZneDBkY24ybXB4cmpvdmwzNWYifQ.h6tcQ380WN5AW6fZr08how"; +constexpr int kDemMaxZoom = 15; +constexpr int kTerrainGrid = 32; // 每瓦片网格分辨率(33x33 顶点) + +// key 打包:z<<44 | x<<22 | y(z≤18, x/y<2^18 < 2^22)。 +void unpackKey(long long key, int& z, int& x, int& y) { + z = static_cast(key >> 44); + x = static_cast((key >> 22) & 0x3FFFFF); + y = static_cast(key & 0x3FFFFF); +} + +// QImage → vtkTexture:转 RGBA + 垂直翻转,使纹理 v=0 对应瓦片南边(与 PlaneSource tcoord 一致)。 +vtkSmartPointer makeTexture(const QImage& img) { + const QImage rgba = img.convertToFormat(QImage::Format_RGBA8888); + const int w = rgba.width(), h = rgba.height(); + if (w <= 0 || h <= 0) return nullptr; + vtkNew vimg; + vimg->SetDimensions(w, h, 1); + vimg->AllocateScalars(VTK_UNSIGNED_CHAR, 4); + for (int row = 0; row < h; ++row) { + const uchar* src = rgba.scanLine(h - 1 - row); + auto* dst = static_cast(vimg->GetScalarPointer(0, row, 0)); + std::memcpy(dst, src, static_cast(w) * 4); + } + auto tex = vtkSmartPointer::New(); + tex->SetInputData(vimg); + tex->InterpolateOn(); // 双线性 + tex->MipmapOn(); // 缩小/斜视不闪烁、不糊 + tex->SetMaximumAnisotropicFiltering(16); // 斜视角下纹理保持清晰 + tex->EdgeClampOn(); // 边缘夹紧,避免相邻瓦片接缝渗色 + return tex; +} + +// 天地图「此级别下,该区域无影像」固定占位 JPEG:所有无影像瓦片字节完全一致(实测 size=4769、 +// MD5 固定)。按 大小+MD5 精确识别 → 仅命中该占位图,绝不误判真实影像瓦片。 +bool isTiandituNoImagery(const QByteArray& data) { + if (data.size() != 4769) return false; // 廉价预筛:仅对疑似占位大小算哈希 + static const QByteArray kNoImageMd5 = + QByteArray::fromHex("c0edbdcb2c8ddd3e6a5cf09348c0fcb4"); + return QCryptographicHash::hash(data, QCryptographicHash::Md5) == kNoImageMd5; +} + +// Terrarium 像素解码高程:(fx,fy)∈[0,1],fy=0 北/顶行。 +double demElev(const QImage& dem, double fx, double fy) { + const int w = dem.width(), h = dem.height(); + if (w <= 0 || h <= 0) return 0.0; + const int px = std::clamp(static_cast(std::lround(fx * (w - 1))), 0, w - 1); + const int py = std::clamp(static_cast(std::lround(fy * (h - 1))), 0, h - 1); + const QRgb c = dem.pixel(px, py); + return -10000.0 + (qRed(c) * 65536.0 + qGreen(c) * 256.0 + qBlue(c)) * 0.1; // Mapbox terrain-RGB +} +} // namespace + +long long TileBasemap::tileKey(int z, int x, int y) { + return (static_cast(z) << 44) | (static_cast(x) << 22) | + static_cast(y); +} + +TileBasemap::TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, + std::shared_ptr frame, QObject* parent) + : QObject(parent), scene_(scene), rw_(rw), frame_(std::move(frame)) {} + +void TileBasemap::requestRender() { + // 合并渲染:同一事件循环轮次内的多次请求只渲染一帧(标准做法,避免逐瓦片重复 Render 卡顿)。 + if (renderPending_) return; + renderPending_ = true; + QMetaObject::invokeMethod( + this, + [this]() { + renderPending_ = false; + // 渲染前更新裁剪面:把异步刚落地的瓦片纳入近/远裁剪范围,否则它们会被切(屏幕暗带)。 + if (auto* ren = scene_.renderer()) ren->ResetCameraClippingRange(); + if (rw_) rw_->Render(); + }, + Qt::QueuedConnection); +} + +TileBasemap::~TileBasemap() { + if (styleObs_ && observer_) styleObs_->RemoveObserver(observer_); +} + +void TileBasemap::ensureObserver() { + if (styleObs_) return; + if (!rw_) return; + auto* iren = rw_->GetInteractor(); + if (!iren) return; + auto* style = iren->GetInteractorStyle(); // EndInteractionEvent 由交互样式发出 + if (!style) return; + styleObs_ = style; + observer_ = vtkSmartPointer::New(); + observer_->SetClientData(this); + observer_->SetCallback(&TileBasemap::onInteractionEnd); + styleObs_->AddObserver(vtkCommand::EndInteractionEvent, observer_); +} + +void TileBasemap::onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*) { + if (auto* self = static_cast(clientData)) self->refresh(); +} + +void TileBasemap::enqueueGet(const QString& url, std::function onDone) { + netQueue_.push_back({url, std::move(onDone)}); + pumpNetQueue(); +} + +void TileBasemap::pumpNetQueue() { + while (netInFlight_ < kMaxConcurrent && !netQueue_.empty()) { + const PendingGet req = std::move(netQueue_.front()); + netQueue_.pop_front(); + ++netInFlight_; + QNetworkReply* reply = nam_.get(QNetworkRequest(QUrl(req.url))); + auto cb = req.cb; + connect(reply, &QNetworkReply::finished, this, [this, reply, cb]() { + cb(reply); // 回调内部 deleteLater + 处理 + --netInFlight_; + pumpNetQueue(); + }); + } +} + +void TileBasemap::hide() { show(Hidden); } + +void TileBasemap::show(Kind kind) { + ensureObserver(); + ++generation_; // 旧回包(含换源前的层)按 generation 丢弃 + for (auto& kv : placed_) scene_.renderer()->RemoveViewProp(kv.second); + placed_.clear(); + inFlight_.clear(); + netQueue_.clear(); // 丢弃换源前排队中的请求(在途的按 gen 自然作废) + desired_.clear(); + // demCache_/texCache_ 跨隐藏-重选保留 → 重选地图秒出,不重拉。 + terrainProbed_ = false; + satMaxZoom_ = kMaxZoom; // 新源/新区域:复位卫星层级上限,重新探测该区域影像覆盖深度 + kind_ = kind; + if (kind == Hidden) { + requestRender(); + return; + } + refresh(); // 四叉树覆盖:近细远粗一次铺满(地形按真实高程,与剖面同系) +} + +void TileBasemap::setVerticalExaggeration(double ve) { + if (ve <= 0.0 || ve == ve_) return; + ve_ = ve; + if (kind_ != Hidden) show(kind_); // 重建地形(高程×新VE),与剖面 VE 保持一致 +} + +void TileBasemap::refineTile(int z, int x, int y, std::set& out, int& count) { + if (count >= kMaxLeaves) { out.insert(tileKey(z, x, y)); return; } // 安全上限:停止细分 + const int n = 1 << z; + if (x < 0 || y < 0 || x >= n || y >= n) return; + + const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y); + const auto sw = frame_->toLocal(b.south, b.west); + const auto ne = frame_->toLocal(b.north, b.east); + + // 视锥剔除:瓦片 AABB 全在某侧面外侧 → 不在视野内,丢弃(否则屏幕外乱细分耗尽预算)。 + // 只用 4 个侧面(左右上下),不用近/远裁剪面——远裁剪面随已加载几何变化, + // 首帧底图未齐时远面贴得近会误剔除远处可见瓦片(等多久都不出、微动才出)。 + const double zmin = -1000.0, zmax = 1000.0; // 地形起伏远小于瓦片尺度,给宽松 z 带 + for (int p = 0; p < 4; ++p) { + const double* pl = &frustum_[p * 4]; // 内法向:内侧 a·x+b·y+c·z+d ≥ 0 + const double vx = pl[0] >= 0 ? ne.x : sw.x; // 取最朝法向的角点(p-vertex) + const double vy = pl[1] >= 0 ? ne.y : sw.y; + const double vz = pl[2] >= 0 ? zmax : zmin; + if (pl[0] * vx + pl[1] * vy + pl[2] * vz + pl[3] < 0.0) return; // 全在外 → 剔除 + } + + const double cx = (sw.x + ne.x) * 0.5, cy = (sw.y + ne.y) * 0.5; // 瓦片中心(局部米) + const double g = std::max(std::abs(ne.x - sw.x), std::abs(ne.y - sw.y)); // 瓦片地面尺寸(米) + + // 距离上限(按剖面范围动态):以覆盖中心(相机焦点 cenX_,cenY_)为心,瓦片离它太远则不加载—— + // 远裁剪面有界(剖面不被近裁剪面切),也避免拉远无限铺。叶块本身可大于此距离(近端仍在范围内即保留)。 + // 心改用焦点而非原点(0,0):否则 frame 锚在别处数据(如深圳)时,看台湾数据全被剔除→底图空。 + const double rx = cx - cenX_, ry = cy - cenY_; + if (std::sqrt(rx * rx + ry * ry) - g * 0.5 > maxTileDist_) return; + + // 该瓦片投影到屏幕的近似像素尺寸 > 阈值且未到最大层级 → 细分为 4 子块(近处更细)。 + double screenPx; + if (projParallel_) { + screenPx = g * projK_; // 平行投影:projK_ = H/(2·parallelScale) + } else { + const double dx = cx - camX_, dy = cy - camY_, dz = -camZ_; // 相对相机(瓦片 z≈0) + const double dist = std::max(1.0, std::sqrt(dx * dx + dy * dy + dz * dz)); + screenPx = g * projK_ / dist; // 透视:projK_ = H/(2·tan(vfov/2)) + } + // 细分条件:屏幕上太大 → 细分(近细远粗);或瓦片本身比允许范围还大 → 也强制细分, + // 否则拉到最远时一块巨瓦(如 78km)正好盖住数据中心、过不了距离剔除 → 覆盖超大面积。 + // 卫星层用「学习到的」上限 satMaxZoom_(无影像区域已降级),街道层仍到 kMaxZoom。 + const int maxZ = (kind_ == Satellite) ? satMaxZoom_ : kMaxZoom; + if ((screenPx > kTargetPx || g > maxTileDist_) && z < maxZ) { + refineTile(z + 1, 2 * x, 2 * y, out, count); + refineTile(z + 1, 2 * x + 1, 2 * y, out, count); + refineTile(z + 1, 2 * x, 2 * y + 1, out, count); + refineTile(z + 1, 2 * x + 1, 2 * y + 1, out, count); + } else { + out.insert(tileKey(z, x, y)); + ++count; + } +} + +void TileBasemap::refresh() { + if (kind_ == Hidden || refreshing_) return; + refreshing_ = true; + + auto* ren = scene_.renderer(); + auto* cam = ren ? ren->GetActiveCamera() : nullptr; + if (!cam) { refreshing_ = false; return; } + + const int* sz = ren->GetSize(); + const double H = (sz && sz[1] > 0) ? sz[1] : 800.0; + double camPos[3]; + cam->GetPosition(camPos); + camX_ = camPos[0]; camY_ = camPos[1]; camZ_ = camPos[2]; + projParallel_ = cam->GetParallelProjection(); + projK_ = projParallel_ ? H / (2.0 * std::max(1.0, cam->GetParallelScale())) + : H / (2.0 * std::tan(cam->GetViewAngle() * 0.5 * kPi / 180.0)); + const double aspect = (sz && sz[1] > 0) ? double(sz[0]) / double(sz[1]) : 1.0; + cam->GetFrustumPlanes(aspect, frustum_); // 6 视锥面(供 refineTile 剔除屏幕外瓦片) + // 用焦点(必在视锥内)统一各面方向为"内侧≥0",规避 VTK 法向内/外约定差异(否则可能全剔成黑屏)。 + double fp[3]; + cam->GetFocalPoint(fp); + for (int p = 0; p < 6; ++p) { + double* pl = &frustum_[p * 4]; + if (pl[0] * fp[0] + pl[1] * fp[1] + pl[2] * fp[2] + pl[3] < 0.0) + for (int k = 0; k < 4; ++k) pl[k] = -pl[k]; + } + // 底图覆盖中心 = 相机焦点(用户正看处)的局部 XY,而非世界原点:frame 锚在首个数据集,看远处别处 + // 数据时原点离视野很远会把全部瓦片距离剔除→底图空。焦点为心则底图随视野走(同 frame 仍与数据对齐)。 + cenX_ = fp[0]; + cenY_ = fp[1]; + + // 底图最大距离按当前剖面合并范围动态定(随勾选增删自动伸缩);无数据用下限。 + maxTileDist_ = kRangeFloor; + if (dataRadiusProvider_) { + const double r = dataRadiusProvider_(); + if (r > 0.0) maxTileDist_ = std::clamp(r * kRangeFactor, kRangeFloor, kRangeCeil); + } + + // 四叉树:从覆盖中心(相机焦点经纬)一圈粗根块出发,按屏幕误差细分 → 近细远粗、铺满视野,无盲区。 + desired_.clear(); + int count = 0; + const auto c = frame_->toLatLon(cenX_, cenY_); // 覆盖中心 = 相机焦点(非世界原点) + const geopro::render::TileXY root = geopro::render::lonLatToTile(c.lon, c.lat, kRootZoom); + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) + refineTile(kRootZoom, root.x + dx, root.y + dy, desired_, count); + + // 拉取缺失瓦片:按离相机距离排序,最近的先拉 → 用户正看的区域最先出现(而非粗/远块先出)。 + std::vector> todo; + for (long long key : desired_) { + if (placed_.count(key) || inFlight_.count(key)) continue; + int z, x, y; + unpackKey(key, z, x, y); + const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y); + const auto sw = frame_->toLocal(b.south, b.west); + const auto ne = frame_->toLocal(b.north, b.east); + const double cx = (sw.x + ne.x) * 0.5 - camX_, cy = (sw.y + ne.y) * 0.5 - camY_; + todo.push_back({cx * cx + cy * cy, key}); + } + std::sort(todo.begin(), todo.end()); + for (const auto& t : todo) { + int z, x, y; + unpackKey(t.second, z, x, y); + fetchTile(z, x, y, t.second); + } + + purgeStale(); + ren->ResetCameraClippingRange(); // 交互后扩裁剪面以含新载入的底图瓦片(防被"蒙版"切) + requestRender(); + refreshing_ = false; +} + +void TileBasemap::purgeStale() { + // 仅当本轮所有请求都落地(inFlight 空)后再删旧层;否则缩放/平移期间老瓦片留作回退,避免空白闪烁。 + // 超过硬上限则强制清理兜底内存(可能短暂空白,极少触发)。 + if (!inFlight_.empty() && placed_.size() <= static_cast(kHardCap)) return; + bool removed = false; + for (auto it = placed_.begin(); it != placed_.end();) { + if (desired_.find(it->first) == desired_.end()) { + scene_.renderer()->RemoveViewProp(it->second); + it = placed_.erase(it); + removed = true; + } else { + ++it; + } + } + if (removed) requestRender(); +} + +void TileBasemap::fetchTile(int z, int x, int y, long long key) { + // 命中影像缓存 → 不走网络,直接落地(DEM 多半也已缓存)。重选地图/缩放回看即秒出。 + auto cit = texCache_.find(key); + if (cit != texCache_.end()) { + inFlight_.insert(key); + auto tex = cit->second; + if (kind_ == Satellite) { + fetchTerrain(z, x, y, key, tex); + } else { + placeActor(key, buildFlat(z, x, y, tex)); + inFlight_.erase(key); + purgeStale(); + requestRender(); + } + return; + } + + const QString layerDir = (kind_ == Satellite) ? QStringLiteral("img_w") : QStringLiteral("vec_w"); + const QString layer = (kind_ == Satellite) ? QStringLiteral("img") : QStringLiteral("vec"); + const int sub = (x + y) % 8; // 子域负载分担 t0-t7 + const QString url = + QStringLiteral("http://t%1.tianditu.gov.cn/%2/wmts?service=wmts&request=GetTile" + "&version=1.0.0&LAYER=%3&tileMatrixSet=w&TileMatrix=%4&TileRow=%5" + "&TileCol=%6&style=default&format=tiles&tk=%7") + .arg(sub) + .arg(layerDir, layer) + .arg(z) + .arg(y) + .arg(x) + .arg(QString::fromLatin1(kTk)); + + const int gen = generation_; + inFlight_.insert(key); + enqueueGet(url, [this, key, z, x, y, gen](QNetworkReply* reply) { + reply->deleteLater(); + // inFlight 保持到瓦片最终落地(起伏/平面),使旧层在新块就位前不被清理 → 无空白闪烁。 + const bool stale = (gen != generation_) || kind_ == Hidden || + desired_.find(key) == desired_.end() || placed_.count(key); + const QByteArray data = + (!stale && reply->error() == QNetworkReply::NoError) ? reply->readAll() : QByteArray(); + // 天地图无影像占位图:该区域此层级无卫星影像 → 学习把卫星上限降到 z-1 并重铺(改用父层真实 + // 影像放大覆盖),不缓存/不落地占位图。仅卫星层适用(街道矢量层全球到 z18 无此占位)。 + if (kind_ == Satellite && !data.isEmpty() && isTiandituNoImagery(data)) { + inFlight_.erase(key); + if (z - 1 < satMaxZoom_) { + satMaxZoom_ = z - 1; + purgeStale(); + refresh(); // 以新上限重铺该区域 + } else { + purgeStale(); + requestRender(); + } + return; + } + QImage img; + const bool ok = !data.isEmpty() && img.loadFromData(data); + if (!ok) { + inFlight_.erase(key); + purgeStale(); + requestRender(); + return; + } + auto tex = makeTexture(img); + if (texCache_.size() > 1200) texCache_.clear(); // 兜底内存;在用纹理由 actor 自身保活 + texCache_[key] = tex; // 缓存供重选/缩放回看复用 + if (kind_ == Satellite) { + fetchTerrain(z, x, y, key, tex); // 拉 DEM 后直接落地起伏块(inFlight 续到那时) + } else { + placeActor(key, buildFlat(z, x, y, tex)); // 街道图无地形 → 直接平面 + inFlight_.erase(key); + purgeStale(); + requestRender(); + } + }); +} + +void TileBasemap::placeActor(long long key, vtkSmartPointer actor) { + if (!actor) return; + scene_.addActor(actor); + placed_[key] = actor; +} + +vtkSmartPointer TileBasemap::buildFlat(int z, int x, int y, + vtkSmartPointer tex) { + const geopro::render::LonLatBox b = geopro::render::tileBounds(z, x, y); + const auto sw = frame_->toLocal(b.south, b.west); + const auto se = frame_->toLocal(b.south, b.east); + const auto nw = frame_->toLocal(b.north, b.west); + const double gz = kGroundZ + (z - kMinZoom) * kZEps; // 高层级略抬高,压在旧层之上防共面闪烁 + + // PlaneSource 自动 tcoord:origin=SW→u 西0东1、v 南0北1(与翻转后纹理对齐)。 + vtkNew plane; + plane->SetOrigin(sw.x, sw.y, gz); + plane->SetPoint1(se.x, se.y, gz); + plane->SetPoint2(nw.x, nw.y, gz); + vtkNew mapper; + mapper->SetInputConnection(plane->GetOutputPort()); + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + actor->SetTexture(tex); + actor->GetProperty()->LightingOff(); // 底图不受场景光照 + actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 + // 注意:UseBounds 保持默认 true → 参与相机裁剪面计算,否则底图会被裁剪面"蒙版"切掉。 + // 坐标轴/取景不被底图撑大,由 VtkSceneView 改用"数据自身包围盒"解决(非靠 UseBounds=false)。 + return actor; +} + +void TileBasemap::fetchTerrain(int z, int x, int y, long long key, vtkSmartPointer tex) { + // Terrarium 数据约到 z15;更高层级取覆盖本块的祖先 DEM 瓦片,按经纬采样其子区域。 + const int dz = std::min(z, kDemMaxZoom); + const int shift = z - dz; + const int dx = x >> shift, dy = y >> shift; + const long long demKey = tileKey(dz, dx, dy); + + // 落地一块瓦片:DEM 有效→起伏,否则→平面兜底;并推进 inFlight/清理。 + auto place = [this, key, z, x, y, dz, dx, dy, tex](const QImage* dem) { + if (dem && !dem->isNull()) { + placeActor(key, buildWarped(z, x, y, dz, dx, dy, tex, *dem)); + } else { + placeActor(key, buildFlat(z, x, y, tex)); // DEM 拉不到 → 平面兜底 + } + inFlight_.erase(key); + purgeStale(); + requestRender(); + }; + + // 命中缓存:同一祖先 DEM 块的多个瓦片瞬间起伏,免重复网络。 + auto cached = demCache_.find(demKey); + if (cached != demCache_.end()) { + place(&cached->second); + return; + } + + if (!terrainProbed_) { + terrainProbed_ = true; + qInfo() << "[basemap] 首次拉DEM 卫星z=" << z << " → DEMz=" << dz << "(" << dx << "," << dy + << ")"; + } + // Mapbox terrain-RGB(pngraw 无损,保证高程解码准确);原版同源,全球 CDN。 + const QString url = + QStringLiteral("https://api.mapbox.com/v4/mapbox.terrain-rgb/%1/%2/%3.pngraw?access_token=%4") + .arg(dz) + .arg(dx) + .arg(dy) + .arg(QString::fromLatin1(kMapboxToken)); + const int gen = generation_; + enqueueGet(url, [this, key, demKey, gen, place](QNetworkReply* reply) { + reply->deleteLater(); + if (gen != generation_ || kind_ != Satellite || + desired_.find(key) == desired_.end() || placed_.count(key)) { + inFlight_.erase(key); // 过期/移出视野 → 不落地 + purgeStale(); + requestRender(); + return; + } + QImage dem; + if (reply->error() != QNetworkReply::NoError) { + qWarning() << "[basemap] DEM 拉取失败(降级平面)" << reply->url().toString() + << reply->errorString(); + } else if (!dem.loadFromData(reply->readAll())) { + qWarning() << "[basemap] DEM 解码失败(降级平面)" << reply->url().toString(); + dem = QImage(); + } else { + demCache_[demKey] = dem; // 缓存供同祖先块复用 + } + place(dem.isNull() ? nullptr : &dem); + }); +} + +vtkSmartPointer TileBasemap::buildWarped(int sz, int sx, int sy, int dz, int dx, int dy, + vtkSmartPointer tex, + const QImage& dem) { + const geopro::render::LonLatBox sb = geopro::render::tileBounds(sz, sx, sy); // 卫星块(几何) + const geopro::render::LonLatBox db = geopro::render::tileBounds(dz, dx, dy); // DEM 块(采样) + const auto sw = frame_->toLocal(sb.south, sb.west); + const auto se = frame_->toLocal(sb.south, sb.east); + const auto nw = frame_->toLocal(sb.north, sb.west); + const double base = kGroundZ + (sz - kMinZoom) * kZEps; + + // PlaneSource(等距圆柱下平面插值即正确 x/y) + 自动 tcoord;再按各点真实经纬采 DEM 位移 Z。 + vtkNew plane; + plane->SetOrigin(sw.x, sw.y, base); + plane->SetPoint1(se.x, se.y, base); + plane->SetPoint2(nw.x, nw.y, base); + plane->SetResolution(kTerrainGrid, kTerrainGrid); + plane->Update(); + + auto warped = vtkSmartPointer::New(); + warped->DeepCopy(plane->GetOutput()); + vtkDataArray* tc = warped->GetPointData()->GetTCoords(); + vtkPoints* pts = warped->GetPoints(); + const double sLonSpan = sb.east - sb.west, sLatSpan = sb.north - sb.south; + const double dLonSpan = db.east - db.west, dLatSpan = db.north - db.south; + const vtkIdType n = pts->GetNumberOfPoints(); + for (vtkIdType id = 0; id < n; ++id) { + double t[2]; + tc->GetTuple(id, t); // u:西0东1, v:南0北1 + const double lon = sb.west + t[0] * sLonSpan; + const double lat = sb.south + t[1] * sLatSpan; + const double fx = (lon - db.west) / dLonSpan; // DEM 块内列比例 + const double fy = (db.north - lat) / dLatSpan; // DEM 顶行=北 → fy + const double elev = demElev(dem, fx, fy); + double p[3]; + pts->GetPoint(id, p); + p[2] = base + elev * ve_; // 真实高程×垂直夸张:与剖面(同样真实高程×VE)同系对齐 + pts->SetPoint(id, p); + } + pts->Modified(); + + vtkNew mapper; + mapper->SetInputData(warped); + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + actor->SetTexture(tex); + actor->GetProperty()->LightingOff(); + actor->GetProperty()->SetOpacity(kTerrainOpacity); // 半透明:不遮挡地下剖面 + return actor; // UseBounds 默认 true:参与裁剪面,避免被"蒙版"切掉 +} + +} // namespace geopro::app diff --git a/src/app/TileBasemap.hpp b/src/app/TileBasemap.hpp new file mode 100644 index 0000000..da6e7af --- /dev/null +++ b/src/app/TileBasemap.hpp @@ -0,0 +1,104 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +class vtkActor; +class vtkObject; +class vtkTexture; +class vtkRenderWindow; +class vtkInteractorObserver; +class vtkCallbackCommand; +class QNetworkReply; +namespace geopro::render { class Scene; } +namespace geopro::core { class GeoLocalFrame; } + +namespace geopro::app { + +// 天地图 WMTS 底图层(局部平面,B 方案)+ LOD:按相机视距自动选瓦片层级、覆盖可视范围, +// 缩放/平移结束后增量增删瓦片。复用轨迹图同款 token;瓦片经同一 GeoLocalFrame 配准。 +class TileBasemap : public QObject { + Q_OBJECT +public: + enum Kind { Street = 0, Satellite = 1, Hidden = 2 }; + TileBasemap(geopro::render::Scene& scene, vtkRenderWindow* rw, + std::shared_ptr frame, QObject* parent = nullptr); + ~TileBasemap() override; + + void show(Kind kind); // 显示某底图(Hidden 等同 hide);记住类型供 LOD 刷新复用 + void hide(); // 移除全部瓦片 + void refresh(); // 按当前相机重算层级+覆盖,增量更新瓦片(交互结束回调) + void setVerticalExaggeration(double ve); // 地形垂向夸张(须与剖面 VE 一致才对齐) + // 数据半径提供者:刷新时查询当前所有勾选剖面的合并范围(半径,米),据此动态定底图最大范围。 + void setDataRadiusProvider(std::function fn) { dataRadiusProvider_ = std::move(fn); } + +private: + static long long tileKey(int z, int x, int y); + void ensureObserver(); // 首次显示时挂到交互样式的 EndInteractionEvent + void requestRender(); // 合并渲染:同一事件循环轮次多次请求只渲染一帧 + void purgeStale(); // 本轮请求全部落地后再删旧层瓦片,避免缩放空白闪烁 + // 四叉树细分:按瓦片投影屏幕尺寸递归(近细远粗),收集叶瓦片到 out。 + void refineTile(int z, int x, int y, std::set& out, int& count); + void fetchTile(int z, int x, int y, long long key); + void fetchTerrain(int z, int x, int y, long long key, + vtkSmartPointer tex); // 拉覆盖该瓦片的 DEM(z>15 取祖先块)后落地 + void placeActor(long long key, vtkSmartPointer actor); + vtkSmartPointer buildFlat(int z, int x, int y, + vtkSmartPointer tex); // 平面瓦片(DEM 兜底) + vtkSmartPointer buildWarped(int sz, int sx, int sy, int dz, int dx, int dy, + vtkSmartPointer tex, + const QImage& dem); // DEM 位移网格 + 卫星贴图 + void enqueueGet(const QString& url, + std::function onDone); // 限并发取瓦片(回调内负责 deleteLater) + void pumpNetQueue(); + static void onInteractionEnd(vtkObject*, unsigned long, void* clientData, void*); + + geopro::render::Scene& scene_; + vtkRenderWindow* rw_; + std::shared_ptr frame_; + QNetworkAccessManager nam_; + Kind kind_ = Hidden; + int generation_ = 0; // show/hide/换源 自增,丢弃过期回包 + std::map> placed_; // 已贴瓦片:key→actor + std::set desired_; // 当前视野应显示的瓦片 key + std::set inFlight_; // 在途瓦片(续到起伏/平面最终落地) + std::map demCache_; // DEM 块缓存(key=DEMz/x/y),跨隐藏/重选复用 + std::map> texCache_; // 影像纹理缓存,重选/缩放回看免重拉 + double ve_ = 1.0; // 地形垂向夸张(与剖面 verticalExaggeration 一致才对齐) + double maxTileDist_ = 2000.0; // 底图最大距离(米),每次刷新按剖面范围动态算 + std::function dataRadiusProvider_; // 返回当前勾选剖面合并范围的半径 + // 卫星层「学习到的」最大可用层级:天地图卫星影像各区域覆盖深度不同(内地到z18, 台湾等仅到z16), + // 超出则回固定「此级别下无影像」占位图。检测到占位即把上限降到 z-1 并重铺(改用父层真实影像放大), + // 使该区域不再请求无影像层。show()/换源时复位为 kMaxZoom 以便新区域重新探测。 + int satMaxZoom_ = 18; + // 四叉树当前帧相机参数(refresh 写, refineTile 读):相机位置 + 投影系数 + 视锥 6 面。 + double camX_ = 0, camY_ = 0, camZ_ = 0; + // 底图覆盖中心(相机焦点的局部 XY):四叉树根块取此处经纬、距离剔除以此为心。 + // 关键——不能用世界原点(0,0):frame 锚在首个数据集(如深圳),看远处别处数据(如台湾,相距数百公里)时 + // 原点离视野数百公里→全部瓦片被距离剔除→底图空。改用焦点→底图随视野走(瓦片与数据同 frame 仍对齐)。 + double cenX_ = 0, cenY_ = 0; + double projK_ = 1.0; + bool projParallel_ = false; + double frustum_[24] = {0}; // 6 个视锥平面(内法向),AABB 全在某面外则剔除 + struct PendingGet { QString url; std::function cb; }; + std::deque netQueue_; // 限并发请求队列(防瓦片暴发饱和卡死) + int netInFlight_ = 0; + bool terrainProbed_ = false; // 首次 fetchTerrain 打一行诊断日志 + vtkSmartPointer styleObs_; // 持引用保证回调期有效 + vtkSmartPointer observer_; + bool renderPending_ = false; // 合并渲染:同轮多次请求只渲染一帧 + bool refreshing_ = false; +}; + +} // namespace geopro::app diff --git a/src/app/ToastOverlay.cpp b/src/app/ToastOverlay.cpp new file mode 100644 index 0000000..e30eef6 --- /dev/null +++ b/src/app/ToastOverlay.cpp @@ -0,0 +1,107 @@ +#include "ToastOverlay.hpp" + +#include +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +namespace { + +// ── 自动消失时长(规范 §7.7:3–4s)── +constexpr int kDismissMs = 3500; +// 距窗口内容区底部的留白(引用间距令牌 xxl)。 +constexpr int kBottomMargin = space::kXxl; +// 卡片最大宽度,避免长文案铺满整窗。 +constexpr int kMaxWidth = 480; +// 左侧状态色竖条宽度(规范 §7.7:状态色左竖条,3px)。 +constexpr int kBarWidth = 3; + +} // namespace + +ToastOverlay::ToastOverlay(QWidget* parent) : QWidget(parent) { + setObjectName(QStringLiteral("toastCard")); + setAttribute(Qt::WA_StyledBackground, true); // 令 QSS 背景在 QWidget 上生效 + setCursor(Qt::PointingHandCursor); + hide(); + + // 卡片样式:bg/panel 底 + 1px border/default 边框 + radius/md 圆角;左侧 3px status/info 状态竖条 + // 以「左 border-left」近似;阴影无法用 QSS 绘制,故以边框 + 半透明底近似浮层高度。 + applyTokenizedStyleSheet( + this, QStringLiteral( + "#toastCard { background:{{bg/panel}}; border:1px solid {{border/default}};" + " border-left:%1px solid {{status/info}}; border-radius:%2px; }" + "#toastLabel { color:{{text/primary}}; font-size:%3px; background:transparent;" + " border:none; }") + .arg(kBarWidth) + .arg(radius::kMd) + .arg(scaledPx(type::kBody))); + + auto* lay = new QHBoxLayout(this); + // 左内边距补上状态竖条宽度,文案与竖条留出间隙。 + lay->setContentsMargins(space::kLg + kBarWidth, space::kMd, space::kLg, space::kMd); + lay->setSpacing(space::kMd); + + label_ = new QLabel(this); + label_->setObjectName(QStringLiteral("toastLabel")); + label_->setWordWrap(true); + label_->setMaximumWidth(kMaxWidth); + lay->addWidget(label_); + + // 单次计时器:到时隐藏(复用实例不销毁,规避悬垂指针)。 + timer_ = new QTimer(this); + timer_->setSingleShot(true); + QObject::connect(timer_, &QTimer::timeout, this, [this] { hide(); }); + + // 监听父窗口尺寸/移动,跟随重定位。 + if (parent) parent->installEventFilter(this); +} + +void ToastOverlay::showMessage(const QString& msg) { + if (!label_) return; + label_->setText(msg); + adjustSize(); + reposition(); + raise(); + show(); + timer_->start(kDismissMs); // 重置自动消失计时 +} + +void ToastOverlay::reposition() { + auto* p = parentWidget(); + if (!p) return; + const int x = (p->width() - width()) / 2; + const int y = p->height() - height() - kBottomMargin; + move(qMax(0, x), qMax(0, y)); +} + +bool ToastOverlay::eventFilter(QObject* obj, QEvent* event) { + if (obj == parentWidget() && + (event->type() == QEvent::Resize || event->type() == QEvent::Move)) { + if (isVisible()) reposition(); + } + return QWidget::eventFilter(obj, event); +} + +void ToastOverlay::mousePressEvent(QMouseEvent* event) { + hide(); // 点击立即关闭 + timer_->stop(); + QWidget::mousePressEvent(event); +} + +void showToast(QWidget* anchorWindow, const QString& msg) { + if (!anchorWindow) return; + QWidget* win = anchorWindow->window(); // 取顶层窗口作为锚 + if (!win) return; + + // 同一窗口复用一个 overlay:首次创建并以窗口为父,后续按 objectName 找回直接替换文案。 + auto* overlay = win->findChild(QStringLiteral("toastCard")); + if (!overlay) overlay = new ToastOverlay(win); + overlay->showMessage(msg); +} + +} // namespace geopro::app diff --git a/src/app/ToastOverlay.hpp b/src/app/ToastOverlay.hpp new file mode 100644 index 0000000..e04fde1 --- /dev/null +++ b/src/app/ToastOverlay.hpp @@ -0,0 +1,42 @@ +#pragma once + +// 浮动轻提示(规范 §7.7 Toast):底部居中浮出的小卡片,bg/panel 底 + 1px border/default 边框 +// + 左侧 3px 状态色竖条(默认 status/info)+ 文案;~3500ms 自动消失,点击立即关闭。 +// 纯外观组件,不触碰任何业务/渲染/数据逻辑。 +// +// 实现取「单例可复用」方案:每个顶层窗口共用一个 ToastOverlay 子控件,新消息直接替换当前文案 +// 并重置计时器——避免多实例叠放带来的悬垂指针风险。作为锚窗口的子控件,随窗口移动/缩放自动跟随。 +// Qt QSS 画不出阴影,故以「边框 + 半透明底」近似浮层高度,不强行模拟 box-shadow。 + +#include + +class QLabel; +class QTimer; + +namespace geopro::app { + +// 浮动 Toast 卡片:作为锚窗口的子控件存在,定位在窗口内容区底部居中。 +class ToastOverlay : public QWidget { + Q_OBJECT +public: + explicit ToastOverlay(QWidget* parent); + + // 显示一条提示(替换当前内容并重置自动消失计时器)。 + void showMessage(const QString& msg); + +protected: + bool eventFilter(QObject* obj, QEvent* event) override; // 跟随锚窗口尺寸/移动变化重定位 + void mousePressEvent(QMouseEvent* event) override; // 点击立即关闭 + +private: + void reposition(); // 依据父窗口内容区,置于底部居中 + + QLabel* label_ = nullptr; + QTimer* timer_ = nullptr; +}; + +// 便捷自由函数:在 anchorWindow 上显示一条 Toast。多次调用复用同一个 overlay 实例。 +// anchorWindow 取传入控件的顶层窗口,保证 toast 浮在主窗口内容区上。 +void showToast(QWidget* anchorWindow, const QString& msg); + +} // namespace geopro::app diff --git a/src/app/TopBar.cpp b/src/app/TopBar.cpp index 159bf1b..aba8d82 100644 --- a/src/app/TopBar.cpp +++ b/src/app/TopBar.cpp @@ -10,10 +10,10 @@ #include #include #include -#include #include #include #include +#include #include #include #include @@ -27,9 +27,9 @@ namespace geopro::app { namespace { -// ── 专业图标尺寸(统一放大;菜单栏字号亦同步加大)── -constexpr int kToolIcon = 22; // 工具条右侧图标 -constexpr int kWorkspaceIcon = 20; // 工作空间 / 项目图标 +// ── 工具条图标尺寸(贴近常见桌面客户端:16px 紧凑)── +constexpr int kToolIcon = 16; // 工具条右侧图标 +constexpr int kWorkspaceIcon = 16; // 工作空间 / 项目图标 // 竖直分隔细线。 QFrame* makeDivider(QWidget* parent) @@ -38,7 +38,7 @@ QFrame* makeDivider(QWidget* parent) line->setObjectName(QStringLiteral("topDivider")); line->setFrameShape(QFrame::VLine); line->setFixedWidth(1); - line->setFixedHeight(24); + line->setFixedHeight(20); return line; } @@ -55,6 +55,46 @@ QWidget* makeIconButton(QWidget* parent, Glyph icon, const QString& tip) return btn; } +// 通知红点(规范 §5):在铃铛按钮右上角叠一个 8px 实心圆点作未读指示,token 色 status/danger。 +// 做法:以按钮为父建一个圆形 QLabel,绝对定位到右上角;透明穿透鼠标,不影响按钮 hover/点击。 +// 尺寸随 scaledPx 缩放;按钮 32×32 内右上偏内 2px。当前为常驻静态指示(UI 合规通过即可)。 +void attachNotificationDot(QWidget* bellBtn) +{ + const int dot = scaledPx(8); // 8px 圆点(随字号缩放) + auto* badge = new QLabel(bellBtn); + badge->setObjectName(QStringLiteral("notifDot")); + badge->setAttribute(Qt::WA_TransparentForMouseEvents); // 不拦截按钮点击/hover + badge->setFixedSize(dot, dot); + applyTokenizedStyleSheet( + badge, QStringLiteral("#notifDot { background:{{status/danger}}; border-radius:%1px; }") + .arg(dot / 2)); + // 右上角内收 2px 定位。延迟到布局/缩放生效后再按真实宽度定位(构造期 width 可能尚未确定), + // 避免换字号/重排时红点错位。 + const int margin = scaledPx(2); + auto place = [badge, bellBtn, dot, margin] { + badge->move(bellBtn->width() - dot - margin, margin); + badge->raise(); + badge->show(); + }; + place(); + QTimer::singleShot(0, badge, place); +} + +// 一级菜单工具按钮:纯文字 + 下拉箭头(chevron menu-indicator),InstantPopup 挂菜单。 +// 文字取菜单标题(视图/项目管理/…),样式见 #menuBtn QSS。 +QToolButton* makeMenuButton(QWidget* parent, QMenu* menu) +{ + auto* btn = new QToolButton(parent); + btn->setObjectName(QStringLiteral("menuBtn")); + btn->setText(menu->title()); + btn->setMenu(menu); + btn->setPopupMode(QToolButton::InstantPopup); + btn->setToolButtonStyle(Qt::ToolButtonTextOnly); + btn->setCursor(Qt::PointingHandCursor); + btn->setAutoRaise(true); + return btn; +} + // 圆形头像图标:强调色填充 + 白色缩写。2x 绘制保证高 DPI 清晰。 QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QColor& fg) { @@ -79,48 +119,6 @@ QPixmap renderAvatar(const QString& initials, int px, const QColor& bg, const QC } // ── 四个菜单(结构对齐需求;叶子项当前为静态占位,后续接真实页面)── -QMenu* buildViewMenu(QWidget* p) -{ - auto* m = new QMenu(QStringLiteral("视图"), p); - m->addAction(QStringLiteral("分析视图")); - m->addAction(QStringLiteral("大屏视图")); - return m; -} - -QMenu* buildProjectMenu(QWidget* p) -{ - auto* m = new QMenu(QStringLiteral("项目管理"), p); - m->addAction(QStringLiteral("数据视图")); - auto* cfg = m->addMenu(QStringLiteral("项目配置")); - cfg->addAction(QStringLiteral("基本信息")); - cfg->addAction(QStringLiteral("项目结构")); - cfg->addAction(QStringLiteral("视图配置")); - m->addAction(QStringLiteral("数据管理")); - auto* biz = m->addMenu(QStringLiteral("业务管理")); - biz->addAction(QStringLiteral("异常管理")); - biz->addAction(QStringLiteral("异常体管理")); - auto* mon = m->addMenu(QStringLiteral("在线监测")); - mon->addAction(QStringLiteral("项目设备")); - mon->addAction(QStringLiteral("在线任务管理")); - auto* doc = m->addMenu(QStringLiteral("项目资料管理")); - doc->addAction(QStringLiteral("项目资料管理")); - doc->addAction(QStringLiteral("报告列表")); - auto* tools = m->addMenu(QStringLiteral("工具组件")); - tools->addAction(QStringLiteral("装置与脚本")); - tools->addAction(QStringLiteral("色阶配置")); - tools->addAction(QStringLiteral("异常类型管理")); - tools->addAction(QStringLiteral("模型管理")); - auto* exp = m->addMenu(QStringLiteral("批量导出")); - exp->addAction(QStringLiteral("文件导出")); - exp->addAction(QStringLiteral("报告导出")); - auto* alarm = m->addMenu(QStringLiteral("告警管理")); - alarm->addAction(QStringLiteral("设备告警")); - alarm->addAction(QStringLiteral("告警查询")); - m->addAction(QStringLiteral("自动任务")); - m->addAction(QStringLiteral("模板管理")); - return m; -} - QMenu* buildToolsMenu(QWidget* p) { auto* m = new QMenu(QStringLiteral("业务工具"), p); @@ -141,44 +139,37 @@ QMenu* buildDeviceMenu(QWidget* p) } // namespace -QWidget* buildMenuBar(QWidget* parent) -{ - auto* mb = new QMenuBar(parent); - mb->setObjectName(QStringLiteral("appMenuBar")); - // ElaMenuBar 自绘 Fluent 外观并自动随 ElaTheme 明暗,不再写内联 QSS。 - mb->addMenu(buildViewMenu(mb)); - mb->addMenu(buildProjectMenu(mb)); - mb->addMenu(buildToolsMenu(mb)); - mb->addMenu(buildDeviceMenu(mb)); - return mb; -} - TopBar::TopBar(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("appToolBar")); - setFixedHeight(56); - // 字号引用 Theme 排版令牌:工作空间切换器=title(15)、头像/用户名=body·label(13)、 - // 角色名=caption(12)。原 11px 角色名上调到 12,去掉只差 1px 的糊层级。 + setFixedHeight(40); + // 字号引用 Theme 排版令牌:切换器/一级菜单按钮=body(13)、头像/用户名=body·label(13)、 + // 角色名=caption(12)。工具条整体收紧到常见桌面客户端尺寸(行高 40 / 图标 16 / 字号 13)。 // 切换器(ElaToolButton)/图标(ElaIconButton) 自绘 Fluent,不再写它们的 QSS。 - // 仅保留:工具条底/分隔线、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。 - // 切换器下拉箭头:用生成的高清 chevron PNG 作 menu-indicator(替代旧的粗糙文字箭头),中性灰双主题可读。 + // 仅保留:工具条底/分隔线、一级菜单按钮、头像(圆形自定义)、用户名/角色。头像白字用 {{text/on-primary}} 令牌。 + // 切换器/菜单按钮下拉箭头:用生成的高清 chevron PNG 作 menu-indicator(中性灰双主题可读)。 const QString chevron = geopro::app::writeChevronIcon(true, QColor("#7C8493")); geopro::app::applyTokenizedStyleSheet( this, QStringLiteral( "#appToolBar { background:{{bg/header}}; border-bottom:1px solid {{divider}}; }" "#topDivider { color:{{divider}}; }" "QToolButton::menu-indicator { image:none; }" - "#wsSwitcher { color:{{text/primary}}; border:none; border-radius:8px; padding:8px 26px 8px 12px;" + "#wsSwitcher { color:{{text/primary}}; border:none; border-radius:8px; padding:6px 24px 6px 10px;" " font-size:%6px; font-weight:%4; }" "#wsSwitcher:hover { background:{{bg/hover}}; }" - "#wsSwitcher::menu-indicator { image:url(%7); width:13px; height:13px;" + "#wsSwitcher::menu-indicator { image:url(%7); width:12px; height:12px;" " subcontrol-position: right center; subcontrol-origin: padding; right:8px; }" - "QToolButton#iconBtn { border:none; border-radius:8px; padding:8px; }" + "QToolButton#menuBtn { color:{{text/primary}}; border:none; border-radius:8px;" + " padding:6px 24px 6px 10px; font-size:%3px; font-weight:%4; }" + "QToolButton#menuBtn:hover { background:{{bg/hover}}; }" + "QToolButton#menuBtn::menu-indicator { image:url(%7); width:12px; height:12px;" + " subcontrol-position: right center; subcontrol-origin: padding; right:6px; }" + "QToolButton#iconBtn { border:none; border-radius:8px; padding:6px; }" "QToolButton#iconBtn:hover { background:{{bg/hover}}; }" "#userBtn { border:none; border-radius:8px; padding:4px 10px 4px 6px;" " color:{{text/primary}}; font-size:%3px; }" "#userBtn:hover { background:{{bg/hover}}; }" "#userBtn::menu-indicator { image:none; }" - "#avatar { background:{{accent/primary}}; color:{{text/on-primary}}; border-radius:17px; font-weight:%2;" + "#avatar { background:{{accent/primary}}; color:{{text/on-primary}}; border-radius:15px; font-weight:%2;" " font-size:%1px; }" "#userName { color:{{text/primary}}; font-size:%3px; font-weight:%4; }" "#userRole { color:{{text/tertiary}}; font-size:%5px; }") @@ -187,7 +178,7 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { .arg(scaledPx(type::kLabel)) .arg(type::kWeightSemibold) .arg(scaledPx(type::kCaption)) - .arg(scaledPx(type::kTitle)) + .arg(scaledPx(type::kBody)) .arg(chevron)); auto* lay = new QHBoxLayout(this); @@ -220,10 +211,25 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { projBtn_->setMenu(new QMenu(projBtn_)); lay->addWidget(projBtn_); + lay->addSpacing(10); + lay->addWidget(makeDivider(this)); + lay->addSpacing(10); + + // 一级菜单 → 工具条按钮(视图/项目管理/业务工具/设备),纯文字 + 下拉箭头。 + // 复用原菜单构造器;菜单作为 popup 挂到按钮(按钮文字取菜单标题)。 + lay->addWidget(makeMenuButton(this, buildViewMenu())); + lay->addWidget(makeMenuButton(this, buildProjectMenu())); + lay->addWidget(makeMenuButton(this, buildToolsMenu(this))); + lay->addWidget(makeMenuButton(this, buildDeviceMenu(this))); + lay->addStretch(); lay->addWidget(makeIconButton(this, Glyph::Help, QStringLiteral("帮助"))); - lay->addWidget(makeIconButton(this, Glyph::Bell, QStringLiteral("通知"))); + // 通知铃铛:固定 32×32(规范 §5 图标按钮尺寸),右上角叠常驻未读红点。 + auto* bellBtn = makeIconButton(this, Glyph::Bell, QStringLiteral("通知")); + bellBtn->setFixedSize(scaledPx(32), scaledPx(32)); // §5 图标按钮 32×32(随字号缩放) + attachNotificationDot(bellBtn); + lay->addWidget(bellBtn); auto* gearBtn = makeIconButton(this, Glyph::Gear, QStringLiteral("设置")); if (auto* gb = qobject_cast(gearBtn)) QObject::connect(gb, &QAbstractButton::clicked, this, [this] { emit settingsRequested(); }); @@ -240,13 +246,13 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { userRow_->setCursor(Qt::PointingHandCursor); userRow_->installEventFilter(this); auto* uLay = new QHBoxLayout(userRow_); - uLay->setContentsMargins(8, 3, 8, 3); - uLay->setSpacing(10); + uLay->setContentsMargins(8, 2, 8, 2); + uLay->setSpacing(8); auto* avatar = new QLabel(userRow_); avatar->setPixmap( - renderAvatar(QStringLiteral("ZL"), 34, geopro::app::tokenColor("accent/primary"), Qt::white)); - avatar->setFixedSize(34, 34); + renderAvatar(QStringLiteral("ZL"), 30, geopro::app::tokenColor("accent/primary"), Qt::white)); + avatar->setFixedSize(30, 30); avatar->setAttribute(Qt::WA_TransparentForMouseEvents); uLay->addWidget(avatar, 0, Qt::AlignVCenter); @@ -284,6 +290,49 @@ TopBar::TopBar(QWidget* parent) : QWidget(parent) { lay->addWidget(userRow_); } +// 视图菜单。「分析视图」=默认工作台(emit analysisViewRequested,中央区从 web 整窗切回工作台); +// 「大屏视图」当前为占位。 +QMenu* TopBar::buildViewMenu() { + auto* m = new QMenu(QStringLiteral("视图"), this); + QObject::connect(m->addAction(QStringLiteral("分析视图")), &QAction::triggered, this, + [this] { emit analysisViewRequested(); }); + m->addAction(QStringLiteral("大屏视图")); + return m; +} + +// 项目管理菜单。仅保留需「直接嵌入」的 web 页(Excel「单个项目」页签第 10~21 行带嵌入地址者): +// 在线监测 / 工具组件 / 批量导出 / 告警管理,点击 emit webPageRequested,由 main 独立整窗加载。 +// 其余全部隐藏(数据视图、项目配置、数据管理、业务管理、项目资料管理、自动任务、模板管理 …)。 +QMenu* TopBar::buildProjectMenu() { + auto* m = new QMenu(QStringLiteral("项目管理"), this); + // web 叶子项:携带 target 路径,点击发信号。 + auto addWeb = [this](QMenu* parent, const QString& title, const QString& target) { + auto* a = parent->addAction(title); + QObject::connect(a, &QAction::triggered, this, + [this, title, target] { emit webPageRequested(title, target); }); + }; + + auto* mon = m->addMenu(QStringLiteral("在线监测")); + addWeb(mon, QStringLiteral("项目设备"), QStringLiteral("/projectSpace/onlineMonitor/projectDevice")); + addWeb(mon, QStringLiteral("在线任务管理"), QStringLiteral("/projectSpace/onlineMonitor/onlineTask")); + + auto* tools = m->addMenu(QStringLiteral("工具组件")); + addWeb(tools, QStringLiteral("装置与脚本"), QStringLiteral("/projectSpace/toolComponent/deviceScript")); + addWeb(tools, QStringLiteral("色阶配置"), QStringLiteral("/projectSpace/toolComponent/levelConfigure")); + addWeb(tools, QStringLiteral("异常类型管理"), QStringLiteral("/projectSpace/toolComponent/exceptionType")); + addWeb(tools, QStringLiteral("模型管理"), QStringLiteral("/projectSpace/toolComponent/modelManage")); + + auto* exp = m->addMenu(QStringLiteral("批量导出")); + addWeb(exp, QStringLiteral("文件导出"), QStringLiteral("/projectSpace/bulkExport/fileExport")); + addWeb(exp, QStringLiteral("报告导出"), QStringLiteral("/projectSpace/bulkExport/templateExport")); + + auto* alarm = m->addMenu(QStringLiteral("告警管理")); + addWeb(alarm, QStringLiteral("设备告警"), QStringLiteral("/projectSpace/alarmManage/deviceAlarm")); + addWeb(alarm, QStringLiteral("告警查询"), QStringLiteral("/projectSpace/alarmManage/alarmQuery")); + + return m; +} + bool TopBar::eventFilter(QObject* obj, QEvent* event) { if (obj == userRow_ && event->type() == QEvent::MouseButtonRelease) { if (userMenu_) diff --git a/src/app/TopBar.hpp b/src/app/TopBar.hpp index 87315d0..e19478a 100644 --- a/src/app/TopBar.hpp +++ b/src/app/TopBar.hpp @@ -9,10 +9,7 @@ class QMenu; namespace geopro::app { -// 顶部菜单栏(静态,本轮不接真实页面)。 -QWidget* buildMenuBar(QWidget* parent); - -// 顶部工具条:数据驱动的工作空间/项目切换器 + 右侧图标 + 用户区。 +// 顶部工具条:数据驱动的工作空间/项目切换器 + 一级菜单按钮 + 右侧图标 + 用户区。 class TopBar : public QWidget { Q_OBJECT public: @@ -32,8 +29,14 @@ signals: void allProjectsRequested(); // 点击"全部项目…" void logoutRequested(); // 头像菜单「退出登录」 void settingsRequested(); // 点击齿轮图标 → 打开设置 + // 项目管理菜单中「直接嵌入」的 web 页被点击:title=窗口标题,target=嵌入页 target 路径。 + void webPageRequested(const QString& title, const QString& target); + void analysisViewRequested(); // 视图菜单「分析视图」→ 中央区切回默认工作台 private: + QMenu* buildViewMenu(); // 视图菜单(成员:「分析视图」需 emit 信号) + QMenu* buildProjectMenu(); // 项目管理菜单(成员:webview 叶子项需 emit 信号) + QToolButton* wsBtn_ = nullptr; QToolButton* projBtn_ = nullptr; QWidget* userRow_ = nullptr; // 用户区整块(头像+姓名/职务+箭头) diff --git a/src/app/VolumeParamsDialog.cpp b/src/app/VolumeParamsDialog.cpp new file mode 100644 index 0000000..fd1f131 --- /dev/null +++ b/src/app/VolumeParamsDialog.cpp @@ -0,0 +1,249 @@ +#include "VolumeParamsDialog.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include "FormKit.hpp" +#include "Theme.hpp" + +namespace geopro::app { + +namespace { +// 默认值与 data::VolumeBuildParams 同口径(保持单一真相)。 +constexpr double kDefCellXY = 1.0; +constexpr double kDefCellZ = 0.5; +constexpr double kDefPower = 2.0; +constexpr double kDefMaxDist = 0.0; // 0=自动「覆盖测区」(全数据 IDW + 凸包足迹裁剪,对齐 Surfer) +constexpr int kRoleDsId = Qt::UserRole + 1; // 源树项存 dsId +constexpr int kRoleMountId = Qt::UserRole + 1; // 生成位置树项存 id +constexpr int kRoleMountConfType = Qt::UserRole + 2; // 生成位置树项存 confType +} // namespace + +VolumeParamsDialog::VolumeParamsDialog(const QVector& sources, + const std::vector& structure, + const QString& defaultMountId, QWidget* parent) + : QDialog(parent) { + setWindowTitle(QStringLiteral("生成三维体")); + setModal(true); + + auto* root = formkit::dialogRoot(this); + + auto* intro = new QLabel(QStringLiteral("勾选参与生成的源数据集,设置参数与生成位置")); + geopro::app::applyTokenizedStyleSheet(intro, QStringLiteral("color:{{text/secondary}};")); + root->addWidget(intro); + + auto* cols = new QHBoxLayout(); + + // ── 左:源数据集树(项目/GS/TM 容器 + 可勾选源 ds,挂到各自结构归属下)── + auto* leftCard = formkit::formCard(this); + auto* leftLay = formkit::cardBody(leftCard); + formkit::addSection(leftLay, QStringLiteral("源数据集"), leftCard, false); + sourceTree_ = new QTreeWidget(); + sourceTree_->setHeaderHidden(true); + sourceTree_->setIndentation(14); + { + std::map byId; + for (const auto& n : structure) byId[n.id] = &n; + // 仅保留「源 ds 结构归属」向上的祖先链容器(树剪枝,不展示无关 GS/TM)。 + std::set keep; + for (const auto& s : sources) { + std::string p = s.structParentId.toStdString(); + while (!p.empty() && byId.count(p) && !keep.count(p)) { + keep.insert(p); + p = byId[p]->parentId; + } + } + QHash cont; // 容器 id → item + for (const auto& n : structure) + if (keep.count(n.id)) { + auto* it = new QTreeWidgetItem(); + it->setText(0, QString::fromStdString(n.name)); + cont.insert(QString::fromStdString(n.id), it); + } + for (const auto& n : structure) + if (keep.count(n.id)) { + auto* it = cont.value(QString::fromStdString(n.id)); + auto* par = cont.value(QString::fromStdString(n.parentId), nullptr); + if (par) + par->addChild(it); + else + sourceTree_->addTopLevelItem(it); + } + for (const auto& s : sources) { + auto* it = new QTreeWidgetItem(); + it->setText(0, s.name.isEmpty() ? s.id : s.name); + it->setData(0, kRoleDsId, s.id); + it->setFlags(it->flags() | Qt::ItemIsUserCheckable); + it->setCheckState(0, Qt::Checked); // 默认全勾(=传入的当前勾选源) + auto* par = cont.value(s.structParentId, nullptr); + if (par) + par->addChild(it); + else + sourceTree_->addTopLevelItem(it); + } + } + sourceTree_->expandAll(); + leftLay->addWidget(sourceTree_); + cols->addWidget(leftCard, 1); + + // ── 右:参数 ── + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + formkit::addSection(cardLay, QStringLiteral("参数"), card, false); + auto* form = formkit::makeEditForm(); + + name_ = new QLineEdit(QStringLiteral("三维体")); + formkit::capField(name_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), name_); + cardLay->addLayout(form); + + auto* form2 = formkit::makeEditForm(); + + // 生成位置:下拉框,下拉面板=GS/项目根/TM 层级树(QComboBox + QTreeView 视图 + 树模型)。 + mount_ = new QComboBox(); + { + auto* model = new QStandardItemModel(mount_); + QHash mItems; + for (const auto& n : structure) { + auto* it = new QStandardItem(QString::fromStdString(n.name)); + it->setEditable(false); + it->setData(QString::fromStdString(n.id), kRoleMountId); + it->setData(n.type == 2 ? 2 : 1, kRoleMountConfType); + mItems.insert(QString::fromStdString(n.id), it); + } + for (const auto& n : structure) { + auto* it = mItems.value(QString::fromStdString(n.id)); + auto* par = mItems.value(QString::fromStdString(n.parentId), nullptr); + if (par) + par->appendRow(it); + else + model->appendRow(it); + } + auto* view = new QTreeView(mount_); + view->setHeaderHidden(true); + view->setItemsExpandable(true); + mount_->setModel(model); + mount_->setView(view); + view->expandAll(); + // 关键:让 combo **自身**按内容加宽(对话框随布局一起变宽)→ popup 宽 = combo 宽,落在对话框内 + // 不外溢;只加宽 popup 而不加宽 combo 会让浮窗比对话框还宽、超出右缘(用户反馈)。 + // 宽度 = 最长节点名文本宽 + 余量(树缩进/展开箭头/滚动条/combo 下拉箭头/内距)。 + int maxTextW = 0; + const QFontMetrics fm = view->fontMetrics(); + for (const auto& n : structure) + maxTextW = std::max(maxTextW, fm.horizontalAdvance(QString::fromStdString(n.name))); + const int contentW = std::max(geopro::app::scaledPx(200), maxTextW + geopro::app::scaledPx(80)); + mount_->setMinimumWidth(contentW); // combo 加宽 → 对话框变宽 + view->setMinimumWidth(contentW); // popup 与 combo 同宽 → 不外溢 + // 默认选中:指定的 defaultMountId,否则首个节点。view 当前项决定 mountTargetId;combo 显示尽力。 + QStandardItem* def = mItems.value(defaultMountId, nullptr); + if (!def && model->rowCount() > 0) def = model->item(0); + if (def) { + view->setCurrentIndex(def->index()); // 决定 mountTargetId/mountConfType 返回值 + mount_->setCurrentText(def->text()); // 顶层项可正确显示;嵌套项尽力(功能不受影响) + } + } + formkit::capField(mount_); + form2->addRow(formkit::editLabel(QStringLiteral("生成位置")), mount_); + model_ = new EmptyAwareComboBox(); + model_->addItem(QStringLiteral("反距离加权 (IDW)"), + static_cast(geopro::data::VolumeBuildParams::Model::Idw)); + model_->addItem(QStringLiteral("克里金 (Kriging)"), + static_cast(geopro::data::VolumeBuildParams::Model::Kriging)); + if (auto* m = qobject_cast(model_->model())) { + if (auto* it = m->item(1)) it->setEnabled(false); // 克里金本期未实现 → 禁用 + } + model_->setCurrentIndex(0); + formkit::capField(model_); + form2->addRow(formkit::editLabel(QStringLiteral("插值模型")), model_); + + auto makeSpin = [this](double val, double min, double max, double step, int decimals) { + auto* s = new QDoubleSpinBox(); + s->setRange(min, max); + s->setSingleStep(step); + s->setDecimals(decimals); + s->setValue(val); + formkit::capField(s); + return s; + }; + cellXY_ = makeSpin(kDefCellXY, 0.01, 1000.0, 0.5, 2); + cellZ_ = makeSpin(kDefCellZ, 0.01, 1000.0, 0.5, 2); + power_ = makeSpin(kDefPower, 0.5, 6.0, 0.5, 1); + maxDist_ = makeSpin(kDefMaxDist, 0.0, 10000.0, 1.0, 2); + // maxDist=0(=最小值)→ 显示「自动」:全数据 IDW + 凸包足迹裁剪填满测区(对齐客户 Surfer); + // >0 → 局部 IDW 半径(剖面附近清晰、跨大空隙可能填不满)。 + maxDist_->setSpecialValueText(QStringLiteral("自动 (覆盖测区)")); + form2->addRow(formkit::editLabel(QStringLiteral("水平间距 (米)")), cellXY_); + form2->addRow(formkit::editLabel(QStringLiteral("竖向间距 (米)")), cellZ_); + form2->addRow(formkit::editLabel(QStringLiteral("IDW 幂次")), power_); + form2->addRow(formkit::editLabel(QStringLiteral("最大影响距离 (米, 0=自动)")), maxDist_); + cardLay->addLayout(form2); + + cols->addWidget(card, 1); + root->addLayout(cols); + + formkit::addDialogButtons(root, this, QStringLiteral("生成"), QStringLiteral("取消")); +} + +QString VolumeParamsDialog::volumeName() const { + const QString n = name_->text().trimmed(); + return n.isEmpty() ? QStringLiteral("三维体") : n; +} + +geopro::data::VolumeBuildParams VolumeParamsDialog::params() const { + geopro::data::VolumeBuildParams p; + p.interpModel = static_cast(model_->currentData().toInt()); + p.cellXY = cellXY_->value(); + p.cellZ = cellZ_->value(); + p.power = power_->value(); + p.maxDist = maxDist_->value(); + return p; +} + +QStringList VolumeParamsDialog::sourceDatasetIds() const { + QStringList ids; + for (QTreeWidgetItemIterator it(sourceTree_); *it; ++it) { + const QString id = (*it)->data(0, kRoleDsId).toString(); + if (!id.isEmpty() && (*it)->checkState(0) == Qt::Checked) ids << id; + } + return ids; +} + +namespace { +// 从生成位置下拉的树视图取当前选中项(树模型下比 combo->currentData 可靠)。 +QModelIndex mountCurrentIndex(QComboBox* mount) { + auto* view = qobject_cast(mount->view()); + return view ? view->currentIndex() : QModelIndex(); +} +} // namespace + +QString VolumeParamsDialog::mountTargetId() const { + const QModelIndex idx = mountCurrentIndex(mount_); + return idx.isValid() ? idx.data(kRoleMountId).toString() : QString(); +} + +int VolumeParamsDialog::mountConfType() const { + const QModelIndex idx = mountCurrentIndex(mount_); + const int ct = idx.isValid() ? idx.data(kRoleMountConfType).toInt() : 0; + return ct == 0 ? 1 : ct; // 缺省按 GS/项目根 +} + +} // namespace geopro::app diff --git a/src/app/VolumeParamsDialog.hpp b/src/app/VolumeParamsDialog.hpp new file mode 100644 index 0000000..98cbf86 --- /dev/null +++ b/src/app/VolumeParamsDialog.hpp @@ -0,0 +1,53 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "repo/RepoTypes.hpp" // StructNode +#include "repo/VolumeBuildParams.hpp" + +class QLineEdit; +class QComboBox; +class QDoubleSpinBox; +class QTreeWidget; + +namespace geopro::app { + +// 源数据集(参与生成):id + 显示名 + 结构归属(structParentId,用于在左侧树里挂到其 GS/TM 下)。 +struct VolumeSourceItem { + QString id; + QString name; + QString structParentId; +}; + +// 「生成三维体」对话框(spec §7/§8):左侧·源数据集**树**(项目/GS/TM/ds,可勾选/取消); +// 右侧·参数(名称 + 生成位置**树**[GS/项目根/TM 层级单选] + 插值模型 + cellXY/cellZ/power/maxDist)。 +class VolumeParamsDialog : public QDialog { + Q_OBJECT +public: + // sources:参与生成的源 ds(含结构归属);structure:项目结构(GS/TM),左右两树共用; + // defaultMountId:默认选中的生成位置 id(空=选首个)。 + VolumeParamsDialog(const QVector& sources, + const std::vector& structure, + const QString& defaultMountId, QWidget* parent = nullptr); + + QString volumeName() const; + geopro::data::VolumeBuildParams params() const; + QStringList sourceDatasetIds() const; // 左侧树最终勾选的源 ds id + QString mountTargetId() const; // 生成位置 id(structParentId) + int mountConfType() const; // 生成位置 confType(1=GS/项目根 2=TM) + +private: + QLineEdit* name_ = nullptr; + QComboBox* model_ = nullptr; + QDoubleSpinBox* cellXY_ = nullptr; + QDoubleSpinBox* cellZ_ = nullptr; + QDoubleSpinBox* power_ = nullptr; + QDoubleSpinBox* maxDist_ = nullptr; + QTreeWidget* sourceTree_ = nullptr; // 左:源数据集树(可勾选) + QComboBox* mount_ = nullptr; // 右:生成位置下拉(下拉面板是 GS/项目根/TM 层级树) +}; + +} // namespace geopro::app diff --git a/src/app/VolumePropertiesDialog.cpp b/src/app/VolumePropertiesDialog.cpp new file mode 100644 index 0000000..27893ec --- /dev/null +++ b/src/app/VolumePropertiesDialog.cpp @@ -0,0 +1,69 @@ +#include "VolumePropertiesDialog.hpp" + +#include + +#include "FormKit.hpp" + +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& 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("三维体属性")); + + formkit::DetailForm form; + // ── 参数(随时可取)───────────────────────────────────────────── + form.group(QStringLiteral("参数")) + .row(QStringLiteral("名称"), name.isEmpty() ? QStringLiteral("—") : name) + .row(QStringLiteral("源数据集"), joinSources(info.params.sourceDatasetIds)) + .row(QStringLiteral("插值模型"), modelLabel(info.params)) + .row(QStringLiteral("网格间距"), QStringLiteral("XY=%1 m Z=%2 m") + .arg(info.params.cellXY, 0, 'f', 2) + .arg(info.params.cellZ, 0, 'f', 2)) + .row(QStringLiteral("超距"), info.params.maxDist > 0.0 + ? QStringLiteral("%1 m").arg(info.params.maxDist, 0, 'f', 2) + : QStringLiteral("自动 (覆盖测区)")) + .row(QStringLiteral("色阶来源"), + info.params.colorScaleId.empty() ? QStringLiteral("首个源数据集") + : QString::fromStdString(info.params.colorScaleId)); + + // ── 统计(仅 loaded 时有效)────────────────────────────────────── + if (info.loaded) { + form.group(QStringLiteral("统计")) + .row(QStringLiteral("值域"), + QStringLiteral("%1 ~ %2").arg(info.vmin, 0, 'f', 2).arg(info.vmax, 0, 'f', 2)) + .row(QStringLiteral("网格"), + QStringLiteral("%1 × %2 × %3").arg(info.nx).arg(info.ny).arg(info.nz)) + .row(QStringLiteral("测点数"), QString::number(static_cast(info.pointCount))) + .row(QStringLiteral("范围"), 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.group(QStringLiteral("统计")).row(QStringLiteral("统计"), QString::fromUtf8(kPending)); + } + + formkit::buildDetailDialog(this, form.build()); +} + +} // namespace geopro::app diff --git a/src/app/VolumePropertiesDialog.hpp b/src/app/VolumePropertiesDialog.hpp new file mode 100644 index 0000000..dc33e21 --- /dev/null +++ b/src/app/VolumePropertiesDialog.hpp @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +#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 diff --git a/src/app/VtkSceneView.cpp b/src/app/VtkSceneView.cpp new file mode 100644 index 0000000..e27753c --- /dev/null +++ b/src/app/VtkSceneView.cpp @@ -0,0 +1,554 @@ +#include "VtkSceneView.hpp" + +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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" +#include "actors/TerrainActor.hpp" +#include "actors/VoxelActor.hpp" +#include "geo/GeoLocalFrame.hpp" + +namespace geopro::app { + +namespace { +// 运行时改某体的最大不透明度:把其不透明度传递函数「最高 x 的点」(=vmax/qmax 处的最大不透明度点) +// 的不透明度值设为 maxOpacity。不重建体、不动颜色与留空透明点,实时生效。 +void applyVolumeOpacity(vtkVolume* v, double maxOpacity) { + if (!v || !v->GetProperty()) return; + vtkPiecewiseFunction* op = v->GetProperty()->GetScalarOpacity(); + if (!op) return; + const int n = op->GetSize(); + if (n <= 0) return; + double node[4]; // {x, y(opacity), midpoint, sharpness} + op->GetNodeValue(n - 1, node); + node[1] = maxOpacity; + op->SetNodeValue(n - 1, node); +} + +// 控制器层枚举 → render 层枚举(保持控制器不依赖 render)。 +geopro::render::AxesMode toRenderMode(geopro::controller::AxesMode m) { + switch (m) { + case geopro::controller::AxesMode::Standard: return geopro::render::AxesMode::Standard; + case geopro::controller::AxesMode::Stereo: return geopro::render::AxesMode::Stereo; + case geopro::controller::AxesMode::None: return geopro::render::AxesMode::None; + } + return geopro::render::AxesMode::Standard; +} +geopro::render::AxesUnit toRenderUnit(geopro::controller::AxesUnit u) { + switch (u) { + case geopro::controller::AxesUnit::None: return geopro::render::AxesUnit::None; + case geopro::controller::AxesUnit::Meter: return geopro::render::AxesUnit::Meter; + case geopro::controller::AxesUnit::Feet: return geopro::render::AxesUnit::Feet; + case geopro::controller::AxesUnit::LatLon: return geopro::render::AxesUnit::LatLon; + } + return geopro::render::AxesUnit::Meter; +} +geopro::render::ViewDir toRenderViewDir(geopro::controller::ViewDir d) { + switch (d) { + case geopro::controller::ViewDir::Front: return geopro::render::ViewDir::Front; + case geopro::controller::ViewDir::Back: return geopro::render::ViewDir::Back; + case geopro::controller::ViewDir::Left: return geopro::render::ViewDir::Left; + case geopro::controller::ViewDir::Right: return geopro::render::ViewDir::Right; + case geopro::controller::ViewDir::Top: return geopro::render::ViewDir::Top; + case geopro::controller::ViewDir::Bottom: return geopro::render::ViewDir::Bottom; + } + return geopro::render::ViewDir::Front; +} +} // namespace + +VtkSceneView::VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow, + std::shared_ptr frame, double zRefElev) + : scene_(scene), + renderWindow_(renderWindow), + frame_(std::move(frame)), + zRefElev_(zRefElev) { + // 近裁剪容差调小:场景含近处剖面 + 远处底图(几十km),默认容差会把近裁剪面随远面推出去、 + // 切掉离相机近的剖面。调小后近面可贴近,剖面不被切(代价:远处深度精度略降,不可见层无所谓)。 + scene_.renderer()->SetNearClippingPlaneTolerance(1e-5); +} + +void VtkSceneView::removeProps(std::vector>& props) { + for (auto& p : props) + if (p) scene_.renderer()->RemoveViewProp(p); + props.clear(); +} + +bool VtkSceneView::computeDataBounds(double out[6]) const { + // 仅计「可见」prop:二维分析下 3D 体/帘面已隐藏,取景/坐标轴/底图范围都应只围当前可见维度, + // 否则二维取景被隐藏的远处 3D 体撑歪、坐标轴框错维度。 + vtkBoundingBox bb; + for (const auto& kv : dsProps_) + for (const auto& p : kv.second) + if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); } + for (const auto& p : miscProps_) + if (p && p->GetVisibility()) { if (double* b = p->GetBounds()) bb.AddBounds(b); } + if (!bb.IsValid()) return false; + bb.GetBounds(out); + return true; +} + +double VtkSceneView::dataHorizontalRadius() const { + double b[6]; + if (!computeDataBounds(b)) return 0.0; + const double dx = b[1] - b[0], dy = b[3] - b[2]; + return 0.5 * std::sqrt(dx * dx + dy * dy); // 水平对角线半径 +} + +void VtkSceneView::clear() { + // 只移除数据 prop(按 ds 跟踪)+ 杂项(地形/测线)+ 坐标轴;不动底图(TileBasemap 自管)→ 重建不丢图。 + for (auto& kv : dsProps_) removeProps(kv.second); + dsProps_.clear(); + mapLineDs_.clear(); // 2D 足迹维度记录随数据图元一并清(模式标志保留) + selectedMapLines_.clear(); // 选中态随图元清(actor 已销毁);Z 偏移 mapLineZOffset_ 保留→重建后复位高度 + removeProps(miscProps_); + clearAnomalies(); // 异常 actor 随清场一并移除 + if (currentAxes_) { + scene_.renderer()->RemoveViewProp(currentAxes_); + currentAxes_ = nullptr; + } + // 体素 image 失效:置空并通知上层关闭切片(防切片附着到已移除的 image)。 + currentVolumeImage_ = nullptr; + volumeOwnerDs_.clear(); + volumes_.clear(); // 多体并发:清场移除所有体 image + frameAnchoredToData_ = false; // 新一轮选择重新按其首个真实剖面重锚原点 + if (onVolumeChanged) onVolumeChanged(); +} + +void VtkSceneView::setVerticalExaggeration(double ve) { verticalExaggeration_ = ve; } + +void VtkSceneView::setVolumeOpacity(double maxOpacity) { + volumeOpacity_ = std::clamp(maxOpacity, 0.0, 1.0); // 记为后续新体默认 + for (auto& kv : volumes_) // 实时更新所有已渲染体(不重建) + applyVolumeOpacity(kv.second.volume, volumeOpacity_); + if (renderWindow_) renderWindow_->Render(); +} + +void VtkSceneView::addSurveyLine(const geopro::core::Grid& grid) { + auto line = geopro::render::buildSurveyLine(grid, *frame_); + if (line) { + scene_.addActor(line); + miscProps_.push_back(line); + } +} + +void VtkSceneView::anchorFrameIfNeeded(const std::vector& lat, + const std::vector& lon, int n) { + // 首个带经纬数据到达 → 把 GeoLocalFrame 原点重锚到其 lat/lon 包围盒中心:使局部坐标从 0 附近起 + // (轴刻度有意义),同一选择内多条剖面/足迹共用此原点 → 相互地理配准。已锚或无经纬则保持不动。 + if (frameAnchoredToData_ || n < 1) return; + if (static_cast(lat.size()) < n || static_cast(lon.size()) < n) return; + double la0 = lat[0], la1 = lat[0], lo0 = lon[0], lo1 = lon[0]; + for (int i = 1; i < n; ++i) { + la0 = std::min(la0, lat[i]); la1 = std::max(la1, lat[i]); + lo0 = std::min(lo0, lon[i]); lo1 = std::max(lo1, lon[i]); + } + // 就地重锚共享 frame(不换对象)→ 同持此 frame 的底图层等随即一致对齐。 + frame_->reanchor((la0 + la1) / 2.0, (lo0 + lo1) / 2.0); + frameAnchoredToData_ = true; + if (onFrameReanchored) onFrameReanchored(); // 通知底图刷新到数据位置 +} + +void VtkSceneView::addCurtain(const std::string& dsId, const geopro::core::Grid& grid, + const geopro::core::ColorScale& cs) { + anchorFrameIfNeeded(grid.lat, grid.lon, grid.nx()); // 首个带经纬剖面 → 重锚原点 + auto curtain = geopro::render::buildCurtain(grid, cs, *frame_); + if (curtain) { + curtain->SetScale(1.0, 1.0, verticalExaggeration_); // 纵向夸张成墙 + curtain->SetVisibility(analysisMode2D_ ? 0 : 1); // 帘面=3D内容:二维分析下隐藏 + scene_.addActor(curtain); + dsProps_[dsId].push_back(curtain); + } +} + +void VtkSceneView::addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, + const geopro::core::ColorScale& cs) { + // 纵向夸张烤进 image 的 z 原点/间距(与帘面 SetScale 同倍,保证纵向一致)。 + // 用暴露 image 的 buildVoxel 重载:保留 currentVolumeImage_ 供 P3 切片附着(几何含 VE)。 + vtkSmartPointer image; + auto volume = geopro::render::buildVoxel( + vol.vol, cs, vol.origin[0], vol.origin[1], vol.origin[2] * verticalExaggeration_, + vol.spacing[0], vol.spacing[1], vol.spacing[2] * verticalExaggeration_, vol.vmin, vol.vmax, + image); + if (volume) { + // 体 actor 不参与拾取:切片选中靠点中切片平面(widget 交互/拾取)。否则点击落到体内部时 + // picker 命中体、worldPoint 落体内 → nearestSlice 按平面距离选错切片(用户 ④ 串选)。 + volume->PickableOff(); + volume->SetVisibility(analysisMode2D_ ? 0 : 1); // 体=3D内容:二维分析下隐藏 + applyVolumeOpacity(volume, volumeOpacity_); // 套用当前透明度(工具条调过则新体跟随) + scene_.addViewProp(volume); + dsProps_[dsId].push_back(volume); + currentVolumeImage_ = image; + currentColorScale_ = cs; + currentVmin_ = vol.vmin; + currentVmax_ = vol.vmax; + volumeOwnerDs_ = dsId; + volumes_[dsId] = VolumeRec{image, cs, vol.vmin, vol.vmax, volume}; // 多体并发:登记本体 image+actor + + // G3 等值面:在值域高段(0.7)抽不透明实心异常体(参考图红块)。挂同一 dsProps_ → 随体一并移除。 + const double isoVal = vol.vmin + 0.7 * (vol.vmax - vol.vmin); + auto iso = geopro::render::buildIsosurface(image, cs, vol.vmin, vol.vmax, isoVal); + if (iso) { + iso->PickableOff(); // 不参与拾取(同体 actor,避免串选) + iso->SetVisibility(analysisMode2D_ ? 0 : 1); // 3D 内容:二维分析下隐藏 + scene_.addActor(iso); + dsProps_[dsId].push_back(iso); + } + if (onVolumeChanged) onVolumeChanged(); + } +} + +void VtkSceneView::addMapLine(const std::string& dsId, const geopro::data::MapLine& line, + double worldZ) { + // 2D 足迹:经共享 frame 投影到世界 XY、Z=worldZ。按 dsId 跟踪(与帘面同 dsProps_ → removeDataset 复用)。 + // worldZ 已是最终世界高程(含摆放语义),不再施加 VE(足迹是水平线,非随深度的竖直图元)。 + // 足迹可能是首个(且唯一)带经纬的数据 → 与帘面同样重锚原点,否则按样本默认原点投到数百公里外不可见。 + anchorFrameIfNeeded(line.lat, line.lon, static_cast(line.lat.size())); + auto actor = geopro::render::buildMapLine(line.lat, line.lon, worldZ, *frame_); + if (actor) { + actor->SetVisibility(analysisMode2D_ ? 1 : 0); // 足迹=2D内容:仅二维分析下显示 + auto off = mapLineZOffset_.find(dsId); // B 期:复用持久 Z 偏移(全量重建后仍在该高度) + if (off != mapLineZOffset_.end()) actor->AddPosition(0.0, 0.0, off->second); + scene_.addActor(actor); + dsProps_[dsId].push_back(actor); + mapLineDs_.insert(dsId); // 记录此 ds 为 2D 足迹(切 tab 按维度翻可见) + } +} + +void VtkSceneView::addTerrain(const geopro::data::TerrainPaths& paths) { + auto terrain = geopro::render::buildTerrain(paths.demPath, paths.imagePath, *frame_, zRefElev_, + verticalExaggeration_); + if (terrain) { + scene_.addActor(terrain); + miscProps_.push_back(terrain); + } +} + +void VtkSceneView::removeDataset(const std::string& dsId) { + auto it = dsProps_.find(dsId); + if (it == dsProps_.end()) return; + removeProps(it->second); + dsProps_.erase(it); + mapLineDs_.erase(dsId); // 若是 2D 足迹则同步去除维度记录 + // 场景已无任何数据图元 → 复位重锚标志:下个数据(可能在别处)重新把 frame 锚到它,底图随之归位。 + // 否则删到空再加远处新数据时,新数据按旧锚点投到偏远世界坐标、底图仍贴在旧位置 → 底图"消失"。 + if (dsProps_.empty()) frameAnchoredToData_ = false; + const bool wasVolume = volumes_.erase(dsId) > 0; + if (volumeOwnerDs_ == dsId) { // 移除的是"当前体" → currentImage 回退到剩余某体,无则置空 + if (!volumes_.empty()) { + const auto& last = *volumes_.rbegin(); + volumeOwnerDs_ = last.first; + currentVolumeImage_ = last.second.image; + currentColorScale_ = last.second.cs; + currentVmin_ = last.second.vmin; + currentVmax_ = last.second.vmax; + } else { + currentVolumeImage_ = nullptr; + volumeOwnerDs_.clear(); + } + } + if (wasVolume && onVolumeChanged) onVolumeChanged(); // 任一体移除 → 上层多体同步切片 +} + +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; + axesUnit_ = unit; + axesFontSize_ = fontSize; +} + +void VtkSceneView::setAxesRanges(const geopro::controller::AxisRangeCfg& x, + const geopro::controller::AxisRangeCfg& y, + const geopro::controller::AxisRangeCfg& z) { + axisX_ = x; + axisY_ = y; + axisZ_ = z; +} + +void VtkSceneView::applyCameraView(geopro::controller::ViewDir dir) { + geopro::render::applyView(scene_.renderer(), toRenderViewDir(dir)); // 设朝向(内部 ResetCamera 含底图) + double bounds[6]; + if (computeDataBounds(bounds)) + scene_.renderer()->ResetCamera(bounds); // 重新取景到数据(否则被~公里级底图推到超远) + scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 相机变了 → 底图按新视锥重算覆盖 +} + +void VtkSceneView::zoom(double factor) { + geopro::render::zoomBy(scene_.renderer(), factor); + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); +} + +void VtkSceneView::fitView() { + double bounds[6]; + if (computeDataBounds(bounds)) + scene_.renderer()->ResetCamera(bounds); // 取景到数据(不含底图) + else + geopro::render::fitView(scene_.renderer()); + scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉 + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 取景后 → 底图按新视锥重算覆盖(治首帧部分瓦片不出) +} + +void VtkSceneView::setAnalysisMode2D(bool is2D) { + if (is2D == analysisMode2D_) return; // 幂等:同模式重复切不做事 + analysisMode2D_ = is2D; + if (!is2D) clearMapLineSelection(); // 离开二维分析:清足迹选中(三维下不可拖 Z);Z 偏移仍持久 + + // ① 按维度翻可见标志(不清空、不重建→切换瞬时):2D 足迹↔3D 帘面/体;异常属 3D。 + // 地形/测线(miscProps_)与底图(TileBasemap 自管)两边常驻、不动。 + for (auto& kv : dsProps_) { + const bool is2dContent = mapLineDs_.count(kv.first) > 0; + const bool vis = is2D ? is2dContent : !is2dContent; + for (auto& p : kv.second) + if (p) p->SetVisibility(vis ? 1 : 0); + } + for (auto& kv : anomalyProps_) + if (kv.second) kv.second->SetVisibility(is2D ? 0 : 1); // 异常=3D内容 + + // ② 取景 + 坐标轴 + 渲染统一走 render():朝向按 analysisMode2D_(已设)选近俯视/自由透视; + // ResetCamera 到"可见"数据包围盒(computeDataBounds 只计可见 prop);rebuildAxes 在二维下自移除; + // 末尾 Render + onCameraChanged(底图按新视锥重算)。不再用相机快照(陈旧易错),每次按可见内容取景。 + render(/*is2D ViewMode=*/false, /*resetCamera=*/true); +} + +// ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ─────────────────────────────────── +void VtkSceneView::applyMapLineSelectionVisual() { + for (auto& kv : dsProps_) { + if (!mapLineDs_.count(kv.first)) continue; + const bool sel = selectedMapLines_.count(kv.first) > 0; + for (auto& p : kv.second) { + auto* a = vtkActor::SafeDownCast(p); + if (!a) continue; + if (sel) { // 选中:黄高亮 + 加粗 + a->GetProperty()->SetColor(1.0, 0.85, 0.2); + a->GetProperty()->SetLineWidth(6.0); + } else { // 未选:复原 buildMapLine 默认(橙 3.0) + a->GetProperty()->SetColor(0.95, 0.55, 0.10); + a->GetProperty()->SetLineWidth(3.0); + } + } + } +} + +void VtkSceneView::clearMapLineSelection() { + if (selectedMapLines_.empty()) return; + selectedMapLines_.clear(); + applyMapLineSelectionVisual(); + if (renderWindow_) renderWindow_->Render(); + if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表:同步清空 +} + +std::vector VtkSceneView::selectedMapLines() const { + return std::vector(selectedMapLines_.begin(), selectedMapLines_.end()); +} + +void VtkSceneView::setSelectedMapLines(const std::vector& dsIds) { + // 列表→VTK:按 dsId 设选中(仅已渲染足迹),高亮+渲染;不回调 onMapLineSelectionChanged(防回环)。 + selectedMapLines_.clear(); + for (const auto& id : dsIds) + if (mapLineDs_.count(id)) selectedMapLines_.insert(id); + applyMapLineSelectionVisual(); + if (renderWindow_) renderWindow_->Render(); +} + +bool VtkSceneView::pickMapLineAt(int screenX, int screenY, bool additive) { + auto* ren = scene_.renderer(); + if (!ren) return false; + // 只在"可见足迹"中拾取(PickFromList):避免地形/底图/隐藏的 3D 体抢命中。 + vtkNew picker; + picker->SetTolerance(0.012); + picker->PickFromListOn(); + bool any = false; + for (auto& kv : dsProps_) { + if (!mapLineDs_.count(kv.first)) continue; + for (auto& p : kv.second) + if (p && p->GetVisibility()) { picker->AddPickList(p); any = true; } + } + if (!any) return false; // 无可见足迹 → 不拦截(交由平移) + if (!picker->Pick(screenX, screenY, 0.0, ren)) { + if (!additive) clearMapLineSelection(); // 点空白(非多选)→ 取消选中 + return false; + } + vtkProp* hit = picker->GetViewProp(); + std::string hitDs; + for (auto& kv : dsProps_) { + if (!mapLineDs_.count(kv.first)) continue; + for (auto& p : kv.second) + if (p.Get() == hit) { hitDs = kv.first; break; } + if (!hitDs.empty()) break; + } + if (hitDs.empty()) { + if (!additive) clearMapLineSelection(); + return false; + } + if (additive) { // Ctrl 多选:切换该足迹 + if (selectedMapLines_.count(hitDs)) selectedMapLines_.erase(hitDs); + else selectedMapLines_.insert(hitDs); + } else if (!selectedMapLines_.count(hitDs)) { // 单击未选中的线 → 替换为它 + selectedMapLines_.clear(); + selectedMapLines_.insert(hitDs); + } + // 单击已选中的线(可能为多选之一):保持当前选中集 → 起手即可整体拖动,不塌缩为单选。 + applyMapLineSelectionVisual(); + if (renderWindow_) renderWindow_->Render(); + if (onMapLineSelectionChanged) onMapLineSelectionChanged(); // VTK→列表:同步选中 + return !selectedMapLines_.empty(); // 有选中 → 交互样式进入 Z 拖动 +} + +void VtkSceneView::nudgeSelectedMapLinesZ(double worldDz) { + if (selectedMapLines_.empty() || worldDz == 0.0) return; + for (const auto& dsId : selectedMapLines_) { + mapLineZOffset_[dsId] += worldDz; // 持久累计(全量重建后 addMapLine 复用) + auto it = dsProps_.find(dsId); + if (it == dsProps_.end()) continue; + for (auto& p : it->second) { + auto* a = vtkActor::SafeDownCast(p); + if (a) a->AddPosition(0.0, 0.0, worldDz); // 仅改 Z,锁 XY + } + } + if (scene_.renderer()) scene_.renderer()->ResetCameraClippingRange(); // Z 抬升后防被裁剪面切 + if (renderWindow_) renderWindow_->Render(); +} + +double VtkSceneView::selectedMapLineZ() const { + if (selectedMapLines_.empty()) return 0.0; + // 代表性 Z = 任一选中足迹 actor 的包围盒中心 Z(含 placement worldZ + 已累计偏移)。 + auto it = dsProps_.find(*selectedMapLines_.begin()); + if (it == dsProps_.end()) return 0.0; + for (const auto& p : it->second) + if (p) { if (double* b = p->GetBounds()) return 0.5 * (b[4] + b[5]); } + return 0.0; +} + +void VtkSceneView::rebuildAxes() { + // 先移除上一次的坐标轴 prop:render 可能在一次 rebuild 内多次调用(末尾统一 render + + // 异步回灌 render),不先移除会叠加坐标轴(评审 HIGH)。移除后再算 bounds(仅数据图元)。 + if (currentAxes_) { + scene_.renderer()->RemoveViewProp(currentAxes_); + currentAxes_ = nullptr; + } + // 二维分析无立体坐标轴:任何渲染路径(全量/增量/切模式)走到此都不重建坐标轴, + // 保证切到二维分析后坐标轴消失、且后续增量渲染不把它带回来。 + if (analysisMode2D_) return; + // 坐标轴随数据包围盒重建:仅按数据图元算 bounds(不含底图,否则被~公里级底图撑大), + // 再造 vtkCubeAxesActor 入场。None 模式或无数据 → buildAxes 返回 nullptr,场景无坐标轴。 + double bounds[6]; + if (!computeDataBounds(bounds)) return; // 无数据 → 不建坐标轴 + geopro::render::AxesOptions opts; + opts.mode = toRenderMode(axesMode_); + opts.unit = toRenderUnit(axesUnit_); + opts.fontSize = axesFontSize_; + opts.frame = frame_.get(); + auto toDisp = [](const geopro::controller::AxisRangeCfg& c) { + return geopro::render::AxisDisplay{c.visible, c.customRange, c.min, c.max}; + }; + opts.x = toDisp(axisX_); + opts.y = toDisp(axisY_); + opts.z = toDisp(axisZ_); + auto axes = geopro::render::buildAxes(bounds, opts, scene_.renderer()); + if (axes) { + scene_.addViewProp(axes); + currentAxes_ = axes; + } +} + +void VtkSceneView::render(bool is2D, bool resetCamera) { + // 视图区背景永远深色(规范 §0.5:不随明暗切换),让色阶数据更突出。 + double bgR, bgG, bgB; + geopro::app::vtkBackground(bgR, bgG, bgB); + scene_.renderer()->SetBackground(bgR, bgG, bgB); + // 坐标轴仅三维视图显示(2D 俯视测线不需要立体坐标轴)。 + if (!is2D) rebuildAxes(); + // 相机预设(朝向)只在取景时应用——保留相机的重建(改放大系数)不重设朝向,否则也会跳视角。 + // 朝向优先看二维分析模式(本期 A):处二维分析→近俯视;否则按 ViewMode(旧 Map2D 正俯视/三维自由)。 + // 这样 VE 改/项目切等全量重建在二维分析下仍保持近俯视,不跳回三维自由视角。 + if (resetCamera) { + if (analysisMode2D_) + geopro::render::applyNearTop2D(scene_.renderer()); + else if (is2D) + geopro::render::applyTop2D(scene_.renderer()); + else + geopro::render::applyFree3D(scene_.renderer()); + double bounds[6]; + if (computeDataBounds(bounds)) + scene_.renderer()->ResetCamera(bounds); // 取景到"可见"数据(不含底图,否则数据缩成小点) + else + scene_.renderer()->ResetCamera(); + } + scene_.renderer()->ResetCameraClippingRange(); // 裁剪面含底图 → 不被"蒙版"切掉 + if (renderWindow_) renderWindow_->Render(); + if (onCameraChanged) onCameraChanged(); // 相机/数据变了 → 底图按新视锥重算覆盖 +} + +void VtkSceneView::renderIncremental() { + // 增量渲染:仅按新包围盒重建坐标轴并提交,不动相机(勾选/取消时视角不跳)。 + rebuildAxes(); + scene_.renderer()->ResetCameraClippingRange(); // 数据/底图变化后扩裁剪面,防被切 + if (renderWindow_) renderWindow_->Render(); +} + +} // namespace geopro::app diff --git a/src/app/VtkSceneView.hpp b/src/app/VtkSceneView.hpp new file mode 100644 index 0000000..b7e67cf --- /dev/null +++ b/src/app/VtkSceneView.hpp @@ -0,0 +1,189 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "I3dSceneView.hpp" +#include "model/ColorScale.hpp" + +namespace geopro::core { class GeoLocalFrame; } +namespace geopro::render { class Scene; } +class vtkRenderer; +class vtkRenderWindow; +class vtkProp; +class vtkActor; +class vtkVolume; + +namespace geopro::app { + +// I3dSceneView 的真实实现:把编排层的"加图元"指令翻译为 render actor + Scene 调用。 +// 持有 Scene / renderer / renderWindow(非拥有)+ 共享 GeoLocalFrame(多视图空间配准)。 +// 纵向夸张统一作用:帘面/地形 actor SetScale(1,1,VE),体素 z 原点/间距烤入 VE。 +// render 层零业务:actor 只吃 core::*,本类负责装配。 +class VtkSceneView : public geopro::controller::I3dSceneView { +public: + // 入参生命周期须覆盖本对象(由调用方保证)。zRefElev:地形 z 基准(测线地表高程)。 + VtkSceneView(geopro::render::Scene& scene, vtkRenderWindow* renderWindow, + std::shared_ptr frame, double zRefElev); + + void clear() override; + void setVerticalExaggeration(double ve) override; + void setVolumeOpacity(double maxOpacity) override; // 运行时调已渲染体 + 后续新体的最大不透明度 + double zRefElev() const override { return zRefElev_; } + void addSurveyLine(const geopro::core::Grid& grid) override; + void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, + const geopro::core::ColorScale& cs) override; + void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, + const geopro::core::ColorScale& cs) override; + void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, + double worldZ) 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 setAxesRanges(const geopro::controller::AxisRangeCfg& x, + const geopro::controller::AxisRangeCfg& y, + const geopro::controller::AxisRangeCfg& z) override; + void applyCameraView(geopro::controller::ViewDir dir) override; + void zoom(double factor) override; + void fitView() override; + void render(bool is2D, bool resetCamera = true) override; + void renderIncremental() override; + + // ── P3 切片交互:暴露当前体素 image(含 VE 烤入的 origin/spacing)供切片附着 ── + // addVolume 用暴露 image 的 buildVoxel 重载保留;clear/无体素时置空。 + vtkImageData* currentVolumeImage() const { return currentVolumeImage_.Get(); } + const geopro::core::ColorScale& currentColorScale() const { return currentColorScale_; } + double currentVmin() const { return currentVmin_; } + double currentVmax() const { return currentVmax_; } + bool hasVolume() const { return currentVolumeImage_ != nullptr; } + const std::string& currentVolumeDsId() const { return volumeOwnerDs_; } // 当前体归属 ds(保存切片用) + + // 体素 image 变化(addVolume 附着新 image / clear 置空)时回调,供上层把新 image 推给 + // InteractionManager(重附着或关闭切片)。clear 时以 nullptr 触发。 + std::function onVolumeChanged; + + // frame 原点重锚(首个带经纬剖面到达)后回调,供底图等随之刷新到数据所在位置。 + std::function onFrameReanchored; + + // 复位"已按数据重锚"标志:切换项目清场后调,使新项目首个数据重新触发重锚(→ onFrameReanchored + // → 底图按新项目位置重显)。否则增量勾选不走 clear(),旧标志残留 → 不重锚 → 底图不再显示。 + void resetFrameAnchor() { frameAnchoredToData_ = false; } + + // 相机程序化变化(取景/预设/缩放)后回调,供底图按新视锥重算覆盖(否则首帧部分瓦片要手动微动才出)。 + std::function onCameraChanged; + + // ── 二维分析改造 A 期:一场景两相机 ────────────────────────────────────────── + // 切「二维分析」(is2D=true):相机锁近俯视、显 2D 足迹/隐 3D(体/帘面/异常);切回反之。 + // 只翻 actor 可见标志(不清空、不重建)→ 切换瞬时、零重插值。地形/底图常驻不动。 + // 切片显隐 + 交互锁由 InteractionManager::setMode2D 配合(上层在同一处调两者)。 + void setAnalysisMode2D(bool is2D); + bool isAnalysisMode2D() const { return analysisMode2D_; } + + // ── 二维分析改造 B 期:选中 2D 足迹沿高程 Z 拖动 ─────────────────────────────── + // 仅二维分析下用。pickMapLineAt:在屏幕(x,y)拾取足迹(只考虑可见足迹,不被地形/底图干扰);命中则 + // 选中(additive=Ctrl 多选切换,否则单选替换)并高亮,返回是否有选中(交互样式据此决定 Z 拖动/平移)。 + // nudgeSelectedMapLinesZ:选中足迹世界 Z += worldDz(锁 XY);偏移按 dsId 持久(切走再回/全量重建保留)。 + // selectedMapLineZ:代表性当前世界 Z(高程读数浮层用);无选中返回 0。 + bool pickMapLineAt(int screenX, int screenY, bool additive); + void clearMapLineSelection(); + bool hasMapLineSelection() const { return !selectedMapLines_.empty(); } + void nudgeSelectedMapLinesZ(double worldDz); + double selectedMapLineZ() const; + // 双向选择联动:列表↔VTK。selectedMapLines 取当前选中 dsId;setSelectedMapLines 由列表设置选中 + // (高亮,不回调,避免环)。VTK 内拾取改变选中时触发 onMapLineSelectionChanged → 上层同步列表。 + std::vector selectedMapLines() const; + void setSelectedMapLines(const std::vector& dsIds); + std::function onMapLineSelectionChanged; + +private: + // 首个带经纬数据(剖面/足迹)到达时把共享 frame 重锚到其 lat/lon 包围盒中心:使数据落在世界原点近旁 + // (否则样本默认原点可能离真实数据数百公里→图元在视锥外、移动视角也找不到)。已锚或无经纬则跳过。 + void anchorFrameIfNeeded(const std::vector& lat, const std::vector& lon, int n); + // 按当前坐标轴设置 + 场景包围盒重建坐标轴 prop(render 末尾调)。 + void rebuildAxes(); + void removeProps(std::vector>& props); // 从 renderer 移除并清空 + // 仅数据图元(剖面/体素/地形/测线)的包围盒,不含底图 → 坐标轴/取景不被~公里级底图撑大。 + bool computeDataBounds(double out[6]) const; + +public: + // 当前所有数据图元(剖面等)合并范围的水平半径(米);无数据返回 0。供底图动态定最大范围。 + double dataHorizontalRadius() const; + +private: + + geopro::render::Scene& scene_; + vtkRenderWindow* renderWindow_; + std::shared_ptr frame_; + double zRefElev_; + double verticalExaggeration_ = 1.0; + double volumeOpacity_ = 0.30; // 三维体体绘制最大不透明度(默认 0.30,工具条可调);新体建好即套用 + // 是否已按真实剖面 lat/lon 重锚 frame 原点(每次 clear 重置):默认原点取自样本、可能离真实数据 + // 很远→局部坐标巨大、轴刻度无意义;首个带经纬剖面到达时重锚到其中心,同选择内多剖面共用配准。 + bool frameAnchoredToData_ = false; + + // 坐标轴设置(P2):默认标准 + 米。 + geopro::controller::AxesMode axesMode_ = geopro::controller::AxesMode::Standard; + geopro::controller::AxesUnit axesUnit_ = geopro::controller::AxesUnit::Meter; + int axesFontSize_ = 12; + geopro::controller::AxisRangeCfg axisX_, axisY_, axisZ_; // per-axis 可见性 + 自定义范围 + // 当前坐标轴 prop:render 可能多次调用 rebuildAxes(rebuild 末尾 + 异步回灌), + // 持引用以便重建前移除旧 prop,避免叠加(评审 HIGH)。 + vtkSmartPointer currentAxes_; + + // 当前体素 image + 色阶(P3 切片附着源);无体素时为空。「当前」=最后添加/活动的体(保存切片/ + // 创建异常的默认归属)。多体并发下各体 image 另存 volumes_。 + vtkSmartPointer currentVolumeImage_; + geopro::core::ColorScale currentColorScale_; + double currentVmin_ = 0.0; + double currentVmax_ = 0.0; + // 多体并发:按 dsId 持各已渲染体的 image + 色阶(供 InteractionManager 让各体切片各用自己的 image)。 + struct VolumeRec { + vtkSmartPointer image; + geopro::core::ColorScale cs; + double vmin = 0.0, vmax = 0.0; + vtkSmartPointer volume; // 体 actor(运行时调不透明度:改其 property 的不透明度传递函数) + }; + std::map volumes_; + +public: + const std::map& volumes() const { return volumes_; } // 已渲染各体 image/色阶 + bool isVolumeRendered(const std::string& dsId) const { return volumes_.count(dsId) > 0; } + const VolumeRec* volume(const std::string& dsId) const { // 取指定已渲染体的 image/色阶(缺=nullptr) + auto it = volumes_.find(dsId); + return it != volumes_.end() ? &it->second : nullptr; + } + +private: + + // 增量渲染:按 dsId 跟踪该数据集的 props(帘面/体素),支持单独移除而不全量重建; + // miscProps_ 为非数据集 prop(地形/测线),仅随 clear 全量移除。底图由 TileBasemap 自管、不在此。 + std::map>> dsProps_; + std::vector> miscProps_; + std::string volumeOwnerDs_; // 当前 currentVolumeImage_ 归属的 ds(其被移除时置空切片源) + std::map> anomalyProps_; // 异常 id → 3D actor + + // ── 二维分析改造 A 期 ── + // 哪些 dsProps_ 条目是 2D 足迹(addMapLine):切 tab 按此区分维度翻可见(其余 dsProps_=帘面/体=3D)。 + std::set mapLineDs_; + bool analysisMode2D_ = false; // 当前是否处二维分析(默认三维:启动在「三维分析」tab) + + // B 期:选中的足迹 dsId(Z 拖动目标) + 各足迹累计 Z 偏移(持久,全量重建后 addMapLine 复用)。 + std::set selectedMapLines_; + std::map mapLineZOffset_; + void applyMapLineSelectionVisual(); // 选中足迹加粗变亮、其余复原(橙 3.0) +}; + +} // namespace geopro::app diff --git a/src/app/VtkViewToolbar.cpp b/src/app/VtkViewToolbar.cpp new file mode 100644 index 0000000..0796058 --- /dev/null +++ b/src/app/VtkViewToolbar.cpp @@ -0,0 +1,134 @@ +#include "VtkViewToolbar.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Glyphs.hpp" +#include "Theme.hpp" + +using geopro::controller::ViewDir; + +namespace geopro::app { + +namespace { +constexpr int kIconPx = 18; +} + +VtkViewToolbar::VtkViewToolbar(QWidget* parent) : QWidget(parent) { + auto* col = new QVBoxLayout(this); + col->setContentsMargins(space::kSm, space::kSm, space::kSm, space::kSm); + col->setSpacing(2); + + const QColor ic = tokenColor("text/secondary"); + auto iconBtn = [&](Glyph g, const QString& tip) { + auto* b = new QToolButton(this); + b->setIcon(makeGlyph(g, ic, kIconPx)); + b->setIconSize(QSize(kIconPx, kIconPx)); + b->setAutoRaise(true); + b->setToolTip(tip); + b->setFixedSize(30, 30); + col->addWidget(b, 0, Qt::AlignHCenter); + return b; + }; + auto textBtn = [&](const QString& t) { + auto* b = new QToolButton(this); + b->setText(t); + b->setAutoRaise(true); + b->setFixedSize(30, 24); + col->addWidget(b, 0, Qt::AlignHCenter); + return b; + }; + auto sep = [&]() { + auto* line = new QFrame(this); + line->setFrameShape(QFrame::HLine); + line->setFrameShadow(QFrame::Plain); + applyTokenizedStyleSheet(line, QStringLiteral("color:{{border/default}};")); + col->addWidget(line); + }; + + // ── 段1:设置(坐标轴)── + connect(iconBtn(Glyph::Gear, QStringLiteral("坐标轴设置")), &QToolButton::clicked, this, + &VtkViewToolbar::axesSettingsRequested); + sep(); + // ── 段2:快捷视图(前/后/上/下/左/右)── + struct V { + const char* t; + ViewDir d; + }; + const V views[] = {{"前", ViewDir::Front}, {"后", ViewDir::Back}, {"上", ViewDir::Top}, + {"下", ViewDir::Bottom}, {"左", ViewDir::Left}, {"右", ViewDir::Right}}; + for (const V& v : views) { + const ViewDir d = v.d; + auto* b = textBtn(QString::fromUtf8(v.t)); + connect(b, &QToolButton::clicked, this, [this, d] { emit viewRequested(d); }); + viewDirButtons_.push_back(b); // 二维分析下禁用(会改朝向、破坏近俯视锁定) + } + sep(); + // ── 段3:透明度(缩放段顶部,放大上面)+ 缩放 / 复位 ── + opacityBtn_ = textBtn(QStringLiteral("透")); + opacityBtn_->setToolTip(QStringLiteral("三维体透明度")); + connect(opacityBtn_, &QToolButton::clicked, this, &VtkViewToolbar::showOpacityPopup); + connect(iconBtn(Glyph::Plus, QStringLiteral("放大")), &QToolButton::clicked, this, + &VtkViewToolbar::zoomInRequested); + connect(iconBtn(Glyph::Minus, QStringLiteral("缩小")), &QToolButton::clicked, this, + &VtkViewToolbar::zoomOutRequested); + connect(iconBtn(Glyph::Fit, QStringLiteral("适配 / 复位")), &QToolButton::clicked, this, + &VtkViewToolbar::fitRequested); + + // 悬浮样式:圆角面板浮于画布上(main 把本控件 setParent(vtkWidget) 叠加定位)。 + setAttribute(Qt::WA_StyledBackground, true); + applyTokenizedStyleSheet( + this, QStringLiteral("QWidget{background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}};border-radius:8px;}" + "QToolButton{border:none;border-radius:4px;color:{{text/secondary}};}" + "QToolButton:hover{background:{{bg/hover}};color:{{accent/primary}};}")); + setFixedWidth(44); + adjustSize(); + + // 透明度弹出面板(Qt::Popup → 点击外部自动关闭):横向滑块 0~100(%),默认 30(=0.30)。 + opacityPopup_ = new QWidget(this, Qt::Popup); + opacityPopup_->setAttribute(Qt::WA_StyledBackground, true); + applyTokenizedStyleSheet( + opacityPopup_, + QStringLiteral("QWidget{background:{{bg/panel-subtle}};border:1px solid {{border/default}};" + "border-radius:8px;}QLabel{border:none;color:{{text/primary}};}")); + auto* pl = new QVBoxLayout(opacityPopup_); + pl->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kSm); + pl->setSpacing(space::kSm); + opacityLabel_ = new QLabel(QStringLiteral("透明度 30%"), opacityPopup_); + opacitySlider_ = new QSlider(Qt::Horizontal, opacityPopup_); + opacitySlider_->setRange(0, 100); + opacitySlider_->setValue(30); // 默认 0.30,与体绘制默认一致 + opacitySlider_->setFixedWidth(scaledPx(160)); + pl->addWidget(opacityLabel_); + pl->addWidget(opacitySlider_); + connect(opacitySlider_, &QSlider::valueChanged, this, [this](int v) { + opacityLabel_->setText(QStringLiteral("透明度 %1%").arg(v)); + emit opacityChanged(v / 100.0); // 实时下发 + }); + opacityPopup_->adjustSize(); +} + +void VtkViewToolbar::showOpacityPopup() { + if (!opacityPopup_ || !opacityBtn_) return; + // 弹在「透」按钮右侧,与按钮顶对齐(全局坐标,Qt::Popup 顶层窗口)。 + opacityPopup_->move(opacityBtn_->mapToGlobal(QPoint(opacityBtn_->width() + 6, 0))); + opacityPopup_->show(); + opacityPopup_->raise(); +} + +void VtkViewToolbar::setAnalysisMode2D(bool is2D) { + for (auto* b : viewDirButtons_) { + if (!b) continue; + b->setEnabled(!is2D); + b->setToolTip(is2D ? QStringLiteral("二维分析下不可用(已锁定近俯视)") : QString()); + } +} + +} // namespace geopro::app diff --git a/src/app/VtkViewToolbar.hpp b/src/app/VtkViewToolbar.hpp new file mode 100644 index 0000000..493ae21 --- /dev/null +++ b/src/app/VtkViewToolbar.hpp @@ -0,0 +1,42 @@ +#pragma once +#include +#include +#include "I3dSceneView.hpp" // geopro::controller::ViewDir + +class QToolButton; +class QSlider; +class QLabel; + +namespace geopro::app { + +// VTK 画布竖排工具条(spec §9):全局视图控制——设置(坐标轴)/前后上下左右/放大缩小复位。 +// 仅发信号,不认 VTK;由 main 接到场景控制器。 +class VtkViewToolbar : public QWidget { + Q_OBJECT +public: + explicit VtkViewToolbar(QWidget* parent = nullptr); + +public slots: + // 二维分析激活时禁用不适用的工具:6 向快捷视图会改相机朝向→破坏二维近俯视锁定,故二维下禁用; + // 缩放/适配/坐标轴设置(含 VE)仍可用。切回三维恢复。 + void setAnalysisMode2D(bool is2D); + +signals: + void axesSettingsRequested(); // 设置 → 弹 AxesSettingsDialog + void viewRequested(geopro::controller::ViewDir dir); // 前/后/上/下/左/右 + void zoomInRequested(); + void zoomOutRequested(); + void fitRequested(); // 复位=适配 + void opacityChanged(double maxOpacity); // 三维体透明度滑块(0~1,实时) + +private: + void showOpacityPopup(); // 在透明度按钮旁弹出滑块面板 + + std::vector viewDirButtons_; // 6 向快捷视图按钮:二维分析下禁用 + QToolButton* opacityBtn_ = nullptr; // 「透」透明度按钮(缩放段顶部) + QWidget* opacityPopup_ = nullptr; // 弹出滑块面板(Qt::Popup,点外即关) + QSlider* opacitySlider_ = nullptr; // 0~100(% → 0~1) + QLabel* opacityLabel_ = nullptr; // 「透明度 N%」读数 +}; + +} // namespace geopro::app diff --git a/src/app/login/LoginWindow.cpp b/src/app/login/LoginWindow.cpp index f2294f3..6e5d765 100644 --- a/src/app/login/LoginWindow.cpp +++ b/src/app/login/LoginWindow.cpp @@ -100,7 +100,8 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent) "#brandSubtitle { color: rgba(255,255,255,0.82); font-size: %3px; }" "#fieldLabel { color: {{text/secondary}}; font-size: %4px; font-weight: %5; }" // 输入框已 Ela 化(ElaLineEdit 自绘 Fluent + 自动明暗),不再写 QLineEdit QSS。 - "#captchaImg { border: 1px solid {{border/strong}}; border-radius: 8px; background: {{bg/hover}}; }") + // 验证码容器固定白底:后端验证码图是浅底,白底贴合图边(两种主题皆然,故用白字面值)。 + "#captchaImg { border: 1px solid {{border/strong}}; border-radius: 8px; background: #FFFFFF; }") .arg(scaledPx(type::kDisplay)) .arg(type::kWeightBold) .arg(scaledPx(type::kCaption)) @@ -240,7 +241,19 @@ void LoginWindow::refreshCaptcha() if (l != captchaLoad_) return; // 身份比对:仅处理最新请求 captchaLoad_.clear(); codeId_ = cap.codeId; - captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code)); + // 后端 getImageCode 返回的是 base64 PNG 图(data.image),直接显示; + // 兼容旧明文 code(若后端仍回传则用客户端绘制兜底)。 + QPixmap pix; + if (!cap.image.isEmpty()) { + const int comma = cap.image.indexOf(QLatin1Char(',')); // 去 "data:image/png;base64," 前缀 + const QByteArray b64 = + (comma >= 0 ? cap.image.mid(comma + 1) : cap.image).toLatin1(); + pix.loadFromData(QByteArray::fromBase64(b64)); + } + if (pix.isNull() && !cap.code.isEmpty()) pix = renderCaptchaPixmap(cap.code); + // 原尺寸显示(后端图约 88x40),不再放大缩放——放大会糊导致看错验证码字符。 + captchaLabel_->setScaledContents(false); + captchaLabel_->setPixmap(pix); refreshBtn_->setEnabled(true); }); connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) { diff --git a/src/app/main.cpp b/src/app/main.cpp index 94618f8..bb7fdeb 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -16,10 +16,8 @@ // - 右 属性:选中数据集属性文本。 // 世界系:启动 loadGrid("grid1") 取一次,用其 lat/lon 中位/均值作 GeoLocalFrame(全项目共享,保证多视图配准)。 -#include #include #include -#include #include #include #include @@ -34,18 +32,30 @@ #include #include #include +#include #include #include +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -56,9 +66,11 @@ #include #include #include +#include #include #include #include +#include #include #include #include @@ -81,18 +93,32 @@ #include "ApiClient.hpp" #include "AuthService.hpp" +#include "DatasetDimension.hpp" +#include "DatasetCategory.hpp" #include "Credential.hpp" #include "Glyphs.hpp" #include "Logging.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" +#include "AnomalySaveDialog.hpp" +#include "AnomalyPropertiesDialog.hpp" +#include "ColorScaleConfigDialog.hpp" #include "SettingsDialog.hpp" +#include "SlicePropertiesDialog.hpp" +#include "SliceExport.hpp" +#include "ToastOverlay.hpp" +#include "panels/LoadingOverlay.hpp" #include "TopBar.hpp" -#include "CentralScene.hpp" +#include "VolumeParamsDialog.hpp" +#include "VolumePropertiesDialog.hpp" +#include "interact/AnomalyDrawTool.hpp" #include "ProjectListDialog.hpp" #include "ObjectFormDialog.hpp" #include "ImportDatasetDialog.hpp" +#include "panels/web/ProjectWebView.hpp" #include "WorkbenchNavController.hpp" +#include "VtkSceneController.hpp" +#include "VtkSceneView.hpp" #include "api/NavRequest.hpp" #include "api/NavLoads.hpp" #include "DatasetDetailController.hpp" @@ -103,6 +129,9 @@ #include "panels/chart/GridStrategy.hpp" #include "api/ApiProjectRepository.hpp" #include "api/ApiDatasetRepository.hpp" +#include "api/ApiColorTemplateRepository.hpp" +#include "api/ApiDatasetCommandRepository.hpp" +#include "api/Api3dRepository.hpp" #include "panels/ObjectTreePanel.hpp" #include "login/LoginWindow.hpp" #include "panels/DatasetListPanel.hpp" @@ -111,11 +140,23 @@ #include "panels/ObjectAttrPanel.hpp" #include "panels/DatasetAttrPanel.hpp" #include "panels/ObjectExceptionPanel.hpp" +#include "TileBasemap.hpp" +#include "panels/columns/ColumnDrawer.hpp" +#include "panels/columns/CategoryAnalysisTab.hpp" +#include "panels/columns/CategorySection.hpp" +#include "VtkViewToolbar.hpp" +#include "AxesSettingsDialog.hpp" +#include "AxesSettingsPanel.hpp" +#include "repo/DatasetFieldDictionary.hpp" +#include "panels/columns/Column2DDataset.hpp" #include "CameraPreset.hpp" #include "ColorLutBuilder.hpp" #include "Scene.hpp" #include "VoxelFromScatters.hpp" +#include "interact/InteractionManager.hpp" +#include "interact/PickInteractorStyle.hpp" +#include "interact/SlicePlaneMath.hpp" #include "actors/AnomalyActor.hpp" #include "actors/CurtainActor.hpp" #include "actors/ElectrodeActor.hpp" @@ -138,9 +179,13 @@ #include #include #include -#include #include +#include +#include #include +#include +#include +#include #include #include #include @@ -157,14 +202,32 @@ public: { host_->installEventFilter(this); } + // overlay 定位/置顶后,再把这些控件 raise 到 overlay 之上(如工具条/提示常驻最上层)。 + void setRaiseAfter(std::vector w) { raiseAfter_ = std::move(w); } void reposition() { overlay_->adjustSize(); const QSize h = host_->size(); - const QSize o = overlay_->size(); - overlay_->move(host_->x() + (h.width() - o.width()) / 2, - host_->y() + (h.height() - o.height()) / 2); - overlay_->raise(); + // 浮层尺寸钳到不超过 host:host 比内容小(窗口/抽屉收窄)时不再溢出视图。 + QSize o = overlay_->size(); + o.setWidth(std::min(o.width(), h.width())); + o.setHeight(std::min(o.height(), h.height())); + overlay_->resize(o); + // 偏移取非负:保证浮层矩形始终 ⊆ host 矩形(不跑到 host 外)。 + const int dx = std::max(0, (h.width() - o.width()) / 2); + const int dy = std::max(0, (h.height() - o.height()) / 2); + if (overlay_->parentWidget() == host_) { + // overlay 是 host 的子级:本地坐标居中。须 raise 到 GL 之上才可见(QVTKOpenGLStereoWidget + // 的子控件 lower 会落到 GL 之下→不可见),再把工具条/提示 raise 回它之上→工具条永在最上层。 + overlay_->move(dx, dy); + overlay_->raise(); + for (QWidget* w : raiseAfter_) + if (w) w->raise(); + } else { + // overlay 与 host 同级:换算到共同父坐标系并置顶。 + overlay_->move(host_->x() + dx, host_->y() + dy); + overlay_->raise(); + } } protected: @@ -178,18 +241,9 @@ protected: private: QWidget* overlay_; QWidget* host_; + std::vector raiseAfter_; // 定位后再 raise 到 overlay 之上的常驻控件(工具条/提示) }; -// 读取 RSA 公钥 PEM 全文(登录时密码加密用)。读不到返回空串,登录将报错。 -std::string readPem(const std::string& path) -{ - std::ifstream in(path, std::ios::binary); - if (!in) return {}; - std::ostringstream ss; - ss << in.rdbuf(); - return ss.str(); -} - // 取 vector 中位数(用于由测线 lat/lon 推世界系原点)。空则返回 0。 double median(std::vector v) { @@ -199,13 +253,10 @@ double median(std::vector v) return n % 2 ? v[n / 2] : 0.5 * (v[n / 2 - 1] + v[n / 2]); } -// 当前中央视图(默认二维地图)。二维地图=测线红线俯视;三维视图=断面墙。 -using geopro::app::ViewMode; - // 纵向夸张倍数(Z 基准统一,M-3):全项目共用同一倍数,使 帘面(z) / 体素 / 切片 / // 数据详情剖面(y) / 地形(relief) 的纵向比例一致——避免「剖面×1.5、帘面×3」不一致。 // 单一可调常量:要整体调纵向观感改这一处即可。 -constexpr double kVerticalExaggeration = 2.0; +constexpr double kVerticalExaggeration = 1.0; // 项目 CRS(实证确定,STATUS §4):散点 projX/Y→经纬→GeoLocalFrame 配准体素到世界系。 constexpr const char* kProjectCrs = "EPSG:4547"; // CGCS2000 / 3-degree GK CM 114E @@ -215,8 +266,12 @@ constexpr const char* kWgs84 = "EPSG:4326"; // repo 生命周期须覆盖到事件循环结束(由调用方保证)。 void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& repo, geopro::data::IAsyncProjectRepository& projectRepo, + geopro::data::IAsyncDatasetRepository& datasetRepo, + geopro::data::IColorTemplateRepository& colorTplRepo, + geopro::data::IDatasetCommandRepository& cmdRepo, geopro::controller::WorkbenchNavController& nav, - geopro::controller::DatasetDetailController& detailCtrl) + geopro::controller::DatasetDetailController& detailCtrl, + const QString& sessionToken) { // ── 世界系:启动取一次 grid1 的 lat/lon,用中位数作 GeoLocalFrame 原点 ── // 全项目共享(shared_ptr 持有):所有帘面用同一 frame 投影,保证多条测线空间配准。 @@ -231,30 +286,51 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // Scene 非 QObject:堆分配,用 widget 销毁信号清理(widget 随 window 销毁)。 auto* scene = new geopro::render::Scene(); auto* vtkWidget = new QVTKOpenGLStereoWidget(); - QObject::connect(vtkWidget, &QObject::destroyed, [scene]() { delete scene; }); vtkNew renderWindow; vtkWidget->setRenderWindow(renderWindow); renderWindow->AddRenderer(scene->renderer()); - vtkRenderer* rendererPtr = scene->renderer(); vtkGenericOpenGLRenderWindow* renderWindowPtr = renderWindow.Get(); - // 当前视图模式(全局共享,切视图/勾选时据此重建内容)。默认二维地图。 - auto viewMode = std::make_shared(ViewMode::Map2D); + // 中央渲染编排(VtkSceneController + VtkSceneView,取代旧 rebuildCentral lambda 与裸 show* 标志)。 + // 3D 场景仓储用 Api3dRepository(真实后端:loadSection 走真实 ERT 反演端点,委托 datasetRepo)。 + // 视图(VtkSceneView)非 QObject、控制器/3D 仓储亦然:随 scene 一并在 vtkWidget 销毁时清理。 + auto* scene3dRepo = new geopro::data::Api3dRepository(datasetRepo, frame); + auto* sceneView = new geopro::app::VtkSceneView(*scene, renderWindowPtr, + frame, refElev); + auto* sceneCtrl = new geopro::controller::VtkSceneController(repo, *scene3dRepo, *sceneView, + vtkWidget); + sceneCtrl->setVerticalExaggeration(kVerticalExaggeration); - // 三维图层显隐(由「视图详情」浮层控制)+ 项目 CRS→WGS84(体素配准)。 - auto showCurtain = std::make_shared(true); // 帘面,默认显示 - auto showVoxel = std::make_shared(false); // 体素,默认关 - auto showTerrain = std::make_shared(false); // 地形(DEM+影像),默认关 - auto showSlice = std::make_shared(false); // dd_slice 交互切片,默认关 - // 持久的切片 widget(挂 interactor,跨重建保活;rebuildCentral 据条件创建/拆除)。 - auto slicePlane = std::make_shared>(); - std::shared_ptr crs; // PROJ 失败→空→体素层无效(不崩) + // ── P3 切片交互编排(InteractionManager)───────────────────────────────── + // interactor 由 QVTK 在 setRenderWindow 后提供(renderWindow->GetInteractor())。 + // 安装自定义拾取样式 + 持活动切片。仅三维 + 有体素可用;切到二维 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 堆对象统一在此清理,按构造逆序(持 interactor 观察者者先析构,防悬挂崩溃): + QObject::connect(vtkWidget, &QObject::destroyed, + [scene, scene3dRepo, sceneView, interactionMgr, anomalyDrawTool]() { + delete anomalyDrawTool; + delete interactionMgr; + delete sceneView; + delete scene3dRepo; + delete scene; + }); + + // PROJ 可用性探测(体素/地形/切片层都需配准):三栏重构后浮层勾选已移除, + // 仅保留探测以便将来在三栏里据此禁用相关项;本期结果暂未消费。 + bool crsAvailable = false; try { - crs = std::make_shared(kProjectCrs, kWgs84); + geopro::core::CrsTransform probe(kProjectCrs, kWgs84); + crsAvailable = true; } catch (const std::exception&) { - crs.reset(); + crsAvailable = false; } + (void)crsAvailable; // 停靠系统配置(必须在 CDockManager 构造前设置):对齐原型——面板固定、 // 标题栏不显示「关闭 / 浮动 / 标签菜单」等子窗口操作按钮,并关闭自动隐藏(钉住)。 @@ -268,7 +344,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re ads::CDockManager::setAutoHideConfigFlags(ads::CDockManager::AutoHideFlags()); // 禁用自动隐藏 auto* dockManager = new ads::CDockManager(&window); - window.setCentralWidget(dockManager); + // 中央区用 QStackedWidget 承载:page0=工作台(dockManager,默认「分析视图」), + // page1=项目管理 web 页(点项目管理菜单整窗加载,视图菜单「分析视图」切回 page0)。 + auto* centralStack = new QStackedWidget(&window); + centralStack->addWidget(dockManager); // index 0:工作台 + window.setCentralWidget(centralStack); // 覆盖 ADS 自带分隔条样式:其 default.css 用 palette(dark) 把面板间分隔画成深灰粗线, // 且设在管理器自身、选择器更具体(优先级高于全局主题)。在其后追加同选择器、按主题着色的极淡分隔覆盖。 @@ -298,75 +378,902 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re return box; }; - // 中央容器:顶部「二维地图/三维视图」工具条 + 下方 QVTK 视图。 + // 中央容器:顶部「VTK视图」表头 + 下方 [左三栏抽屉 | 右 QVTK 画布]。 auto* centerWidget = new QWidget(); auto* centerLayout = new QVBoxLayout(centerWidget); centerLayout->setContentsMargins(0, 0, 0, 0); centerLayout->setSpacing(0); - // 「二维地图/三维视图」分段切换表头:与「异常/属性」面板表头同款(42px 表头底 + 强调色下划线页签)。 - auto seg = geopro::app::buildSegmentedHeader( - {QStringLiteral("二维地图"), QStringLiteral("三维视图")}, - {{geopro::app::Glyph::Collapse, QStringLiteral("折叠")}, - {geopro::app::Glyph::Download, QStringLiteral("导出")}}); - auto* viewHeader = seg.header; - auto* act2D = seg.buttons[0]; - auto* act3D = seg.buttons[1]; - centerLayout->addWidget(viewHeader); - centerLayout->addWidget(vtkWidget, 1); + // VTK视图面板表头(Task 7):图标 + 标题「VTK视图」+ 全屏操作按钮(全屏 connect 见 Task 8)。 + auto* viewHeader = geopro::app::buildPanelHeader( + geopro::app::Glyph::Map, QStringLiteral("VTK视图"), + {{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}}); - // ──「视图详情」图层浮层(对齐原型 3D 视图左上):浮在 QVTK 之上,控制三维图层显隐。 - // 仅三维视图显示;含 帘面 / 体素 勾选(体素=两交叉测线散点配准 IDW 的派生层,正确归宿)。 - auto* layerPanel = new QFrame(centerWidget); - layerPanel->setFrameShape(QFrame::StyledPanel); + // 左侧内嵌三栏抽屉(自带折叠按钮)+ 右侧 GL 画布,水平并列(非 GL 覆盖层,避免 z-order/圆角伪影)。 + auto* fieldDict = new geopro::data::DatasetFieldDictionary(); // 进程级;ColumnDrawer/段持非拥有指针 + auto* drawer = new geopro::app::ColumnDrawer(centerWidget, fieldDict); + auto* viewToolbar = new geopro::app::VtkViewToolbar(centerWidget); // VTK 画布全局视图控制竖排工具条 + // 拉装置类型枚举(全局,登录后一次)→ 填字典;电阻率/视电阻率段装置下拉据此显示 + 过滤(spec §6/§10)。 + cmdRepo.listArrayTypes([fieldDict, drawer](bool ok, QJsonArray list, QString) { + if (!ok) return; + std::map e; + for (const auto& v : list) { + const QJsonObject o = v.toObject(); + const std::string iv = + o.value(QStringLiteral("itemValue")).toVariant().toString().toStdString(); + if (!iv.empty()) e[iv] = o.value(QStringLiteral("name")).toString().toStdString(); + } + fieldDict->setArrayTypeEnum(std::move(e)); + // 枚举到达(异步,可能晚于已加载的数据)→ 重填各段装置下拉,避免下拉为空。 + drawer->analysisTab()->refreshArrayFilters(); + }); + // 左侧抽屉 | 右侧画布用 QSplitter(左面板可拖改宽);工具条悬浮于画布上(不占布局)。 + auto* split = new QSplitter(Qt::Horizontal); + split->setChildrenCollapsible(false); + split->setHandleWidth(4); + split->addWidget(drawer); + split->addWidget(vtkWidget); + split->setStretchFactor(0, 0); + split->setStretchFactor(1, 1); + // 折叠/展开抽屉 → 同步 QSplitter 尺寸:收起时把抽屉栏压到按钮宽(18)、余量全给画布(否则残留空白区); + // 展开恢复约 280。setSizes 为相对比例,splitter 按 min/max 钳制后铺满。 + QObject::connect(drawer, &geopro::app::ColumnDrawer::collapsedChanged, split, + [split](bool collapsed) { + split->setSizes(collapsed ? QList{18, 100000} + : QList{280, 100000}); + }); + centerLayout->addWidget(viewHeader); + centerLayout->addWidget(split, 1); + // 工具条悬浮于画布左上角(overlay;左上固定,画布 resize 无需重定位)。 + viewToolbar->setParent(vtkWidget); + viewToolbar->move(12, 12); + viewToolbar->raise(); + viewToolbar->show(); + // 异常绘制操作提示:右上角 QLabel 浮层(VTK 内置字体不含中文字形,故用 Qt 渲染中文 + QSS 美化)。 + // 深底 + accent 描边;不挡画布鼠标事件;绘制开始显示、结束/取消隐藏(见 onSliceContextMenuRequested)。 + auto* anomalyHint = new QLabel(vtkWidget); + anomalyHint->setObjectName(QStringLiteral("anomalyHint")); + anomalyHint->setAttribute(Qt::WA_TransparentForMouseEvents); + // 方角 + 不透明深底:避免「圆角外三角区露白底」与「半透明在 GL 子控件上渲染成灰」两个坑。 geopro::app::applyTokenizedStyleSheet( - layerPanel, - // 不设 border-radius:浮层是 centerWidget 的子控件,悬于原生 GL 画布上,圆角四角处会 - // 露出父级浅底(浅色模式下即四个白直角)。改为直角矩形,不透明底铺满整块,无四角伪影。 - QStringLiteral("QFrame{background:{{canvas/bg-soft}};border:1px solid {{canvas/grid}};}" - "QCheckBox{padding:2px 1px;color:{{canvas/text}};}" - "QCheckBox:disabled{color:{{canvas/text-dim}};}")); - auto* layerLayout = new QVBoxLayout(layerPanel); - // 浮层内边距取间距令牌:左右 lg(12)、上下 ml(10),对称(原 13/10/15/11 是手调奇数)。 - layerLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMl, - geopro::app::space::kLg, geopro::app::space::kMl); - layerLayout->setSpacing(geopro::app::space::kSm); - auto* layerTitle = new QLabel(QStringLiteral("视图详情")); + anomalyHint, + QStringLiteral("QLabel#anomalyHint{background:#0E1A2D;color:#E6ECF5;" + "border:1px solid {{accent/primary}};padding:8px 12px;}")); + anomalyHint->hide(); + + // 保存三维体等待蒙版(公共组件 LoadingOverlay):挂 centerWidget → showOver 铺满整个「VTK视图」 + // 子视图(表头+左树+画布)并 raise 到顶层;保存阶段显示,完成即撤。复用于其他需要整窗等待的场景。 + auto* vtkLoading = new geopro::app::LoadingOverlay(centerWidget); + + // ── 二维分析 B 期:高程 Z 拖动读数浮层(顶部居中)+ 足迹拾取/拖动回调注入交互样式 ────────── + // 拖动选中足迹时显示其当前世界 Z,松开隐藏;不挡画布鼠标。深底方角(同异常提示坑规避)。 + auto* elevHint = new QLabel(vtkWidget); + elevHint->setObjectName(QStringLiteral("elevHint")); + elevHint->setAttribute(Qt::WA_TransparentForMouseEvents); geopro::app::applyTokenizedStyleSheet( - layerTitle, QStringLiteral("font-weight:%1;color:{{canvas/text}};border:none;background:transparent;" - "padding-bottom:3px;font-size:%2px;") - .arg(geopro::app::type::kWeightSemibold) - .arg(geopro::app::scaledPx(geopro::app::type::kTitle))); - auto* chkCurtain = new QCheckBox(QStringLiteral("帘面(断面墙)")); - chkCurtain->setChecked(true); - auto* chkVoxel = new QCheckBox(QStringLiteral("体素(dd_voxel)")); - chkVoxel->setChecked(false); - auto* chkTerrain = new QCheckBox(QStringLiteral("地形(DEM+影像)")); - chkTerrain->setChecked(false); - auto* chkSlice = new QCheckBox(QStringLiteral("切片(dd_slice)")); - chkSlice->setChecked(false); - if (!crs) { // PROJ 不可用 → 体素/切片/地形层(都需配准)禁用并提示 - const QString tip = QStringLiteral("PROJ 数据(proj.db)缺失,配准不可用"); - chkVoxel->setEnabled(false); chkVoxel->setToolTip(tip); - chkTerrain->setEnabled(false); chkTerrain->setToolTip(tip); - chkSlice->setEnabled(false); chkSlice->setToolTip(tip); + elevHint, QStringLiteral("QLabel#elevHint{background:#0E1A2D;color:#E6ECF5;" + "border:1px solid {{accent/primary}};padding:6px 12px;}")); + elevHint->hide(); + // 滚轮升降时读数浮层 1.2s 后自动隐藏(拖动则在松开时隐藏)。 + auto* zHideTimer = new QTimer(vtkWidget); + zHideTimer->setSingleShot(true); + QObject::connect(zHideTimer, &QTimer::timeout, elevHint, [elevHint]() { elevHint->hide(); }); + auto showZReadout = std::make_shared>([sceneView, elevHint, vtkWidget]() { + elevHint->setText( + QStringLiteral("高程 Z:%1 m").arg(sceneView->selectedMapLineZ(), 0, 'f', 1)); + elevHint->adjustSize(); + elevHint->move((vtkWidget->width() - elevHint->width()) / 2, 12); // 顶部居中 + elevHint->show(); + elevHint->raise(); + }); + if (auto* style = interactionMgr->pickStyle()) { + // 命中可见足迹→选中(Ctrl 多选)并返回是否进入 Z 拖动;未命中(返回 false)→交互样式回退平移。 + style->onPick2D = [sceneView](int x, int y, bool additive) { + return sceneView->pickMapLineAt(x, y, additive); + }; + // 拖动中:施加世界 Z 增量(仅改 Z),并把选中足迹当前高程显示在顶部读数浮层。 + style->onDrag2D = [sceneView, showZReadout](double worldDz) { + sceneView->nudgeSelectedMapLinesZ(worldDz); + (*showZReadout)(); + }; + style->onDrag2DEnd = [elevHint]() { elevHint->hide(); }; + // 滚轮升降:有选中足迹则施加 Z 增量并显示读数(1.2s 后自动隐藏),返回 true 消费滚轮;否则缩放。 + style->onWheel2D = [sceneView, showZReadout, zHideTimer](double worldDz) { + if (!sceneView->hasMapLineSelection()) return false; + sceneView->nudgeSelectedMapLinesZ(worldDz); + (*showZReadout)(); + zHideTimer->start(1200); + return true; + }; } - // 本轮中央不接真实派生层:体素/切片/地形勾选置灰,待下一轮接入对应数据源。 - for (QCheckBox* c : {chkVoxel, chkSlice, chkTerrain}) { - c->setEnabled(false); - c->setToolTip(QStringLiteral("(下一轮接入真实数据源)")); - } - layerLayout->addWidget(layerTitle); - layerLayout->addWidget(chkCurtain); - layerLayout->addWidget(chkVoxel); - layerLayout->addWidget(chkSlice); - layerLayout->addWidget(chkTerrain); - layerPanel->setVisible(false); // 默认二维,不显示图层浮层 + // 双向选择联动:列表行选中 ↔ VTK 足迹高亮。两向各自屏蔽回环(setSelectedMapLines 不回调、 + // setSelectedDsIds 屏蔽信号),故无需额外守卫。 + QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::selectedDatasetsChanged, &window, + [sceneView](const QStringList& ids) { + std::vector v; + for (const QString& s : ids) v.push_back(s.toStdString()); + sceneView->setSelectedMapLines(v); + }); + sceneView->onMapLineSelectionChanged = [sceneView, drawer]() { + QStringList ids; + for (const std::string& s : sceneView->selectedMapLines()) + ids << QString::fromStdString(s); + drawer->col2D()->setSelectedDsIds(ids); + }; + + // 坐标轴设置抽屉面板:叠加 vtkWidget、工具条右侧滑出,默认隐藏(点设置 toggle)。 + auto* axesPanel = new geopro::app::AxesSettingsPanel(vtkWidget); + axesPanel->hide(); + // 预热:首次打开前先完成样式表解析 + 布局尺寸计算(含 spinbox/滑块/中文标签首次字体解析), + // 避免用户第一次点开时同步做这些导致 UI 卡顿。 + axesPanel->ensurePolished(); + axesPanel->adjustSize(); + + // 3b:三维分析栏勾选的已保存切片(dd_slice) id 集合 + 调和函数。 + // syncSlices:按"当前活动体 dsId"调和 InteractionManager 上显示的已保存切片—— + // 勾选且父体=当前体 → 显示(按 spec 还原);否则移除。须在 onVolumeChanged(体到场/移除)末尾 + // 及分析栏勾选变化时调用。注:setVolumeImage 会 closeAll,故体变更后由本函数重建。 + auto checkedSliceIds = std::make_shared>(); + auto syncSlices = [interactionMgr, scene3dRepo, checkedSliceIds]() { + // 多体并发:切片只要勾选 + 其父体已渲染就显示(不再限定"当前体")→ 多个体的切片可并存。 + for (const std::string& shownId : interactionMgr->shownSavedSliceIds()) { + geopro::data::I3dSceneRepository::SliceSpec sp; + const bool wanted = checkedSliceIds->count(shownId) > 0 && + scene3dRepo->sliceSpec(shownId, sp) && + interactionMgr->isVolumeRendered(sp.volumeDsId); + if (!wanted) interactionMgr->hideSavedSlice(shownId); + } + for (const std::string& id : *checkedSliceIds) { + geopro::data::I3dSceneRepository::SliceSpec sp; + if (scene3dRepo->sliceSpec(id, sp) && interactionMgr->isVolumeRendered(sp.volumeDsId)) + interactionMgr->showSavedSlice(id, sp.axis, sp.origin, sp.point1, sp.point2, + sp.volumeDsId); + } + }; + + // 异常刷新渲染(#4b/4c):恒「全部显示」——旧三维分析栏的过滤档位 UI 已退役,新分段 tab 暂无档位 + // 控件(功能缺失,待补)。异常列表由 refreshAnalysis 经 voxelTree 全量注入三维体段,此处只管渲染。 + // (loadAnomalyTree 空 key=全部。mock 同步回调。) + auto refreshAnomalies = [sceneView, scene3dRepo, renderWindowPtr, drawer]() { + sceneView->clearAnomalies(); + std::vector set; + scene3dRepo->loadAnomalyTree( + std::string{}, // 空 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); + // 异常显隐跟随三维体段复选框:取消勾选的异常隐藏(修「创建异常后取消勾选仍渲染」)。 + // 须在树已含该异常后调用(创建回调里先 refreshAnalysis 再 refreshAnomalies)。 + if (auto* sec = drawer->analysisTab()->section("voxel")) { + const QStringList checked = sec->checkedIds(); + for (const auto& a : set) + sceneView->setAnomalyVisible(a.id, checked.contains(QString::fromStdString(a.id))); + } + renderWindowPtr->Render(); // 必须重绘:clear+addAnomaly 改了 prop,否则 VTK 不刷新 + }; + + // 体素变化(重建/清场)后把体素 image 推给 InteractionManager(切片基底),并调和已保存切片 + 异常。 + sceneView->onVolumeChanged = [interactionMgr, sceneView, syncSlices, refreshAnomalies]() { + // 多体并发:先移除 interactionMgr 中已不再渲染的体(关其切片),再 upsert 当前所有已渲染体 image。 + for (const std::string& id : interactionMgr->volumeIds()) + if (!sceneView->isVolumeRendered(id)) interactionMgr->removeVolumeImage(id); + for (const auto& kv : sceneView->volumes()) + interactionMgr->setVolumeImage(kv.first, kv.second.image.Get(), kv.second.cs, + kv.second.vmin, kv.second.vmax); + syncSlices(); // 体到场/移除后调和各体下已勾选切片(多体并存) + refreshAnomalies(); // 同步重载异常 actor + 刷新异常列表 + }; + + // ── 抽屉信号 → 控制器/交互(Task 7/12 接线)────────────────────────────── + // 三维分析栏 = 后端 Analysis 行(dd_slice) + 客户端创建的三维体(mock)。生成的三维体是"分析产物" + // (设计 §2.1:三维分析栏按 对象/三维体模型/切片 三级树),归三维分析栏(非数据集栏)。 + // 后端列表每次勾选测线全量覆盖,故客户端体单独维护、刷新时合并注入(不被冲掉)。 + // 三维分析数据源 = 最近对象树勾选拉取的 ds + 客户端三维体(mock) + 已保存切片; + // splitByCategory 后注入 5 段(电阻率/视电阻率/瞬变/三维体/切片);二维(足迹)经 dim2D 仍走 col2D。 + auto lastSourceRows = std::make_shared>(); + auto lastStructNodes = std::make_shared>(); // 生成位置候选(项目内 GS/TM) + auto refreshAnalysis = [drawer, scene3dRepo, lastSourceRows, lastStructNodes]() { + const auto vols = scene3dRepo->volumeRows(); + const auto slices = scene3dRepo->sliceRows(); + const auto anomalies = scene3dRepo->anomalyRows(); + // 电阻率/视/瞬变段=对象树反演 ds(splitByCategory 按 dsTypeCode 分;voxel 段下方单独喂完整树)。 + // 各段据结构建「从项目根的容器层级树」(项目根/GS/TM → ds,CategorySection 自剪枝)。 + drawer->analysisTab()->setStructure(*lastStructNodes); + drawer->analysisTab()->setBuckets(geopro::app::splitByCategory(*lastSourceRows)); + // 三维体段=体 + 切片 + 异常;容器层级由 CategorySection 据 structure + 各 ds 的 structParentId 自建。 + std::vector voxelTree; + for (const auto& v : vols) voxelTree.push_back(v); + for (const auto& s : slices) voxelTree.push_back(s); + for (const auto& a : anomalies) voxelTree.push_back(a); + if (auto* sec = drawer->analysisTab()->section("voxel")) sec->setDatasets(voxelTree); + drawer->col2D()->setDatasets(geopro::app::splitByDimension(*lastSourceRows).dim2D); + }; + + // 渲染勾选聚合:三维数据集栏(剖面→帘面)+ 三维分析栏(三维体/切片→体素/切片)两套勾选并集 + // 后下发控制器(setCheckedDatasets 全量 diff,须并集;否则一栏勾选会清掉另一栏的图元)。 + auto checkedProfiles = std::make_shared(); + auto checkedAnalysis = std::make_shared(); + auto pushChecked = [sceneCtrl, checkedProfiles, checkedAnalysis]() { + QStringList all = *checkedProfiles; + all += *checkedAnalysis; + sceneCtrl->setCheckedDatasets(all); + }; + + // ── VTK 视图切片右键菜单(设计 §2.3)────────────────────────────────────── + // 右键命中切片 → InteractionManager 选中并回调本 lambda → 弹菜单(QCursor 处定位)。 + // 保存=按"未保存/已保存"分派(新建+链接+自动勾选 / 覆盖位姿);导出统一为「导出▸图片·dat」; + // 正视/翻转/关闭=接现有交互(关闭已保存切片→onSliceClosed 取消列表勾选);创建异常=占位(#4)。 + interactionMgr->onSliceContextMenuRequested = + [&window, &cmdRepo, &nav, interactionMgr, sceneView, scene3dRepo, refreshAnalysis, + refreshAnomalies, drawer, anomalyDrawTool, renderWindowPtr, anomalyHint, vtkWidget]() { + QMenu menu(&window); + QMenu* anomMenu = menu.addMenu(QStringLiteral("创建异常")); // → 点/线/面 子菜单 + QAction* aAnoPoint = anomMenu->addAction(QStringLiteral("点")); + QAction* aAnoLine = anomMenu->addAction(QStringLiteral("线")); + QAction* aAnoFace = anomMenu->addAction(QStringLiteral("面")); + // 「保存」仅对未保存(临时)切片显示——已保存切片定稿锁定、不可再改/再存(用户要求)。 + QAction* aSave = interactionMgr->selectedSliceDsId().empty() + ? menu.addAction(QStringLiteral("保存")) + : nullptr; + QMenu* expMenu = menu.addMenu(QStringLiteral("导出")); + 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 == aAnoPoint || chosen == aAnoLine || chosen == aAnoFace) { + // 形态(1点/2线/3面):同时决定绘制工具 mode、a.markType、对话框查平台类型的 remarkSourceType。 + // core::AnomalyMarkType 与 remarkSourceType 同值(Point=1/Polyline=2/Polygon=3),用一个 shape 贯通。 + namespace ri = geopro::render::interact; + using DM = ri::AnomalyDrawTool::DrawMode; + const int shape = (chosen == aAnoPoint) ? 1 : (chosen == aAnoLine) ? 2 : 3; + const DM mode = + (chosen == aAnoPoint) ? DM::Point : (chosen == aAnoLine) ? DM::Line : DM::Face; + int axis = 3; + 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)); + // 操作提示浮层(右上角):按形态显示结束方式;绘制结束/取消隐藏。 + anomalyHint->setText( + shape == 1 ? QStringLiteral("标注点\n左键单击落点即完成\nEsc 取消") + : shape == 2 + ? QStringLiteral("标注线\n左键逐点 · 双击结束\nBackspace 撤点 · Esc 取消") + : QStringLiteral("标注面\n左键逐点 · 点回起点闭合\nBackspace 撤点 · Esc 取消")); + anomalyHint->adjustSize(); + anomalyHint->move(vtkWidget->width() - anomalyHint->width() - 12, 12); // 右上角 + anomalyHint->show(); + anomalyHint->raise(); + // 多体并发:异常挂到"选中切片所属体"(非 currentVolume),无选中切片回退当前体。 + std::string volId = interactionMgr->selectedSliceVolumeDsId(); + if (volId.empty()) volId = sceneView->currentVolumeDsId(); + // 异常归属(spec §8):当前选中切片已保存(selectedSliceDsId 非空)→挂该切片;临时切片→挂体。 + const std::string savedSliceId = interactionMgr->selectedSliceDsId(); + anomalyDrawTool->start( + mode, o, normal, + [&window, &cmdRepo, &nav, interactionMgr, sceneView, scene3dRepo, renderWindowPtr, + refreshAnomalies, refreshAnalysis, volId, savedSliceId, normal, o, p1, p2, shape, + anomalyHint](const std::vector& worldPts) { + anomalyHint->hide(); // 绘制结束 → 隐藏操作提示 + // 草稿异常:先临时渲染(让用户在对话框前看到所画,且截图含异常)。 + geopro::core::Anomaly a; + a.markType = static_cast(shape); + a.remarkSourceId = + geopro::core::resolveAnomalyMount(!savedSliceId.empty(), savedSliceId, 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; + // 截图(正确做法):只从切片那张 2D 剖面彩图、按异常几何向外缓冲一圈裁剪(GIS buffer+掩膜)。 + std::vector> wpts; + wpts.reserve(worldPts.size()); + for (const auto& p : worldPts) wpts.push_back({p[0], p[1], p[2]}); + vtkSmartPointer sliceColor = interactionMgr->selectedSliceColorImage(); + const double oo[3] = {o[0], o[1], o[2]}; + const double pp1[3] = {p1[0], p1[1], p1[2]}; + const double pp2[3] = {p2[0], p2[1], p2[2]}; + bool shotOk = sliceColor && geopro::app::captureAnomalyShotFromSlice( + sliceColor, oo, pp1, pp2, wpts, shape, + a.lineColor, shot.toStdString(), sw, sh); + if (!shotOk) { // 回退:无切片图时退回相机框景(整窗外扩),至少有图。 + double rb[6] = {worldPts[0][0], worldPts[0][0], worldPts[0][1], + worldPts[0][1], worldPts[0][2], worldPts[0][2]}; + for (const auto& p : worldPts) { + rb[0] = std::min(rb[0], p[0]); rb[1] = std::max(rb[1], p[0]); + rb[2] = std::min(rb[2], p[1]); rb[3] = std::max(rb[3], p[1]); + rb[4] = std::min(rb[4], p[2]); rb[5] = std::max(rb[5], p[2]); + } + auto vlen = [](double x, double y, double z) { + return std::sqrt(x * x + y * y + z * z); + }; + const double e1 = vlen(p1[0] - o[0], p1[1] - o[1], p1[2] - o[2]); + const double e2 = vlen(p2[0] - o[0], p2[1] - o[1], p2[2] - o[2]); + const double minExt = 0.25 * std::min(e1, e2); + geopro::app::captureFramedRegionPng(renderWindowPtr, rb, 1.4, minExt, + shot.toStdString(), sw, sh); + } + // 异常类型按标注形态(shape=1点/2线/3面)拉对应平台类型,与平台一致。 + geopro::app::AnomalySaveDialog dlg(shot, sw, sh, &cmdRepo, + nav.currentProjectId(), shape, &window); + if (dlg.exec() != QDialog::Accepted) { + 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(); + // 平台样式:选中异常类型的 legend 派生(与平台一致);未取到则保留上面的默认样式。 + if (!dlg.styleColor().isEmpty()) { + a.lineColor = dlg.styleColor().toStdString(); + if (dlg.styleWidth() > 0.0) a.lineWidth = dlg.styleWidth(); + a.dashed = dlg.styleDashed(); + } + scene3dRepo->saveAnomaly( + a, shot.toStdString(), + [sceneView, renderWindowPtr, refreshAnomalies, refreshAnalysis, + draftId](std::string) { + sceneView->removeAnomaly(draftId); // 撤草稿 + refreshAnalysis(); // 先:新异常进三维体段三级树(默认勾选=显示) + refreshAnomalies(); // 后:重渲染异常 actor 并按复选框设显隐 + renderWindowPtr->Render(); + }, + [&window](const std::string& m) { + QMessageBox::warning(&window, QStringLiteral("保存异常"), + QString::fromStdString(m)); + }); + }, + [anomalyHint]() { anomalyHint->hide(); }); // 取消(Esc)→隐藏提示 + return; + } + if (aSave != nullptr && 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; + // 多体并发:切片归属"选中切片所属体"(非 currentVolume),无则回退当前体。 + spec.volumeDsId = interactionMgr->selectedSliceVolumeDsId(); + if (spec.volumeDsId.empty()) 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(); // 新行进列表 + // 新切片自动勾选 → 列表打勾 + 保持渲染(refreshAnalysis 已重建列表,故在其后勾选)。 + if (auto* sec = drawer->analysisTab()->section("voxel")) + sec->setChecked(QString::fromStdString(newId), true); + }, + [&window](const std::string& m) { + QMessageBox::warning(&window, QStringLiteral("保存切片"), + QString::fromStdString(m)); + }); + return; + } + if (chosen == aImg) { + vtkSmartPointer 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; + } + }; + + // TODO:关闭已保存切片(VTK「关闭」)时同步取消列表勾选——旧三维分析栏退役后,新分段 tab 暂无 + // setItemChecked,场景→列表的取消勾选同步暂缺(actor 已移除,仅列表打勾态不同步,待补)。 + + // ── VTK 画布工具条(Task 12 §9):全局视图控制 → sceneCtrl ────────────────── + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::viewRequested, sceneCtrl, + &geopro::controller::VtkSceneController::applyView); + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::zoomInRequested, sceneCtrl, + &geopro::controller::VtkSceneController::zoomIn); + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::zoomOutRequested, sceneCtrl, + &geopro::controller::VtkSceneController::zoomOut); + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::fitRequested, sceneCtrl, + &geopro::controller::VtkSceneController::fit); + // 透明度滑块 → 运行时调三维体不透明度(实时)。vtkWidget->update() 保证离屏渲染呈现到屏 + // (滑块在弹出面板上,vtkWidget 自身无 paint 事件,需显式请求重绘,同 volumeRendered 修复)。 + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::opacityChanged, vtkWidget, + [sceneCtrl, vtkWidget](double op) { + sceneCtrl->setVolumeOpacity(op); + vtkWidget->update(); + }); + // 设置(⚙)→ 工具条右侧 toggle 抽屉面板(非模态弹窗)。 + QObject::connect(viewToolbar, &geopro::app::VtkViewToolbar::axesSettingsRequested, &window, + [axesPanel, viewToolbar]() { + if (axesPanel->isVisible()) { + axesPanel->hide(); + return; + } + axesPanel->adjustSize(); + axesPanel->move(viewToolbar->x() + viewToolbar->width() + 8, viewToolbar->y()); + axesPanel->raise(); + axesPanel->show(); + }); + QObject::connect(axesPanel, &geopro::app::AxesSettingsPanel::closed, &window, + [axesPanel]() { axesPanel->hide(); }); + // ── 三维分析 tab(5 段)信号接线(Task 12)────────────────────────────────── + auto* analysisTab = drawer->analysisTab(); + // 5 段勾选并集 → 按类型分流渲染:反演剖面→帘面(checkedProfiles);三维体→体素(checkedAnalysis); + // 切片(dd_slice)→不进控制器,经 syncSlices 在父体上还原。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::checkedDatasetsChanged, sceneCtrl, + [checkedProfiles, checkedAnalysis, checkedSliceIds, syncSlices, pushChecked, + scene3dRepo](const QStringList& ids) { + QStringList profiles, analysis; + checkedSliceIds->clear(); + for (const QString& id : ids) { + const std::string s = id.toStdString(); + if (scene3dRepo->isAnomalyDataset(s)) + continue; // 异常不进渲染勾选(显隐另管,避免被当帘面源 loadSection 失败) + else if (scene3dRepo->isSliceDataset(s)) + checkedSliceIds->insert(s); + else if (scene3dRepo->isVolumeDataset(s)) + analysis << id; + else + profiles << id; // 反演剖面 → 帘面 + } + *checkedProfiles = profiles; + *checkedAnalysis = analysis; + pushChecked(); + syncSlices(); + }); + // 段头「+新增三维体」:弹参数对话框 → 组装真实 VoxelGenerateRequest → createVolume(mock) → 刷新。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::generateVolumeRequested, &window, + [&window, &nav, scene3dRepo, refreshAnalysis, lastSourceRows, lastStructNodes, + analysisTab, vtkLoading](const QString& /*dsTypeCode*/, const QStringList& sourceIds) { + if (sourceIds.isEmpty()) return; + // 源 ds(id,名称,结构归属):名称/structParentId 从最近拉取的行查(缺则用 id)。 + QVector sources; + for (const QString& id : sourceIds) { + geopro::app::VolumeSourceItem s; + s.id = id; + s.name = id; + for (const auto& r : *lastSourceRows) + if (r.id == id.toStdString()) { + s.name = QString::fromStdString(r.dsName); + s.structParentId = QString::fromStdString(r.structParentId); + break; + } + sources.push_back(std::move(s)); + } + // 左右两树共用项目结构(GS/项目根/TM 层级);生成位置默认选首个节点。 + geopro::app::VolumeParamsDialog dlg(sources, *lastStructNodes, /*defaultMountId=*/QString(), + &window); + if (dlg.exec() != QDialog::Accepted) return; + const QStringList chosen = dlg.sourceDatasetIds(); + if (chosen.isEmpty()) return; // 全取消则不生成 + const geopro::data::VolumeBuildParams p = dlg.params(); + geopro::data::VoxelGenerateRequest req; + req.projectId = nav.currentProjectId().toStdString(); + req.structParentId = dlg.mountTargetId().toStdString(); // 生成位置(归属) + req.structParentConfType = dlg.mountConfType(); + req.name = dlg.volumeName().toStdString(); + for (const QString& id : chosen) req.sourceDatasetIds.push_back(id.toStdString()); + req.interpModel = + (p.interpModel == geopro::data::VolumeBuildParams::Model::Kriging) ? "Kriging" : "Idw"; + req.cellXY = p.cellXY; + req.cellZ = p.cellZ; + req.power = p.power; + req.maxDist = p.maxDist; + req.colorScaleId = p.colorScaleId; + // 保存(目前 mock,瞬时同步):直接建体+入树,不弹等待蒙版——mock 没有耗时,蒙版 + // 会 showOver 后立刻 hide 在渲染区一闪而过。等待蒙版只在「真有耗时的后端保存」 + // 才有意义:接真实异步保存端点后,在那个调用外用 LoadingOverlay 包蒙版即可。 + // 保存阶段:VTK 整体加等待蒙版(挂 centerWidget→盖整个 VTK 子视图)。singleShot 让 + // 蒙版先绘出再干活,避免 mock 即时完成时蒙版根本没画出来。 + vtkLoading->showOver(QStringLiteral("正在保存三维体…")); + QTimer::singleShot(0, &window, [=]() { + const std::string newId = scene3dRepo->createVolume(req); // 保存 + 注册(mock) + { + // refreshAnalysis 重建列表会让各段重发"勾选变化"→ 触发场景重算 → 已渲染 + // 剖面被删了又加。保存时屏蔽 analysisTab 渲染信号 → 渲染区一动不动。 + const QSignalBlocker block(analysisTab); + refreshAnalysis(); // 入三维体树(仅刷列表,不触发渲染) + } + vtkLoading->hide(); // 保存阶段结束 → 撤蒙版 + const QString qid = QString::fromStdString(newId); + // 渲染阶段无蒙版:自动勾选新体 → 增量加体(剖面不动) + 标题前等待 spinner; + // 渲染完成由 volumeRendered 撤 spinner、失败由 loadFailed 兜底。 + analysisTab->setItemChecked(qid, true); + analysisTab->setItemBusy(qid, true); + analysisTab->scrollItemToTop(qid); // 新三维体行尽量滚到分析栏顶部 + }); + }); + // 任一数据集(剖面/体)异步加载开始 → 列表项复选框转等待 spinner;渲染完成 → 复原复选框。 + // 覆盖非三维体:勾选剖面首次渲染较慢时也有等待反馈(用户反馈)。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetLoading, analysisTab, + [analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, true); }); + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::datasetRendered, analysisTab, + [analysisTab](const QString& dsId) { analysisTab->setItemBusy(dsId, false); }); + // 根因修复:异步建体的渲染发生在后台线程触发的投递事件里,renderWindow->Render() 渲到离屏 FBO, + // 但 QVTKOpenGLStereoWidget 把 FBO「呈现到屏」绑定 Qt 的 paint;建体完成后 app 空闲、无后续 paint, + // FBO 渲好却没贴到屏 → 体偶发不可见(动鼠标产生 paint 才出来)。显式请求一次 Qt 重绘补上呈现步骤。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::volumeRendered, vtkWidget, + [vtkWidget](const QString&) { vtkWidget->update(); }); + // 加载失败 → 兜底撤回所有 spinner(避免标题卡在等待态)。 + QObject::connect(sceneCtrl, &geopro::controller::VtkSceneController::loadFailed, analysisTab, + [analysisTab, &window](const QString& msg) { + analysisTab->clearAllBusy(); + // 明确提示而非静默:源数据加载失败(如后端 502)时用户能区分"后端没给数据"与"渲染问题"。 + geopro::app::showToast( + &window, QStringLiteral("数据加载失败,未生成三维体:%1").arg(msg)); + }); + // 双击数据详情:dd_slice→切片属性;dd_voxel→三维体属性(同 colAnalysis 详情口径)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::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 if (ddCode == QStringLiteral("dd_voxel")) { + geopro::data::Api3dRepository::VolumeInfo info; + if (scene3dRepo->volumeInfo(dsId.toStdString(), info)) { + geopro::app::VolumePropertiesDialog dlg(name, info, &window); + dlg.exec(); + } + } else if (ddCode == QStringLiteral("dd_anomaly")) { + geopro::core::Anomaly a; + if (scene3dRepo->anomalyById(dsId.toStdString(), a)) { + geopro::app::AnomalyPropertiesDialog dlg(a, &window); + dlg.exec(); + } + } + }); + // 三维体段右键删除:切片→deleteSlice / 异常→deleteAnomaly,删后刷新树。 + // 异常删除须同时 refreshAnomalies(重载异常 actor)——否则列表行没了但场景里异常仍渲染(技术债,已修)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::deleteDatasetRequested, &window, + [scene3dRepo, refreshAnalysis, refreshAnomalies, &window](const QString& dsId, + const QString& ddCode) { + const std::string id = dsId.toStdString(); + // 删除前确认(不可撤销):明确中文「删除/取消」按钮。 + const QString what = + ddCode == QStringLiteral("dd_slice") ? QStringLiteral("切片") + : QStringLiteral("异常"); + QMessageBox box(QMessageBox::Warning, QStringLiteral("删除%1").arg(what), + QStringLiteral("确定删除该%1吗?此操作不可撤销。").arg(what), + QMessageBox::NoButton, &window); + QPushButton* del = box.addButton(QStringLiteral("删除"), QMessageBox::AcceptRole); + box.addButton(QStringLiteral("取消"), QMessageBox::RejectRole); + box.exec(); + if (box.clickedButton() != del) return; // 取消 → 不删 + if (ddCode == QStringLiteral("dd_slice")) { + scene3dRepo->deleteSlice( + id, [refreshAnalysis]() { refreshAnalysis(); }, + [](const std::string&) {}); + } else if (ddCode == QStringLiteral("dd_anomaly")) { + scene3dRepo->deleteAnomaly( + id, + [refreshAnalysis, refreshAnomalies]() { + refreshAnalysis(); // 刷三维体段列表(异常行消失) + refreshAnomalies(); // 重载异常 actor(场景同步移除) + }, + [](const std::string&) {}); + } + }); + // (O点位置/字体、旧栏「生成三维体」「勾选→渲染」接线均已退役——分别由 analysisTab 的 + // generateVolumeRequested / checkedDatasetsChanged 接管。) + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::sliceRequested, vtkWidget, + [interactionMgr](geopro::render::interact::SliceAxis axis, const QString& volumeDsId) { + // 切片建到被右键的三维体上(③:不再用 currentVolume)。该体须已渲染(有 image)。 + interactionMgr->addSlice(axis, volumeDsId.toStdString()); + }); + // 列表切片「保存」=把当前(可能被拖动过的)位姿覆盖更新到该 dd_slice;须该切片正在渲染才有位姿可取。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::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; + // 多体并发:保位姿归属"该切片所属体"(非 currentVolume)。 + spec.volumeDsId = interactionMgr->selectedSliceVolumeDsId(); + if (spec.volumeDsId.empty()) 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(analysisTab, &geopro::app::CategoryAnalysisTab::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(analysisTab, &geopro::app::CategoryAnalysisTab::sliceExportImageRequested, &window, + [&window, interactionMgr](const QString& dsId) { + if (!interactionMgr->selectSavedSlice(dsId.toStdString())) { + QMessageBox::information(&window, QStringLiteral("导出"), + QStringLiteral("请先勾选该切片渲染后再导出。")); + return; + } + vtkSmartPointer 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(analysisTab, &geopro::app::CategoryAnalysisTab::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("导出失败。")); + }); + // 色阶(三维体/切片):复刻原版「色阶配置」对话框,确定后体素 + 其切片随新色阶重渲染。 + // 仅对当前已渲染的三维体生效(切片色阶继承体色阶,经 InteractionManager 重建)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::colorScaleRequested, &window, + [&window, &colorTplRepo, &nav, sceneCtrl, sceneView](const QString& qid) { + const std::string dsId = qid.toStdString(); + // 多体并发:编辑"该体"(任一已渲染体,不限当前体)的色阶。 + const auto* vol = sceneView->volume(dsId); + if (!vol) { + QMessageBox::information( + &window, QStringLiteral("色阶"), + QStringLiteral("请先勾选该三维体使其渲染后再编辑色阶。")); + return; + } + // 等积分层需原始标量:从当前体素 image 抽取(无则等积退化线性)。 + // 大体素按步长抽样(等积分位无需全量点),避免主线程长循环卡 UI。 + std::vector samples; + if (vtkImageData* img = vol->image.Get()) { + if (vtkDataArray* sc = img->GetPointData()->GetScalars()) { + const vtkIdType n = sc->GetNumberOfTuples(); + if (n > 0) { + constexpr vtkIdType kMaxSamples = 200000; + const vtkIdType stride = + (n > kMaxSamples) ? (n / kMaxSamples) : 1; + samples.reserve( + static_cast(n / stride + 1)); + for (vtkIdType i = 0; i < n; i += stride) + samples.push_back(sc->GetComponent(i, 0)); + } + } + } + // 3D 无等值线,线形/标注配置忽略(用默认);仅取色阶应用。 + // 「另存为/打开」与「新建色阶/配色方案」走色阶模板仓储,projectId 取当前项目。 + // 3D 体无来源 lvl 模板 → lvlTemplateId 传空(覆盖复选框禁用,行为不变)。 + geopro::app::ColorScaleConfigDialog dlg( + vol->cs, vol->vmin, vol->vmax, std::move(samples), {}, &colorTplRepo, + nav.currentProjectId(), QString(), &window); + if (dlg.exec() == QDialog::Accepted) + sceneCtrl->setVolumeColorScale(dsId, dlg.colorScale()); + }); + + // ── 3D 异常控制(#4c):单条显隐 → 驱动 VTK 异常渲染 ────────── + // 单条显隐 → 切该异常 actor 可见性。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::anomalyVisibilityChanged, vtkWidget, + [sceneView, renderWindowPtr](const QString& id, bool vis) { + sceneView->setAnomalyVisible(id.toStdString(), vis); + renderWindowPtr->Render(); + }); + // 树选中切片/异常 → VTK 高亮联动(正向 list→VTK;反向 VTK→list 需拾取回调,见 OPT-002)。 + QObject::connect(analysisTab, &geopro::app::CategoryAnalysisTab::datasetSelected, vtkWidget, + [sceneView, interactionMgr, renderWindowPtr](const QString& dsId, + const QString& ddCode) { + const std::string id = dsId.toStdString(); + // 选中项决定高亮:异常↔切片互斥,选其它对象两者都清(否则切到别的对象后切片/异常仍高亮)。 + if (ddCode == QStringLiteral("dd_anomaly")) { + sceneView->setSelectedAnomaly(id); + interactionMgr->deselectSlice(); + } else if (ddCode == QStringLiteral("dd_slice")) { + sceneView->setSelectedAnomaly(std::string{}); + interactionMgr->selectSavedSlice(id); // 选中已渲染的该切片(高亮) + } else { + sceneView->setSelectedAnomaly(std::string{}); + interactionMgr->deselectSlice(); + } + renderWindowPtr->Render(); + }); + // 反向 VTK→list:在 VTK 里点中/选中一张切片 → 在三维体段树里同步选中该切片行(②反向)。 + // 点空白/清选(dsId 空) → 一并清 VTK 异常高亮(否则取消选中后异常图形仍高亮,用户反馈)。 + interactionMgr->onSliceSelectionChanged = [drawer, sceneView, renderWindowPtr]( + const std::string& dsId) { + if (auto* sec = drawer->analysisTab()->section("voxel")) + sec->selectItem(QString::fromStdString(dsId)); + if (dsId.empty()) { + sceneView->setSelectedAnomaly(std::string{}); + renderWindowPtr->Render(); + } + }; + // 已保存切片经 VTK 右键「关闭」→ 取消数据列表对应切片项的勾选(否则列表仍勾选,用户反馈)。 + interactionMgr->onSliceClosed = [drawer](const std::string& dsId) { + if (auto* sec = drawer->analysisTab()->section("voxel")) + sec->setChecked(QString::fromStdString(dsId), false); + }; + + // ── 二维数据集栏:天地图底图开关(③,复用轨迹图 token,经同一共享 GeoLocalFrame 配准)── + auto* basemap = new geopro::app::TileBasemap(*scene, renderWindowPtr, frame, &window); + // 当前底图选择(默认 天地图=Satellite,对齐 Column2DDataset 默认项);数据重锚后据此在数据位置加载。 + auto basemapKind = + std::make_shared(geopro::app::TileBasemap::Satellite); + QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::basemapChanged, basemap, + [basemap, basemapKind](int idx) { + // 地图下拉:0 天地图(卫星影像) / 1 Google(暂未实现→隐藏) / 2 隐藏。 + *basemapKind = (idx == 0) ? geopro::app::TileBasemap::Satellite + : geopro::app::TileBasemap::Hidden; + basemap->show(*basemapKind); + }); + // ── 二维数据集栏:勾选足迹(测线/轨迹) → 平铺进 View3D 地图;2D视图下拉控摆放高度 ── + // 足迹经控制器 loadMapLine(Api3dRepository 走 dd/ert/trajectory/line 端点) → addMapLine 至 + // 当前摆放 Z,与帘面/底图共享 GeoLocalFrame 配准。与 3D 勾选集独立、按 dsId 增量。 + QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::checkedDatasetsChanged, + sceneCtrl, &geopro::controller::VtkSceneController::setChecked2DDatasets); + // 2D视图下拉(0关闭/1 Z=0/2顶部/3底部/4自定义) + 自定义 Z → 控制器重摆已勾选足迹。 + auto custom2dZ = std::make_shared(0.0); + // 默认 1(Z=0):与「2D视图」下拉可见默认项(setCurrentIndex(1))及控制器默认摆放一致—— + // 组合框初始 view2DModeChanged 因 connect 前发射而丢失,此处不可依赖其同步初值。 + auto view2dMode = std::make_shared(1); + QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::view2DModeChanged, sceneCtrl, + [sceneCtrl, custom2dZ, view2dMode](int mode) { + *view2dMode = mode; + sceneCtrl->set2DPlacement(mode, *custom2dZ); + }); + // 自定义 Z 变化:记录;若当前正处自定义模式则即时重摆(控制器内 changed 判定避免无谓重画)。 + QObject::connect(drawer->col2D(), &geopro::app::Column2DDataset::customZChanged, sceneCtrl, + [sceneCtrl, custom2dZ, view2dMode](double z) { + *custom2dZ = z; + if (*view2dMode == 4) sceneCtrl->set2DPlacement(4, z); + }); + + // ── 二维分析改造 A 期:切「三维分析/二维分析」tab → 一场景两相机 ────────────────── + // 三处协作:①切片隐藏+交互锁(仅平移+缩放) [InteractionManager];②按目标维度重置取景基线 + // [VtkSceneController]——使切换后该维度首条数据自动取景;③维度显隐+近俯视/自由相机+取景+坐标轴+ + // 渲染 [VtkSceneView]。顺序:先 ①②(都不渲染),最后 ③ 收尾统一渲染。只翻可见标志、不清空/重建 → + // 切换瞬时;地形+底图常驻。 + QObject::connect(drawer, &geopro::app::ColumnDrawer::analysisModeChanged, &window, + [interactionMgr, sceneCtrl, sceneView, viewToolbar](bool is2D) { + interactionMgr->setMode2D(is2D); + sceneCtrl->onAnalysisModeChanged(is2D); + sceneView->setAnalysisMode2D(is2D); + viewToolbar->setAnalysisMode2D(is2D); // 二维下禁用 6 向快捷视图 + }); + + // 首个真实剖面到达 → frame 重锚到数据 lat/lon 后,把选中的底图加载到数据所在位置 + // (默认天地图即在此刻出现,避免启动时在样本原点拉无关瓦片)。 + sceneView->onFrameReanchored = [basemap, basemapKind]() { + if (*basemapKind != geopro::app::TileBasemap::Hidden) basemap->show(*basemapKind); + }; + // 相机程序化变化(取景/预设/缩放)后,底图按新视锥重算覆盖(治首帧部分瓦片需手动微动才出)。 + sceneView->onCameraChanged = [basemap]() { basemap->refresh(); }; + // 底图最大范围按当前勾选剖面合并范围动态定(随增删自动伸缩);刷新时实时查询。 + basemap->setDataRadiusProvider([sceneView]() { return sceneView->dataHorizontalRadius(); }); + // 垂直夸张:地形须与剖面用同一 VE 才对齐(都按真实高程×VE)。单一来源 kVerticalExaggeration 下发 + // 控制器(上方)/底图;运行时 VE 改由坐标轴设置抽屉「应用」下发(旧三维数据集栏滑块已退役)。 + basemap->setVerticalExaggeration(kVerticalExaggeration); + // 坐标轴设置抽屉「应用」:一次性下发 轴显示/单位/范围(增量重建,不重绘数据)+ 放大系数(VE)。 + // VE 改几何须重建,但走保留相机的重建(setVerticalExaggeration 内 preserveCamera)→ 当前视角直接 + // 重绘,不再先跳远视角;底图同步 VE。仅在 VE 实际变化时下发,避免无谓重建。 + auto lastVE = std::make_shared(kVerticalExaggeration); + QObject::connect(axesPanel, &geopro::app::AxesSettingsPanel::applied, &window, + [axesPanel, sceneCtrl, basemap, lastVE](geopro::app::AxisRange x, + geopro::app::AxisRange y, + geopro::app::AxisRange z, int unitIdx, + double scale) { + const bool anyShow = x.show || y.show || z.show; + // 每轴:可见性=显示开关;自定义范围=面板 min/max(按当前单位),真正改刻度。 + auto cfg = [](const geopro::app::AxisRange& a) { + return geopro::controller::AxisRangeCfg{a.show, true, a.min, a.max}; + }; + sceneCtrl->setAxesConfig(anyShow ? geopro::controller::AxesMode::Standard + : geopro::controller::AxesMode::None, + unitIdx == 1 ? geopro::controller::AxesUnit::Feet + : geopro::controller::AxesUnit::Meter, + cfg(x), cfg(y), cfg(z)); + if (scale > 0 && scale != *lastVE) { // 放大系数变化 → 保留相机重建 + 底图同步 + *lastVE = scale; + sceneCtrl->setVerticalExaggeration(scale); + basemap->setVerticalExaggeration(scale); + } + axesPanel->hide(); + }); // ── 中央“空状态”引导浮层:未接入真实 sections 时,引导首次使用者从左侧入手。── // 透明背景 + 鼠标穿透(不挡 QVTK 交互);CenterOverlay 随视口尺寸保持居中; // 接入真实中央数据后改成依 sections 是否为空调 setVisible 即可。 - auto* emptyState = new QFrame(centerWidget); + // 挂在 vtkWidget 下(而非 centerWidget):使其与工具条/提示同属 vtkWidget 子级,CenterOverlay 会把它 + // 压到子级最底(在 GL 之上、工具条/提示之下)→ 工具条永远在最上层、引导层在最下层(修视图缩小时 + // 引导层挡住工具条)。 + auto* emptyState = new QFrame(vtkWidget); emptyState->setObjectName(QStringLiteral("centralEmpty")); emptyState->setAttribute(Qt::WA_TransparentForMouseEvents); // 背景取 canvas/bg(#0B1320)——与画布同色:在原生 GL 上覆盖透明无法生效(会回退成不透明浅底), @@ -387,17 +1294,19 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re .pixmap(56, 56)); esIcon->setAlignment(Qt::AlignCenter); - auto* esTitle = new QLabel(QStringLiteral("选择左侧数据集开始分析"), emptyState); + auto* esTitle = new QLabel(QStringLiteral("勾选左侧数据集开始渲染"), emptyState); esTitle->setAlignment(Qt::AlignCenter); + esTitle->setWordWrap(true); // 窄时换行,不撑宽浮层 geopro::app::applyTokenizedStyleSheet( esTitle, QStringLiteral("color:{{canvas/text}}; font-size:%1px; font-weight:%2;") .arg(geopro::app::scaledPx(geopro::app::type::kHeading)) .arg(geopro::app::type::kWeightSemibold)); - auto* esHint = new QLabel(QStringLiteral("单击左侧采集批次,查看反演剖面与异常点\n" - "切到「三维视图」可叠加帘面、体素与地形图层"), + auto* esHint = new QLabel(QStringLiteral("在左侧「三维数据集 / 二维数据集 / 三维分析」栏勾选数据集,\n" + "在此叠加显示;可切换二维 / 三维视图。"), emptyState); esHint->setAlignment(Qt::AlignCenter); + esHint->setWordWrap(true); // 窄时换行,不撑宽浮层 geopro::app::applyTokenizedStyleSheet( esHint, QStringLiteral("color:{{canvas/text-dim}}; font-size:%1px;").arg(geopro::app::scaledPx(geopro::app::type::kBody))); @@ -407,22 +1316,34 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re esLay->addWidget(esHint); auto* emptyCentering = new CenterOverlay(emptyState, vtkWidget); + // 引导层定位后,把工具条与提示浮层 raise 到其上 → 工具条永在最上层(修:缩小渲染区时引导层挡工具条)。 + emptyCentering->setRaiseAfter({viewToolbar, anomalyHint, elevHint}); emptyCentering->reposition(); - auto* vtkDock = new ads::CDockWidget(QStringLiteral("二维地图/三维视图")); + auto* vtkDock = new ads::CDockWidget(QStringLiteral("VTK视图")); vtkDock->setWidget(centerWidget); auto* centerDockArea = dockManager->addDockWidget(ads::CenterDockWidgetArea, vtkDock); + // 项目管理「直接嵌入」web 页:作为中央 QStackedWidget 的第二页,整窗加载(覆盖整个工作台)。 + // 单实例复用——点不同菜单项时重新 load;token 已注入页面 localStorage。 + auto* projectWebView = new geopro::app::ProjectWebView(sessionToken); + centralStack->addWidget(projectWebView); // index 1:项目管理 web 整窗 + // ── 下方「数据详情」dock:平面图表多 Tab 面板(QGraphicsView,VTK 仅算几何)── // 单击数据集 → 聚焦已开页;双击 → 新建/聚焦页(真实反演剖面/散点/异常/色阶)。 auto* detailPanel = new geopro::app::DatasetDetailPanel(); + // 注入色阶模板仓储 + 当前 projectId 取值回调(网格剖面「色阶配置」编辑器的另存/打开/新建色阶)。 + detailPanel->setColorTemplateRepo(&colorTplRepo, [&nav] { return nav.currentProjectId(); }); + // 注入反演命令仓储(measurement 反演运算/生成视电阻率)。projectId 取值仍由页内 projectIdGetter 提供。 + detailPanel->setCommandRepo(&cmdRepo); auto* detailDock = new ads::CDockWidget(QStringLiteral("数据详情")); // ForceNoScrollArea:禁止 ADS 默认把整块内容(含标题栏/页签栏)包进 QScrollArea。 // 否则内容最小尺寸超过 dock 时,整个面板(标题+页签+图)一起滚动。改为内容自适应填充; // 需要时由内层(图表内容区)自行滚动,标题/页签固定。 - detailDock->setWidget(wrapWithHeader( - geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel), - ads::CDockWidget::ForceNoScrollArea); + auto* detailHeader = wrapWithHeader( + geopro::app::Glyph::Detail, QStringLiteral("数据详情"), detailPanel, + {{geopro::app::Glyph::Fullscreen, QStringLiteral("全屏")}}); + detailDock->setWidget(detailHeader, ads::CDockWidget::ForceNoScrollArea); // 放在中央视图下方。 dockManager->addDockWidget(ads::BottomDockWidgetArea, detailDock, centerDockArea); @@ -483,8 +1404,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re rightDock->setWidget(anomalyPanel.container); auto* rightArea = dockManager->addDockWidget(ads::RightDockWidgetArea, rightDock); - // 右下 dock:属性(数据集属性,上半只读元字段 + 下半可编辑描述)。 - auto* propView = new geopro::app::DatasetAttrPanel(projectRepo); + // 右下 dock:属性(数据集属性,只读元字段;不可编辑)。 + auto* propView = new geopro::app::DatasetAttrPanel(); auto* propDock = new ads::CDockWidget(QStringLiteral("数据集属性")); propDock->setWidget( wrapWithHeader(geopro::app::Glyph::Property, QStringLiteral("数据集属性"), propView)); @@ -503,17 +1424,12 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; hideDockTitleBars(); - // 中央编排已解耦到 CentralScene::rebuildCentralScene(数据驱动)。本轮空 sections → 空背景占位。 - // 下一轮:用真实 DS 数据构建 sections 调同一 helper 即复活。 - auto rebuildCentral = [scene, rendererPtr, renderWindowPtr, viewMode, showCurtain, frame]() { - geopro::app::rebuildCentralScene(*scene, rendererPtr, renderWindowPtr, *viewMode, - std::vector{}, *showCurtain, - *frame, kVerticalExaggeration); - }; + // 中央渲染由 sceneCtrl(VtkSceneController)驱动:勾选对象/2D-3D切换/图层勾选/主题 → 重建场景。 + // (旧 rebuildCentral lambda + 裸 show* 标志已由控制器取代。) // ── 单击左下数据列表的采集批次(DS) → 属性表单 + 聚焦详情已开页 ── QObject::connect(datasetList, &QTreeWidget::itemClicked, datasetList, - [&nav, &detailCtrl, propView](QTreeWidgetItem* item, int) { + [&nav, &detailCtrl](QTreeWidgetItem* item, int) { if (item->data(0, geopro::app::kDsLoadMoreRole).toBool()) { nav.loadMoreData(); return; @@ -521,7 +1437,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); if (dsId.isEmpty()) return; nav.selectDataset(dsId); // 只读元字段表单(datasetDetailLoaded) - propView->selectDataset(dsId); // 可编辑描述:回填 + 启用保存 detailCtrl.focusDataset(dsId); // 单击=聚焦已开页 }); @@ -531,7 +1446,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re const QString dsId = item->data(0, geopro::app::kDsIdRole).toString(); const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); const QString dsName = item->data(0, geopro::app::kDsNameRole).toString(); - if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName); + // tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。 + const QString tmObjectId = + item->data(0, geopro::app::kDsTmObjectIdRole).toString(); + if (!dsId.isEmpty()) detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId); }); // ── 控制器信号 → 详情面板(tab 引擎):开页 / 页签就绪 / 加载中 / 聚焦 ── @@ -568,77 +1486,59 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re } }); - // 「视图详情」浮层显隐:仅三维显示,置于 QVTK 左上(工具条下方)并置顶。 - auto showLayerPanel = [layerPanel, viewHeader](bool show3D) { - if (show3D) { - layerPanel->move(14, viewHeader->height() + 12); - layerPanel->adjustSize(); - layerPanel->setVisible(true); - layerPanel->raise(); - } else { - layerPanel->setVisible(false); - } - }; + // ── 左上对象树勾选 → 拉取各 TM 的 ds 子树,按维度分发到三栏列表(spec §6.1/§8)── + // 渲染由分段勾选框驱动(Task 12:CategoryAnalysisTab::checkedDatasetsChanged → setCheckedDatasets)。 + auto generation = std::make_shared(0); + QObject::connect( + objectTree, &geopro::app::ObjectTreePanel::checkedSourcesChanged, &window, + [&projectRepo, &nav, drawer, emptyState, generation, lastSourceRows, + refreshAnalysis](const QList& sources) { + const unsigned long long myGen = ++(*generation); + emptyState->setVisible(sources.isEmpty()); // 有勾选→隐藏引导层,露出中央渲染 + if (sources.isEmpty()) { + *lastSourceRows = {}; + refreshAnalysis(); // 清空 5 段(客户端三维体仍驻留) + col2D + return; + } + // 多源异步汇总:每个源(TM / GS·项目根直挂)按 confType 取整棵 ds 子树,全部回来后 splitByCategory 分 5 段。 + auto acc = std::make_shared>(); + auto remaining = std::make_shared(sources.size()); + auto finish = [acc, generation, myGen, lastSourceRows, refreshAnalysis]() { + if (*generation != myGen) return; // 已被更新的勾选批次取代→丢弃陈旧结果 + *lastSourceRows = *acc; // 全部对象树 ds 作分析数据源 + refreshAnalysis(); // splitByCategory→5段 + 合并三维体/切片 + dim2D→col2D + }; + for (const geopro::data::DataSource& src : sources) { + // 第3参 confType:1=GS/项目根(直挂 ds),2=TM(测线下 ds)——透传给 loadRowsAsync(spec §6)。 + geopro::data::NavRequest* req = projectRepo.loadRowsAsync( + nav.currentProjectId().toStdString(), src.id, src.confType, 3, 1, 100000); + QObject::connect(req, &geopro::data::NavRequest::done, drawer, + [acc, remaining, finish](const QVariant& v) { + auto page = qvariant_cast(v); + acc->insert(acc->end(), page.rows.begin(), page.rows.end()); + if (--(*remaining) == 0) finish(); + }); + QObject::connect(req, &geopro::data::NavRequest::failed, drawer, + [remaining, finish](const QString&) { + if (--(*remaining) == 0) finish(); // 单个失败不卡死,其余照常分发 + }); + } + }); - // ── 工具条「二维地图/三维视图」:切换互斥视图 → 重建内容 + 图层浮层显隐 ── - QObject::connect(act2D, &QAbstractButton::clicked, vtkWidget, - [viewMode, rebuildCentral, showLayerPanel]() { - *viewMode = ViewMode::Map2D; - showLayerPanel(false); - rebuildCentral(); - }); - QObject::connect(act3D, &QAbstractButton::clicked, vtkWidget, - [viewMode, rebuildCentral, showLayerPanel]() { - *viewMode = ViewMode::View3D; - showLayerPanel(true); - rebuildCentral(); - }); + // ── 启动:建立一次中央视图。三栏重构后删除了 2D/3D 切换,统一固定为三维视图 + // (帘面默认开启 showCurtain_=true,勾选 dd_section → 帘面)。无勾选 → 空场景 + 背景。 + sceneCtrl->setViewMode(geopro::controller::ViewMode::View3D); - // ──「视图详情」图层勾选 → 更新图层显隐 → 重建中央 ── - QObject::connect(chkCurtain, &QCheckBox::toggled, vtkWidget, - [showCurtain, rebuildCentral](bool on) { - *showCurtain = on; - rebuildCentral(); - }); - QObject::connect(chkVoxel, &QCheckBox::toggled, vtkWidget, - [showVoxel, rebuildCentral](bool on) { - *showVoxel = on; - rebuildCentral(); - }); - QObject::connect(chkTerrain, &QCheckBox::toggled, vtkWidget, - [showTerrain, rebuildCentral](bool on) { - *showTerrain = on; - rebuildCentral(); - }); - QObject::connect(chkSlice, &QCheckBox::toggled, vtkWidget, - [showSlice, rebuildCentral](bool on) { - *showSlice = on; - rebuildCentral(); - }); + // VTK 背景随主题切换:控制器重渲染(走完整渲染路径、末尾必 Render)。 + // context 用 sceneCtrl(非 window):ThemeManager 是进程级单例,连接须随 sceneCtrl 析构自动断开, + // 否则 window 析构期间 sceneCtrl(其孙级子对象)已销毁、主题异步变化会触悬垂指针。 + QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, sceneCtrl, + [sceneCtrl]() { sceneCtrl->rebuild(); }); - // ── 启动:建立一次空背景中央视图(真实 sections 数据由下一轮接入)。 - rebuildCentral(); - - // VTK 背景随主题切换:直接重跑 rebuildCentral(走完整渲染路径、末尾必 Render, - // 比手动 SetBackground+Render 稳;兼顾 syncSystemTheme 异步切暗的时序)。 - QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, &window, - [rebuildCentral]() { - rebuildCentral(); - }); - - // 顶部应用区(静态视觉壳,对齐原型):上=菜单栏(视图/项目管理/业务工具/设备), - // 下=工具条(工作空间切换 + 项目 + 帮助/通知/设置 + 用户)。纵向堆叠后挂到主窗口顶部。 - geopro::app::TopBar* topBar = nullptr; - { - auto* topChrome = new QWidget(&window); - auto* topLayout = new QVBoxLayout(topChrome); - topLayout->setContentsMargins(0, 0, 0, 0); - topLayout->setSpacing(0); - topLayout->addWidget(geopro::app::buildMenuBar(topChrome)); - topBar = new geopro::app::TopBar(topChrome); - topLayout->addWidget(topBar); - window.setMenuWidget(topChrome); - } + // 顶部应用区:单行工具条(工作空间/项目切换 + 一级菜单按钮 视图/项目管理/业务工具/设备 + // + 帮助/通知/设置 + 用户)。菜单栏已去除,一级菜单改为工具条上的下拉按钮。 + geopro::app::TopBar* topBar = new geopro::app::TopBar(&window); + window.setMenuWidget(topBar); // ── 控制器 ↔ UI 信号接线(导航壳)────────────────────────────────────── // "加载更多"行:列表末尾若已加载数 < 总数,放一行可点击的"加载更多(已/共)"。 @@ -677,8 +1577,55 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }; QObject::connect(topBar, &geopro::app::TopBar::workspaceSwitchRequested, &nav, &geopro::controller::WorkbenchNavController::switchWorkspace); + + // 项目管理「直接嵌入」web 页:拼当前项目的嵌入 URL,在中央区整窗加载(切到 web 页)。 + // space=3 为「项目空间(projectSpace)」固定常量——Excel 所有 projectSpace 页均 space=3, + // 与租户/工作空间 id 无关(误用工作空间 snowflake 会被后端拒:space 参数无效)。 + QObject::connect( + topBar, &geopro::app::TopBar::webPageRequested, &window, + [projectWebView, centralStack, &nav](const QString& /*title*/, const QString& target) { + const QString url = + QStringLiteral("http://tenant.geomative.cn/#/embed?space=3&projectId=%1&target=%2") + .arg(nav.currentProjectId(), target); + projectWebView->load(url); + centralStack->setCurrentWidget(projectWebView); // 整窗切到 web 页 + }); + // 视图菜单「分析视图」:中央区切回工作台(默认视图)。 + QObject::connect(topBar, &geopro::app::TopBar::analysisViewRequested, &window, + [centralStack, dockManager]() { + centralStack->setCurrentWidget(dockManager); + }); + + // 切换到「不同项目」时先清空中央区,避免新项目残留旧项目的三栏数据与 VTK 渲染。 + // 仅真正换项目用(delete-refresh 等 switchProject(currentProjectId) 不走此处,避免误清)。 + auto clearCentral = [sceneCtrl, emptyState, checkedProfiles, checkedAnalysis, + pushChecked, lastSourceRows, refreshAnalysis, checkedSliceIds, + syncSlices, basemap, sceneView, scene3dRepo]() { + // 切项目:先清内存态三维体/切片/异常(否则上个项目的产物残留进新项目列表),再 refreshAnalysis。 + scene3dRepo->clearMockData(); + // 数据源清空 → 5 段 + col2D 清空(refreshAnalysis 内 setBuckets/dim2D)。 + *lastSourceRows = {}; + refreshAnalysis(); + // 勾选集清空并下发空到 VTK(帘面/体素/切片/2D 足迹全部撤场)。 + checkedProfiles->clear(); + checkedAnalysis->clear(); + checkedSliceIds->clear(); + pushChecked(); // setCheckedDatasets({}) → 帘面/体素清空 + syncSlices(); // 切片随空勾选调和 + sceneCtrl->setChecked2DDatasets({}); // 2D 足迹显式撤场(与 col2D 空勾选双保险) + // 复位重锚标志:增量勾选不走 clear(),不复位则旧标志残留 → 新项目数据不重锚 → + // onFrameReanchored 不触发 → 下面 hide() 的底图永不再显。复位后新项目首个数据重锚→重显。 + sceneView->resetFrameAnchor(); + basemap->hide(); // 底图瓦片清空(锚在旧项目位置;新项目数据到来 re-anchor 时按新位置重显) + // 空状态浮层恢复(对象树勾选会随 structureLoaded 重建而清,无需手动)。 + emptyState->setVisible(true); + }; + QObject::connect(topBar, &geopro::app::TopBar::projectSwitchRequested, &nav, - &geopro::controller::WorkbenchNavController::switchProject); + [&nav, clearCentral](const QString& id) { + if (id != nav.currentProjectId()) clearCentral(); // 真正换项目才清 + nav.switchProject(id); + }); // 退出登录:清除记住的凭证(QtKeychain+QSettings) → 重启应用回到登录页。 QObject::connect(topBar, &geopro::app::TopBar::logoutRequested, &window, []() { geopro::app::forgetSession(); @@ -692,12 +1639,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re dlg.exec(); }); QObject::connect(topBar, &geopro::app::TopBar::allProjectsRequested, &window, - [&projectRepo, &nav, topBar, &window]() { + [&projectRepo, &nav, topBar, &window, clearCentral]() { auto* dlg = new geopro::app::ProjectListDialog(projectRepo, &window); dlg->setAttribute(Qt::WA_DeleteOnClose); QObject::connect(dlg, &geopro::app::ProjectListDialog::projectChosen, &nav, - [&nav, topBar](const QString& id, const QString& name) { + [&nav, topBar, clearCentral](const QString& id, + const QString& name) { topBar->setProjectButtonText(name); + if (id != nav.currentProjectId()) + clearCentral(); // 真正换项目才清 nav.switchProject(id); }); dlg->exec(); @@ -729,8 +1679,8 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // ── 交互操作接线(对象/数据集视图:右键菜单 + 快速筛选 + 增删,2D/3D 相关占位)──────── auto* anomalyTabGroup = anomalyPanel.tabGroup; // 捕指针(anomalyPanel 为局部,勿按引用捕获) - // 状态栏轻提示(toast 替代;window 生命周期覆盖整个会话,按引用捕获安全)。 - auto toast = [&window](const QString& msg) { window.statusBar()->showMessage(msg, 4000); }; + // 浮动轻提示(规范 §7.7 Toast:底部居中浮出小卡片;window 生命周期覆盖整个会话,按引用捕获安全)。 + auto toast = [&window](const QString& msg) { geopro::app::showToast(&window, msg); }; // 表头操作按钮定位器:按 glyphId 在已包装面板表头里找具体 QToolButton。 auto findHeaderAction = [](QWidget* box, geopro::app::Glyph g) -> QToolButton* { const int gid = static_cast(g); @@ -739,6 +1689,49 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re return nullptr; }; + // ── 全屏切换:VTK视图 / 数据详情 表头右上角「全屏」按钮 ────────────────────────── + // 点击 → 目标 dock 全屏(隐藏其余所有 dock);再点 → 还原(全部显示)。 + // 使用 ADS CDockWidget::toggleView(bool) 控制可见性(标准 ADS API,v4+)。 + { + const QList allDocks{vtkDock, detailDock, leftDock, datasetDock, + rightDock, propDock}; + auto applyFullscreen = [](ads::CDockWidget* target, + const QList& all, bool on) { + for (ads::CDockWidget* d : all) { + if (d == target) continue; + d->toggleView(!on); // on=进入全屏→隐藏其它; off=还原→全部显示 + } + }; + + auto* vtkFsBtn = findHeaderAction(viewHeader, geopro::app::Glyph::Fullscreen); + auto* detailFsBtn = findHeaderAction(detailHeader, geopro::app::Glyph::Fullscreen); + + if (vtkFsBtn) { + vtkFsBtn->setCheckable(true); + QObject::connect(vtkFsBtn, &QToolButton::toggled, &window, + [applyFullscreen, vtkDock, allDocks, detailFsBtn, drawer](bool on) { + if (on && detailFsBtn && detailFsBtn->isChecked()) { + QSignalBlocker b(detailFsBtn); + detailFsBtn->setChecked(false); + } + // VTK 全屏含左侧三栏(drawer 本就在 vtkDock 内):进入时确保展开可见。 + if (on) drawer->expand(); + applyFullscreen(vtkDock, allDocks, on); + }); + } + if (detailFsBtn) { + detailFsBtn->setCheckable(true); + QObject::connect(detailFsBtn, &QToolButton::toggled, &window, + [applyFullscreen, detailDock, allDocks, vtkFsBtn](bool on) { + if (on && vtkFsBtn && vtkFsBtn->isChecked()) { + QSignalBlocker b(vtkFsBtn); + vtkFsBtn->setChecked(false); + } + applyFullscreen(detailDock, allDocks, on); + }); + } + } + // 对象树右键菜单动作路由。 QObject::connect( objectTree, &geopro::app::ObjectTreePanel::contextActionRequested, &window, @@ -762,19 +1755,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QStringLiteral("确定删除「%1」?该操作不可撤销。").arg(name), QMessageBox::Yes | QMessageBox::No, QMessageBox::No); if (r == QMessageBox::Yes) nav.deleteObject(id, confType); - } else if (action == QStringLiteral("edit")) { - // 动态表单编辑器:拉 project/getDynamicForm 真实 schema 渲染可编辑表单; - // 确定→校验+提交(PUT,body 为推断结构,确切性以服务端为准)→成功刷新结构。 - auto* dlg = new geopro::app::ObjectFormDialog(projectRepo, nav.currentProjectId(), - &window); - dlg->setAttribute(Qt::WA_DeleteOnClose); - dlg->editObject(typeId, id, confType, name, objectTree->parentObjectId(id)); - QObject::connect(dlg, &geopro::app::ObjectFormDialog::submitted, &window, - [&nav, toast](int) { - toast(QStringLiteral("保存成功")); - nav.switchProject(nav.currentProjectId()); - }); - dlg->open(); } else if (action == QStringLiteral("newTm")) { // 新建 TM:对话框拉 tmList(全局方法类型)选类型 → getDynamicForm(type=2) → POST /tmObject。 // 父对象:在 GS/项目根上=该节点;在 TM 上=其父 GS/根(即新建同级 TM)。 @@ -827,9 +1807,6 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re toast(QStringLiteral("保存成功")); nav.switchProject(nav.currentProjectId()); }); - // 数据集属性面板描述保存成功 → toast。 - QObject::connect(propView, &geopro::app::DatasetAttrPanel::saved, &window, - [toast]() { toast(QStringLiteral("描述已保存")); }); // 增删改结果 → 状态栏反馈(成功后控制器已自行刷新)。 QObject::connect(&nav, &geopro::controller::WorkbenchNavController::mutationSucceeded, &window, @@ -838,7 +1815,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re [&window](const QString& msg) { auto* sb = window.statusBar(); sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}") - .arg(QString::fromUtf8(geopro::app::semantic::kDanger))); + .arg(geopro::app::token("status/danger"))); sb->showMessage(QStringLiteral("操作失败:%1").arg(msg), 6000); QTimer::singleShot(6000, sb, [sb]() { sb->setStyleSheet(QString()); }); }); @@ -883,16 +1860,24 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); dlg->open(); }; - // 按选中类型决定菜单项:选 项目根/GS → 新建GS+TM;选 TM → 仅新建TM(同级)。 - // 父对象由 currentParentForNew() 统一给出(TM→父GS、GS/根→自身、未选→根),三种情况均正确。 + // 选 项目根/GS → 可新建 GS+TM。选中 TM 时按钮已被禁用(测线下不能新增对象), + // 故此处仅处理非 TM;父对象由 currentParentForNew() 给出(GS/根→自身、未选→根)。 QMenu m(objectTree); - if (objectTree->currentSelectedConfType() != 2) // 非 TM:可新建检测对象(GS) - m.addAction(QStringLiteral("新建检测对象"), objectTree, - [openForm]() { openForm(true); }); + m.addAction(QStringLiteral("新建检测对象"), objectTree, + [openForm]() { openForm(true); }); m.addAction(QStringLiteral("新建方法对象"), objectTree, [openForm]() { openForm(false); }); m.exec(objAddBtn->mapToGlobal(QPoint(0, objAddBtn->height()))); }); + // 选中 TM(方法对象,confType=2)→ 禁用「新增」:测线下不能新增对象。 + // 选根/GS 恢复可用;切项目/重载结构后选中清空,亦恢复可用。 + QObject::connect(objectTree, &geopro::app::ObjectTreePanel::objectSelectedForEdit, objAddBtn, + [objAddBtn](const QString&, int confType, const QString&, const QString&, + bool) { objAddBtn->setEnabled(confType != 2); }); + QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objAddBtn, + [objAddBtn](const QString&, const std::vector&) { + objAddBtn->setEnabled(true); + }); } // 模型/插件目录:全局一次性拉取并缓存,供数据集右键「插件」子菜单同步填充。 @@ -917,14 +1902,15 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (dsId.isEmpty()) return; const QString ddCode = item->data(0, geopro::app::kDsDdCodeRole).toString(); const QString dsName = item->data(0, geopro::app::kDsNameRole).toString(); + // tmObjectId(白化 structParentId)从行读出透传,使白化模板列表非空。 + const QString tmObjectId = item->data(0, geopro::app::kDsTmObjectIdRole).toString(); QMenu menu(datasetList); menu.addAction(QStringLiteral("数据集详情"), datasetList, - [&detailCtrl, dsId, ddCode, dsName]() { - detailCtrl.openDataset(dsId, ddCode, dsName); + [&detailCtrl, dsId, ddCode, dsName, tmObjectId]() { + detailCtrl.openDataset(dsId, ddCode, dsName, tmObjectId); }); - menu.addAction(QStringLiteral("属性"), datasetList, [&nav, propView, dsId]() { - nav.selectDataset(dsId); // 只读元字段 - propView->selectDataset(dsId); // 可编辑描述 + menu.addAction(QStringLiteral("属性"), datasetList, [&nav, dsId]() { + nav.selectDataset(dsId); // 只读元字段 }); menu.addSeparator(); QMenu* plugins = menu.addMenu(QStringLiteral("插件")); @@ -1070,9 +2056,10 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::structureLoaded, objectTree, [objectTree, datasetList, fileList, datasetTitle, datasetTabs, exceptionPanel, - objAttrView, propView, anomalyBadge]( + objAttrView, propView, anomalyBadge, lastStructNodes]( const QString& projectName, const std::vector& nodes) { + *lastStructNodes = nodes; // 供「生成三维体」对话框的生成位置下拉 objectTree->setStructure(projectName, nodes); datasetList->clear(); fileList->clear(); @@ -1086,10 +2073,11 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re }); QObject::connect(&nav, &geopro::controller::WorkbenchNavController::datasetsLoaded, datasetList, [removeTreeLoadMore, addTreeLoadMore, datasetList, datasetTitle, datasetTabs]( - const QString&, const std::vector& rows, int total, - bool append) { + const QString& tmObjectId, const std::vector& rows, + int total, bool append) { removeTreeLoadMore(datasetList); - geopro::app::populateDatasetList(datasetList, rows, append); + // tmObjectId(本批所属 TM 对象 id)存入每项 → 白化对话框透传用(structParentId)。 + geopro::app::populateDatasetList(datasetList, rows, append, tmObjectId); const int loaded = addTreeLoadMore(datasetList, total); if (datasetTitle) datasetTitle->setText(QStringLiteral("数据集")); datasetTabs->setTabText( @@ -1119,7 +2107,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re // 状态栏错误反馈:临时染 danger 红,8s 后随消息超时还原全局中性色。 auto* sb = window.statusBar(); sb->setStyleSheet(QStringLiteral("QStatusBar{color:%1;}") - .arg(QString::fromUtf8(geopro::app::semantic::kDanger))); + .arg(geopro::app::token("status/danger"))); sb->showMessage(QStringLiteral("加载失败(%1):%2").arg(stage, msg), 8000); QTimer::singleShot(8000, sb, [sb]() { sb->setStyleSheet(QString()); }); }); @@ -1146,7 +2134,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re if (!geo.isEmpty()) window.restoreGeometry(geo); // 注意:ADS 按 dock 唯一名作键。改过 dock 名后旧布局会失配 → bump 此键丢弃旧布局, // 回落到下方 addDockWidget 的默认排布(再改 dock 名时同样要 bump 版本号)。 - const QByteArray dockState = settings.value(QStringLiteral("ui/dockState_v2")).toByteArray(); + const QByteArray dockState = settings.value(QStringLiteral("ui/dockState_v3")).toByteArray(); if (!dockState.isEmpty()) { dockManager->restoreState(dockState); // restoreState 重建停靠区会重新显示 ADS 标题栏,再隐藏一次保持“无双标题”。 @@ -1157,7 +2145,7 @@ void buildWorkbench(QMainWindow& window, geopro::data::LocalSampleRepository& re QObject::connect(qApp, &QCoreApplication::aboutToQuit, dockManager, [dockManager, &window]() { QSettings settings; settings.setValue(QStringLiteral("ui/geometry"), window.saveGeometry()); - settings.setValue(QStringLiteral("ui/dockState_v2"), dockManager->saveState()); + settings.setValue(QStringLiteral("ui/dockState_v3"), dockManager->saveState()); }); } @@ -1187,6 +2175,17 @@ public: return false; } }; + +// VTK 警告/错误输出窗口:转 Qt 日志(qWarning),不弹独立窗口;并把 VTK 报错落进 geopro 日志便于排查。 +class QtVtkOutputWindow : public vtkOutputWindow { +public: + static QtVtkOutputWindow* New(); + vtkTypeMacro(QtVtkOutputWindow, vtkOutputWindow); + void DisplayText(const char* txt) override { + if (txt && *txt) qWarning().noquote() << "[vtk]" << QString::fromUtf8(txt).trimmed(); + } +}; +vtkStandardNewMacro(QtVtkOutputWindow); } // namespace int main(int argc, char* argv[]) @@ -1206,10 +2205,28 @@ int main(int argc, char* argv[]) QSurfaceFormat::setDefaultFormat(QVTKOpenGLStereoWidget::defaultFormat()); GuardedApplication app(argc, argv); // 顶层异常护栏:slot/事件里的异常不致客户端崩溃 + // VTK 警告/错误转 Qt 日志(qWarning → geopro 日志),彻底不弹独立 vtkOutputWindow 窗口 + // (Windows 默认 vtkWin32OutputWindow 不认 DisplayMode,仍会弹空窗;故直接替换实例)。 + // 同时把 VTK 报错落进日志,便于排查(如体绘制偶发不渲染的真因)。 + { + auto* vtkOut = QtVtkOutputWindow::New(); + vtkOutputWindow::SetInstance(vtkOut); + vtkOut->Delete(); // SetInstance 已持引用 + } + // 异步 ApiCall::finished 等信号携带 ApiResponse;注册元类型以支持跨 QueuedConnection 传递 // (当前详情链路为同线程 DirectConnection,非严格必需,但作防御性注册,见 spec §5.1)。 qRegisterMetaType(); + // Qt 标准控件文案中文化:安装 Qt 自带 zh_CN 翻译 → QMessageBox/QDialogButtonBox/QFileDialog/ + // QColorDialog 等的 OK/Cancel/Yes/No/Save… 全局显示中文。translator 须存活至程序结束(放 main 栈)。 + QTranslator qtZhTranslator; + const QString appTr = QCoreApplication::applicationDirPath() + QStringLiteral("/translations"); + if (qtZhTranslator.load(QStringLiteral("qtbase_zh_CN"), appTr) || // 部署版(exe 旁) + qtZhTranslator.load(QStringLiteral("qtbase_zh_CN"), + QLibraryInfo::path(QLibraryInfo::TranslationsPath))) // dev(Qt 安装) + app.installTranslator(&qtZhTranslator); + // 组织/应用名:QSettings 持久化(dock 布局、登录记忆等)按此定位存储位置。 QCoreApplication::setOrganizationName(QStringLiteral("Geomative")); QCoreApplication::setApplicationName(QStringLiteral("Geopro3")); @@ -1241,9 +2258,17 @@ int main(int argc, char* argv[]) } } - // 网络层:共享会话 ApiClient + 登录编排 AuthService(RSA 公钥从 resources 读取)。 + // 网络层:共享会话 ApiClient + 登录编排 AuthService。 + // RSA 登录公钥内嵌于二进制(qrc :/keys),不依赖外部文件路径——部署到任意机器均可用。 geopro::net::ApiClient api(QStringLiteral("http://tenant.geomative.cn/pop-api")); - const std::string pem = readPem("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"); + std::string pem; + { + QFile pemFile(QStringLiteral(":/keys/rsa_public_key.pem")); + if (pemFile.open(QIODevice::ReadOnly)) { + const QByteArray bytes = pemFile.readAll(); + pem.assign(bytes.constData(), static_cast(bytes.size())); + } + } geopro::net::AuthService auth(api, pem); // 记住登录:若上次勾选「记住」且未超 30 天,凭证库里有有效 token → 免登录直接进。 @@ -1260,9 +2285,27 @@ int main(int argc, char* argv[]) api.setToken(token); // 注入 token 供后续 API 使用 + // 本地样本演示数据目录:优先随安装包目录(exe 旁 sampledata/),回退源码树开发路径。 + // 不依赖写死的开发机绝对路径——部署到任意机器均可用。 + const std::string sampleDir = []() -> std::string { + const QString appDir = QCoreApplication::applicationDirPath(); + const QStringList candidates = { + appDir + QStringLiteral("/sampledata"), + QStringLiteral("D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件"), + }; + for (const auto& c : candidates) { + if (QDir(c).exists()) { + const QByteArray u8 = (c + QStringLiteral("/")).toUtf8(); + return std::string(u8.constData(), static_cast(u8.size())); + } + } + // 都不存在:返回首选路径,交由下游报错并被启动防护捕获(提示安装不完整)。 + const QByteArray u8 = (candidates.front() + QStringLiteral("/")).toUtf8(); + return std::string(u8.constData(), static_cast(u8.size())); + }(); + // 登录成功 → 构建并显示工作台。 - geopro::data::LocalSampleRepository repo( - "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/"); + geopro::data::LocalSampleRepository repo(sampleDir); // 导航仓储 + 控制器(接口/逻辑层):用同一共享会话 ApiClient。 geopro::data::ApiProjectRepository projectRepo(api); @@ -1270,6 +2313,10 @@ int main(int argc, char* argv[]) // 数据详情仓储 + 控制器(接真实反演 API):同一共享会话 ApiClient。 geopro::data::ApiDatasetRepository datasetRepo(api); + // 色阶模板仓储(lvl 模板 + clr 色阶):同一共享会话 ApiClient,注入 2D/3D 色阶编辑器。 + geopro::data::ApiColorTemplateRepository colorTplRepo(api); + // 反演命令仓储(反演运算 / 生成视电阻率 / 模型列表 / 动态表单):同一共享会话 ApiClient。 + geopro::data::ApiDatasetCommandRepository cmdRepo(api); // 策略注册表须比 detailCtrl 活得久(同作用域、在前声明)。 geopro::controller::ChartStrategyRegistry chartRegistry; chartRegistry.add(std::make_unique()); @@ -1286,7 +2333,18 @@ int main(int argc, char* argv[]) window->setWindowTitle(kTitle); window->resize(1280, 800); window->setMinimumSize(1024, 680); - buildWorkbench(*window, repo, projectRepo, nav, detailCtrl); + // 启动防护:工作台构建期间任何同步加载失败(如样本数据缺失)都不应让进程静默退出, + // 而是给出可见错误提示。否则登录后窗口未显示、进程消失,用户无从排查。 + try { + buildWorkbench(*window, repo, projectRepo, datasetRepo, colorTplRepo, cmdRepo, nav, + detailCtrl, token); + } catch (const std::exception& e) { + QMessageBox::critical( + nullptr, QStringLiteral("启动失败"), + QStringLiteral("工作台初始化失败:\n%1\n\n请确认安装完整(样本数据 / 运行库未缺失)。") + .arg(QString::fromUtf8(e.what()))); + return 1; + } // 主题桥:ThemeManager 明/暗切换 → 重应用全局 QSS+调色板(标准控件 + ADS;内联 chrome 经各自连接)。 QObject::connect(&geopro::app::ThemeManager::instance(), &geopro::app::ThemeManager::changed, diff --git a/src/app/panels/AnomalyListPanel.cpp b/src/app/panels/AnomalyListPanel.cpp index f2eb677..4a676f4 100644 --- a/src/app/panels/AnomalyListPanel.cpp +++ b/src/app/panels/AnomalyListPanel.cpp @@ -54,6 +54,41 @@ QColor barColor(const QString& s) return QColor(c.r, c.g, c.b); } +// 异常分级 → 状态语义键(规范 §1.4/§6.3/§8.3:高=Danger 中=Warning 低=Info 未知=Neutral)。 +// Anomaly 数据模型不带显式 level 字段,故按可得信号稳健推断: +// 1) typeName/name 含「高/中/低」字样 → 直接定级; +// 2) 否则按 lineColor 色相归类(红→高 橙/黄→中 蓝→低,与右栏列表/三维标注牌同色); +// 3) 仍无法判定 → Neutral(停用/未知),避免乱给状态色。 +// 返回值为状态 token 前缀("danger"/"warning"/"info"/"neutral"),调用方据此拼 token 名。 +QString anomalyStatus(const geopro::core::Anomaly& a) +{ + const QString tag = QString::fromStdString(a.typeName + a.name); + if (tag.contains(QStringLiteral("高"))) return QStringLiteral("danger"); + if (tag.contains(QStringLiteral("中"))) return QStringLiteral("warning"); + if (tag.contains(QStringLiteral("低"))) return QStringLiteral("info"); + + // 按 lineColor 色相归类(HSV 色相环:红≈0/360 橙黄≈20–70 蓝≈190–260)。 + const QColor c = barColor(QString::fromStdString(a.lineColor)); + if (c.isValid() && c.saturationF() > 0.25) { + const int h = c.hue(); // -1=无色相(灰) + if (h >= 0) { + if (h < 20 || h >= 330) return QStringLiteral("danger"); + if (h < 75) return QStringLiteral("warning"); + if (h >= 185 && h < 265) return QStringLiteral("info"); + } + } + return QStringLiteral("neutral"); +} + +// 状态键 + 后缀 → 主题 token 名(如 status + "danger" + "-bg")。neutral 无 -bg,回落中性面。 +QColor statusColor(const QString& status, bool bg) +{ + if (status == QStringLiteral("neutral")) + return geopro::app::tokenColor(bg ? "bg/panel-subtle" : "status/neutral"); + const QString name = QStringLiteral("status/%1%2").arg(status, bg ? QStringLiteral("-bg") : QString()); + return geopro::app::tokenColor(name.toUtf8().constData()); +} + // 右侧眼睛命中区(卡片右端,竖直居中)。 QRect anomalyEyeRect(const QRect& itemRect) { @@ -95,14 +130,20 @@ public: const bool selected = opt.state & QStyle::State_Selected; const bool hover = opt.state & QStyle::State_MouseOver; - // 卡底(hover/选中高亮) - if (selected || hover) { - QPainterPath path; path.addRoundedRect(r, 6, 6); - p->fillPath(path, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover")); - } - // 左 3px 状态色竖条(取异常自身 lineColor) + // 分级状态键(§6.3):高=Danger 中=Warning 低=Info 未知=Neutral。 + const QString status = idx.data(kAnomalyStatusRole).toString(); + + // 卡底(§6.3 规范§3.2 radius/md=6):静止态 = 该分级状态浅底;选中/hover 叠一档 + // 交互态(选中=bg/selected 强调底,hover=bg/hover),让交互可辨又不丢分级语义。 + QPainterPath path; path.addRoundedRect(r, geopro::app::radius::kMd, geopro::app::radius::kMd); + const QColor cardBg = selected ? geopro::app::tokenColor("bg/selected") + : hover ? geopro::app::tokenColor("bg/hover") + : statusColor(status, /*bg=*/true); + p->fillPath(path, cardBg); + + // 左 3px 状态色竖条(取分级状态主色,与卡底/标签同源;§6.3) p->fillRect(QRect(r.left(), r.top() + 4, 3, r.height() - 8), - barColor(idx.data(kAnomalyColorRole).toString())); + statusColor(status, /*bg=*/false)); const QString name = idx.data(Qt::DisplayRole).toString(); const QString type = idx.data(kAnomalyTypeRole).toString(); @@ -112,18 +153,24 @@ public: const int right = anomalyEyeRect(opt.rect).left() - 8; // 给眼睛留位 const int rowW = right - left; - // 第一行:名称(加粗) + // 第一行:状态圆点 + 名称(text/body-strong 600)。圆点 8px,色 = 分级状态主色(§6.3)。 + const int dot = 8; + const QRect nameR(left + dot + 6, r.top() + 8, rowW - dot - 6, 20); + p->setBrush(statusColor(status, /*bg=*/false)); + p->setPen(Qt::NoPen); + p->drawEllipse(QPointF(left + dot / 2.0, nameR.center().y()), dot / 2.0, dot / 2.0); QFont nf = opt.font; nf.setPixelSize(geopro::app::scaledPx(13)); nf.setWeight(QFont::DemiBold); p->setFont(nf); p->setPen(geopro::app::tokenColor("text/primary")); - const QRect nameR(left, r.top() + 8, rowW, 20); + p->setBrush(Qt::NoBrush); p->drawText(nameR, Qt::AlignLeft | Qt::AlignVCenter, - p->fontMetrics().elidedText(name, Qt::ElideRight, rowW)); + p->fontMetrics().elidedText(name, Qt::ElideRight, nameR.width())); - // 第二行:类型胶囊 + 摘要 + // 第二行:分级胶囊标签 + 摘要 int x = left; const int cy = r.top() + 38; if (!type.isEmpty()) { + // 等级标签(§6.8):胶囊 radius/pill(按高度算半径),底 = 状态浅底,文字 = 状态主色。 QFont pf = opt.font; pf.setPixelSize(geopro::app::scaledPx(11)); p->setFont(pf); const QFontMetrics fm(pf); @@ -131,13 +178,16 @@ public: const int ph = fm.height() + 2; const QRect pill(x, cy - ph / 2, tw + 12, ph); QPainterPath pp; pp.addRoundedRect(pill, ph / 2.0, ph / 2.0); - p->fillPath(pp, geopro::app::tokenColor("bg/hover")); - p->setPen(geopro::app::tokenColor("text/secondary")); + p->fillPath(pp, statusColor(status, /*bg=*/true)); + p->setPen(statusColor(status, /*bg=*/false)); p->drawText(pill, Qt::AlignCenter, type); x = pill.right() + 8; } if (!summary.isEmpty()) { - QFont sf = opt.font; sf.setPixelSize(geopro::app::scaledPx(11)); + // 属性行数值(§2.1/§6.3):用等宽字族,保证「140m · 18m / 32 Ω·m」逐列对齐。 + QFont sf = opt.font; + sf.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", "))); + sf.setPixelSize(geopro::app::scaledPx(11)); p->setFont(sf); p->setPen(geopro::app::tokenColor("text/secondary")); const QRect sumR(x, cy - 10, right - x, 20); @@ -183,11 +233,25 @@ void populateAnomalyList(QListWidget* list, const std::vectorsetData(kAnomalyColorRole, QString::fromStdString(a.lineColor)); item->setData(kAnomalyTypeRole, QString::fromStdString(a.typeName)); item->setData(kAnomalySummaryRole, summarize(a)); + item->setData(kAnomalyStatusRole, anomalyStatus(a)); // 分级状态键(驱动卡底/标签/竖条同色) item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(Qt::Checked); // 默认显示 } } +QString anomalyVisibleCountText(QListWidget* list) +{ + if (!list) return QStringLiteral("0/0"); + int total = 0, visible = 0; + for (int i = 0; i < list->count(); ++i) { + const QListWidgetItem* it = list->item(i); + if (!it) continue; + ++total; + if (it->checkState() == Qt::Checked) ++visible; + } + return QStringLiteral("%1/%2").arg(visible).arg(total); +} + void applyAnomalyCardDelegate(QListWidget* list) { if (!list) return; diff --git a/src/app/panels/AnomalyListPanel.hpp b/src/app/panels/AnomalyListPanel.hpp index ce6e6b2..1952c50 100644 --- a/src/app/panels/AnomalyListPanel.hpp +++ b/src/app/panels/AnomalyListPanel.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include + #include "model/Anomaly.hpp" class QListWidget; @@ -14,6 +16,7 @@ constexpr int kAnomalyIndexRole = 0x0100; // Qt::UserRole constexpr int kAnomalyColorRole = 0x0101; // lineColor 字符串 constexpr int kAnomalyTypeRole = 0x0102; // typeName constexpr int kAnomalySummaryRole = 0x0103; // 位置·深·尺寸 摘要 +constexpr int kAnomalyStatusRole = 0x0104; // 分级状态键(danger/warning/info/neutral,§6.3) // 给异常列表套用卡片委托(左色条+名称+类型标签+摘要+右侧显隐眼睛,规范§6.3)。 void applyAnomalyCardDelegate(QListWidget* list); @@ -24,4 +27,9 @@ void applyAnomalyCardDelegate(QListWidget* list); // 清空旧条目后重填。 void populateAnomalyList(QListWidget* list, const std::vector& anomalies); +// 「异常列表 可见/总数」计数文本(规范§6.3:标题计数=勾选可见数/总数,如 "2/3")。 +// 供宿主面板表头/徽标显示——卡片列表本身不含标题栏,标题由外层 buildTabbedPanel 持有, +// 故此处只产出计数串,由调用方(监听列表勾选变化)写回标题或徽标。 +QString anomalyVisibleCountText(QListWidget* list); + } // namespace geopro::app diff --git a/src/app/panels/AnomalyTablePanel.cpp b/src/app/panels/AnomalyTablePanel.cpp index 84c7c0b..34526d8 100644 --- a/src/app/panels/AnomalyTablePanel.cpp +++ b/src/app/panels/AnomalyTablePanel.cpp @@ -1,10 +1,14 @@ #include "panels/AnomalyTablePanel.hpp" -#include -#include +#include #include +#include +#include #include +#include namespace geopro::app { -static QString markName(int t) { return t == 1 ? "点" : t == 3 ? "多边形" : "多段线"; } +static QString markName(int t) { + return t == 1 ? "点" : t == 3 ? "多边形" : t == 4 ? "文字" : "多段线"; +} AnomalyTablePanel::AnomalyTablePanel(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); @@ -23,18 +27,46 @@ void AnomalyTablePanel::setAnomalies(const std::vector& l table_->setRowCount(static_cast(list.size())); for (int i = 0; i < static_cast(list.size()); ++i) { const auto& a = list[i]; + // 创建时间/备注:优先用形参(兼容),否则回退 Anomaly 字段(DTO 已解析)。 + const QString ct = i < (int)createTimes.size() && !createTimes[i].isEmpty() + ? createTimes[i] : QString::fromStdString(a.createTime); + const QString rm = i < (int)remarks.size() && !remarks[i].isEmpty() + ? remarks[i] : QString::fromStdString(a.remark); table_->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(a.name))); table_->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(a.typeName))); table_->setItem(i, 2, new QTableWidgetItem(markName(static_cast(a.markType)))); - table_->setItem(i, 3, new QTableWidgetItem(i < (int)createTimes.size() ? createTimes[i] : "")); - table_->setItem(i, 4, new QTableWidgetItem(i < (int)remarks.size() ? remarks[i] : "")); - auto* eye = new QToolButton(table_); eye->setCheckable(true); eye->setChecked(true); - eye->setText("👁"); + table_->setItem(i, 3, new QTableWidgetItem(ct)); + table_->setItem(i, 4, new QTableWidgetItem(rm)); + + // 操作列:定位 / 详情 / 删除(对照原版 contourPage 操作列),保留眼睛显隐。 + auto* ops = new QWidget(table_); + auto* opLay = new QHBoxLayout(ops); + opLay->setContentsMargins(2, 0, 2, 0); + opLay->setSpacing(2); + auto* eye = new QToolButton(ops); eye->setCheckable(true); eye->setChecked(true); + eye->setText("👁"); eye->setToolTip(QStringLiteral("显示/隐藏")); connect(eye, &QToolButton::toggled, this, [this, i](bool on) { if (on) hidden_.erase(i); else hidden_.insert(i); emit hiddenChanged(hidden_); }); - table_->setCellWidget(i, 5, eye); + auto* btnLocate = new QToolButton(ops); btnLocate->setText(QStringLiteral("定位")); + connect(btnLocate, &QToolButton::clicked, this, [this, i]() { emit locateRequested(i); }); + auto* btnDetail = new QToolButton(ops); btnDetail->setText(QStringLiteral("详情")); + connect(btnDetail, &QToolButton::clicked, this, [this, i]() { emit detailRequested(i); }); + auto* btnDelete = new QToolButton(ops); btnDelete->setText(QStringLiteral("删除")); + connect(btnDelete, &QToolButton::clicked, this, [this, i]() { + // 原版 a-popconfirm 二次确认(contourContentDelete)→ 这里用 QMessageBox,文案对齐原版。 + if (QMessageBox::question(this, QStringLiteral("提示"), + QStringLiteral("该操作会删除该异常标注数据,确认?")) == + QMessageBox::Yes) + emit deleteRequested(i); + }); + opLay->addWidget(eye); + opLay->addWidget(btnLocate); + opLay->addWidget(btnDetail); + opLay->addWidget(btnDelete); + opLay->addStretch(); + table_->setCellWidget(i, 5, ops); } } } // namespace geopro::app diff --git a/src/app/panels/AnomalyTablePanel.hpp b/src/app/panels/AnomalyTablePanel.hpp index b765759..50e3aa6 100644 --- a/src/app/panels/AnomalyTablePanel.hpp +++ b/src/app/panels/AnomalyTablePanel.hpp @@ -6,16 +6,23 @@ class QTableWidget; namespace geopro::app { -// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作(显隐眼睛)。行显隐 → 信号驱动图表叠加。 +// ds 级异常表:名称/异常类型/几何类型/创建时间/备注/操作。 +// 操作列对照原版 contourPage:定位 / 详情 / 删除(删除带二次确认)。 +// 行级显隐(眼睛)保留:信号驱动图表叠加(与原版分开,眼睛为客户端既有能力)。 class AnomalyTablePanel : public QWidget { Q_OBJECT public: explicit AnomalyTablePanel(QWidget* parent = nullptr); + // createTimes/remarks 形参保留兼容旧调用;为空时回退到 Anomaly 自带字段。 void setAnomalies(const std::vector& list, - const std::vector& createTimes, - const std::vector& remarks); + const std::vector& createTimes = {}, + const std::vector& remarks = {}); signals: void hiddenChanged(const std::set& hiddenIndices); + // 操作列:传出异常在当前列表中的下标(调用方据此取 Anomaly 拿 id/坐标)。 + void locateRequested(int index); + void detailRequested(int index); + void deleteRequested(int index); private: QTableWidget* table_; std::set hidden_; diff --git a/src/app/panels/DatasetAttrPanel.cpp b/src/app/panels/DatasetAttrPanel.cpp index 8fde6b2..7ee1db9 100644 --- a/src/app/panels/DatasetAttrPanel.cpp +++ b/src/app/panels/DatasetAttrPanel.cpp @@ -1,61 +1,20 @@ #include "panels/DatasetAttrPanel.hpp" -#include -#include -#include -#include -#include -#include -#include #include -#include "Theme.hpp" -#include "api/NavRequest.hpp" #include "panels/DynamicFormView.hpp" -#include "repo/IAsyncProjectRepository.hpp" namespace geopro::app { -DatasetAttrPanel::DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent) - : QWidget(parent), repo_(repo) { +DatasetAttrPanel::DatasetAttrPanel(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); - // 上半:只读元字段(复用 DynamicFormView)。 + // 只读元字段(复用 DynamicFormView)。数据集属性不可编辑。 metaView_ = new DynamicFormView(this); metaView_->showMessage(QStringLiteral("(单击数据集查看属性)")); lay->addWidget(metaView_, 1); - - // 下半:可编辑描述区。 - auto* descBox = new QWidget(this); - auto* descLay = new QVBoxLayout(descBox); - descLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kSm, - geopro::app::space::kLg, geopro::app::space::kMd); - descLay->setSpacing(geopro::app::space::kXs); - - auto* hint = new QLabel(QStringLiteral("描述(备注)"), descBox); - geopro::app::applyTokenizedStyleSheet(hint, QStringLiteral("color:{{text/secondary}};")); - descLay->addWidget(hint); - - descEdit_ = new QTextEdit(descBox); - descEdit_->setAcceptRichText(false); // 纯文本(与服务端 delta 纯文本往返一致) - descEdit_->setEnabled(false); - descEdit_->setMaximumHeight(geopro::app::scaledPx(120)); - descLay->addWidget(descEdit_); - - auto* btnRow = new QHBoxLayout(); - status_ = new QLabel(QString(), descBox); - geopro::app::applyTokenizedStyleSheet(status_, QStringLiteral("color:{{text/disabled}};")); - btnRow->addWidget(status_, 1); - saveBtn_ = new QPushButton(QStringLiteral("保存"), descBox); - saveBtn_->setEnabled(false); - btnRow->addWidget(saveBtn_); - descLay->addLayout(btnRow); - - lay->addWidget(descBox); - - QObject::connect(saveBtn_, &QPushButton::clicked, this, &DatasetAttrPanel::onSave); } void DatasetAttrPanel::setForm(const geopro::data::DynamicForm& form) { @@ -63,66 +22,7 @@ void DatasetAttrPanel::setForm(const geopro::data::DynamicForm& form) { } void DatasetAttrPanel::showMessage(const QString& message) { - dsObjectId_.clear(); metaView_->showMessage(message); - descEdit_->clear(); - descEdit_->setEnabled(false); - saveBtn_->setEnabled(false); - status_->clear(); -} - -void DatasetAttrPanel::selectDataset(const QString& dsObjectId) { - // 切换数据集:中止在途保存,避免旧 save 回调串台触发 saved()/启用按钮。 - if (saveReq_) saveReq_->abort(); - dsObjectId_ = dsObjectId; - descEdit_->setEnabled(false); - saveBtn_->setEnabled(false); - status_->setText(QStringLiteral("加载描述…")); - - if (loadReq_) loadReq_->abort(); - loadReq_ = repo_.loadDatasetDetailAsync(dsObjectId.toStdString()); - QObject::connect(loadReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) { - descEdit_->setPlainText(v.toString()); // payload=QString(现有描述纯文本) - descEdit_->setEnabled(true); - saveBtn_->setEnabled(true); - status_->clear(); - }); - QObject::connect(loadReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { - // 加载失败仍允许编辑/保存(视为新建描述),仅提示。 - descEdit_->setEnabled(true); - saveBtn_->setEnabled(true); - status_->setText(QStringLiteral("加载描述失败:%1(可直接编辑保存)").arg(msg)); - }); -} - -void DatasetAttrPanel::onSave() { - if (dsObjectId_.isEmpty()) return; - const QString text = descEdit_->toPlainText(); - // 最小 Quill delta:[{ insert: <文本 + "\n"> }],承载纯文本与服务端往返。 - const QJsonArray deltaOps{QJsonObject{{QStringLiteral("insert"), text + QStringLiteral("\n")}}}; - const QJsonObject body{ - {QStringLiteral("dsObjectId"), dsObjectId_}, - {QStringLiteral("description"), text}, - {QStringLiteral("attachedParameters"), - QJsonObject{{QStringLiteral("deltaContent"), deltaOps}}}}; - - saveBtn_->setEnabled(false); - descEdit_->setEnabled(false); - status_->setText(QStringLiteral("保存中…")); - if (saveReq_) saveReq_->abort(); - saveReq_ = - repo_.updateDatasetAsync(QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString()); - QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) { - descEdit_->setEnabled(true); - saveBtn_->setEnabled(true); - status_->clear(); - emit saved(); - }); - QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { - status_->setText(QStringLiteral("保存失败:%1").arg(msg)); - descEdit_->setEnabled(true); - saveBtn_->setEnabled(true); - }); } } // namespace geopro::app diff --git a/src/app/panels/DatasetAttrPanel.hpp b/src/app/panels/DatasetAttrPanel.hpp index 7087ada..258c346 100644 --- a/src/app/panels/DatasetAttrPanel.hpp +++ b/src/app/panels/DatasetAttrPanel.hpp @@ -1,52 +1,25 @@ #pragma once -#include #include #include #include "repo/RepoTypes.hpp" -namespace geopro::data { -class IAsyncProjectRepository; -class NavRequest; -} // namespace geopro::data - -class QLabel; -class QPushButton; -class QTextEdit; - namespace geopro::app { class DynamicFormView; -// 数据集属性面板(右下「数据集属性」):上半只读元字段(DynamicForm)+ 下半可编辑描述 + 保存。 -// 元字段无写接口,仅展示(datasetDetailLoaded 推送的 DynamicForm)。 -// 描述可写:PUT dsObject/updateDsObject/ body -// { dsObjectId, description:<纯文本>, attachedParameters:{ deltaContent:[{ insert:<文本+"\n"> }] } } -// 描述文本经 loadDatasetDetailAsync(GET getDetail) 回填(payload=QString)。 +// 数据集属性面板(右下「数据集属性」):**只读**元字段展示(datasetDetailLoaded 推送的 DynamicForm)。 +// 数据集属性不可编辑——仅展示,无写接口(描述编辑功能已移除)。 class DatasetAttrPanel : public QWidget { Q_OBJECT public: - DatasetAttrPanel(geopro::data::IAsyncProjectRepository& repo, QWidget* parent = nullptr); + explicit DatasetAttrPanel(QWidget* parent = nullptr); void setForm(const geopro::data::DynamicForm& form); // 元字段只读展示 void showMessage(const QString& message); // 空/占位 - void selectDataset(const QString& dsObjectId); // 选中 → 回填描述、启用保存 - -signals: - void saved(); // 描述保存成功 private: - void onSave(); - - geopro::data::IAsyncProjectRepository& repo_; - QString dsObjectId_; - DynamicFormView* metaView_ = nullptr; - QTextEdit* descEdit_ = nullptr; - QLabel* status_ = nullptr; - QPushButton* saveBtn_ = nullptr; - QPointer loadReq_; - QPointer saveReq_; }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPage.cpp b/src/app/panels/DatasetDetailPage.cpp index 16bfd00..0cc7f9b 100644 --- a/src/app/panels/DatasetDetailPage.cpp +++ b/src/app/panels/DatasetDetailPage.cpp @@ -1,5 +1,7 @@ #include "panels/DatasetDetailPage.hpp" +#include + #include #include @@ -18,6 +20,16 @@ DatasetDetailPage::DatasetDetailPage(QWidget* parent) : QWidget(parent) { lay->setSpacing(0); } +void DatasetDetailPage::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + colorTplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + +void DatasetDetailPage::setCommandRepo(geopro::data::IDatasetCommandRepository* repo) { + cmdRepo_ = repo; +} + void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs) { Q_ASSERT(views_.empty()); // build() 仅一次:views_ 为已 release 的裸指针,二次构建会泄漏旧视图 @@ -35,7 +47,11 @@ void DatasetDetailPage::build(const QString& dsId, const QString& ddCode, const QVector panelTabs; for (size_t i = 0; i < tabs.size(); ++i) { const auto& spec = tabs[i]; - auto view = makeDetailView(spec.kind, this); // 抛出由调用栈兜底(GuardedApplication) + // 仓储与 projectId 回调透传给工厂(FilledContour 用色阶模板仓储;Scatter 用反演命令仓储)。 + // dsIdGetter 用本页 dsId_(此处已赋值),随项目/数据集稳定。 + auto view = makeDetailView(spec.kind, this, colorTplRepo_, projectIdGetter_, cmdRepo_, + [this] { return dsId_; }, + [this] { return tmObjectId_; }); // 抛出由调用栈兜底(GuardedApplication) IDetailView* raw = view.release(); // QWidget 由 this/QwtPlot 父子树接管生命周期 views_[i] = raw; // lazy 页签:建覆盖该视图的加载遮罩(父为视图 widget,随其尺寸覆盖图区)。 diff --git a/src/app/panels/DatasetDetailPage.hpp b/src/app/panels/DatasetDetailPage.hpp index 98dc161..a80564e 100644 --- a/src/app/panels/DatasetDetailPage.hpp +++ b/src/app/panels/DatasetDetailPage.hpp @@ -1,10 +1,17 @@ #pragma once +#include #include #include +#include #include #include #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec +namespace geopro::data { +class IColorTemplateRepository; +class IDatasetCommandRepository; +} + namespace geopro::app { class IDetailView; @@ -17,6 +24,17 @@ class DatasetDetailPage : public QWidget { public: explicit DatasetDetailPage(QWidget* parent = nullptr); + // 色阶模板仓储 + projectId 取值回调(注入网格剖面色阶编辑器,须在 build 前设置)。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + + // 反演命令仓储注入(measurement 反演运算/生成视电阻率,须在 build 前设置)。 + // dsId 用本页 dsId_(build 内构造 dsIdGetter,此时 dsId_ 已赋值);projectId 复用上面的 getter。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); + + // 所属 TM 对象 id(=白化 structParentId)注入(须在 build 前设置 → tmObjectIdGetter 透传给视图)。 + void setTmObjectId(const QString& tmObjectId) { tmObjectId_ = tmObjectId; } + // 按页签集构建页签(首次打开调一次)。dsId/ddCode/dsName 用于 tabNeeded。 void build(const QString& dsId, const QString& ddCode, const QString& dsName, const std::vector& tabs); @@ -42,6 +60,7 @@ private: QString dsId_; QString ddCode_; QString dsName_; + QString tmObjectId_; // 所属 TM 对象 id(白化 structParentId),经 tmObjectIdGetter 透传给视图 std::vector tabs_; // 与 tabs_ 同序。每个 IDetailView 持有的 QWidget 经 build() 以 this 为父接管, // 生命周期由 Qt 父子树清理(不在此 delete);build() 仅调用一次(见其断言)。 @@ -49,6 +68,13 @@ private: std::vector loaded_; // 各页签是否已加载(避免重复请求) std::vector requested_; // lazy 页签是否已请求过 QMap overlays_; // lazy 页签的加载遮罩(覆盖该视图) + + // 色阶模板仓储注入(透传给 makeDetailView → 网格剖面色阶编辑器)。 + geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; + std::function projectIdGetter_; + + // 反演命令仓储注入(透传给 makeDetailView → measurement 散点视图反演按钮)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/DatasetDetailPanel.cpp b/src/app/panels/DatasetDetailPanel.cpp index 4688d62..8eb2009 100644 --- a/src/app/panels/DatasetDetailPanel.cpp +++ b/src/app/panels/DatasetDetailPanel.cpp @@ -1,7 +1,20 @@ #include "panels/DatasetDetailPanel.hpp" + +#include + #include "panels/DatasetDetailPage.hpp" namespace geopro::app { +void DatasetDetailPanel::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + colorTplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + +void DatasetDetailPanel::setCommandRepo(geopro::data::IDatasetCommandRepository* repo) { + cmdRepo_ = repo; +} + DatasetDetailPanel::DatasetDetailPanel(QWidget* parent) : QTabWidget(parent) { setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, [this](int i) { widget(i)->deleteLater(); }); @@ -19,11 +32,15 @@ DatasetDetailPage* DatasetDetailPanel::pageFor(const QString& dsId) const { } void DatasetDetailPanel::onDatasetOpened(const QString& dsId, const QString& ddCode, - const QString& dsName, + const QString& dsName, const QString& tmObjectId, const std::vector& tabs) { auto* p = pageFor(dsId); if (!p) { p = new DatasetDetailPage(this); + // 注入须在 build 前(build 内造视图时即透传给工厂)。 + p->setColorTemplateRepo(colorTplRepo_, projectIdGetter_); + p->setCommandRepo(cmdRepo_); + p->setTmObjectId(tmObjectId); // 白化 structParentId(build 前设置 → 透传给视图) p->build(dsId, ddCode, dsName, tabs); // ddCode 透传 → 页内 tabNeeded 携带 const QString title = dsName.isEmpty() ? dsId : dsName; // 页签标题用数据名(空则回退 id) const int idx = addTab(p, title); diff --git a/src/app/panels/DatasetDetailPanel.hpp b/src/app/panels/DatasetDetailPanel.hpp index 1707a5d..8babdd4 100644 --- a/src/app/panels/DatasetDetailPanel.hpp +++ b/src/app/panels/DatasetDetailPanel.hpp @@ -1,8 +1,14 @@ #pragma once +#include #include +#include #include #include #include "DatasetDetailTab.hpp" // geopro::controller::TabSpec +namespace geopro::data { +class IColorTemplateRepository; +class IDatasetCommandRepository; +} namespace geopro::app { class DatasetDetailPage; @@ -12,8 +18,17 @@ class DatasetDetailPanel : public QTabWidget { public: explicit DatasetDetailPanel(QWidget* parent = nullptr); + // 色阶模板仓储 + projectId 取值回调:透传给每个新建的详情页(网格剖面色阶编辑器用)。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + + // 反演命令仓储:透传给每个新建的详情页(measurement 反演运算/生成视电阻率用)。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo); + // 数据集打开:find-or-create 页 → build(tabs) → 加/抬该面板页签。 + // tmObjectId:所属 TM 对象 id(白化 structParentId),build 前交给页 → 视图。 void onDatasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, + const QString& tmObjectId, const std::vector& tabs); void onTabReady(const QString& dsId, int tabIndex, const QVariant& payload); void onTabLoadStarted(const QString& dsId, int tabIndex); @@ -29,5 +44,12 @@ signals: private: DatasetDetailPage* pageFor(const QString& dsId) const; + + // 色阶模板仓储注入(新页 build 前 setColorTemplateRepo 透传)。 + geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; + std::function projectIdGetter_; + + // 反演命令仓储注入(新页 build 前 setCommandRepo 透传)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/DatasetListPanel.cpp b/src/app/panels/DatasetListPanel.cpp index 1e418f5..76c7461 100644 --- a/src/app/panels/DatasetListPanel.cpp +++ b/src/app/panels/DatasetListPanel.cpp @@ -1,14 +1,18 @@ #include "panels/DatasetListPanel.hpp" #include +#include #include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -35,7 +39,10 @@ public: QSize sizeHint(const QStyleOptionViewItem&, const QModelIndex& idx) const override { const bool special = idx.data(kDsLoadMoreRole).toBool() || !(idx.flags() & Qt::ItemIsSelectable); - return QSize(0, special ? 34 : 52); + if (special) return QSize(0, 34); + // 无副标题(容器节点 项目/GS/TM)用紧凑矮卡,避免标题下大片留白。 + const bool hasMeta = idx.data(Qt::DisplayRole).toString().contains(QLatin1Char('\n')); + return QSize(0, hasMeta ? 52 : 30); } void paint(QPainter* p, const QStyleOptionViewItem& opt, const QModelIndex& idx) const override { @@ -63,18 +70,52 @@ public: return; } - // 卡片 + // 选中/hover:与对象树(ObjectRowDelegate)完全一致——整行方角填充(非圆角卡)+ 左 2px + // accent 竖条贴行左缘满高 + 选中标题加粗。保留双行卡片内容(标题 + 创建时间·类型副标题)。 const QRect r = opt.rect.adjusted(4, 2, -4, -2); const bool selected = opt.state & QStyle::State_Selected; const bool hover = opt.state & QStyle::State_MouseOver; - if (selected || hover) { - QPainterPath path; - path.addRoundedRect(r, 6, 6); - p->fillPath(path, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover")); - } - if (selected) { // 左 2px 强调竖条(规范§6.2) - p->fillRect(QRect(r.left(), r.top() + 4, 2, r.height() - 8), + if (selected || hover) + p->fillRect(opt.rect, geopro::app::tokenColor(selected ? "bg/selected" : "bg/hover")); + if (selected) + p->fillRect(QRect(opt.rect.left(), opt.rect.top(), 2, opt.rect.height()), geopro::app::tokenColor("accent/primary")); + + // 可勾选项:左侧画复选框(用当前 style 的指示器),文本整体右移。容器节点(项目/GS/TM)不画 + // 复选框、名称紧跟展开图标(左留白小,与对象树容器一致;勿为对齐子级而预留空复选框列, + // 否则容器名与展开图标间出现大段空白,见用户 #2 反馈)。 + const int box = 16; + int textLeftPad = 6; + const bool checkable = (idx.flags() & Qt::ItemIsUserCheckable); + const bool isContainer = idx.data(kDsDdCodeRole).toString() == QStringLiteral("container"); + if (checkable) { + QRect checkRect(r.left() + 12, r.top() + (r.height() - box) / 2, box, box); + if (idx.data(kDsBusyRole).toBool()) { + // 渲染中:复选框位置画旋转 spinner(等待动画,角度由 kDsSpinAngleRole 驱动)。 + const int angle = idx.data(kDsSpinAngleRole).toInt(); + p->save(); + p->setRenderHint(QPainter::Antialiasing, true); + QPen pen(geopro::app::tokenColor("accent/primary"), 2.0); + pen.setCapStyle(Qt::RoundCap); + p->setPen(pen); + p->drawArc(QRectF(checkRect).adjusted(2, 2, -2, -2), -angle * 16, 270 * 16); + p->restore(); + } else { + const auto cs = static_cast(idx.data(Qt::CheckStateRole).toInt()); + QStyleOptionViewItem o(opt); + o.rect = checkRect; + o.state &= ~QStyle::State_HasFocus; + o.state |= (cs == Qt::Checked ? QStyle::State_On : QStyle::State_Off); + const QWidget* w = opt.widget; + QStyle* st = w ? w->style() : QApplication::style(); + st->drawPrimitive(QStyle::PE_IndicatorItemViewItemCheck, &o, p, w); + } + textLeftPad = 12 + box + 8; // 复选框右侧留白后再放文本 + } else if (isContainer) { + // 容器文本左缘对齐子级复选框的左缘(r.left()+12)——使「容器→带框子级」的视觉缩进 = 一个树级 + // (14px),与「带框父→带框子」一致,消除带框子级相对容器缩进过大(用户 #6)。只 +12(非整列宽), + // 故名称仍紧邻展开图标、无 #2 的大留白。 + textLeftPad = 12; } QString title = disp, meta; @@ -84,17 +125,20 @@ public: meta = disp.mid(nl + 1); } - const QRect textR = r.adjusted(14, 6, -12, -6); - // 标题 + const QRect textR = r.adjusted(textLeftPad, 4, -12, -4); QFont tf = opt.font; tf.setPixelSize(geopro::app::scaledPx(13)); + if (selected) tf.setWeight(QFont::DemiBold); // 选中加粗,与对象树一致 p->setFont(tf); p->setPen(geopro::app::tokenColor("text/primary")); - const QRect titleR(textR.left(), textR.top(), textR.width(), textR.height() / 2); - p->drawText(titleR, Qt::AlignLeft | Qt::AlignVCenter, - p->fontMetrics().elidedText(title, Qt::ElideRight, titleR.width())); - // 元信息 - if (!meta.isEmpty()) { + if (meta.isEmpty()) { + // 无副标题(容器节点):标题垂直居中整卡,不留下半空白。 + p->drawText(textR, Qt::AlignLeft | Qt::AlignVCenter, + p->fontMetrics().elidedText(title, Qt::ElideRight, textR.width())); + } else { + const QRect titleR(textR.left(), textR.top(), textR.width(), textR.height() / 2); + p->drawText(titleR, Qt::AlignLeft | Qt::AlignVCenter, + p->fontMetrics().elidedText(title, Qt::ElideRight, titleR.width())); QFont mf = opt.font; mf.setPixelSize(geopro::app::scaledPx(11)); p->setFont(mf); @@ -106,12 +150,43 @@ public: } p->restore(); } + + bool editorEvent(QEvent* ev, QAbstractItemModel* model, const QStyleOptionViewItem& opt, + const QModelIndex& idx) override { + if (!(idx.flags() & Qt::ItemIsUserCheckable)) + return QStyledItemDelegate::editorEvent(ev, model, opt, idx); + const bool busy = idx.data(kDsBusyRole).toBool(); // 渲染中 → 吞掉勾选交互 + const QRect r = opt.rect.adjusted(4, 2, -4, -2); + const int box = 16; + // 命中区放宽到复选框左侧整段(含一点文本起始),便于点击。 + const QRect hit(r.left(), r.top(), 12 + box + 8, r.height()); + auto toggle = [&]() { + const auto cur = static_cast(idx.data(Qt::CheckStateRole).toInt()); + model->setData(idx, cur == Qt::Checked ? Qt::Unchecked : Qt::Checked, Qt::CheckStateRole); + }; + if (ev->type() == QEvent::MouseButtonRelease) { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton && hit.contains(me->pos())) { + if (busy) return true; // 渲染中:不切换,仅消费 + toggle(); + return true; + } + } else if (ev->type() == QEvent::KeyPress) { + auto* ke = static_cast(ev); + if (ke->key() == Qt::Key_Space || ke->key() == Qt::Key_Select) { + if (busy) return true; + toggle(); + return true; + } + } + return QStyledItemDelegate::editorEvent(ev, model, opt, idx); + } }; } // namespace namespace { // 建一条数据集树项(不挂载):列0 文本 = dsName +「创建时间 · 类型名」,data 存各角色。 -QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { +QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d, const QString& tmObjectId) { QString text = QString::fromStdString(d.dsName); QString sub = QString::fromStdString(d.createTime); // 名称下先创建时间 if (!d.typeName.empty()) @@ -125,6 +200,7 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { item->setData(0, kDsNameRole, QString::fromStdString(d.dsName)); item->setData(0, kDsTypeNameRole, QString::fromStdString(d.typeName)); item->setData(0, kDsCreateTimeRole, QString::fromStdString(d.createTime)); + item->setData(0, kDsTmObjectIdRole, tmObjectId); // 所属 TM 对象 id(白化 structParentId) // 单击 tip:显示数据集主要属性(名称 / 类型 / 创建时间),对齐菜单文档「tip显示ds的主要属性」。 QString tip = QStringLiteral("名称:%1").arg(QString::fromStdString(d.dsName)); if (!d.typeName.empty()) tip += QStringLiteral("\n类型:%1").arg(QString::fromStdString(d.typeName)); @@ -135,7 +211,8 @@ QTreeWidgetItem* makeDatasetItem(const geopro::data::DsRow& d) { } } // namespace -void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append) { +void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append, + const QString& tmObjectId) { if (!tree) return; if (!append) tree->clear(); @@ -150,7 +227,7 @@ void populateDatasetList(QTreeWidget* tree, const std::vector batch; batch.reserve(rows.size()); for (const auto& d : rows) { - auto* item = makeDatasetItem(d); + auto* item = makeDatasetItem(d, tmObjectId); byId.insert(QString::fromStdString(d.id), item); batch.push_back(item); } diff --git a/src/app/panels/DatasetListPanel.hpp b/src/app/panels/DatasetListPanel.hpp index 1f437b5..aa2d89b 100644 --- a/src/app/panels/DatasetListPanel.hpp +++ b/src/app/panels/DatasetListPanel.hpp @@ -22,11 +22,16 @@ constexpr int kDsDdCodeRole = 0x0104; // Qt::UserRole + 4(ddCode,双击详 constexpr int kDsNameRole = 0x0105; // Qt::UserRole + 5(dsName,详情页签标题用) constexpr int kDsTypeNameRole = 0x0106; // Qt::UserRole + 6(类型名,快速筛选用) constexpr int kDsCreateTimeRole = 0x0107; // Qt::UserRole + 7(创建时间,按日期筛选用) +constexpr int kDsTmObjectIdRole = 0x0108; // Qt::UserRole + 8(所属 TM 对象 id=白化 structParentId) +constexpr int kDsBusyRole = 0x0109; // Qt::UserRole + 9(true=该行渲染中,复选框位置画等待 spinner) +constexpr int kDsSpinAngleRole = 0x010A; // Qt::UserRole + 10(spinner 角度,定时器驱动,delegate 据此旋转) // 数据页签:树形(按 DsRow.parentId 嵌套,源数据为根、派生数据挂其下,对齐原版 el-table 树)。 // 每项列0:文本 = dsName +「创建时间 · 类型名」;data(0,角色) 存 dsId/ddCode/dsName。 // append=true 时把新行挂到已加载的父节点下(分页)。 -void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append); +// tmObjectId:本批数据所属 TM 对象 id(=白化 structParentId),存入每项 kDsTmObjectIdRole;可空。 +void populateDatasetList(QTreeWidget* tree, const std::vector& rows, bool append, + const QString& tmObjectId = QString()); // 文件页签:每条 = 文件名 +(可读大小);UserRole 存 dsId、+2 存文件 url。空时显示占位。 void populateFileList(QListWidget* list, const std::vector& rows, bool append); diff --git a/src/app/panels/DescriptionPanel.cpp b/src/app/panels/DescriptionPanel.cpp index 5289b9d..251cb4b 100644 --- a/src/app/panels/DescriptionPanel.cpp +++ b/src/app/panels/DescriptionPanel.cpp @@ -1,22 +1,173 @@ #include "panels/DescriptionPanel.hpp" +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include #include +#include +#include #include +#include "Theme.hpp" +#include "panels/QuillDelta.hpp" + namespace geopro::app { +namespace { + +// 字号下拉选项(px)——对照原版 ql-size 的 12~32px。 +const int kFontSizesPx[] = {12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32}; + +// 字体族——对照原版 ql-font:显示名 + Qt family。 +struct FontOption { const char* label; const char* family; }; +const FontOption kFontFamilies[] = { + {"微软雅黑", "Microsoft YaHei"}, {"宋体", "SimSun"}, {"仿宋", "FangSong"}, + {"楷体", "KaiTi"}, {"黑体", "SimHei"}, {"仿宋_GB2312", "FangSong"}, +}; + +} // namespace DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { auto* lay = new QVBoxLayout(this); - lay->setContentsMargins(8, 8, 8, 8); + lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd, + geopro::app::space::kMd, geopro::app::space::kMd); + lay->setSpacing(geopro::app::space::kSm); + + auto* tb = new QToolBar(this); + buildToolbar(tb); + lay->addWidget(tb); edit_ = new QTextEdit(this); - edit_->setReadOnly(true); + edit_->setAcceptRichText(true); edit_->setPlaceholderText(QStringLiteral("暂无描述")); - lay->addWidget(edit_); + lay->addWidget(edit_, 1); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + saveBtn_ = new QPushButton(QStringLiteral("保存"), this); + saveBtn_->setEnabled(false); // 注入 cmdRepo 后启用 + btnLay->addWidget(saveBtn_); + lay->addLayout(btnLay); + + connect(saveBtn_, &QPushButton::clicked, this, [this]() { emit saveRequested(); }); } -void DescriptionPanel::setText(const QString& text) { - edit_->setPlainText(text); +void DescriptionPanel::buildToolbar(QToolBar* tb) { + // 粗体 / 斜体 / 下划线:可勾选按钮,作用于选区当前字符格式。 + auto addToggle = [this, tb](const QString& label, auto applier) { + auto* btn = new QToolButton(tb); + btn->setText(label); + btn->setCheckable(true); + tb->addWidget(btn); + connect(btn, &QToolButton::toggled, this, applier); + return btn; + }; + // 对照原版 Quill 工具栏:粗/斜 + 字色/背景色 + 对齐 + 标题 + 字号 + 字体族。 + // (原版无下划线/列表,故此处不设,以贴近原版。) + addToggle(QStringLiteral("B"), [this](bool on) { + QTextCharFormat f; + f.setFontWeight(on ? QFont::Bold : QFont::Normal); + edit_->mergeCurrentCharFormat(f); + }); + addToggle(QStringLiteral("I"), [this](bool on) { + QTextCharFormat f; + f.setFontItalic(on); + edit_->mergeCurrentCharFormat(f); + }); + + // 字色:弹色板,作用于选区前景色。 + auto* colorBtn = new QToolButton(tb); + colorBtn->setText(QStringLiteral("A")); + colorBtn->setToolTip(QStringLiteral("字体颜色")); + tb->addWidget(colorBtn); + connect(colorBtn, &QToolButton::clicked, this, [this]() { + const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色")); + if (!c.isValid()) return; + QTextCharFormat f; + f.setForeground(c); + edit_->mergeCurrentCharFormat(f); + }); + + // 背景色:弹色板,作用于选区背景色(对照原版 ql-background)。 + auto* bgBtn = new QToolButton(tb); + bgBtn->setText(QStringLiteral("▢")); + bgBtn->setToolTip(QStringLiteral("背景颜色")); + tb->addWidget(bgBtn); + connect(bgBtn, &QToolButton::clicked, this, [this]() { + const QColor c = QColorDialog::getColor(Qt::yellow, this, QStringLiteral("背景颜色")); + if (!c.isValid()) return; + QTextCharFormat f; + f.setBackground(c); + edit_->mergeCurrentCharFormat(f); + }); + + // 对齐:左/中/右/两端(对照原版 ql-align)——块级。 + auto addAlignBtn = [this, tb](const QString& label, Qt::Alignment align) { + auto* btn = new QToolButton(tb); + btn->setText(label); + tb->addWidget(btn); + connect(btn, &QToolButton::clicked, this, [this, align]() { + edit_->setAlignment(align); + }); + }; + addAlignBtn(QStringLiteral("⯇"), Qt::AlignLeft); + addAlignBtn(QStringLiteral("≡"), Qt::AlignHCenter); + addAlignBtn(QStringLiteral("⯈"), Qt::AlignRight); + addAlignBtn(QStringLiteral("☰"), Qt::AlignJustify); + + // 标题下拉(正文 / H1~H4)——块级,作用于当前段。 + auto* headerBox = new EmptyAwareComboBox(tb); + headerBox->addItem(QStringLiteral("正文"), 0); + for (int h = 1; h <= 4; ++h) headerBox->addItem(QStringLiteral("标题%1").arg(h), h); + tb->addWidget(headerBox); + connect(headerBox, &QComboBox::currentIndexChanged, this, [this, headerBox](int) { + const int h = headerBox->currentData().toInt(); + QTextCursor cur = edit_->textCursor(); + QTextBlockFormat bf = cur.blockFormat(); + bf.setHeadingLevel(h); // 0 表示正文。 + cur.mergeBlockFormat(bf); + edit_->setTextCursor(cur); + }); + + // 字号下拉(px)。 + auto* sizeBox = new EmptyAwareComboBox(tb); + for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px); + sizeBox->setCurrentIndex(2); // 默认 16px(与原版一致)。 + tb->addWidget(sizeBox); + connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) { + const int px = sizeBox->currentData().toInt(); + QTextCharFormat f; + f.setFontPointSize(px * 3.0 / 4.0); // px→pt。 + edit_->mergeCurrentCharFormat(f); + }); + + // 字体族下拉(对照原版 ql-font)。 + auto* fontBox = new EmptyAwareComboBox(tb); + for (const auto& fo : kFontFamilies) + fontBox->addItem(QString::fromUtf8(fo.label), QString::fromUtf8(fo.family)); + tb->addWidget(fontBox); + connect(fontBox, &QComboBox::currentIndexChanged, this, [this, fontBox](int) { + const QString family = fontBox->currentData().toString(); + QTextCharFormat f; + f.setFontFamilies({family}); + edit_->mergeCurrentCharFormat(f); + }); } +void DescriptionPanel::setDelta(const QJsonArray& ops) { + if (ops.isEmpty()) return; + deltaToDocument(ops, *edit_->document()); +} + +void DescriptionPanel::setPlainText(const QString& text) { edit_->setPlainText(text); } + +QJsonArray DescriptionPanel::delta() const { return documentToDelta(*edit_->document()); } + +QString DescriptionPanel::plainText() const { return edit_->toPlainText(); } + +void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); } + } // namespace geopro::app diff --git a/src/app/panels/DescriptionPanel.hpp b/src/app/panels/DescriptionPanel.hpp index b364fae..7559485 100644 --- a/src/app/panels/DescriptionPanel.hpp +++ b/src/app/panels/DescriptionPanel.hpp @@ -1,18 +1,40 @@ #pragma once +#include #include class QTextEdit; +class QPushButton; +class QToolBar; namespace geopro::app { -// 数据集描述面板:只读文本,供网格数据底部页签「描述」使用。 +// 数据集描述面板:富文本编辑器 + 格式工具栏 + 保存按钮(I14)。 +// 对照原版 web(contourPage.vue)的 Quill 编辑器:粗体/斜体/下划线/字色/字号 + +// 有序/无序列表 + 标题。保存时把富文本转 Quill Delta(attachedParameters.deltaContent) +// 与纯文本(description)一并提交(组装/请求见 GridDataChartView)。 +// Delta↔QTextDocument 互转见 QuillDelta.{hpp,cpp}(纯函数,可单测)。 class DescriptionPanel : public QWidget { Q_OBJECT public: explicit DescriptionPanel(QWidget* parent = nullptr); - void setText(const QString& text); + + // 用 Quill Delta ops 回填编辑器(无 ops 时回退 setPlainText 兜底)。 + void setDelta(const QJsonArray& ops); + void setPlainText(const QString& text); + + // 当前内容导出:Delta ops(与原版 deltaContent 兼容)+ 纯文本(description)。 + QJsonArray delta() const; + QString plainText() const; + + // 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。 + void setSaveEnabled(bool on); +signals: + void saveRequested(); private: + void buildToolbar(QToolBar* tb); + QTextEdit* edit_; + QPushButton* saveBtn_; }; } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.cpp b/src/app/panels/DynamicFormEditor.cpp index ae93415..3ab1879 100644 --- a/src/app/panels/DynamicFormEditor.cpp +++ b/src/app/panels/DynamicFormEditor.cpp @@ -2,20 +2,25 @@ #include #include + +#include "EmptyAwareComboBox.hpp" #include #include #include #include #include #include +#include #include #include #include #include +#include #include #include #include +#include "FormKit.hpp" #include "Theme.hpp" namespace geopro::app { @@ -54,7 +59,7 @@ QString labelText(const data::EditField& f) { QString t = QString::fromStdString(f.name); if (f.required == kRequiredYes) t += QStringLiteral(" *") - .arg(QString::fromUtf8(geopro::app::semantic::kDanger)); + .arg(geopro::app::token("status/danger")); return t; } @@ -90,15 +95,18 @@ QWidget* buildWidget(const data::EditField& f) { } case kCompSelect: case kCompTreeSelect: { - auto* cb = new QComboBox(); + // 无候选项:退化为自由文本输入(QLineEdit),而非「可编辑下拉框」。 + // 可编辑下拉框与不可编辑下拉框在 QSS 下几何/高度不一致,是表单观感分裂的来源之一。 if (f.options.empty()) { - cb->setEditable(true); - cb->setCurrentText(val); - } else { - flattenOptions(f.options, cb); - const int idx = cb->findData(val); - if (idx >= 0) cb->setCurrentIndex(idx); + auto* le = new QLineEdit(); + le->setText(val); + if (ro) le->setEnabled(false); + return le; } + auto* cb = new EmptyAwareComboBox(); + flattenOptions(f.options, cb); + const int idx = cb->findData(val); + if (idx >= 0) cb->setCurrentIndex(idx); if (ro) cb->setEnabled(false); return cb; } @@ -132,7 +140,7 @@ QWidget* buildWidget(const data::EditField& f) { auto* te = new QPlainTextEdit(); te->setPlainText(val); te->setFixedHeight(geopro::app::scaledPx(64)); - if (ro) te->setReadOnly(true); + if (ro) te->setEnabled(false); // 只读:灰显禁用 return te; } case kCompNumber: { @@ -143,7 +151,7 @@ QWidget* buildWidget(const data::EditField& f) { } else { applyIntRange(le, f); } - if (ro) le->setReadOnly(true); + if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled) return le; } case kCompText: @@ -158,7 +166,7 @@ QWidget* buildWidget(const data::EditField& f) { } else if (f.dataType == kDtFloat) { le->setValidator(new QDoubleValidator(le)); } - if (ro) le->setReadOnly(true); + if (ro) le->setEnabled(false); // 只读:灰显禁用(明确不可编辑,对齐原版 a-input disabled) return le; } } @@ -176,6 +184,7 @@ QString readWidget(int comp, QWidget* w) { const QVariant d = cb->currentData(); return d.isValid() ? d.toString() : cb->currentText(); } + if (auto* le = qobject_cast(w)) return le->text(); // 无候选项时的自由文本退化 return {}; case kCompDate: if (auto* de = qobject_cast(w)) @@ -202,6 +211,12 @@ bool widgetEmpty(int comp, QWidget* w) { if (comp == kCompCheckbox) return false; // 复选框总有值 return readWidget(comp, w).trimmed().isEmpty(); } + +// 限制字段控件最大宽(规范 §7.0.2「不要拉满」≈360px);多行文本不限(可更宽)。 +void capFieldWidth(int comp, QWidget* w) { + if (comp == kCompMultiline) return; // 多行/长文本可更宽,不设上限 + w->setMaximumWidth(geopro::app::scaledPx(geopro::app::space::kFormFieldMax)); +} } // namespace DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) { @@ -210,36 +225,92 @@ DynamicFormEditor::DynamicFormEditor(QWidget* parent) : QWidget(parent) { lay->setSpacing(0); } -void DynamicFormEditor::setForm(const data::EditableForm& form) { +void DynamicFormEditor::clear() { entries_.clear(); if (body_) { body_->deleteLater(); body_ = nullptr; } +} + +void DynamicFormEditor::setForm(const data::EditableForm& form, + const QSet& hiddenFieldNames) { + clear(); body_ = new QWidget(this); auto* outer = new QVBoxLayout(body_); outer->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kMd, geopro::app::space::kLg, geopro::app::space::kMd); outer->setSpacing(geopro::app::space::kMd); - for (const auto& g : form.groups) { - if (form.groups.size() > 1 || !g.name.empty()) { - auto* sec = new QLabel(QString::fromStdString(g.name), body_); - geopro::app::applyTokenizedStyleSheet( - sec, QStringLiteral("color:{{text/secondary}};font-weight:%1;") - .arg(geopro::app::type::kWeightSemibold)); - outer->addWidget(sec); + // 用户修改任一可编辑控件 → changed()(脏标记)。值已在 buildWidget 内预填、连接在其后, + // 故初始化不会误触发;只读字段不挂(其值不应被视作用户编辑)。 + auto wireChanged = [this](QWidget* w, int comp) { + switch (comp) { + case kCompCheckbox: + if (auto* x = qobject_cast(w)) + connect(x, &QCheckBox::toggled, this, [this] { emit changed(); }); + break; + case kCompSelect: + case kCompTreeSelect: + if (auto* x = qobject_cast(w)) + connect(x, &QComboBox::currentTextChanged, this, [this] { emit changed(); }); + else if (auto* le = qobject_cast(w)) // 无候选项时的自由文本退化 + connect(le, &QLineEdit::textEdited, this, [this] { emit changed(); }); + break; + case kCompDate: + if (auto* x = qobject_cast(w)) + connect(x, &QDateEdit::dateChanged, this, [this] { emit changed(); }); + break; + case kCompTime: + if (auto* x = qobject_cast(w)) + connect(x, &QTimeEdit::timeChanged, this, [this] { emit changed(); }); + break; + case kCompDateTime: + if (auto* x = qobject_cast(w)) + connect(x, &QDateTimeEdit::dateTimeChanged, this, [this] { emit changed(); }); + break; + case kCompMultiline: + if (auto* x = qobject_cast(w)) + connect(x, &QPlainTextEdit::textChanged, this, [this] { emit changed(); }); + break; + default: + if (auto* x = qobject_cast(w)) + connect(x, &QLineEdit::textEdited, this, [this] { emit changed(); }); + break; } - auto* fl = new QFormLayout(); - fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); - fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); + }; + + bool renderedGroup = false; // 已渲染过组标题 → 后续组上方留 space/lg 分隔 + for (const auto& g : form.groups) { + // 先分流:隐藏字段保留提交值但不渲染;可见字段进 visible 用于布局。 + std::vector visible; for (const auto& f : g.fields) { + if (hiddenFieldNames.contains(QString::fromStdString(f.name))) { + Entry e; + e.code = QString::fromStdString(f.code); + e.name = QString::fromStdString(f.name); + e.comp = f.comp; + e.readonly = true; + e.hidden = true; + e.value = QString::fromStdString(f.value); + entries_.push_back(e); + } else { + visible.push_back(&f); + } + } + if (visible.empty()) continue; // 整组被隐藏 → 不渲染空标题 + + if (form.groups.size() > 1 || !g.name.empty()) { + // 分组标题 + 标题下 1px divider(规范 §7.0.3 / §6.4),统一走 formkit(单一真相)。 + formkit::addSection(outer, QString::fromStdString(g.name), body_, renderedGroup); + } + renderedGroup = true; + auto* fl = formkit::makeEditForm(); + for (const data::EditField* fp : visible) { + const data::EditField& f = *fp; QWidget* w = buildWidget(f); - auto* lbl = new QLabel(labelText(f), body_); - lbl->setTextFormat(Qt::RichText); // 允许 * 的红色 span - fl->addRow(lbl, w); + capFieldWidth(f.comp, w); // 字段最大宽上限(§7.0.2 不要拉满;多行除外,故仍用本地版) + fl->addRow(formkit::editLabel(labelText(f), body_, true), w); Entry e; e.code = QString::fromStdString(f.code); e.name = QString::fromStdString(f.name); @@ -248,6 +319,7 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) { e.readonly = isReadonly(f); e.widget = w; entries_.push_back(e); + if (!e.readonly) wireChanged(w, f.comp); // 仅可编辑字段挂脏标记 } outer->addLayout(fl); } @@ -257,7 +329,12 @@ void DynamicFormEditor::setForm(const data::EditableForm& form) { QMap DynamicFormEditor::collectValues() const { QMap out; - for (const auto& e : entries_) out.insert(e.code, readWidget(e.comp, e.widget)); + for (const auto& e : entries_) { + if (e.hidden || !e.widget) + out.insert(e.code, e.value); // 隐藏字段:回填原值,避免提交时被清空 + else + out.insert(e.code, readWidget(e.comp, e.widget)); + } return out; } @@ -272,4 +349,22 @@ bool DynamicFormEditor::validateRequired(QString* missingName) const { return true; } +void DynamicFormEditor::focusFirstInvalid() { + for (const auto& e : entries_) { + if (e.required && !e.readonly && e.widget && widgetEmpty(e.comp, e.widget)) { + e.widget->setFocus(Qt::OtherFocusReason); + // 滚动到可见:上层 QScrollArea 会响应 ensureWidgetVisible,这里主动请求一次。 + if (auto* p = e.widget->parentWidget()) p->updateGeometry(); + QWidget* w = e.widget; + for (QWidget* a = w->parentWidget(); a; a = a->parentWidget()) { + if (auto* sa = qobject_cast(a)) { + sa->ensureWidgetVisible(w); + break; + } + } + return; + } + } +} + } // namespace geopro::app diff --git a/src/app/panels/DynamicFormEditor.hpp b/src/app/panels/DynamicFormEditor.hpp index 38f706e..0697e80 100644 --- a/src/app/panels/DynamicFormEditor.hpp +++ b/src/app/panels/DynamicFormEditor.hpp @@ -1,5 +1,6 @@ #pragma once #include +#include #include #include #include @@ -21,10 +22,18 @@ class DynamicFormEditor : public QWidget { public: explicit DynamicFormEditor(QWidget* parent = nullptr); - void setForm(const data::EditableForm& form); // 重建控件 + // 重建控件。hiddenFieldNames:按 fieldName 隐藏的字段(如与上层固定「名称」重复)—— + // 不渲染,但其值仍参与 collectValues(保留提交,避免后端清空)。 + void setForm(const data::EditableForm& form, const QSet& hiddenFieldNames = {}); + void clear(); // 清空控件与状态(切换/加载前去残留) QMap collectValues() const; // fieldCode → 当前值 // 校验必填:全部满足返回 true;否则返回 false 并把首个缺失字段名写入 *missingName。 bool validateRequired(QString* missingName) const; + // 聚焦第一个「必填且为空」的可编辑控件并滚动可见(规范 §7.0.5:聚焦第一个错误字段)。 + void focusFirstInvalid(); + +signals: + void changed(); // 任一可编辑控件被用户修改(上层据此启用「保存」脏标记) private: struct Entry { @@ -33,6 +42,8 @@ private: int comp = 1; bool required = false; bool readonly = false; // 只读(comp2 / requiredType2 / 核心测量值):不参与必填校验 + bool hidden = false; // 去重隐藏:不渲染,但 value 仍参与收集提交 + QString value; // hidden 时的固定提交值(widget 为空) QWidget* widget = nullptr; // 取值控件 }; QVector entries_; diff --git a/src/app/panels/DynamicFormView.cpp b/src/app/panels/DynamicFormView.cpp index a4e6c5a..39c298b 100644 --- a/src/app/panels/DynamicFormView.cpp +++ b/src/app/panels/DynamicFormView.cpp @@ -1,76 +1,16 @@ #include "panels/DynamicFormView.hpp" #include -#include #include #include #include #include "Theme.hpp" +#include "panels/KeyValueView.hpp" namespace geopro::app { -namespace { - -// 两列字段网格的逻辑列:label/value 各两份,value 列吸收伸展、label 列贴合内容。 -constexpr int kColLabelA = 0; -constexpr int kColValueA = 1; -constexpr int kColLabelB = 2; -constexpr int kColValueB = 3; -constexpr int kColSpanAll = 4; // 分组标题带横跨全部 4 列 - -// 字段标签(次要色,右侧留点呼吸,顶对齐以配合值换行)。 -QLabel* makeLabel(const QString& text) -{ - auto* k = new QLabel(text); - k->setAlignment(Qt::AlignLeft | Qt::AlignTop); - geopro::app::applyTokenizedStyleSheet( - k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); - return k; -} - -// 字段值(主色、可换行、可选中复制)。 -QLabel* makeValue(const QString& text) -{ - auto* v = new QLabel(text); - v->setWordWrap(true); - v->setAlignment(Qt::AlignLeft | Qt::AlignTop); - v->setTextInteractionFlags(Qt::TextSelectableByMouse); - geopro::app::applyTokenizedStyleSheet( - v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); - return v; -} - -// 行间横向分隔线(1px,divider 令牌,随主题重着色)。 -QFrame* makeRowDivider() -{ - auto* line = new QFrame(); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - line->setFixedHeight(1); - geopro::app::applyTokenizedStyleSheet( - line, QStringLiteral("background:{{divider}}; border:none;")); - return line; -} - -// 分组标题带:横跨整行的淡底强调条,半粗次要色,给表单清晰的层级。 -QLabel* makeGroupHeader(const QString& name) -{ - auto* title = new QLabel(name); - geopro::app::applyTokenizedStyleSheet( - title, QStringLiteral("color:{{text/secondary}}; background:{{bg/hover}};" - "font-weight:%1; font-size:%2px;" - "border-radius:%3px; padding:5px 10px;") - .arg(geopro::app::type::kWeightSemibold) - .arg(geopro::app::scaledPx(geopro::app::type::kBody)) - .arg(geopro::app::radius::kSm)); - return title; -} - -} // namespace - -DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) -{ +DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) { auto* outer = new QVBoxLayout(this); outer->setContentsMargins(0, 0, 0, 0); outer->setSpacing(0); @@ -87,18 +27,31 @@ DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) hostLayout->setSpacing(0); // 表单卡片:浅一档底色 + 1px 边框 + 中圆角,从面板底上读出独立「表单」面。 - card_ = new QFrame(); - card_->setObjectName(QStringLiteral("attrForm")); + auto* card = new QFrame(); + card->setObjectName(QStringLiteral("attrForm")); geopro::app::applyTokenizedStyleSheet( - card_, QStringLiteral("#attrForm { background:{{bg/panel-subtle}};" - "border:1px solid {{border/default}}; border-radius:%1px; }") - .arg(geopro::app::radius::kMd)); - cardLayout_ = new QVBoxLayout(card_); - cardLayout_->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, - geopro::app::space::kLg, geopro::app::space::kLg); - cardLayout_->setSpacing(geopro::app::space::kMd); + card, QStringLiteral("#attrForm { background:{{bg/panel-subtle}};" + "border:1px solid {{border/default}}; border-radius:%1px; }") + .arg(geopro::app::radius::kMd)); + auto* cardLayout = new QVBoxLayout(card); + cardLayout->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + cardLayout->setSpacing(geopro::app::space::kMd); - hostLayout->addWidget(card_); + // 空/错占位(与键值视图互斥显示)。 + placeholder_ = new QLabel(card); + placeholder_->setAlignment(Qt::AlignCenter); + geopro::app::applyTokenizedStyleSheet( + placeholder_, + QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;") + .arg(geopro::app::space::kXl)); + cardLayout->addWidget(placeholder_); + + // 唯一只读渲染器(§6.4)。详情对话框直接复用 KeyValueView,保证处处一致。 + kv_ = new KeyValueView(card); + cardLayout->addWidget(kv_); + + hostLayout->addWidget(card); hostLayout->addStretch(); scroll->setWidget(host); outer->addWidget(scroll); @@ -106,91 +59,21 @@ DynamicFormView::DynamicFormView(QWidget* parent) : QWidget(parent) showMessage(QStringLiteral("(选中后显示属性详情)")); } -void DynamicFormView::clear() -{ - while (cardLayout_->count() > 0) { - QLayoutItem* it = cardLayout_->takeAt(0); - if (it->widget()) it->widget()->deleteLater(); - if (QLayout* sub = it->layout()) { - // 嵌套网格:先回收其子控件(控件归属 card_,删布局不会连带删),再让下方 - // delete it 释放该嵌套布局本身——注意 it == it->layout()(QLayout 即 QLayoutItem), - // 故此处不可再 delete sub,否则与 delete it 重复释放导致崩溃。 - while (sub->count() > 0) { - QLayoutItem* sit = sub->takeAt(0); - if (sit->widget()) sit->widget()->deleteLater(); - delete sit; - } - } - delete it; - } -} - -void DynamicFormView::showCardMessage(const QString& message) -{ - auto* hint = new QLabel(message); - hint->setAlignment(Qt::AlignCenter); - geopro::app::applyTokenizedStyleSheet( - hint, QStringLiteral("color:{{text/disabled}}; background:transparent; padding:%1px;") - .arg(geopro::app::space::kXl)); - cardLayout_->addWidget(hint); -} - -void DynamicFormView::showMessage(const QString& message) -{ - clear(); - showCardMessage(message); - cardLayout_->addStretch(); -} - -void DynamicFormView::setForm(const geopro::data::DynamicForm& form) -{ - clear(); +void DynamicFormView::setForm(const geopro::data::DynamicForm& form) { if (form.groups.empty()) { - showCardMessage(QStringLiteral("(暂无属性)")); - cardLayout_->addStretch(); + showMessage(QStringLiteral("(暂无属性)")); return; } + placeholder_->hide(); + kv_->show(); + kv_->setForm(form); +} - for (const auto& group : form.groups) { - // 每组一个独立网格:分组标题带横跨 4 列,字段两列自上而下、自左而右铺排。 - auto* grid = new QGridLayout(); - grid->setContentsMargins(0, 0, 0, 0); - grid->setHorizontalSpacing(geopro::app::space::kLg); - grid->setVerticalSpacing(geopro::app::space::kXs); - grid->setColumnStretch(kColValueA, 1); - grid->setColumnStretch(kColValueB, 1); - - // gridRow 线性递增:标题占一行,之后「分隔线行 + 字段对行」交替。 - int gridRow = 0; - grid->addWidget(makeGroupHeader(QString::fromStdString(group.name)), gridRow, 0, 1, - kColSpanAll); - ++gridRow; - - const int n = static_cast(group.fields.size()); - if (n == 0) { - grid->addWidget(makeLabel(QStringLiteral("(本组暂无字段)")), gridRow, 0, 1, - kColSpanAll); - } - // 字段两两成对,每对一行:偶数下标落左对(labelA|valueA),奇数落右对(labelB|valueB)。 - for (int i = 0; i < n; ++i) { - const auto& f = group.fields[static_cast(i)]; - const bool left = (i % 2 == 0); - if (left) { - // 每新起一行前先放一条横向分隔线(铺满 4 列),再让字段对落到下一行。 - grid->addWidget(makeRowDivider(), gridRow, 0, 1, kColSpanAll); - ++gridRow; - } - const int labelCol = left ? kColLabelA : kColLabelB; - const int valueCol = left ? kColValueA : kColValueB; - grid->addWidget(makeLabel(QString::fromStdString(f.name)), gridRow, labelCol); - grid->addWidget(makeValue(QString::fromStdString(f.value)), gridRow, valueCol); - if (!left) ++gridRow; // 右对填满,行完结;左对则等右对或循环结束 - } - - cardLayout_->addLayout(grid); - } - - cardLayout_->addStretch(); +void DynamicFormView::showMessage(const QString& message) { + kv_->clear(); + kv_->hide(); + placeholder_->setText(message); + placeholder_->show(); } } // namespace geopro::app diff --git a/src/app/panels/DynamicFormView.hpp b/src/app/panels/DynamicFormView.hpp index 31ea617..0470dad 100644 --- a/src/app/panels/DynamicFormView.hpp +++ b/src/app/panels/DynamicFormView.hpp @@ -2,14 +2,15 @@ #include #include "repo/RepoTypes.hpp" -class QVBoxLayout; -class QFrame; +class QLabel; namespace geopro::app { -// 被动:渲染 DynamicForm(分组键值)为「两列卡片式属性表单」。对象属性 / 数据集属性两面板共用。 -// 视觉:外层滚动区内嵌一张带边框/底色/圆角的表单卡片;每组一个分组标题带(横跨整行), -// 组内字段两列排布(labelA|valueA labelB|valueB),行间细分隔线。颜色全走主题令牌。 +class KeyValueView; + +// 属性面板用的只读表单容器:滚动区 + 卡片,内嵌唯一渲染器 KeyValueView(§6.4)。 +// 对象属性 / 数据集属性面板共用。数据详情对话框不经本类,直接用 KeyValueView +// (见 formkit::buildDetailDialog),但二者共享同一渲染器,外观一致。 class DynamicFormView : public QWidget { public: explicit DynamicFormView(QWidget* parent = nullptr); @@ -17,11 +18,8 @@ public: void showMessage(const QString& message); // 空/错占位 private: - void clear(); // 拆掉卡片内全部内容(含分隔线/标题/字段) - void showCardMessage(const QString& message); // 卡片内居中淡色提示 - - QFrame* card_ = nullptr; // 表单卡片(objectName=attrForm) - QVBoxLayout* cardLayout_ = nullptr; // 卡片内纵向布局(容纳各分组网格 / 占位提示) + KeyValueView* kv_ = nullptr; + QLabel* placeholder_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/KeyValueView.cpp b/src/app/panels/KeyValueView.cpp new file mode 100644 index 0000000..08c129c --- /dev/null +++ b/src/app/panels/KeyValueView.cpp @@ -0,0 +1,119 @@ +#include "panels/KeyValueView.hpp" + +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" + +namespace geopro::app { + +namespace { + +constexpr int kColLabelA = 0; +constexpr int kColValueA = 1; +constexpr int kColLabelB = 2; +constexpr int kColValueB = 3; + +constexpr int kRowMinHeight = 28; // 字段行高(§6.4) + +// 数值/坐标类值用等宽字族逐列对齐(§2.1/§6.4);纯文字保持默认字族。 +bool isNumericValue(const QString& text) { + const QString trimmed = text.trimmed(); + if (trimmed.isEmpty()) return false; + int digits = 0; + int letters = 0; + for (const QChar c : trimmed) { + if (c.isDigit()) + ++digits; + else if (c.isLetter()) + ++letters; + } + return digits > 0 && digits >= letters; +} + +QLabel* makeKey(const QString& text) { + auto* k = new QLabel(text); + k->setAlignment(Qt::AlignLeft | Qt::AlignTop); + k->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kDetailKeyCol)); // 定宽,跨组对齐 + k->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); + geopro::app::applyTokenizedStyleSheet( + k, QStringLiteral("color:{{text/secondary}}; background:transparent; padding:2px 0;")); + return k; +} + +QLabel* makeValue(const QString& text) { + auto* v = new QLabel(text); + v->setWordWrap(true); + v->setAlignment(Qt::AlignLeft | Qt::AlignTop); + v->setTextInteractionFlags(Qt::TextSelectableByMouse); + v->setMinimumHeight(geopro::app::scaledPx(kRowMinHeight)); + if (isNumericValue(text)) { + QFont f = v->font(); + // 必须 setFamilies(列表):setFamily 把整串逗号名当单一字族找不到→静默回退。 + f.setFamilies(QString::fromLatin1(geopro::app::type::kMonoFamily).split(QStringLiteral(", "))); + v->setFont(f); + } + geopro::app::applyTokenizedStyleSheet( + v, QStringLiteral("color:{{text/primary}}; background:transparent; padding:2px 0;")); + return v; +} + +} // namespace + +KeyValueView::KeyValueView(QWidget* parent) : QWidget(parent) { + lay_ = new QVBoxLayout(this); + lay_->setContentsMargins(0, 0, 0, 0); + lay_->setSpacing(geopro::app::space::kMd); +} + +void KeyValueView::clear() { + QLayoutItem* it = nullptr; + while ((it = lay_->takeAt(0)) != nullptr) { + if (it->widget()) it->widget()->deleteLater(); + if (QLayout* sub = it->layout()) { + QLayoutItem* sit = nullptr; + while ((sit = sub->takeAt(0)) != nullptr) { + if (sit->widget()) sit->widget()->deleteLater(); + delete sit; + } + } + delete it; + } +} + +void KeyValueView::setForm(const geopro::data::DynamicForm& form) { + clear(); + bool renderedGroup = false; + for (const auto& group : form.groups) { + const QString gname = QString::fromStdString(group.name); + if (!gname.isEmpty() || form.groups.size() > 1) { + formkit::addSection(lay_, gname, this, renderedGroup); + renderedGroup = true; + } + + // 两列网格:label/value 各两份,value 列吸收伸展、label 列贴合内容。 + auto* grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setHorizontalSpacing(geopro::app::space::kLg); + grid->setVerticalSpacing(geopro::app::space::kXs); + grid->setColumnStretch(kColValueA, 1); + grid->setColumnStretch(kColValueB, 1); + + const int n = static_cast(group.fields.size()); + for (int i = 0; i < n; ++i) { + const auto& f = group.fields[static_cast(i)]; + const bool left = (i % 2 == 0); + const int gridRow = i / 2; + const int labelCol = left ? kColLabelA : kColLabelB; + const int valueCol = left ? kColValueA : kColValueB; + grid->addWidget(makeKey(QString::fromStdString(f.name)), gridRow, labelCol); + grid->addWidget(makeValue(QString::fromStdString(f.value)), gridRow, valueCol); + } + lay_->addLayout(grid); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/KeyValueView.hpp b/src/app/panels/KeyValueView.hpp new file mode 100644 index 0000000..cd274d8 --- /dev/null +++ b/src/app/panels/KeyValueView.hpp @@ -0,0 +1,25 @@ +#pragma once +#include + +#include "repo/RepoTypes.hpp" + +class QVBoxLayout; + +namespace geopro::app { + +// 唯一的「只读键值表」渲染器(规范 §6.4 / §7.0.10):分组标题(formkit::addSection)+ +// 两列键值(数值/坐标等宽对齐)。**不含**滚动区/卡片外壳——容器由调用方决定: +// · 属性面板:DynamicFormView 在滚动卡片内嵌本视图; +// · 数据详情对话框:formkit::buildDetailDialog 直接内嵌本视图(随内容自适应大小)。 +// 属性面板与各详情对话框共用本类,保证只读表单处处一致。 +class KeyValueView : public QWidget { +public: + explicit KeyValueView(QWidget* parent = nullptr); + void setForm(const geopro::data::DynamicForm& form); + void clear(); + +private: + QVBoxLayout* lay_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/LoadingOverlay.cpp b/src/app/panels/LoadingOverlay.cpp index 095c1f9..b7f2460 100644 --- a/src/app/panels/LoadingOverlay.cpp +++ b/src/app/panels/LoadingOverlay.cpp @@ -30,7 +30,10 @@ LoadingOverlay::LoadingOverlay(QWidget* parent) : QWidget(parent), label_(new QL hide(); } -void LoadingOverlay::showOver() { +void LoadingOverlay::showOver() { showOver(QStringLiteral("加载中…")); } + +void LoadingOverlay::showOver(const QString& message) { + label_->setText(message); if (parentWidget()) setGeometry(parentWidget()->rect()); raise(); show(); diff --git a/src/app/panels/LoadingOverlay.hpp b/src/app/panels/LoadingOverlay.hpp index 624b4eb..fc33499 100644 --- a/src/app/panels/LoadingOverlay.hpp +++ b/src/app/panels/LoadingOverlay.hpp @@ -3,12 +3,14 @@ class QLabel; namespace geopro::app { -// 半透明「加载中…」遮罩。贴在目标视图上层,showOver()/hide() 切换,几何随父 resize 跟随。 +// 半透明「等待」遮罩(公共组件)。贴在任意目标视图上层(含 VTK QVTKOpenGLStereoWidget), +// showOver()/hide() 切换,几何随父 resize 跟随。可传自定义文案在不同场景复用。 class LoadingOverlay : public QWidget { Q_OBJECT public: explicit LoadingOverlay(QWidget* parent); - void showOver(); // 铺满父尺寸、置顶、显示 + void showOver(); // 铺满父尺寸、置顶、显示(默认「加载中…」) + void showOver(const QString& message); // 同上,自定义提示文案(如「正在保存三维体…」) protected: bool eventFilter(QObject* obj, QEvent* ev) override; // 跟随父 resize private: diff --git a/src/app/panels/ObjectAttrPanel.cpp b/src/app/panels/ObjectAttrPanel.cpp index 4f38bea..c03e85f 100644 --- a/src/app/panels/ObjectAttrPanel.cpp +++ b/src/app/panels/ObjectAttrPanel.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include "Theme.hpp" @@ -59,6 +60,10 @@ ObjectAttrPanel::ObjectAttrPanel(geopro::data::IAsyncProjectRepository& repo, QW lay->addLayout(btnRow); QObject::connect(saveBtn_, &QPushButton::clicked, this, &ObjectAttrPanel::onSave); + // 脏标记:动态字段被用户修改 → 启用「保存」(加载/未改时保持禁用)。 + QObject::connect(editor_, &DynamicFormEditor::changed, this, [this] { + if (!objectId_.isEmpty()) saveBtn_->setEnabled(true); + }); // 初始:无选中 → 隐藏编辑区,仅占位提示。 scroll_->setVisible(false); @@ -88,27 +93,36 @@ void ObjectAttrPanel::loadObject(const QString& projectId, const QString& typeId typeId_ = typeId; parentId_ = parentId; + editor_->clear(); // 去残留:切换/加载期间不显示上一个对象的字段 + + if (typeId.isEmpty()) { + // 无类型 → getDynamicForm 必「数据不存在」;直接给友好占位,避免报错 + 残留旧表单。 + showMessage(QStringLiteral("(该对象暂无可编辑属性)")); + return; + } + scroll_->setVisible(true); saveBtn_->setVisible(true); - saveBtn_->setEnabled(false); + saveBtn_->setEnabled(false); // 脏标记:未修改不可保存(等用户实际编辑) status_->setText(QStringLiteral("加载中…")); status_->setVisible(true); rebuildTopFields(); - if (nameEdit_) nameEdit_->setText(displayName); // 编辑态:名称预填(沿用对话框语义) + if (nameEdit_) nameEdit_->setText(displayName); // 名称只读预填(与原版一致) if (formReq_) formReq_->abort(); formReq_ = repo_.loadEditableFormAsync(typeId.toStdString(), objectId.toStdString(), confType, projectId_.toStdString()); QObject::connect(formReq_, &geopro::data::NavRequest::done, this, [this](const QVariant& v) { const auto form = qvariant_cast(v); - editor_->setForm(form); + // 隐藏动态表单里与顶部固定「名称」重复的字段(值仍随 properties 提交,不丢)。 + editor_->setForm(form, QSet{QStringLiteral("名称")}); status_->setVisible(false); - saveBtn_->setEnabled(true); + // 不在此启用保存——保持禁用,直到用户真正修改某字段(changed 信号)。 }); QObject::connect(formReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { - status_->setText(QStringLiteral("加载失败:%1").arg(msg)); - status_->setVisible(true); + // 失败:清空编辑区只留错误提示,避免残留上一个对象的属性。 + showMessage(QStringLiteral("加载失败:%1").arg(msg)); }); } @@ -131,23 +145,35 @@ void ObjectAttrPanel::rebuildTopFields() { geopro::app::space::kLg, 0); fl->setLabelAlignment(Qt::AlignRight | Qt::AlignVCenter); fl->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); - fl->setHorizontalSpacing(geopro::app::space::kMd); - fl->setVerticalSpacing(geopro::app::space::kSm); + fl->setHorizontalSpacing(geopro::app::space::kLg); // 标签↔字段 space/md(12)=项目 kLg + fl->setVerticalSpacing(geopro::app::space::kMd); // 行距 ≈8px(与动态表单一致) + + // 顶部固定字段标签列与动态表单等宽对齐(规范 §7.0.2)。 + auto addRow = [&](const QString& text, QLineEdit* edit) { + auto* lbl = new QLabel(text, topBox_); + lbl->setFixedWidth(geopro::app::scaledPx(geopro::app::space::kFormLabelCol)); + edit->setMaximumWidth(geopro::app::scaledPx(geopro::app::space::kFormFieldMax)); + fl->addRow(lbl, edit); + }; nameEdit_ = new QLineEdit(topBox_); nameEdit_->setEnabled(false); // 编辑态名称禁用(与对话框一致) - fl->addRow(QStringLiteral("名称"), nameEdit_); + addRow(QStringLiteral("名称"), nameEdit_); if (confType_ == kConfGs) { responsibleEdit_ = new QLineEdit(topBox_); responsibleEdit_->setPlaceholderText(QStringLiteral("负责人")); - fl->addRow(QStringLiteral("负责人"), responsibleEdit_); + // 负责人可编辑 → 纳入脏标记。 + QObject::connect(responsibleEdit_, &QLineEdit::textEdited, this, + [this] { saveBtn_->setEnabled(true); }); + addRow(QStringLiteral("负责人"), responsibleEdit_); } } void ObjectAttrPanel::onSave() { QString missing; if (!editor_->validateRequired(&missing)) { + editor_->focusFirstInvalid(); // 规范 §7.0.5:聚焦第一个错误字段 QMessageBox::warning(this, QStringLiteral("校验"), QStringLiteral("请填写必填项:%1").arg(missing)); return; @@ -182,7 +208,7 @@ void ObjectAttrPanel::onSave() { confType_, false, QJsonDocument(body).toJson(QJsonDocument::Compact).toStdString()); QObject::connect(saveReq_, &geopro::data::NavRequest::done, this, [this](const QVariant&) { status_->setVisible(false); - saveBtn_->setEnabled(true); + saveBtn_->setEnabled(false); // 保存成功 → 回到「无未保存修改」态 emit saved(); }); QObject::connect(saveReq_, &geopro::data::NavRequest::failed, this, [this](const QString& msg) { diff --git a/src/app/panels/ObjectTreePanel.cpp b/src/app/panels/ObjectTreePanel.cpp index 32bc913..f260c03 100644 --- a/src/app/panels/ObjectTreePanel.cpp +++ b/src/app/panels/ObjectTreePanel.cpp @@ -1,14 +1,19 @@ #include "panels/ObjectTreePanel.hpp" #include +#include #include #include #include #include +#include #include +#include #include +#include #include #include +#include #include #include #include @@ -19,6 +24,7 @@ #include "Glyphs.hpp" #include "Theme.hpp" #include "dto/NavDto.hpp" +#include "panels/ObjectTreeSelection.hpp" namespace geopro::app { @@ -27,9 +33,71 @@ constexpr int kRoleObjId = Qt::UserRole + 2; // 节点对象 id(GS/TM 都 constexpr int kRoleConfType = Qt::UserRole + 3; // 1=GS 2=TM constexpr int kRoleTypeId = Qt::UserRole + 4; // 类型 id(编辑调 getDynamicForm 用) constexpr int kRoleIsRoot = Qt::UserRole + 5; // 项目根标记(按 GS 处理,但仅 新建GS/TM/属性) +constexpr int kRoleChildCount = Qt::UserRole + 6; // §6.1 计数徽标:GS 直接子节点数(>0 才显示) +constexpr int kRoleGsDsOn = Qt::UserRole + 7; // GS 自身直挂 ds 开关(bool;三态聚合用,spec §6) constexpr int kConfTypeGs = 1; // GS(工区) constexpr int kConfTypeTm = 2; // TM 叶子 +// §6.1 规范像素(随字号缩放,与全局 px 化 QSS 对齐)。 +constexpr int kRowHeightBase = 28; // 行高 28px +constexpr int kIndentBase = 16; // 每级缩进 16px +constexpr int kTypeIconBase = 14; // 类型图标 14px +constexpr int kAccentBarBase = 2; // 选中行左侧 2px 竖条 + +// §6.1 对象树行委托:先调基类绘制标准单元(保留原生复选框指示区 + 选中底色 + 图标 + 文字, +// 故 SE_ItemViewItemCheckIndicator 命中检测不受影响),再叠加: +// ① 选中行左侧 2px accent/primary 竖条(覆盖层,不挤压文字); +// ② 右对齐计数徽标(GS 子节点数,text/caption + text/tertiary 色)。 +// 行高经 sizeHint 固定为 scaledPx(28);选中行文字加粗(600)在 initStyleOption 注入。 +class ObjectRowDelegate : public QStyledItemDelegate { +public: + using QStyledItemDelegate::QStyledItemDelegate; + + QSize sizeHint(const QStyleOptionViewItem& opt, const QModelIndex& idx) const override { + QSize s = QStyledItemDelegate::sizeHint(opt, idx); + s.setHeight(scaledPx(kRowHeightBase)); + return s; + } + +protected: + // 选中行:名称用 text/body-strong(字重 600)。基类据此 option.font 绘制加粗文字。 + void initStyleOption(QStyleOptionViewItem* opt, const QModelIndex& idx) const override { + QStyledItemDelegate::initStyleOption(opt, idx); + if (opt->state & QStyle::State_Selected) opt->font.setWeight(QFont::DemiBold); + } + +public: + void paint(QPainter* painter, const QStyleOptionViewItem& opt, + const QModelIndex& idx) const override { + // ① 标准单元(复选框/图标/名称/选中底色)——必须先画,勿替换,否则复选框消失。 + QStyledItemDelegate::paint(painter, opt, idx); + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + // ② 选中行左侧 2px accent 竖条(覆盖层,贴行左缘,不改文字位置)。 + if (opt.state & QStyle::State_Selected) { + const int bar = scaledPx(kAccentBarBase); + painter->fillRect(QRect(opt.rect.left(), opt.rect.top(), bar, opt.rect.height()), + tokenColor("accent/primary")); + } + + // ③ 右对齐计数徽标:仅 GS 且子节点数 >0。text/caption + text/tertiary。 + const int count = idx.data(kRoleChildCount).toInt(); + if (count > 0) { + QFont f = opt.font; + f.setPixelSize(scaledPx(type::kCaption)); // setPixelSize 直接生效,无需先 setPointSizeF(-1) + // (-1 会触发 QFont::setPointSizeF 警告刷屏日志) + f.setWeight(QFont::Normal); + painter->setFont(f); + painter->setPen(tokenColor("text/tertiary")); + QRect r = opt.rect.adjusted(0, 0, -scaledPx(space::kMd), 0); + painter->drawText(r, Qt::AlignRight | Qt::AlignVCenter, QString::number(count)); + } + painter->restore(); + } +}; + // topLevel=true 仅用于项目根:按 GS 处理(xlsx 第32行 + 真实数据 TM 挂根), // 携带其 id/typeId,可右键 新建GS/TM/属性;勾选随 2D/3D 批次暂不开放。 void addNodes(QTreeWidgetItem* parent, const std::vector& nodes, @@ -39,19 +107,29 @@ void addNodes(QTreeWidgetItem* parent, const std::vectorsetText(0, QString::fromStdString(n.node.name)); item->setData(0, kRoleObjId, QString::fromStdString(n.node.id)); item->setData(0, kRoleTypeId, QString::fromStdString(n.node.typeId)); + // §6.1 类型图标 14px,text/secondary 色:GS=工区(WorkArea)、TM=测线(SurveyLine)。 + const int iconPx = scaledPx(kTypeIconBase); + const QColor iconColor = tokenColor("text/secondary"); if (topLevel) { // 项目根:作为 GS 承载(id 携带),不可勾选;菜单仅 新建GS/TM/属性。 item->setData(0, kRoleConfType, kConfTypeGs); item->setData(0, kRoleIsRoot, true); + item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); } else if (n.isTm) { item->setData(0, kRoleConfType, kConfTypeTm); item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(0, Qt::Unchecked); + item->setIcon(0, makeGlyph(Glyph::SurveyLine, iconColor, iconPx)); } else { item->setData(0, kRoleConfType, kConfTypeGs); // GS - item->setFlags(item->flags() | Qt::ItemIsUserCheckable | Qt::ItemIsAutoTristate); + // 停用 Qt::ItemIsAutoTristate:GS 三态由 recomputeGsState 手动聚合(自身 ds 开关 + 子 TM)。 + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setData(0, kRoleGsDsOn, false); // GS 自身直挂 ds 开关初始关 item->setCheckState(0, Qt::Unchecked); + item->setIcon(0, makeGlyph(Glyph::WorkArea, iconColor, iconPx)); } + // §6.1 计数徽标:GS(含项目根)显示直接子节点数;TM 叶子无计数。 + if (!n.isTm) item->setData(0, kRoleChildCount, static_cast(n.children.size())); addNodes(item, n.children, false); // 子层永远非顶层 } } @@ -65,7 +143,10 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { // Qt 原生标准树:复选框/展开箭头由 Fusion 绘制(明暗都清晰);配色取自全局主题 QSS。 tree_ = new QTreeWidget(this); tree_->setHeaderHidden(true); - tree_->setIndentation(14); // 收紧缩进 + tree_->setIndentation(scaledPx(kIndentBase)); // §6.1 每级缩进 16px(随字号缩放) + tree_->setIconSize(QSize(scaledPx(kTypeIconBase), scaledPx(kTypeIconBase))); // 类型图标 14px + // §6.1 自定义行委托:行高 28px + 选中左竖条 + 右计数徽标 + 选中加粗(不破坏原生复选框)。 + tree_->setItemDelegate(new ObjectRowDelegate(tree_)); lay->addWidget(tree_, 1); @@ -81,6 +162,24 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { QObject::connect(tree_, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem* item, int) { if (pressOnCheckbox_) { // 点的是复选框:只切换勾选态,不当作「选中」(不重载数据集列表) pressOnCheckbox_ = false; + // GS 复选框点击:翻转全开/全关(spec §6)。Qt 因 ItemIsUserCheckable 在 itemClicked 发射前 + // 已临时 toggle 了 GS 自身 checkState——故此处**故意不读 item->checkState(0)**(它已是 toggle 后的脏值), + // 而是读 kRoleGsDsOn + 子 TM(二者均未被本次点击改写)重建"点击前"聚合判断方向;末尾 recomputeGsState 覆盖回正确三态。 + if (item->data(0, kRoleConfType).toInt() == kConfTypeGs && !item->data(0, kRoleIsRoot).toBool()) { + const bool dsOn = item->data(0, kRoleGsDsOn).toBool(); + int total = 0, checked = 0; + for (int i = 0; i < item->childCount(); ++i) { + QTreeWidgetItem* c = item->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + const bool turnOn = (aggregateGsState(dsOn, checked, total) == GsCheck::Unchecked); + item->setData(0, kRoleGsDsOn, turnOn); + setAllChildTmChecked(item, turnOn); + recomputeGsState(item); + emitCheckedSources(); + } return; } const bool isRoot = item->data(0, kRoleIsRoot).toBool(); @@ -93,23 +192,15 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { if (isRoot) return; // 项目根:不联动数据列表/异常(仅右键操作 + 属性占位) emit objectClicked(id, confType); }); - // 勾选变化:GS 级联会触发多次 itemChanged,用 0ms 单发合并成一次「收集勾选叶子并发射」。 + // 勾选变化:TM 勾选/GS 级联触发多次 itemChanged,用 0ms 单发合并; + // 合并里先按子 TM 重算每个 GS 三态(手动聚合),再收集勾选源并集发射。 QObject::connect(tree_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { if (checkPending_) return; checkPending_ = true; QTimer::singleShot(0, this, [this]() { checkPending_ = false; - QStringList tmIds; - std::function walk = [&](QTreeWidgetItem* node) { - for (int i = 0; i < node->childCount(); ++i) { - QTreeWidgetItem* c = node->child(i); - if (c->data(0, kRoleConfType).toInt() == 2 && c->checkState(0) == Qt::Checked) - tmIds << c->data(0, kRoleObjId).toString(); - walk(c); - } - }; - walk(tree_->invisibleRootItem()); - emit checkedTmsChanged(tmIds); + recomputeAllGsStates(); // 按子 TM 重算各 GS 三态(内部 SignalBlocker,不再触发 itemChanged) + emitCheckedSources(); }); }); @@ -149,16 +240,37 @@ ObjectTreePanel::ObjectTreePanel(QWidget* parent) : QWidget(parent) { add(QStringLiteral("属性"), QStringLiteral("properties")); add(QStringLiteral("异常详情"), QStringLiteral("exceptionDetail")); menu.addSeparator(); - add(QStringLiteral("编辑"), QStringLiteral("edit")); + // 「编辑」弹窗通道已移除:GS/TM 统一走「属性」面板(右上对象属性)就地编辑,避免双编辑入口。 if (isGs) { + // GS「选择」子菜单:分别开关「自身直挂 ds」与「全部子 TM」(spec §6 三态手动维护)。 + QMenu* sel = menu.addMenu(QStringLiteral("选择")); + int tmCount = 0; + for (int i = 0; i < item->childCount(); ++i) + if (item->child(i)->data(0, kRoleConfType).toInt() == kConfTypeTm) ++tmCount; + QAction* dsAct = sel->addAction(QStringLiteral("ds")); + dsAct->setCheckable(true); + dsAct->setChecked(item->data(0, kRoleGsDsOn).toBool()); + QObject::connect(dsAct, &QAction::triggered, this, [this, item](bool on) { + item->setData(0, kRoleGsDsOn, on); + recomputeGsState(item); + emitCheckedSources(); + }); + QAction* tmAct = sel->addAction(QStringLiteral("tm")); + tmAct->setCheckable(true); + tmAct->setChecked(allTmChecked(item)); + tmAct->setEnabled(tmCount > 0); + QObject::connect(tmAct, &QAction::triggered, this, [this, item](bool on) { + setAllChildTmChecked(item, on); + recomputeGsState(item); + emitCheckedSources(); + }); + menu.addSeparator(); // GS 节点:新建检测对象 / 新建方法对象。(TM 节点上不显示「新建检测对象」——xlsx:tm 上新建GS 无效。) add(QStringLiteral("新建检测对象"), QStringLiteral("newGs")); add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); } if (isTm) { - // TM 节点:仅「新建方法对象」(同级,父=该 TM 的父 GS/根)+ 导入 DS。 - // (xlsx:tm 上新建GS 无效,故不显示「新建检测对象」。) - add(QStringLiteral("新建方法对象"), QStringLiteral("newTm")); + // TM 节点:不提供任何「新建」(测线下不能新增对象)——仅「导入数据集」。 add(QStringLiteral("导入数据集…"), QStringLiteral("importDs")); } menu.addSeparator(); @@ -187,8 +299,96 @@ bool ObjectTreePanel::eventFilter(QObject* watched, QEvent* event) { return QWidget::eventFilter(watched, event); } -// ── 快速筛选:遍历所有 TM 叶子,对其 setCheckState。批量改用 SignalBlocker 屏蔽逐项 itemChanged, -// 末尾手动触发一次 itemChanged 让既有 0ms 合并逻辑收集并发射 checkedTmsChanged。── +// ── GS 三态手动维护(spec §6):停用 AutoTristate 后,GS 复选框状态完全由此派生。── +void ObjectTreePanel::recomputeGsState(QTreeWidgetItem* gs) { + if (!gs || gs->data(0, kRoleConfType).toInt() != kConfTypeGs || gs->data(0, kRoleIsRoot).toBool()) + return; + const bool dsOn = gs->data(0, kRoleGsDsOn).toBool(); + int total = 0, checked = 0; + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + const GsCheck s = aggregateGsState(dsOn, checked, total); + const QSignalBlocker block(tree_); // setCheckState 不再触发 itemChanged 递归 + gs->setCheckState(0, s == GsCheck::Checked ? Qt::Checked + : s == GsCheck::Partial ? Qt::PartiallyChecked + : Qt::Unchecked); +} + +void ObjectTreePanel::recomputeAllGsStates() { + if (!tree_) return; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + if (c->data(0, kRoleConfType).toInt() == kConfTypeGs && !c->data(0, kRoleIsRoot).toBool()) + recomputeGsState(c); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); +} + +void ObjectTreePanel::setAllChildTmChecked(QTreeWidgetItem* gs, bool checked) { + if (!gs) return; + const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked; + const QSignalBlocker block(tree_); + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() == kConfTypeTm) c->setCheckState(0, st); + } +} + +bool ObjectTreePanel::allTmChecked(QTreeWidgetItem* gs) const { + if (!gs) return false; + int total = 0, checked = 0; + for (int i = 0; i < gs->childCount(); ++i) { + QTreeWidgetItem* c = gs->child(i); + if (c->data(0, kRoleConfType).toInt() != kConfTypeTm) continue; + ++total; + if (c->checkState(0) == Qt::Checked) ++checked; + } + return total > 0 && checked == total; +} + +// 收集勾选源并集:勾选的 TM(confType=2) + ds 开的 GS(confType=1) + 项目根直挂 ds(固定纳入), +// 按 {id,confType} 去重,发 checkedSourcesChanged;兼发旧 checkedTmsChanged(Task 12 删)。 +void ObjectTreePanel::emitCheckedSources() { + if (!tree_) return; + std::vector src; + QStringList tmIds; + std::function walk = [&](QTreeWidgetItem* node) { + for (int i = 0; i < node->childCount(); ++i) { + QTreeWidgetItem* c = node->child(i); + const int ct = c->data(0, kRoleConfType).toInt(); + if (ct == kConfTypeTm && c->checkState(0) == Qt::Checked) { + const QString id = c->data(0, kRoleObjId).toString(); + src.push_back({id.toStdString(), kConfTypeTm}); + tmIds << id; + } + if (ct == kConfTypeGs && !c->data(0, kRoleIsRoot).toBool() && c->data(0, kRoleGsDsOn).toBool()) + src.push_back({c->data(0, kRoleObjId).toString().toStdString(), kConfTypeGs}); + walk(c); + } + }; + walk(tree_->invisibleRootItem()); + // 项目根直挂 ds 固定纳入(spec §6:项目根无复选框、直挂 ds 固定显示)。 + if (tree_->topLevelItemCount() > 0) { + QTreeWidgetItem* root = tree_->topLevelItem(0); + if (root->data(0, kRoleIsRoot).toBool()) + src.push_back({root->data(0, kRoleObjId).toString().toStdString(), kConfTypeGs}); + } + const auto deduped = dedupeSources(std::move(src)); + QList list; + for (const auto& s : deduped) list.push_back(s); + emit checkedSourcesChanged(list); + emit checkedTmsChanged(tmIds); +} + +// ── 快速筛选:遍历所有 TM 叶子 setCheckState(SignalBlocker 屏蔽逐项 itemChanged), +// 末尾直接 recomputeAllGsStates + emitCheckedSources 同步 GS 三态并发射勾选源。── void ObjectTreePanel::setAllTmsChecked(bool checked) { if (!tree_) return; const Qt::CheckState st = checked ? Qt::Checked : Qt::Unchecked; @@ -203,7 +403,8 @@ void ObjectTreePanel::setAllTmsChecked(bool checked) { const QSignalBlocker block(tree_); walk(tree_->invisibleRootItem()); } - emit tree_->itemChanged(nullptr, 0); // 触发既有合并发射 + recomputeAllGsStates(); // 子 TM 批量变更后同步各 GS 三态 + emitCheckedSources(); } void ObjectTreePanel::invertTmChecks() { @@ -220,7 +421,8 @@ void ObjectTreePanel::invertTmChecks() { const QSignalBlocker block(tree_); walk(tree_->invisibleRootItem()); } - emit tree_->itemChanged(nullptr, 0); + recomputeAllGsStates(); + emitCheckedSources(); } QString ObjectTreePanel::currentParentForNew() const { diff --git a/src/app/panels/ObjectTreePanel.hpp b/src/app/panels/ObjectTreePanel.hpp index e94ea59..6b49421 100644 --- a/src/app/panels/ObjectTreePanel.hpp +++ b/src/app/panels/ObjectTreePanel.hpp @@ -1,10 +1,12 @@ #pragma once +#include #include #include #include #include "repo/RepoTypes.hpp" class QTreeWidget; +class QTreeWidgetItem; class QLabel; namespace geopro::app { @@ -46,13 +48,23 @@ signals: // 单击行(含项目根,带 typeId/name/isRoot):驱动对象属性面板的可编辑表单。 void objectSelectedForEdit(const QString& objectId, int confType, const QString& typeId, const QString& name, bool isRoot); - // 当前全部被勾选的 TM 叶子 id(已合并发射)。 + // 当前全部被勾选的 TM 叶子 id(已合并发射)。【旧信号,Task 12 接线切换后删除】 void checkedTmsChanged(const QStringList& tmObjectIds); + // 当前全部被勾选的数据源并集(TM + GS 自身 ds 开 + 项目根直挂 ds),按 {id,confType} 去重(spec §6)。 + void checkedSourcesChanged(const QList& sources); // 右键菜单动作(action 取值见 .cpp;objectId/confType/typeId 为右键命中项,name 用于确认框/标题)。 void contextActionRequested(const QString& action, const QString& objectId, int confType, const QString& typeId, const QString& name); private: + // GS 三态(停用 Qt::ItemIsAutoTristate,手动维护):据自身 ds 开关 + 子 TM 勾选聚合(spec §6)。 + void recomputeGsState(QTreeWidgetItem* gs); + void recomputeAllGsStates(); // 遍历全树对每个非根 GS 调 recomputeGsState + void setAllChildTmChecked(QTreeWidgetItem* gs, bool checked); // 批量设子 TM(内部 SignalBlocker) + bool allTmChecked(QTreeWidgetItem* gs) const; // GS 下子 TM 是否全勾(无子 TM=false) + // 收集勾选源并集(TM + GS 自身 ds + 项目根直挂 ds),去重后发 checkedSourcesChanged(兼发旧 checkedTmsChanged)。 + void emitCheckedSources(); + QTreeWidget* tree_ = nullptr; // Qt 原生标准树(复选框/箭头由 Fusion 绘制,清晰可控) QLabel* hint_ = nullptr; bool checkPending_ = false; // 勾选合并发射防重入 diff --git a/src/app/panels/ObjectTreeSelection.hpp b/src/app/panels/ObjectTreeSelection.hpp new file mode 100644 index 0000000..36a26fc --- /dev/null +++ b/src/app/panels/ObjectTreeSelection.hpp @@ -0,0 +1,34 @@ +#pragma once +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::app { + +enum class GsCheck { Unchecked, Partial, Checked }; + +// GS 复选框三态(spec §6): +// Unchecked = 自身 ds 关 且 无子 TM 勾选; +// Checked = 自身 ds 开 且 全部子 TM 勾选(注意是 AND,不是"父子都打钩即满"); +// Partial = 其余(只 ds 开 / 只部分 TM / ds 开但 TM 未满 等)。 +// 无子 TM(totalTmCount==0)时退化为仅看 dsOn(ds 开=Checked,关=Unchecked)。 +inline GsCheck aggregateGsState(bool dsOn, int checkedTmCount, int totalTmCount) { + const bool anyOn = dsOn || checkedTmCount > 0; + if (!anyOn) return GsCheck::Unchecked; + const bool tmAll = (totalTmCount == 0) || (checkedTmCount == totalTmCount); + if (dsOn && tmAll) return GsCheck::Checked; + return GsCheck::Partial; +} + +// 对象树勾选产出的数据源并集按 {id,confType} 去重保序(spec §6)。 +inline std::vector dedupeSources(std::vector in) { + std::vector out; + for (const auto& s : in) { + bool dup = false; + for (const auto& o : out) + if (o.id == s.id && o.confType == s.confType) { dup = true; break; } + if (!dup) out.push_back(s); + } + return out; +} + +} // namespace geopro::app diff --git a/src/app/panels/QuillDelta.cpp b/src/app/panels/QuillDelta.cpp new file mode 100644 index 0000000..2130aea --- /dev/null +++ b/src/app/panels/QuillDelta.cpp @@ -0,0 +1,190 @@ +#include "panels/QuillDelta.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::app { +namespace { + +// ── 序列化方向(QTextDocument → Delta)───────────────────────────────────── + +// 颜色转 Quill 习惯的小写 #rrggbb(与原版 ql-color/ql-background 选项一致)。 +QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); } + +// 字体族:原版 ql-font 的 token ↔ Qt QFont family 名。 +// token(whitelist 值):Microsoft-YaHei / SimSun / SimHei / KaiTi / FangSong / FangSong_GB2312。 +// Delta 写出用 token;反序列化时把 token 转成 Qt 可识别的 family(连字符→空格等)。 +QString fontTokenToFamily(const QString& token) { + if (token == QStringLiteral("Microsoft-YaHei")) return QStringLiteral("Microsoft YaHei"); + if (token == QStringLiteral("SimSun")) return QStringLiteral("SimSun"); + if (token == QStringLiteral("SimHei")) return QStringLiteral("SimHei"); + if (token == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi"); + if (token == QStringLiteral("FangSong")) return QStringLiteral("FangSong"); + if (token == QStringLiteral("FangSong_GB2312")) return QStringLiteral("FangSong"); + return token; // Arial / sans-serif 等原样 +} + +QString familyToFontToken(const QString& family) { + if (family == QStringLiteral("Microsoft YaHei")) return QStringLiteral("Microsoft-YaHei"); + if (family == QStringLiteral("SimSun")) return QStringLiteral("SimSun"); + if (family == QStringLiteral("SimHei")) return QStringLiteral("SimHei"); + if (family == QStringLiteral("KaiTi")) return QStringLiteral("KaiTi"); + if (family == QStringLiteral("FangSong")) return QStringLiteral("FangSong"); + return family; +} + +// 行内样式 → attributes 对象(bold/italic/underline/color/background/size)。 +QJsonObject inlineAttrs(const QTextCharFormat& fmt) { + QJsonObject a; + if (fmt.fontWeight() >= QFont::Bold) a[QStringLiteral("bold")] = true; + if (fmt.fontItalic()) a[QStringLiteral("italic")] = true; + if (fmt.fontUnderline()) a[QStringLiteral("underline")] = true; + if (fmt.foreground().style() != Qt::NoBrush) + a[QStringLiteral("color")] = hexOf(fmt.foreground().color()); + if (fmt.background().style() != Qt::NoBrush) + a[QStringLiteral("background")] = hexOf(fmt.background().color()); + const double pt = fmt.fontPointSize(); + if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx";pt→px 约 *4/3 取整)。 + a[QStringLiteral("size")] = QStringLiteral("%1px").arg(qRound(pt * 4.0 / 3.0)); + const QStringList fams = fmt.fontFamilies().toStringList(); + if (!fams.isEmpty() && !fams.first().isEmpty()) // 字体族(原版 ql-font token)。 + a[QStringLiteral("font")] = familyToFontToken(fams.first()); + return a; +} + +// 块级样式 → attributes 对象(header/list/align),挂在换行 op 上。 +QJsonObject blockAttrs(const QTextBlock& block) { + QJsonObject a; + const int headingLevel = block.blockFormat().headingLevel(); + if (headingLevel >= 1 && headingLevel <= 4) a[QStringLiteral("header")] = headingLevel; + if (QTextList* lst = block.textList()) { + const QTextListFormat::Style s = lst->format().style(); + a[QStringLiteral("list")] = (s == QTextListFormat::ListDecimal) + ? QStringLiteral("ordered") + : QStringLiteral("bullet"); + } + switch (block.blockFormat().alignment() & Qt::AlignHorizontal_Mask) { + case Qt::AlignHCenter: a[QStringLiteral("align")] = QStringLiteral("center"); break; + case Qt::AlignRight: a[QStringLiteral("align")] = QStringLiteral("right"); break; + case Qt::AlignJustify: a[QStringLiteral("align")] = QStringLiteral("justify"); break; + default: break; // 左对齐为默认,不写 attribute。 + } + return a; +} + +// 追加一个文本 op(带可选 attributes)。 +void pushInsert(QJsonArray& ops, const QString& text, const QJsonObject& attrs) { + if (text.isEmpty()) return; + QJsonObject op{{QStringLiteral("insert"), text}}; + if (!attrs.isEmpty()) op[QStringLiteral("attributes")] = attrs; + ops.append(op); +} + +// 追加一个「换行」op(带可选块级 attributes)。Quill 中块级样式作用于其前整行。 +void pushNewline(QJsonArray& ops, const QJsonObject& attrs) { + pushInsert(ops, QStringLiteral("\n"), attrs); +} + +// 序列化单个文本块的所有 fragment(按行内样式分段)。 +void serializeBlock(const QTextBlock& block, QJsonArray& ops) { + for (auto it = block.begin(); it != block.end(); ++it) { + const QTextFragment frag = it.fragment(); + if (frag.isValid()) pushInsert(ops, frag.text(), inlineAttrs(frag.charFormat())); + } +} + +// ── 反序列化方向(Delta → QTextDocument)────────────────────────────────── + +// 把 inline attributes 应用到字符格式。 +void applyInlineAttrs(const QJsonObject& attrs, QTextCharFormat& fmt) { + if (attrs.value(QStringLiteral("bold")).toBool()) fmt.setFontWeight(QFont::Bold); + if (attrs.value(QStringLiteral("italic")).toBool()) fmt.setFontItalic(true); + if (attrs.value(QStringLiteral("underline")).toBool()) fmt.setFontUnderline(true); + const QString color = attrs.value(QStringLiteral("color")).toString(); + if (QColor(color).isValid()) fmt.setForeground(QColor(color)); + const QString bg = attrs.value(QStringLiteral("background")).toString(); + if (QColor(bg).isValid()) fmt.setBackground(QColor(bg)); + QString size = attrs.value(QStringLiteral("size")).toString(); + if (size.endsWith(QStringLiteral("px"))) { + const double px = size.chopped(2).toDouble(); + if (px > 0.0) fmt.setFontPointSize(px * 3.0 / 4.0); // px→pt。 + } + const QString font = attrs.value(QStringLiteral("font")).toString(); + if (!font.isEmpty()) fmt.setFontFamilies({fontTokenToFamily(font)}); +} + +// 把 block attributes 应用到块格式 / 列表(作用于换行前的当前块)。 +void applyBlockAttrs(const QJsonObject& attrs, QTextCursor& cur) { + QTextBlockFormat bf = cur.blockFormat(); + const int header = attrs.value(QStringLiteral("header")).toInt(); + if (header >= 1 && header <= 4) bf.setHeadingLevel(header); + const QString align = attrs.value(QStringLiteral("align")).toString(); + if (align == QStringLiteral("center")) bf.setAlignment(Qt::AlignHCenter); + else if (align == QStringLiteral("right")) bf.setAlignment(Qt::AlignRight); + else if (align == QStringLiteral("justify")) bf.setAlignment(Qt::AlignJustify); + cur.setBlockFormat(bf); + const QString list = attrs.value(QStringLiteral("list")).toString(); + if (list == QStringLiteral("ordered")) cur.createList(QTextListFormat::ListDecimal); + else if (list == QStringLiteral("bullet")) cur.createList(QTextListFormat::ListDisc); +} + +// 写入一段不含换行的文本(带行内样式)。 +void insertSegment(QTextCursor& cur, const QString& text, const QJsonObject& attrs) { + QTextCharFormat fmt; + applyInlineAttrs(attrs, fmt); + cur.insertText(text, fmt); +} + +// 处理单个 insert op:按换行拆段。每个换行:先对当前块应用块级样式(header/list/align), +// 再开新块承接后续内容。Delta 末尾必有一个收尾换行,会多造一个尾部空块,由 +// deltaToDocument 末尾统一裁掉(与 QTextDocument 起始即含一个空块的语义对齐)。 +void applyOp(const QString& insert, const QJsonObject& attrs, QTextCursor& cur) { + const QStringList lines = insert.split(QLatin1Char('\n')); + for (int i = 0; i < lines.size(); ++i) { + insertSegment(cur, lines.at(i), attrs); + if (i + 1 < lines.size()) { // 该位置原本是一个换行符。 + applyBlockAttrs(attrs, cur); // 块级样式作用于换行之前的整行。 + cur.insertBlock(); + } + } +} + +// 裁掉文档末尾因 Delta 收尾换行而多出的空块(仅当其确为空且非唯一块)。 +void trimTrailingEmptyBlock(QTextDocument& doc) { + if (doc.blockCount() <= 1) return; + const QTextBlock last = doc.lastBlock(); + if (!last.text().isEmpty()) return; + QTextCursor cur(&doc); + cur.movePosition(QTextCursor::End); + cur.deletePreviousChar(); // 删除上一个块结尾的换行,合并末尾空块。 +} + +} // namespace + +QJsonArray documentToDelta(const QTextDocument& doc) { + QJsonArray ops; + for (QTextBlock block = doc.begin(); block.isValid(); block = block.next()) { + serializeBlock(block, ops); + // 每个块以换行 op 收尾,携带该块的块级样式(最后一个块也写,对应 Quill 末尾换行)。 + pushNewline(ops, blockAttrs(block)); + } + return ops; +} + +void deltaToDocument(const QJsonArray& ops, QTextDocument& doc) { + doc.clear(); + QTextCursor cur(&doc); + for (const QJsonValue& v : ops) { + const QJsonObject op = v.toObject(); + const QJsonValue insert = op.value(QStringLiteral("insert")); + if (!insert.isString()) continue; // 仅支持文本 insert(图片/嵌入等降级丢弃)。 + applyOp(insert.toString(), op.value(QStringLiteral("attributes")).toObject(), cur); + } + trimTrailingEmptyBlock(doc); +} + +} // namespace geopro::app diff --git a/src/app/panels/QuillDelta.hpp b/src/app/panels/QuillDelta.hpp new file mode 100644 index 0000000..fd9c619 --- /dev/null +++ b/src/app/panels/QuillDelta.hpp @@ -0,0 +1,32 @@ +#pragma once +#include + +class QTextDocument; + +namespace geopro::app { + +// Quill Delta ↔ QTextDocument 互转(纯函数,仅依赖 Qt Core/Gui,无 Widgets/MOC)。 +// +// 背景:原版 web(contourPage.vue)描述用 Quill 富文本,保存 +// attachedParameters.deltaContent = quill.getContents().ops(Quill Delta ops 数组), +// description = quill.getText()(纯文本)。读取时 quill.setContents(deltaContent)。 +// 客户端无 Quill,这里用 QTextDocument 承载富文本,并在两种表示间转换以与原版互通。 +// +// 边界(无法做到字节级 1:1,目标是「常见格式往返可用」): +// - 支持的 inline attributes:bold / italic / underline / color / background / size("NNpx")。 +// - 支持的 block attributes(挂在换行 op 上):header(1-4)/ list("ordered"|"bullet")/ align。 +// - 不支持的 attributes 容错降级:保留 insert 文本,丢弃无法表达的样式,不崩。 +// - 拆出独立 TU 便于 gtest(往返断言常见格式)。 +// +// Quill Delta 结构:ops 为数组,每个 op = { "insert": "文本", "attributes": {...} }。 +// 行内样式挂在文本 op 上;块级样式(标题/列表/对齐)挂在「单个换行」op 的 attributes 上, +// 作用于该换行之前的整行。文档以隐含的「最后一个换行」结尾。 + +// 把 QTextDocument 序列化为 Quill Delta ops 数组(与原版 quill.getContents().ops 兼容)。 +QJsonArray documentToDelta(const QTextDocument& doc); + +// 把 Quill Delta ops 数组反序列化进 QTextDocument(与原版 quill.setContents(ops) 对应)。 +// 先清空 doc 再写入;无法识别的 attributes 跳过。 +void deltaToDocument(const QJsonArray& ops, QTextDocument& doc); + +} // namespace geopro::app diff --git a/src/app/panels/chart/AutoAnnotationDialog.cpp b/src/app/panels/chart/AutoAnnotationDialog.cpp new file mode 100644 index 0000000..f9e79fb --- /dev/null +++ b/src/app/panels/chart/AutoAnnotationDialog.cpp @@ -0,0 +1,503 @@ +#include "panels/chart/AutoAnnotationDialog.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "FormKit.hpp" +#include "Theme.hpp" // scaledPx +#include "dto/DatasetChartDto.hpp" // parseDatasetAnomalies(JSON→Anomaly) +#include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourPlotItem.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr int kDefaultMinPoints = 4; // 原版 minPointCount 默认 4 +// 面异常类型固定 remarkSourceType="3"(原版自动标注仅支持面/polygon)。 +const QString kPolygonType = QStringLiteral("3"); + +// 从网格标量算 max/min/mean/median(过滤 NaN)。空 → 全 '-'。 +struct Stats { bool valid = false; double mx = 0, mn = 0, mean = 0, median = 0; }; +Stats computeStats(const std::vector& raw) { + std::vector v; + v.reserve(raw.size()); + for (double d : raw) + if (!std::isnan(d)) v.push_back(d); + if (v.empty()) return {}; + std::sort(v.begin(), v.end()); + Stats s; + s.valid = true; + s.mn = v.front(); + s.mx = v.back(); + double sum = 0; + for (double d : v) sum += d; + s.mean = sum / static_cast(v.size()); + const size_t m = v.size() / 2; + s.median = (v.size() % 2 == 0) ? (v[m - 1] + v[m]) / 2.0 : v[m]; + return s; +} + +QString fmt2(bool valid, double x) { + return valid ? QString::number(x, 'f', 2) : QStringLiteral("-"); +} +} // namespace + +AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, + QString dsObjectId, QString projectId, + const geopro::core::Grid& grid, + const geopro::core::ColorScale& scale, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsObjectId_(std::move(dsObjectId)), + projectId_(std::move(projectId)), + grid_(grid), + scale_(scale) { + setWindowTitle(QStringLiteral("自动标注")); + setModal(true); + resize(geopro::app::scaledPx(1400), geopro::app::scaledPx(600)); + + auto* root = formkit::dialogRoot(this); + auto* split = new QHBoxLayout(); + + // ── 左:规则卡片列表(35%,对照原版左栏)──────────────────────────────── + auto* leftCol = new QVBoxLayout(); + leftCol->addWidget(new QLabel(QStringLiteral("异常判定规则"), this)); + auto* ruleContainer = new QWidget(this); + ruleHost_ = new QVBoxLayout(ruleContainer); + ruleHost_->setContentsMargins(0, 0, 0, 0); + leftCol->addWidget(ruleContainer); + auto* addBtn = new QPushButton(QStringLiteral("添加规则"), this); + connect(addBtn, &QPushButton::clicked, this, [this]() { addRule(); }); + leftCol->addWidget(addBtn); + leftCol->addStretch(); + auto* leftWrap = new QWidget(this); + leftWrap->setLayout(leftCol); + split->addWidget(leftWrap, 35); + + // ── 右:上(统计条 + 预览图) + 下预览表 ────────────────────────────────── + // 对照原版右上 :等值面预览图为主、数据统计在上。 + auto* rightCol = new QVBoxLayout(); + buildStatsBar(rightCol); + buildPreviewPlot(rightCol); + + detectedLabel_ = new QLabel(QStringLiteral("自动标注结果"), this); + rightCol->addWidget(detectedLabel_); + previewTable_ = new QTableWidget(0, 6, this); + previewTable_->setHorizontalHeaderLabels({QStringLiteral("序号"), QStringLiteral("异常名称"), + QStringLiteral("异常类型"), QStringLiteral("阈值范围"), + QStringLiteral("阈值模式"), QStringLiteral("操作")}); + previewTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + previewTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + rightCol->addWidget(previewTable_, 1); + auto* rightWrap = new QWidget(this); + rightWrap->setLayout(rightCol); + split->addWidget(rightWrap, 65); + + root->addLayout(split, 1); + + // ── 底部按钮:取消 / 执行自动标注 / 确认保存 ───────────────────────────── + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + auto* execBtn = new QPushButton(QStringLiteral("执行自动标注"), this); + saveBtn_ = new QPushButton(QStringLiteral("确认保存"), this); + saveBtn_->setDefault(true); // 区域唯一主操作(规范 §6.7 primary);执行/取消为次按钮 + saveBtn_->setEnabled(false); // 必须先执行得到预览才能保存 + btnLay->addWidget(cancelBtn); + btnLay->addWidget(execBtn); + btnLay->addWidget(saveBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(execBtn, &QPushButton::clicked, this, &AutoAnnotationDialog::onExecute); + connect(saveBtn_, &QPushButton::clicked, this, &AutoAnnotationDialog::onSave); + + addRule(); // 默认一条规则 + loadExceptionTypes(); // 拉面异常类型 +} + +AutoAnnotationDialog::~AutoAnnotationDialog() { + // previewItem_ 由 QwtPlot autoDelete=true 处理,但析构顺序不确定:先 detach 再交还。 + if (previewItem_) { + previewItem_->detach(); + delete previewItem_; + previewItem_ = nullptr; + } + // colorSvc_ 为 unique_ptr,自动释放。 +} + +void AutoAnnotationDialog::buildStatsBar(QVBoxLayout* into) { + // 数据统计(max/min/mean/median,从网格标量算)。 + const Stats s = computeStats(grid_.values()); + auto* bar = new QFrame(this); + bar->setFrameShape(QFrame::StyledPanel); + auto* lay = new QHBoxLayout(bar); + auto addStat = [&](const QString& name, double v) { + lay->addWidget(new QLabel(QStringLiteral("%1:%2").arg(name, fmt2(s.valid, v)), bar)); + }; + addStat(QStringLiteral("最大值"), s.mx); + addStat(QStringLiteral("最小值"), s.mn); + addStat(QStringLiteral("均值"), s.mean); + addStat(QStringLiteral("中位数"), s.median); + lay->addStretch(); + into->addWidget(bar); +} + +void AutoAnnotationDialog::buildPreviewPlot(QVBoxLayout* into) { + // 复刻原版 :用 GridDataChartView 同款 ContourPlotItem 渲染当前网格等值面, + // 预览图为主区域;执行/删除时把预演异常实时叠加(refreshPreviewAnomalies)。 + previewPlot_ = new QwtPlot(this); + previewPlot_->setObjectName(QStringLiteral("autoAnnotPreview")); + previewPlot_->enableAxis(QwtPlot::xBottom, true); + previewPlot_->enableAxis(QwtPlot::xTop, false); + previewPlot_->enableAxis(QwtPlot::yLeft, true); + previewPlot_->setMinimumHeight(geopro::app::scaledPx(220)); + + // 网格 < 2×2 视为无可渲染数据:仅占位,不建等值面项。 + if (grid_.nx() < 2 || grid_.ny() < 2) { + into->addWidget(previewPlot_, 1); + return; + } + + colorSvc_ = std::make_unique(scale_); + previewItem_ = new ContourPlotItem(); + // 轻量预览:渲染等值面 + 等值线,关标注(小图标注过密);初始无异常。 + previewItem_->setData(grid_, colorSvc_.get(), {}, /*showLines=*/true, /*showLabels=*/false); + previewItem_->setShowAnomalies(true); + previewItem_->attach(previewPlot_); + + // 轴范围 = 数据范围(y 深度向下:上沿 ymax、下沿 ymin,与 GridDataChartView 一致)。 + const QRectF bbox = previewItem_->boundingRect(); + if (!bbox.isNull()) { + previewPlot_->setAxisScale(QwtPlot::xBottom, bbox.left(), bbox.right()); + previewPlot_->setAxisScale(QwtPlot::yLeft, bbox.top(), bbox.bottom()); + } + previewPlot_->replot(); + into->addWidget(previewPlot_, 1); +} + +void AutoAnnotationDialog::refreshPreviewAnomalies() { + // 把当前 previewExceptions_(execute 返回 / 删除后剩余)映射成 Anomaly 叠加到预览图。 + // 复用 dto::parseDatasetAnomalies(与正式异常同一 JSON 形态:location.coordinate + legend)。 + if (!previewItem_ || !colorSvc_) return; + const auto anoms = geopro::data::dto::parseDatasetAnomalies(previewExceptions_); + previewItem_->setData(grid_, colorSvc_.get(), anoms, /*showLines=*/true, /*showLabels=*/false); + previewItem_->setShowAnomalies(true); + if (previewPlot_) previewPlot_->replot(); +} + +void AutoAnnotationDialog::loadExceptionTypes() { + if (!repo_) return; + QPointer self(this); + repo_->listExceptionTypes(projectId_, kPolygonType, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->exceptionTypeOptions_ = {}; + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + QString name = o.value(QStringLiteral("exceptionTypeName")).toString(); + if (name.isEmpty()) name = o.value(QStringLiteral("label")).toString(); + QString id = o.value(QStringLiteral("id")).toString(); + if (id.isEmpty()) id = o.value(QStringLiteral("value")).toString(); + if (!id.isEmpty()) + self->exceptionTypeOptions_.append( + QJsonObject{{QStringLiteral("id"), id}, {QStringLiteral("name"), name}}); + } + // 回填已存在规则卡片下拉。 + for (auto& r : self->rules_) { + r.type->clear(); + for (const QJsonValue& ov : self->exceptionTypeOptions_) { + const QJsonObject o = ov.toObject(); + r.type->addItem(o.value(QStringLiteral("name")).toString(), + o.value(QStringLiteral("id")).toString()); + } + } + }); +} + +void AutoAnnotationDialog::addRule() { + auto* card = new QFrame(this); + card->setFrameShape(QFrame::StyledPanel); + auto* cardLay = new QVBoxLayout(card); + cardLay->setContentsMargins(6, 6, 6, 6); + + RuleCard rc; + rc.frame = card; + + // 卡片头:折叠 + 「规则N」 + 删除。 + auto* header = new QHBoxLayout(); + auto* collapseBtn = new QToolButton(card); + collapseBtn->setText(QStringLiteral("▼")); + collapseBtn->setCheckable(true); + rc.title = new QLabel(card); + auto* delBtn = new QToolButton(card); + delBtn->setText(QStringLiteral("删除")); + header->addWidget(collapseBtn); + header->addWidget(rc.title, 1); + header->addWidget(delBtn); + cardLay->addLayout(header); + + // 卡片主体(可折叠)。 + rc.body = new QWidget(card); + auto* form = formkit::makeEditForm(); + + // 阈值模式:radio-button 组(数值/百分位)。 + auto* modeRow = new QWidget(rc.body); + auto* modeLay = new QHBoxLayout(modeRow); + modeLay->setContentsMargins(0, 0, 0, 0); + auto* rbNum = new QRadioButton(QStringLiteral("数值"), modeRow); + auto* rbPct = new QRadioButton(QStringLiteral("百分位"), modeRow); + rbNum->setChecked(true); + rc.modeGroup = new QButtonGroup(rc.body); + rc.modeGroup->addButton(rbNum, 1); + rc.modeGroup->addButton(rbPct, 2); + modeLay->addWidget(rbNum); + modeLay->addWidget(rbPct); + modeLay->addStretch(); + form->addRow(formkit::editLabel(QStringLiteral("阈值模式")), modeRow); + + // 阈值范围:min - max。 + auto* rangeRow = new QWidget(rc.body); + auto* rangeLay = new QHBoxLayout(rangeRow); + rangeLay->setContentsMargins(0, 0, 0, 0); + rc.min = new QLineEdit(rangeRow); + rc.min->setPlaceholderText(QStringLiteral("最小")); + rc.max = new QLineEdit(rangeRow); + rc.max->setPlaceholderText(QStringLiteral("最大")); + rangeLay->addWidget(rc.min); + rangeLay->addWidget(new QLabel(QStringLiteral("-"), rangeRow)); + rangeLay->addWidget(rc.max); + form->addRow(formkit::editLabel(QStringLiteral("阈值范围")), rangeRow); + + // 切模式清空 min/max(对照原版 handleThresholdModeChange)。 + auto clearRange = [min = rc.min, max = rc.max]() { min->clear(); max->clear(); }; + connect(rbNum, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); }); + connect(rbPct, &QRadioButton::toggled, rc.body, [clearRange](bool on) { if (on) clearRange(); }); + + rc.minPoints = new QSpinBox(rc.body); + rc.minPoints->setRange(1, 100000); + rc.minPoints->setValue(kDefaultMinPoints); + form->addRow(formkit::editLabel(QStringLiteral("最小点数")), rc.minPoints); + + // 空态感知下拉:异常类型异步加载(loadExceptionTypes),未选显占位、无数据弹「暂无数据」。 + rc.type = formkit::comboBox(QStringLiteral("请选择异常类型"), rc.body); + for (const QJsonValue& ov : exceptionTypeOptions_) { + const QJsonObject o = ov.toObject(); + rc.type->addItem(o.value(QStringLiteral("name")).toString(), + o.value(QStringLiteral("id")).toString()); + } + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), rc.type); + + rc.body->setLayout(form); + cardLay->addWidget(rc.body); + + // 折叠/展开。 + connect(collapseBtn, &QToolButton::toggled, rc.body, [rc, collapseBtn](bool collapsed) { + rc.body->setVisible(!collapsed); + collapseBtn->setText(collapsed ? QStringLiteral("▶") : QStringLiteral("▼")); + }); + // 删除(至少保留一条)。 + connect(delBtn, &QToolButton::clicked, this, [this, card]() { removeRule(card); }); + + rules_.push_back(rc); + ruleHost_->addWidget(card); + renumberRules(); +} + +void AutoAnnotationDialog::removeRule(QWidget* frame) { + if (rules_.size() <= 1) { // 对照原版 atLeastOneRule。 + QMessageBox::warning(this, windowTitle(), QStringLiteral("至少保留一条规则")); + return; + } + auto it = std::find_if(rules_.begin(), rules_.end(), + [frame](const RuleCard& c) { return c.frame == frame; }); + if (it == rules_.end()) return; + ruleHost_->removeWidget(frame); + frame->deleteLater(); + rules_.erase(it); + renumberRules(); +} + +void AutoAnnotationDialog::renumberRules() { + for (int i = 0; i < static_cast(rules_.size()); ++i) + rules_[i].title->setText(QStringLiteral("规则 %1").arg(i + 1)); +} + +int AutoAnnotationDialog::currentMode(const RuleCard& c) const { + const int id = c.modeGroup->checkedId(); + return id > 0 ? id : 1; // 默认数值 +} + +QJsonArray AutoAnnotationDialog::buildRuleList() const { + QJsonArray arr; + for (const auto& r : rules_) { + QJsonObject rule{ + {QStringLiteral("exceptionTypeId"), r.type->currentData().toString()}, + {QStringLiteral("thresholdMode"), currentMode(r)}, + {QStringLiteral("minPointCount"), r.minPoints->value()}, + }; + // min/max:空 → null(对照原版 Number(...) 或 null)。 + const QString mn = r.min->text().trimmed(); + const QString mx = r.max->text().trimmed(); + rule.insert(QStringLiteral("thresholdMin"), + mn.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mn.toDouble())); + rule.insert(QStringLiteral("thresholdMax"), + mx.isEmpty() ? QJsonValue(QJsonValue::Null) : QJsonValue(mx.toDouble())); + arr.append(rule); + } + return arr; +} + +void AutoAnnotationDialog::onExecute() { + if (!repo_) return; + // 校验:每条规则 min/max 至少填一个、异常类型必选(对照原版 handleExecute)。 + for (const auto& r : rules_) { + if (r.min->text().trimmed().isEmpty() && r.max->text().trimmed().isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("阈值范围至少填写一项")); + return; + } + if (r.type->currentData().toString().isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); + return; + } + } + const QJsonArray rules = buildRuleList(); + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsObjectId_}, + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("exceptionMarkRuleList"), rules}, + }; + QPointer self(this); + repo_->executeExceptionMark(body, [self](bool ok, QJsonObject data, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("自动标注执行失败") : msg); + return; + } + // 预览异常:兼容 data 直接为数组(wireObject 包成 value) 或 data.list。 + QJsonArray list = data.value(QStringLiteral("value")).toArray(); + if (list.isEmpty()) list = data.value(QStringLiteral("list")).toArray(); + self->previewExceptions_ = list; + self->detectedLabel_->setText( + QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(list.size())); + self->previewTable_->setRowCount(list.size()); + for (int i = 0; i < list.size(); ++i) { + const QJsonObject o = list[i].toObject(); + self->previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1))); + self->previewTable_->setItem( + i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); + self->previewTable_->setItem( + i, 2, + new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); + self->previewTable_->setItem( + i, 3, new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); + self->previewTable_->setItem( + i, 4, + new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); + // 操作列:逐条删除(对照原版预览表 删除)。 + auto* delBtn = new QPushButton(QStringLiteral("删除"), self->previewTable_); + QPointer weak(self); + QObject::connect(delBtn, &QPushButton::clicked, self, [weak, i]() { + if (weak) weak->deletePreviewRow(i); + }); + self->previewTable_->setCellWidget(i, 5, delBtn); + } + self->saveBtn_->setEnabled(!list.isEmpty()); + self->refreshPreviewAnomalies(); // 实时叠加预演异常到预览图 + if (list.isEmpty()) + QMessageBox::information(self, self->windowTitle(), QStringLiteral("暂未识别到异常")); + }); +} + +void AutoAnnotationDialog::deletePreviewRow(int row) { + if (row < 0 || row >= previewExceptions_.size()) return; + const QString name = previewExceptions_[row].toObject().value(QStringLiteral("exceptionName")).toString(); + if (QMessageBox::question(this, QStringLiteral("确认删除"), + QStringLiteral("%1,确认删除?").arg(name)) != QMessageBox::Yes) + return; + previewExceptions_.removeAt(row); + // 重建预览表(重排序号 + 重绑删除)。复用 onExecute 的填表分支会重发请求,故就地重建。 + previewTable_->setRowCount(previewExceptions_.size()); + for (int i = 0; i < previewExceptions_.size(); ++i) { + const QJsonObject o = previewExceptions_[i].toObject(); + previewTable_->setItem(i, 0, new QTableWidgetItem(QString::number(i + 1))); + previewTable_->setItem( + i, 1, new QTableWidgetItem(o.value(QStringLiteral("exceptionName")).toString())); + previewTable_->setItem( + i, 2, new QTableWidgetItem(o.value(QStringLiteral("exceptionTypeName")).toString())); + previewTable_->setItem(i, 3, + new QTableWidgetItem(o.value(QStringLiteral("remark")).toString())); + previewTable_->setItem( + i, 4, new QTableWidgetItem(o.value(QStringLiteral("thresholdModeName")).toString())); + auto* delBtn = new QPushButton(QStringLiteral("删除"), previewTable_); + QPointer weak(this); + connect(delBtn, &QPushButton::clicked, this, [weak, i]() { + if (weak) weak->deletePreviewRow(i); + }); + previewTable_->setCellWidget(i, 5, delBtn); + } + detectedLabel_->setText( + QStringLiteral("自动标注结果(共识别到 %1 个异常)").arg(previewExceptions_.size())); + saveBtn_->setEnabled(!previewExceptions_.isEmpty()); + refreshPreviewAnomalies(); // 同步从预览图移除该异常 +} + +void AutoAnnotationDialog::onSave() { + if (!repo_ || previewExceptions_.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("暂无可保存的异常,请先执行自动标注")); + return; + } + // 组装 exceptionList:保留 execute 返回项的关键字段(对照原版 batchCreateException)。 + QJsonArray exceptionList; + for (const QJsonValue& v : previewExceptions_) { + const QJsonObject o = v.toObject(); + exceptionList.append(QJsonObject{ + {QStringLiteral("remarkSourceType"), o.value(QStringLiteral("exceptionMarkType"))}, + {QStringLiteral("exceptionName"), o.value(QStringLiteral("exceptionName"))}, + {QStringLiteral("exceptionTypeId"), o.value(QStringLiteral("exceptionTypeId"))}, + {QStringLiteral("location"), o.value(QStringLiteral("location"))}, + {QStringLiteral("remark"), o.value(QStringLiteral("remark"))}, + }); + } + QJsonObject body{ + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("remarkSourceId"), dsObjectId_}, + {QStringLiteral("exceptionList"), exceptionList}, + }; + saveBtn_->setEnabled(false); + QPointer self(this); + repo_->batchCreateException(body, [self](bool ok, QString msg) { + if (!self) return; + self->saveBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/AutoAnnotationDialog.hpp b/src/app/panels/chart/AutoAnnotationDialog.hpp new file mode 100644 index 0000000..60f237b --- /dev/null +++ b/src/app/panels/chart/AutoAnnotationDialog.hpp @@ -0,0 +1,96 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "model/Field.hpp" // core::Grid(预览图等值面) +#include "model/ColorScale.hpp" // core::ColorScale(预览图色阶) + +class QButtonGroup; +class QComboBox; +class QLineEdit; +class QSpinBox; +class QTableWidget; +class QPushButton; +class QToolButton; +class QLabel; +class QVBoxLayout; +class QWidget; +class QwtPlot; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +class ColorMapService; +class ContourPlotItem; + +// 自动标注对话框(I13,复刻原版 AutoAnnotationDialog,1400×600): +// 左:规则卡片列表(标题「规则N」+ 折叠 + 删除;阈值模式 radio-button 数值/百分位、min/max、 +// 最小点数、异常类型)+「添加规则」。 +// 右上:数据统计(最大/最小/均值/中位数,从网格标量算)+ 预览图(后置标注)。 +// 右下:预览表(序号/异常名称/异常类型/阈值范围/阈值模式/操作删除)。 +// 执行 → executeExceptionMark(预演) → 预览表;确认保存 → batchCreateException → reloadGrid。 +// 异常类型仅支持面/polygon(原版同),listExceptionTypes 取 remarkSourceType="3"。 +class AutoAnnotationDialog : public QDialog { + Q_OBJECT +public: + // grid/scale:当前反演网格 + 色阶,用于右上预览图(ContourPlotItem 渲染等值面) + // 及数据统计(max/min/mean/median 从 grid.values() 算)。 + AutoAnnotationDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, + QString projectId, const geopro::core::Grid& grid, + const geopro::core::ColorScale& scale, QWidget* parent = nullptr); + ~AutoAnnotationDialog() override; + +private: + // 一条规则卡片的控件集合(卡片标题/折叠/删除 + 模式 radio + min/max + 最小点数 + 类型)。 + struct RuleCard { + QWidget* frame = nullptr; // 整张卡片(删除时移除) + QWidget* body = nullptr; // 折叠隐藏的主体 + QLabel* title = nullptr; // 「规则N」 + QButtonGroup* modeGroup = nullptr; // 1 数值 / 2 百分位(radio-button) + QLineEdit* min = nullptr; + QLineEdit* max = nullptr; + QSpinBox* minPoints = nullptr; + QComboBox* type = nullptr; // userData = 异常类型 id + }; + + void buildStatsBar(QVBoxLayout* into); // 右上统计条(max/min/mean/median) + void buildPreviewPlot(QVBoxLayout* into); // 右上预览图(QwtPlot + ContourPlotItem 等值面) + void refreshPreviewAnomalies(); // 把 previewExceptions_ 映射成 Anomaly 叠加到预览图并重绘 + void loadExceptionTypes(); // 拉面异常类型(填充所有规则卡片下拉) + void addRule(); // 加一条规则卡片 + void removeRule(QWidget* frame); // 删除指定卡片(至少保留一条) + void renumberRules(); // 重排卡片标题「规则N」 + int currentMode(const RuleCard& c) const; // 取当前 radio 模式(1/2) + QJsonArray buildRuleList() const; // 组装 exceptionMarkRuleList + void onExecute(); // executeExceptionMark → 预览 + void onSave(); // batchCreateException + void deletePreviewRow(int row); // 删除一条预览异常 + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsObjectId_; + QString projectId_; + geopro::core::Grid grid_; // 反演网格(预览图等值面 + 统计) + geopro::core::ColorScale scale_; // 网格色阶(预览图取色) + + // 预览图(复用 GridDataChartView 的 ContourPlotItem 渲染等值面 + 异常叠加)。 + // colorSvc_ 非 QObject 手动持有;previewItem_ 由 QwtPlot autoDelete,析构前 detach。 + QwtPlot* previewPlot_ = nullptr; + std::unique_ptr colorSvc_; + ContourPlotItem* previewItem_ = nullptr; + + QVBoxLayout* ruleHost_ = nullptr; + std::vector rules_; + QJsonArray exceptionTypeOptions_; // 缓存的类型列表({id,name}),新增规则卡片复用 + QTableWidget* previewTable_ = nullptr; + QJsonArray previewExceptions_; // execute 返回的预览异常(confirm 时批量存) + QLabel* detectedLabel_ = nullptr; // 「共识别到 N 个异常」 + QPushButton* saveBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ChartPickGeometry.cpp b/src/app/panels/chart/ChartPickGeometry.cpp new file mode 100644 index 0000000..1a4f909 --- /dev/null +++ b/src/app/panels/chart/ChartPickGeometry.cpp @@ -0,0 +1,38 @@ +#include "panels/chart/ChartPickGeometry.hpp" + +#include +#include + +namespace geopro::app { + +std::vector pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect) { + std::vector hits; + const auto& xs = field.x; + const auto& ys = field.y; + const std::size_t n = std::min(xs.size(), ys.size()); + const bool hasStatus = field.displayStatus.size() == n; + for (std::size_t i = 0; i < n; ++i) { + const double x = xs[i]; + const double y = ys[i]; + if (!std::isfinite(x) || !std::isfinite(y)) continue; // 脏数据跳过 + if (hasStatus && field.displayStatus[i] != 0) continue; // 隐藏点不参与框选 + if (rect.contains(x, y)) hits.push_back(static_cast(i)); + } + return hits; +} + +int minPointsForMarkType(int markType) { + if (markType == 2) return 2; // 线 + if (markType == 3) return 3; // 面 + return 1; // 点(1)/文字(4) +} + +std::vector normalizeDrawnPoints(const std::vector& pts, int markType) { + if (pts.empty()) return {}; + // 点/文字:单点定位,仅取首点(即便误收集多点)。 + if (markType == 1 || markType == 4) return {pts.front()}; + // 线/面:保留全部顶点(不足/闭合由调用方校验)。 + return pts; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ChartPickGeometry.hpp b/src/app/panels/chart/ChartPickGeometry.hpp new file mode 100644 index 0000000..d1ddf4b --- /dev/null +++ b/src/app/panels/chart/ChartPickGeometry.hpp @@ -0,0 +1,32 @@ +#pragma once +#include + +#include +#include + +#include "model/Field.hpp" + +namespace geopro::app { + +// 图上交互的纯几何逻辑(无 Qt Widgets / Qwt 依赖,可独立单测)。 +// M14 框选命中 + I9 绘形归一化共用。 + +// M14 框选命中:返回散点 x/y 落在数据坐标矩形 rect 内的下标集合。 +// rect 用数据坐标(调用方已把像素橡皮筋反变换为数据坐标,并 normalized)。 +// 只测有限值;x/y 长度不一致取 min 防越界;隐藏点(displayStatus!=0)跳过(与原版 +// box-select 仅命中可见点一致)。 +std::vector pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect); + +// I9 绘形:判断多边形是否「可闭合」(至少 3 个顶点)。线≥2、点/文字==1 的最少点数判断 +// 见 ExceptionGeometry::minPointsForMarkType。此处仅多边形语义糖。 +inline bool canClosePolygon(int vertexCount) { return vertexCount >= 3; } + +// I9 绘形:把一串数据坐标点裁成「该标注类型的有效几何」: +// 点(1)/文字(4) → 仅取首点;线(2) → 全部(至少 2);面(3) → 全部(至少 3)。 +// 超出/不足由调用方在完成时校验(minPointsForMarkType)。本函数仅做截断(点/文字取首点)。 +std::vector normalizeDrawnPoints(const std::vector& pts, int markType); + +// I9 绘形:各标注类型的最少点数(点1/线2/面3/文字1)。markType 为 "1".."4" 对应的整数。 +int minPointsForMarkType(int markType); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourDrawTool.cpp b/src/app/panels/chart/ContourDrawTool.cpp new file mode 100644 index 0000000..ddae09a --- /dev/null +++ b/src/app/panels/chart/ContourDrawTool.cpp @@ -0,0 +1,222 @@ +#include "panels/chart/ContourDrawTool.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "panels/chart/ChartPickGeometry.hpp" + +namespace geopro::app { + +// 透明预览覆盖层(贴 canvas):画已落点 + 连线 + 到光标的橡皮筋。无 Q_OBJECT(仅重写 +// paintEvent,无信号槽),不入 MOC。透明且不吃事件(穿透给 canvas 上的 ContourDrawTool)。 +class ContourDrawOverlay : public QWidget { +public: + explicit ContourDrawOverlay(QWidget* parent) : QWidget(parent) { + setAttribute(Qt::WA_TransparentForMouseEvents, true); + setAttribute(Qt::WA_NoSystemBackground, true); + setAttribute(Qt::WA_TranslucentBackground, true); + } + + // 设当前绘制态(像素坐标点 + 光标 + 类型)。markType 3=面(闭合预览)。 + void setState(const QVector& pts, const QPoint& cursor, int markType, bool drawing) { + pts_ = pts; + cursor_ = cursor; + markType_ = markType; + drawing_ = drawing; + update(); + } + +protected: + void paintEvent(QPaintEvent*) override { + if (!drawing_) return; + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const QColor accent(24, 144, 255); // 品牌蓝预览 + QPen pen(accent, 1.5); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + + // 已落点连线(线/面)。 + if (pts_.size() >= 2) p.drawPolyline(QPolygon(pts_)); + // 橡皮筋:最后一点 → 光标。 + if (!pts_.isEmpty() && !cursor_.isNull()) { + QPen dash(accent, 1.2, Qt::DashLine); + p.setPen(dash); + p.drawLine(pts_.back(), cursor_); + // 面:光标 → 首点(闭合预览)。 + if (markType_ == 3 && pts_.size() >= 2) p.drawLine(cursor_, pts_.front()); + p.setPen(pen); + } + // 顶点小方块。 + p.setBrush(accent); + for (const QPoint& pt : pts_) p.drawRect(pt.x() - 3, pt.y() - 3, 6, 6); + } + +private: + QVector pts_; + QPoint cursor_; + int markType_ = 0; + bool drawing_ = false; +}; + +ContourDrawTool::ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) { + overlay_ = new ContourDrawOverlay(plot_->canvas()); + overlay_->setGeometry(plot_->canvas()->rect()); + overlay_->hide(); + // 后装于 LivePanner/hover → 绘制期优先消费事件(含 canvas resize 同步 overlay)。 + plot_->canvas()->installEventFilter(this); + } +} + +void ContourDrawTool::begin(int markType) { + if (!plot_ || !plot_->canvas()) return; + active_ = true; + markType_ = markType; + dataPts_.clear(); + lastCursor_ = QPoint(); + plot_->canvas()->setCursor(Qt::CrossCursor); + savedFocus_ = plot_->canvas()->focusPolicy(); // 记录原焦点策略,退出时还原 + savedFocusValid_ = true; + plot_->canvas()->setFocusPolicy(Qt::StrongFocus); + plot_->canvas()->setFocus(); // 让 canvas 接收回车/Esc 键 + if (overlay_) { + overlay_->setGeometry(plot_->canvas()->rect()); + overlay_->raise(); + overlay_->show(); + refreshOverlay(); + } + const QString hint = (markType == 2 || markType == 3) + ? QStringLiteral("逐点单击采集,双击或回车结束,Esc 取消") + : QStringLiteral("单击落点,Esc 取消"); + QToolTip::showText(plot_->canvas()->mapToGlobal(QPoint(8, 8)), hint, plot_->canvas()); +} + +void ContourDrawTool::restoreCanvas() { + if (plot_ && plot_->canvas()) { + plot_->canvas()->unsetCursor(); + if (savedFocusValid_) plot_->canvas()->setFocusPolicy(savedFocus_); // 还原焦点策略 + } + savedFocusValid_ = false; + if (overlay_) { + overlay_->setState({}, QPoint(), 0, false); + overlay_->hide(); + } +} + +void ContourDrawTool::cancel() { + active_ = false; + dataPts_.clear(); + restoreCanvas(); +} + +QPointF ContourDrawTool::toData(const QPoint& canvasPos) const { + if (!plot_) return {}; + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + return QPointF(xMap.invTransform(canvasPos.x()), yMap.invTransform(canvasPos.y())); +} + +void ContourDrawTool::addVertex(const QPoint& canvasPos) { + dataPts_.push_back(toData(canvasPos)); + // 点/文字:单点即完成。 + if (markType_ == 1 || markType_ == 4) { finish(); return; } + refreshOverlay(); +} + +void ContourDrawTool::finish() { + if (!active_) return; + auto pts = normalizeDrawnPoints(dataPts_, markType_); + if (static_cast(pts.size()) < minPointsForMarkType(markType_)) { + // 点数不足(如线只点了 1 个就双击):保持绘制态,等用户补点。 + return; + } + active_ = false; + restoreCanvas(); + if (onComplete_) onComplete_(pts); +} + +void ContourDrawTool::refreshOverlay() { + if (!overlay_ || !plot_) return; + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + QVector px; + px.reserve(static_cast(dataPts_.size())); + for (const QPointF& d : dataPts_) + px.push_back(QPoint(static_cast(xMap.transform(d.x())), + static_cast(yMap.transform(d.y())))); + overlay_->setState(px, lastCursor_, markType_, active_); +} + +bool ContourDrawTool::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + // 始终同步 overlay 尺寸(即便未激活,保证下次激活时贴合)。 + if (ev->type() == QEvent::Resize && overlay_) { + overlay_->setGeometry(plot_->canvas()->rect()); + return QObject::eventFilter(obj, ev); + } + if (!active_) return QObject::eventFilter(obj, ev); + + switch (ev->type()) { + case QEvent::MouseButtonDblClick: { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { + // Qt 双击序列:Press→Release→DblClick。前一个 Press 已 addVertex 落了一个与 + // 双击位置重合的伪顶点,结束前移除它,避免线/面末尾出现重复点。 + if (!dataPts_.empty()) dataPts_.pop_back(); + finish(); + return true; + } + break; + } + case QEvent::MouseButtonPress: { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) { + // 双击的第一下会先来一个 Press;线/面靠 DblClick 结束,这里只加点。 + addVertex(me->pos()); + return true; + } + if (me->button() == Qt::RightButton) { // 右键取消 + const bool wasActive = active_; + cancel(); + if (wasActive && onCancel_) onCancel_(); + return true; + } + break; + } + case QEvent::MouseMove: { + auto* me = static_cast(ev); + lastCursor_ = me->pos(); + if (!dataPts_.empty()) refreshOverlay(); + return true; // 绘制期消费移动(不弹 hover) + } + case QEvent::KeyPress: { + auto* ke = static_cast(ev); + if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { finish(); return true; } + if (ke->key() == Qt::Key_Escape) { + cancel(); + if (onCancel_) onCancel_(); + return true; + } + break; + } + default: + break; + } + return QObject::eventFilter(obj, ev); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourDrawTool.hpp b/src/app/panels/chart/ContourDrawTool.hpp new file mode 100644 index 0000000..a5a5067 --- /dev/null +++ b/src/app/panels/chart/ContourDrawTool.hpp @@ -0,0 +1,66 @@ +#pragma once +#include +#include + +#include +#include +#include +#include +#include // Qt::FocusPolicy + +class QwtPlot; + +namespace geopro::app { + +class ContourDrawOverlay; + +// I9 图上绘形工具:开启后在等值面图上用鼠标采集几何,实时预览,完成后回调数据坐标点。 +// 复刻原版 contour overlay 绘制交互(先弹窗选类型→再图上画→drawingComplete): +// markType 1 点 / 4 文字:单击落点立即完成; +// markType 2 线:逐点单击,双击或回车结束(≥2 点); +// markType 3 面:逐点单击,双击或回车闭合(≥3 点)。 +// Esc 取消。绘制时事件优先于 LivePanner/hover 消费(开启期间禁用平移)。 +// 不拥有 plot(外部持有,QPointer 守护);overlay 父=canvas 随之析构。 +class ContourDrawTool : public QObject { + Q_OBJECT +public: + // xAxis/yAxis:等值面所在轴(GridDataChartView 为 xBottom/yLeft)。 + ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + + // 完成回调:参数为数据坐标点序列(已按类型归一化:点/文字 1 点,线≥2,面≥3)。 + void setOnComplete(std::function&)> cb) { onComplete_ = std::move(cb); } + // 取消回调(Esc / 右键 / 外部 cancel):调用方据此恢复 UI(如重新开放工具条)。 + void setOnCancel(std::function cb) { onCancel_ = std::move(cb); } + + // 开始绘制指定标注类型("1".."4" 对应整数)。会重置已采集点并显示提示。 + void begin(int markType); + // 外部强制取消(不触发 onCancel_)。 + void cancel(); + bool isActive() const { return active_; } + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + QPointF toData(const QPoint& canvasPos) const; // 像素 → 数据坐标 + void addVertex(const QPoint& canvasPos); + void finish(); // 校验最少点数 → onComplete_ + void refreshOverlay(); // 把当前已落点(数据坐标)映射回像素喂给 overlay 预览 + + QPointer plot_; + int xAxis_; + int yAxis_; + std::function&)> onComplete_; + std::function onCancel_; + + ContourDrawOverlay* overlay_ = nullptr; // 父=canvas + bool active_ = false; + int markType_ = 0; + std::vector dataPts_; // 已采集点(数据坐标) + QPoint lastCursor_; // 当前光标(橡皮筋预览到此) + Qt::FocusPolicy savedFocus_ = Qt::NoFocus; // begin 前 canvas 焦点策略(退出还原) + bool savedFocusValid_ = false; + void restoreCanvas(); // 退出绘制态:还原光标/焦点策略 + 隐藏 overlay +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourHoverTip.cpp b/src/app/panels/chart/ContourHoverTip.cpp new file mode 100644 index 0000000..cae50f1 --- /dev/null +++ b/src/app/panels/chart/ContourHoverTip.cpp @@ -0,0 +1,60 @@ +#include "panels/chart/ContourHoverTip.hpp" + +#include + +#include +#include +#include +#include +#include +#include + +#include "panels/chart/ContourPlotItem.hpp" + +namespace geopro::app { + +ContourHoverTip::ContourHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + if (plot_ && plot_->canvas()) { + plot_->canvas()->setMouseTracking(true); // hover 需开启鼠标跟踪 + plot_->canvas()->installEventFilter(this); + } +} + +bool ContourHoverTip::eventFilter(QObject* obj, QEvent* ev) { + if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + if (!enabled_ || !item_) return false; // 未开启提示则不处理 + + if (ev->type() == QEvent::Leave) { + QToolTip::hideText(); + return false; + } + if (ev->type() != QEvent::MouseMove) return QObject::eventFilter(obj, ev); + + auto* me = static_cast(ev); + if (me->buttons() != Qt::NoButton) return false; // 拖动平移中不弹(交给 LivePanner) + + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + const QPointF mp = me->position(); + const double dataX = xMap.invTransform(mp.x()); + const double dataY = yMap.invTransform(mp.y()); + // 像素命中半径换算到数据坐标(取 x 方向比例;x:y 等比锁定,足够近似)。 + const double dx1 = xMap.invTransform(mp.x() + kHitRadiusPx) - dataX; + const double radius = std::fabs(dx1); + + const double level = item_->contourLevelNear(dataX, dataY, radius); + if (std::isnan(level)) { + QToolTip::hideText(); + return false; + } + // 对照原版 customHoverFormatter:数值 + 坐标。 + const QString text = QStringLiteral("等值线信息
数值: %1
坐标: (%2, %3)") + .arg(level, 0, 'f', 2) + .arg(dataX, 0, 'f', 2) + .arg(dataY, 0, 'f', 2); + QToolTip::showText(me->globalPosition().toPoint(), text, plot_->canvas()); + return false; // 不消费 +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourHoverTip.hpp b/src/app/panels/chart/ContourHoverTip.hpp new file mode 100644 index 0000000..0d94ace --- /dev/null +++ b/src/app/panels/chart/ContourHoverTip.hpp @@ -0,0 +1,34 @@ +#pragma once +#include + +class QwtPlot; + +namespace geopro::app { + +class ContourPlotItem; + +// 等值线提示(I7「显示等值线提示信息」):监听画布鼠标移动(无按键时), +// 命中最近等值线则 QToolTip 显示其数值与坐标(对照原版 contour hover tooltip)。 +// 默认关闭,由工具条复选框 setEnabled(true) 开启。不消费事件,与 LivePanner 共存。 +class ContourHoverTip : public QObject { + Q_OBJECT +public: + ContourHoverTip(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + + void setItem(const ContourPlotItem* item) { item_ = item; } + void setEnabled(bool on) { enabled_ = on; } + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + QwtPlot* plot_; + int xAxis_; + int yAxis_; + const ContourPlotItem* item_ = nullptr; + bool enabled_ = false; + + static constexpr double kHitRadiusPx = 6.0; // 命中半径(像素) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourPlotItem.cpp b/src/app/panels/chart/ContourPlotItem.cpp index 663bd61..531d66d 100644 --- a/src/app/panels/chart/ContourPlotItem.cpp +++ b/src/app/panels/chart/ContourPlotItem.cpp @@ -11,6 +11,7 @@ #include #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourSimplify.hpp" namespace geopro::app { @@ -41,6 +42,7 @@ void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, static_cast(g.y.size()) < ny) { fillImage_ = QImage(); dataBBox_ = QRectF(); + linesRaw_.clear(); lines_.clear(); return; } @@ -57,15 +59,60 @@ void ContourPlotItem::setData(const core::Grid& g, ColorMapService* svc, opt.upsample = 2; opt.makeLines = true; auto res = render::buildContourBands(g, svc->scale(), opt); - lines_ = std::move(res.lines); + linesRaw_ = std::move(res.lines); + lines_ = linesRaw_; // buildContourBands 当前未回填 level(恒 0);在此按线上代表点采网格值并吸附到最近色阶级, // 使标注显示真实等值线值。 resolveLineLevels(g, svc->scale()); + linesRaw_ = lines_; // level 回填后同步到原始集(简化保留 level)。 + applySimplify(); // 按当前容差抽稀(首次 tol=0 即原样)。 } else { + linesRaw_.clear(); lines_.clear(); } } +void ContourPlotItem::setSimplifyTolerance(double tol) { + simplifyTol_ = tol < 0.0 ? 0.0 : tol; + applySimplify(); +} + +void ContourPlotItem::applySimplify() { + if (simplifyTol_ <= 0.0) { + lines_ = linesRaw_; + return; + } + lines_.clear(); + lines_.reserve(linesRaw_.size()); + for (const auto& ln : linesRaw_) { + render::ContourLine s; + s.level = ln.level; + s.pts = douglasPeucker(ln.pts, simplifyTol_); + lines_.push_back(std::move(s)); + } +} + +double ContourPlotItem::contourLevelNear(double dataX, double dataY, double hitDataRadius) const { + // 在已绘制等值线(简化后)找最近线段,命中半径内返回该线 level。 + double bestD2 = hitDataRadius * hitDataRadius; + double bestLevel = std::nan(""); + for (const auto& ln : lines_) { + if (std::isnan(ln.level)) continue; + for (std::size_t i = 1; i < ln.pts.size(); ++i) { + const auto& a = ln.pts[i - 1]; + const auto& b = ln.pts[i]; + const double dx = b.x - a.x, dy = b.y - a.y; + const double len2 = dx * dx + dy * dy; + double t = len2 > 0 ? ((dataX - a.x) * dx + (dataY - a.y) * dy) / len2 : 0.0; + t = std::clamp(t, 0.0, 1.0); + const double px = a.x + t * dx, py = a.y + t * dy; + const double d2 = (dataX - px) * (dataX - px) + (dataY - py) * (dataY - py); + if (d2 < bestD2) { bestD2 = d2; bestLevel = ln.level; } + } + } + return bestLevel; +} + void ContourPlotItem::resolveLineLevels(const core::Grid& g, const core::ColorScale& cs) { const auto stops = cs.stopValues(); if (stops.empty() || lines_.empty()) return; @@ -158,6 +205,19 @@ QRectF ContourPlotItem::boundingRect() const { return dataBBox_; } +QRectF ContourPlotItem::anomalyBoundingRect(int index) const { + if (index < 0 || index >= static_cast(anoms_.size())) return {}; + const auto& pts = anoms_[index].localPts; + if (pts.empty()) return {}; + double minX = pts.front().x, maxX = pts.front().x; + double minY = pts.front().y, maxY = pts.front().y; + for (const auto& p : pts) { + minX = std::min(minX, p.x); maxX = std::max(maxX, p.x); + minY = std::min(minY, p.y); maxY = std::max(maxY, p.y); + } + return QRectF(minX, minY, maxX - minX, maxY - minY); +} + void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, const QRectF& /*canvasRect*/) const { if (dataBBox_.isNull()) return; @@ -182,12 +242,13 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt return QPointF(xMap.transform(p.x), yMap.transform(p.y)); }; - // 2) 等值线:黑色 0 宽(cosmetic)细线。 + // 2) 等值线:按线形⚙ 配置取色/虚实(默认黑实线)。 if (showLines_ && !lines_.empty()) { painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); - QPen pen(QColor(0, 0, 0)); - pen.setWidthF(1.0); // 1px 黑色等值线 + QPen pen(QColor(lineColor_.r, lineColor_.g, lineColor_.b, lineColor_.a)); + pen.setWidthF(1.0); // 1px 等值线 + pen.setStyle(lineDashed_ ? Qt::DashLine : Qt::SolidLine); painter->setPen(pen); for (const auto& ln : lines_) { if (ln.pts.size() < 2) continue; @@ -205,7 +266,7 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt QFont f = painter->font(); f.setPixelSize(kLabelFontPx); painter->setFont(f); - painter->setPen(QColor(0, 0, 0)); + painter->setPen(QColor(labelColor_.r, labelColor_.g, labelColor_.b, labelColor_.a)); const QFontMetricsF fm(f); for (const auto& ln : lines_) { if (ln.pts.size() < 2 || std::isnan(ln.level)) continue; @@ -252,18 +313,21 @@ void ContourPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const Qwt painter->save(); painter->setRenderHint(QPainter::Antialiasing, true); painter->setBrush(Qt::NoBrush); - for (const auto& a : anoms_) { + for (int ai = 0; ai < static_cast(anoms_.size()); ++ai) { + const auto& a = anoms_[ai]; if (a.localPts.empty()) continue; + const bool hl = (ai == highlightIdx_); // I12 当前定位高亮 QColor col(QString::fromStdString(a.lineColor)); if (!col.isValid()) col = QColor(0, 0, 0); - QPen pen(col); - pen.setWidthF(a.lineWidth > 0 ? a.lineWidth : 1.0); - pen.setStyle(a.dashed ? Qt::DashLine : Qt::SolidLine); + QPen pen(hl ? QColor(255, 255, 0) : col); // 高亮黄(对照原版 #ffff00) + pen.setWidthF(hl ? std::max(3.0, a.lineWidth) : (a.lineWidth > 0 ? a.lineWidth : 1.0)); + pen.setStyle(hl ? Qt::SolidLine : (a.dashed ? Qt::DashLine : Qt::SolidLine)); painter->setPen(pen); if (a.markType == core::AnomalyMarkType::Point) { const QPointF c = mapPt(a.localPts.front()); - painter->drawRect(QRectF(c.x() - 3, c.y() - 3, 6, 6)); + const double r = hl ? 5.0 : 3.0; + painter->drawRect(QRectF(c.x() - r, c.y() - r, 2 * r, 2 * r)); } else { QPolygonF poly; poly.reserve(static_cast(a.localPts.size())); diff --git a/src/app/panels/chart/ContourPlotItem.hpp b/src/app/panels/chart/ContourPlotItem.hpp index 5052773..ca23ddc 100644 --- a/src/app/panels/chart/ContourPlotItem.hpp +++ b/src/app/panels/chart/ContourPlotItem.hpp @@ -33,6 +33,21 @@ public: void setShowLines(bool on) { showLines_ = on; } void setShowLabels(bool on) { showLabels_ = on; } void setShowAnomalies(bool on) { showAnomalies_ = on; } + // I8 简化容差:对等值线做 Douglas-Peucker 抽稀(数据坐标系,tol>0 生效)。 + // 改容差即重算 lines_(从原始 linesRaw_),调用方随后 replot。 + void setSimplifyTolerance(double tol); + // I7 等值线提示:按数据坐标命中最近等值线,返回其 level(无命中返回 NaN)。 + // hitDataRadius 为命中半径(数据坐标,由调用方按像素半径换算)。 + double contourLevelNear(double dataX, double dataY, double hitDataRadius) const; + // 线形⚙ 配置(色阶编辑器下发):等值线色/线型(虚实)、标注色。默认黑实线。 + void setLineColor(const core::Rgba& c) { lineColor_ = c; } + void setLineDashed(bool dashed) { lineDashed_ = dashed; } + void setLabelColor(const core::Rgba& c) { labelColor_ = c; } + + // I12 定位:高亮指定下标的异常(黄色加粗描边),-1=清除。调用方随后 replot。 + void setHighlightedAnomaly(int index) { highlightIdx_ = index; } + // I12 定位:取某异常的数据坐标包围盒(用于视图缩放);下标越界/无点返回 null。 + QRectF anomalyBoundingRect(int index) const; int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } @@ -44,15 +59,22 @@ public: private: void buildFillImage(const core::Grid& g, ColorMapService* svc); void resolveLineLevels(const core::Grid& g, const core::ColorScale& cs); + void applySimplify(); // 从 linesRaw_ 按 simplifyTol_ 重算 lines_(保留 level) QImage fillImage_; // 预渲染填充热力图(ARGB32,含透明无数据区) QRectF dataBBox_; // 数据包围盒(x[xmin,xmax] y[ymin,ymax]) - std::vector lines_; // 矢量等值线(含 level) + std::vector linesRaw_; // 原始等值线(简化前,作简化数据源) + std::vector lines_; // 当前绘制等值线(按容差简化后,含 level) + double simplifyTol_ = 0.0; // I8 简化容差(数据坐标,0=不简化) std::vector anoms_; // 异常叠加 bool showLines_ = true; bool showLabels_ = true; bool showAnomalies_ = true; + core::Rgba lineColor_{0, 0, 0, 255}; // 等值线色(默认黑) + bool lineDashed_ = false; // 等值线虚实(默认实线) + core::Rgba labelColor_{0, 0, 0, 255}; // 标注色(默认黑) + int highlightIdx_ = -1; // I12 当前高亮异常下标(-1=无) }; } // namespace geopro::app diff --git a/src/app/panels/chart/ContourSimplify.cpp b/src/app/panels/chart/ContourSimplify.cpp new file mode 100644 index 0000000..4956ed2 --- /dev/null +++ b/src/app/panels/chart/ContourSimplify.cpp @@ -0,0 +1,51 @@ +#include "panels/chart/ContourSimplify.hpp" + +#include + +namespace geopro::app { + +namespace { +// 点 p 到线段 ab 的垂距(ab 退化为点时取点距)。 +double perpDistance(const geopro::core::Vec2& p, const geopro::core::Vec2& a, + const geopro::core::Vec2& b) { + const double dx = b.x - a.x, dy = b.y - a.y; + const double len2 = dx * dx + dy * dy; + if (len2 <= 0.0) return std::hypot(p.x - a.x, p.y - a.y); + // 叉积 / 段长 = 垂距。 + const double cross = std::fabs((p.x - a.x) * dy - (p.y - a.y) * dx); + return cross / std::sqrt(len2); +} + +// 递归对 [lo,hi] 区间抽稀,保留点索引置 keep[]。 +void dpRecurse(const std::vector& pts, int lo, int hi, double tol, + std::vector& keep) { + if (hi <= lo + 1) return; + double maxD = -1.0; + int idx = lo; + for (int i = lo + 1; i < hi; ++i) { + const double d = perpDistance(pts[i], pts[lo], pts[hi]); + if (d > maxD) { maxD = d; idx = i; } + } + if (maxD > tol) { + keep[idx] = true; + dpRecurse(pts, lo, idx, tol, keep); + dpRecurse(pts, idx, hi, tol, keep); + } +} +} // namespace + +std::vector douglasPeucker(const std::vector& pts, + double tol) { + if (tol <= 0.0 || pts.size() <= 2) return pts; + std::vector keep(pts.size(), false); + keep.front() = true; + keep.back() = true; + dpRecurse(pts, 0, static_cast(pts.size()) - 1, tol, keep); + std::vector out; + out.reserve(pts.size()); + for (std::size_t i = 0; i < pts.size(); ++i) + if (keep[i]) out.push_back(pts[i]); + return out; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ContourSimplify.hpp b/src/app/panels/chart/ContourSimplify.hpp new file mode 100644 index 0000000..61bb9e0 --- /dev/null +++ b/src/app/panels/chart/ContourSimplify.hpp @@ -0,0 +1,14 @@ +#pragma once +#include + +#include "model/Anomaly.hpp" // core::Vec2 + +namespace geopro::app { + +// 折线 Douglas-Peucker 抽稀(纯几何,无 Qt/VTK 依赖,便于单测)。 +// tol<=0 或点数<=2 时原样返回(拷贝)。容差单位与点坐标一致(数据坐标)。 +// I8「简化容差」滑块据此对等值线做点抽稀(容差越大点越少、线越粗略)。 +std::vector douglasPeucker(const std::vector& pts, + double tol); + +} // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.cpp b/src/app/panels/chart/DataTableView.cpp index 410edf0..3904d31 100644 --- a/src/app/panels/chart/DataTableView.cpp +++ b/src/app/panels/chart/DataTableView.cpp @@ -1,11 +1,26 @@ #include "panels/chart/DataTableView.hpp" +#include + +#include +#include #include +#include +#include #include +#include +#include +#include #include +#include #include +#include "Theme.hpp" + +#include "panels/chart/InversionFormDialog.hpp" +#include "panels/chart/ScatterDataOps.hpp" // toggledDisplayStatus #include "panels/chart/TablePager.hpp" +#include "repo/IDatasetCommandRepository.hpp" namespace geopro::app { @@ -63,6 +78,38 @@ geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const { return payload_.columns[static_cast(column)].kind; } +int TablePayloadModel::toggleColumn() const { + for (size_t i = 0; i < payload_.columns.size(); ++i) + if (payload_.columns[i].kind == geopro::core::TableColumnKind::Toggle) + return static_cast(i); + return -1; +} + +QString TablePayloadModel::rowId(int row) const { + if (row < 0 || row >= static_cast(payload_.rowIds.size())) return {}; + return payload_.rowIds[static_cast(row)]; +} + +int TablePayloadModel::rowDisplayStatus(int row) const { + const int col = toggleColumn(); + if (col < 0 || row < 0 || row >= static_cast(payload_.rows.size())) return 0; + const auto& cells = payload_.rows[static_cast(row)]; + if (col >= static_cast(cells.size())) return 0; + // Toggle 单元 "1"=ON/可见 → displayStatus 0;否则隐藏 → 1。 + return cells[static_cast(col)] == QLatin1String("1") ? 0 : 1; +} + +void TablePayloadModel::setRowDisplayStatus(int row, int status) { + const int col = toggleColumn(); + if (col < 0 || row < 0 || row >= static_cast(payload_.rows.size())) return; + auto& cells = payload_.rows[static_cast(row)]; + if (col >= static_cast(cells.size())) return; + // status 0=显示 → 单元 "1"(ON);status 1=隐藏 → "0"(OFF)。 + cells[static_cast(col)] = (status == 0) ? QStringLiteral("1") : QStringLiteral("0"); + const QModelIndex idx = index(row, col); + emit dataChanged(idx, idx, {Qt::DisplayRole}); +} + ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent) : QStyledItemDelegate(parent), model_(model) {} @@ -111,6 +158,19 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) { lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); + // 顶部功能按钮行(默认隐藏;仅 dd_grid 载荷带 functionButtons 时显示)。 + // 布局对照原版 DdGrid/index.vue .swicth:左侧 radio-group(「电法列表」单选项) + 右侧主按钮组, + // space-between。radio 仅视觉占位(原版亦仅一项、无实际切换作用)。 + toolbar_ = new QWidget(this); + toolbarLay_ = new QHBoxLayout(toolbar_); + toolbarLay_->setContentsMargins(0, 0, 0, 8); + auto* listRadio = new QRadioButton(QStringLiteral("电法列表"), toolbar_); + listRadio->setChecked(true); + toolbarLay_->addWidget(listRadio); // index 0:左侧单选项 + toolbarLay_->addStretch(1); // index 1:把功能按钮推到右侧(rebuildToolbar 在末尾追加) + toolbar_->hide(); + lay->addWidget(toolbar_); + model_ = new TablePayloadModel(this); table_ = new QTableView(this); table_->setModel(model_); @@ -126,6 +186,8 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) { // Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。 table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_)); + // M2:点击 Toggle 列(仅 measurement 可交互)→ 行级显隐切换。 + connect(table_, &QTableView::clicked, this, &DataTableView::onCellClicked); lay->addWidget(table_); @@ -162,6 +224,104 @@ void DataTableView::setPayload(const QVariant& payload) { } else { pager_->hide(); } + + // 功能按钮行:按载荷 functionButtons 重建(空 → 隐藏;非 dd_grid 始终空)。 + rebuildToolbar(t.functionButtons); +} + +void DataTableView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + projectIdGetter_ = std::move(projectIdGetter); +} + +void DataTableView::rebuildToolbar(const std::vector& buttons) { + // 清空旧功能按钮(仅删 QPushButton,保留左侧 radio 与中间 stretch)。 + for (int i = toolbarLay_->count() - 1; i >= 0; --i) { + if (auto* btn = qobject_cast(toolbarLay_->itemAt(i)->widget())) { + toolbarLay_->removeWidget(btn); + btn->deleteLater(); + } + } + + // 仅渲染 enable 的按钮(原版 v-show="enable");全空/全禁用 → 隐藏整行。 + // 主按钮蓝色实心(对照原版 type="primary"),复用 primaryBtn QSS。 + int shown = 0; + for (const auto& b : buttons) { + if (!b.enable) continue; + auto* btn = new QPushButton(b.nameChn, toolbar_); + btn->setObjectName(QStringLiteral("primaryBtn")); + const QString code = b.code; + connect(btn, &QPushButton::clicked, this, [this, code] { onFunctionButton(code); }); + toolbarLay_->addWidget(btn); // 末尾追加 → 落在 stretch 之后(右侧) + ++shown; + } + if (shown > 0) { + applyTokenizedStyleSheet( + toolbar_, + QStringLiteral( + "QPushButton#primaryBtn { background: {{accent/primary}}; color: {{text/on-primary}};" + " border: 1px solid {{accent/primary}}; border-radius: 6px; padding: 6px 14px; }" + "QPushButton#primaryBtn:hover { background: {{accent/primary-hover}};" + " border-color: {{accent/primary-hover}}; }" + "QPushButton#primaryBtn:pressed { background: {{accent/primary-pressed}}; }")); + } + toolbar_->setVisible(shown > 0); +} + +void DataTableView::onFunctionButton(const QString& code) { + // 路由:原版 handleFunctionBtn 仅处理 inversion(其余 code 无操作)。 + if (code != QLatin1String("inversion")) return; + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { + QToolTip::showText(QCursor::pos(), QStringLiteral("暂未实现"), this); + return; + } + // 反演运算:复用共享对话框 Mode::Inversion(模型列表+动态表单+submitInversionTask)。 + // 与原版 DdGrid 完全一致(同 InversionForm.vue + outerInversion 端点)。 + InversionFormDialog dlg(InversionFormDialog::Mode::Inversion, cmdRepo_, dsId, projectId, this); + dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。 +} + +void DataTableView::onCellClicked(const QModelIndex& index) { + // M2 行级显隐:仅 measurement 列表(toggleInteractive)的 Toggle 列响应点击;其余视图无操作。 + if (!index.isValid() || !model_->isToggleInteractive()) return; + if (model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) return; + + const int row = index.row(); + const QString id = model_->rowId(row); + const int cur = model_->rowDisplayStatus(row); // 0=显示 1=隐藏 + const int next = toggledDisplayStatus(cur); // 取反(对照原版 record.displayStatus ? 0 : 1) + + // popconfirm 文案:当前显示(0)→将隐藏;当前隐藏→将显示(对照原版 scatterPopHide/scatterPopShow)。 + const QString text = (cur == 0) ? QStringLiteral("该操作会隐藏该散点,确认?") + : QStringLiteral("该操作会显示该散点,确认?"); + if (QMessageBox::question(this, QStringLiteral("提示"), text, + QMessageBox::Ok | QMessageBox::Cancel) != QMessageBox::Ok) + return; + + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty() || id.isEmpty()) { + // 无仓储/无 dsId/无行 id → 仅本地切换(退化,不持久化)。 + model_->setRowDisplayStatus(row, next); + return; + } + + QJsonArray ids; + ids.append(id); + QPointer self(this); + cmdRepo_->saveDisplayStatus(dsId, ids, next, [self, row, next](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("操作失败") : msg); + return; + } + self->model_->setRowDisplayStatus(row, next); // 持久化成功后更新该行状态 + }); } } // namespace geopro::app diff --git a/src/app/panels/chart/DataTableView.hpp b/src/app/panels/chart/DataTableView.hpp index d0eaaa5..b095ec9 100644 --- a/src/app/panels/chart/DataTableView.hpp +++ b/src/app/panels/chart/DataTableView.hpp @@ -1,11 +1,20 @@ #pragma once +#include + #include #include +#include #include #include "model/detail/DetailPayloads.hpp" #include "panels/chart/IDetailView.hpp" class QTableView; +class QWidget; +class QHBoxLayout; + +namespace geopro::data { +class IDatasetCommandRepository; +} namespace geopro::app { @@ -24,7 +33,17 @@ public: // 列渲染种类(供委托判断是否画开关)。越界返回 Text。 geopro::core::TableColumnKind columnKind(int column) const; + // M2:该 Toggle 列是否可交互(仅 measurement 载荷为 true)。 + bool isToggleInteractive() const { return payload_.toggleInteractive; } + // M2:取行点 id(越界/无 id → 空串)。 + QString rowId(int row) const; + // M2:取行当前显隐状态(0=显示 1=隐藏;据 Toggle 单元 "1"=ON/可见反推)。 + int rowDisplayStatus(int row) const; + // M2:把某行 Toggle 单元就地设为指定状态(status 0=显示 → "1"/ON;持久化成功后调用)。 + void setRowDisplayStatus(int row, int status); + private: + int toggleColumn() const; // Toggle 列下标(无则 -1) geopro::core::TablePayload payload_; }; @@ -48,6 +67,9 @@ class TablePager; // 通用数据列表视图:IDetailView + QTableView(+ 分页型载荷时底部 TablePager 分页器)。 // measurement/grid/trajectory 列表共用。载荷 pageSize>0(dd_grid)时显示分页器并转发翻页请求; // 否则隐藏分页器(全量列表)。 +// 顶部功能按钮行:仅当载荷 functionButtons 非空(dd_grid)时显示,按钮文案/可见来自服务端 +// functionList;点 code=="inversion" → 弹反演动态表单对话框(复用 InversionFormDialog/Mode::Inversion)。 +// 其余 Table 复用场景(measurement 列表/trajectory/gr)functionButtons 为空 → 工具条隐藏,无任何变化。 class DataTableView : public QWidget, public IDetailView { Q_OBJECT public: @@ -56,14 +78,30 @@ public: QWidget* widget() override { return this; } void setPayload(const QVariant& payload) override; // 坏/空 variant → 保持空态不崩 + // 注入反演命令仓储 + dsId/projectId 取值回调(dd_grid「反演」功能按钮用)。 + // 未注入时功能按钮仍渲染但点击退化为占位提示(与其它未接入视图一致)。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + signals: // 分页器请求加载某页(翻页/跳页/改每页条数)。壳据此触发控制器 loadTabPaged。 void pageRequested(int pageNo, int pageSize); private: + void rebuildToolbar(const std::vector& buttons); + void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效) + void onCellClicked(const QModelIndex& index); // M2 行级显隐切换(仅 measurement Toggle 列) + + QWidget* toolbar_; // 顶部功能按钮行容器(functionButtons 空时隐藏) + QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填) QTableView* table_; TablePayloadModel* model_; TablePager* pager_; // 分页器(pageSize>0 时显示,否则隐藏) + + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; + std::function projectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/DetailViewFactory.cpp b/src/app/panels/chart/DetailViewFactory.cpp index 5185893..bf1d3df 100644 --- a/src/app/panels/chart/DetailViewFactory.cpp +++ b/src/app/panels/chart/DetailViewFactory.cpp @@ -1,6 +1,7 @@ #include "panels/chart/DetailViewFactory.hpp" #include +#include #include "panels/chart/BarChartView.hpp" #include "panels/chart/DataTableView.hpp" @@ -11,14 +12,39 @@ namespace geopro::app { -std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent) { +std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent, + geopro::data::IColorTemplateRepository* colorTplRepo, + std::function projectIdGetter, + geopro::data::IDatasetCommandRepository* cmdRepo, + std::function dsIdGetter, + std::function tmObjectIdGetter) { switch (kind) { - case controller::ViewKind::Scatter: - return std::unique_ptr(new RawDataChartView(parent)); - case controller::ViewKind::FilledContour: - return std::unique_ptr(new GridDataChartView(parent)); - case controller::ViewKind::Table: - return std::unique_ptr(new DataTableView(parent)); + case controller::ViewKind::Scatter: { + auto* raw = new RawDataChartView(parent); + // 注入反演命令仓储 + dsId/projectId 取值回调(measurement 反演运算/生成视电阻率)。 + raw->setCommandRepo(cmdRepo, dsIdGetter, projectIdGetter); + // 注入色阶模板仓储(散点「色阶配置」编辑器另存为/打开/覆盖用;projectId 复用上面的 getter)。 + raw->setColorTemplateRepo(colorTplRepo); + return std::unique_ptr(raw); + } + case controller::ViewKind::FilledContour: { + auto* grid = new GridDataChartView(parent); + // 注入色阶模板仓储 + projectId 取值回调(网格剖面「色阶配置」编辑器用)。 + grid->setColorTemplateRepo(colorTplRepo, projectIdGetter); + // 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。 + grid->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); + // 注入 tmObjectId 取值回调(白化对话框模板列表用,= 数据集 structParentId)。 + grid->setTmObjectIdGetter(std::move(tmObjectIdGetter)); + return std::unique_ptr(grid); + } + case controller::ViewKind::Table: { + auto* table = new DataTableView(parent); + // 注入反演命令仓储 + dsId/projectId 取值回调(dd_grid「反演」功能按钮用)。 + // 其余 Table 复用场景(measurement 列表/trajectory/gr)载荷无 functionButtons, + // 工具条隐藏,注入的仓储不会被触达 → 无副作用。 + table->setCommandRepo(cmdRepo, std::move(dsIdGetter), std::move(projectIdGetter)); + return std::unique_ptr(table); + } case controller::ViewKind::Bar: return std::unique_ptr(new BarChartView(parent)); case controller::ViewKind::LineProfile: diff --git a/src/app/panels/chart/DetailViewFactory.hpp b/src/app/panels/chart/DetailViewFactory.hpp index 98464b0..da9435e 100644 --- a/src/app/panels/chart/DetailViewFactory.hpp +++ b/src/app/panels/chart/DetailViewFactory.hpp @@ -1,9 +1,18 @@ #pragma once +#include #include + +#include + #include "DatasetDetailTab.hpp" // geopro::controller::ViewKind class QWidget; +namespace geopro::data { +class IColorTemplateRepository; +class IDatasetCommandRepository; +} + namespace geopro::app { class IDetailView; @@ -11,6 +20,15 @@ class IDetailView; // 按 render kind 造详情视图。E1b 仅支持 Scatter / FilledContour(反演两页签); // Bar/LineProfile/PolylineMap/Table 留待后续阶段(measurement/trajectory/grid)补, // 现阶段命中会抛 std::runtime_error(明确失败,而非静默空指针)。 -std::unique_ptr makeDetailView(controller::ViewKind kind, QWidget* parent); +// colorTplRepo/projectIdGetter:FilledContour 视图的色阶模板仓储注入(可空 → 编辑器后端按钮禁用)。 +// cmdRepo/dsIdGetter:Scatter 视图(measurement)反演运算/生成视电阻率命令仓储注入(可空 → 按钮占位提示)。 +// tmObjectIdGetter:FilledContour 视图白化对话框所需 tmObjectId(=数据集 structParentId)取值回调(可空 → 模板列表空)。 +std::unique_ptr makeDetailView( + controller::ViewKind kind, QWidget* parent, + geopro::data::IColorTemplateRepository* colorTplRepo = nullptr, + std::function projectIdGetter = {}, + geopro::data::IDatasetCommandRepository* cmdRepo = nullptr, + std::function dsIdGetter = {}, + std::function tmObjectIdGetter = {}); } // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDetailDialog.cpp b/src/app/panels/chart/ExceptionDetailDialog.cpp new file mode 100644 index 0000000..1c8858b --- /dev/null +++ b/src/app/panels/chart/ExceptionDetailDialog.cpp @@ -0,0 +1,250 @@ +#include "panels/chart/ExceptionDetailDialog.hpp" + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { + +// 只读色块(对照原版 disabled ColorPicker):显示 hex + 背景色,不可点。 +QLabel* readonlySwatch(const QString& hex, QWidget* parent) { + auto* lbl = new QLabel(hex, parent); + lbl->setStyleSheet( + QStringLiteral("background:%1;border:1px solid #ccc;padding:2px 6px;color:#fff;").arg(hex)); + return lbl; +} + +// 线型 code → 中文(对照原版 solid/dash)。 +QString lineTypeName(bool dashed) { + return dashed ? QStringLiteral("虚线") : QStringLiteral("实线"); +} + +} // namespace + +ExceptionDetailDialog::ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo, + const geopro::core::Anomaly& anomaly, QWidget* parent) + : QDialog(parent), repo_(repo), anomaly_(anomaly) { + setWindowTitle(QStringLiteral("标注详情")); + setModal(true); + // 右侧抽屉观感:窄而高(对照原版 ADrawer width=380)。 + resize(geopro::app::scaledPx(380), geopro::app::scaledPx(560)); + + auto* root = formkit::dialogRoot(this); + + // ── 头部:名称(可编辑) + 异常类型(只读) ─────────────────────────────────── + auto* head = formkit::makeEditForm(); + nameEdit_ = new QLineEdit(QString::fromStdString(anomaly_.name), this); + formkit::capField(nameEdit_); + head->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); + auto* typeLabel = new QLabel(QString::fromStdString(anomaly_.typeName), this); + head->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeLabel); + root->addLayout(head); + + // ── 双 Tab:图例信息 / 坐标信息 ─────────────────────────────────────────── + auto* tabs = new QTabWidget(this); + tabs->addTab(buildLegendTab(), QStringLiteral("图例信息")); + tabs->addTab(buildCoordTab(), QStringLiteral("坐标信息")); + root->addWidget(tabs, 1); + + // ── 底部:备注(可编辑) ─────────────────────────────────────────────────── + root->addWidget(new QLabel(QStringLiteral("备注:"), this)); + remarkEdit_ = new QPlainTextEdit(QString::fromStdString(anomaly_.remark), this); + remarkEdit_->setFixedHeight(geopro::app::scaledPx(70)); + root->addWidget(remarkEdit_); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("更新"), this); // 对照原版 ok-text="更新" + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &ExceptionDetailDialog::onConfirm); +} + +QWidget* ExceptionDetailDialog::buildLegendTab() { + auto* tab = new QWidget(this); + auto* form = formkit::makeEditForm(); + + // 类型 + 顶点数/端点数(对照原版:多边形→顶点数,多段线→端点数)。 + const int mt = static_cast(anomaly_.markType); + const QString geoTypeName = mt == 1 ? QStringLiteral("点") + : mt == 3 ? QStringLiteral("多边形") + : mt == 4 ? QStringLiteral("文字") + : QStringLiteral("多段线"); + form->addRow(formkit::editLabel(QStringLiteral("类型")), new QLabel(geoTypeName, tab)); + if (mt == 2 || mt == 3) { + const QString cntLabel = mt == 3 ? QStringLiteral("顶点数") : QStringLiteral("端点数"); + form->addRow(formkit::editLabel(cntLabel), + new QLabel(QString::number(anomaly_.localPts.size()), tab)); + } + + // 线样式(只读展示,对照原版 disabled 控件:线色/线宽/线型)。 + form->addRow(formkit::editLabel(QStringLiteral("线色")), + readonlySwatch(QString::fromStdString(anomaly_.lineColor), tab)); + form->addRow(formkit::editLabel(QStringLiteral("线宽")), + new QLabel(QString::number(anomaly_.lineWidth), tab)); + form->addRow(formkit::editLabel(QStringLiteral("线型")), + new QLabel(lineTypeName(anomaly_.dashed), tab)); + + // 文字类型:另展示字体/字号/字色/不透明度(只读)。 + if (mt == 4) { + form->addRow(formkit::editLabel(QStringLiteral("内容")), + new QLabel(QString::fromStdString(anomaly_.textContent), tab)); + form->addRow(formkit::editLabel(QStringLiteral("字色")), + readonlySwatch(QString::fromStdString(anomaly_.textColor), tab)); + form->addRow(formkit::editLabel(QStringLiteral("字号")), + new QLabel(QString::number(anomaly_.textSize), tab)); + } + + auto* lay = new QVBoxLayout(tab); + lay->addLayout(form); + lay->addStretch(); + return tab; +} + +QWidget* ExceptionDetailDialog::buildCoordTab() { + auto* tab = new QWidget(this); + auto* lay = new QVBoxLayout(tab); + + // 坐标系切换 + 顶点数 + 导出(对照原版坐标信息 tab)。 + auto* topRow = new QHBoxLayout(); + topRow->addWidget(new QLabel(QStringLiteral("坐标系:"), tab)); + coordSysCombo_ = new EmptyAwareComboBox(tab); + coordSysCombo_->addItem(QStringLiteral("图形坐标"), QStringLiteral("jb")); + // 条件显示(对照原版 drawerExceptionInfo:latLon.length===0 → 仅图形坐标;否则三项)。 + // 纯展示响应坐标,不做客户端换算;响应未携带经纬度时退化为仅图形坐标,与原版一致。 + if (!anomaly_.lonLatPts.empty()) { + coordSysCombo_->addItem(QStringLiteral("经纬度坐标"), QStringLiteral("lonlat")); + coordSysCombo_->addItem(QStringLiteral("投影坐标"), QStringLiteral("projection")); + } + topRow->addWidget(coordSysCombo_); + topRow->addStretch(); + vertexCountLabel_ = + new QLabel(QStringLiteral("顶点数:%1").arg(anomaly_.localPts.size()), tab); + topRow->addWidget(vertexCountLabel_); + auto* exportBtn = new QPushButton(QStringLiteral("导出"), tab); + topRow->addWidget(exportBtn); + lay->addLayout(topRow); + + coordTable_ = new QTableWidget(0, 4, tab); + coordTable_->setHorizontalHeaderLabels( + {QStringLiteral("序号"), QStringLiteral("X"), QStringLiteral("Y"), QStringLiteral("Z")}); + coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + coordTable_->setEditTriggers(QAbstractItemView::NoEditTriggers); + lay->addWidget(coordTable_, 1); + + connect(coordSysCombo_, &QComboBox::currentIndexChanged, this, + [this](int) { onCoordSystemChanged(); }); + connect(exportBtn, &QPushButton::clicked, this, &ExceptionDetailDialog::exportCoords); + + onCoordSystemChanged(); // 初次填图形坐标 + return tab; +} + +const std::vector& ExceptionDetailDialog::activeCoords() const { + // 按当前坐标系返回对应点集(对照原版 handleCoordChange:jb=图形 / lonlat=经纬度 / projection=投影)。 + const QString sys = coordSysCombo_ ? coordSysCombo_->currentData().toString() : QString(); + if (sys == QStringLiteral("lonlat")) return anomaly_.lonLatPts; // x=经度 y=纬度 + if (sys == QStringLiteral("projection")) return anomaly_.eastNorthPts; // x=northCoord y=eastCoord + return anomaly_.localPts; // 图形坐标 +} + +void ExceptionDetailDialog::onCoordSystemChanged() { + if (!coordTable_) return; + // 纯展示响应坐标(不做客户端换算):按当前坐标系填表(对照原版 showCoord 重填)。 + const std::vector& pts = activeCoords(); + const int n = static_cast(pts.size()); + coordTable_->setRowCount(n); + for (int r = 0; r < n; ++r) { + coordTable_->setItem(r, 0, new QTableWidgetItem(QString::number(r + 1))); + coordTable_->setItem(r, 1, new QTableWidgetItem(QString::number(pts[r].x, 'f', 7))); + coordTable_->setItem(r, 2, new QTableWidgetItem(QString::number(pts[r].y, 'f', 7))); + coordTable_->setItem(r, 3, new QTableWidgetItem(QString())); // Z 空(对照原版) + } + if (vertexCountLabel_) vertexCountLabel_->setText(QStringLiteral("顶点数:%1").arg(n)); +} + +void ExceptionDetailDialog::exportCoords() { + const std::vector& pts = activeCoords(); + if (pts.empty()) { + QMessageBox::information(this, windowTitle(), QStringLiteral("当前坐标系无可导出的坐标。")); + return; + } + const QString base = QString::fromStdString(anomaly_.name); + const QString path = QFileDialog::getSaveFileName( + this, QStringLiteral("导出坐标"), + (base.isEmpty() ? QStringLiteral("coordinates") : base) + QStringLiteral(".txt"), + QStringLiteral("Text (*.txt)")); + if (path.isEmpty()) return; + QFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("无法写入文件")); + return; + } + QTextStream ts(&f); + // 对照原版:TSV「序号\tX\tY\tZ」,X/Y 7位小数,Z 空。 + ts << QStringLiteral("序号\tX\tY\tZ\n"); + for (int i = 0; i < static_cast(pts.size()); ++i) { + ts << (i + 1) << '\t' << QString::number(pts[i].x, 'f', 7) << '\t' + << QString::number(pts[i].y, 'f', 7) << "\t\n"; + } + f.close(); +} + +void ExceptionDetailDialog::onConfirm() { + if (!repo_ || anomaly_.id.empty()) { reject(); return; } + const QString name = nameEdit_->text().trimmed(); + if (name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); + return; + } + // 对照原版 drawerExceptionInfo onOk:图例样式控件 disabled、不发 legend, + // 仅 PUT {id, exceptionName, remark}。 + QJsonObject body{ + {QStringLiteral("id"), QString::fromStdString(anomaly_.id)}, + {QStringLiteral("exceptionName"), name}, + {QStringLiteral("remark"), remarkEdit_->toPlainText()}, + }; + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->updateException(body, [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("更新失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDetailDialog.hpp b/src/app/panels/chart/ExceptionDetailDialog.hpp new file mode 100644 index 0000000..b6644eb --- /dev/null +++ b/src/app/panels/chart/ExceptionDetailDialog.hpp @@ -0,0 +1,53 @@ +#pragma once +#include +#include + +#include "model/Anomaly.hpp" + +class QLineEdit; +class QPlainTextEdit; +class QComboBox; +class QTableWidget; +class QPushButton; +class QLabel; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 异常详情/编辑对话框(I11,复刻原版 drawerExceptionInfo 右侧抽屉形态): +// 双 Tab「图例信息 / 坐标信息」。 +// - 头部:名称(可编辑) + 异常类型(只读)。 +// - 图例信息:类型 + 顶点/端点数 + 线色/线宽/线型/不透明度(全部「只读展示」,对照原版 disabled)。 +// - 坐标信息:坐标系切换(图形/经纬度/投影) + 顶点数 + 坐标表(7位小数) + 导出 txt。 +// - 底部:备注(可编辑)。 +// 确认 → updateException(PUT body 仅 {id, exceptionName, remark},与原版一致;线样式只读不发)。 +class ExceptionDetailDialog : public QDialog { + Q_OBJECT +public: + ExceptionDetailDialog(geopro::data::IDatasetCommandRepository* repo, + const geopro::core::Anomaly& anomaly, QWidget* parent = nullptr); + +private: + void onConfirm(); + void onCoordSystemChanged(); // 切换坐标系 → 按对应点集重填坐标表(图形/经纬度/投影) + void exportCoords(); // 导出当前坐标系坐标为 txt(7位小数) + // 当前坐标系对应的点集(jb=图形 / lonlat=经纬度 / projection=投影;纯展示响应数据,不换算)。 + const std::vector& activeCoords() const; + QWidget* buildLegendTab(); // 图例信息 Tab(只读样式) + QWidget* buildCoordTab(); // 坐标信息 Tab + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + geopro::core::Anomaly anomaly_; // 拷贝(不可变范式:编辑后组装新 body,不改原对象) + + QLineEdit* nameEdit_ = nullptr; + QPlainTextEdit* remarkEdit_ = nullptr; + QComboBox* coordSysCombo_ = nullptr; // jb 图形 / lonlat 经纬度 / projection 投影 + QTableWidget* coordTable_ = nullptr; + QLabel* vertexCountLabel_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDialog.cpp b/src/app/panels/chart/ExceptionDialog.cpp new file mode 100644 index 0000000..08ec595 --- /dev/null +++ b/src/app/panels/chart/ExceptionDialog.cpp @@ -0,0 +1,268 @@ +#include "panels/chart/ExceptionDialog.hpp" + +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" +#include "panels/chart/ExceptionTypeDialog.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +// 标注类型 → 最少坐标点数(点/文字=1,线≥2,面≥3)。 +int minPoints(const QString& markType) { + if (markType == QStringLiteral("2")) return 2; + if (markType == QStringLiteral("3")) return 3; + return 1; // 点("1")/文字("4") +} +} // namespace + +ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, + QString remarkSourceId, QWidget* parent) + : QDialog(parent), + repo_(repo), + projectId_(std::move(projectId)), + remarkSourceId_(std::move(remarkSourceId)) { + setWindowTitle(QStringLiteral("新建异常")); + setModal(true); + resize(440, 480); + + auto* root = formkit::dialogRoot(this); + + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + auto* form = formkit::makeEditForm(); + + // 标注类型(remarkSourceType "1".."4",与原版 annotationType 一致)。 + markTypeCombo_ = new EmptyAwareComboBox(this); + markTypeCombo_->addItem(QStringLiteral("点"), QStringLiteral("1")); + markTypeCombo_->addItem(QStringLiteral("线"), QStringLiteral("2")); + markTypeCombo_->addItem(QStringLiteral("面"), QStringLiteral("3")); + markTypeCombo_->addItem(QStringLiteral("文字"), QStringLiteral("4")); + formkit::capField(markTypeCombo_); + form->addRow(formkit::editLabel(QStringLiteral("标注类型")), markTypeCombo_); + + // 异常类型行:下拉 + 「新增异常类型」按钮(对照原版 exceptionDialog 同行布局)。 + // 空态感知下拉:异常类型异步加载(loadExceptionTypes),未选显占位、无数据弹「暂无数据」。 + exceptionTypeCombo_ = formkit::comboBox(QStringLiteral("请选择异常类型"), this); + formkit::capField(exceptionTypeCombo_); + addTypeBtn_ = new QPushButton(QStringLiteral("新增异常类型"), this); + auto* typeRow = new QWidget(this); + auto* typeRowLay = new QHBoxLayout(typeRow); + typeRowLay->setContentsMargins(0, 0, 0, 0); + typeRowLay->addWidget(exceptionTypeCombo_, 1); + typeRowLay->addWidget(addTypeBtn_); + form->addRow(formkit::editLabel(QStringLiteral("异常类型")), typeRow); + connect(addTypeBtn_, &QPushButton::clicked, this, &ExceptionDialog::onAddType); + + nameEdit_ = new QLineEdit(this); + nameEdit_->setPlaceholderText(QStringLiteral("数据名称+异常类型代号+序号")); + formkit::capField(nameEdit_); + form->addRow(formkit::editLabel(QStringLiteral("名称")), nameEdit_); + + remarkEdit_ = new QPlainTextEdit(this); + remarkEdit_->setFixedHeight(geopro::app::scaledPx(60)); + formkit::capField(remarkEdit_); + form->addRow(formkit::editLabel(QStringLiteral("备注")), remarkEdit_); + cardLay->addLayout(form); + root->addWidget(card); + + // 坐标兜底表(x/y 多行):留空 → 确定后在图上绘形采集(主路径);手填 → 直接提交。 + root->addWidget(new QLabel(QStringLiteral("坐标(x,y,留空则在图上绘制):"), this)); + coordTable_ = new QTableWidget(0, 2, this); + coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); + coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + root->addWidget(coordTable_, 1); + + auto* rowBtns = new QHBoxLayout(); + auto* addRow = new QPushButton(QStringLiteral("加一行"), this); + auto* delRow = new QPushButton(QStringLiteral("删一行"), this); + rowBtns->addWidget(addRow); + rowBtns->addWidget(delRow); + rowBtns->addStretch(); + root->addLayout(rowBtns); + connect(addRow, &QPushButton::clicked, this, + [this]() { coordTable_->insertRow(coordTable_->rowCount()); }); + connect(delRow, &QPushButton::clicked, this, [this]() { + if (coordTable_->rowCount() > 0) coordTable_->removeRow(coordTable_->rowCount() - 1); + }); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &ExceptionDialog::onConfirm); + connect(markTypeCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onTypeChanged(); }); + connect(exceptionTypeCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { suggestName(); }); + + onTypeChanged(); // 初始:拉首个类型的异常类型列表 + 铺最少坐标行 +} + +QString ExceptionDialog::markTypeValue() const { + return markTypeCombo_->currentData().toString(); +} + +QString ExceptionDialog::exceptionTypeId() const { + return exceptionTypeCombo_->currentData().toString(); +} + +QString ExceptionDialog::exceptionName() const { + return nameEdit_->text().trimmed(); +} + +QString ExceptionDialog::exceptionRemark() const { + return remarkEdit_->toPlainText(); +} + +QJsonArray ExceptionDialog::manualCoordinates() const { + QJsonArray coords; + for (int r = 0; r < coordTable_->rowCount(); ++r) { + auto* ix = coordTable_->item(r, 0); + auto* iy = coordTable_->item(r, 1); + if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue; + bool okx = false, oky = false; + const double x = ix->text().toDouble(&okx); + const double y = iy->text().toDouble(&oky); + if (!okx || !oky) continue; + coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}}); + } + return coords; +} + +void ExceptionDialog::onTypeChanged() { + // 对照原版 handleAnnotationTypeChange:标注类型变 → 清空名称(待重选类型后回填)+ + // 重拉对应几何形态的异常类型列表 + 刷新「新增类型」按钮可用性。 + nameEdit_->clear(); + updateAddTypeEnabled(); + loadExceptionTypes(); +} + +void ExceptionDialog::onAddType() { + if (!repo_) return; + // 完整复刻原版:打开「标注类型」对话框(异常属性 + 标注名称双 Tab + 按 markType 图例编辑器), + // 内部走 addExceptionType;成功后回此处刷新异常类型下拉并按名称选中新建项(对照原版 emit ok)。 + ExceptionTypeDialog typeDlg(repo_, projectId_, markTypeValue(), this); + if (typeDlg.exec() != QDialog::Accepted) return; + + pendingSelectTypeName_ = typeDlg.createdTypeName(); // 重拉列表后按名称匹配选中 + loadExceptionTypes(); +} + +void ExceptionDialog::updateAddTypeEnabled() { + if (!addTypeBtn_) return; + // 原版:文字类型(4) 或 未选标注类型时禁用「新增异常类型」。 + const QString mt = markTypeValue(); + addTypeBtn_->setEnabled(!mt.isEmpty() && mt != QStringLiteral("4")); +} + +void ExceptionDialog::loadExceptionTypes() { + if (!repo_) return; + QPointer self(this); + repo_->listExceptionTypes( + projectId_, markTypeValue(), [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->exceptionTypeCombo_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + // 兼容 {label,value} 与 {exceptionTypeName,id} 两种返回形态。 + QString label = o.value(QStringLiteral("label")).toString(); + if (label.isEmpty()) label = o.value(QStringLiteral("exceptionTypeName")).toString(); + QString id = o.value(QStringLiteral("value")).toString(); + if (id.isEmpty()) id = o.value(QStringLiteral("id")).toString(); + if (!id.isEmpty()) self->exceptionTypeCombo_->addItem(label, id); + } + // 若刚新建了类型 → 按名称匹配选中(找不到则保持默认首项,不报错)。 + if (!self->pendingSelectTypeName_.isEmpty()) { + const int idx = self->exceptionTypeCombo_->findText(self->pendingSelectTypeName_); + if (idx >= 0) self->exceptionTypeCombo_->setCurrentIndex(idx); + self->pendingSelectTypeName_.clear(); + } + if (self->exceptionTypeCombo_->count() > 0) self->suggestName(); + }); +} + +void ExceptionDialog::suggestName() { + if (!repo_) return; + const QString typeId = exceptionTypeCombo_->currentData().toString(); + if (typeId.isEmpty()) return; + QPointer self(this); + // 对照原版 handleExceptionTypeChange:每次选/换异常类型都回填名称(res.data 为纯字符串)。 + repo_->getExceptionName(typeId, remarkSourceId_, [self](bool ok, QString name, QString) { + if (!self || !ok) return; + self->nameEdit_->setText(name); + }); +} + +void ExceptionDialog::onConfirm() { + if (!repo_) { reject(); return; } + const QString name = nameEdit_->text().trimmed(); + const QString typeId = exceptionTypeCombo_->currentData().toString(); + if (name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); + return; + } + if (typeId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); + return; + } + // 主路径:坐标表留空 → accept(),由调用方在图上绘形采集坐标后 newException。 + const QJsonArray coords = manualCoordinates(); + if (coords.isEmpty()) { accept(); return; } + + // 兜底路径:用户手填了坐标 → 校验点数后直接弹窗内提交。 + if (coords.size() < minPoints(markTypeValue())) { + QMessageBox::warning(this, windowTitle(), + QStringLiteral("坐标点数不足(点/文字≥1,线≥2,面≥3)")); + return; + } + + QJsonObject body{ + {QStringLiteral("exceptionName"), name}, + {QStringLiteral("exceptionTypeId"), typeId}, + {QStringLiteral("remark"), remarkEdit_->toPlainText()}, + {QStringLiteral("remarkSourceType"), markTypeValue()}, // 几何形态字符串 + {QStringLiteral("remarkSourceId"), remarkSourceId_}, // = dsObjectId + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}}, + }; + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->newException(body, [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("创建失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionDialog.hpp b/src/app/panels/chart/ExceptionDialog.hpp new file mode 100644 index 0000000..3a89883 --- /dev/null +++ b/src/app/panels/chart/ExceptionDialog.hpp @@ -0,0 +1,63 @@ +#pragma once +#include + +#include +#include +#include +#include + +class QComboBox; +class QLineEdit; +class QPlainTextEdit; +class QTableWidget; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 异常创建对话框(I9,复刻原版 exceptionDialog 时序): +// 先弹窗选 标注类型(点/线/面/文字 → remarkSourceType "1".."4") + 异常类型(listExceptionTypes) +// + 名称(getExceptionName 建议) + 备注;确认后由调用方在图上交互绘形采集坐标,再 newException。 +// 时序对照原版 contourPage/exceptionDialog:弹窗仅收元信息(不收坐标),accept() 后调用方读 +// markTypeValue()/exceptionTypeId()/name()/remark() 启动 ContourDrawTool 绘形 → 完成提交。 +// 兜底:坐标表仍保留(无法图上绘形时可手填),有有效坐标行则直接走旧版「弹窗内提交」链路。 +class ExceptionDialog : public QDialog { + Q_OBJECT +public: + ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, + QString remarkSourceId, QWidget* parent = nullptr); + + // accept() 后供调用方读取(驱动图上绘形 + newException)。 + QString markTypeValue() const; // 标注类型 "1".."4"(remarkSourceType) + QString exceptionTypeId() const; // 异常类型 id + QString exceptionName() const; // 名称 + QString exceptionRemark() const; // 备注 + // 兜底坐标(用户在表里手填的有效行);空 = 走图上绘形主路径。 + QJsonArray manualCoordinates() const; + +private: + void onTypeChanged(); // 标注类型变 → 清名称 + 重拉异常类型列表 + 刷新「新增类型」可用性 + void onAddType(); // 「新增异常类型」:打开 ExceptionTypeDialog(双 Tab) → addExceptionType → 刷新+选中 + void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) + void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称回填 + void onConfirm(); // 校验 → 有手填坐标则直接 newException,否则 accept() 交给绘形 + void updateAddTypeEnabled(); // 「新增异常类型」可用性:文字类型/未选类型时禁用(对照原版) + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString projectId_; + QString remarkSourceId_; + + QComboBox* markTypeCombo_ = nullptr; // userData = "1".."4" + QComboBox* exceptionTypeCombo_ = nullptr; // userData = 异常类型 id + QPushButton* addTypeBtn_ = nullptr; // 新增异常类型(对照原版,文字/未选类型禁用) + QString pendingSelectTypeName_; // 新建类型后待选中的类型名(下一次列表刷新时匹配选中) + QLineEdit* nameEdit_ = nullptr; + QPlainTextEdit* remarkEdit_ = nullptr; + QTableWidget* coordTable_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionTextDialog.cpp b/src/app/panels/chart/ExceptionTextDialog.cpp new file mode 100644 index 0000000..7d83369 --- /dev/null +++ b/src/app/panels/chart/ExceptionTextDialog.cpp @@ -0,0 +1,115 @@ +#include "panels/chart/ExceptionTextDialog.hpp" + +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" + +namespace geopro::app { + +ExceptionTextDialog::ExceptionTextDialog(QWidget* parent) + : QDialog(parent), color_(QStringLiteral("#000000")) { + setWindowTitle(QStringLiteral("文本编辑")); + setModal(true); + resize(geopro::app::scaledPx(560), geopro::app::scaledPx(420)); + + auto* root = formkit::dialogRoot(this); + auto* card = formkit::formCard(this); + auto* cardLay = formkit::cardBody(card); + auto* form = formkit::makeEditForm(); + + // 字体(对照原版 1宋体/2微软雅黑/3黑体/4楷体,值为字符串 int)。 + fontCombo_ = new EmptyAwareComboBox(this); + fontCombo_->addItem(QStringLiteral("宋体"), QStringLiteral("1")); + fontCombo_->addItem(QStringLiteral("微软雅黑"), QStringLiteral("2")); + fontCombo_->addItem(QStringLiteral("黑体"), QStringLiteral("3")); + fontCombo_->addItem(QStringLiteral("楷体"), QStringLiteral("4")); + formkit::capField(fontCombo_); + form->addRow(formkit::editLabel(QStringLiteral("字体")), fontCombo_); + + // 大小(px,默认 12)。 + sizeSpin_ = new QSpinBox(this); + sizeSpin_->setRange(1, 200); + sizeSpin_->setValue(12); + formkit::capField(sizeSpin_); + form->addRow(formkit::editLabel(QStringLiteral("大小")), sizeSpin_); + + // 颜色(默认黑)。 + colorBtn_ = new QPushButton(color_, this); + colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_)); + connect(colorBtn_, &QPushButton::clicked, this, &ExceptionTextDialog::pickColor); + formkit::capField(colorBtn_); + form->addRow(formkit::editLabel(QStringLiteral("颜色")), colorBtn_); + + // 不透明度(0–100%,默认 100)。 + auto* opRow = new QWidget(this); + auto* opLay = new QHBoxLayout(opRow); + opLay->setContentsMargins(0, 0, 0, 0); + opacitySlider_ = new QSlider(Qt::Horizontal, opRow); + opacitySlider_->setRange(0, 100); + opacitySlider_->setValue(100); + opacityLabel_ = new QLabel(QStringLiteral("100%"), opRow); + opacityLabel_->setFixedWidth(geopro::app::scaledPx(40)); + connect(opacitySlider_, &QSlider::valueChanged, this, + [this](int v) { opacityLabel_->setText(QStringLiteral("%1%").arg(v)); }); + opLay->addWidget(opacitySlider_, 1); + opLay->addWidget(opacityLabel_); + form->addRow(formkit::editLabel(QStringLiteral("不透明度")), opRow); + cardLay->addLayout(form); + + // 内容(必填)。 + cardLay->addWidget(new QLabel(QStringLiteral("内容:"), this)); + contentEdit_ = new QPlainTextEdit(this); + contentEdit_->setPlaceholderText(QStringLiteral("请输入内容")); + contentEdit_->setFixedHeight(geopro::app::scaledPx(140)); + cardLay->addWidget(contentEdit_); + root->addWidget(card); + + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + auto* okBtn = new QPushButton(QStringLiteral("确定"), this); + okBtn->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn, &QPushButton::clicked, this, &ExceptionTextDialog::onConfirm); +} + +void ExceptionTextDialog::pickColor() { + const QColor c = QColorDialog::getColor(QColor(color_), this, QStringLiteral("颜色")); + if (!c.isValid()) return; + color_ = c.name(QColor::HexRgb); + colorBtn_->setText(color_); + colorBtn_->setStyleSheet(QStringLiteral("background:%1;").arg(color_)); +} + +QString ExceptionTextDialog::fontFamilyValue() const { return fontCombo_->currentData().toString(); } +int ExceptionTextDialog::fontSize() const { return sizeSpin_->value(); } +QString ExceptionTextDialog::color() const { return color_; } +int ExceptionTextDialog::opacityPercent() const { return opacitySlider_->value(); } +QString ExceptionTextDialog::content() const { return contentEdit_->toPlainText().trimmed(); } + +void ExceptionTextDialog::onConfirm() { + if (content().isEmpty()) { // 对照原版:内容必填。 + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入文本内容")); + return; + } + accept(); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionTextDialog.hpp b/src/app/panels/chart/ExceptionTextDialog.hpp new file mode 100644 index 0000000..9f75ce4 --- /dev/null +++ b/src/app/panels/chart/ExceptionTextDialog.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +class QComboBox; +class QSpinBox; +class QSlider; +class QPlainTextEdit; +class QPushButton; +class QLabel; + +namespace geopro::app { + +// 文字标注编辑对话框(I9,复刻原版 exceptionText.vue「文本编辑」): +// 字体(1宋体/2微软雅黑/3黑体/4楷体) / 大小(px) / 颜色 / 不透明度(0–100%) / 内容(必填)。 +// 确定 → accept(),调用方读取各字段组装 newException 的 customLegend。 +// 时序对照原版:文字类型绘制落点后弹此对话框,提交带 customLegend +// {text, content, color, size, font(CSS族), opacity(0–1)}。 +class ExceptionTextDialog : public QDialog { + Q_OBJECT +public: + explicit ExceptionTextDialog(QWidget* parent = nullptr); + + // accept() 后供调用方读取。 + QString fontFamilyValue() const; // "1".."4"(字体族 int 字符串) + int fontSize() const; // px + QString color() const; // #rrggbb + int opacityPercent() const; // 0–100 + QString content() const; // 文字内容 + +private: + void onConfirm(); // 校验内容非空 → accept() + void pickColor(); + + QComboBox* fontCombo_ = nullptr; + QSpinBox* sizeSpin_ = nullptr; + QPushButton* colorBtn_ = nullptr; + QString color_; // 当前色 #rrggbb + QSlider* opacitySlider_ = nullptr; + QLabel* opacityLabel_ = nullptr; + QPlainTextEdit* contentEdit_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionTypeDialog.cpp b/src/app/panels/chart/ExceptionTypeDialog.cpp new file mode 100644 index 0000000..2828d02 --- /dev/null +++ b/src/app/panels/chart/ExceptionTypeDialog.cpp @@ -0,0 +1,415 @@ +#include "panels/chart/ExceptionTypeDialog.hpp" + +#include + +#include +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "Theme.hpp" // scaledPx +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +// 图例选项(对照原版 ExceptionLabel/const.js 的启用项,value 与原版一致)。 +struct Opt { + const char* value; + const char* label; +}; +const Opt kPointShapes[] = {{"circle", "圆点"}, {"diamond", "方块"}, {"triangle", "三角形"}}; +const Opt kLineShapes[] = {{"dash", "虚线"}, {"solid", "实线"}}; +const Opt kSurfaceFills[] = { + {"/", "斜线"}, {"+", "正交网格"}, {".", "圆点阵列"}, {"", "颜色填充"}}; +const Opt kTextFonts[] = {{"1", "宋体"}, {"2", "微软雅黑"}, {"3", "黑体"}, {"4", "楷体"}}; + +// 用 Opt 表填充下拉(userData = value 字符串),并按 value 选中默认项。 +template +void fillCombo(QComboBox* c, const Opt (&opts)[N], const QString& defValue) { + for (const Opt& o : opts) c->addItem(QString::fromUtf8(o.label), QString::fromUtf8(o.value)); + const int idx = c->findData(defValue); + if (idx >= 0) c->setCurrentIndex(idx); +} + +// 不透明度 0–100 spin。 +QSpinBox* makeOpacity(int def) { + auto* s = new QSpinBox(); + s->setRange(0, 100); + s->setValue(def); + s->setSuffix(QStringLiteral("%")); + return s; +} +} // namespace + +ExceptionTypeDialog::ExceptionTypeDialog(geopro::data::IDatasetCommandRepository* repo, + QString projectId, QString markType, QWidget* parent) + : QDialog(parent), + repo_(repo), + projectId_(std::move(projectId)), + markType_(std::move(markType)), + pointColor_(Qt::black), + polylineColor_(Qt::black), + polygonFillColor_(Qt::black), + textColor_(Qt::black) { + markTypeInt_ = markType_.toInt(); + if (markTypeInt_ < 1 || markTypeInt_ > 4) markTypeInt_ = 1; + + setWindowTitle(QStringLiteral("标注类型")); + setModal(true); + resize(geopro::app::scaledPx(880), geopro::app::scaledPx(560)); + + auto* root = formkit::dialogRoot(this); + + // 顶部 RadioGroup(按钮态) 双 Tab:异常属性 / 标注名称(对照原版 RadioGroup type=button)。 + auto* tabRow = new QHBoxLayout(); + auto* attrTab = new QRadioButton(QStringLiteral("异常属性"), this); + auto* nameTab = new QRadioButton(QStringLiteral("标注名称"), this); + attrTab->setChecked(true); + auto* grp = new QButtonGroup(this); + grp->addButton(attrTab, 0); + grp->addButton(nameTab, 1); + tabRow->addWidget(attrTab); + tabRow->addWidget(nameTab); + tabRow->addStretch(); + root->addLayout(tabRow); + + stack_ = new QStackedWidget(this); + auto* attrPage = new QWidget(stack_); + auto* namePage = new QWidget(stack_); + buildAttributeTab(attrPage); + buildNameTab(namePage); + stack_->addWidget(attrPage); + stack_->addWidget(namePage); + root->addWidget(stack_, 1); + connect(grp, &QButtonGroup::idClicked, this, [this](int id) { stack_->setCurrentIndex(id); }); + + auto* buttons = formkit::addDialogButtons(root, this, QStringLiteral("确定"), + QStringLiteral("取消")); + // 接管 OK:改走 onSubmit(addDialogButtons 默认接的 accept 会被 onSubmit 内部按需调用)。 + disconnect(buttons, nullptr, this, nullptr); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, this, &ExceptionTypeDialog::onSubmit); +} + +// ── 异常属性 Tab ────────────────────────────────────────────────────────────── +void ExceptionTypeDialog::buildAttributeTab(QWidget* page) { + auto* lay = new QVBoxLayout(page); + lay->setContentsMargins(0, 0, 0, 0); + + auto* form = formkit::makeEditForm(); + nameEdit_ = new QLineEdit(page); + nameEdit_->setPlaceholderText(QStringLiteral("请输入异常类型名称")); + form->addRow(formkit::editLabel(QStringLiteral("异常类型名称")), nameEdit_); + codeEdit_ = new QLineEdit(page); + codeEdit_->setPlaceholderText(QStringLiteral("请输入")); + form->addRow(formkit::editLabel(QStringLiteral("代号")), codeEdit_); + standardNumberEdit_ = new QLineEdit(page); + standardNumberEdit_->setPlaceholderText(QStringLiteral("请输入")); + form->addRow(formkit::editLabel(QStringLiteral("标准编号")), standardNumberEdit_); + standardNameEdit_ = new QLineEdit(page); + standardNameEdit_->setPlaceholderText(QStringLiteral("请输入")); + form->addRow(formkit::editLabel(QStringLiteral("标准名称")), standardNameEdit_); + descEdit_ = new QPlainTextEdit(page); + descEdit_->setFixedHeight(geopro::app::scaledPx(56)); + form->addRow(formkit::editLabel(QStringLiteral("说明")), descEdit_); + lay->addLayout(form); + + // 图例样式:按 markType 选择性显示(对照原版 ACollapseItem v-if 条件)。 + if (markTypeInt_ == 1) lay->addWidget(buildPointLegend()); + if (markTypeInt_ == 2 || markTypeInt_ == 3 || markTypeInt_ == 4) + lay->addWidget(buildPolylineLegend()); + if (markTypeInt_ == 3 || markTypeInt_ == 4) lay->addWidget(buildPolygonLegend()); + lay->addWidget(buildTextLegend()); // 文字图例对所有 markType 显示 + lay->addStretch(); +} + +QPushButton* ExceptionTypeDialog::makeColorSwatch(QColor& target) { + auto* btn = new QPushButton(this); + btn->setText(target.name(QColor::HexArgb)); + btn->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);") + .arg(target.red()) + .arg(target.green()) + .arg(target.blue()) + .arg(target.alpha())); + connect(btn, &QPushButton::clicked, this, [this, btn, &target]() { pickColor(btn, target); }); + return btn; +} + +void ExceptionTypeDialog::pickColor(QPushButton* swatch, QColor& target) { + const QColor picked = + QColorDialog::getColor(target, this, QStringLiteral("颜色"), QColorDialog::ShowAlphaChannel); + if (!picked.isValid()) return; + target = picked; + swatch->setText(picked.name(QColor::HexArgb)); + swatch->setStyleSheet(QStringLiteral("background-color: rgba(%1,%2,%3,%4);") + .arg(picked.red()) + .arg(picked.green()) + .arg(picked.blue()) + .arg(picked.alpha())); +} + +QGroupBox* ExceptionTypeDialog::buildPointLegend() { + auto* box = new QGroupBox(QStringLiteral("点"), this); + auto* form = formkit::makeEditForm(); + pointShape_ = new EmptyAwareComboBox(box); + fillCombo(pointShape_, kPointShapes, QStringLiteral("circle")); + form->addRow(formkit::editLabel(QStringLiteral("形状:")), pointShape_); + pointSize_ = new QSpinBox(box); + pointSize_->setRange(1, 18); + pointSize_->setValue(8); + form->addRow(formkit::editLabel(QStringLiteral("大小:")), pointSize_); + pointColorBtn_ = makeColorSwatch(pointColor_); + form->addRow(formkit::editLabel(QStringLiteral("颜色:")), pointColorBtn_); + pointOpacity_ = makeOpacity(100); + form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), pointOpacity_); + box->setLayout(form); + return box; +} + +QGroupBox* ExceptionTypeDialog::buildPolylineLegend() { + auto* box = new QGroupBox(QStringLiteral("多段线"), this); + auto* form = formkit::makeEditForm(); + polylineShape_ = new EmptyAwareComboBox(box); + fillCombo(polylineShape_, kLineShapes, QStringLiteral("solid")); + form->addRow(formkit::editLabel(QStringLiteral("线形:")), polylineShape_); + polylineWidth_ = new QSpinBox(box); + polylineWidth_->setRange(1, 10); + polylineWidth_->setValue(1); + form->addRow(formkit::editLabel(QStringLiteral("线宽:")), polylineWidth_); + polylineColorBtn_ = makeColorSwatch(polylineColor_); + form->addRow(formkit::editLabel(QStringLiteral("颜色:")), polylineColorBtn_); + polylineOpacity_ = makeOpacity(100); + form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), polylineOpacity_); + box->setLayout(form); + return box; +} + +QGroupBox* ExceptionTypeDialog::buildPolygonLegend() { + auto* box = new QGroupBox(QStringLiteral("多边形"), this); + auto* form = formkit::makeEditForm(); + polygonFill_ = new EmptyAwareComboBox(box); + fillCombo(polygonFill_, kSurfaceFills, QString()); // 默认 '' = 颜色填充 + form->addRow(formkit::editLabel(QStringLiteral("填充图例:")), polygonFill_); + polygonFillColorBtn_ = makeColorSwatch(polygonFillColor_); + form->addRow(formkit::editLabel(QStringLiteral("颜色:")), polygonFillColorBtn_); + polygonFillOpacity_ = makeOpacity(100); + form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), polygonFillOpacity_); + box->setLayout(form); + return box; +} + +QGroupBox* ExceptionTypeDialog::buildTextLegend() { + auto* box = new QGroupBox(QStringLiteral("文字"), this); + auto* form = formkit::makeEditForm(); + textFont_ = new EmptyAwareComboBox(box); + fillCombo(textFont_, kTextFonts, QStringLiteral("1")); // 默认 1=宋体 + form->addRow(formkit::editLabel(QStringLiteral("字体:")), textFont_); + textSize_ = new QSpinBox(box); + textSize_->setRange(8, 24); + textSize_->setValue(12); + form->addRow(formkit::editLabel(QStringLiteral("大小:")), textSize_); + textColorBtn_ = makeColorSwatch(textColor_); + form->addRow(formkit::editLabel(QStringLiteral("颜色:")), textColorBtn_); + textOpacity_ = makeOpacity(100); + form->addRow(formkit::editLabel(QStringLiteral("不透明度:")), textOpacity_); + box->setLayout(form); + return box; +} + +// ── 标注名称 Tab ────────────────────────────────────────────────────────────── +void ExceptionTypeDialog::buildNameTab(QWidget* page) { + auto* lay = new QVBoxLayout(page); + lay->setContentsMargins(0, 0, 0, 0); + + auto* form = formkit::makeEditForm(); + customFormatEdit_ = new QLineEdit(page); + customFormatEdit_->setReadOnly(true); + customFormatEdit_->setPlaceholderText(QStringLiteral("请输入自定义格式描述")); + form->addRow(formkit::editLabel(QStringLiteral("自定义格式描述")), customFormatEdit_); + separatorEdit_ = new QLineEdit(page); + separatorEdit_->setPlaceholderText(QStringLiteral("请输入分隔符号")); + form->addRow(formkit::editLabel(QStringLiteral("分隔符号")), separatorEdit_); + lay->addLayout(form); + connect(separatorEdit_, &QLineEdit::textChanged, this, + [this](const QString&) { onSeparatorChanged(); }); + + lay->addWidget(new QLabel(QStringLiteral("列表:"), page)); + nameTable_ = new QTableWidget(0, 2, page); + nameTable_->setHorizontalHeaderLabels({QStringLiteral("名称"), QStringLiteral("代号")}); + nameTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + lay->addWidget(nameTable_, 1); + // 任意单元格改动(含名称编辑)→ 重算自定义格式描述。 + connect(nameTable_, &QTableWidget::itemChanged, this, + [this](QTableWidgetItem*) { recomputeCustomFormat(); }); + + auto* rowBtns = new QHBoxLayout(); + auto* addBtn = new QPushButton(QStringLiteral("新增"), page); + auto* delBtn = new QPushButton(QStringLiteral("删除"), page); + rowBtns->addWidget(addBtn); + rowBtns->addWidget(delBtn); + rowBtns->addStretch(); + lay->addLayout(rowBtns); + connect(addBtn, &QPushButton::clicked, this, &ExceptionTypeDialog::addNameRow); + connect(delBtn, &QPushButton::clicked, this, &ExceptionTypeDialog::delNameRow); +} + +void ExceptionTypeDialog::onSeparatorChanged() { recomputeCustomFormat(); } + +void ExceptionTypeDialog::recomputeCustomFormat() { + // 对照原版:自定义格式描述 = 名称列按分隔符号拼接(原版按选中行,此处按全部名称行)。 + if (!nameTable_ || !customFormatEdit_) return; + const QString sep = separatorEdit_ ? separatorEdit_->text() : QString(); + QStringList names; + for (int r = 0; r < nameTable_->rowCount(); ++r) { + auto* it = nameTable_->item(r, 0); + if (it && !it->text().trimmed().isEmpty()) names << it->text().trimmed(); + } + customFormatEdit_->setText(names.join(sep)); +} + +QString ExceptionTypeDialog::nextFieldCode() const { + // 自动代号 custom_N(N 取当前最大序号 +1,避免与已填代号冲突)。 + int maxN = 0; + for (int r = 0; r < nameTable_->rowCount(); ++r) { + auto* it = nameTable_->item(r, 1); + if (!it) continue; + const QString code = it->text(); + if (code.startsWith(QStringLiteral("custom_"))) { + const int n = code.mid(7).toInt(); + if (n > maxN) maxN = n; + } + } + return QStringLiteral("custom_%1").arg(maxN + 1); +} + +void ExceptionTypeDialog::addNameRow() { + const int r = nameTable_->rowCount(); + nameTable_->insertRow(r); + nameTable_->setItem(r, 0, new QTableWidgetItem(QString())); + // 代号默认自动生成,用户可手填覆盖。 + nameTable_->setItem(r, 1, new QTableWidgetItem(nextFieldCode())); +} + +void ExceptionTypeDialog::delNameRow() { + const int r = nameTable_->currentRow(); + if (r >= 0) { + nameTable_->removeRow(r); + recomputeCustomFormat(); + } +} + +// ── 提交 ──────────────────────────────────────────────────────────────────── +QJsonObject ExceptionTypeDialog::buildLegend() const { + // 对照原版 form.legend:整对象提交(与 markType 无关,全字段都带,rgba 用 alpha 0–1 不同, + // 这里沿用色块的 HexArgb 颜色串 + 0–100 不透明度,与原版控件取值一致)。 + auto hex = [](const QColor& c) { return c.name(QColor::HexArgb); }; + return QJsonObject{ + {QStringLiteral("pointShape"), + pointShape_ ? pointShape_->currentData().toString() : QStringLiteral("circle")}, + {QStringLiteral("pointSize"), pointSize_ ? pointSize_->value() : 8}, + {QStringLiteral("pointColor"), hex(pointColor_)}, + {QStringLiteral("pointNoOpacity"), pointOpacity_ ? pointOpacity_->value() : 100}, + {QStringLiteral("polylineShape"), + polylineShape_ ? polylineShape_->currentData().toString() : QStringLiteral("solid")}, + {QStringLiteral("polylineWidth"), polylineWidth_ ? polylineWidth_->value() : 1}, + {QStringLiteral("polylineColor"), hex(polylineColor_)}, + {QStringLiteral("polylineNoOpacity"), polylineOpacity_ ? polylineOpacity_->value() : 100}, + {QStringLiteral("polygonFill"), + polygonFill_ ? polygonFill_->currentData().toString() : QString()}, + {QStringLiteral("polygonFillColor"), hex(polygonFillColor_)}, + {QStringLiteral("polygonFillNoOpacity"), + polygonFillOpacity_ ? polygonFillOpacity_->value() : 100}, + {QStringLiteral("textFont"), textFont_ ? textFont_->currentData().toString() : QString("1")}, + {QStringLiteral("textSize"), textSize_ ? textSize_->value() : 12}, + {QStringLiteral("textColor"), hex(textColor_)}, + {QStringLiteral("textNoOpacity"), textOpacity_ ? textOpacity_->value() : 100}, + }; +} + +QJsonObject ExceptionTypeDialog::buildFormData() const { + // exceptionNameList:表格行 → {fieldName, fieldCode, sort}(对照原版 handleBeforeOk 映射)。 + QJsonArray nameList; + for (int r = 0; r < nameTable_->rowCount(); ++r) { + auto* nameItem = nameTable_->item(r, 0); + if (!nameItem || nameItem->text().trimmed().isEmpty()) continue; + auto* codeItem = nameTable_->item(r, 1); + nameList.append(QJsonObject{ + {QStringLiteral("fieldName"), nameItem->text().trimmed()}, + {QStringLiteral("fieldCode"), codeItem ? codeItem->text().trimmed() : QString()}, + {QStringLiteral("sort"), nameList.size()}, + }); + } + return QJsonObject{ + {QStringLiteral("exceptionTypeName"), nameEdit_->text().trimmed()}, + {QStringLiteral("exceptionTypeCode"), codeEdit_->text().trimmed()}, + {QStringLiteral("standardNumber"), standardNumberEdit_->text().trimmed()}, + {QStringLiteral("standardName"), standardNameEdit_->text().trimmed()}, + {QStringLiteral("description"), descEdit_->toPlainText()}, + {QStringLiteral("legend"), buildLegend()}, + {QStringLiteral("exceptionNameList"), nameList}, + {QStringLiteral("customFormat"), customFormatEdit_->text()}, + {QStringLiteral("separatorSymbol"), separatorEdit_->text()}, + {QStringLiteral("projectId"), projectId_}, + {QStringLiteral("exceptionMarkType"), markType_}, + {QStringLiteral("type"), 2}, + }; +} + +void ExceptionTypeDialog::onSubmit() { + if (!repo_) { reject(); return; } + // 校验:代号(exceptionTypeCode) 必填(对照原版 validateForm)。 + if (codeEdit_->text().trimmed().isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请完善异常属性信息")); + return; + } + // 若处于「标注名称」Tab:至少一个名称(对照原版 labelTag==name 分支校验)。 + if (stack_->currentIndex() == 1) { + bool hasName = false; + for (int r = 0; r < nameTable_->rowCount(); ++r) { + auto* it = nameTable_->item(r, 0); + if (it && !it->text().trimmed().isEmpty()) { hasName = true; break; } + } + if (!hasName) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请添加至少一个标注名称")); + return; + } + } + + const QJsonObject body = buildFormData(); + QPointer self(this); + repo_->addExceptionType(body, [self](bool ok, QString msg) { + if (!self) return; + if (ok) { + self->createdTypeName_ = self->nameEdit_->text().trimmed(); + QMessageBox::information(self, self->windowTitle(), + QStringLiteral("新增异常类型成功!")); + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("新增异常类型失败!") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ExceptionTypeDialog.hpp b/src/app/panels/chart/ExceptionTypeDialog.hpp new file mode 100644 index 0000000..b73b3a5 --- /dev/null +++ b/src/app/panels/chart/ExceptionTypeDialog.hpp @@ -0,0 +1,109 @@ +#pragma once +#include + +#include +#include +#include +#include + +class QComboBox; +class QGroupBox; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; +class QSpinBox; +class QStackedWidget; +class QTableWidget; +class QWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 新建异常类型对话框(1:1 复刻原版 ExceptionLabel/index.vue + ExceptionAttribute.vue + LabelName.vue)。 +// 880px 宽,标题「标注类型」;顶部 RadioGroup(按钮态) 双 Tab:异常属性 / 标注名称。 +// - 异常属性:异常类型名称 / 代号(exceptionTypeCode 必填) / 标准编号 / 标准名称 / 说明, +// 以及按 markType(点1/线2/面3/文字4) 显示的图例样式编辑器(点形状·大小·颜色·不透明度 / +// 多段线线形·线宽·颜色·不透明度 / 多边形填充·颜色·不透明度 / 文字字体·大小·颜色·不透明度)。 +// - 标注名称:分隔符号 + 自定义格式描述(只读,按分隔符拼接)+ 可增删的名称表(fieldName/fieldCode)。 +// 提交 handleBeforeOk:组装 formData = {...异常属性, exceptionNameList(映射 sort), customFormat, +// separatorSymbol, projectId, exceptionMarkType:markType, type:2} → addExceptionType。 +class ExceptionTypeDialog : public QDialog { + Q_OBJECT +public: + // markType:"1".."4"(与 ExceptionDialog::markTypeValue / 原版 exceptionMarkType 一致)。 + ExceptionTypeDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, + QString markType, QWidget* parent = nullptr); + + // accept() 后供调用方读取(用于刷新下拉后按名称选中新建项)。 + QString createdTypeName() const { return createdTypeName_; } + +private: + void buildAttributeTab(QWidget* page); // 异常属性 Tab(表单 + 按 markType 的图例分组) + void buildNameTab(QWidget* page); // 标注名称 Tab(分隔符 + 自定义格式 + 名称表) + // 图例分组构建器(按 markType 选择性创建,单一职责拆分以控函数行数)。 + QGroupBox* buildPointLegend(); // 点(markType==1) + QGroupBox* buildPolylineLegend(); // 多段线(markType∈{2,3,4}) + QGroupBox* buildPolygonLegend(); // 多边形(markType∈{3,4}) + QGroupBox* buildTextLegend(); // 文字(所有 markType) + + void pickColor(QPushButton* swatch, QColor& target); // 色块 → QColorDialog → 回填 + QPushButton* makeColorSwatch(QColor& target); // 创建已接 pickColor 的色块按钮 + void onSeparatorChanged(); // 分隔符变 → 重算自定义格式描述(选中名称按分隔符拼接) + void recomputeCustomFormat(); + void addNameRow(); // 名称表加一行(fieldName 手填,fieldCode 自动) + void delNameRow(); // 删选中行 + QString nextFieldCode() const; // 自动 fieldCode("custom_"+序号,去重) + + QJsonObject buildLegend() const; // 按 markType 组装 legend 子对象(对照原版默认结构) + QJsonObject buildFormData() const; // 组装完整提交体 + void onSubmit(); // 校验(代号必填) → addExceptionType → 成功 accept() + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString projectId_; + QString markType_; + int markTypeInt_ = 1; + QString createdTypeName_; // 提交成功后记录的异常类型名称(供刷新下拉选中) + + QStackedWidget* stack_ = nullptr; // 双 Tab 内容容器 + + // ── 异常属性表单 ── + QLineEdit* nameEdit_ = nullptr; + QLineEdit* codeEdit_ = nullptr; + QLineEdit* standardNumberEdit_ = nullptr; + QLineEdit* standardNameEdit_ = nullptr; + QPlainTextEdit* descEdit_ = nullptr; + + // 点图例 + QComboBox* pointShape_ = nullptr; + QSpinBox* pointSize_ = nullptr; + QColor pointColor_; + QPushButton* pointColorBtn_ = nullptr; + QSpinBox* pointOpacity_ = nullptr; + // 多段线图例 + QComboBox* polylineShape_ = nullptr; + QSpinBox* polylineWidth_ = nullptr; + QColor polylineColor_; + QPushButton* polylineColorBtn_ = nullptr; + QSpinBox* polylineOpacity_ = nullptr; + // 多边形图例 + QComboBox* polygonFill_ = nullptr; + QColor polygonFillColor_; + QPushButton* polygonFillColorBtn_ = nullptr; + QSpinBox* polygonFillOpacity_ = nullptr; + // 文字图例 + QComboBox* textFont_ = nullptr; + QSpinBox* textSize_ = nullptr; + QColor textColor_; + QPushButton* textColorBtn_ = nullptr; + QSpinBox* textOpacity_ = nullptr; + + // ── 标注名称 Tab ── + QLineEdit* customFormatEdit_ = nullptr; // 只读 + QLineEdit* separatorEdit_ = nullptr; + QTableWidget* nameTable_ = nullptr; // 列:名称(fieldName) / 代号(fieldCode) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/FilterDialog.cpp b/src/app/panels/chart/FilterDialog.cpp new file mode 100644 index 0000000..c297f93 --- /dev/null +++ b/src/app/panels/chart/FilterDialog.cpp @@ -0,0 +1,428 @@ +#include "panels/chart/FilterDialog.hpp" + +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" // addDialogButtons / addSection / editLabel +#include "Theme.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr int kDialogW = 900; // 原版弹窗宽 900px +constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21) +constexpr int kDefaultDim = 3; +const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删) +const char kCustomGroupName[] = "自定义滤波器"; + +// 单元格读数(空/非法 → 0)。 +double cellValue(const QTableWidgetItem* it) { + if (!it) return 0.0; + bool ok = false; + const double v = it->text().toDouble(&ok); + return ok ? v : 0.0; +} + +// 分组小标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 +void addSpecTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { + formkit::addSection(into, title, parent, /*topGap=*/false); +} + +// 原版带边框卡片(1px 边框 + 圆角 + 内距)。 +QFrame* cardFrame(QWidget* parent) { + auto* card = new QFrame(parent); + card->setFrameShape(QFrame::StyledPanel); + return card; +} +} // namespace + +FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QString projectId, QWidget* parent) + : QDialog(parent), repo_(repo), dsId_(std::move(dsId)), projectId_(std::move(projectId)) { + setWindowTitle(QStringLiteral("滤波设置")); // 原版 filterSetting + setModal(true); + setFixedWidth(kDialogW); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + root->setSpacing(geopro::app::space::kMd); + auto* body = new QHBoxLayout(); + body->setSpacing(geopro::app::space::kLg); + root->addLayout(body, 1); + + buildLeft(body); + buildRight(body); + + // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);「保存设置」为次按钮 + // 经 ActionRole 落在左侧(QDialogButtonBox 自动把 ActionRole 排到主操作左边),不抢 primary。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + auto* saveSettingBtn = box->addButton(QStringLiteral("保存设置"), QDialogButtonBox::ActionRole); + // 确认需异步 applyFilter 成功才关闭 → 断开默认 accept,改接 onConfirm。 + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm); + + resizeMatrix(); // 默认 3x3 中心 1 + if (auto* c = matrix_->item(1, 1)) c->setText(QStringLiteral("1")); + + connect(saveSettingBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter); + connect(tree_, &QTreeWidget::itemSelectionChanged, this, + &FilterDialog::onTreeSelectionChanged); + // 行/列:仅奇数允许,偶数弹警告并回退旧值(对照原版 watch rows/cols)。 + prevRows_ = kDefaultDim; + prevCols_ = kDefaultDim; + connect(rows_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int v) { onDimChanged(rows_, v, prevRows_, QStringLiteral("行")); }); + connect(cols_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int v) { onDimChanged(cols_, v, prevCols_, QStringLiteral("列")); }); + // 数据边缘/无数据点:仅「填充」启用对应值输入框(对照原版 v-if=filling)。 + connect(dataEdge_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { + dataEdgeValue_->setEnabled(dataEdge_->currentData().toString() == + QStringLiteral("filling")); + }); + connect(noDataPoints_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { + noDataValue_->setEnabled(noDataPoints_->currentData().toString() == + QStringLiteral("filling")); + }); + noDataValue_->setEnabled(true); // 默认填充 + + loadFilters(); +} + +void FilterDialog::buildLeft(QHBoxLayout* body) { + auto* card = cardFrame(this); + card->setMinimumWidth(270); // 原版左卡片 min-width:270px + auto* leftLay = new QVBoxLayout(card); + leftLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + auto* title = new QLabel(QStringLiteral("滤波方式:"), card); // 原版 filterType: + auto tf = title->font(); + tf.setBold(true); + title->setFont(tf); + leftLay->addWidget(title); + tree_ = new QTreeWidget(card); + tree_->setHeaderHidden(true); + leftLay->addWidget(tree_, 1); + auto* treeBtnLay = new QHBoxLayout(); + auto* addBtn = new QPushButton(QStringLiteral("另存为"), card); + auto* delBtn = new QPushButton(QStringLiteral("删除"), card); + treeBtnLay->addWidget(addBtn); + treeBtnLay->addWidget(delBtn); + leftLay->addLayout(treeBtnLay); + body->addWidget(card, 3); // 左 ~30% + + connect(addBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter); + connect(delBtn, &QPushButton::clicked, this, &FilterDialog::deleteSelectedFilter); +} + +void FilterDialog::buildRight(QHBoxLayout* body) { + auto* card = cardFrame(this); + auto* rightLay = new QVBoxLayout(card); + rightLay->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + rightLay->setSpacing(geopro::app::space::kMd); + + // 滤波设置分组。 + addSpecTitle(rightLay, QStringLiteral("滤波设置"), card); + dataEdge_ = new EmptyAwareComboBox(card); + dataEdge_->addItem(QStringLiteral("设置为无效点"), QStringLiteral("whitening")); + dataEdge_->addItem(QStringLiteral("忽略"), QStringLiteral("skip")); + dataEdge_->addItem(QStringLiteral("复制边缘点"), QStringLiteral("edgePoint")); + dataEdge_->addItem(QStringLiteral("填充"), QStringLiteral("filling")); + dataEdgeValue_ = new QLineEdit(card); + dataEdgeValue_->setEnabled(false); + noDataPoints_ = new EmptyAwareComboBox(card); + noDataPoints_->addItem(QStringLiteral("扩展"), QStringLiteral("expansion")); + noDataPoints_->addItem(QStringLiteral("保留"), QStringLiteral("retain")); + noDataPoints_->addItem(QStringLiteral("忽略"), QStringLiteral("skip")); // 原版 skip → 忽略 + noDataPoints_->addItem(QStringLiteral("填充"), QStringLiteral("filling")); + noDataPoints_->setCurrentIndex(3); // 默认填充(对照原版) + noDataValue_ = new QLineEdit(card); + filterTimes_ = new QSpinBox(card); + filterTimes_->setRange(1, 10); + + rightLay->addLayout(settingRow(QStringLiteral("数据边缘:"), dataEdge_, + QStringLiteral("值:"), dataEdgeValue_, card)); + rightLay->addLayout(settingRow(QStringLiteral("无数据点:"), noDataPoints_, + QStringLiteral("值:"), noDataValue_, card)); + rightLay->addLayout(settingRow(QStringLiteral("滤波次数:"), filterTimes_, + QString(), nullptr, card)); + + // 滤波器规格分组。 + addSpecTitle(rightLay, QStringLiteral("滤波器规格"), card); + rows_ = new QSpinBox(card); + rows_->setRange(kMatrixMin, kMatrixMax); + rows_->setValue(kDefaultDim); + cols_ = new QSpinBox(card); + cols_->setRange(kMatrixMin, kMatrixMax); + cols_->setValue(kDefaultDim); + auto* specsRow = new QHBoxLayout(); + auto* rowLbl = new QLabel(QStringLiteral("行:"), card); + rowLbl->setMinimumWidth(30); + specsRow->addWidget(rowLbl); + specsRow->addWidget(rows_); + specsRow->addSpacing(geopro::app::space::kLg); + auto* colLbl = new QLabel(QStringLiteral("列:"), card); + colLbl->setMinimumWidth(30); + specsRow->addWidget(colLbl); + specsRow->addWidget(cols_); + specsRow->addStretch(); + rightLay->addLayout(specsRow); + + matrix_ = new QTableWidget(kDefaultDim, kDefaultDim, card); + matrix_->horizontalHeader()->setVisible(true); // 原版矩阵带行列号表头 + matrix_->verticalHeader()->setVisible(true); + rightLay->addWidget(matrix_, 1); + body->addWidget(card, 6); // 右 ~60% +} + +// 设置行:定宽右标签列(§7.0.2 editLabel)+ 主控件 [+ 右侧「值:」标签 + 值框]。 +QHBoxLayout* FilterDialog::settingRow(const QString& label, QWidget* main, const QString& valLabel, + QWidget* valField, QWidget* parent) { + auto* row = new QHBoxLayout(); + row->setSpacing(geopro::app::space::kMd); + row->addWidget(formkit::editLabel(label, parent)); + row->addWidget(main, 3); + if (valField) { + row->addSpacing(geopro::app::space::kLg); + row->addWidget(new QLabel(valLabel, parent)); + row->addWidget(valField, 3); + } else { + row->addStretch(4); + } + return row; +} + +void FilterDialog::onDimChanged(QSpinBox* box, int newVal, int& prev, const QString& which) { + if (newVal % 2 == 0) { // 偶数 → 警告并回退(对照原版 watch 回退旧值) + QMessageBox::warning( + this, windowTitle(), + QStringLiteral("滤波矩阵%1数必须为奇数,以确保有唯一的中心点").arg(which)); + QSignalBlocker b(box); + box->setValue(prev); + return; + } + prev = newVal; + resizeMatrix(); +} + +void FilterDialog::loadFilters() { + if (!repo_) return; + QPointer self(this); + repo_->listFilters([self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->flatItems_.clear(); + for (const QJsonValue& v : list) self->flatItems_.push_back(v.toObject()); + self->buildTree(); + }); +} + +void FilterDialog::buildTree() { + tree_->clear(); + // 按 id 索引节点,先建全部节点再挂父子(扁平 → 树,对照原版 buildTreeData)。 + QHash byId; + QHash parentOf; + for (const auto& o : flatItems_) { + const QString id = o.value(QStringLiteral("id")).toString(); + const QString name = o.value(QStringLiteral("name")).toString(); + auto* item = new QTreeWidgetItem(QStringList{name}); + item->setData(0, Qt::UserRole, o.value(QStringLiteral("id")).toString()); + item->setData(0, Qt::UserRole + 1, QString::fromUtf8(QJsonDocument(o).toJson())); + // 叶节点(含矩阵 rowColumValue)才可选中应用。 + const bool selectable = !o.value(QStringLiteral("rowColumValue")).isNull() && + o.contains(QStringLiteral("rowColumValue")); + if (!selectable) item->setFlags(item->flags() & ~Qt::ItemIsSelectable); + byId.insert(id, item); + parentOf.insert(id, o.value(QStringLiteral("parentId")).toString()); + } + for (auto it = byId.begin(); it != byId.end(); ++it) { + const QString pid = parentOf.value(it.key()); + if (!pid.isEmpty() && byId.contains(pid)) + byId.value(pid)->addChild(it.value()); + else + tree_->addTopLevelItem(it.value()); + } + tree_->expandAll(); +} + +void FilterDialog::onTreeSelectionChanged() { + auto* item = tree_->currentItem(); + if (!item) return; + const QByteArray raw = item->data(0, Qt::UserRole + 1).toString().toUtf8(); + const QJsonObject o = QJsonDocument::fromJson(raw).object(); + const QJsonObject rc = o.value(QStringLiteral("rowColumValue")).toObject(); + const QJsonArray form = rc.value(QStringLiteral("form")).toArray(); + if (form.isEmpty()) return; + // 回填矩阵 + 行列。 + const int r = form.size(); + const int c = form.at(0).toArray().size(); + rows_->setValue(r); + cols_->setValue(c); // 触发 resizeMatrix + for (int i = 0; i < r; ++i) { + const QJsonArray row = form.at(i).toArray(); + for (int j = 0; j < c && j < row.size(); ++j) + if (auto* cell = matrix_->item(i, j)) + cell->setText(QString::number(row.at(j).toDouble())); + } + // 回填配置(存在则用,缺省保持当前)。code 1..N 直接映射到下拉项 0..N-1。 + auto setCombo = [](QComboBox* combo, int code) { + if (code >= 1 && code <= combo->count()) combo->setCurrentIndex(code - 1); + }; + if (o.contains(QStringLiteral("boundary"))) + setCombo(dataEdge_, o.value(QStringLiteral("boundary")).toInt()); + if (o.contains(QStringLiteral("noDataPoints"))) + setCombo(noDataPoints_, o.value(QStringLiteral("noDataPoints")).toInt()); + if (o.contains(QStringLiteral("number"))) + filterTimes_->setValue(o.value(QStringLiteral("number")).toInt()); +} + +void FilterDialog::resizeMatrix() { + const int r = rows_->value(); + const int c = cols_->value(); + // 保留重叠区旧值(对照原版 updateFilterMatrix 保留 + 补 0)。 + std::vector> old = readMatrix(); + matrix_->setRowCount(r); + matrix_->setColumnCount(c); + // 行列号表头(1..n)。 + QStringList hh, vh; + for (int j = 0; j < c; ++j) hh << QString::number(j + 1); + for (int i = 0; i < r; ++i) vh << QString::number(i + 1); + matrix_->setHorizontalHeaderLabels(hh); + matrix_->setVerticalHeaderLabels(vh); + for (int i = 0; i < r; ++i) + for (int j = 0; j < c; ++j) { + auto* it = matrix_->item(i, j); + if (!it) { + it = new QTableWidgetItem(); + matrix_->setItem(i, j, it); + } + const double v = (i < static_cast(old.size()) && + j < static_cast(old[i].size())) + ? old[i][j] + : 0.0; + it->setText(QString::number(v)); + } +} + +std::vector> FilterDialog::readMatrix() const { + std::vector> out; + for (int i = 0; i < matrix_->rowCount(); ++i) { + std::vector row; + for (int j = 0; j < matrix_->columnCount(); ++j) row.push_back(cellValue(matrix_->item(i, j))); + out.push_back(std::move(row)); + } + return out; +} + +QString FilterDialog::selectedFilterName() const { + auto* item = tree_->currentItem(); + return item ? item->text(0) : QString(); +} + +QString FilterDialog::customGroupParentId() const { + // 找名为「自定义滤波器」的分组节点 id(newFilter parentId)。 + for (const auto& o : flatItems_) + if (o.value(QStringLiteral("name")).toString() == QLatin1String(kCustomGroupName)) + return o.value(QStringLiteral("id")).toString(); + return QString(); +} + +void FilterDialog::saveCustomFilter() { + if (!repo_) return; + const QString parentId = customGroupParentId(); + if (parentId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("未找到自定义滤波器分组")); + return; + } + bool ok = false; + const QString name = QInputDialog::getText(this, QStringLiteral("保存为新的自定义滤波器"), + QStringLiteral("请输入滤波器名称"), QLineEdit::Normal, + QStringLiteral("自定义滤波器1"), &ok); + if (!ok || name.trimmed().isEmpty()) return; + QPointer self(this); + repo_->newFilter(buildNewFilterBody(name.trimmed(), projectId_, parentId, readMatrix()), + [self](bool ok2, QString msg) { + if (!self) return; + if (ok2) + self->loadFilters(); // 刷新树 + else + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + }); +} + +void FilterDialog::deleteSelectedFilter() { + if (!repo_) return; + auto* item = tree_->currentItem(); + if (!item) return; + const QString id = item->data(0, Qt::UserRole).toString(); + if (id.isEmpty() || id == QLatin1String(kDefaultCustomKey)) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("该滤波器不可删除")); + return; + } + if (QMessageBox::question(this, windowTitle(), QStringLiteral("确认删除该滤波器?")) != + QMessageBox::Yes) + return; + QPointer self(this); + repo_->deleteFilter(id, [self](bool ok, QString msg) { + if (!self) return; + if (ok) + self->loadFilters(); + else + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("删除失败") : msg); + }); +} + +void FilterDialog::onConfirm() { + if (!repo_) { reject(); return; } + FilterApplyParams p; + p.dsObjectId = dsId_; + p.dataEdge = dataEdge_->currentData().toString(); + p.dataEdgeValue = dataEdgeValue_->text().toDouble(); + p.noDataPoints = noDataPoints_->currentData().toString(); + p.noDataValue = noDataValue_->text().toDouble(); + p.number = filterTimes_->value(); + p.row = rows_->value(); + p.column = cols_->value(); + p.matrix = readMatrix(); + p.filteringMethod = selectedFilterName(); + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->applyFilter(buildFilterApplyBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("滤波处理失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/FilterDialog.hpp b/src/app/panels/chart/FilterDialog.hpp new file mode 100644 index 0000000..d4aa442 --- /dev/null +++ b/src/app/panels/chart/FilterDialog.hpp @@ -0,0 +1,73 @@ +#pragma once +#include + +#include +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QSpinBox; +class QPushButton; +class QTreeWidget; +class QTreeWidgetItem; +class QTableWidget; +class QLineEdit; +class QHBoxLayout; +class QWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 滤波处理对话框(I4,1:1 复刻原版 FilterDialog): +// 左:滤波器树(listFilters,按 parentId 建树,叶节点可选)+ 自定义滤波器增删。 +// 右:数据边缘 / 无数据点 / 滤波次数 / 矩阵行列 + 矩阵编辑表。 +// 确认 → applyFilter,成功 accept(),调用方随后重载网格重绘。 +class FilterDialog : public QDialog { + Q_OBJECT +public: + FilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, QString projectId, + QWidget* parent = nullptr); + +private: + void buildLeft(QHBoxLayout* body); // 左:滤波方式树卡片 + void buildRight(QHBoxLayout* body); // 右:滤波设置 + 滤波器规格卡片 + QHBoxLayout* settingRow(const QString& label, QWidget* main, const QString& valLabel, + QWidget* valField, QWidget* parent); // 原版 .setting-row + void onDimChanged(QSpinBox* box, int newVal, int& prev, const QString& which); // 奇偶校验 + void loadFilters(); // 拉滤波器树 + void buildTree(); // 由 flatItems_ 建树 + void onTreeSelectionChanged(); // 选中叶节点 → 右侧回填 + void resizeMatrix(); // 行/列变更 → 重建矩阵表(保留重叠值) + std::vector> readMatrix() const; // 读当前矩阵表 + void saveCustomFilter(); // 另存为新自定义滤波器(newFilter) + void deleteSelectedFilter(); // 删除选中自定义滤波器(deleteFilter) + void onConfirm(); // 应用 → applyFilter + QString selectedFilterName() const; // 选中节点名(filteringMethod 字段) + QString customGroupParentId() const; // 「自定义滤波器」分组节点 id(newFilter parentId) + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + + QTreeWidget* tree_ = nullptr; + std::vector flatItems_; // listFilters 原始扁平项(建树用) + + QComboBox* dataEdge_ = nullptr; + QLineEdit* dataEdgeValue_ = nullptr; + QComboBox* noDataPoints_ = nullptr; + QLineEdit* noDataValue_ = nullptr; + QSpinBox* filterTimes_ = nullptr; + QSpinBox* rows_ = nullptr; + QSpinBox* cols_ = nullptr; + int prevRows_ = 3; // 奇偶校验回退用(上一合法行数) + int prevCols_ = 3; // 上一合法列数 + QTableWidget* matrix_ = nullptr; + + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.cpp b/src/app/panels/chart/GridDataChartView.cpp index d268977..7409ee4 100644 --- a/src/app/panels/chart/GridDataChartView.cpp +++ b/src/app/panels/chart/GridDataChartView.cpp @@ -1,10 +1,23 @@ #include "panels/chart/GridDataChartView.hpp" +#include + #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include #include #include @@ -14,15 +27,27 @@ #include #include +#include "ColorScaleConfigDialog.hpp" #include "PanelHeader.hpp" #include "Theme.hpp" #include "panels/AnomalyTablePanel.hpp" #include "panels/DescriptionPanel.hpp" #include "panels/chart/ChartTheme.hpp" +#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorMapService.hpp" +#include "panels/chart/ContourDrawTool.hpp" +#include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourPlotItem.hpp" +#include "panels/chart/ExceptionDetailDialog.hpp" +#include "panels/chart/ExceptionDialog.hpp" +#include "panels/chart/ExceptionTextDialog.hpp" +#include "panels/chart/FilterDialog.hpp" +#include "panels/chart/GridWizardDialog.hpp" #include "panels/chart/LivePanner.hpp" +#include "panels/chart/SaveAsDialog.hpp" +#include "panels/chart/WhiteningDialog.hpp" +#include "repo/IDatasetCommandRepository.hpp" namespace geopro::app { @@ -54,22 +79,30 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { auto* chkShowContourLabel = new QCheckBox(QStringLiteral("显示等值线标注"), toolbar); chkShowContourLabel->setChecked(true); + chkShowLabels_ = chkShowContourLabel; // 存成员:线形⚙ 改标注显隐后回写复选框 UI auto* chkContourTip = new QCheckBox(QStringLiteral("显示等值线提示信息"), toolbar); chkContourTip->setChecked(false); auto* lblSimplify = new QLabel(QStringLiteral("简化容差:"), toolbar); + // 简化容差:对照原版 a-slider min0 max2 step0.1,默认 0.5。客户端用整数滑块 0~20 映射 /10。 simplifySlider_ = new QSlider(Qt::Horizontal, toolbar); - simplifySlider_->setRange(0, 100); - simplifySlider_->setValue(50); + simplifySlider_->setRange(0, 20); + simplifySlider_->setValue(5); // 0.5 simplifySlider_->setFixedWidth(80); simplifyValueLabel_ = new QLabel(QStringLiteral("0.5"), toolbar); simplifyValueLabel_->setFixedWidth(28); + // I8 防抖(~300ms,对照原版 300ms 节流):滑动只改标签,停下后真重算等值线简化。 + simplifyDebounce_ = new QTimer(this); + simplifyDebounce_->setSingleShot(true); + simplifyDebounce_->setInterval(300); + connect(simplifyDebounce_, &QTimer::timeout, this, [this]() { applySimplify(); }); connect(simplifySlider_, &QSlider::valueChanged, this, [this](int v) { - simplifyValueLabel_->setText(QString::number(v / 100.0, 'f', 1)); + simplifyValueLabel_->setText(QString::number(v / 10.0, 'f', 1)); + simplifyDebounce_->start(); }); auto* btnAnomalyLabel = new QToolButton(toolbar); @@ -91,10 +124,11 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { tbLay->addWidget(lblSimplify); tbLay->addWidget(simplifySlider_); tbLay->addWidget(simplifyValueLabel_); + // 原版 .right-buttons margin-left:auto:异常标注/自动标注/另存为 右对齐。 + tbLay->addStretch(); tbLay->addWidget(btnAnomalyLabel); tbLay->addWidget(btnAutoLabel); tbLay->addWidget(btnSaveAs); - tbLay->addStretch(); lay->addWidget(toolbar); @@ -165,6 +199,39 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) { showLabels_ = on; if (contourItem_) { contourItem_->setShowLabels(on); plot_->replot(); } }); + // 「色阶配置」→ 共享色阶编辑器(与三维体右键「色阶」同一对话框)。 + connect(btnColorScale, &QToolButton::clicked, this, + [this]() { openColorScaleEditor(); }); + + // 处理类按钮(网格化/白化/滤波/另存为):成功后重载网格重绘。 + connect(btnGrid, &QToolButton::clicked, this, [this]() { openGridWizard(); }); + connect(btnWhiten, &QToolButton::clicked, this, [this]() { openWhitening(); }); + connect(btnFilter, &QToolButton::clicked, this, [this]() { openFilter(); }); + connect(btnSaveAs, &QToolButton::clicked, this, [this]() { openSaveAs(); }); + + // 异常标注 / 自动标注(I9 / I13)。 + connect(btnAnomalyLabel, &QToolButton::clicked, this, [this]() { openExceptionDialog(); }); + connect(btnAutoLabel, &QToolButton::clicked, this, [this]() { openAutoAnnotation(); }); + + // 异常表操作列 → 删除/详情/定位(I10 / I11 / I12)。 + connect(anomalyTable_, &AnomalyTablePanel::deleteRequested, this, + [this](int i) { deleteAnomaly(i); }); + connect(anomalyTable_, &AnomalyTablePanel::detailRequested, this, + [this](int i) { showAnomalyDetail(i); }); + connect(anomalyTable_, &AnomalyTablePanel::locateRequested, this, + [this](int i) { locateAnomaly(i); }); + + // 描述保存(I14)。 + connect(descriptionPanel_, &DescriptionPanel::saveRequested, this, + [this]() { saveDescription(); }); + + // I7 显示等值线提示信息:hover tooltip 显隐(本地,挂画布事件过滤器)。 + contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); + connect(chkContourTip, &QCheckBox::toggled, this, + [this](bool on) { if (contourTip_) contourTip_->setEnabled(on); }); + + // I9 图上绘形工具(后装于 LivePanner/hover,绘制期优先消费事件)。默认空闲。 + drawTool_ = new ContourDrawTool(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); // 主题配色:当前主题套一次 + 监听切换热更新。 applyChartPlotTheme(plot_); @@ -183,6 +250,7 @@ void GridDataChartView::setPayload(const QVariant& payload) { return; } const auto p = payload.value(); + lvlTemplateId_ = p.templateId; // 色阶模板 id(保存/覆盖回带,对照原版 lvlTemplateId) setGridData(p.grid, p.scale, p.anomalies); } @@ -217,8 +285,14 @@ void GridDataChartView::rebuildContour() { } contourItem_ = new ContourPlotItem(); - contourItem_->setData(grid_, colorSvc_, anoms_, /*showLines*/ true, showLabels_); + contourItem_->setData(grid_, colorSvc_, anoms_, lineCfg_.lineShow, showLabels_); contourItem_->setShowAnomalies(showAnomalies_); + contourItem_->setLineColor(lineCfg_.lineColor); // 线形⚙ 配置 + contourItem_->setLineDashed(lineCfg_.dashed); + contourItem_->setLabelColor(lineCfg_.labelColor); + // I8 应用当前简化容差;I7 把等值线提示绑定到新建项。 + if (simplifySlider_) contourItem_->setSimplifyTolerance(simplifySlider_->value() / 10.0); + if (contourTip_) contourTip_->setItem(contourItem_); contourItem_->attach(plot_); // 轴范围 = 数据范围(x=距离、y=深度/高程)。 @@ -232,4 +306,296 @@ void GridDataChartView::rebuildContour() { plot_->replot(); } +void GridDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter) { + tplRepo_ = repo; + projectIdGetter_ = std::move(projectIdGetter); +} + +void GridDataChartView::openColorScaleEditor() { + if (!hasGrid_) return; + // 数据范围始终取网格真实值域(不取编辑后色阶端点,否则等积兜底/新增插值会用错区间)。 + std::vector samples = grid_.values(); // 等积分层用原始标量 + + // projectId 在打开时取一次(随项目切换生效);无 getter 退化为空 → 后端按钮禁用。 + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + // 传入网格色阶模板 id(getDetail type2 顶层 templateId)→ 「另存为覆盖」可用(对照原版 lvlTemplateId)。 + ColorScaleConfigDialog dlg(gridScale_, grid_.vmin, grid_.vmax, std::move(samples), lineCfg_, + tplRepo_, projectId, lvlTemplateId_, this); + if (dlg.exec() != QDialog::Accepted) return; + + gridScale_ = dlg.colorScale(); + lineCfg_ = dlg.lineConfig(); + showLabels_ = lineCfg_.labelShow; // 标注显隐同步 + 回写工具条复选框(避免 UI 与状态脱钩) + if (chkShowLabels_) { + const QSignalBlocker block(chkShowLabels_); + chkShowLabels_->setChecked(showLabels_); + } + + delete colorSvc_; + colorSvc_ = new ColorMapService(gridScale_); + rebuildContour(); + colorBar_->setColorScale(gridScale_); +} + +void GridDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + // projectId 取值回调与色阶编辑器共用(色阶注入可能先到,二者一致)。 + if (projectIdGetter) projectIdGetter_ = std::move(projectIdGetter); + + // I14:有仓储 + dsId 时启用描述保存并回填一次。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (descriptionPanel_) descriptionPanel_->setSaveEnabled(cmdRepo_ && !dsId.isEmpty()); + loadDescription(); +} + +void GridDataChartView::showNotImplemented(QWidget* anchor) { + const QPoint pos = anchor ? anchor->mapToGlobal(QPoint(0, anchor->height())) : QCursor::pos(); + QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor); +} + +void GridDataChartView::reloadGrid() { + // 处理类操作成功后:只读重载网格 → setGridData 重绘(与 measurement V 值重载同范式)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; + QPointer self(this); + cmdRepo_->loadInversionGrid( + dsId, [self](bool ok, geopro::core::ContourPayload p, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("重载失败") : msg); + return; + } + self->lvlTemplateId_ = p.templateId; // 重载后同步模板 id(色阶覆盖回带) + self->setGridData(p.grid, p.scale, p.anomalies); + }); +} + +void GridDataChartView::openGridWizard() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + GridWizardDialog dlg(cmdRepo_, dsId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openWhitening() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // tmObjectId(白化模板列表用)= 当前数据集的 structParentId(对照原版 dsFileRow.structParentId)。 + // 经 open 链路从数据集列表行透传至此(注入的 tmObjectIdGetter_)。空串也照常打开(仅模板列表为空)。 + const QString tmObjectId = tmObjectIdGetter_ ? tmObjectIdGetter_() : QString(); + WhiteningDialog dlg(cmdRepo_, dsId, projectId, tmObjectId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openFilter() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + FilterDialog dlg(cmdRepo_, dsId, projectId, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::openSaveAs() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::Inversion, cmdRepo_, dsId, this); + dlg.exec(); // 另存为新数据,不改当前视图(与原版一致)。 +} + +void GridDataChartView::applySimplify() { + if (!contourItem_ || !simplifySlider_) return; + contourItem_->setSimplifyTolerance(simplifySlider_->value() / 10.0); + plot_->replot(); +} + +// ============================ 异常标注 / 描述(I9~I14)============================ + +void GridDataChartView::openExceptionDialog() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // 时序复刻原版:先弹窗选 标注类型/异常类型/名称/备注(remarkSourceId = dsObjectId)。 + ExceptionDialog dlg(cmdRepo_, projectId, dsId, this); + if (dlg.exec() != QDialog::Accepted) return; + + // 兜底:用户手填了坐标 → 对话框内部已提交,仅重载。 + if (!dlg.manualCoordinates().isEmpty()) { reloadGrid(); return; } + + // 主路径:弹窗后在图上交互绘形 → 完成回调组装 newException(对照原版 startDraw*→ + // drawingComplete→newExceptionInProfileInversion)。 + const QString markType = dlg.markTypeValue(); + const QString typeId = dlg.exceptionTypeId(); + const QString name = dlg.exceptionName(); + const QString remark = dlg.exceptionRemark(); + if (!drawTool_) return; + QPointer self(this); + drawTool_->setOnComplete([self, markType, typeId, name, remark](const std::vector& pts) { + if (!self) return; + QJsonArray coords; + for (const QPointF& p : pts) + coords.append(QJsonObject{{QStringLiteral("x"), p.x()}, {QStringLiteral("y"), p.y()}}); + // 文字类型(4):落点后另弹「文本编辑」对话框,提交带 customLegend(对照原版 exceptionText)。 + if (markType == QStringLiteral("4")) { + ExceptionTextDialog tdlg(self); + if (tdlg.exec() != QDialog::Accepted) return; // 取消 → 不提交 + // 字体族 int → CSS family(对照原版 fontFamily 映射)。 + static const QHash kCssFont{ + {QStringLiteral("1"), QStringLiteral("SimSun")}, + {QStringLiteral("2"), QStringLiteral("Microsoft YaHei")}, + {QStringLiteral("3"), QStringLiteral("SimHei")}, + {QStringLiteral("4"), QStringLiteral("KaiTi")}}; + const QString content = tdlg.content(); + QJsonObject customLegend{ + {QStringLiteral("text"), content}, + {QStringLiteral("content"), content}, + {QStringLiteral("color"), tdlg.color()}, + {QStringLiteral("size"), tdlg.fontSize()}, + {QStringLiteral("font"), kCssFont.value(tdlg.fontFamilyValue())}, + {QStringLiteral("opacity"), tdlg.opacityPercent() / 100.0}, // 0–1 + }; + self->submitDrawnException(markType, typeId, name, remark, coords, customLegend); + return; + } + self->submitDrawnException(markType, typeId, name, remark, coords); + }); + drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交) + drawTool_->begin(markType.toInt()); +} + +void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId, + const QString& name, const QString& remark, + const QJsonArray& coords, + const QJsonObject& customLegend) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; + QJsonObject body{ + {QStringLiteral("exceptionName"), name}, + {QStringLiteral("exceptionTypeId"), typeId}, + {QStringLiteral("remark"), remark}, + {QStringLiteral("remarkSourceType"), markType}, // 几何形态字符串 "1".."4" + {QStringLiteral("remarkSourceId"), dsId}, // = dsObjectId + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}}, + }; + // 文字类型带 customLegend(对照原版:仅文字非空,其它形态不带此字段)。 + if (!customLegend.isEmpty()) body.insert(QStringLiteral("customLegend"), customLegend); + QPointer self(this); + cmdRepo_->newException(body, [self](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("新建异常"), + msg.isEmpty() ? QStringLiteral("创建失败") : msg); + return; + } + self->reloadGrid(); // 成功后重载(列表 + 图层同步) + }); +} + +void GridDataChartView::openAutoAnnotation() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // 透传网格 + 色阶:右上预览图(ContourPlotItem 等值面)+ 数据统计(max/min/mean/median)。 + AutoAnnotationDialog dlg(cmdRepo_, dsId, projectId, grid_, gridScale_, this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::deleteAnomaly(int index) { + if (!cmdRepo_ || index < 0 || index >= static_cast(anoms_.size())) return; + const QString id = QString::fromStdString(anoms_[index].id); + if (id.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("提示"), QStringLiteral("该异常无 id,无法删除")); + return; + } + QPointer self(this); + cmdRepo_->deleteException(id, [self](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("删除失败") : msg); + return; + } + self->reloadGrid(); // 成功后重载(列表 + 图层同步) + }); +} + +void GridDataChartView::showAnomalyDetail(int index) { + if (!cmdRepo_ || index < 0 || index >= static_cast(anoms_.size())) return; + ExceptionDetailDialog dlg(cmdRepo_, anoms_[index], this); + if (dlg.exec() == QDialog::Accepted) reloadGrid(); +} + +void GridDataChartView::locateAnomaly(int index) { + if (!contourItem_ || index < 0 || index >= static_cast(anoms_.size())) return; + contourItem_->setHighlightedAnomaly(index); + // 缩放到该异常包围盒(含 10% padding,对照原版 padding 0.1)。 + QRectF box = contourItem_->anomalyBoundingRect(index); + if (!box.isNull()) { + const double padX = box.width() * 0.1 + (box.width() <= 0 ? 1.0 : 0.0); + const double padY = box.height() * 0.1 + (box.height() <= 0 ? 1.0 : 0.0); + plot_->setAxisScale(QwtPlot::xBottom, box.left() - padX, box.right() + padX); + plot_->setAxisScale(QwtPlot::yLeft, box.top() - padY, box.bottom() + padY); + if (rescaler_) rescaler_->rescale(); + } + plot_->replot(); + // 1s 后清除高亮(对照原版 highlight duration 1000ms)。 + QPointer self(this); + QTimer::singleShot(1000, this, [self]() { + if (!self || !self->contourItem_) return; + self->contourItem_->setHighlightedAnomaly(-1); + self->plot_->replot(); + }); +} + +void GridDataChartView::loadDescription() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) return; + QPointer self(this); + cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) { + if (!self || !ok) return; + // 原版从 attachedParameters.deltaContent 取 Quill Delta 回填编辑器(quill.setContents)。 + // 客户端用 QuillDelta::deltaToDocument 还原富文本;无 deltaContent 时回退 description 纯文本。 + const QJsonArray ops = data.value(QStringLiteral("attachedParameters")) + .toObject() + .value(QStringLiteral("deltaContent")) + .toArray(); + if (!ops.isEmpty()) + self->descriptionPanel_->setDelta(ops); + else + self->descriptionPanel_->setPlainText( + data.value(QStringLiteral("description")).toString()); + }); +} + +void GridDataChartView::saveDescription() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) { + showNotImplemented(nullptr); + return; + } + // 与原版 saveQuillEditorContent 对齐: + // description = 纯文本(quill.getText());deltaContent = Quill Delta ops(quill.getContents().ops)。 + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("description"), descriptionPanel_->plainText()}, + {QStringLiteral("attachedParameters"), + QJsonObject{{QStringLiteral("deltaContent"), descriptionPanel_->delta()}}}, + }; + QPointer self(this); + cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) { + if (!self) return; + QMessageBox::information( + self, QStringLiteral("提示"), + ok ? QStringLiteral("描述已保存") + : (msg.isEmpty() ? QStringLiteral("保存失败") : msg)); + }); +} + } // namespace geopro::app diff --git a/src/app/panels/chart/GridDataChartView.hpp b/src/app/panels/chart/GridDataChartView.hpp index ab8c6d4..b52139e 100644 --- a/src/app/panels/chart/GridDataChartView.hpp +++ b/src/app/panels/chart/GridDataChartView.hpp @@ -1,19 +1,33 @@ #pragma once +#include +#include #include +#include // submitDrawnException 默认参数 const QJsonObject& = {} 需完整类型 +#include #include +class QJsonArray; + #include "model/Anomaly.hpp" #include "model/ColorScale.hpp" #include "model/Field.hpp" #include "model/detail/DetailPayloads.hpp" #include "panels/chart/IDetailView.hpp" +#include "ContourLineDialog.hpp" // ContourLineConfig(线形/标注状态) class QSlider; class QLabel; +class QCheckBox; +class QTimer; class QwtPlot; class QwtPlotRescaler; +namespace geopro::data { +class IColorTemplateRepository; +class IDatasetCommandRepository; +} + namespace geopro::app { class AnomalyTablePanel; @@ -21,6 +35,8 @@ class DescriptionPanel; class ColorBarWidget; class ColorMapService; class ContourPlotItem; +class ContourHoverTip; +class ContourDrawTool; // 网格数据图表视图:工具条 + QwtPlot(白底 + 真实比尺 + 实时平移/滚轮缩放,x 轴在底部) // + 独立色阶条 + 底部双页签(异常列表/描述)。 @@ -39,8 +55,45 @@ public: QWidget* widget() override { return this; } void setPayload(const QVariant& payload) override; + // 注入色阶模板仓储 + 当前 projectId 取值回调(打开编辑器时取一次,随项目切换生效)。 + // 可传空仓储 → 编辑器内「另存为/打开」「新建色阶」禁用。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo, + std::function projectIdGetter); + + // 注入反演命令仓储 + dsId/projectId 取值回调(网格化/白化/滤波/另存为按钮)。 + // 可传空仓储 → 这些按钮退化为「暂未实现」占位提示。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + + // 注入 tmObjectId 取值回调(= 数据集 structParentId)。白化对话框模板列表用;空 → 模板列表为空。 + void setTmObjectIdGetter(std::function tmObjectIdGetter) { + tmObjectIdGetter_ = std::move(tmObjectIdGetter); + } + private: void rebuildContour(); // 按当前显隐开关重建并重绘 ContourPlotItem + void openColorScaleEditor(); // 「色阶配置」→ 共享色阶编辑器(色阶 + 层级⚙ + 线形⚙) + void openGridWizard(); // I1「网格」→ 网格化向导 + void openWhitening(); // I3「白化」→ 白化弹窗 + void openFilter(); // I4「滤波处理」→ 滤波弹窗 + void openSaveAs(); // I15「另存为」→ 复用 SaveAsDialog(Inversion) + void reloadGrid(); // 处理类成功后:loadInversionGrid → setGridData 重绘 + void applySimplify(); // I8:把当前滑块容差透传给 ContourPlotItem 并重绘 + void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId) + + void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 →[文字另弹文本编辑]→ 提交) + // I9 图上绘形完成:组装 body 提交 newException(成功 reloadGrid)。 + // customLegend 仅文字类型非空(对照原版:文字 customLegend,其它形态留空 {})。 + void submitDrawnException(const QString& markType, const QString& typeId, const QString& name, + const QString& remark, const QJsonArray& coords, + const QJsonObject& customLegend = {}); + void openAutoAnnotation(); // I13 自动标注 + void deleteAnomaly(int index); // I10 异常删除 + void showAnomalyDetail(int index); // I11 异常详情/编辑 + void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放) + void loadDescription(); // I14 进入时回填描述 + void saveDescription(); // I14 保存描述(从面板取 Delta + 纯文本) QwtPlot* plot_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr; @@ -49,6 +102,10 @@ private: DescriptionPanel* descriptionPanel_ = nullptr; QSlider* simplifySlider_ = nullptr; QLabel* simplifyValueLabel_ = nullptr; + QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步) + QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms) + ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示(hover) + ContourDrawTool* drawTool_ = nullptr; // I9 图上绘形工具(QObject,this 持有) // 渲染状态 ColorMapService* colorSvc_ = nullptr; // heap,setGridData 重建 @@ -56,11 +113,24 @@ private: geopro::core::Grid grid_{1, 1}; geopro::core::ColorScale gridScale_; std::vector anoms_; + QString lvlTemplateId_; // 网格色阶模板 id(getDetail type2 顶层 templateId);色阶「另存为覆盖」用 bool hasGrid_ = false; // 工具条显隐开关 bool showAnomalies_ = true; bool showLabels_ = true; + ContourLineConfig lineCfg_; // 线形/标注配置(色阶编辑器 线形⚙ 下发) + + // 色阶模板仓储 + projectId 取值回调(注入;空则编辑器后端按钮禁用)。 + geopro::data::IColorTemplateRepository* tplRepo_ = nullptr; + std::function projectIdGetter_; + + // 反演命令仓储 + dsId 取值回调(注入;空则处理类按钮占位提示)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; + + // tmObjectId 取值回调(= 数据集 structParentId)。白化对话框模板列表用;空 → 模板列表为空。 + std::function tmObjectIdGetter_; }; } // namespace geopro::app diff --git a/src/app/panels/chart/GridWizardDialog.cpp b/src/app/panels/chart/GridWizardDialog.cpp new file mode 100644 index 0000000..22fa4e6 --- /dev/null +++ b/src/app/panels/chart/GridWizardDialog.cpp @@ -0,0 +1,329 @@ +#include "panels/chart/GridWizardDialog.hpp" + +#include +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" // addSection / editLabel +#include "Theme.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildGridToBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kCoordRange = 1e9; // 坐标范围(足够宽) +constexpr int kSizeMin = 1, kSizeMax = 300; // 点数范围(对照原版 1~300) +constexpr int kDefaultXSize = 100; // 默认点数(原版 xPoints 默认 100) +constexpr int kStep1W = 500, kStep2W = 800; // 原版步骤 1/2 弹窗宽 +constexpr int kParamLabelW = 60; // 原版 .param-group label min-width:60px +constexpr int kParamFieldW = 100; // 原版输入框宽 100px + +// 配一个坐标 spinbox(6 位小数,宽范围)。 +QDoubleSpinBox* makeCoordSpin(QWidget* parent) { + auto* sp = new QDoubleSpinBox(parent); + sp->setRange(-kCoordRange, kCoordRange); + sp->setDecimals(6); + sp->setFixedWidth(kParamFieldW); + return sp; +} + +// 分组标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 +void addSectionTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { + formkit::addSection(into, title, parent, /*topGap=*/false); +} + +// 原版 .param-group:定宽右标签 + 紧随输入框,多个并排成一行栅格。 +void addParamCell(QGridLayout* grid, int row, int col, const QString& label, QWidget* field, + QWidget* parent) { + auto* cell = new QHBoxLayout(); + cell->setSpacing(geopro::app::space::kSm); + auto* lbl = new QLabel(label, parent); + lbl->setMinimumWidth(kParamLabelW); + lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + cell->addWidget(lbl); + cell->addWidget(field); + grid->addLayout(cell, row, col); +} +} // namespace + +GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent) + : QDialog(parent), repo_(repo), dsId_(std::move(dsId)) { + setWindowTitle(QStringLiteral("网格配置")); // 原版 gridSetting + setModal(true); + setFixedWidth(kStep1W); + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(geopro::app::space::kLg, geopro::app::space::kLg, + geopro::app::space::kLg, geopro::app::space::kLg); + root->setSpacing(geopro::app::space::kMd); + stack_ = new QStackedWidget(this); + root->addWidget(stack_, 1); + + buildStep1(); + buildStep2(); + + // ── 底部操作栏(规范 §7.5 右对齐):上一步(次按钮) 左;取消(次) + 下一步/确认(主) 右。── + auto* btnLay = new QHBoxLayout(); + btnLay->setSpacing(geopro::app::space::kMd); + prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); // 次按钮(描边),左侧 + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); + okBtn_ = new QPushButton(QStringLiteral("确认"), this); + nextBtn_->setDefault(true); // 步骤 1 主操作 + okBtn_->setDefault(true); // 步骤 2 主操作(每屏仅一个可见,故无双 primary) + btnLay->addWidget(prevBtn_); + btnLay->addStretch(); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(nextBtn_); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + prevBtn_->setVisible(false); + okBtn_->setVisible(false); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(nextBtn_, &QPushButton::clicked, this, &GridWizardDialog::goToStep2); + connect(prevBtn_, &QPushButton::clicked, this, [this]() { + stack_->setCurrentIndex(0); + setFixedWidth(kStep1W); + prevBtn_->setVisible(false); okBtn_->setVisible(false); nextBtn_->setVisible(true); + }); + connect(okBtn_, &QPushButton::clicked, this, &GridWizardDialog::onConfirm); + + loadAlgorithms(); +} + +void GridWizardDialog::buildStep1() { + auto* page1 = new QWidget(this); + auto* p1Lay = new QVBoxLayout(page1); + p1Lay->setContentsMargins(0, 0, 0, 0); + p1Lay->setSpacing(geopro::app::space::kMd); + p1Lay->addWidget(new QLabel(QStringLiteral("请选择网格方法:"), page1)); + algoList_ = new QListWidget(page1); + p1Lay->addWidget(algoList_, 1); + stack_->addWidget(page1); +} + +void GridWizardDialog::buildStep2() { + auto* page2 = new QWidget(this); + auto* p2Lay = new QVBoxLayout(page2); + p2Lay->setContentsMargins(0, 0, 0, 0); + p2Lay->setSpacing(geopro::app::space::kLg); + + xMin_ = makeCoordSpin(page2); xMax_ = makeCoordSpin(page2); + yMin_ = makeCoordSpin(page2); yMax_ = makeCoordSpin(page2); + vMin_ = makeCoordSpin(page2); vMax_ = makeCoordSpin(page2); + xSize_ = new QSpinBox(page2); xSize_->setRange(kSizeMin, kSizeMax); + xSize_->setValue(kDefaultXSize); xSize_->setFixedWidth(100); + ySize_ = new QSpinBox(page2); ySize_->setRange(kSizeMin, kSizeMax); + ySize_->setValue(kDefaultXSize); ySize_->setFixedWidth(100); + xSpacing_ = makeCoordSpin(page2); xSpacing_->setRange(0.0, kCoordRange); + ySpacing_ = makeCoordSpin(page2); ySpacing_->setRange(0.0, kCoordRange); + + // 分组 1:网格参数(栅格:Xmax→Xmin→X间距→X点数 同行;Y 同理)。 + addSectionTitle(p2Lay, QStringLiteral("网格参数"), page2); + auto* grid = new QGridLayout(); + grid->setHorizontalSpacing(geopro::app::space::kMd); + grid->setVerticalSpacing(geopro::app::space::kMd); + addParamCell(grid, 0, 0, QStringLiteral("Xmax"), xMax_, page2); + addParamCell(grid, 0, 1, QStringLiteral("Xmin"), xMin_, page2); + addParamCell(grid, 0, 2, QStringLiteral("X间距"), xSpacing_, page2); + addParamCell(grid, 0, 3, QStringLiteral("X点数"), xSize_, page2); + addParamCell(grid, 1, 0, QStringLiteral("Ymax"), yMax_, page2); + addParamCell(grid, 1, 1, QStringLiteral("Ymin"), yMin_, page2); + addParamCell(grid, 1, 2, QStringLiteral("Y间距"), ySpacing_, page2); + addParamCell(grid, 1, 3, QStringLiteral("Y点数"), ySize_, page2); + grid->setColumnStretch(4, 1); + p2Lay->addLayout(grid); + + // 分组 2:数据值设置(数据值max/min + 恢复默认值 + 数据值保存为)。 + addSectionTitle(p2Lay, QStringLiteral("数据值设置"), page2); + saveFormat_ = new EmptyAwareComboBox(page2); + saveFormat_->addItem(QStringLiteral("线性"), QStringLiteral("linear")); + saveFormat_->addItem(QStringLiteral("对数"), QStringLiteral("log")); + saveFormat_->setFixedWidth(120); + auto* restoreBtn = new QPushButton(QStringLiteral("恢复默认值"), page2); + + auto* dv = new QGridLayout(); + dv->setHorizontalSpacing(geopro::app::space::kLg); + dv->setVerticalSpacing(geopro::app::space::kMd); + auto* vmaxLbl = new QLabel(QStringLiteral("数据值max"), page2); + vmaxLbl->setMinimumWidth(kParamLabelW + 20); + vmaxLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + dv->addWidget(vmaxLbl, 0, 0); + dv->addWidget(vMax_, 0, 1); + dv->addWidget(restoreBtn, 0, 3); + auto* vminLbl = new QLabel(QStringLiteral("数据值min"), page2); + vminLbl->setMinimumWidth(kParamLabelW + 20); + vminLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + dv->addWidget(vminLbl, 1, 0); + dv->addWidget(vMin_, 1, 1); + auto* fmtLbl = new QLabel(QStringLiteral("数据值保存为"), page2); // 原版 saveFormat + fmtLbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + dv->addWidget(fmtLbl, 1, 2); + dv->addWidget(saveFormat_, 1, 3); + dv->setColumnStretch(4, 1); + p2Lay->addLayout(dv); + p2Lay->addStretch(); + stack_->addWidget(page2); + + // 联动:Xmax/Xmin/X点数变 → 反算 X间距(round);X间距变 → 反算 X点数(round)。 + connect(xMax_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcXInterval(); }); + connect(xMin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcXInterval(); }); + connect(xSize_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { calcXInterval(); }); + connect(xSpacing_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcXPoints(); }); + connect(yMax_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcYInterval(); }); + connect(yMin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcYInterval(); }); + connect(ySize_, QOverload::of(&QSpinBox::valueChanged), this, + [this](int) { calcYInterval(); }); + connect(ySpacing_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double) { calcYPoints(); }); + connect(restoreBtn, &QPushButton::clicked, this, &GridWizardDialog::loadParams); +} + +void GridWizardDialog::loadAlgorithms() { + if (!repo_) return; + QPointer self(this); + repo_->listGridAlgorithm(dsId_, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->algoList_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + const QString name = o.value(QStringLiteral("scriptName")).toString(); + const QString code = o.value(QStringLiteral("scriptCode")).toString(); + auto* item = new QListWidgetItem(name, self->algoList_); + item->setData(Qt::UserRole, code); + } + if (self->algoList_->count() > 0) self->algoList_->setCurrentRow(0); // 默认首项 + }); +} + +void GridWizardDialog::goToStep2() { + if (!algoList_->currentItem()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择网格方法")); + return; + } + stack_->setCurrentIndex(1); + setFixedWidth(kStep2W); + nextBtn_->setVisible(false); + prevBtn_->setVisible(true); + okBtn_->setVisible(true); + loadParams(); +} + +void GridWizardDialog::loadParams() { + if (!repo_) return; + QPointer self(this); + repo_->getGridRawDataParams(dsId_, [self](bool ok, QJsonObject d, QString) { + if (!self || !ok) return; + self->xMin_->setValue(d.value(QStringLiteral("xmin")).toDouble()); + self->xMax_->setValue(d.value(QStringLiteral("xmax")).toDouble()); + self->yMin_->setValue(d.value(QStringLiteral("ymin")).toDouble()); + self->yMax_->setValue(d.value(QStringLiteral("ymax")).toDouble()); + self->vMin_->setValue(d.value(QStringLiteral("vmin")).toDouble()); + self->vMax_->setValue(d.value(QStringLiteral("vmax")).toDouble()); + // 原版 onMounted:X点数固定 100 → 算 X间距 → Y间距=X间距 → Y点数=ceil。 + self->xSize_->setValue(kDefaultXSize); + self->calcXInterval(); + const double dx = self->xSpacing_->value(); + if (dx > 0) self->ySpacing_->setValue(dx); // 触发 calcYPoints + }); +} + +void GridWizardDialog::calcXInterval() { + const int n = xSize_->value(); + const double range = xMax_->value() - xMin_->value(); + if (n > 0 && range > 0) { + QSignalBlocker b(xSpacing_); // 防与 calcXPoints 互触发 + xSpacing_->setValue(range / n); + } +} + +void GridWizardDialog::calcXPoints() { + const double iv = xSpacing_->value(); + const double range = xMax_->value() - xMin_->value(); + if (iv > 0 && range > 0) { + QSignalBlocker b(xSize_); + xSize_->setValue(static_cast(std::lround(range / iv))); // 原版 round + } +} + +void GridWizardDialog::calcYInterval() { + const int n = ySize_->value(); + const double range = yMax_->value() - yMin_->value(); + if (n > 0 && range > 0) { + QSignalBlocker b(ySpacing_); + ySpacing_->setValue(range / n); + } +} + +void GridWizardDialog::calcYPoints() { + const double iv = ySpacing_->value(); + const double range = yMax_->value() - yMin_->value(); + if (iv > 0 && range > 0) { + QSignalBlocker b(ySize_); + ySize_->setValue(static_cast(std::ceil(range / iv))); // 原版 ceil(与 X 的 round 不同) + } +} + +void GridWizardDialog::onConfirm() { + if (!repo_ || !algoList_->currentItem()) { reject(); return; } + // 原版按 Xmax/Ymax/数据值分别提示。 + if (xMax_->value() < xMin_->value()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("Xmax不能小于Xmin")); + return; + } + if (yMax_->value() < yMin_->value()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("Ymax不能小于Ymin")); + return; + } + if (vMax_->value() < vMin_->value()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("数据值max不能小于数据值min")); + return; + } + GridToParams p; + p.dsObjectId = dsId_; + p.actionCode = algoList_->currentItem()->data(Qt::UserRole).toString(); + p.xMin = xMin_->value(); p.xMax = xMax_->value(); + p.yMin = yMin_->value(); p.yMax = yMax_->value(); + p.vMin = vMin_->value(); p.vMax = vMax_->value(); + p.xSize = xSize_->value(); p.ySize = ySize_->value(); + p.xSpacing = xSpacing_->value(); p.ySpacing = ySpacing_->value(); + p.logFormat = (saveFormat_->currentData().toString() == QStringLiteral("log")); + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->toGrid(buildGridToBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("网格化失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/GridWizardDialog.hpp b/src/app/panels/chart/GridWizardDialog.hpp new file mode 100644 index 0000000..bde55b5 --- /dev/null +++ b/src/app/panels/chart/GridWizardDialog.hpp @@ -0,0 +1,64 @@ +#pragma once +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QSpinBox; +class QPushButton; +class QStackedWidget; +class QListWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 网格化向导(2 步,1:1 复刻原版 GridDialog): +// 步骤 1:listGridAlgorithm 选算法(单选列表,scriptName 显示 / scriptCode 提交)。 +// 步骤 2:getGridRawDataParams 取 x/y/v min/max 默认 + 点数/间距/保存格式(线性|对数), +// 确认 → toGrid。成功 accept(),调用方(视图)随后重载网格重绘。 +// I1(网格视图工具条「网格」)与 O1(原数据工具条「网格」)共用本向导。 +class GridWizardDialog : public QDialog { + Q_OBJECT +public: + GridWizardDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent = nullptr); + +private: + void buildStep1(); // 步骤 1:算法选择列表 + void buildStep2(); // 步骤 2:网格参数 + 数据值设置(两分组卡片) + void loadAlgorithms(); // 步骤 1:拉算法列表 + void loadParams(); // 步骤 2:拉 x/y/v 默认参数(兼「恢复默认值」) + void goToStep2(); // 下一步(校验算法已选) + void calcXInterval(); // Xmax/Xmin/X点数变 → X间距=(xMax-xMin)/xSize + void calcXPoints(); // X间距变 → X点数=round((xMax-xMin)/xSpacing) + void calcYInterval(); // Ymax/Ymin/Y点数变 → Y间距=(yMax-yMin)/ySize + void calcYPoints(); // Y间距变 → Y点数=ceil((yMax-yMin)/ySpacing) + void onConfirm(); // 确认 → toGrid + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + + QStackedWidget* stack_ = nullptr; + QListWidget* algoList_ = nullptr; // 算法单选列表(userData=scriptCode) + + QDoubleSpinBox* xMin_ = nullptr; + QDoubleSpinBox* xMax_ = nullptr; + QDoubleSpinBox* yMin_ = nullptr; + QDoubleSpinBox* yMax_ = nullptr; + QDoubleSpinBox* vMin_ = nullptr; + QDoubleSpinBox* vMax_ = nullptr; + QSpinBox* xSize_ = nullptr; + QSpinBox* ySize_ = nullptr; + QDoubleSpinBox* xSpacing_ = nullptr; + QDoubleSpinBox* ySpacing_ = nullptr; + QComboBox* saveFormat_ = nullptr; // linear / log + + QPushButton* nextBtn_ = nullptr; + QPushButton* prevBtn_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormDialog.cpp b/src/app/panels/chart/InversionFormDialog.cpp new file mode 100644 index 0000000..4ac6abb --- /dev/null +++ b/src/app/panels/chart/InversionFormDialog.cpp @@ -0,0 +1,175 @@ +#include "panels/chart/InversionFormDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" +#include "dto/NavDto.hpp" // parseEditableForm(与对象/结构编辑共用的动态表单解析) +#include "panels/DynamicFormEditor.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr char kVisualResistivityCode[] = "script_visual_resistivity_data"; +} // namespace + +InversionFormDialog::InversionFormDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, + QString dsId, QString projectId, QWidget* parent) + : QDialog(parent), + mode_(mode), + repo_(repo), + dsId_(std::move(dsId)), + projectId_(std::move(projectId)) { + setWindowTitle(mode_ == Mode::Inversion ? QStringLiteral("反演运算") + : QStringLiteral("反演参数设置")); + setModal(true); + resize(480, 360); + + auto* root = formkit::dialogRoot(this); + + // 模型选择行(label + 下拉)。生成视电阻率下拉禁用(复刻原版 disabled)。 + auto* modelLay = new QVBoxLayout(); + modelLay->addWidget(formkit::editLabel(QStringLiteral("反演模型"), this)); + // 空态感知下拉:反演模型异步加载(listInversionScripts)。反演运算模式占位「请选择反演模型」 + // (替代旧的空首项 allow-clear hack),未选显占位、无脚本弹「暂无数据」;生成视电阻率模式 + // 禁用并由 loadScripts 显式选中,占位不影响其默认选中。 + modelCombo_ = formkit::comboBox(QStringLiteral("请选择反演模型"), this); + if (mode_ == Mode::ApparentResistivity) modelCombo_->setEnabled(false); + modelLay->addWidget(modelCombo_); + root->addLayout(modelLay); + + // 动态字段容器:复用 DynamicFormEditor(按 displayComponentType 渲染 11 种控件 + 必填校验)。 + // 放滚动区内,避免字段多时撑爆对话框。 + auto* scroll = new QScrollArea(this); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + editor_ = new DynamicFormEditor(); + scroll->setWidget(editor_); + root->addWidget(scroll, 1); + + // 底部按钮(取消 / 确定)。 + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + okBtn_ = new QPushButton(QStringLiteral("确定"), this); + okBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(okBtn_); + root->addLayout(btnLay); + + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(okBtn_, &QPushButton::clicked, this, &InversionFormDialog::onConfirm); + // 反演运算下拉可切换模型(allow-clear 在 Qt 以可空首项体现);生成视电阻率禁用故不触发。 + connect(modelCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onModelChanged(); }); + + loadScripts(); +} + +void InversionFormDialog::loadScripts() { + if (!repo_) return; + QPointer self(this); + const Mode mode = mode_; + repo_->listInversionScripts(dsId_, [self, mode](bool ok, QJsonArray list, QString) { + if (!self) return; + if (!ok) return; + QSignalBlocker block(self->modelCombo_); // 填充期不触发 onModelChanged + self->modelCombo_->clear(); + // 反演运算:不再插空首项——占位文案 + currentIndex=-1 即「无默认选中」(对应原版 allow-clear)。 + int defaultIdx = -1; + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + const QString label = o.value(QStringLiteral("label")).toString(); + const QString value = o.value(QStringLiteral("value")).toString(); + const QString code = o.value(QStringLiteral("code")).toString(); + self->modelCombo_->addItem(label, value); + if (mode == Mode::ApparentResistivity && + code == QLatin1String(kVisualResistivityCode)) { + defaultIdx = self->modelCombo_->count() - 1; // 默认选中视电阻率(复刻原版) + } + } + if (mode == Mode::ApparentResistivity) { + // 默认选中视电阻率项(未命中则退首项);setCurrentIndex 触发 onModelChanged 拉表单。 + self->modelCombo_->setCurrentIndex(defaultIdx >= 0 ? defaultIdx : 0); + self->onModelChanged(); + } + }); +} + +void InversionFormDialog::onModelChanged() { + const QString typeId = modelCombo_->currentData().toString(); + if (typeId.isEmpty()) { + editor_->clear(); // 清空表单(复刻 changeModel: 清空 dynamicForms) + return; + } + loadDynamicForm(typeId); +} + +void InversionFormDialog::loadDynamicForm(const QString& typeId) { + if (!repo_) return; + QPointer self(this); + repo_->getDynamicForm(projectId_, typeId, [self](bool ok, QJsonObject data, QString) { + if (!self) return; + if (!ok) return; + // 复用 parseEditableForm(formList → values → displayComponentType/requiredType/optionsObject) + // + DynamicFormEditor(11 种控件渲染)。confType 对反演渲染无影响,取 0 占位。 + self->editor_->setForm(geopro::data::dto::parseEditableForm(data, 0)); + }); +} + +void InversionFormDialog::onConfirm() { + if (!repo_) { reject(); return; } + const QString scriptId = modelCombo_->currentData().toString(); + if (scriptId.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择反演模型")); + return; + } + + // 必填校验(requiredType===1):拦截提交并聚焦首个缺失字段(对照原版 a-form rules)。 + QString missing; + if (!editor_->validateRequired(&missing)) { + editor_->focusFirstInvalid(); + QMessageBox::warning(this, windowTitle(), + QStringLiteral("请填写必填项:%1").arg(missing)); + return; + } + + // 由各动态控件收集 {fieldCode: value}。生成视电阻率:空值不进体(对照原版 if(selectedValue)); + // 反演运算:保留全部字段(对照原版 InversionForm 提交 form.properties 整体)。 + const auto values = editor_->collectValues(); + QJsonObject fields; + const bool omitEmpty = (mode_ == Mode::ApparentResistivity); + for (auto it = values.constBegin(); it != values.constEnd(); ++it) { + if (omitEmpty && it.value().trimmed().isEmpty()) continue; + fields.insert(it.key(), it.value()); + } + + okBtn_->setEnabled(false); + QPointer self(this); + auto onDone = [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("反演任务失败") : msg); + } + }; + if (mode_ == Mode::Inversion) { + repo_->submitInversionTask(dsId_, scriptId, fields, onDone); + } else { + repo_->createVisualResistivityData(dsId_, scriptId, fields, onDone); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionFormDialog.hpp b/src/app/panels/chart/InversionFormDialog.hpp new file mode 100644 index 0000000..f73c756 --- /dev/null +++ b/src/app/panels/chart/InversionFormDialog.hpp @@ -0,0 +1,56 @@ +#pragma once +#include +#include +#include + +class QComboBox; +class QPushButton; +class QWidget; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +class DynamicFormEditor; + +// 反演动态表单对话框(1:1 复刻原版 web)。一套对话框服务两个入口,用 Mode 区分: +// - Inversion → measurement「反演运算」(原版 InversionForm.vue + postInversionTask) +// - ApparentResistivity → measurement「生成视电阻率」(原版 InversionDialog.vue + createVisualResistivityData) +// 共同流程:① 拉模型列表 → ② 选模型 → ③ 按 typeId 拉动态表单 → ④ 分组卡片渲染字段 → ⑤ 提交。 +// 动态字段渲染复用项目内 DynamicFormEditor(与对象/结构编辑同一套控件),按 displayComponentType +// 渲染 11 种控件并做 requiredType===1 必填校验、requiredType===2 只读禁用(对照原版 FormItem.vue)。 +// 差异(严格对照原版): +// Inversion:模型下拉可选(allow-clear)、无默认选中;提交体 {dsId,scriptId,properties}。 +// ApparentResistivity:模型下拉禁用、默认选中 code=='script_visual_resistivity_data'; +// 提交体 {dsObjectId,scriptId,scriptParamListJsonStr}。 +// 回调用 QPointer 守卫(对话框 modal exec,但异步回调仍可能在关闭后到达)。 +class InversionFormDialog : public QDialog { + Q_OBJECT +public: + enum class Mode { + Inversion, // 反演运算 + ApparentResistivity, // 生成视电阻率 + }; + + InversionFormDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, + QString dsId, QString projectId, QWidget* parent = nullptr); + +private: + void loadScripts(); // 拉模型列表填下拉 + void onModelChanged(); // 模型变更 → 拉动态表单 + void loadDynamicForm(const QString& typeId); + void onConfirm(); // 提交(按 mode 走不同端点) + + Mode mode_; + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + + QComboBox* modelCombo_ = nullptr; + DynamicFormEditor* editor_ = nullptr; // 动态字段渲染/收集/必填校验(项目内复用) + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionProcessOps.cpp b/src/app/panels/chart/InversionProcessOps.cpp new file mode 100644 index 0000000..523b586 --- /dev/null +++ b/src/app/panels/chart/InversionProcessOps.cpp @@ -0,0 +1,98 @@ +#include "panels/chart/InversionProcessOps.hpp" + +namespace geopro::app { + +QJsonObject buildGridToBody(const GridToParams& p) { + // 字段名/casing 严格对照原版 toGridTheData:xsize/ysize 小写,xSpacing/ySpacing 驼峰。 + return QJsonObject{ + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("actionCode"), p.actionCode}, + {QStringLiteral("xminValue"), p.xMin}, + {QStringLiteral("xmaxValue"), p.xMax}, + {QStringLiteral("yminValue"), p.yMin}, + {QStringLiteral("ymaxValue"), p.yMax}, + {QStringLiteral("xsize"), p.xSize}, + {QStringLiteral("xSpacing"), p.xSpacing}, + {QStringLiteral("ysize"), p.ySize}, + {QStringLiteral("ySpacing"), p.ySpacing}, + {QStringLiteral("vminValue"), p.vMin}, + {QStringLiteral("vmaxValue"), p.vMax}, + // saveDataValueType:1=线性 2=对数(原版 saveFormat==='linear'?1:2)。 + {QStringLiteral("saveDataValueType"), p.logFormat ? 2 : 1}, + }; +} + +QJsonObject buildWhitenBody(const WhitenParams& p) { + QJsonObject body{ + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("whiteningMethod"), p.whiteningMethod}, + }; + if (p.whiteningMethod == 1) { + body.insert(QStringLiteral("boundaryExtension"), p.boundaryExtension); + // 原版 whitenedWay 为布尔:whiteningType===0 → true(外部白化)。 + body.insert(QStringLiteral("whitenedWay"), p.whiteningType == 0); + } else if (p.whiteningMethod == 2) { + body.insert(QStringLiteral("whitenedDataId"), p.whitenedDataId); + } else if (p.whiteningMethod == 3) { + body.insert(QStringLiteral("modelWhiteningSubType"), p.modelWhiteningSubType); + } + return body; +} + +int filterBoundaryCode(const QString& dataEdge) { + if (dataEdge == QStringLiteral("whitening")) return 1; + if (dataEdge == QStringLiteral("skip")) return 2; + if (dataEdge == QStringLiteral("edgePoint")) return 3; + if (dataEdge == QStringLiteral("filling")) return 4; + return 1; // 默认设为无效点 +} + +int filterNoDataCode(const QString& noDataPoints) { + if (noDataPoints == QStringLiteral("expansion")) return 1; + if (noDataPoints == QStringLiteral("retain")) return 2; + if (noDataPoints == QStringLiteral("skip")) return 3; + if (noDataPoints == QStringLiteral("filling")) return 4; + return 4; // 默认填充 +} + +QJsonArray matrixToJson(const std::vector>& matrix) { + QJsonArray form; + for (const auto& r : matrix) { + QJsonArray row; + for (double v : r) row.append(v); + form.append(row); + } + return form; +} + +QJsonObject buildFilterApplyBody(const FilterApplyParams& p) { + // 全字段对照原版 useFilterToProcessData。 + return QJsonObject{ + {QStringLiteral("boundary"), filterBoundaryCode(p.dataEdge)}, + {QStringLiteral("boundaryValue"), p.dataEdgeValue}, + {QStringLiteral("column"), p.column}, + {QStringLiteral("dsObjectId"), p.dsObjectId}, + {QStringLiteral("noDataPoints"), filterNoDataCode(p.noDataPoints)}, + {QStringLiteral("noDataPointsValue"), p.noDataValue}, + {QStringLiteral("number"), p.number}, + {QStringLiteral("row"), p.row}, + {QStringLiteral("rowColumValue"), + QJsonObject{{QStringLiteral("form"), matrixToJson(p.matrix)}}}, + {QStringLiteral("filteringMethod"), p.filteringMethod}, + }; +} + +QJsonObject buildNewFilterBody(const QString& name, const QString& projectId, + const QString& parentId, + const std::vector>& matrix) { + return QJsonObject{ + {QStringLiteral("type"), 1}, + {QStringLiteral("rowColumValue"), + QJsonObject{{QStringLiteral("form"), matrixToJson(matrix)}}}, + {QStringLiteral("name"), name}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("parentId"), parentId}, + }; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/InversionProcessOps.hpp b/src/app/panels/chart/InversionProcessOps.hpp new file mode 100644 index 0000000..6114520 --- /dev/null +++ b/src/app/panels/chart/InversionProcessOps.hpp @@ -0,0 +1,77 @@ +#pragma once +#include + +#include +#include +#include + +namespace geopro::app { + +// 反演网格视图「处理类」操作的纯逻辑(仅依赖 QtCore JSON,无 Widgets/MOC)。 +// 拆出独立 TU 以便单测(与 ScatterDataOps / InversionFormParse 同范式)。 +// 字段名/取值一律对照原版 web(GridDialog/WhiteningDialog/FilterDialog)。 + +// ── I1/O1 网格化(toGrid,对照原版 toGridTheData)───────────────────────────── +// 网格化向导第二步参数集合。spacing/size 取整由调用方按 UI 输入填好。 +struct GridToParams { + QString dsObjectId; + QString actionCode; // = 算法项 scriptCode + double xMin = 0.0, xMax = 0.0, yMin = 0.0, yMax = 0.0; + double vMin = 0.0, vMax = 0.0; + int xSize = 0, ySize = 0; // 点数 + double xSpacing = 0.0, ySpacing = 0.0; // 间距 + bool logFormat = false; // 保存格式:false=线性(1) true=对数(2) +}; + +// 组装 toGrid 请求体(字段名/casing 对照原版:xsize/ysize 小写,xSpacing/ySpacing 驼峰)。 +QJsonObject buildGridToBody(const GridToParams& p); + +// ── I3 白化(whitenData,对照原版 whitenTheData)──────────────────────────── +// 三种白化方式(数值对照原版 whiteningMethod 1/2/3)。 +struct WhitenParams { + QString dsObjectId; + int whiteningMethod = 1; // 1 数据边界自动 / 2 白化模板 / 3 模型白化 + // method 1:边界扩展 + 内/外白化(whiteningType:0 外部 / 1 内部)。 + double boundaryExtension = 0.0; + int whiteningType = 0; + // method 2:选中的白化文件 id。 + QString whitenedDataId; + // method 3:模型白化子类型(2 梯形 / 1 矩形)。 + int modelWhiteningSubType = 2; +}; + +// 组装 whitenData 请求体(按 method 分支组装差异字段,对照原版)。 +// method 1 的 whitenedWay 是布尔(whiteningType==0 → true 外部)。 +QJsonObject buildWhitenBody(const WhitenParams& p); + +// ── I4 滤波(applyFilter,对照原版 useFilterToProcessData)──────────────────── +// 数据边缘处理方式 code(whitening→1 / skip→2 / edgePoint→3 / filling→4)。 +int filterBoundaryCode(const QString& dataEdge); +// 无数据点处理方式 code(expansion→1 / retain→2 / skip→3 / filling→4)。 +int filterNoDataCode(const QString& noDataPoints); + +struct FilterApplyParams { + QString dsObjectId; + QString dataEdge = QStringLiteral("whitening"); // 数据边缘下拉值 + double dataEdgeValue = 0.0; // 仅 filling 生效 + QString noDataPoints = QStringLiteral("filling"); // 无数据点下拉值 + double noDataValue = 0.0; // 仅 filling 生效 + int number = 1; // 滤波次数 + int row = 3, column = 3; // 矩阵行/列 + std::vector> matrix; // 滤波矩阵 + QString filteringMethod; // 选中滤波器节点名(字符串) +}; + +// 把二维矩阵转为 rowColumValue.form(QJsonArray of QJsonArray)。 +QJsonArray matrixToJson(const std::vector>& matrix); + +// 组装 applyFilter 请求体(全字段对照原版 useFilterToProcessData)。 +QJsonObject buildFilterApplyBody(const FilterApplyParams& p); + +// 组装 newFilter 请求体(自定义滤波器另存,对照原版 newTheFilter): +// {type:1, rowColumValue:{form:matrix}, name, projectId, parentId}。 +QJsonObject buildNewFilterBody(const QString& name, const QString& projectId, + const QString& parentId, + const std::vector>& matrix); + +} // namespace geopro::app diff --git a/src/app/panels/chart/RangeSlider.cpp b/src/app/panels/chart/RangeSlider.cpp new file mode 100644 index 0000000..d3dbd86 --- /dev/null +++ b/src/app/panels/chart/RangeSlider.cpp @@ -0,0 +1,104 @@ +#include "panels/chart/RangeSlider.hpp" + +#include +#include + +#include +#include + +namespace geopro::app { + +namespace { +constexpr int kHandleR = 7; // 手柄半径(像素) +constexpr int kTrackH = 4; // 轨道高度 +constexpr int kMargin = kHandleR + 2; // 左右留边,保证端点手柄不被裁 +const QColor kTrackBg(229, 230, 235); // 轨道底(浅灰) +const QColor kTrackSel(245, 63, 63); // 选中段:红(对照原版滑轨 #f53f3f) +const QColor kHandleFill(255, 255, 255); +const QColor kHandleBorder(245, 63, 63); // 手柄边框红(对照原版) +} // namespace + +RangeSlider::RangeSlider(QWidget* parent) : QWidget(parent) { + setMinimumHeight(2 * kHandleR + 6); + setMouseTracking(false); + setCursor(Qt::PointingHandCursor); +} + +void RangeSlider::setRange(double min, double max) { + min_ = min; + max_ = (max > min) ? max : min + 1.0; // 退化区间兜底,避免除零 + low_ = std::clamp(low_, min_, max_); + high_ = std::clamp(high_, min_, max_); + update(); +} + +void RangeSlider::setValues(double low, double high) { + low_ = std::clamp(std::min(low, high), min_, max_); + high_ = std::clamp(std::max(low, high), min_, max_); + update(); +} + +int RangeSlider::valueToX(double v) const { + const int usable = width() - 2 * kMargin; + if (usable <= 0 || max_ <= min_) return kMargin; + return kMargin + static_cast((v - min_) / (max_ - min_) * usable); +} + +double RangeSlider::xToValue(int px) const { + const int usable = width() - 2 * kMargin; + if (usable <= 0) return min_; + const double t = std::clamp(static_cast(px - kMargin) / usable, 0.0, 1.0); + return min_ + t * (max_ - min_); +} + +void RangeSlider::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const int cy = height() / 2; + const int xLo = valueToX(low_); + const int xHi = valueToX(high_); + + // 轨道底 + p.setPen(Qt::NoPen); + p.setBrush(kTrackBg); + p.drawRoundedRect(QRect(kMargin, cy - kTrackH / 2, width() - 2 * kMargin, kTrackH), 2, 2); + // 选中段(红) + p.setBrush(kTrackSel); + p.drawRect(QRect(xLo, cy - kTrackH / 2, std::max(0, xHi - xLo), kTrackH)); + + // 两个手柄 + QPen border(kHandleBorder, 2); + p.setPen(border); + p.setBrush(kHandleFill); + p.drawEllipse(QPoint(xLo, cy), kHandleR, kHandleR); + p.drawEllipse(QPoint(xHi, cy), kHandleR, kHandleR); +} + +void RangeSlider::mousePressEvent(QMouseEvent* event) { + const int px = event->position().toPoint().x(); + const int dLo = std::abs(px - valueToX(low_)); + const int dHi = std::abs(px - valueToX(high_)); + // 就近选手柄;距离相等时按点击位置相对中点决定(左半选低,右半选高)。 + if (dLo == dHi) + dragging_ = (px < valueToX((low_ + high_) / 2.0)) ? 1 : 2; + else + dragging_ = (dLo < dHi) ? 1 : 2; + mouseMoveEvent(event); +} + +void RangeSlider::mouseMoveEvent(QMouseEvent* event) { + if (dragging_ == 0) return; + const double v = xToValue(event->position().toPoint().x()); + if (dragging_ == 1) + low_ = std::min(v, high_); + else + high_ = std::max(v, low_); + update(); + emit rangeChanged(low_, high_); +} + +void RangeSlider::mouseReleaseEvent(QMouseEvent*) { + dragging_ = 0; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/RangeSlider.hpp b/src/app/panels/chart/RangeSlider.hpp new file mode 100644 index 0000000..f837ebe --- /dev/null +++ b/src/app/panels/chart/RangeSlider.hpp @@ -0,0 +1,40 @@ +#pragma once +#include + +namespace geopro::app { + +// 双手柄范围滑块(M3 数据过滤底部 a-slider range 的 Qt 复刻)。 +// Qt 无原生双手柄滑块,故自绘:一条水平轨道 + 两个圆形手柄(低/高), +// 选中段轨道用强调红(对照原版滑轨 #f53f3f)。值域用 double(连续,对照原版 step 0.01)。 +// 拖动手柄发 rangeChanged(low, high);外部 setRange/setValues 同步。 +class RangeSlider : public QWidget { + Q_OBJECT +public: + explicit RangeSlider(QWidget* parent = nullptr); + + void setRange(double min, double max); // 设定值域(数据 min/max) + void setValues(double low, double high); // 设定当前低/高手柄值(外部联动用,不发信号) + double lowValue() const { return low_; } + double highValue() const { return high_; } + +signals: + void rangeChanged(double low, double high); + +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private: + int valueToX(double v) const; // 值 → 像素 x + double xToValue(int px) const; // 像素 x → 值(夹到值域) + + double min_ = 0.0; + double max_ = 1.0; + double low_ = 0.0; + double high_ = 1.0; + int dragging_ = 0; // 0=无,1=低手柄,2=高手柄 +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 7268f10..b20f6a5 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -1,23 +1,48 @@ #include "panels/chart/RawDataChartView.hpp" +#include "ColorScaleConfigDialog.hpp" #include "panels/chart/ChartTheme.hpp" #include "panels/chart/ColorBarWidget.hpp" +#include "panels/chart/GridWizardDialog.hpp" +#include "panels/chart/InversionFormDialog.hpp" +#include "panels/chart/SaveAsDialog.hpp" +#include "panels/chart/ScatterDataOps.hpp" +#include "panels/chart/ScatterFilterDialog.hpp" #include "panels/chart/ScatterHoverTip.hpp" +#include "panels/chart/ScatterMarqueePicker.hpp" #include "panels/chart/ScatterPlotItem.hpp" +#include + +#include "repo/IDatasetCommandRepository.hpp" + +#include #include + +#include "EmptyAwareComboBox.hpp" #include +#include +#include #include #include +#include +#include #include +#include +#include #include #include #include #include +#include #include +#include +#include #include #include #include +#include + #include #include #include @@ -30,6 +55,7 @@ #include "panels/chart/LivePanner.hpp" #include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) namespace geopro::app { @@ -54,7 +80,7 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { auto* lblCurrentChart = new QLabel(QStringLiteral("当前图形:"), toolbar); - chartTypeCombo_ = new QComboBox(toolbar); + chartTypeCombo_ = new EmptyAwareComboBox(toolbar); chartTypeCombo_->addItem(QStringLiteral("散点图")); auto* btnSaveAs = new QToolButton(toolbar); @@ -64,8 +90,16 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { tbLay->addWidget(btnColorScale); tbLay->addWidget(lblCurrentChart); tbLay->addWidget(chartTypeCombo_); - tbLay->addWidget(btnSaveAs); + // 原版 .right-buttons margin-left:auto:另存为 右对齐。 tbLay->addStretch(); + tbLay->addWidget(btnSaveAs); + + // 反演原数据默认工具条交互(O1 网格 / O2 色阶配置 / O3 另存为)。 + connect(btnGrid, &QToolButton::clicked, this, [this, btnGrid]() { openGridWizard(btnGrid); }); + connect(btnColorScale, &QToolButton::clicked, this, + [this, btnColorScale]() { openInversionColorScale(btnColorScale); }); + connect(btnSaveAs, &QToolButton::clicked, this, + [this, btnSaveAs]() { openInversionSaveAs(btnSaveAs); }); lay->addWidget(toolbar); @@ -112,6 +146,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) { hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); hoverTip_->setField(&data_.scatter); + // M14 框选拾取器(最后装 → 事件链最先收到;active 时优先消费拖拽,禁用平移)。默认关闭。 + marquee_ = new ScatterMarqueePicker(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); + marquee_->setField(&data_.scatter); + marquee_->setOnSelected([this](const std::vector& idx) { onMarqueeSelected(idx); }); + // 允许随停靠面板自由收缩(不强制最小宽度)。 plot_->setMinimumSize(0, 0); @@ -177,6 +216,12 @@ void fillCombo(QComboBox* combo, const std::vector& o constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐) constexpr qreal kToolIconScale = 2.0; // 超采样倍率(HiDPI 清晰) +// measurement 工具条下拉固定宽度(对照原版 datasetTool.vue 各 a-select 的 width)。 +constexpr int kComboW_X = 120; // X 下拉 width:120px +constexpr int kComboW_Y = 160; // Y 下拉 width:160px +constexpr int kComboW_V = 160; // V 值下拉 width:160px +constexpr int kComboW_ValueType = 120; // 值类型下拉 width:120px + QPixmap makeToolIconCanvas(QPainter& p) { // 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。 const int dim = qRound(kToolIconPx * kToolIconScale); @@ -257,6 +302,41 @@ void styleToolIconButton(QToolButton* btn, const QIcon& icon) { btn->setCursor(Qt::PointingHandCursor); } +// core::Rgba → colorBar 颜色串(与 ColorScaleConfigDialog::rgbaToCss 同格式:不透明 #RRGGBB, +// 半透明 rgba(r,g,b,a∈0..1)),与后端 colorBar 互通。 +QString rgbaToColorBarCss(const geopro::core::Rgba& c) { + if (c.a >= 255) + return QStringLiteral("#%1%2%3") + .arg(c.r, 2, 16, QLatin1Char('0')) + .arg(c.g, 2, 16, QLatin1Char('0')) + .arg(c.b, 2, 16, QLatin1Char('0')) + .toUpper(); + return QStringLiteral("rgba(%1, %2, %3, %4)") + .arg(c.r) + .arg(c.g) + .arg(c.b) + .arg(QString::number(c.a / 255.0, 'g', 3)); +} + +// 组装色阶 properties(colorBar + lineConfig + labelConfig),与原版散点路径 +// newLvlColorLevel 一致(battery/scatters 仅发这三块,不含 lvlSchemeType 等等值面专属字段)。 +QJsonObject buildColorScaleProperties(const geopro::core::ColorScale& scale, + const ContourLineConfig& lineCfg) { + QJsonArray colorBar; + for (const auto& [value, color] : scale.stops()) + colorBar.append(QJsonArray{QString::number(value, 'f', 2), rgbaToColorBarCss(color)}); + QJsonObject lineConfig{ + {QStringLiteral("showLines"), lineCfg.lineShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.lineColor)}, + {QStringLiteral("lineType"), + lineCfg.dashed ? QStringLiteral("dashed") : QStringLiteral("solid")}}; + QJsonObject labelConfig{{QStringLiteral("showLabels"), lineCfg.labelShow}, + {QStringLiteral("color"), rgbaToColorBarCss(lineCfg.labelColor)}}; + return QJsonObject{{QStringLiteral("colorBar"), colorBar}, + {QStringLiteral("lineConfig"), lineConfig}, + {QStringLiteral("labelConfig"), labelConfig}}; +} + } // namespace void RawDataChartView::showNotImplemented(QWidget* anchor) { @@ -266,6 +346,412 @@ void RawDataChartView::showNotImplemented(QWidget* anchor) { QToolTip::showText(pos, QStringLiteral("暂未实现"), anchor); } +void RawDataChartView::setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter) { + cmdRepo_ = repo; + dsIdGetter_ = std::move(dsIdGetter); + projectIdGetter_ = std::move(projectIdGetter); +} + +void RawDataChartView::setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo) { + colorTplRepo_ = repo; +} + +void RawDataChartView::openInversionDialog(bool apparentResistivity, QWidget* anchor) { + // 无仓储/无 dsId 取值回调 → 退化占位(与未注入时一致)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { + showNotImplemented(anchor); + return; + } + const auto mode = apparentResistivity ? InversionFormDialog::Mode::ApparentResistivity + : InversionFormDialog::Mode::Inversion; + InversionFormDialog dlg(mode, cmdRepo_, dsId, projectId, this); + dlg.exec(); // 提交成功/失败由对话框内部反馈;本视图无需后续刷新(原版亦仅提示)。 +} + +void RawDataChartView::openGridWizard(QWidget* anchor) { + // O1:网格化向导(与网格视图 I1 共用 GridWizardDialog)。成功后散点视图无法渲染网格, + // 故仅提示成功(用户切到网格页签查看,与原版「生成新网格数据后刷新」语义一致)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + GridWizardDialog dlg(cmdRepo_, dsId, this); + if (dlg.exec() == QDialog::Accepted) + QMessageBox::information(this, QStringLiteral("网格化"), QStringLiteral("网格化成功!")); +} + +void RawDataChartView::openInversionColorScale(QWidget* anchor) { + // O2:反演原数据散点色阶(type1,businessCode 空串,对照原版 originPage)。 + if (data_.scale.empty()) { showNotImplemented(anchor); return; } + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } + std::vector samples = data_.scatter.v; + // 接通色阶模板库:注入仓储 + 当前 projectId + 载荷 templateId(另存为/打开/覆盖 可用)。 + ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, colorTplRepo_, + projectIdGetter_ ? projectIdGetter_() : QString(), data_.templateId, + this); + if (dlg.exec() != QDialog::Accepted) return; + + // 本地重建上色重绘。 + data_.scale = dlg.colorScale(); + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + colorBar_->setColorScale(data_.scale); + showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success) + + // 持久化(businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空) + {QStringLiteral("businessCode"), QString()}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + }; + QPointer self(this); + cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { + if (!self || ok) return; + QMessageBox::warning(self, QStringLiteral("色阶配置"), + msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg); + }); +} + +void RawDataChartView::openInversionSaveAs(QWidget* anchor) { + // O3:另存为(复用 SaveAsDialog::Inversion → saveInversionAsData)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::Inversion, cmdRepo_, dsId, this); + dlg.exec(); +} + +QString RawDataChartView::currentVFieldCode() const { + if (vCombo_ && vCombo_->currentIndex() >= 0) { + const QString code = vCombo_->currentData().toString(); + if (!code.isEmpty()) return code; + } + return QStringLiteral("R0"); // 默认视电阻率(与初始加载一致) +} + +void RawDataChartView::redrawScatter() { + // 用当前 data_.scatter + colorSvc_ 重绘(M7/M8 本地变换/色阶变更后复用)。 + if (!scatterItem_ || !colorSvc_) return; + // 数据范围跟随当前 v 有限值 min/max(cauto,与初始上色一致)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin <= vMax) colorSvc_->setDataRange(vMin, vMax); + scatterItem_->setData(data_.scatter, colorSvc_); + if (hoverTip_) hoverTip_->setField(&data_.scatter); + plot_->replot(); +} + +void RawDataChartView::onShowHide(bool hide) { + // popconfirm 确认(原版 a-popconfirm):复刻确认文案。 + const QString text = hide ? QStringLiteral("该操作将会把已选择的散点进行隐藏?") + : QStringLiteral("该操作将会显示所有已经隐藏的散点?"); + const auto ans = QMessageBox::question(this, QStringLiteral("提示"), text, + QMessageBox::Ok | QMessageBox::Cancel); + if (ans != QMessageBox::Ok) return; + + // 选区联动(M14↔M1):隐藏且有选区 → 只对选中点(原版 getSelectedPointIds); + // 其余(隐藏无选区 / 显示)维持全部(原版显示恒为全部隐藏点)。 + const bool selective = hide && scatterItem_ && scatterItem_->hasSelection(); + + // 本地切换可见性。selective:逐点改 displayStatus(仅选中点隐藏);否则整体显隐全部方块。 + auto localToggle = [this, hide, selective]() { + if (!scatterItem_) return; + if (selective) { + scatterItem_->setData(data_.scatter, colorSvc_); // 重读 displayStatus 逐点生效 + scatterItem_->clearSelection(); + } else { + for (int& s : data_.scatter.displayStatus) s = hide ? 1 : 0; + scatterItem_->setScatterVisible(!hide); + if (!hide) scatterItem_->setData(data_.scatter, colorSvc_); // 显示全部:清逐点隐藏 + } + plot_->replot(); + }; + + // 收集要持久化的点 id:selective → 选中点 id;否则隐藏取可见点 / 显示取隐藏点。 + QJsonArray ids; + if (selective) { + for (const QString& id : scatterItem_->getSelectedIds()) ids.append(id); + // 先把选中点的 displayStatus 标为隐藏(本地,供 localToggle 重读生效)。 + const auto sel = scatterItem_->getSelectedIds(); + for (int i = 0; i < static_cast(data_.scatter.id.size()); ++i) { + const QString id = QString::fromStdString(data_.scatter.id[i]); + if (!id.isEmpty() && sel.end() != std::find(sel.begin(), sel.end(), id)) + data_.scatter.displayStatus[i] = 1; + } + } else { + ids = collectScatterIds(data_.scatter, hide); + } + + // 无仓储/无 dsId → 仅本地切换(退化,不持久化)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; } + + const int status = hide ? 1 : 0; + QPointer self(this); + cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, selective, localToggle](bool ok, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("操作失败") : msg); + return; + } + // selective 时本地 displayStatus 已在请求前更新;非 selective 同步整体状态。 + if (!selective) + for (int& s : self->data_.scatter.displayStatus) s = hide ? 1 : 0; + localToggle(); + }); +} + +void RawDataChartView::openFilterDialog(QWidget* anchor) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + // 传当前 V 值数组驱动分布直方图(与图上散点同源,反映当前值类型变换后的分布)。 + ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), data_.scatter.v, this); + dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。 +} + +void RawDataChartView::reloadForVValue() { + // V 值切换:重新请求散点 + 色阶(原版 vValueType change)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(vCombo_); return; } + QPointer self(this); + const QString vCode = currentVFieldCode(); + cmdRepo_->loadMeasurementScatter( + dsId, vCode, [self](bool ok, geopro::core::ScatterPayload payload, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("提示"), + msg.isEmpty() ? QStringLiteral("加载失败") : msg); + return; + } + // 保留已建 measurement 工具条(不重建):只换数据 + 色阶。setData 会重置 baseV_。 + self->setData(payload); + // 切 V 后值类型回到线性(与原版重新请求后默认线性一致)。 + if (self->valueTypeCombo_) { + const QSignalBlocker block(self->valueTypeCombo_); + self->valueTypeCombo_->setCurrentIndex(0); + } + }); +} + +void RawDataChartView::applyValueType() { + // 本地值类型变换(线性/倒数/对数):从 baseV_ 算,重新上色重绘(无后端)。 + if (!valueTypeCombo_ || baseV_.empty()) return; + const ScatterValueType type = scatterValueTypeFromCode(valueTypeCombo_->currentData().toString()); + data_.scatter.v = applyScatterValueType(baseV_, type); + redrawScatter(); +} + +void RawDataChartView::openScatterColorScale(QWidget* anchor) { + if (data_.scale.empty()) { showNotImplemented(anchor); return; } + // 数据范围取当前 v 有限值 min/max(散点上色 cauto 区间)。 + double vMin = std::numeric_limits::max(); + double vMax = std::numeric_limits::lowest(); + for (double v : data_.scatter.v) { + if (!std::isfinite(v)) continue; + if (v < vMin) vMin = v; + if (v > vMax) vMax = v; + } + if (vMin > vMax) { vMin = 0.0; vMax = 1.0; } + std::vector samples = data_.scatter.v; // 直方图/等积分层用原始标量 + + // 接通色阶模板库:注入仓储 + 当前 projectId + 载荷 templateId(另存为/打开/覆盖 可用)。 + ColorScaleConfigDialog dlg(data_.scale, vMin, vMax, std::move(samples), {}, colorTplRepo_, + projectIdGetter_ ? projectIdGetter_() : QString(), data_.templateId, + this); + if (dlg.exec() != QDialog::Accepted) return; + + // 本地重建 colorSvc_ 重绘散点(M8 即时生效)。 + data_.scale = dlg.colorScale(); + delete colorSvc_; + colorSvc_ = new ColorMapService(data_.scale); + redrawScatter(); + // 同步右侧竖条/底部横条色阶图例。 + if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale); + else colorBar_->setColorScale(data_.scale); + showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success) + + // 持久化到后端(saveColorGradation,businessCode=当前 V 值,type=3 散点路径)。 + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) return; // 无仓储 → 仅本地生效(不阻塞) + QJsonObject body{ + {QStringLiteral("dsObjectId"), dsId}, + {QStringLiteral("templateId"), data_.templateId}, // 读取到的色阶模板 id(对照原版,可空) + {QStringLiteral("businessCode"), currentVFieldCode()}, + {QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), buildColorScaleProperties(data_.scale, dlg.lineConfig())}, + }; + QPointer self(this); + cmdRepo_->saveColorGradation(body, [self](bool ok, QString msg) { + if (!self || ok) return; + QMessageBox::warning(self, QStringLiteral("色阶配置"), + msg.isEmpty() ? QStringLiteral("色阶保存失败") : msg); + }); +} + +void RawDataChartView::openSaveAs(QWidget* anchor) { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } + SaveAsDialog dlg(SaveAsDialog::Mode::RawData, cmdRepo_, dsId, this); + dlg.exec(); // 成功/失败由对话框内部反馈。 +} + +void RawDataChartView::exportDat() { + const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); + if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } + // 参数与原版 exportScatterData2Dat 一致:electrodePosition=2, ipDataMark=0, typeMeasurement=0。 + QPointer self(this); + cmdRepo_->exportMeasurementDat( + dsId, /*electrodePosition*/ 2, /*ipDataMark*/ 0, /*typeMeasurement*/ 0, + [self](bool ok, QString fileName, QString fileData, QString msg) { + if (!self) return; + if (!ok) { + QMessageBox::warning(self, QStringLiteral("导出"), + msg.isEmpty() ? QStringLiteral("导出失败!") : msg); + return; + } + const QString suggested = fileName.isEmpty() ? QStringLiteral("export.dat") : fileName; + const QString path = QFileDialog::getSaveFileName( + self, QStringLiteral("导出 DAT"), suggested, QStringLiteral("DAT 文件 (*.dat)")); + if (path.isEmpty()) return; + const QByteArray bytes = QByteArray::fromBase64(fileData.toUtf8()); + QSaveFile f(path); + if (!f.open(QIODevice::WriteOnly) || f.write(bytes) != bytes.size() || !f.commit()) { + QMessageBox::warning(self, QStringLiteral("导出"), QStringLiteral("写入文件失败。")); + } + }); +} + +void RawDataChartView::toggleInfoMode(bool on) { + infoMode_ = on; + if (on && !infoPanel_) { + // 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。 + // A/B/M/N 按原版逐项配色(item-label-a 红 / -b 蓝 / -m 绿 / -n 橙#F4B008)。 + infoPanel_ = new QWidget(plot_->canvas()); + infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel")); + auto* il = new QVBoxLayout(infoPanel_); + il->setContentsMargins(8, 6, 8, 6); + il->setSpacing(2); + infoHint_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_); + il->addWidget(infoHint_); + // 各属性行:标签上色(label QSS 不染行值),初始隐藏,命中点后填值显示。 + infoValA_ = new QLabel(infoPanel_); + infoValA_->setObjectName(QStringLiteral("infoA")); + infoValB_ = new QLabel(infoPanel_); + infoValB_->setObjectName(QStringLiteral("infoB")); + infoValM_ = new QLabel(infoPanel_); + infoValM_->setObjectName(QStringLiteral("infoM")); + infoValN_ = new QLabel(infoPanel_); + infoValN_->setObjectName(QStringLiteral("infoN")); + infoValRow_ = new QLabel(infoPanel_); + infoValPseu_ = new QLabel(infoPanel_); + for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_}) { + l->setVisible(false); + il->addWidget(l); + } + applyTokenizedStyleSheet( + infoPanel_, + QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};" + " border: 1px solid {{border/default}}; border-radius: 6px; }" + "QLabel { color: {{text/primary}}; }" + "QLabel#infoA { color: #FF0000; }" // A 红 + "QLabel#infoB { color: #0000FF; }" // B 蓝 + "QLabel#infoM { color: #008000; }" // M 绿 + "QLabel#infoN { color: #F4B008; }")); // N 橙黄 + // 画布事件过滤器:信息模式下点击找最近点显示属性。 + plot_->canvas()->installEventFilter(this); + } + if (infoPanel_) { + infoPanel_->setVisible(on); + if (on) { + infoPanel_->adjustSize(); + infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); + infoPanel_->raise(); + } + } +} + +void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) { + if (!infoValA_ || data_.scatter.x.empty()) return; + const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xTop); + const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft); + const auto& s = data_.scatter; + const std::size_t n = std::min(s.x.size(), s.y.size()); + double bestD2 = std::numeric_limits::max(); + std::size_t bestI = 0; + for (std::size_t i = 0; i < n; ++i) { + const double dx = xMap.transform(s.x[i]) - canvasPos.x(); + const double dy = yMap.transform(s.y[i]) - canvasPos.y(); + const double d2 = dx * dx + dy * dy; + if (d2 < bestD2) { bestD2 = d2; bestI = i; } + } + constexpr double kHit = 8.0; // 命中半径(像素) + if (bestD2 > kHit * kHit) return; // 未命中任何点 → 保持当前显示 + auto at = [](const std::vector& v, std::size_t i) { + return i < v.size() ? v[i] : 0.0; + }; + // 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis(A/B/M/N 标签逐项配色)。 + if (infoHint_) infoHint_->setVisible(false); + infoValA_->setText(QStringLiteral("A= %1").arg(QString::number(at(s.a, bestI), 'g', 6))); + infoValB_->setText(QStringLiteral("B= %1").arg(QString::number(at(s.b, bestI), 'g', 6))); + infoValM_->setText(QStringLiteral("M= %1").arg(QString::number(at(s.m, bestI), 'g', 6))); + infoValN_->setText(QStringLiteral("N= %1").arg(QString::number(at(s.n, bestI), 'g', 6))); + infoValRow_->setText(QStringLiteral("DataRow= %1").arg(QString::number(at(s.row, bestI), 'g', 6))); + infoValPseu_->setText( + QStringLiteral("Pseu_Resis= %1").arg(QString::number(at(s.pseu, bestI), 'g', 6))); + for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_}) + l->setVisible(true); + infoPanel_->adjustSize(); + infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); + infoPanel_->raise(); +} + +void RawDataChartView::toggleMarqueeMode(bool on) { + marqueeMode_ = on; + if (marquee_) marquee_->setActive(on); + if (!on && scatterItem_) { + // 退出框选:清选区高亮(与原版 exitSelectMode clearSelection 一致)。 + scatterItem_->clearSelection(); + plot_->replot(); + } +} + +void RawDataChartView::onMarqueeSelected(const std::vector& indices) { + // 框选完成:高亮框内散点(红框)。空框 → 清选区。 + if (!scatterItem_) return; + scatterItem_->setSelectedIndices(indices); + plot_->replot(); +} + +bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) { + // 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。 + if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) { + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) showPointInfoAt(me->position().toPoint()); + } + return QWidget::eventFilter(obj, ev); +} + void RawDataChartView::replotForAxis() { // 本地换 x/y(无网络):按下拉 fieldCode 从备选列取数据,重设 scatter.x/.y 并重绘。 if (!xCombo_ || !yCombo_ || !scatterItem_) return; @@ -303,65 +789,94 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba // [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标(HiDPI 清晰,随主题)。 auto* btnInfo = new QToolButton(toolbar); - btnInfo->setToolTip(QStringLiteral("信息")); + btnInfo->setToolTip(QStringLiteral("查看散点属性")); // 对照原版 datasetTool.vue tooltip styleToolIconButton(btnInfo, makeInfoIcon()); - connect(btnInfo, &QToolButton::clicked, this, [this, btnInfo]() { showNotImplemented(btnInfo); }); auto* btnMarquee = new QToolButton(toolbar); - btnMarquee->setToolTip(QStringLiteral("框选")); + btnMarquee->setToolTip(QStringLiteral("散点的点选")); // 对照原版 datasetTool.vue tooltip styleToolIconButton(btnMarquee, makeMarqueeIcon()); - connect(btnMarquee, &QToolButton::clicked, this, [this, btnMarquee]() { showNotImplemented(btnMarquee); }); // 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。 connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo, [btnInfo]() { btnInfo->setIcon(makeInfoIcon()); }); connect(&ThemeManager::instance(), &ThemeManager::changed, btnMarquee, [btnMarquee]() { btnMarquee->setIcon(makeMarqueeIcon()); }); - // 显示 / 隐藏:功能性——切换全部数据方块可见性(电极保留)。 + // [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis)。 + btnInfo->setCheckable(true); + connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); }); + // [▣] 框选:可勾选 → 进入框选模式(橡皮筋选框内散点高亮;显示/隐藏改对选中点)。 + btnMarquee->setCheckable(true); + connect(btnMarquee, &QToolButton::toggled, this, [this](bool on) { toggleMarqueeMode(on); }); + + // 显示 / 隐藏:popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换(M1)。 auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar); auto* btnHide = new QPushButton(QStringLiteral("隐藏"), toolbar); - connect(btnShow, &QPushButton::clicked, this, [this]() { - if (scatterItem_) { scatterItem_->setScatterVisible(true); plot_->replot(); } - }); - connect(btnHide, &QPushButton::clicked, this, [this]() { - if (scatterItem_) { scatterItem_->setScatterVisible(false); plot_->replot(); } - }); + connect(btnShow, &QPushButton::clicked, this, [this]() { onShowHide(/*hide*/ false); }); + connect(btnHide, &QPushButton::clicked, this, [this]() { onShowHide(/*hide*/ true); }); - // 数据过滤:占位(暂未实现)。 + // 数据过滤:范围过滤弹窗(M3)。 auto* btnFilter = new QPushButton(QStringLiteral("数据过滤"), toolbar); - connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { showNotImplemented(btnFilter); }); + connect(btnFilter, &QPushButton::clicked, this, [this, btnFilter]() { openFilterDialog(btnFilter); }); - // x / y 下拉:功能性(本地换列重绘);v / method 下拉:视觉占位(选不同 v/method 提示暂未实现)。 - xCombo_ = new QComboBox(toolbar); + // 导出 DAT:原版在页头「导出」,客户端页头为占位且跨 dd 共用,故 measurement 专属导出 + // 收纳进本工具条(M12)。点击 → exportMeasurementDat → 选路径写盘。 + auto* btnExport = new QPushButton(QStringLiteral("导出"), toolbar); + connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); }); + + // x / y 下拉:本地换列重绘;v 下拉:重新请求散点+色阶(M6);值类型下拉:本地变换(M7)。 + // 各下拉固定宽度对照原版 datasetTool.vue(X=120/Y=160/V=160/值类型=120)。 + xCombo_ = new EmptyAwareComboBox(toolbar); + xCombo_->setFixedWidth(kComboW_X); fillCombo(xCombo_, conf.x, conf.defaultX, QString()); - yCombo_ = new QComboBox(toolbar); + yCombo_ = new EmptyAwareComboBox(toolbar); + yCombo_->setFixedWidth(kComboW_Y); fillCombo(yCombo_, conf.y, conf.defaultY, QString()); - auto* vCombo = new QComboBox(toolbar); - fillCombo(vCombo, conf.v, conf.defaultV, QString()); - auto* methodCombo = new QComboBox(toolbar); - fillCombo(methodCombo, conf.method, QString(), conf.defaultMethod); + vCombo_ = new EmptyAwareComboBox(toolbar); + vCombo_->setFixedWidth(kComboW_V); + fillCombo(vCombo_, conf.v, conf.defaultV, QString()); + valueTypeCombo_ = new EmptyAwareComboBox(toolbar); + valueTypeCombo_->setFixedWidth(kComboW_ValueType); + // 值类型固定三项(原版 linearity/inverse/logarithm),本地变换无后端。 + valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity")); + valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse")); + valueTypeCombo_->addItem(QStringLiteral("对数"), QStringLiteral("logarithm")); + + // 无高程时禁用 X/Y 下拉(对照原版 :disabled="!currentHasElevation")。 + // 判断依据:高程相关备选列 altYElevationPseudo 非空即视为「有高程数据」(与 y 下拉 + // 「伪深度+高程」项的数据源一致;无高程时该列为空,X/Y 轴切换无意义故禁用)。 + const bool hasElevation = !data_.altYElevationPseudo.empty(); + xCombo_->setEnabled(hasElevation); + yCombo_->setEnabled(hasElevation); connect(xCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { replotForAxis(); }); connect(yCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { replotForAxis(); }); - // v / method:换选项 → 暂未实现(需重新请求散点/色阶,属重交互,本轮不做)。 - connect(vCombo, QOverload::of(&QComboBox::activated), this, - [this, vCombo](int) { showNotImplemented(vCombo); }); - connect(methodCombo, QOverload::of(&QComboBox::activated), this, - [this, methodCombo](int) { showNotImplemented(methodCombo); }); + // V 值切换:重新请求散点+色阶(用 activated 仅用户操作触发,填充期不误触)。 + connect(vCombo_, QOverload::of(&QComboBox::activated), this, + [this](int) { reloadForVValue(); }); + connect(valueTypeCombo_, QOverload::of(&QComboBox::activated), this, + [this](int) { applyValueType(); }); - // 色阶配置:占位(暂未实现)。 + // 色阶配置:复用 ColorScaleConfigDialog(散点上色路径),保存 + 本地重绘(M8)。 auto* btnColorScale = new QPushButton(QStringLiteral("色阶配置"), toolbar); - connect(btnColorScale, &QPushButton::clicked, this, [this, btnColorScale]() { showNotImplemented(btnColorScale); }); + connect(btnColorScale, &QPushButton::clicked, this, + [this, btnColorScale]() { openScatterColorScale(btnColorScale); }); - // 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为 —— 占位(暂未实现)。 + // 右侧主操作(蓝色):生成视电阻率数据 / 反演运算 / 另存为。 auto* btnGen = new QPushButton(QStringLiteral("生成视电阻率数据"), toolbar); auto* btnInvert = new QPushButton(QStringLiteral("反演运算"), toolbar); auto* btnSaveAs = new QPushButton(QStringLiteral("另存为"), toolbar); for (auto* b : {btnGen, btnInvert, btnSaveAs}) { b->setObjectName(QStringLiteral("primaryBtn")); // 蓝色主按钮(下方 QSS) - connect(b, &QPushButton::clicked, this, [this, b]() { showNotImplemented(b); }); } + // 反演运算 → submitInversionTask;生成视电阻率 → createVisualResistivityData(共享反演对话框)。 + connect(btnInvert, &QPushButton::clicked, this, + [this, btnInvert]() { openInversionDialog(/*apparentResistivity*/ false, btnInvert); }); + connect(btnGen, &QPushButton::clicked, this, + [this, btnGen]() { openInversionDialog(/*apparentResistivity*/ true, btnGen); }); + // 另存为:新增/覆盖弹窗 → saveRawData(M11)。 + connect(btnSaveAs, &QPushButton::clicked, this, + [this, btnSaveAs]() { openSaveAs(btnSaveAs); }); tbLay->addWidget(btnInfo); tbLay->addWidget(btnMarquee); @@ -370,10 +885,15 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba tbLay->addWidget(btnFilter); tbLay->addWidget(xCombo_); tbLay->addWidget(yCombo_); - tbLay->addWidget(vCombo); - tbLay->addWidget(methodCombo); + tbLay->addWidget(vCombo_); + tbLay->addWidget(valueTypeCombo_); tbLay->addWidget(btnColorScale); - tbLay->addStretch(); // 把主操作推到右侧 + tbLay->addStretch(); // 把导出 + 主操作推到右侧 + // 导出:原版在详情页头 Header(非工具条)。客户端页头「导出」HeaderAction 为跨 ddCode + // 共用的静态占位(PanelHeader 不暴露按钮/不发信号,IDetailView 亦无导出接口),按 ddCode + // 分派转发成本高且易误触其它视图;故 measurement 专属导出保留在工具条内,置于最右侧、 + // 紧邻主操作组,样式贴近原版(outline 风格的普通按钮)。 + tbLay->addWidget(btnExport); tbLay->addWidget(btnGen); tbLay->addWidget(btnInvert); tbLay->addWidget(btnSaveAs); @@ -405,7 +925,9 @@ void RawDataChartView::setPayload(const QVariant& payload) { void RawDataChartView::setData(const geopro::core::ScatterPayload& p) { data_ = p; + baseV_ = data_.scatter.v; // 缓存原始 v(线性),M7 值类型变换从原值算,不累积误差 if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) + if (marquee_) marquee_->setField(&data_.scatter); // M14 框选拾取同源重绑 // measurement 载荷(toolbar 非空):首次到来时建并替换工具条(视觉 1:1)。反演留空 → 不动。 if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar); diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index 38b2c51..f354f93 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -1,4 +1,7 @@ #pragma once +#include + +#include #include #include "model/detail/DetailPayloads.hpp" #include "panels/chart/ColorMapService.hpp" @@ -6,14 +9,21 @@ class QComboBox; class QVBoxLayout; +class QLabel; class QwtPlot; class QwtPlotRescaler; +namespace geopro::data { +class IDatasetCommandRepository; +class IColorTemplateRepository; +} + namespace geopro::app { class ColorBarWidget; class ScatterPlotItem; class ScatterHoverTip; +class ScatterMarqueePicker; // 原数据图表视图:工具条 + QwtPlot(x 轴顶部、Panner/Magnifier)+ 独立色阶条。 class RawDataChartView : public QWidget, public IDetailView { @@ -32,6 +42,20 @@ public: // 供外部访问(已不再是占位,保留兼容接口返回 plot_) QWidget* plotArea() const; + // 注入反演命令仓储 + dsId/projectId 取值回调(measurement 反演运算/生成视电阻率按钮)。 + // 可传空仓储 → 两按钮退化为「暂未实现」占位提示。 + void setCommandRepo(geopro::data::IDatasetCommandRepository* repo, + std::function dsIdGetter, + std::function projectIdGetter); + + // 注入色阶模板仓储(散点「色阶配置」编辑器「另存为/打开/覆盖」用;projectId 复用 + // setCommandRepo 注入的 projectIdGetter_)。可传空 → 编辑器后端按钮禁用。 + void setColorTemplateRepo(geopro::data::IColorTemplateRepository* repo); + +protected: + // 信息模式(M13)下捕获画布点击:找最近散点显示属性。其余事件不消费。 + bool eventFilter(QObject* obj, QEvent* ev) override; + private: // 工具条按载荷类型二选一:反演原数据 = ctor 默认建的 inversion 工具条;measurement = // 首个非空 ScatterToolbarConf 到来时建一次并替换(视觉 1:1)。建好后缓存,后续 setData 复用。 @@ -40,6 +64,29 @@ private: void replotForAxis(); // “暂未实现”轻提示(占位按钮/下拉点击)。 void showNotImplemented(QWidget* anchor); + // 打开反演动态表单对话框(反演运算/生成视电阻率共享)。无仓储/无 dsId → 占位提示。 + void openInversionDialog(bool apparentResistivity, QWidget* anchor); + + // 反演原数据默认工具条交互(O1/O2/O3): + void openGridWizard(QWidget* anchor); // O1 网格化向导(复用 GridWizardDialog) + void openInversionColorScale(QWidget* anchor); // O2 原数据散点色阶(type1,businessCode='') + void openInversionSaveAs(QWidget* anchor); // O3 另存为(复用 SaveAsDialog::Inversion) + + // measurement 交互: + void onShowHide(bool hide); // M1 显示/隐藏(popconfirm→持久化→本地切) + void openFilterDialog(QWidget* anchor); // M3 数据过滤 + void reloadForVValue(); // M6 V 值切换重新请求散点+色阶 + void applyValueType(); // M7 值类型本地变换重新上色 + void openScatterColorScale(QWidget* anchor); // M8 色阶配置(编辑+保存+重绘) + void openSaveAs(QWidget* anchor); // M11 另存为 + void exportDat(); // M12 导出 DAT + void toggleInfoMode(bool on); // M13 [i] 信息模式开关 + void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性 + void toggleMarqueeMode(bool on); // M14 框选模式开关 + void onMarqueeSelected(const std::vector& indices); // M14 框选回调:高亮选中点 + // 用 colorSvc_ 重绘当前散点(M7/M8 本地变换/色阶变更后复用)。 + void redrawScatter(); + QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode geopro::core::ScatterPayload data_; QwtPlot* plot_; @@ -53,11 +100,36 @@ private: bool measurementToolbar_ = false; // 已建 measurement 工具条 QComboBox* xCombo_ = nullptr; // measurement x 下拉 QComboBox* yCombo_ = nullptr; // measurement y 下拉 + QComboBox* vCombo_ = nullptr; // measurement V 值下拉(M6 重载) + QComboBox* valueTypeCombo_ = nullptr; // measurement 值类型下拉(M7 本地变换) + + // M7 值类型:保留原始 v(线性)以便倒数/对数从原值变换,不累积误差。 + std::vector baseV_; + // M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。 + bool infoMode_ = false; + QWidget* infoPanel_ = nullptr; // 属性覆盖面板(A/B/M/N/DataRow/Pseu_Resis) + QLabel* infoHint_ = nullptr; // 未点选时的提示文案 + // A/B/M/N 标签按原版配色(A 红 / B 蓝 / M 绿 / N 橙#F4B008);DataRow/Pseu_Resis 默认色。 + QLabel* infoValA_ = nullptr; + QLabel* infoValB_ = nullptr; + QLabel* infoValM_ = nullptr; + QLabel* infoValN_ = nullptr; + QLabel* infoValRow_ = nullptr; + QLabel* infoValPseu_ = nullptr; // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 ScatterPlotItem* scatterItem_ = nullptr; ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示(QObject,this 持有) + ScatterMarqueePicker* marquee_ = nullptr; // M14 框选拾取器(QObject,this 持有) + bool marqueeMode_ = false; // M14 框选模式开关 + + // 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。 + geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; + std::function dsIdGetter_; + std::function projectIdGetter_; + // 色阶模板仓储(注入;空则编辑器「另存为/打开」禁用)。 + geopro::data::IColorTemplateRepository* colorTplRepo_ = nullptr; }; } // namespace geopro::app diff --git a/src/app/panels/chart/SaveAsDialog.cpp b/src/app/panels/chart/SaveAsDialog.cpp new file mode 100644 index 0000000..cd6d4d4 --- /dev/null +++ b/src/app/panels/chart/SaveAsDialog.cpp @@ -0,0 +1,124 @@ +#include "panels/chart/SaveAsDialog.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" // makeEditForm / editLabel / capField / addDialogButtons +#include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) +#include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测) +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr int kInversionW = 420; // 规范 §7.5 小号对话框宽 +constexpr int kRawDataW = 420; // 同上(窄内容仍取小号标准宽,避免局促) +} // namespace + +SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent) + : QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) { + setModal(true); + + // 规范 §7.5 对话框外壳 + §7.0.10 唯一表单实现(makeEditForm)。 + auto* root = formkit::dialogRoot(this); + auto* form = formkit::makeEditForm(); + + if (mode_ == Mode::Inversion) { + // ── inversion:原版「另存为新的网格数据」,仅名称行 ── + setWindowTitle(QStringLiteral("另存为新的网格数据")); + setFixedWidth(scaledPx(kInversionW)); + + nameLabel_ = formkit::editLabel(QStringLiteral("名称"), this); // 原版 label「名称」 + nameEdit_ = new QLineEdit(this); + nameEdit_->setPlaceholderText(QStringLiteral("请输入名称")); + nameEdit_->setText(QStringLiteral("网格数据1")); // 原版默认值 + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); + } else { + // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」)── + setWindowTitle(QStringLiteral("数据另存为")); + setFixedWidth(scaledPx(kRawDataW)); + + auto* opWrap = new QWidget(this); + auto* opLay = new QHBoxLayout(opWrap); + opLay->setContentsMargins(0, 0, 0, 0); + auto* rbNew = new QRadioButton(QStringLiteral("新增"), opWrap); + auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), opWrap); + opGroup_ = new QButtonGroup(this); + opGroup_->addButton(rbNew, 1); + opGroup_->addButton(rbOverwrite, 0); + rbNew->setChecked(true); // 默认新增 + opLay->addWidget(rbNew); + opLay->addWidget(rbOverwrite); + opLay->addStretch(); + form->addRow(formkit::editLabel(QStringLiteral("操作"), this), opWrap); + + nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this); + nameEdit_ = new QLineEdit(this); + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); + + // 切到覆盖隐藏名称框,切回新增显示。 + connect(opGroup_, QOverload::of(&QButtonGroup::idClicked), this, [this](int id) { + const bool isNew = (id == 1); + nameLabel_->setVisible(isNew); + nameEdit_->setVisible(isNew); + }); + } + + // 规范 §7.5 底部操作栏:右对齐 取消(次) + 确认(主);确认需异步保存成功才关闭。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(okBtn_, &QPushButton::clicked, this, &SaveAsDialog::onConfirm); +} + +void SaveAsDialog::onConfirm() { + if (!repo_) { reject(); return; } + + // RawData 新增 / Inversion 均需名称(覆盖不需)。 + const int operationType = opGroup_ ? opGroup_->checkedId() : 1; + const bool needName = (mode_ == Mode::Inversion) || (operationType == 1); + const QString name = nameEdit_->text().trimmed(); + if (needName && name.isEmpty()) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请输入名称")); + return; + } + + okBtn_->setEnabled(false); + QPointer self(this); + auto onDone = [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + // 成功提示挂到父窗口(对话框随即关闭,toast 取顶层窗口锚定,故用 parentWidget)。 + if (auto* anchor = self->parentWidget()) showToast(anchor, QStringLiteral("保存成功")); + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("保存失败") : msg); + } + }; + if (mode_ == Mode::Inversion) { + repo_->saveInversionAsData(dsId_, name, onDone); + } else { + repo_->saveRawData(buildSaveRawDataBody(dsId_, operationType, name), onDone); + } +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/SaveAsDialog.hpp b/src/app/panels/chart/SaveAsDialog.hpp new file mode 100644 index 0000000..450fce1 --- /dev/null +++ b/src/app/panels/chart/SaveAsDialog.hpp @@ -0,0 +1,50 @@ +#pragma once +#include + +#include +#include +#include + +class QLineEdit; +class QButtonGroup; +class QLabel; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 「另存为」对话框(1:1 复刻原版 web,两形态可复用): +// - RawData(measurement「另存为」,原版 saveRawDataValue):新增/覆盖单选 +(新增时)名称框。 +// 提交体 {dsId, operationType(1新增/0覆盖), name?}(覆盖不带 name)。 +// - Inversion(inversion「另存为」,原版 saveVisualResistivityData):仅名称框。 +// 提交体 {dsObjectId, name}。 +// 设计成可复用:Mode 区分两形态;提交统一经 IDatasetCommandRepository::saveRawData / +// saveInversionAsData。回调用 QPointer 守卫(虽 modal exec,仍异步回调)。 +class SaveAsDialog : public QDialog { + Q_OBJECT +public: + enum class Mode { + RawData, // measurement 另存为(新增/覆盖 + 名称) + Inversion, // inversion 另存为(仅名称) + }; + + SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, + QWidget* parent = nullptr); + +private: + void onConfirm(); + + Mode mode_; + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + + QButtonGroup* opGroup_ = nullptr; // RawData:新增(1)/覆盖(0) + QLabel* nameLabel_ = nullptr; // RawData:仅新增可见 + QLineEdit* nameEdit_ = nullptr; + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterDataOps.cpp b/src/app/panels/chart/ScatterDataOps.cpp new file mode 100644 index 0000000..415863e --- /dev/null +++ b/src/app/panels/chart/ScatterDataOps.cpp @@ -0,0 +1,100 @@ +#include "panels/chart/ScatterDataOps.hpp" + +#include + +namespace geopro::app { + +ScatterValueType scatterValueTypeFromCode(const QString& code) { + if (code == QStringLiteral("inverse")) return ScatterValueType::Inverse; + if (code == QStringLiteral("logarithm")) return ScatterValueType::Logarithm; + return ScatterValueType::Linearity; // 'linearity' 及未知 +} + +std::vector applyScatterValueType(const std::vector& v, ScatterValueType type) { + std::vector out; + out.reserve(v.size()); + for (double x : v) { + switch (type) { + case ScatterValueType::Inverse: + out.push_back(x == 0.0 ? 0.0 : 1.0 / x); + break; + case ScatterValueType::Logarithm: + // v<=0 无定义:保持原值(避免 NaN/-inf 污染数据范围→全图取 NaN 色)。 + out.push_back(x > 0.0 ? std::log10(x) : x); + break; + case ScatterValueType::Linearity: + default: + out.push_back(x); + break; + } + } + return out; +} + +QJsonArray collectScatterIds(const geopro::core::ScatterField& field, bool hide) { + QJsonArray ids; + const std::size_t n = field.id.size(); + for (std::size_t i = 0; i < n; ++i) { + const std::string& id = field.id[i]; + if (id.empty()) continue; + // displayStatus 缺省视为可见(0)。 + const int status = i < field.displayStatus.size() ? field.displayStatus[i] : 0; + const bool visible = (status == 0); + // 隐藏:取可见点;显示:取隐藏点。 + if (hide == visible) ids.append(QString::fromStdString(id)); + } + return ids; +} + +QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFieldCode, double min, + double max) { + return QJsonObject{ + {QStringLiteral("sourceDsObjectId"), dsObjectId}, + {QStringLiteral("sourceVFieldCode"), vFieldCode}, + {QStringLiteral("min"), min}, + {QStringLiteral("max"), max}, + }; +} + +QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name) { + QJsonObject body{ + {QStringLiteral("dsId"), dsId}, + {QStringLiteral("operationType"), operationType}, + }; + if (operationType == 1) body.insert(QStringLiteral("name"), name); // 新增才带名称 + return body; +} + +ScatterHistogram buildScatterHistogram(const std::vector& v, double min, double max, + int binCount) { + ScatterHistogram h; + h.binMin = min; + h.binMax = max; + if (binCount <= 0 || !(max > min)) return h; // 退化:返回空 counts(视图渲染空态) + h.counts.assign(static_cast(binCount), 0); + h.step = (max - min) / binCount; + for (double x : v) { + if (!std::isfinite(x) || x < min || x > max) continue; // 区间外/非有限 跳过 + int idx = static_cast((x - min) / h.step); + if (idx >= binCount) idx = binCount - 1; // 末箱右闭(恰等于 max 归入末箱) + if (idx < 0) idx = 0; + ++h.counts[static_cast(idx)]; + } + return h; +} + +int toggledDisplayStatus(int currentStatus) { + return currentStatus == 0 ? 1 : 0; // 0 显示 → 1 隐藏;其余 → 0 显示 +} + +int countScatterInRange(const std::vector& v, double min, double max) { + if (max < min) return 0; + int count = 0; + for (double x : v) { + if (!std::isfinite(x)) continue; // 非有限值不计入(与直方图一致) + if (x >= min && x <= max) ++count; // 闭区间 [min,max],与原版过滤一致 + } + return count; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterDataOps.hpp b/src/app/panels/chart/ScatterDataOps.hpp new file mode 100644 index 0000000..1ffd9d2 --- /dev/null +++ b/src/app/panels/chart/ScatterDataOps.hpp @@ -0,0 +1,66 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/Field.hpp" + +namespace geopro::app { + +// measurement 散点的纯逻辑(仅依赖 QtCore JSON + core model,无 Widgets/MOC)。 +// 拆出独立 TU 以便单测(与 InversionFormParse 同范式)。 + +// 值类型变换(M7 值类型下拉:线性 / 倒数 / 对数)。原版本地变换显示,无后端。 +enum class ScatterValueType { + Linearity, // 线性:原值 v + Inverse, // 倒数:1/v(v==0 → 保持 0,避免 inf) + Logarithm, // 对数:log10(v)(v<=0 → 保持原值,避免 NaN/-inf 污染色阶范围) +}; + +// fieldCode('linearity'/'inverse'/'logarithm')→ 枚举;未知回退线性。 +ScatterValueType scatterValueTypeFromCode(const QString& code); + +// 对一组 v 值应用变换,返回新数组(不可变:不改入参)。 +std::vector applyScatterValueType(const std::vector& v, ScatterValueType type); + +// 收集「显示/隐藏」要持久化的点 id(M1)。 +// hide=true → 收集当前可见(displayStatus==0)的点 id(原版隐藏全部已选/可见点)。 +// hide=false → 收集当前隐藏(displayStatus!=0)的点 id(原版显示全部已隐藏点)。 +// id 为空串的点跳过(无效)。 +QJsonArray collectScatterIds(const geopro::core::ScatterField& field, bool hide); + +// 组装散点过滤请求体(M3,applyScatterFilter): +// {sourceDsObjectId, sourceVFieldCode, min, max}(字段名对照原版 applyScatterFilterInfo)。 +QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFieldCode, + double min, double max); + +// 组装 measurement「另存为」请求体(M11,saveRawData,对照原版 saveRawDataValue): +// {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。 +QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name); + +// 直方图分箱结果(M3 数据过滤分布图):等宽 binCount 个箱,落在 [min,max] 区间内计数。 +// binMin/binMax = 分箱区间端点(= 入参 min/max);step = 单箱宽度;counts[i] = 第 i 箱内点数。 +// 边界归属:左闭右开 [lo,hi),末箱右闭以纳入恰等于 max 的点。 +struct ScatterHistogram { + double binMin = 0.0; + double binMax = 0.0; + double step = 0.0; + std::vector counts; +}; + +// 对 v 数组在 [min,max] 区间按 binCount 等宽分箱(M3,对照原版 D3 直方图 stepRange=20)。 +// 非有限值(NaN/inf)跳过;区间外的点不计入;min>=max 或 binCount<=0 → counts 全 0。 +ScatterHistogram buildScatterHistogram(const std::vector& v, double min, double max, + int binCount); + +// 行级显隐状态取反(M2,对照原版 updateStatus = record.displayStatus ? 0 : 1)。 +// 入参/返回 0=显示、1=隐藏。当前显示(0) → 隐藏(1);当前隐藏(非0) → 显示(0)。 +int toggledDisplayStatus(int currentStatus); + +// 统计落在闭区间 [min,max] 内的有限值个数(M3 数据过滤「当前点数」/占比用)。 +// 非有限值(NaN/inf)不计入;max& v, double min, double max); + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterFilterDialog.cpp b/src/app/panels/chart/ScatterFilterDialog.cpp new file mode 100644 index 0000000..1c83635 --- /dev/null +++ b/src/app/panels/chart/ScatterFilterDialog.cpp @@ -0,0 +1,235 @@ +#include "panels/chart/ScatterFilterDialog.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" // makeEditForm / editLabel / capField +#include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:成功轻提示 +#include "panels/chart/RangeSlider.hpp" +#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody / countScatterInRange +#include "panels/chart/ScatterHistogram.hpp" +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/电位等量纲 +constexpr int kDialogW = 1000; // 原版 dataFilter.vue width:1000 +constexpr int kBodyH = 500; // 原版 .data-filter-container height:500 +constexpr int kInfoW = 300; // 原版 .filter-options width:300 +constexpr int kInfoLabelW = 120; // 原版 .label min-width:120 +const char* kHighlight = "#f77234"; // 原版橙色高亮(当前点数/原始点数) + +// 信息区一行:定宽 label(左)+ 值(右)。返回值标签供调用方写值/上色。 +QLabel* addInfoRow(QFormLayout* form, const QString& labelText) { + auto* lbl = new QLabel(labelText); + lbl->setMinimumWidth(kInfoLabelW); + auto* val = new QLabel(); + form->addRow(lbl, val); + return val; +} +} // namespace + +ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, + QString dsObjectId, QString vFieldCode, + std::vector values, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsObjectId_(std::move(dsObjectId)), + vFieldCode_(std::move(vFieldCode)) { + setWindowTitle(QStringLiteral("数据过滤")); + setModal(true); + resize(kDialogW, kBodyH + 120); // body 500 + 滑块/按钮/边距 + + // 全量有限值 + 数据域 + 原始点数(统计基线)。 + values_.reserve(values.size()); + for (double x : values) + if (std::isfinite(x)) values_.push_back(x); + originalPoints_ = static_cast(values_.size()); + if (!values_.empty()) { + dataMin_ = *std::min_element(values_.begin(), values_.end()); + dataMax_ = *std::max_element(values_.begin(), values_.end()); + } + + auto* root = new QVBoxLayout(this); + root->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl); + root->setSpacing(space::kLg); + + // ── 上半区:左直方图 + 右信息区(高 500)── + auto* bodyLay = new QHBoxLayout(); + bodyLay->setSpacing(space::kXl); + histogram_ = new ScatterHistogramView(this); + histogram_->setValues(values_); + histogram_->setMinimumHeight(kBodyH); + bodyLay->addWidget(histogram_, 1); + + // 右信息区(定宽 300,带边框卡片)。 + auto* info = new QFrame(this); + info->setObjectName(QStringLiteral("filterInfo")); + info->setFixedWidth(kInfoW); + applyTokenizedStyleSheet( + info, QStringLiteral("QFrame#filterInfo { border:1px solid {{border/default}};" + " border-radius:4px; }")); + auto* infoLay = new QVBoxLayout(info); + infoLay->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl); + infoLay->setSpacing(space::kLg); + + auto* statForm = new QFormLayout(); + statForm->setLabelAlignment(Qt::AlignLeft); + rangeValueLbl_ = addInfoRow(statForm, QStringLiteral("数值范围:")); + percentLbl_ = addInfoRow(statForm, QStringLiteral("当前数据量占比:")); + auto* origVal = addInfoRow(statForm, QStringLiteral("原始点数:")); + origVal->setText(QString::number(originalPoints_)); + origVal->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 + currentPtsLbl_ = addInfoRow(statForm, QStringLiteral("当前点数:")); + currentPtsLbl_->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 + infoLay->addLayout(statForm); + + // 最大值在上、最小值在下(对照原版输入框顺序)。可编辑表单走 §7.0.10 唯一实现。 + auto* inputForm = formkit::makeEditForm(); + maxSpin_ = new QDoubleSpinBox(this); + maxSpin_->setRange(-kSpinRange, kSpinRange); + maxSpin_->setDecimals(2); + minSpin_ = new QDoubleSpinBox(this); + minSpin_->setRange(-kSpinRange, kSpinRange); + minSpin_->setDecimals(2); + inputForm->addRow(formkit::editLabel(QStringLiteral("最大值"), this), maxSpin_); + inputForm->addRow(formkit::editLabel(QStringLiteral("最小值"), this), minSpin_); + infoLay->addLayout(inputForm); + + // 计算分布 / 重置(信息区中部,对照原版 .filter-actions)。 + auto* actionLay = new QHBoxLayout(); + auto* calcBtn = new QPushButton(QStringLiteral("计算分布"), this); + auto* resetBtn = new QPushButton(QStringLiteral("重置"), this); + actionLay->addStretch(); + actionLay->addWidget(calcBtn); + actionLay->addWidget(resetBtn); + infoLay->addLayout(actionLay); + infoLay->addStretch(); + + bodyLay->addWidget(info); + root->addLayout(bodyLay, 1); + + // ── 底部:范围滑块 ── + slider_ = new RangeSlider(this); + slider_->setRange(dataMin_, dataMax_); + slider_->setValues(dataMin_, dataMax_); + root->addWidget(slider_); + + // ── 底部按钮:取消 / 应用过滤(右对齐)── + auto* btnLay = new QHBoxLayout(); + btnLay->addStretch(); + auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); + applyBtn_ = new QPushButton(QStringLiteral("应用过滤"), this); + applyBtn_->setDefault(true); + btnLay->addWidget(cancelBtn); + btnLay->addWidget(applyBtn_); + root->addLayout(btnLay); + + // ── 三方联动(min/max 输入 ↔ 滑块 ↔ 直方图/统计)── + connect(minSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double v) { setCurrentRange(v, maxSpin_->value(), false, true); }); + connect(maxSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double v) { setCurrentRange(minSpin_->value(), v, false, true); }); + connect(slider_, &RangeSlider::rangeChanged, this, + [this](double lo, double hi) { setCurrentRange(lo, hi, true, false); }); + connect(calcBtn, &QPushButton::clicked, this, [this]() { refreshStats(); }); + connect(resetBtn, &QPushButton::clicked, this, &ScatterFilterDialog::onResetFilter); + connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply); + + loadConfig(); +} + +void ScatterFilterDialog::setCurrentRange(double min, double max, bool fromSlider, bool fromSpin) { + // 同步未发起方的控件(屏蔽信号避免回环),再刷新统计/直方图。 + if (!fromSpin) { + const QSignalBlocker b1(minSpin_); + const QSignalBlocker b2(maxSpin_); + minSpin_->setValue(min); + maxSpin_->setValue(max); + } + if (!fromSlider && slider_) { + const QSignalBlocker b(slider_); + slider_->setValues(min, max); + } + refreshStats(); +} + +void ScatterFilterDialog::refreshStats() { + const double mn = minSpin_->value(); + const double mx = maxSpin_->value(); + rangeValueLbl_->setText(QStringLiteral("%1 — %2") + .arg(QString::number(mn, 'g', 6), QString::number(mx, 'g', 6))); + const int cur = countScatterInRange(values_, mn, mx); + currentPtsLbl_->setText(QString::number(cur)); + const QString pct = (originalPoints_ > 0) + ? QStringLiteral("%1%").arg( + QString::number(100.0 * cur / originalPoints_, 'f', 2)) + : QStringLiteral("0%"); + percentLbl_->setText(pct); + if (histogram_) histogram_->setSelection(mn, mx); +} + +void ScatterFilterDialog::onResetFilter() { + // 重置:恢复到全量数据域(对照原版 resetFilter)。 + setCurrentRange(dataMin_, dataMax_, false, false); +} + +void ScatterFilterDialog::loadConfig() { + if (!repo_) { + setCurrentRange(dataMin_, dataMax_, false, false); // 无仓储:用数据域初值 + return; + } + QPointer self(this); + repo_->getScatterFilterConfig( + dsObjectId_, vFieldCode_, [self](bool ok, QJsonObject cfg, QString) { + if (!self) return; + if (!ok) { self->setCurrentRange(self->dataMin_, self->dataMax_, false, false); return; } + const double mn = cfg.value(QStringLiteral("min")).toDouble(); + const double mx = cfg.value(QStringLiteral("max")).toDouble(); + self->setCurrentRange(mn, mx, false, false); + }); +} + +void ScatterFilterDialog::onApply() { + if (!repo_) { reject(); return; } + const double mn = minSpin_->value(); + const double mx = maxSpin_->value(); + if (mn > mx) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("最小值不能大于最大值")); + return; + } + + applyBtn_->setEnabled(false); + QPointer self(this); + repo_->applyScatterFilter( + buildScatterFilterBody(dsObjectId_, vFieldCode_, mn, mx), [self](bool ok, QString msg) { + if (!self) return; + self->applyBtn_->setEnabled(true); + if (ok) { + // 成功提示挂父窗口(对话框随即关闭)。文案对照原版「应用过滤成功!」。 + if (auto* anchor = self->parentWidget()) + showToast(anchor, QStringLiteral("应用过滤成功!")); + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("应用过滤失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterFilterDialog.hpp b/src/app/panels/chart/ScatterFilterDialog.hpp new file mode 100644 index 0000000..b18536f --- /dev/null +++ b/src/app/panels/chart/ScatterFilterDialog.hpp @@ -0,0 +1,61 @@ +#pragma once +#include + +#include +#include + +class QDoubleSpinBox; +class QLabel; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +class ScatterHistogramView; +class RangeSlider; + +// 「数据过滤」对话框(1:1 复刻原版 web dataFilter.vue,弹窗宽 1000px): +// 左:数值分布直方图(自绘 ScatterHistogram,hover 柱变红 + tooltip); +// 右:信息区(数值范围 / 当前数据量占比 / 原始点数 / 当前点数橙色高亮 + 最大值/最小值输入 + +// 「计算分布」「重置」);底部:范围双手柄滑块 + 「取消」「应用过滤」。 +// min/max 输入、滑块、直方图选区三方联动;统计随当前区间实时更新。 +// 打开时经 getScatterFilterConfig 取 min/max 初值;「应用过滤」经 applyScatterFilter +// 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max});成功提示 toast。 +// 回调用 QPointer 守卫(虽 modal exec,仍异步回调)。 +class ScatterFilterDialog : public QDialog { + Q_OBJECT +public: + // values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布 + 统计);可空(空则空态)。 + ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, + QString vFieldCode, std::vector values, QWidget* parent = nullptr); + +private: + void loadConfig(); // 取 min/max 初值 + void onApply(); // 应用过滤 + void onResetFilter(); // 重置:恢复到全量数据域 + void setCurrentRange(double min, double max, bool fromSlider, bool fromSpin); // 三方联动 + void refreshStats(); // 刷新统计(占比/当前点数)与直方图选区 + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsObjectId_; + QString vFieldCode_; + + std::vector values_; // 全量有限 v 值(统计/分箱用) + double dataMin_ = 0.0; // 数据域下界 + double dataMax_ = 0.0; // 数据域上界 + int originalPoints_ = 0; // 原始点数(全量有限值个数) + + QLabel* rangeValueLbl_ = nullptr; // 「数值范围」值 + QLabel* percentLbl_ = nullptr; // 「当前数据量占比」值 + QLabel* currentPtsLbl_ = nullptr; // 「当前点数」值(橙色高亮) + QDoubleSpinBox* minSpin_ = nullptr; + QDoubleSpinBox* maxSpin_ = nullptr; + QPushButton* applyBtn_ = nullptr; + ScatterHistogramView* histogram_ = nullptr; // 左侧分布直方图 + RangeSlider* slider_ = nullptr; // 底部范围滑块 +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHistogram.cpp b/src/app/panels/chart/ScatterHistogram.cpp new file mode 100644 index 0000000..4662fe0 --- /dev/null +++ b/src/app/panels/chart/ScatterHistogram.cpp @@ -0,0 +1,175 @@ +#include "panels/chart/ScatterHistogram.hpp" + +#include +#include + +#include +#include +#include +#include +#include + +#include "panels/chart/ScatterDataOps.hpp" // buildScatterHistogram + +namespace geopro::app { + +namespace { +constexpr int kBinCount = 20; // 分箱数(对照原版 D3 stepRange=20) +const QColor kBarIn(64, 128, 255); // 选区内柱:蓝(对照原版 #4080FF) +const QColor kBarOut(200, 205, 215); // 选区外柱:灰 +const QColor kBarHover(245, 63, 63); // hover 柱:红(对照原版 #F53F3F) +const QColor kIndicator(64, 128, 255, 51); // 选区指示矩形:rgba(64,128,255,0.2) +const QColor kAxis(150, 150, 150); // 轴线/刻度 +constexpr int kPadL = 8; // 左右内边距 +constexpr int kPadR = 8; +constexpr int kPadTop = 8; // 顶部内边距 +constexpr int kAxisH = 18; // 底部刻度区高度 +constexpr int kBarGap = 1; // 柱间距(像素) +} // namespace + +ScatterHistogramView::ScatterHistogramView(QWidget* parent) : QWidget(parent) { + setMinimumHeight(160); + setMinimumWidth(280); + setMouseTracking(true); // 无按键也接收 MouseMove,用于 hover 高亮 +} + +void ScatterHistogramView::setValues(const std::vector& values) { + values_.clear(); + values_.reserve(values.size()); + for (double x : values) + if (std::isfinite(x)) values_.push_back(x); + if (values_.empty()) { + dataMin_ = dataMax_ = 0.0; + } else { + dataMin_ = *std::min_element(values_.begin(), values_.end()); + dataMax_ = *std::max_element(values_.begin(), values_.end()); + } + hoverBin_ = -1; + update(); +} + +void ScatterHistogramView::setSelection(double min, double max) { + selMin_ = min; + selMax_ = max; + hasSel_ = (min <= max); + update(); +} + +int ScatterHistogramView::binAtX(int px) const { + if (values_.empty() || !(dataMax_ > dataMin_)) return -1; + const QRect r = rect(); + const int plotL = r.left() + kPadL; + const int plotR = r.right() - kPadR; + const int plotW = plotR - plotL; + if (plotW <= 0) return -1; + const double binW = static_cast(plotW) / kBinCount; + if (binW <= 0) return -1; + const int idx = static_cast((px - plotL) / binW); + if (idx < 0 || idx >= kBinCount) return -1; + return idx; +} + +void ScatterHistogramView::mouseMoveEvent(QMouseEvent* event) { + const int bin = binAtX(event->position().toPoint().x()); + if (bin != hoverBin_) { + hoverBin_ = bin; + update(); + } + if (bin >= 0 && dataMax_ > dataMin_) { + // tooltip:数值范围 + 数据点数量(对照原版 D3 tooltip 两行)。 + const double step = (dataMax_ - dataMin_) / kBinCount; + const double lo = dataMin_ + bin * step; + const double hi = lo + step; + const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount); + const int cnt = (bin < static_cast(h.counts.size())) ? h.counts[static_cast(bin)] : 0; + QToolTip::showText(event->globalPosition().toPoint(), + QStringLiteral("数值范围: %1 - %2\n数据点数量: %3") + .arg(QString::number(std::llround(lo)), + QString::number(std::llround(hi)), + QString::number(cnt)), + this); + } else { + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); +} + +void ScatterHistogramView::leaveEvent(QEvent* event) { + if (hoverBin_ != -1) { + hoverBin_ = -1; + update(); + } + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + +void ScatterHistogramView::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, false); + + const QRect r = rect(); + const int plotL = r.left() + kPadL; + const int plotR = r.right() - kPadR; + const int plotTop = r.top() + kPadTop; + const int plotBottom = r.bottom() - kAxisH; + const int plotW = plotR - plotL; + const int plotH = plotBottom - plotTop; + if (plotW <= 0 || plotH <= 0) return; + + // 数据域无效(无值/退化区间)→ 仅画基线,空态。 + if (values_.empty() || !(dataMax_ > dataMin_)) { + p.setPen(kAxis); + p.drawLine(plotL, plotBottom, plotR, plotBottom); + return; + } + + // 在全量数据域上分箱(每柱代表一个等宽区间)。 + const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount); + const int n = static_cast(h.counts.size()); + if (n <= 0) return; + const int maxCount = *std::max_element(h.counts.begin(), h.counts.end()); + if (maxCount <= 0) { + p.setPen(kAxis); + p.drawLine(plotL, plotBottom, plotR, plotBottom); + return; + } + + // 值 → 像素 x 的映射(数据域 [dataMin,dataMax] → [plotL,plotR])。 + auto xPix = [&](double val) { + return plotL + (val - dataMin_) / (dataMax_ - dataMin_) * plotW; + }; + + // 选区指示矩形(先画,柱叠其上)。 + if (hasSel_ && selMax_ > selMin_) { + const double lo = std::clamp(selMin_, dataMin_, dataMax_); + const double hi = std::clamp(selMax_, dataMin_, dataMax_); + const QRectF ind(xPix(lo), plotTop, xPix(hi) - xPix(lo), plotH); + p.fillRect(ind, kIndicator); + } + + // 画柱:高度按计数归一;hover 柱红;其余按选区内蓝/外灰。 + const double binW = static_cast(plotW) / n; + for (int i = 0; i < n; ++i) { + const double binLo = dataMin_ + i * h.step; + const double binHi = binLo + h.step; + const int barH = static_cast(static_cast(h.counts[static_cast(i)]) / + maxCount * plotH); + const int bx = static_cast(plotL + i * binW) + kBarGap; + const int bw = std::max(1, static_cast(binW) - 2 * kBarGap); + // 柱中心落在选区内 → 高亮蓝(与原版“区间内点高亮”观感一致);hover 优先红。 + const double binCenter = (binLo + binHi) / 2.0; + const bool inSel = hasSel_ && binCenter >= selMin_ && binCenter <= selMax_; + const QColor c = (i == hoverBin_) ? kBarHover : (inSel ? kBarIn : kBarOut); + p.fillRect(bx, plotBottom - barH, bw, barH, c); + } + + // 底部基线 + 两端数值刻度(min/max)。 + p.setPen(kAxis); + p.drawLine(plotL, plotBottom, plotR, plotBottom); + p.drawText(QRect(plotL, plotBottom, plotW / 2, kAxisH), Qt::AlignLeft | Qt::AlignVCenter, + QString::number(dataMin_, 'g', 4)); + p.drawText(QRect(plotL + plotW / 2, plotBottom, plotW / 2, kAxisH), + Qt::AlignRight | Qt::AlignVCenter, QString::number(dataMax_, 'g', 4)); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHistogram.hpp b/src/app/panels/chart/ScatterHistogram.hpp new file mode 100644 index 0000000..d325fcb --- /dev/null +++ b/src/app/panels/chart/ScatterHistogram.hpp @@ -0,0 +1,43 @@ +#pragma once +#include + +#include + +namespace geopro::app { + +// 数值分布直方图(M3 数据过滤对话框左侧分布图,复刻原版 dataFilter.vue 的 D3 直方图)。 +// 自绘 QWidget:按全量 v 值的数据域分箱画柱;当前选定 [min,max] 区间内的柱高亮蓝、区间外灰, +// 并在选区上叠加半透明蓝色指示矩形(对照原版 .filter-indicator)。x 轴底部画数值刻度。 +// 选区由对话框输入框/范围联动 setSelection 更新。 +// hover:鼠标悬停某柱时该柱变红(对照原版 #F53F3F)+ QToolTip 显示「数值范围 / 数据点数量」。 +// 命名加 View 后缀以与 ScatterDataOps.hpp 的数据结构 struct ScatterHistogram(分箱结果)区分, +// 避免同名 geopro::app::ScatterHistogram 在同一 TU 内冲突。 +class ScatterHistogramView : public QWidget { + Q_OBJECT +public: + explicit ScatterHistogramView(QWidget* parent = nullptr); + + // 设置全量 v 值(取有限值数据域作为分箱总区间,分箱数固定 kBinCount)。 + void setValues(const std::vector& values); + // 设置当前选定区间(区间内柱高亮 + 指示矩形)。min>max 时不画指示矩形。 + void setSelection(double min, double max); + +protected: + void paintEvent(QPaintEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; // hover 高亮 + tooltip + void leaveEvent(QEvent* event) override; // 离开清 hover + +private: + // 命中坐标 x(像素)所在的柱索引;未命中返回 -1。绘制/命中共用同一映射,保证一致。 + int binAtX(int px) const; + + std::vector values_; // 全量有限 v 值(已过滤 NaN/inf) + double dataMin_ = 0.0; // 数据域下界(分箱总区间) + double dataMax_ = 0.0; // 数据域上界 + double selMin_ = 0.0; // 当前选区下界 + double selMax_ = 0.0; // 当前选区上界 + bool hasSel_ = false; // 选区有效(selMin<=selMax) + int hoverBin_ = -1; // 当前 hover 的柱索引(-1=无) +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterMarqueePicker.cpp b/src/app/panels/chart/ScatterMarqueePicker.cpp new file mode 100644 index 0000000..8039bc6 --- /dev/null +++ b/src/app/panels/chart/ScatterMarqueePicker.cpp @@ -0,0 +1,94 @@ +#include "panels/chart/ScatterMarqueePicker.hpp" + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "model/Field.hpp" +#include "panels/chart/ChartPickGeometry.hpp" + +namespace geopro::app { + +ScatterMarqueePicker::ScatterMarqueePicker(QwtPlot* plot, int xAxis, int yAxis, QObject* parent) + : QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) { + // 后装于 LivePanner → 事件链中先收到(active 时优先消费左键拖拽,禁用平移)。 + if (plot_ && plot_->canvas()) plot_->canvas()->installEventFilter(this); +} + +void ScatterMarqueePicker::setActive(bool on) { + active_ = on; + if (!on) { + dragging_ = false; + if (band_) band_->hide(); + if (plot_ && plot_->canvas()) plot_->canvas()->unsetCursor(); + } else if (plot_ && plot_->canvas()) { + plot_->canvas()->setCursor(Qt::CrossCursor); // 对齐原版 crosshair + } +} + +bool ScatterMarqueePicker::eventFilter(QObject* obj, QEvent* ev) { + if (!active_ || !plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev); + + switch (ev->type()) { + case QEvent::MouseButtonPress: { + auto* me = static_cast(ev); + if (me->button() != Qt::LeftButton) break; + dragging_ = true; + origin_ = me->pos(); + if (!band_) band_ = new QRubberBand(QRubberBand::Rectangle, plot_->canvas()); + band_->setGeometry(QRect(origin_, QSize())); + band_->show(); + return true; // 消费 → 不触发 LivePanner 平移 + } + case QEvent::MouseMove: { + if (!dragging_) break; + auto* me = static_cast(ev); + if (band_) band_->setGeometry(QRect(origin_, me->pos()).normalized()); + return true; + } + case QEvent::MouseButtonRelease: { + auto* me = static_cast(ev); + if (me->button() != Qt::LeftButton || !dragging_) break; + dragging_ = false; + if (band_) band_->hide(); + finishSelection(me->pos()); + return true; + } + case QEvent::MouseButtonDblClick: { + // 框选态吞掉双击,避免穿透触发 LivePanner(行为一致)。 + auto* me = static_cast(ev); + if (me->button() == Qt::LeftButton) return true; + break; + } + default: + break; + } + return QObject::eventFilter(obj, ev); +} + +void ScatterMarqueePicker::finishSelection(const QPoint& endPos) { + if (!field_ || !onSelected_ || !plot_) return; + // 近似单击(拖拽距离 < 3px)→ 视为误触,不产生空选回调(避免清掉已有选区)。 + const QRect pxBox = QRect(origin_, endPos).normalized(); + if (pxBox.width() < 3 && pxBox.height() < 3) return; + // 像素橡皮筋 → 数据坐标矩形(两端点各自反变换后取 normalized)。 + const QwtScaleMap xMap = plot_->canvasMap(xAxis_); + const QwtScaleMap yMap = plot_->canvasMap(yAxis_); + const double x0 = xMap.invTransform(origin_.x()); + const double x1 = xMap.invTransform(endPos.x()); + const double y0 = yMap.invTransform(origin_.y()); + const double y1 = yMap.invTransform(endPos.y()); + QRectF rect(QPointF(std::min(x0, x1), std::min(y0, y1)), + QPointF(std::max(x0, x1), std::max(y0, y1))); + onSelected_(pointsInRect(*field_, rect)); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterMarqueePicker.hpp b/src/app/panels/chart/ScatterMarqueePicker.hpp new file mode 100644 index 0000000..48e0387 --- /dev/null +++ b/src/app/panels/chart/ScatterMarqueePicker.hpp @@ -0,0 +1,54 @@ +#pragma once +#include +#include + +#include +#include +#include + +class QwtPlot; +class QRubberBand; + +namespace geopro::core { +struct ScatterField; +} + +namespace geopro::app { + +// M14 框选拾取器:开启后接管画布左键拖拽,画橡皮筋矩形 → 松手把框内散点下标回调出去。 +// 复刻原版散点「点选模式」(plotly selectmode:'select' 的 box-select 框选):开启时禁用平移 +// (事件优先于 LivePanner 消费拖拽),关闭时让位平移/hover。本类不拥有 plot/field(外部持有, +// 地址稳定);用 QPointer 守 plot,父子树管理生命周期。 +class ScatterMarqueePicker : public QObject { + Q_OBJECT +public: + // xAxis/yAxis:散点所在轴(RawData 为 xTop/yLeft)。field 由 RawDataChartView 持有(其 + // 成员 data_.scatter,地址稳定);onSelected 回调收框内下标(数据坐标命中)。 + ScatterMarqueePicker(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr); + + void setField(const geopro::core::ScatterField* field) { field_ = field; } + void setOnSelected(std::function&)> cb) { onSelected_ = std::move(cb); } + + // 开/关框选模式。关闭时收起橡皮筋(不清已选——清选区由调用方决定)。 + void setActive(bool on); + bool isActive() const { return active_; } + +protected: + bool eventFilter(QObject* obj, QEvent* ev) override; + +private: + void finishSelection(const QPoint& endPos); + + QPointer plot_; + int xAxis_; + int yAxis_; + const geopro::core::ScatterField* field_ = nullptr; + std::function&)> onSelected_; + + QRubberBand* band_ = nullptr; // 父=canvas,随之析构 + bool active_ = false; + bool dragging_ = false; + QPoint origin_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/ScatterPlotItem.cpp b/src/app/panels/chart/ScatterPlotItem.cpp index 85a051a..10b5c12 100644 --- a/src/app/panels/chart/ScatterPlotItem.cpp +++ b/src/app/panels/chart/ScatterPlotItem.cpp @@ -47,6 +47,27 @@ QRectF ScatterPlotItem::boundingRect() const { return bounding_; } +void ScatterPlotItem::setSelectedIndices(const std::vector& indices) { + selected_.clear(); + const int n = static_cast(std::min(field_.x.size(), field_.y.size())); + for (int i : indices) + if (i >= 0 && i < n) selected_.insert(i); +} + +void ScatterPlotItem::clearSelection() { + selected_.clear(); +} + +std::vector ScatterPlotItem::getSelectedIds() const { + std::vector ids; + for (int i : selected_) { + if (i < 0 || static_cast(i) >= field_.id.size()) continue; + const QString id = QString::fromStdString(field_.id[i]); + if (!id.isEmpty()) ids.push_back(id); + } + return ids; +} + void ScatterPlotItem::draw(QPainter* painter, const QwtScaleMap& xMap, const QwtScaleMap& yMap, @@ -82,8 +103,13 @@ void ScatterPlotItem::draw(QPainter* painter, // 隐藏数据方块:电极已绘,直接收尾(保留电极菱形)。 if (!scatterVisible_) { painter->restore(); return; } - painter->setPen(QPen(Qt::white, kPenWidth)); + const QPen normalPen(Qt::white, kPenWidth); + const QPen selectPen(QColor(255, 0, 0), kSelectPenWidth); // M14 选中红框 + const auto& status = field_.displayStatus; // 0=显示 1=隐藏(M14 选中隐藏后逐点生效) + const bool hasStatus = status.size() == n; for (std::size_t i = 0; i < n; ++i) { + // 逐点隐藏(M14:仅隐藏选中点):displayStatus!=0 的点不绘制。 + if (hasStatus && status[i] != 0) continue; double px = xMap.transform(xs[i]); double py = yMap.transform(ys[i]); double val = (i < vs.size()) ? vs[i] : 0.0; @@ -91,6 +117,7 @@ void ScatterPlotItem::draw(QPainter* painter, // 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。 if (!std::isfinite(val)) continue; auto c = colorSvc_->colorAtContinuous(val); + painter->setPen(selected_.count(static_cast(i)) ? selectPen : normalPen); painter->setBrush(QColor(c.r, c.g, c.b, c.a)); painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, kHalfSide * 2.0, kHalfSide * 2.0)); diff --git a/src/app/panels/chart/ScatterPlotItem.hpp b/src/app/panels/chart/ScatterPlotItem.hpp index 3b39b32..12503fe 100644 --- a/src/app/panels/chart/ScatterPlotItem.hpp +++ b/src/app/panels/chart/ScatterPlotItem.hpp @@ -1,8 +1,12 @@ #pragma once +#include +#include + #include "model/Field.hpp" #include "panels/chart/ColorMapService.hpp" #include #include +#include class QPainter; class QwtScaleMap; @@ -22,6 +26,13 @@ public: // 显示/隐藏数据方块(measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。 void setScatterVisible(bool on) { scatterVisible_ = on; } + // M14 框选:设/清选中点下标集(红色加粗描边高亮,对齐原版 marker.line red/2)。 + void setSelectedIndices(const std::vector& indices); + void clearSelection(); + // M14:当前选中点的 id(对照原版 getSelectedPointIds,供 saveDisplayStatus)。空 id 跳过。 + std::vector getSelectedIds() const; + bool hasSelection() const { return !selected_.empty(); } + int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } QRectF boundingRect() const override; @@ -36,6 +47,7 @@ private: ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 QRectF bounding_; bool scatterVisible_ = true; // false:仅画电极,隐藏数据方块 + std::set selected_; // M14 选中点下标(红框高亮) // 数据方块:原版 Plotly marker.size 12px(绝对像素直径)→ 半边长 6px,真 1:1。 static constexpr double kHalfSide = 6.0; // 方块半边长(像素,全宽 12px) @@ -44,6 +56,8 @@ private: // 相对数据方块 ≈1.33×(16 vs 12),实心 #BEBEBE 填充 + 白色 2px 描边。 static constexpr double kElectrodeHalfSide = 8.0; // 半对角(像素,全宽 16px) static constexpr double kElectrodePenWidth = 2.0; // 电极白色描边宽度(像素) + // M14 选中描边:红 2px(对齐原版 marker.line color:'red' width:2)。 + static constexpr double kSelectPenWidth = 2.0; }; } // namespace geopro::app diff --git a/src/app/panels/chart/TablePager.cpp b/src/app/panels/chart/TablePager.cpp index 3656647..446af0b 100644 --- a/src/app/panels/chart/TablePager.cpp +++ b/src/app/panels/chart/TablePager.cpp @@ -3,6 +3,8 @@ #include #include + +#include "EmptyAwareComboBox.hpp" #include #include #include @@ -90,7 +92,7 @@ TablePager::TablePager(QWidget* parent) : QWidget(parent) { lay->addWidget(new QLabel(QStringLiteral("页"), this)); - sizeCombo_ = new QComboBox(this); + sizeCombo_ = new EmptyAwareComboBox(this); for (int s : kPageSizes) sizeCombo_->addItem(QStringLiteral("%1条/页").arg(s), s); connect(sizeCombo_, &QComboBox::activated, this, [this](int i) { diff --git a/src/app/panels/chart/WhiteningDialog.cpp b/src/app/panels/chart/WhiteningDialog.cpp new file mode 100644 index 0000000..cbb1bf6 --- /dev/null +++ b/src/app/panels/chart/WhiteningDialog.cpp @@ -0,0 +1,178 @@ +#include "panels/chart/WhiteningDialog.hpp" + +#include + +#include +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "FormKit.hpp" // formkit::comboBox / makeEditForm / editLabel / capField / addDialogButtons +#include "Theme.hpp" +#include "panels/chart/InversionProcessOps.hpp" // buildWhitenBody +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::app { + +namespace { +constexpr int kDialogW = 560; // 规范 §7.5 中号对话框宽 + +// 把「标签 + 控件」按 §7.0 度量加入表单(右对齐定宽标签列 + 字段宽上限)。 +void addFormRow(QFormLayout* form, const QString& label, QWidget* ctrl, QWidget* parent) { + formkit::capField(ctrl); + form->addRow(formkit::editLabel(label, parent), ctrl); +} +} // namespace + +WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, + QString projectId, QString tmObjectId, QWidget* parent) + : QDialog(parent), + repo_(repo), + dsId_(std::move(dsId)), + projectId_(std::move(projectId)), + tmObjectId_(std::move(tmObjectId)) { + setWindowTitle(QStringLiteral("白化配置")); // 原版 whiteningSetting + setModal(true); + setFixedWidth(scaledPx(kDialogW)); + + // 规范 §7.5 对话框外壳:统一边距 + 行距(dialogRoot)。 + auto* root = formkit::dialogRoot(this); + + // 白化方式下拉(原版 3 项,数值对照 whiteningMethod 1/2/3)。 + methodCombo_ = new EmptyAwareComboBox(this); + methodCombo_->addItem(QStringLiteral("数据边界自动白化"), 1); + methodCombo_->addItem(QStringLiteral("白化文件"), 2); + methodCombo_->addItem(QStringLiteral("模型白化"), 3); + // §7.0.10 唯一实现:makeEditForm + editLabel(右对齐定宽标签列)。 + auto* methodForm = formkit::makeEditForm(); + addFormRow(methodForm, QStringLiteral("白化方式"), methodCombo_, this); + root->addLayout(methodForm); + + stack_ = new QStackedWidget(this); + root->addWidget(stack_); + + // ── 方式 1:数据边界自动白化(边界扩展文本框 + 内/外白化单选)──────────── + auto* page1 = new QWidget(this); + auto* p1 = formkit::makeEditForm(); + page1->setLayout(p1); + extension_ = new QLineEdit(QStringLiteral("0"), page1); // 原版 AInput,默认 "0" + addFormRow(p1, QStringLiteral("白化边界扩展"), extension_, page1); + auto* typeWrap = new QWidget(page1); + auto* typeRow = new QHBoxLayout(typeWrap); + typeRow->setContentsMargins(0, 0, 0, 0); + auto* rbOuter = new QRadioButton(QStringLiteral("外部白化"), typeWrap); + auto* rbInner = new QRadioButton(QStringLiteral("内部白化"), typeWrap); + rbOuter->setChecked(true); + whiteningType_ = new QButtonGroup(this); + whiteningType_->addButton(rbOuter, 0); + whiteningType_->addButton(rbInner, 1); + typeRow->addWidget(rbOuter); + typeRow->addWidget(rbInner); + typeRow->addStretch(); + p1->addRow(formkit::editLabel(QStringLiteral("白化"), page1), typeWrap); + stack_->addWidget(page1); + + // ── 方式 2:白化文件(选文件)────────────────────────────────────── + auto* page2 = new QWidget(this); + auto* p2 = formkit::makeEditForm(); + page2->setLayout(p2); + // 空态感知下拉:白化文件异步加载(listWhitenedData),未选显占位、无文件弹「暂无数据」。 + fileCombo_ = formkit::comboBox(QStringLiteral("请选择白化文件"), page2); + addFormRow(p2, QStringLiteral("选择白化文件"), fileCombo_, page2); + stack_->addWidget(page2); + + // ── 方式 3:模型白化(梯形/矩形)─────────────────────────────────── + auto* page3 = new QWidget(this); + auto* p3 = formkit::makeEditForm(); + page3->setLayout(p3); + auto* subWrap = new QWidget(page3); + auto* subRow = new QHBoxLayout(subWrap); + subRow->setContentsMargins(0, 0, 0, 0); + auto* rbTrap = new QRadioButton(QStringLiteral("梯形白化"), subWrap); + auto* rbRect = new QRadioButton(QStringLiteral("矩形白化"), subWrap); + rbTrap->setChecked(true); + modelSubType_ = new QButtonGroup(this); + modelSubType_->addButton(rbTrap, 2); // 2 梯形 + modelSubType_->addButton(rbRect, 1); // 1 矩形 + subRow->addWidget(rbTrap); + subRow->addWidget(rbRect); + subRow->addStretch(); + p3->addRow(formkit::editLabel(QStringLiteral("白化"), page3), subWrap); + stack_->addWidget(page3); + + // 规范 §7.5 底部操作栏:右对齐,取消(次) 左 + 确认(主) 右。 + // 确认需先异步 whitenData 成功才关闭 → 断开 Ok 默认 accept,改接 onConfirm。 + auto* box = formkit::addDialogButtons(root, this, QStringLiteral("确认"), QStringLiteral("取消")); + okBtn_ = box->button(QDialogButtonBox::Ok); + QObject::disconnect(box, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(okBtn_, &QPushButton::clicked, this, &WhiteningDialog::onConfirm); + connect(methodCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int) { onMethodChanged(methodCombo_->currentData().toInt()); }); + + loadWhitenedFiles(); // 预拉文件列表(方式 2 用) +} + +void WhiteningDialog::onMethodChanged(int method) { + stack_->setCurrentIndex(method - 1); // 1/2/3 → 页 0/1/2 +} + +void WhiteningDialog::loadWhitenedFiles() { + if (!repo_) return; + QPointer self(this); + repo_->listWhitenedData(projectId_, tmObjectId_, [self](bool ok, QJsonArray list, QString) { + if (!self || !ok) return; + self->fileCombo_->clear(); + for (const QJsonValue& v : list) { + const QJsonObject o = v.toObject(); + QString label = o.value(QStringLiteral("dsName")).toString(); + if (label.isEmpty()) label = o.value(QStringLiteral("name")).toString(); + const QString id = o.value(QStringLiteral("id")).toString(); + if (label.isEmpty()) label = QStringLiteral("未命名文件_%1").arg(id); + self->fileCombo_->addItem(label, id); + } + }); +} + +void WhiteningDialog::onConfirm() { + if (!repo_) { reject(); return; } + WhitenParams p; + p.dsObjectId = dsId_; + p.whiteningMethod = methodCombo_->currentData().toInt(); + if (p.whiteningMethod == 1) { + p.boundaryExtension = extension_->text().toDouble(); // 原版 Number(extension)||0 + p.whiteningType = whiteningType_->checkedId(); + } else if (p.whiteningMethod == 2) { + if (fileCombo_->currentIndex() < 0) { + QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择一个白化文件")); + return; + } + p.whitenedDataId = fileCombo_->currentData().toString(); + } else { + p.modelWhiteningSubType = modelSubType_->checkedId(); + } + + okBtn_->setEnabled(false); + QPointer self(this); + repo_->whitenData(buildWhitenBody(p), [self](bool ok, QString msg) { + if (!self) return; + self->okBtn_->setEnabled(true); + if (ok) { + self->accept(); + } else { + QMessageBox::warning(self, self->windowTitle(), + msg.isEmpty() ? QStringLiteral("白化失败") : msg); + } + }); +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/WhiteningDialog.hpp b/src/app/panels/chart/WhiteningDialog.hpp new file mode 100644 index 0000000..8996774 --- /dev/null +++ b/src/app/panels/chart/WhiteningDialog.hpp @@ -0,0 +1,51 @@ +#pragma once +#include +#include + +class QComboBox; +class QLineEdit; +class QButtonGroup; +class QStackedWidget; +class QPushButton; + +namespace geopro::data { +class IDatasetCommandRepository; +} + +namespace geopro::app { + +// 白化对话框(I3,1:1 复刻原版 WhiteningDialog)。三种白化方式: +// 1 数据边界自动:边界扩展 + 内/外白化单选。 +// 2 白化模板:listWhitenedData(projectId, tmObjectId) 选白化文件。 +// 3 模型白化:梯形/矩形单选。 +// 确认 → whitenData,成功 accept(),调用方随后重载网格重绘。 +class WhiteningDialog : public QDialog { + Q_OBJECT +public: + WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, QString dsId, QString projectId, + QString tmObjectId, QWidget* parent = nullptr); + +private: + void onMethodChanged(int method); // 切换方式 → 切到对应配置页 + void loadWhitenedFiles(); // 方式 2:拉白化文件列表 + void onConfirm(); // 确认 → whitenData + + geopro::data::IDatasetCommandRepository* repo_ = nullptr; + QString dsId_; + QString projectId_; + QString tmObjectId_; + + QComboBox* methodCombo_ = nullptr; + QStackedWidget* stack_ = nullptr; + // 方式 1: + QLineEdit* extension_ = nullptr; // 原版 AInput 文本框(默认 "0") + QButtonGroup* whiteningType_ = nullptr; // 0 外部 / 1 内部 + // 方式 2: + QComboBox* fileCombo_ = nullptr; + // 方式 3: + QButtonGroup* modelSubType_ = nullptr; // 2 梯形 / 1 矩形 + + QPushButton* okBtn_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/columns/CategoryAnalysisTab.cpp b/src/app/panels/columns/CategoryAnalysisTab.cpp new file mode 100644 index 0000000..7f5959c --- /dev/null +++ b/src/app/panels/columns/CategoryAnalysisTab.cpp @@ -0,0 +1,158 @@ +#include "panels/columns/CategoryAnalysisTab.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" +#include "panels/columns/CategorySection.hpp" + +namespace geopro::app { + +CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent) + : QWidget(parent) { + auto* outer = new QVBoxLayout(this); + outer->setContentsMargins(0, 0, 0, 0); + outer->setSpacing(0); + + auto* scroll = new QScrollArea(this); + scroll_ = scroll; + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条 + outer->addWidget(scroll, 1); + + auto* content = new QWidget(scroll); + content_ = content; + auto* col = new QVBoxLayout(content); + col_ = col; + col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶 + col->setSpacing(space::kSm); + + for (const CategorySpec& spec : categoryConfigs()) { + auto* sec = new CategorySection(spec, dict, content); + sections_[spec.id] = sec; + ordered_.push_back(sec); + connect(sec, &CategorySection::collapsedChanged, this, + &CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch(向上收) + const std::string segId = spec.id; + connect(sec, &CategorySection::checkedDatasetsChanged, this, + [this, segId](const QStringList& ids) { + checkedBySeg_[segId] = ids; + recomputeCheckedUnion(); + }); + connect(sec, &CategorySection::generateVolumeRequested, this, + &CategoryAnalysisTab::generateVolumeRequested); + connect(sec, &CategorySection::detailRequested, this, &CategoryAnalysisTab::detailRequested); + connect(sec, &CategorySection::deleteDatasetRequested, this, + &CategoryAnalysisTab::deleteDatasetRequested); + connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested); + connect(sec, &CategorySection::colorScaleRequested, this, &CategoryAnalysisTab::colorScaleRequested); + connect(sec, &CategorySection::sliceSaveRequested, this, &CategoryAnalysisTab::sliceSaveRequested); + connect(sec, &CategorySection::sliceSaveAsRequested, this, &CategoryAnalysisTab::sliceSaveAsRequested); + connect(sec, &CategorySection::sliceExportImageRequested, this, + &CategoryAnalysisTab::sliceExportImageRequested); + connect(sec, &CategorySection::sliceExportDatRequested, this, + &CategoryAnalysisTab::sliceExportDatRequested); + connect(sec, &CategorySection::anomalyVisibilityChanged, this, + &CategoryAnalysisTab::anomalyVisibilityChanged); + connect(sec, &CategorySection::datasetSelected, this, &CategoryAnalysisTab::datasetSelected); + // #7:各段等分 stretch → 内容都少时四段平分高度填满面板(初始与 VTK 区等高、不出滚动条); + // 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。 + col->addWidget(sec, 1); + } + // 尾部弹簧(末项):默认 0;全部段折叠时由 relayoutSections 置 1,吸收余量把段头顶到顶部。 + col->addStretch(0); + scroll->setWidget(content); +} + +void CategoryAnalysisTab::relayoutSections() { + if (!col_) return; + int expanded = 0; + for (auto* sec : ordered_) + if (sec->isExpanded()) ++expanded; + // 展开段 stretch=1(吸收余量、铺满);折叠段 stretch=0(只占段头高,下方不再留空)。 + for (auto* sec : ordered_) col_->setStretchFactor(sec, sec->isExpanded() ? 1 : 0); + // 尾部弹簧:仅当全部折叠时=1(把所有段头顶到顶部);有任一展开段时=0(由展开段吸收余量)。 + col_->setStretch(col_->count() - 1, expanded == 0 ? 1 : 0); +} + +void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { + const auto& cfg = categoryConfigs(); + for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) { + // voxel(三维体) 段数据来自 mock voxelTree(体/切片/异常),由调用方单独 section("voxel")->setDatasets + // 注入;splitByCategory 对它永远是空桶,若在此用空桶覆盖会先清掉其勾选(随后重填但勾选已丢) → + // 表现为「创建切片/异常后体/切片选择被清空」(用户 issue 1)。故跳过 voxel,勿覆盖。 + if (cfg[i].id == "voxel") continue; + if (auto* sec = section(cfg[i].id)) sec->setDatasets(b.segments[i]); + } +} + +void CategoryAnalysisTab::setStructure(const std::vector& nodes) { + for (auto& [id, sec] : sections_) sec->setStructure(nodes); +} + +void CategoryAnalysisTab::refreshArrayFilters() { + for (auto& [id, sec] : sections_) sec->refreshArrayFilter(); +} + +CategorySection* CategoryAnalysisTab::section(const std::string& id) const { + const auto it = sections_.find(id); + return it != sections_.end() ? it->second : nullptr; +} + +// ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op)。 +void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) { + for (auto* sec : ordered_) sec->setChecked(dsId, on); +} +void CategoryAnalysisTab::setItemBusy(const QString& dsId, bool busy) { + for (auto* sec : ordered_) sec->setBusy(dsId, busy); +} +void CategoryAnalysisTab::clearAllBusy() { + for (auto* sec : ordered_) sec->clearAllBusy(); +} + +void CategoryAnalysisTab::scrollItemToTop(const QString& dsId) { + // 先就地展开所在段(同步),再进入多拍重试定位(等布局/滚动条范围结算)。 + for (auto* sec : ordered_) + if (sec->itemFor(dsId)) { sec->ensureExpanded(); break; } + scrollItemToTopRetry(dsId, /*attemptsLeft=*/5); +} + +void CategoryAnalysisTab::scrollItemToTopRetry(const QString& dsId, int attemptsLeft) { + if (!scroll_ || !content_) return; + CategorySection* sec = nullptr; + QTreeWidgetItem* item = nullptr; + for (auto* s : ordered_) + if ((item = s->itemFor(dsId)) != nullptr) { sec = s; break; } + if (sec && item) { + sec->ensureExpanded(); + for (QTreeWidgetItem* p = item->parent(); p; p = p->parent()) + p->setExpanded(true); // 展开树内父节点,使目标行有有效几何 + QTreeWidget* tree = sec->listWidget(); + tree->scrollToItem(item, QAbstractItemView::PositionAtTop); // 内层树(若有内滚动) + // 行顶映射到滚动内容坐标 → 设外层滚动条把该行顶到面板最上方。 + const int y = tree->viewport()->mapTo(content_, tree->visualItemRect(item).topLeft()).y(); + scroll_->verticalScrollBar()->setValue(y); + } + // 多拍重试:每拍布局更趋稳定(滚动条 range 长够、行几何更新),末拍稳定到位 → 根治"有时滚不到位"。 + if (attemptsLeft > 0) { + QPointer self(this); + const QString id = dsId; + QTimer::singleShot(16, this, [self, id, attemptsLeft]() { + if (self) self->scrollItemToTopRetry(id, attemptsLeft - 1); + }); + } +} + +void CategoryAnalysisTab::recomputeCheckedUnion() { + QStringList all; // ds 归属唯一段,跨段不重复,直接拼接 + for (const auto& [id, ids] : checkedBySeg_) all += ids; + emit checkedDatasetsChanged(all); +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/CategoryAnalysisTab.hpp b/src/app/panels/columns/CategoryAnalysisTab.hpp new file mode 100644 index 0000000..2f927d0 --- /dev/null +++ b/src/app/panels/columns/CategoryAnalysisTab.hpp @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include +#include + +class QVBoxLayout; +class QScrollArea; +#include "DatasetCategory.hpp" // CategoryBuckets +#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis +#include "repo/RepoTypes.hpp" + +namespace geopro::data { +class DatasetFieldDictionary; +} + +namespace geopro::app { + +class CategorySection; + +// 「三维分析」tab:QScrollArea 竖向堆叠 5 个数据类型大类段(spec §4)。 +// 各段勾选合并为并集上抛;生成/详情请求透传。 +class CategoryAnalysisTab : public QWidget { + Q_OBJECT +public: + explicit CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent = nullptr); + + void setBuckets(const CategoryBuckets& b); // 分发到 5 段(与 categoryConfigs 同序) + void setStructure(const std::vector& nodes); // 转发各段 + void refreshArrayFilters(); // 装置枚举异步加载后,重填各段装置筛选下拉 + CategorySection* section(const std::string& id) const; // 按 CategorySpec.id 取段 + // ── 三维体生成后:自动勾选触发渲染 + 标题前等待动画(spinner)反馈(转发到含该 ds 的段)── + void setItemChecked(const QString& dsId, bool on); // 自动勾选(触发渲染) + void setItemBusy(const QString& dsId, bool busy); // 复选框↔等待 spinner 切换 + void clearAllBusy(); // 撤回所有 spinner(失败兜底) + void scrollItemToTop(const QString& dsId); // 把某行尽量滚到面板顶部(新建三维体后定位) + +signals: + void checkedDatasetsChanged(const QStringList& dsIds); // 5 段勾选并集 + void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); + void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除切片/异常 + // ── 三维体段操作转发(迁自旧 Column3DAnalysis,全接)── + void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); + void colorScaleRequested(const QString& dsId); + void sliceSaveRequested(const QString& dsId); + void sliceSaveAsRequested(const QString& dsId); + void sliceExportImageRequested(const QString& dsId); + void sliceExportDatRequested(const QString& dsId); + void anomalyVisibilityChanged(const QString& dsId, bool vis); + void datasetSelected(const QString& dsId, const QString& ddCode); // 树选中→VTK 高亮联动 + +private: + void recomputeCheckedUnion(); + // scrollItemToTop 的多拍重试实现:展开段/新增行后布局与滚动条范围需多次结算,单拍常滚不到位。 + // 每拍重算行位置并设滚动条,剩余拍数耗尽前持续校正 → 末拍几何稳定后行稳定到顶。 + void scrollItemToTopRetry(const QString& dsId, int attemptsLeft); + // 据各段折叠态重排 stretch:折叠段=0(仅段头高)、展开段=1(吸收余量);全折叠时尾部弹簧吸收→段头顶到顶部。 + // 仅在面板不产生滚动条(内容short于视口)时有可见效果——正是用户反馈的"折叠后停在原位中间"场景。 + void relayoutSections(); + + std::map sections_; + std::vector ordered_; // 按 categoryConfigs 顺序(relayout 遍历用) + QScrollArea* scroll_ = nullptr; // 外层滚动区(scrollItemToTop 定位用) + QWidget* content_ = nullptr; // 滚动内容容器(坐标映射用) + QVBoxLayout* col_ = nullptr; // 段堆叠布局(末项=尾部弹簧) + std::map checkedBySeg_; // 各段当前勾选(合并成并集) +}; + +} // namespace geopro::app diff --git a/src/app/panels/columns/CategorySection.cpp b/src/app/panels/columns/CategorySection.cpp new file mode 100644 index 0000000..2d9544f --- /dev/null +++ b/src/app/panels/columns/CategorySection.cpp @@ -0,0 +1,420 @@ +#include "panels/columns/CategorySection.hpp" + +#include +#include +#include +#include +#include "panels/columns/DateRangeEdit.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" +#include "panels/DatasetListPanel.hpp" +#include "repo/DatasetFieldDictionary.hpp" + +namespace geopro::app { + +using geopro::data::DsRow; +using geopro::data::DsTypeFields; + +CategorySection::CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, + QWidget* parent) + : QWidget(parent), spec_(spec), dict_(dict) { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // 数据类型段头(可折叠,规范§4.3/§6):chevron + 标题(title 字号·半粗) |「+ 新增三维体」(右,仅反演类)。 + // 段头加浅底 + 底分隔线作视觉分段;去原生小三角(难看)→chevron 文本前缀,随主题/hover 变色。 + auto* headerRow = new QWidget(this); + headerRow->setObjectName(QStringLiteral("secHeader")); + applyTokenizedStyleSheet(headerRow, + QStringLiteral("QWidget#secHeader{background:{{bg/panel-subtle}};" + "border-bottom:1px solid {{divider}};}")); + auto* hl = new QHBoxLayout(headerRow); + hl->setContentsMargins(space::kMd, space::kSm, space::kSm, space::kSm); + hl->setSpacing(space::kSm); + header_ = new QToolButton(headerRow); + header_->setCheckable(true); + header_->setChecked(true); + header_->setArrowType(Qt::NoArrow); + header_->setToolButtonStyle(Qt::ToolButtonTextOnly); + header_->setCursor(Qt::PointingHandCursor); + applyTokenizedStyleSheet( + header_, QStringLiteral("QToolButton{border:none;background:transparent;padding:0;" + "font-size:%1px;font-weight:%2;color:{{text/primary}};}" + "QToolButton:hover{color:{{accent/primary}};}") + .arg(scaledPx(type::kTitle)) + .arg(type::kWeightSemibold)); + auto syncHeader = [this] { + header_->setText((header_->isChecked() ? QStringLiteral("▾ ") : QStringLiteral("▸ ")) + + QString::fromStdString(spec_.title)); + }; + syncHeader(); + hl->addWidget(header_); + hl->addStretch(1); + if (spec_.canGenerateVolume) { + auto* gen = new QToolButton(headerRow); + gen->setText(QStringLiteral("+ 新增三维体")); + gen->setCursor(Qt::PointingHandCursor); + // 次级强调按钮(规范§6.7):描边 accent + accent 文字,hover 浅强调底;非裸文字。 + applyTokenizedStyleSheet( + gen, QStringLiteral( + "QToolButton{border:1px solid {{accent/primary}};border-radius:%1px;" + "color:{{accent/primary}};background:transparent;padding:%2px %3px;font-size:%4px;}" + "QToolButton:hover{background:{{bg/selected}};}" + "QToolButton:pressed{background:{{bg/hover}};}") + .arg(radius::kSm) + .arg(scaledPx(space::kXxs)) + .arg(scaledPx(space::kMd)) + .arg(scaledPx(type::kCaption))); + connect(gen, &QToolButton::clicked, this, [this] { + emit generateVolumeRequested(QString::fromStdString(spec_.dsTypeCode), checkedDsIds()); + }); + hl->addWidget(gen); + } + root->addWidget(headerRow); + + body_ = new QWidget(this); + auto* body = new QVBoxLayout(body_); + body->setContentsMargins(space::kMd, space::kSm, space::kMd, space::kMd); + body->setSpacing(space::kSm); + + // 筛选行:采集时间范围(在前)+ 装置类型(在后,仅 ERT 类)。 + auto* filterRow = new QHBoxLayout(); + filterRow->setSpacing(space::kSm); + dateRange_ = new DateRangeEdit(body_); + connect(dateRange_, &DateRangeEdit::rangeChanged, this, [this] { rebuildList(); }); + filterRow->addWidget(dateRange_, 1); + if (spec_.hasArrayTypeFilter) { + arrayCombo_ = new QComboBox(body_); + arrayCombo_->addItem(QStringLiteral("全部装置"), QString()); + connect(arrayCombo_, qOverload(&QComboBox::currentIndexChanged), this, + [this](int) { rebuildList(); }); + filterRow->addWidget(arrayCombo_); + } + body->addLayout(filterRow); + + // 段体:可勾选数据树(勾选=渲染)。复用数据列表卡片委托与 populateDatasetList。 + list_ = new QTreeWidget(body_); + list_->setHeaderHidden(true); + list_->setRootIsDecorated(true); + list_->setIndentation(14); // 紧凑父子缩进(默认 20 太宽) + // #7:段体不出内层滚动条——内容超出时整段拉长,由 CategoryAnalysisTab 外层 QScrollArea 统一滚动。 + list_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + list_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + applyDatasetCardDelegate(list_); + connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem* it, int) { + // 异常行复选框 = 该异常显隐(异常不进渲染勾选集,单独走 anomalyVisibilityChanged → setAnomalyVisible)。 + if (it && it->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly")) + emit anomalyVisibilityChanged(it->data(0, kDsIdRole).toString(), + it->checkState(0) == Qt::Checked); + emitChecked(); + }); + connect(list_, &QTreeWidget::itemDoubleClicked, this, [this](QTreeWidgetItem* it, int) { + const QString id = it->data(0, kDsIdRole).toString(); + if (id.isEmpty()) return; + emit detailRequested(id, it->data(0, kDsDdCodeRole).toString(), it->data(0, kDsNameRole).toString()); + }); + if (spec_.id == "voxel") { // 仅三维体段提供右键操作菜单(体/切片/异常) + list_->setContextMenuPolicy(Qt::CustomContextMenu); + connect(list_, &QTreeWidget::customContextMenuRequested, this, &CategorySection::showContextMenu); + // 树选中切片/异常 → VTK 高亮联动(正向 list→VTK)。 + connect(list_, &QTreeWidget::itemSelectionChanged, this, [this] { + const auto items = list_->selectedItems(); + if (items.isEmpty()) return; + QTreeWidgetItem* it = items.first(); + const QString id = it->data(0, kDsIdRole).toString(); + const QString dd = it->data(0, kDsDdCodeRole).toString(); + if (!id.isEmpty() && dd != QStringLiteral("container")) emit datasetSelected(id, dd); + }); + } + body->addWidget(list_, 1); + + root->addWidget(body_, 1); + + connect(header_, &QToolButton::toggled, this, [this, syncHeader](bool on) { + body_->setVisible(on); + syncHeader(); // ▾(展开)/▸(折叠) 切换 + emit collapsedChanged(); // 外层据此把折叠段 stretch 归 0、展开段吸收余量 → 折叠向上收 + }); +} + +bool CategorySection::isExpanded() const { return header_ && header_->isChecked(); } + +void CategorySection::ensureExpanded() { + if (header_ && !header_->isChecked()) header_->setChecked(true); // toggled→展开段体 +} + +QTreeWidgetItem* CategorySection::itemFor(const QString& dsId) const { + if (!list_ || dsId.isEmpty()) return nullptr; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsIdRole).toString() == dsId) return *it; + return nullptr; +} + +void CategorySection::setStructure(const std::vector& nodes) { + structure_ = nodes; // 容器分层(项目根/GS/TM→ds)在 Task 12 接入真实结构后据此构建。 +} + +void CategorySection::setDatasets(const std::vector& rows) { + rows_ = rows; + refreshArrayCombo(); + rebuildList(); +} + +void CategorySection::selectItem(const QString& dsId) { + const QSignalBlocker block(list_); // 程序化选中(VTK→list)不回发 datasetSelected,避免环路 + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if (!dsId.isEmpty() && (*it)->data(0, kDsIdRole).toString() == dsId) { + list_->setCurrentItem(*it); + return; + } + list_->setCurrentItem(nullptr); // 空 dsId / 未找到 → 清选中 +} + +void CategorySection::setChecked(const QString& dsId, bool on) { + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsIdRole).toString() == dsId && + ((*it)->flags() & Qt::ItemIsUserCheckable)) { + (*it)->setCheckState(0, on ? Qt::Checked : Qt::Unchecked); // 触发 itemChanged→emitChecked→渲染 + return; + } +} + +void CategorySection::setBusy(const QString& dsId, bool on) { + QTreeWidgetItem* target = nullptr; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsIdRole).toString() == dsId) { target = *it; break; } + if (!target) return; + { + // 改 busy/角度角色用 SignalBlocker:不触发 itemChanged→emitChecked→重渲染;viewport 仍重绘。 + const QSignalBlocker block(list_); + target->setData(0, kDsBusyRole, on); + if (on) target->setData(0, Qt::CheckStateRole, Qt::Checked); // 渲染中保持勾选(仍属渲染集) + } + bool anyBusy = false; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) { anyBusy = true; break; } + if (anyBusy) { + if (!spinTimer_) { + spinTimer_ = new QTimer(this); + spinTimer_->setInterval(80); + connect(spinTimer_, &QTimer::timeout, this, [this]() { + spinAngle_ = (spinAngle_ + 30) % 360; + const QSignalBlocker block(list_); + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) + (*it)->setData(0, kDsSpinAngleRole, spinAngle_); + }); + } + if (!spinTimer_->isActive()) spinTimer_->start(); + } else if (spinTimer_) { + spinTimer_->stop(); + } + if (list_->viewport()) list_->viewport()->update(); +} + +void CategorySection::clearAllBusy() { + const QSignalBlocker block(list_); + bool any = false; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->data(0, kDsBusyRole).toBool()) { + (*it)->setData(0, kDsBusyRole, false); + any = true; + } + if (spinTimer_) spinTimer_->stop(); + if (any && list_->viewport()) list_->viewport()->update(); +} + +void CategorySection::refreshArrayCombo() { + if (!spec_.hasArrayTypeFilter || !arrayCombo_) return; + const QString prev = arrayCombo_->currentData().toString(); + const QSignalBlocker block(arrayCombo_); + arrayCombo_->clear(); + arrayCombo_->addItem(QStringLiteral("全部装置"), QString()); + // 按「加载到本段的数据自身携带的类型值」的范围筛选(用户口径)。装置/arrayType 不在 ds 行数据上 + // (实测 data/page 列表无该字段),故用 ds 已带的类型名 typeName(多为中文)作筛选维度; + // typeName 缺失时回退 dsTypeCode + 全局枚举翻译。列出组内出现过的类型值。 + QSet seen; + for (const auto& r : rows_) { + QString display = QString::fromStdString(r.typeName); + QString code = display; + if (display.isEmpty() && !r.dsTypeCode.empty()) { // 无类型名 → 用 dsTypeCode(必要时枚举翻译) + code = QString::fromStdString(r.dsTypeCode); + display = code; + if (dict_) { + const auto& en = dict_->arrayTypeEnum(); + const auto it = en.find(r.dsTypeCode); + if (it != en.end()) display = QString::fromStdString(it->second); + } + } + if (code.isEmpty() || seen.contains(code)) continue; + seen.insert(code); + arrayCombo_->addItem(display, code); // itemData=类型值(passesFilters 据此比对 typeName/dsTypeCode) + } + const int idx = arrayCombo_->findData(prev); // 尽量保留上次选择 + arrayCombo_->setCurrentIndex(idx >= 0 ? idx : 0); +} + +bool CategorySection::passesFilters(const DsRow& row) const { + // 类型筛选("全部"=空不筛):按 ds 自身类型值(typeName,回退 dsTypeCode)命中选中项。 + if (spec_.hasArrayTypeFilter && arrayCombo_) { + const QString sel = arrayCombo_->currentData().toString(); + if (!sel.isEmpty()) { + const QString t = !row.typeName.empty() ? QString::fromStdString(row.typeName) + : QString::fromStdString(row.dsTypeCode); + if (t != sel) return false; + } + } + // 采集时间范围(collectTime 经 dict 取;缺则回退 createTime;空范围不约束)。 + const QDate from = dateRange_ ? dateRange_->from() : QDate(); + const QDate to = dateRange_ ? dateRange_->to() : QDate(); + if (from.isValid() || to.isValid()) { + const DsTypeFields* f = dict_ ? dict_->fields(spec_.dsTypeCode) : nullptr; + std::string ts = f ? collectTimeOf(row, *f) : std::string(); + if (ts.empty()) ts = row.createTime; + const QDate d = QDate::fromString(QString::fromStdString(ts).left(10), QStringLiteral("yyyy-MM-dd")); + if (d.isValid()) { + if (from.isValid() && d < from) return false; + if (to.isValid() && d > to) return false; + } + } + return true; +} + +void CategorySection::rebuildList() { + // 增量保留:记住当前已勾选的 ds,重建后复原(仍存在的项保持勾选)。否则每次刷新(勾选对象/建体/ + // 存切片/建异常都会触发)清空全部勾选 → 渲染被重置,体验极差(用户反馈:必须增量更新)。 + std::set wasChecked, wasSeen; // wasSeen=重建前所有可勾选项(区分"新项" vs "曾取消") + for (QTreeWidgetItemIterator it(list_); *it; ++it) { + if (!((*it)->flags() & Qt::ItemIsUserCheckable)) continue; + const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString(); + wasSeen.insert(id); + if ((*it)->checkState(0) == Qt::Checked) wasChecked.insert(id); + } + + std::vector filtered; + filtered.reserve(rows_.size()); + for (const auto& r : rows_) + if (passesFilters(r)) filtered.push_back(r); + + // 从项目根的层级树:容器节点(结构,剪枝仅留有 ds 的路径)+ ds(挂 parentId 或 structParentId)。 + std::vector display; + if (!structure_.empty()) { + std::map byId; + for (const auto& n : structure_) byId[n.id] = &n; + std::set keep; // 收集每个 ds 的结构归属向上的祖先链 + for (const auto& d : filtered) { + std::string p = d.structParentId; + while (!p.empty() && byId.count(p) && !keep.count(p)) { + keep.insert(p); + p = byId[p]->parentId; + } + } + for (const auto& n : structure_) + if (keep.count(n.id)) { + DsRow c; + c.id = n.id; + c.dsName = n.name; + c.ddCode = "container"; + c.parentId = n.parentId; + display.push_back(std::move(c)); + } + } + // 分段树是「项目/GS/TM」组织树:ds 应挂到其结构容器(TM)下。但派生 ds(如反演剖面)带派生父 + // parentId(指向原始 ds),若该原始 ds 不在本段(被筛掉/属别类型段),按 parentId 挂载会失败 → + // ds 浮到树根平铺(用户实测 bug:ds 没挂在 tm 下)。故:派生父在本段则保留派生嵌套(如 体>切片), + // 否则回退挂到结构容器 structParentId(TM)。 + std::set presentIds; + for (const auto& d : filtered) presentIds.insert(d.id); + for (const auto& d : filtered) { + DsRow x = d; + if (x.parentId.empty() || !presentIds.count(x.parentId)) x.parentId = x.structParentId; + display.push_back(std::move(x)); + } + + { + const QSignalBlocker block(list_); + populateDatasetList(list_, display, /*append=*/false); + for (QTreeWidgetItemIterator it(list_); *it; ++it) { + // 容器节点(项目根/GS/TM)只作层级骨架——明确去掉复选框、不可勾选。 + if ((*it)->data(0, kDsDdCodeRole).toString() == QStringLiteral("container")) { + (*it)->setFlags((*it)->flags() & ~Qt::ItemIsUserCheckable); + continue; + } + (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); + const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString(); + const bool isAnomaly = (*it)->data(0, kDsDdCodeRole).toString() == QStringLiteral("dd_anomaly"); + // 复原勾选:曾勾选→勾;曾出现但未勾→不勾;新项→异常默认勾(显示),其余默认不勾。 + const Qt::CheckState st = wasChecked.count(id) ? Qt::Checked + : wasSeen.count(id) ? Qt::Unchecked + : (isAnomaly ? Qt::Checked : Qt::Unchecked); + (*it)->setCheckState(0, st); + } + } + list_->expandAll(); // 展开容器层级(项目根/GS/TM),让体/切片/异常可见 + // #7:段体无内层滚动条 → list 最小高度 = 可见项内容总高(与 DatasetCardDelegate::sizeHint 一致: + // 有副标题 52,否则 30)。内容多则撑大本段、超视口由外层滚动;内容少则被 stretch 拉到平分高度。 + int contentH = 2 * list_->frameWidth() + 4; + for (QTreeWidgetItemIterator hit(list_); *hit; ++hit) + if (!(*hit)->isHidden()) + contentH += (*hit)->text(0).contains(QLatin1Char('\n')) ? 52 : 30; + list_->setMinimumHeight(contentH); + emitChecked(); // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染图元) +} + +QStringList CategorySection::checkedDsIds() const { + QStringList ids; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->checkState(0) == Qt::Checked) ids << (*it)->data(0, kDsIdRole).toString(); + return ids; +} + +void CategorySection::emitChecked() { emit checkedDatasetsChanged(checkedDsIds()); } + +void CategorySection::showContextMenu(const QPoint& pos) { + using geopro::render::interact::SliceAxis; + QTreeWidgetItem* it = list_->itemAt(pos); + if (!it) return; + const QString id = it->data(0, kDsIdRole).toString(); + const QString ddCode = it->data(0, kDsDdCodeRole).toString(); + if (id.isEmpty() || ddCode == QStringLiteral("container")) return; // 容器节点无操作菜单 + const QString name = it->data(0, kDsNameRole).toString(); + QMenu menu(this); + menu.addAction(QStringLiteral("详情"), this, + [this, id, ddCode, name] { emit detailRequested(id, ddCode, name); }); + if (ddCode == QStringLiteral("dd_voxel")) { // 三维体 + QMenu* sl = menu.addMenu(QStringLiteral("生成切片")); // id=被右键的三维体 dsId(切片建到该体上) + sl->addAction(QStringLiteral("上下"), this, [this, id] { emit sliceRequested(SliceAxis::UpDown, id); }); + sl->addAction(QStringLiteral("前后"), this, [this, id] { emit sliceRequested(SliceAxis::FrontBack, id); }); + sl->addAction(QStringLiteral("左右"), this, [this, id] { emit sliceRequested(SliceAxis::LeftRight, id); }); + sl->addAction(QStringLiteral("任意"), this, [this, id] { emit sliceRequested(SliceAxis::Oblique, id); }); + menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); }); + } else if (ddCode == QStringLiteral("dd_slice")) { // 切片(列表中均为已保存=定稿锁定,无保存/另存) + QMenu* ex = menu.addMenu(QStringLiteral("导出")); + ex->addAction(QStringLiteral("图片"), this, [this, id] { emit sliceExportImageRequested(id); }); + ex->addAction(QStringLiteral("dat"), this, [this, id] { emit sliceExportDatRequested(id); }); + menu.addAction(QStringLiteral("色阶"), this, [this, id] { emit colorScaleRequested(id); }); + menu.addSeparator(); + menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); }); + } else if (ddCode == QStringLiteral("dd_anomaly")) { // 异常 + menu.addAction(QStringLiteral("显示"), this, [this, id] { emit anomalyVisibilityChanged(id, true); }); + menu.addAction(QStringLiteral("隐藏"), this, [this, id] { emit anomalyVisibilityChanged(id, false); }); + menu.addSeparator(); + menu.addAction(QStringLiteral("删除"), this, [this, id, ddCode] { emit deleteDatasetRequested(id, ddCode); }); + } + menu.exec(list_->viewport()->mapToGlobal(pos)); +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/CategorySection.hpp b/src/app/panels/columns/CategorySection.hpp new file mode 100644 index 0000000..4de7528 --- /dev/null +++ b/src/app/panels/columns/CategorySection.hpp @@ -0,0 +1,88 @@ +#pragma once +#include +#include +#include +#include "interact/SlicePlaneMath.hpp" // geopro::render::interact::SliceAxis +#include "repo/CategoryConfig.hpp" +#include "repo/RepoTypes.hpp" + +class QTreeWidget; +class QTreeWidgetItem; +class QComboBox; +class QDateEdit; +class QLabel; +class QToolButton; +class QTimer; +class QWidget; + +namespace geopro::data { +class DatasetFieldDictionary; +} + +namespace geopro::app { + +class DateRangeEdit; + +// 单个数据类型大类段(spec §7):段头(标题/折叠 + 装置类型/日期筛选 + 「+新增三维体」)+ 段体(可勾选数据树)。 +// 勾选数据行 = 渲染(帘面/体素/切片);段头生成按钮据当前勾选源发 generateVolumeRequested。 +class CategorySection : public QWidget { + Q_OBJECT +public: + CategorySection(const CategorySpec& spec, geopro::data::DatasetFieldDictionary* dict, + QWidget* parent = nullptr); + + // 对象树同源的扁平 GS/TM 节点(段体容器分层用;Task 12 接入真实结构,当前仅存储)。 + void setStructure(const std::vector& nodes); + void setDatasets(const std::vector& rows); + void setChecked(const QString& dsId, bool on); // 按 dsId 勾选/取消(新建切片自动勾选等场景) + // 渲染中:该行复选框替换为等待 spinner(busy=true)/复原(false)。busy 期间保持勾选、动画由定时器驱动。 + void setBusy(const QString& dsId, bool busy); + void clearAllBusy(); // 撤回本段所有 spinner(失败兜底) + void selectItem(const QString& dsId); // 程序化选中某行(VTK→list 反向联动);空/未找到=清选中 + QStringList checkedIds() const { return checkedDsIds(); } // 当前勾选 ds(异常显隐同步用) + void refreshArrayFilter() { refreshArrayCombo(); } // 装置枚举异步加载后重填下拉 + const CategorySpec& spec() const { return spec_; } + bool isExpanded() const; // 段头展开态(供外层按折叠状态重分配 stretch,实现"折叠向上收") + void ensureExpanded(); // 展开本段(折叠则展开):滚动定位前确保目标行可见 + QTreeWidget* listWidget() const { return list_; } // 段体树(外层滚动定位用) + QTreeWidgetItem* itemFor(const QString& dsId) const; // 按 dsId 取行(无则 nullptr) + +signals: + void checkedDatasetsChanged(const QStringList& dsIds); // 数据行勾选=渲染 + void collapsedChanged(); // 折叠/展开切换 → 外层 CategoryAnalysisTab 重排各段 stretch + void generateVolumeRequested(const QString& dsTypeCode, const QStringList& sourceDsIds); // 段头「+新增三维体」 + void detailRequested(const QString& dsId, const QString& ddCode, const QString& name); // 双击/右键=详情 + void deleteDatasetRequested(const QString& dsId, const QString& ddCode); // 右键删除(切片/异常) + // ── 三维体段右键操作(迁自旧 Column3DAnalysis,全接)── + void sliceRequested(geopro::render::interact::SliceAxis axis, const QString& volumeDsId); // 体→生成切片(轴+目标体) + void colorScaleRequested(const QString& dsId); // 体/切片→色阶 + void sliceSaveRequested(const QString& dsId); // 切片→保存位姿 + void sliceSaveAsRequested(const QString& dsId); // 切片→另存 + void sliceExportImageRequested(const QString& dsId); // 切片→导出图片 + void sliceExportDatRequested(const QString& dsId); // 切片→导出 dat + void anomalyVisibilityChanged(const QString& dsId, bool vis); // 异常→显示/隐藏 + void datasetSelected(const QString& dsId, const QString& ddCode); // 树选中行→VTK 高亮联动(切片/异常) + +private: + void showContextMenu(const QPoint& pos); // 段体树右键菜单(详情 + 删除) + void rebuildList(); // 据 rows_(经装置/日期筛选)重建段体树并复原勾选 + void refreshArrayCombo(); // 据当前 rows_ 重填装置类型下拉项(经字典 value→中文) + void emitChecked(); // 收集勾选 → checkedDatasetsChanged + QStringList checkedDsIds() const; + bool passesFilters(const geopro::data::DsRow& row) const; // 装置类型 + 采集时间范围 + + CategorySpec spec_; + geopro::data::DatasetFieldDictionary* dict_ = nullptr; + std::vector rows_; + std::vector structure_; + + QToolButton* header_ = nullptr; // 折叠头(标题 + 箭头) + QWidget* body_ = nullptr; // 段体容器(折叠时隐藏) + QComboBox* arrayCombo_ = nullptr; // 装置类型筛选(仅 hasArrayTypeFilter) + DateRangeEdit* dateRange_ = nullptr; // 采集时间范围筛选(起止双日历,可清空) + QTreeWidget* list_ = nullptr; + QTimer* spinTimer_ = nullptr; // 驱动 busy 行 spinner 旋转(有 busy 行时运行) + int spinAngle_ = 0; // 当前 spinner 角度(度) +}; + +} // namespace geopro::app diff --git a/src/app/panels/columns/Column2DDataset.cpp b/src/app/panels/columns/Column2DDataset.cpp new file mode 100644 index 0000000..a8da596 --- /dev/null +++ b/src/app/panels/columns/Column2DDataset.cpp @@ -0,0 +1,129 @@ +#include "panels/columns/Column2DDataset.hpp" + +#include +#include + +#include + +#include "EmptyAwareComboBox.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" +#include "panels/DatasetListPanel.hpp" + +namespace geopro::app { + +Column2DDataset::Column2DDataset(QWidget* parent) : QWidget(parent) { + auto* root = new QVBoxLayout(this); + root->setContentsMargins(space::kMd, space::kMd, space::kMd, space::kMd); + root->setSpacing(space::kMd); + + // 地图 + { + auto* form = new QFormLayout(); + auto* basemap = new EmptyAwareComboBox(); + basemap->addItem(QStringLiteral("天地图")); + basemap->addItem(QStringLiteral("Google Map")); + basemap->addItem(QStringLiteral("隐藏")); + basemap->setCurrentIndex(0); // 默认天地图:数据重锚后由 onFrameReanchored 在数据位置加载 + connect(basemap, qOverload(&QComboBox::currentIndexChanged), this, + [this](int index) { emit basemapChanged(index); }); + form->addRow(QStringLiteral("底图源"), basemap); + root->addWidget(new QLabel(QStringLiteral("地图"))); + root->addLayout(form); + } + + // 2D视图 + { + auto* form = new QFormLayout(); + auto* view2d = new EmptyAwareComboBox(); + view2d->addItem(QStringLiteral("关闭")); + view2d->addItem(QStringLiteral("Z=0")); + view2d->addItem(QStringLiteral("顶部")); + view2d->addItem(QStringLiteral("底部")); + view2d->addItem(QStringLiteral("自定义")); + view2d->setCurrentIndex(1); + auto* zSpin = new QDoubleSpinBox(); + zSpin->setRange(-1000000, 1000000); + zSpin->setSuffix(QStringLiteral(" m")); + zSpin->setValue(0); + connect(view2d, qOverload(&QComboBox::currentIndexChanged), this, + [this, form, zSpin](int idx) { + form->setRowVisible(zSpin, idx == 4); // 整行隐藏(含"Z 值"标签),非自定义时不留孤标签 + emit view2DModeChanged(idx); + }); + connect(zSpin, qOverload(&QDoubleSpinBox::valueChanged), this, + [this](double z) { emit customZChanged(z); }); + form->addRow(QStringLiteral("位置"), view2d); + form->addRow(QStringLiteral("Z 值"), zSpin); + form->setRowVisible(zSpin, false); // 默认非自定义→隐藏整行 + root->addWidget(new QLabel(QStringLiteral("2D视图"))); + root->addLayout(form); + } + + // 数据集列表(可勾选) + list_ = new QTreeWidget(); + list_->setHeaderHidden(true); + list_->setRootIsDecorated(true); + list_->setSelectionMode(QAbstractItemView::ExtendedSelection); // 多选行(与 VTK 多选拖动联动) + applyDatasetCardDelegate(list_); + connect(list_, &QTreeWidget::itemChanged, this, [this](QTreeWidgetItem*, int) { + QStringList ids; + for (QTreeWidgetItemIterator it(list_); *it; ++it) { + if ((*it)->checkState(0) == Qt::Checked) + ids << (*it)->data(0, kDsIdRole).toString(); + } + emit checkedDatasetsChanged(ids); + }); + // 行选中变化 → 上抛选中 dsId(高亮联动 VTK;与勾选/渲染独立)。 + connect(list_, &QTreeWidget::itemSelectionChanged, this, [this]() { + QStringList ids; + for (QTreeWidgetItem* it : list_->selectedItems()) + ids << it->data(0, kDsIdRole).toString(); + emit selectedDatasetsChanged(ids); + }); + root->addWidget(list_, 1); +} + +void Column2DDataset::setDatasets(const std::vector& rows) { + // 增量保留:记住当前已勾选的足迹 ds,重建后复原(仍存在的项保持勾选)。否则对象树每次增删勾选都触发 + // 本刷新 → 清空全部勾选 + 上抛空集 → 已渲染足迹被移除、列表选中丢失(用户反馈:必须增量更新, + // 与三维分析段 CategorySection::rebuildList 同一处理)。 + std::set wasChecked; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->checkState(0) == Qt::Checked) + wasChecked.insert((*it)->data(0, kDsIdRole).toString().toStdString()); + + { + QSignalBlocker blocker(list_); + populateDatasetList(list_, rows, /*append=*/false); + for (QTreeWidgetItemIterator it(list_); *it; ++it) { + (*it)->setFlags((*it)->flags() | Qt::ItemIsUserCheckable); + const std::string id = (*it)->data(0, kDsIdRole).toString().toStdString(); + // 复原勾选:仍存在的曾勾选项保持勾选;新项默认不勾。 + (*it)->setCheckState(0, wasChecked.count(id) ? Qt::Checked : Qt::Unchecked); + } + } // blocker released here + // 上抛复原后的勾选集(保持渲染,不再清空 → 控制器据 diff 增量保留已渲染足迹,集合不变则不增删)。 + QStringList ids; + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if ((*it)->checkState(0) == Qt::Checked) + ids << (*it)->data(0, kDsIdRole).toString(); + emit checkedDatasetsChanged(ids); +} + +void Column2DDataset::setSelectedDsIds(const QStringList& dsIds) { + QSignalBlocker blocker(list_); // 防回环:VTK→列表 设置选中不再上抛 selectedDatasetsChanged + list_->clearSelection(); + for (QTreeWidgetItemIterator it(list_); *it; ++it) + if (dsIds.contains((*it)->data(0, kDsIdRole).toString())) (*it)->setSelected(true); +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/Column2DDataset.hpp b/src/app/panels/columns/Column2DDataset.hpp new file mode 100644 index 0000000..5003510 --- /dev/null +++ b/src/app/panels/columns/Column2DDataset.hpp @@ -0,0 +1,31 @@ +#pragma once +#include +#include +#include +#include "repo/RepoTypes.hpp" + +class QTreeWidget; + +namespace geopro::app { + +// 二维数据集栏:地图 + 2D视图(含自定义 Z) + 2D 数据集列表。 +class Column2DDataset : public QWidget { + Q_OBJECT +public: + explicit Column2DDataset(QWidget* parent = nullptr); + void setDatasets(const std::vector& rows); + // VTK→列表 选择联动:按 dsId 选中对应行(高亮),内部屏蔽信号避免回环。 + void setSelectedDsIds(const QStringList& dsIds); + +signals: + void basemapChanged(int index); // 0 天地图 / 1 Google / 2 隐藏 + void view2DModeChanged(int index); // 0 关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义 + void customZChanged(double z); // 世界绝对高程(米),向上为正 + void checkedDatasetsChanged(const QStringList& dsIds); // 勾选(渲染开关)变化 + void selectedDatasetsChanged(const QStringList& dsIds); // 行选中(高亮联动)变化,非勾选 + +private: + QTreeWidget* list_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/panels/columns/ColumnDrawer.cpp b/src/app/panels/columns/ColumnDrawer.cpp new file mode 100644 index 0000000..7d74414 --- /dev/null +++ b/src/app/panels/columns/ColumnDrawer.cpp @@ -0,0 +1,94 @@ +#include "panels/columns/ColumnDrawer.hpp" +#include "panels/columns/Column2DDataset.hpp" +#include "panels/columns/CategoryAnalysisTab.hpp" +#include + +#include "Glyphs.hpp" +#include "Theme.hpp" +#include +#include +#include +#include +#include + +namespace geopro::app { + +ColumnDrawer::ColumnDrawer(QWidget* parent, geopro::data::DatasetFieldDictionary* dict) + : QWidget(parent) +{ + col2D_ = new Column2DDataset(this); + analysisTab_ = new CategoryAnalysisTab(dict, this); + + // Tab 容器(body_):两 tab(三维分析[分段] / 二维分析)。 + auto* tabs = new QTabWidget(this); + body_ = tabs; + tabs->addTab(analysisTab_, QStringLiteral("三维分析")); + tabs->addTab(col2D_, QStringLiteral("二维分析")); + tabs->tabBar()->setUsesScrollButtons(false); // 永不出左右滚动箭头(两 tab 必能平铺) + // 切 tab → 发 analysisModeChanged(is2D):以"当前 widget 是否 col2D"判定,不写死索引。 + connect(tabs, &QTabWidget::currentChanged, this, [this, tabs](int idx) { + emit analysisModeChanged(tabs->widget(idx) == col2D_); + }); + + // 折叠按钮:固定宽 18px,垂直拉伸。 + // 用 SVG 图标(makeGlyph)而非 ◀/▶ 文字——三角符(U+25C0/25B6)不在 YaHei,作按钮文字会触发 + // Qt 字体回退,本机 DirectWrite 在回退枚举时崩(0xc0000005)。SVG 图标走 QIcon 不做字体整形,规避之。 + toggleBtn_ = new QPushButton(this); + toggleBtn_->setIcon(geopro::app::makeGlyph(Glyph::ChevronLeft, + geopro::app::tokenColor("text/secondary"), 14)); + toggleBtn_->setFixedWidth(18); + toggleBtn_->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding); + toggleBtn_->setCursor(Qt::PointingHandCursor); + toggleBtn_->setToolTip(QStringLiteral("折叠 / 展开")); + geopro::app::applyTokenizedStyleSheet(toggleBtn_, + QStringLiteral("QPushButton{background:{{bg/panel-subtle}};color:{{text/secondary}};" + "border:none;border-left:1px solid {{border/default}};font-size:12px;}" + "QPushButton:hover{background:{{bg/hover}};color:{{accent/primary}};}")); + connect(toggleBtn_, &QPushButton::clicked, this, &ColumnDrawer::toggleCollapsed); + + // 根布局:[body_ | toggleBtn_],无边距 + auto* root = new QHBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + root->addWidget(body_, 1); + root->addWidget(toggleBtn_, 0); + + // 可调宽:min 180 / max 560(外层 QSplitter 拖动改宽)。 + setMinimumWidth(180); + setMaximumWidth(560); +} + +void ColumnDrawer::resizeEvent(QResizeEvent* e) +{ + QWidget::resizeEvent(e); + // 两 tab 平分抽屉宽度填满(带样式表的 tab 不响应 setExpanding,须按 barWidth/n 显式给宽)。 + // 消除旧 3 栏布局遗留的右侧空白——重构成 2 栏后不再三分、留空第三位。 + if (auto* tabs = qobject_cast(body_)) { + const int n = tabs->count(); + if (n > 0 && tabs->width() > 0) { + // 每 tab 内容宽 = 总宽/n - 每 tab 非内容开销(全局 QSS padding 8+16+16=… 约 32 + margin 4)。 + // 稍欠一点宽避免溢出(溢出会触发滚动箭头);setUsesScrollButtons(false) 再兜底。 + const int w = std::max(40, tabs->width() / n - 42); + tabs->tabBar()->setStyleSheet(QStringLiteral("QTabBar::tab{width:%1px;}").arg(w)); + } + } +} + +void ColumnDrawer::toggleCollapsed() +{ + collapsed_ = !collapsed_; + body_->setVisible(!collapsed_); + toggleBtn_->setIcon(geopro::app::makeGlyph(collapsed_ ? Glyph::ChevronRight : Glyph::ChevronLeft, + geopro::app::tokenColor("text/secondary"), 14)); + // 折叠后只保留按钮宽度;展开恢复可调范围 + setMinimumWidth(collapsed_ ? 0 : 180); + setMaximumWidth(collapsed_ ? 18 : 560); + emit collapsedChanged(collapsed_); // 通知上层调 QSplitter 尺寸,回收/恢复栏宽(防残留空白) +} + +void ColumnDrawer::expand() +{ + if (collapsed_) toggleCollapsed(); // 仅在折叠时展开 +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/ColumnDrawer.hpp b/src/app/panels/columns/ColumnDrawer.hpp new file mode 100644 index 0000000..05175c1 --- /dev/null +++ b/src/app/panels/columns/ColumnDrawer.hpp @@ -0,0 +1,47 @@ +#pragma once +#include + +class QPushButton; +class QResizeEvent; + +namespace geopro::data { +class DatasetFieldDictionary; +} + +namespace geopro::app { + +class Column2DDataset; +class CategoryAnalysisTab; + +// VTK视图左侧内嵌抽屉:两 tab(三维分析[按数据类型分段]/二维分析) + 折叠开关。 +class ColumnDrawer : public QWidget { + Q_OBJECT +public: + explicit ColumnDrawer(QWidget* parent = nullptr, + geopro::data::DatasetFieldDictionary* dict = nullptr); + + Column2DDataset* col2D() const { return col2D_; } + CategoryAnalysisTab* analysisTab() const { return analysisTab_; } + +signals: + // 切换「三维分析 / 二维分析」tab:is2D=true 进入二维分析。上层据此切相机+翻维度可见标志。 + void analysisModeChanged(bool is2D); + // 折叠/展开切换:上层据此调整 QSplitter 尺寸,回收/恢复抽屉栏宽(否则收起残留空白区)。 + void collapsedChanged(bool collapsed); + +public slots: + void toggleCollapsed(); + void expand(); // 强制展开(进入全屏时确保三栏可见) + +protected: + void resizeEvent(QResizeEvent* e) override; // 两 tab 按抽屉宽平分(消除右侧空白"第三栏位") + +private: + Column2DDataset* col2D_ = nullptr; + CategoryAnalysisTab* analysisTab_ = nullptr; + QWidget* body_ = nullptr; // QTabWidget,折叠时隐藏 + QPushButton* toggleBtn_ = nullptr; + bool collapsed_ = false; +}; + +} // namespace geopro::app diff --git a/src/app/panels/columns/DateRangeEdit.cpp b/src/app/panels/columns/DateRangeEdit.cpp new file mode 100644 index 0000000..d2f9151 --- /dev/null +++ b/src/app/panels/columns/DateRangeEdit.cpp @@ -0,0 +1,96 @@ +#include "panels/columns/DateRangeEdit.hpp" + +#include +#include +#include +#include +#include +#include + +#include "Theme.hpp" + +namespace geopro::app { + +DateRangeEdit::DateRangeEdit(QWidget* parent) : QComboBox(parent) { + addItem(QString()); // 单项承载显示文本(不展开列表,点击改为弹双日历) + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setToolTip(QStringLiteral("按采集时间筛选(起 ~ 止),可清空")); + updateText(); +} + +void DateRangeEdit::showPopup() { + if (!popup_) { + // 父对象用顶层窗口而非本 combo:否则日历(QAbstractItemView)成 QComboBox 后代,吃到全局 + // 「QComboBox QAbstractItemView::item」下拉项样式(padding/min-height)→ 日期格重叠错位。 + QWidget* owner = window() ? static_cast(window()) : nullptr; + popup_ = new QFrame(owner, Qt::Popup); + popup_->setFrameShape(QFrame::StyledPanel); + applyTokenizedStyleSheet( + popup_, QStringLiteral("QFrame{background:{{bg/panel}};border:1px solid {{border/default}};" + "border-radius:8px;}")); + auto* v = new QVBoxLayout(popup_); + v->setContentsMargins(space::kSm, space::kSm, space::kSm, space::kSm); + + auto* cals = new QHBoxLayout(); + auto* fromCol = new QVBoxLayout(); + fromCol->addWidget(new QLabel(QStringLiteral("起始"), popup_)); + fromCal_ = new QCalendarWidget(popup_); + fromCol->addWidget(fromCal_); + cals->addLayout(fromCol); + auto* toCol = new QVBoxLayout(); + toCol->addWidget(new QLabel(QStringLiteral("结束"), popup_)); + toCal_ = new QCalendarWidget(popup_); + toCol->addWidget(toCal_); + cals->addLayout(toCol); + v->addLayout(cals); + + auto* btns = new QHBoxLayout(); + auto* clr = new QPushButton(QStringLiteral("清空"), popup_); + auto* ok = new QPushButton(QStringLiteral("确定"), popup_); + connect(clr, &QPushButton::clicked, this, &DateRangeEdit::clearAndClose); + connect(ok, &QPushButton::clicked, this, &DateRangeEdit::applyAndClose); + btns->addWidget(clr); + btns->addStretch(1); + btns->addWidget(ok); + v->addLayout(btns); + } + // 已有范围则定位到当前值,否则日历默认今天。 + if (from_.isValid()) fromCal_->setSelectedDate(from_); + if (to_.isValid()) toCal_->setSelectedDate(to_); + popup_->adjustSize(); + popup_->move(mapToGlobal(QPoint(0, height()))); + popup_->show(); +} + +void DateRangeEdit::hidePopup() { + if (popup_) popup_->hide(); // 我们用自绘双日历 popup,不走 QComboBox 列表 +} + +void DateRangeEdit::applyAndClose() { + from_ = fromCal_->selectedDate(); + to_ = toCal_->selectedDate(); + if (from_.isValid() && to_.isValid() && from_ > to_) std::swap(from_, to_); // 起>止则交换 + if (popup_) popup_->hide(); + updateText(); + emit rangeChanged(); +} + +void DateRangeEdit::clearAndClose() { + from_ = QDate(); + to_ = QDate(); + if (popup_) popup_->hide(); + updateText(); + emit rangeChanged(); +} + +void DateRangeEdit::updateText() { + if (!from_.isValid() && !to_.isValid()) { + setItemText(0, QStringLiteral("全部时间")); + return; + } + const QString f = from_.isValid() ? from_.toString(QStringLiteral("yyyy-MM-dd")) : QStringLiteral("不限"); + const QString t = to_.isValid() ? to_.toString(QStringLiteral("yyyy-MM-dd")) : QStringLiteral("不限"); + setItemText(0, QStringLiteral("%1 ~ %2").arg(f, t)); +} + +} // namespace geopro::app diff --git a/src/app/panels/columns/DateRangeEdit.hpp b/src/app/panels/columns/DateRangeEdit.hpp new file mode 100644 index 0000000..4a591ac --- /dev/null +++ b/src/app/panels/columns/DateRangeEdit.hpp @@ -0,0 +1,39 @@ +#pragma once +#include +#include + +class QCalendarWidget; +class QFrame; + +namespace geopro::app { + +// 日期范围选择器(spec 采集时间筛选):外观与普通 QComboBox **完全一致**(同款下拉箭头/高度/边框, +// 与装置类型下拉是同一种控件),但点开弹「双日历」选起止而非下拉列表。显示「起 ~ 止」/「全部时间」; +// 可清空(回到"全部时间");默认不限,日历定位今天。 +class DateRangeEdit : public QComboBox { + Q_OBJECT +public: + explicit DateRangeEdit(QWidget* parent = nullptr); + + QDate from() const { return from_; } // 无效 QDate = 不限下限 + QDate to() const { return to_; } // 无效 QDate = 不限上限 + + void showPopup() override; // 覆写:弹双日历面板(而非 QComboBox 默认列表) + void hidePopup() override; + +signals: + void rangeChanged(); + +private: + void applyAndClose(); + void clearAndClose(); + void updateText(); + + QFrame* popup_ = nullptr; + QCalendarWidget* fromCal_ = nullptr; + QCalendarWidget* toCal_ = nullptr; + QDate from_; + QDate to_; +}; + +} // namespace geopro::app diff --git a/src/app/panels/web/ProjectWebView.cpp b/src/app/panels/web/ProjectWebView.cpp new file mode 100644 index 0000000..4a50953 --- /dev/null +++ b/src/app/panels/web/ProjectWebView.cpp @@ -0,0 +1,63 @@ +#include "panels/web/ProjectWebView.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::app { + +namespace { + +// 把字符串转成安全的 JS 字面量(带引号、转义),用于拼进注入脚本。 +QString jsStringLiteral(const QString& s) { + // QJsonValue::toJson 不直接给单值字符串;手工转义足够(token 仅含 base64/空格)。 + QString out; + out.reserve(s.size() + 2); + out += QLatin1Char('"'); + for (const QChar c : s) { + switch (c.unicode()) { + case '\\': out += QStringLiteral("\\\\"); break; + case '"': out += QStringLiteral("\\\""); break; + case '\n': out += QStringLiteral("\\n"); break; + case '\r': out += QStringLiteral("\\r"); break; + case '\t': out += QStringLiteral("\\t"); break; + default: out += c; break; + } + } + out += QLatin1Char('"'); + return out; +} + +} // namespace + +ProjectWebView::ProjectWebView(const QString& token, QWidget* parent) : QWidget(parent) { + auto* lay = new QVBoxLayout(this); + lay->setContentsMargins(0, 0, 0, 0); + lay->setSpacing(0); + + view_ = new QWebEngineView(this); + lay->addWidget(view_, 1); + + // token 注入:DocumentCreation 阶段把登录 token 写入 localStorage["token"], + // 早于嵌入页 SPA 启动脚本,保证其读取鉴权时已就绪。每次 load 都会重新执行。 + if (!token.isEmpty()) { + QWebEngineScript script; + script.setName(QStringLiteral("inject-geopro-token")); + script.setInjectionPoint(QWebEngineScript::DocumentCreation); + script.setWorldId(QWebEngineScript::MainWorld); + script.setRunsOnSubFrames(true); + script.setSourceCode( + QStringLiteral("try{localStorage.setItem('token', %1);}catch(e){}") + .arg(jsStringLiteral(token))); + view_->page()->scripts().insert(script); + } +} + +void ProjectWebView::load(const QString& url) { + view_->load(QUrl(url)); +} + +} // namespace geopro::app diff --git a/src/app/panels/web/ProjectWebView.hpp b/src/app/panels/web/ProjectWebView.hpp new file mode 100644 index 0000000..e8dcafe --- /dev/null +++ b/src/app/panels/web/ProjectWebView.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include + +class QWebEngineView; + +namespace geopro::app { + +// 项目管理 webview 宿主:内嵌 QWebEngineView,承载需「直接嵌入」的 web 管理页 +// (在线监测 / 工具组件 / 批量导出 / 告警管理)。 +// 构造期注入 DocumentCreation 脚本,把登录 token 写入页面 localStorage["token"], +// 早于页面自身脚本执行,确保 web 端读取鉴权时已就绪。 +class ProjectWebView : public QWidget { + Q_OBJECT +public: + explicit ProjectWebView(const QString& token, QWidget* parent = nullptr); + + // 加载嵌入页(完整 URL,含 #/embed?space=..&projectId=..&target=..)。 + void load(const QString& url); + +private: + QWebEngineView* view_ = nullptr; +}; + +} // namespace geopro::app diff --git a/src/app/resources/icons.qrc b/src/app/resources/icons.qrc new file mode 100644 index 0000000..7a03108 --- /dev/null +++ b/src/app/resources/icons.qrc @@ -0,0 +1,9 @@ + + + + + icons/chevron-down.svg + icons/chevron-up.svg + + diff --git a/src/app/resources/icons/chevron-down.svg b/src/app/resources/icons/chevron-down.svg new file mode 100644 index 0000000..82cd35f --- /dev/null +++ b/src/app/resources/icons/chevron-down.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/app/resources/icons/chevron-up.svg b/src/app/resources/icons/chevron-up.svg new file mode 100644 index 0000000..9d9d562 --- /dev/null +++ b/src/app/resources/icons/chevron-up.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/app/resources/keys.qrc b/src/app/resources/keys.qrc new file mode 100644 index 0000000..512d98b --- /dev/null +++ b/src/app/resources/keys.qrc @@ -0,0 +1,8 @@ + + + + + ../../../resources/rsa_public_key.pem + + diff --git a/src/controller/CMakeLists.txt b/src/controller/CMakeLists.txt index 0bbe490..54a5a11 100644 --- a/src/controller/CMakeLists.txt +++ b/src/controller/CMakeLists.txt @@ -1,7 +1,8 @@ find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_controller STATIC WorkbenchNavController.cpp - DatasetDetailController.cpp) + DatasetDetailController.cpp + VtkSceneController.cpp) target_include_directories(geopro_controller PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(geopro_controller PUBLIC geopro_data Qt6::Core) target_compile_features(geopro_controller PUBLIC cxx_std_17) diff --git a/src/controller/DatasetDetailController.cpp b/src/controller/DatasetDetailController.cpp index 5706aa3..347da44 100644 --- a/src/controller/DatasetDetailController.cpp +++ b/src/controller/DatasetDetailController.cpp @@ -19,9 +19,9 @@ DatasetDetailController::~DatasetDetailController() { } void DatasetDetailController::openDataset(const QString& dsId, const QString& ddCode, - const QString& dsName) { - qInfo("[detail] openDataset id=%s ddCode=%s name=%s", qUtf8Printable(dsId), - qUtf8Printable(ddCode), qUtf8Printable(dsName)); + const QString& dsName, const QString& tmObjectId) { + qInfo("[detail] openDataset id=%s ddCode=%s name=%s tm=%s", qUtf8Printable(dsId), + qUtf8Printable(ddCode), qUtf8Printable(dsName), qUtf8Printable(tmObjectId)); auto* s = registry_.find(ddCode.toStdString()); if (!s) { // 未注册策略 → 优雅降级 qWarning("[detail] 未注册策略 ddCode=%s → 降级提示", qUtf8Printable(ddCode)); @@ -29,7 +29,7 @@ void DatasetDetailController::openDataset(const QString& dsId, const QString& dd return; } const std::vector tabs = s->tabs(); - emit datasetOpened(dsId, ddCode, dsName, tabs); + emit datasetOpened(dsId, ddCode, dsName, tmObjectId, tabs); for (int i = 0; i < static_cast(tabs.size()); ++i) if (!tabs[static_cast(i)].lazy) loadTab(dsId, ddCode, i); } diff --git a/src/controller/DatasetDetailController.hpp b/src/controller/DatasetDetailController.hpp index c3f6aaf..ea7b06b 100644 --- a/src/controller/DatasetDetailController.hpp +++ b/src/controller/DatasetDetailController.hpp @@ -21,7 +21,9 @@ public: ~DatasetDetailController() override; // 退出契约(spec §7):abort 全部在飞句柄,避免迟到信号打到已析构 this public slots: // 打开数据集:查策略 → datasetOpened(页签集) → 对每个非 lazy 页签发起 loadTab。 - void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString()); + // tmObjectId:数据集所属 TM 对象 id(=白化 structParentId),透传给详情页给白化对话框用;可空。 + void openDataset(const QString& dsId, const QString& ddCode, const QString& dsName = QString(), + const QString& tmObjectId = QString()); // 加载某页签(lazy 页签首次激活时由壳触发;非 lazy 由 openDataset 自动触发)。 // 分页型页签(如 dd_grid 列表)首载用默认页(pageNo=1/pageSize=0 → 仓储解析默认每页条数)。 void loadTab(const QString& dsId, const QString& ddCode, int tabIndex); @@ -31,7 +33,7 @@ public slots: void focusDataset(const QString& dsId); signals: void datasetOpened(const QString& dsId, const QString& ddCode, const QString& dsName, - const std::vector& tabs); + const QString& tmObjectId, const std::vector& tabs); void tabLoadStarted(const QString& dsId, int tabIndex); void tabReady(const QString& dsId, int tabIndex, const QVariant& payload); void loadFailed(const QString& dsId, const QString& message); diff --git a/src/controller/I3dSceneView.hpp b/src/controller/I3dSceneView.hpp new file mode 100644 index 0000000..d0cebde --- /dev/null +++ b/src/controller/I3dSceneView.hpp @@ -0,0 +1,85 @@ +#pragma once +#include + +#include "model/ColorScale.hpp" +#include "model/Field.hpp" +#include "repo/I3dSceneRepository.hpp" + +namespace geopro::controller { + +// 坐标轴显示方式(spec §4 C3–I3):标准 / 三维立体 / 不显示。 +enum class AxesMode { Standard, Stereo, None }; +// 坐标轴刻度单位(spec §4 D5–I5):无 / 米 / 英尺 / 经纬度。 +enum class AxesUnit { None, Meter, Feet, LatLon }; +// 快捷视图方向(spec §4 C7):前/后/左/右/上/下。 +enum class ViewDir { Front, Back, Left, Right, Top, Bottom }; + +// 单轴显示配置(坐标轴设置面板):是否显示该轴 + 是否用自定义刻度范围(值按当前单位)。 +struct AxisRangeCfg { + bool visible = true; + bool customRange = false; + double min = 0.0, max = 0.0; +}; + +// 三维场景视图抽象(编排层与 VTK 渲染解耦的缝): +// VtkSceneController 只发出"清场 / 加某类图元 / 提交渲染"指令,不认 vtkActor/vtkVolume; +// 真实实现(VtkSceneView)调 render actor + Scene;测试用 fake 记录调用断言编排。 +// verticalExaggeration 由视图统一作用于 3D 图元(actor SetScale(1,1,VE) / image z 烤入)。 +class I3dSceneView { +public: + virtual ~I3dSceneView() = default; + + virtual void clear() = 0; + virtual void setVerticalExaggeration(double ve) = 0; + // 三维体体绘制最大不透明度(0~1):运行时调节已渲染体 + 后续新体(默认 0.30)。默认空实现,测试 mock 无需覆盖。 + virtual void setVolumeOpacity(double maxOpacity) { (void)maxOpacity; } + // 地表高程基准(测线地表高程):2D 足迹「顶部/底部」摆放锚定真实地表。 + virtual double zRefElev() const = 0; + + // 2D:俯视测线红线(z=0)。 + virtual void addSurveyLine(const geopro::core::Grid& grid) = 0; + // 3D:竖直帘面(grid + colorScale 着色);按 dsId 跟踪以支持增量移除。 + virtual void addCurtain(const std::string& dsId, const geopro::core::Grid& grid, + const geopro::core::ColorScale& cs) = 0; + // 3D:体绘制(IDW 体素 + colorScale);按 dsId 跟踪。 + virtual void addVolume(const std::string& dsId, const geopro::data::VolumeGrid& vol, + const geopro::core::ColorScale& cs) = 0; + // 2D 足迹:把测线/轨迹经纬折线平铺进 3D 地图(worldZ=摆放高程);按 dsId 跟踪以支持增量移除。 + virtual void addMapLine(const std::string& dsId, const geopro::data::MapLine& line, + double worldZ) = 0; + // 3D:DEM 地形 + 影像纹理。 + virtual void addTerrain(const geopro::data::TerrainPaths& paths) = 0; + // 增量移除某数据集的全部图元(取消勾选时调,不影响其余 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; + // per-axis 可见性 + 自定义范围(坐标轴设置面板「应用」)。默认空实现,测试 mock 无需覆盖。 + virtual void setAxesRanges(const AxisRangeCfg&, const AxisRangeCfg&, const AxisRangeCfg&) {} + + // 快捷视图(P2):应用 6 向相机预设并提交渲染。 + virtual void applyCameraView(ViewDir dir) = 0; + // 缩放(P2):factor>1 放大、<1 缩小,提交渲染。 + virtual void zoom(double factor) = 0; + // 适配全览(P2):ResetCamera 并提交渲染。 + virtual void fitView() = 0; + + // 应用相机预设(2D 俯视 / 3D 自由)并提交渲染(全量重建用)。resetCamera=true 时 ResetCamera 取景到 + // 数据;false 时保留当前相机(如改放大系数全量重建但要原地重绘,不跳远视角)。 + virtual void render(bool is2D, bool resetCamera = true) = 0; + // 增量提交渲染:重建坐标轴并刷新,但不动相机(勾选/取消单个 ds 时视角不跳)。 + virtual void renderIncremental() = 0; +}; + +} // namespace geopro::controller diff --git a/src/controller/VtkSceneController.cpp b/src/controller/VtkSceneController.cpp new file mode 100644 index 0000000..16aefce --- /dev/null +++ b/src/controller/VtkSceneController.cpp @@ -0,0 +1,361 @@ +#include "VtkSceneController.hpp" + +#include +#include +#include + +#include +#include + +#include "I3dSceneView.hpp" +#include "repo/IDatasetRepository.hpp" + +namespace geopro::controller { + +namespace { +// 二维足迹「顶部/底部」摆放相对参考高程(Z=0)的偏移(米):控制器无地形/参考高程源 +// (地形异步、帘面经纬未必到场),故退化为 Z=0 上/下固定偏移,使足迹不与帘面顶/底面重叠遮挡。 +constexpr double kTopOffsetZ = 50.0; // 顶部:参考面上方 +constexpr double kBottomOffsetZ = -50.0; // 底部:参考面下方 +} // namespace + +VtkSceneController::VtkSceneController(data::IDatasetRepository& dsRepo, + data::I3dSceneRepository& sceneRepo, I3dSceneView& view, + QObject* parent) + : QObject(parent), dsRepo_(dsRepo), sceneRepo_(sceneRepo), view_(view) {} + +void VtkSceneController::setCheckedDatasets(const QStringList& dsIds) { + std::vector newDs; + newDs.reserve(static_cast(dsIds.size())); + for (const QString& id : dsIds) newDs.push_back(id.toStdString()); + + // 2D 俯视测线:保持全量重建(测线非按 ds 跟踪移除)。 + if (mode_ == ViewMode::Map2D) { + checkedDs_ = std::move(newDs); + rebuildInternal(); + return; + } + + // 3D:增量 diff —— 只处理新增/移除,不全量重建(底图、其余 ds、相机均不动)。 + const std::set oldSet(checkedDs_.begin(), checkedDs_.end()); + const std::set newSet(newDs.begin(), newDs.end()); + + for (const auto& id : checkedDs_) + if (!newSet.count(id)) view_.removeDataset(id); // 移除:旧有新无 → 仅删该 ds 图元 + + checkedDs_ = std::move(newDs); + // 取景意图按「场景是否已有数据到场过」判定,而非 checkedDs_ 是否空——否则连续快速勾选第二个 + // ds 时 checkedDs_ 已非空但首批尚未到场,会被误清取景意图,相机不对准数据 → 看似不渲染。 + fitOnArrival_ = !hadArrivedData_; + // 仅当 3D 与 2D 都空才复位取景基线:否则取消全部 3D 但仍有 2D 足迹时,下个 3D 勾选会误跳取景。 + if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; + + const unsigned long long gen = rebuildGeneration_; // 不自增:并发增量互不作废 + for (const auto& id : checkedDs_) + if (!oldSet.count(id)) addDatasetAsync(id, gen); // 新增:新有旧无 → 异步取数增量入场 + + view_.renderIncremental(); // 立即反映移除 / 触发坐标轴重算 +} + +void VtkSceneController::setChecked2DDatasets(const QStringList& dsIds) { + std::vector newDs; + newDs.reserve(static_cast(dsIds.size())); + for (const QString& id : dsIds) newDs.push_back(id.toStdString()); + + // 二维足迹始终画进 View3D 场景,且按 dsId 跟踪 → 一律增量 diff(不全量重建,不打断 3D 帘面/体)。 + const std::set oldSet(checked2dDs_.begin(), checked2dDs_.end()); + const std::set newSet(newDs.begin(), newDs.end()); + + for (const auto& id : checked2dDs_) + if (!newSet.count(id)) view_.removeDataset(id); // 取消勾选 → 移除该足迹图元 + + checked2dDs_ = std::move(newDs); + // 取景基线与 3D 路径统一用 hadArrivedData_(而非"两栏皆空"):否则二维分析下若已有隐藏的 3D 数据, + // 勾选首条足迹会因 wasEmpty=false 而不取景 → 足迹落在视野外。切 tab 时 onAnalysisModeChanged 已按 + // 目标维度是否有数据重置该基线,故此处首条可见维度数据能正确取景。 + fitOnArrival_ = !hadArrivedData_; + if (checkedDs_.empty() && checked2dDs_.empty()) hadArrivedData_ = false; + + // 足迹画进 View3D 场景;mode=0 关闭 → 仅记录勾选不渲染(见 set2DPlacement 切回时补画)。 + if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { + const unsigned long long gen = rebuildGeneration_; // 不自增:与 3D 增量互不作废 + for (const auto& id : checked2dDs_) + if (!oldSet.count(id)) add2DDatasetAsync(id, gen); // 新增 → 异步取足迹增量入场 + } + + view_.renderIncremental(); // 立即反映移除 +} + +void VtkSceneController::set2DPlacement(int mode, double customZ) { + const bool changed = (mode != placement2dMode_) || (mode == 4 && customZ != customZ2d_); + placement2dMode_ = mode; + customZ2d_ = customZ; + if (!changed || checked2dDs_.empty()) return; + + // 摆放变化 → 对已勾选足迹重摆:先全部移除,再按新 Z 重加(mode=0 关闭则只移除不重加)。 + for (const auto& id : checked2dDs_) view_.removeDataset(id); + if (placement2dMode_ != 0 && mode_ == ViewMode::View3D) { + const unsigned long long gen = rebuildGeneration_; + fitOnArrival_ = false; // 重摆:保持相机 + for (const auto& id : checked2dDs_) add2DDatasetAsync(id, gen); + } + view_.renderIncremental(); +} + +double VtkSceneController::placementZ() const { + const double surf = view_.zRefElev(); // 真实地表高程基准(测线地表高程) + switch (placement2dMode_) { + case 1: return 0.0; // Z=0(世界原点) + case 2: return surf + kTopOffsetZ; // 顶部:贴真实地表上方 + case 3: return surf + kBottomOffsetZ; // 底部:真实地表下方 + case 4: return customZ2d_; // 自定义 + default: return 0.0; // 关闭(0) 不应走到此(调用方拦截) + } +} + +void VtkSceneController::add2DDatasetAsync(const std::string& dsId, unsigned long long gen) { + if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 + loadingDs_.insert(dsId); + QPointer self(this); + sceneRepo_.loadMapLine( + dsId, + [self, gen, dsId](data::MapLine line) { + if (!self) return; + self->loadingDs_.erase(dsId); + // gen 作废 / 已取消勾选 / 摆放已关闭 → 丢弃迟到回调。 + if (gen != self->rebuildGeneration_ || !self->is2DChecked(dsId) || + self->placement2dMode_ == 0) { + return; + } + // 落地时按当前摆放 Z(非请求时快照)→ 加载期间摆放变化也取最新高程。 + self->view_.addMapLine(dsId, line, self->placementZ()); + self->onDatasetArrived(); + }, + [self, gen, dsId](const std::string& m) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); +} + +void VtkSceneController::addDatasetAsync(const std::string& dsId, unsigned long long gen) { + if (loadingDs_.count(dsId)) return; // 已在加载(重复勾选竞态)→ 不重复请求 + QPointer self(this); + + // 按数据集类型分流(取代旧全局 showCurtain_/showVoxel_ 开关): + // 三维体(dd_voxel,客户端创建)→ 体素渲染;其余剖面(dd_section 等)→ 帘面渲染。 + if (sceneRepo_.isVolumeDataset(dsId)) { + auto cachedGrid = volumeCache_.find(dsId); + auto cachedScale = volumeScaleCache_.find(dsId); + if (cachedGrid != volumeCache_.end() && cachedScale != volumeScaleCache_.end()) { + view_.addVolume(dsId, cachedGrid->second, cachedScale->second); // 缓存命中(色阶随体缓存) + onDatasetArrived(); + emit volumeRendered(QString::fromStdString(dsId)); // 缓存命中即时完成 → 撤 spinner + emit datasetRendered(QString::fromStdString(dsId)); + return; + } + loadingDs_.insert(dsId); + emit datasetLoading(QString::fromStdString(dsId)); // 异步建体开始 → 列表项转 spinner + sceneRepo_.loadVolume( + dsId, + [self, gen, dsId](data::VolumeGrid g, core::ColorScale cs) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; + self->volumeScaleCache_[dsId] = cs; // 色阶随体一起缓存(mock 体在 dsRepo_ 无条目) + auto it = self->volumeCache_.emplace(dsId, std::move(g)).first; + qInfo().noquote() << "[volrender] addVolume dsId=" << QString::fromStdString(dsId) + << "nx=" << it->second.vol.nx() << "ny=" << it->second.vol.ny() + << "nz=" << it->second.vol.nz(); + self->view_.addVolume(dsId, it->second, self->volumeScaleCache_[dsId]); + self->onDatasetArrived(); + emit self->volumeRendered(QString::fromStdString(dsId)); // 落地完成 → 撤 spinner + emit self->datasetRendered(QString::fromStdString(dsId)); + }, + [self, gen, dsId](const std::string& m) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); + return; + } + + // 剖面 → 帘面(着色用 loadSection 返回的 s.scale,与体的源色阶同源)。 + loadingDs_.insert(dsId); + emit datasetLoading(QString::fromStdString(dsId)); // 剖面首次加载较慢 → 列表项转 spinner + sceneRepo_.loadSection( + dsId, + [self, gen, dsId](data::SectionData s) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_ || !self->isChecked(dsId)) return; // 作废/已取消 + self->view_.addCurtain(dsId, s.grid, s.scale); + self->onDatasetArrived(); + emit self->datasetRendered(QString::fromStdString(dsId)); // 帘面落地 → 复原复选框 + }, + [self, gen, dsId](const std::string& m) { + if (!self) return; + self->loadingDs_.erase(dsId); + if (gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); +} + +void VtkSceneController::onDatasetArrived() { + hadArrivedData_ = true; // 标记场景已有数据到场(取景意图据此判定) + view_.renderIncremental(); + // 首批取景阶段(含连续勾选的多个 ds)每个到场都 fit → ResetCamera 含全部 actor,两个 ds 都入景; + // 后续单独勾选时 setCheckedDatasets 已据 hadArrivedData_ 置 fitOnArrival_=false,相机不再跳。 + if (fitOnArrival_) view_.fitView(); +} + +bool VtkSceneController::isChecked(const std::string& dsId) const { + return std::find(checkedDs_.begin(), checkedDs_.end(), dsId) != checkedDs_.end(); +} + +bool VtkSceneController::is2DChecked(const std::string& dsId) const { + return std::find(checked2dDs_.begin(), checked2dDs_.end(), dsId) != checked2dDs_.end(); +} + +void VtkSceneController::setViewMode(ViewMode mode) { + mode_ = mode; + rebuildInternal(); +} + +void VtkSceneController::onAnalysisModeChanged(bool is2D) { + // 切「三维分析/二维分析」tab:按目标维度是否已有数据重置取景基线。 + // 目标维度空 → hadArrivedData_=false:切换后该维度第一条数据自动取景(治"3D 数据不知生成到哪")。 + // 目标维度非空 → hadArrivedData_=true:视图切换时已 fit 到该维度,后续勾选不再跳(与三维一致)。 + // 显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 处理(上层在同一处调用);此处只管取景基线。 + hadArrivedData_ = is2D ? !checked2dDs_.empty() : !checkedDs_.empty(); +} + +void VtkSceneController::setLayer(SceneLayer layer, bool on) { + switch (layer) { + case SceneLayer::Curtain: showCurtain_ = on; break; + case SceneLayer::Voxel: showVoxel_ = on; break; + case SceneLayer::Terrain: showTerrain_ = on; break; + } + rebuildInternal(); +} + +void VtkSceneController::setVerticalExaggeration(double ve) { + verticalExaggeration_ = ve; + // VE 烤进帘面 SetScale / 体素 image / 地形几何,须全量重建;但保留当前相机 → 原地按新夸张重绘, + // 不先跳远视角再回(用户反馈)。重建中 fitOnArrival_ 也置 false(见 rebuildInternal), + // 异步到场数据经 renderIncremental 在当前相机下显示。 + preserveCameraOnRebuild_ = true; + rebuildInternal(); + preserveCameraOnRebuild_ = false; +} + +void VtkSceneController::setVolumeOpacity(double maxOpacity) { + // 运行时更新已渲染体的不透明度传递函数(不重建体,实时跟手)+ 记为后续新体默认(见 VtkSceneView)。 + view_.setVolumeOpacity(maxOpacity); +} + +void VtkSceneController::rebuild() { rebuildInternal(); } + +void VtkSceneController::setVolumeColorScale(const std::string& dsId, + const geopro::core::ColorScale& cs) { + volumeScaleCache_[dsId] = cs; // 会话级 mock 持久(再勾选命中缓存,见 addDatasetAsync) + if (!isChecked(dsId)) return; // 未渲染 → 仅更缓存,下次勾选生效 + auto git = volumeCache_.find(dsId); + if (git == volumeCache_.end()) return; // 体网格尚未到场 → 同上 + // 移除旧体素 → 以新色阶重建:addVolume 内部置 currentColorScale_ 并触发 onVolumeChanged, + // InteractionManager 据此以新色阶重建该体下已勾选切片。 + view_.removeDataset(dsId); + view_.addVolume(dsId, git->second, cs); + view_.renderIncremental(); +} + +void VtkSceneController::setAxesMode(AxesMode mode) { + axesMode_ = mode; + rebuildInternal(); // 坐标轴随场景重建(clear 会移除旧坐标轴 prop) +} + +void VtkSceneController::setAxesUnit(AxesUnit unit) { + axesUnit_ = unit; + rebuildInternal(); +} + +void VtkSceneController::setAxesConfig(AxesMode mode, AxesUnit unit, const AxisRangeCfg& x, + const AxisRangeCfg& y, const AxisRangeCfg& z) { + axesMode_ = mode; + axesUnit_ = unit; + axisX_ = x; + axisY_ = y; + axisZ_ = z; + // 增量:坐标轴只是 overlay prop,改单位/范围/可见性无需清场景重载数据。仅把配置下发 view 后 + // renderIncremental(rebuildAxes 重建坐标轴 prop + Render),不动数据图元、不重置相机。 + view_.setAxes(axesMode_, axesUnit_, kAxesFontSize); + view_.setAxesRanges(axisX_, axisY_, axisZ_); + view_.renderIncremental(); +} + +// 快捷视图 / 缩放:仅改相机,不重建场景(无须取数/重装图元)。 +void VtkSceneController::applyView(ViewDir dir) { view_.applyCameraView(dir); } +void VtkSceneController::zoomIn() { view_.zoom(1.2); } +void VtkSceneController::zoomOut() { view_.zoom(1.0 / 1.2); } +void VtkSceneController::fit() { view_.fitView(); } + +const geopro::core::Grid& VtkSceneController::grid(const std::string& dsId) { + auto it = gridCache_.find(dsId); + if (it == gridCache_.end()) it = gridCache_.emplace(dsId, dsRepo_.loadGrid(dsId)).first; + return it->second; +} + +const geopro::core::ColorScale& VtkSceneController::colorScale(const std::string& dsId) { + auto it = colorScaleCache_.find(dsId); + if (it == colorScaleCache_.end()) + it = colorScaleCache_.emplace(dsId, dsRepo_.loadColorScale(dsId)).first; + return it->second; +} + +void VtkSceneController::rebuildInternal() { + const unsigned long long gen = ++rebuildGeneration_; // 自增:作废此前所有在途增量回调 + const bool is2D = (mode_ == ViewMode::Map2D); + + view_.clear(); // 移除全部数据图元(保留底图);frame 重锚标志复位 + loadingDs_.clear(); // 旧在途加载随之作废(回调按 gen 丢弃) + view_.setVerticalExaggeration(verticalExaggeration_); + // 坐标轴设置在 clear 后下发:render 末尾据当前场景包围盒重建坐标轴 prop。 + view_.setAxes(axesMode_, axesUnit_, kAxesFontSize); + view_.setAxesRanges(axisX_, axisY_, axisZ_); + fitOnArrival_ = !preserveCameraOnRebuild_; // 保留相机重建(改VE):到场数据不自动取景,留当前视角 + + // 坏 dsId(loadGrid/loadColorScale 抛异常)= best-effort 跳过:emit loadFailed 但不中断。 + try { + if (is2D) { + for (const auto& dsId : checkedDs_) view_.addSurveyLine(grid(dsId)); + } else { + // 回调用 QPointer 守对象存活 + gen 守数据新鲜:迟到回调若已析构/作废则丢弃。 + QPointer self(this); + if (showTerrain_) { + sceneRepo_.loadTerrainPaths( + [self, gen](data::TerrainPaths p) { + if (!self || gen != self->rebuildGeneration_) return; // 已析构/迟到:丢弃 + self->view_.addTerrain(std::move(p)); + self->onDatasetArrived(); + }, + [self, gen](const std::string& m) { + if (!self || gen != self->rebuildGeneration_) return; + emit self->loadFailed(QString::fromStdString(m)); + }); + } + for (const auto& dsId : checkedDs_) addDatasetAsync(dsId, gen); + // 二维足迹随全量重建一并重画(clear 已移除其图元);mode=0 关闭则跳过。 + if (placement2dMode_ != 0) + for (const auto& dsId : checked2dDs_) add2DDatasetAsync(dsId, gen); + } + } catch (const std::exception& e) { + emit loadFailed(QString::fromStdString(e.what())); + } + + // 保留相机重建(改VE):不 ResetCamera,原地按新夸张重绘。 + view_.render(is2D, /*resetCamera=*/!preserveCameraOnRebuild_); +} + +} // namespace geopro::controller diff --git a/src/controller/VtkSceneController.hpp b/src/controller/VtkSceneController.hpp new file mode 100644 index 0000000..88d85ce --- /dev/null +++ b/src/controller/VtkSceneController.hpp @@ -0,0 +1,138 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "I3dSceneView.hpp" +#include "model/ColorScale.hpp" +#include "model/Field.hpp" +#include "repo/I3dSceneRepository.hpp" + +namespace geopro::data { +class IDatasetRepository; +} + +namespace geopro::controller { + +// 中央视图模式:二维地图(俯视测线)/ 三维视图(帘面/体素/地形)。 +enum class ViewMode { Map2D, View3D }; + +// 三维图层("视图详情"浮层勾选)。 +enum class SceneLayer { Curtain, Voxel, Terrain }; + +// 中央 VTK 渲染编排(spec §8):聚合 勾选数据集 + 视图模式 + 图层开关 + 纵向比例, +// 经仓储取 core::* 数据,命令 I3dSceneView 重建场景。取代 main.cpp 的 rebuildCentral lambda。 +// 异步:经 I3dSceneRepository 回调取体素/地形(回调内置幂请求标记防迟到回灌)。 +// 缓存:Grid / VolumeGrid 按 dsId 缓存,避免重复取数。 +// 不持有 widget;不认 vtkActor/vtkVolume(全交给 I3dSceneView)。 +class VtkSceneController : public QObject { + Q_OBJECT +public: + VtkSceneController(data::IDatasetRepository& dsRepo, data::I3dSceneRepository& sceneRepo, + I3dSceneView& view, QObject* parent = nullptr); + +public slots: + void setCheckedDatasets(const QStringList& dsIds); + // 二维数据集栏勾选(足迹型测线/轨迹)→ 平铺进 View3D 地图。与 3D 勾选集独立,按 dsId 增量。 + void setChecked2DDatasets(const QStringList& dsIds); + // 二维足迹摆放高度(mode:0关闭 /1 Z=0 /2 顶部 /3 底部 /4 自定义;customZ 仅 mode=4 用)。 + void set2DPlacement(int mode, double customZ); + void setViewMode(ViewMode mode); + // 切「三维分析/二维分析」tab(A 期):按目标维度是否已有数据重置取景基线,使切换后该维度第一条 + // 数据自动取景。显隐/相机/坐标轴由 VtkSceneView::setAnalysisMode2D 负责(上层在同一处一并调用)。 + void onAnalysisModeChanged(bool is2D); + void setLayer(SceneLayer layer, bool on); + void setVerticalExaggeration(double ve); + // 三维体透明度调节(工具条滑块):运行时更新已渲染体的不透明度,并作为后续新体默认(0~1)。 + void setVolumeOpacity(double maxOpacity); + void rebuild(); // 主题切换等外部触发的重渲染 + + // 色阶编辑器「确定」:更新某三维体色阶并就地重渲染(体素 + 其切片随新色阶重建)。 + // 后端 3D 色阶保存未就绪 → 缓存即会话级 mock 持久(再勾选命中 volumeScaleCache_)。 + void setVolumeColorScale(const std::string& dsId, const geopro::core::ColorScale& cs); + + // ── P2 三维数据集栏 ── + void setAxesMode(AxesMode mode); + void setAxesUnit(AxesUnit unit); + // 坐标轴设置面板「应用」:一次性下发 显示方式 + 单位 + per-axis 可见性/范围(单次重建)。 + void setAxesConfig(AxesMode mode, AxesUnit unit, const AxisRangeCfg& x, const AxisRangeCfg& y, + const AxisRangeCfg& z); + void applyView(ViewDir dir); // 6 向快捷视图 + void zoomIn(); // Zoom In (×1.2) + void zoomOut(); // Zoom Out (×1/1.2) + void fit(); // Fit (ResetCamera) + +signals: + void loadFailed(const QString& message); + // 三维体异步建体+落地渲染完成(dsId)。供 UI 撤回该体列表项的等待 spinner、复原复选框。 + void volumeRendered(const QString& dsId); + // 任一数据集(剖面/体)异步加载开始 / 渲染完成:上层据此把该列表项复选框↔等待 spinner 切换。 + // 仅异步路径发(缓存命中即时完成只发 rendered);覆盖非三维体(剖面首次渲染也较慢,用户反馈)。 + void datasetLoading(const QString& dsId); + void datasetRendered(const QString& dsId); + +private: + void rebuildInternal(); + // 增量加入单个 ds(帘面/体素,按图层开关);回调按 gen + 仍勾选 守护,落地后增量渲染。 + void addDatasetAsync(const std::string& dsId, unsigned long long gen); + // 增量加入单个 2D 足迹(异步 loadMapLine → addMapLine at 当前摆放 Z);回调按 gen + 仍勾选 守护。 + void add2DDatasetAsync(const std::string& dsId, unsigned long long gen); + void onDatasetArrived(); // 单个 ds 落地后:增量渲染 + 首批数据自动取景 + bool isChecked(const std::string& dsId) const; + bool is2DChecked(const std::string& dsId) const; + // 当前摆放模式下足迹的世界 Z(mode 0=关闭由调用方拦截;此处算 1/2/3/4 的 Z)。 + double placementZ() const; + + data::IDatasetRepository& dsRepo_; + data::I3dSceneRepository& sceneRepo_; + I3dSceneView& view_; + + std::vector checkedDs_; + // 二维足迹勾选集(与 checkedDs_ 独立;都画进 View3D 场景)。按 dsId 增量加/删。 + std::vector checked2dDs_; + // 二维足迹摆放:mode 0关闭/1 Z=0/2顶部/3底部/4自定义;customZ2d_ 仅 mode=4 用。 + // 默认 Z=0(1) 与 Column2DDataset「2D视图」下拉可见默认项一致——避免「下拉显示 Z=0 但 + // 控制器实为关闭」的初始信号丢失desync(组合框 setCurrentIndex 在 connect 前发射、且 + // 组件早于 main.cpp 接线构造,初始 view2DModeChanged 永不送达),致勾选足迹静默不渲染。 + int placement2dMode_ = 1; + double customZ2d_ = 0.0; + ViewMode mode_ = ViewMode::Map2D; + bool showCurtain_ = true; + bool showVoxel_ = false; + bool showTerrain_ = false; + double verticalExaggeration_ = 1.0; + bool preserveCameraOnRebuild_ = false; // 改放大系数等:全量重建但保留当前相机(不跳远视角) + + // 坐标轴设置(P2):默认标准 + 米;字号固定 12(字体设置待 1.0 确认)。 + AxesMode axesMode_ = AxesMode::Standard; + AxesUnit axesUnit_ = AxesUnit::Meter; + AxisRangeCfg axisX_, axisY_, axisZ_; // 坐标轴设置面板的 per-axis 可见性 + 自定义范围 + static constexpr int kAxesFontSize = 12; + + // 缓存(按 dsId):避免重复读盘/插值。 + std::map gridCache_; + std::map colorScaleCache_; + std::map volumeCache_; + // 三维体色阶缓存:mock 体在 dsRepo_ 无条目,色阶随 loadVolume 一起交付并缓存于此。 + std::map volumeScaleCache_; + + // 异步回灌防护:每次全量 rebuild 自增,回调比对丢弃迟到结果。 + unsigned long long rebuildGeneration_ = 0; + + // 增量渲染状态:本批数据到场是否自动取景(全量重建/从空开始=true;增量追加=false,保持相机)。 + bool fitOnArrival_ = true; + // 场景是否已有数据到场过:取景意图据此判定,避免连续快速勾选时 checkedDs_ 已非空但首批未到场 + // 被误判为「增量追加」而不取景(连续勾两个 ds 看似不渲染的根因)。全取消勾选时复位。 + bool hadArrivedData_ = false; + // 正在加载的 ds:防重复勾选竞态重复请求;全量重建时清空。 + std::set loadingDs_; + + const geopro::core::Grid& grid(const std::string& dsId); + const geopro::core::ColorScale& colorScale(const std::string& dsId); +}; + +} // namespace geopro::controller diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 6312581..e41bbdd 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -7,6 +7,8 @@ add_library(geopro_core STATIC geo/CrsTransform.cpp model/ColorScale.cpp algo/IdwInterpolator.cpp + algo/VolumeBuilder.cpp + algo/GprVolumeBuilder.cpp ) target_include_directories(geopro_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/core/algo/GeoVolumeBuilder.cpp b/src/core/algo/GeoVolumeBuilder.cpp new file mode 100644 index 0000000..201ce71 --- /dev/null +++ b/src/core/algo/GeoVolumeBuilder.cpp @@ -0,0 +1,423 @@ +// GeoVolumeBuilder:按真实 RTK 几何把多条测线插值进统一路向网格。 +// +// 编排层(io_gpr + core 采样核 + store),故与 StreamingVolumeBuilder 一样编进 +// geopro_store(不污染纯 geopro_core)。命名空间保持 geopro::core(接口契约)。 +#include "core/algo/GeoVolumeBuilder.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" // BuiltI16 +#include "core/model/ScalarVolumeI16.hpp" // ScalarVolumeI16::kBlank, Quant +#include "data/store/ChunkedVolumeStore.hpp" +#include "io/gpr/GprGeometry.hpp" // parseChannelXOffsets, depthOfSample +#include "io/gpr/GpsTrack.hpp" +#include "io/gpr/IprHeader.hpp" +#include "io/gpr/IprbReader.hpp" + +namespace fs = std::filesystem; + +namespace geopro::core { + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +// 读 .iprh 文本 → 解析头(与 .iprb 同名)。 +geopro::io::gpr::IprHeader readHeaderFor(const std::string& iprbPath) { + fs::path h = fs::path(iprbPath).replace_extension(".iprh"); + std::ifstream f(h); + if (!f) throw std::runtime_error("GeoVolumeBuilder: 打不开 iprh " + h.string()); + std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return geopro::io::gpr::parseIprHeader(text); +} + +// 一条线的标尺(轨迹局部米 + 通道横偏 + 头)。 +struct LineScale { + std::vector trackM; // 该线轨迹(局部米,旋转前) + std::vector chanX; // 通道横偏(米,文件顺序) + geopro::io::gpr::IprHeader header; + std::int64_t totalTraces = 0; +}; + +// 全线总道数 = min 通道(fileBytes/(samples*2))(与 assembler 口径一致)。 +std::int64_t totalTracesOf(const std::vector& iprb, int samples) { + std::int64_t minTr = std::numeric_limits::max(); + const std::int64_t per = static_cast(samples) * 2; + if (per <= 0) throw std::runtime_error("samples<=0"); + for (const auto& p : iprb) { + const std::int64_t bytes = static_cast(fs::file_size(p)); + minTr = std::min(minTr, bytes / per); + } + return minTr; +} + +} // namespace + +// ---- PCA 主轴角(可测纯函数)---- +double principalAxisAngle(const std::vector& xs, + const std::vector& ys) { + const std::size_t n = std::min(xs.size(), ys.size()); + if (n < 2) return 0.0; + + double mx = 0, my = 0; + for (std::size_t i = 0; i < n; ++i) { + mx += xs[i]; + my += ys[i]; + } + mx /= static_cast(n); + my /= static_cast(n); + + double sxx = 0, syy = 0, sxy = 0; + for (std::size_t i = 0; i < n; ++i) { + const double dx = xs[i] - mx, dy = ys[i] - my; + sxx += dx * dx; + syy += dy * dy; + sxy += dx * dy; + } + sxx /= static_cast(n); + syy /= static_cast(n); + sxy /= static_cast(n); + + // 2×2 对称协方差矩阵最大特征向量方向 = 0.5*atan2(2*sxy, sxx-syy)。 + if (std::abs(sxy) < 1e-15 && std::abs(sxx - syy) < 1e-15) return 0.0; + return 0.5 * std::atan2(2.0 * sxy, sxx - syy); +} + +// ---- 距离权(可测纯函数)---- +double distanceWeight(double distCells) { + return 1.0 / (1.0 + distCells * distCells); +} + +GeoBuildResult buildGeoVolume(const std::vector& lines, + const GeoGridSpec& spec, + const std::string& outStoreDir, + int pyramidLevels, bool curvilinear) { + using geopro::io::gpr::interpAlongTrack; + using geopro::io::gpr::lonLatToLocalM; + using geopro::io::gpr::PosHeading; + using geopro::io::gpr::projectToCenterline; + using geopro::io::gpr::resampleAndSmooth; + using geopro::io::gpr::XY; + + if (lines.empty()) throw std::runtime_error("buildGeoVolume: 无测线"); + if (spec.cellXY <= 0 || spec.cellZ <= 0) + throw std::runtime_error("buildGeoVolume: cell 必须 > 0"); + + // ---- 1) 各线 .gps → 局部米(共用原点 = 全体最小 lat/lon)---- + std::vector tracks(lines.size()); + double minLat = std::numeric_limits::infinity(); + double minLon = std::numeric_limits::infinity(); + for (std::size_t i = 0; i < lines.size(); ++i) { + tracks[i] = geopro::io::gpr::parseGps(lines[i].gps); + for (const auto& p : tracks[i].pts) { + minLat = std::min(minLat, p.lat); + minLon = std::min(minLon, p.lon); + } + } + if (!std::isfinite(minLat) || !std::isfinite(minLon)) + throw std::runtime_error("buildGeoVolume: 轨迹为空"); + + // 各线标尺:轨迹局部米 + 通道横偏 + 头 + 总道数。 + std::vector scales(lines.size()); + int samples = 0; + geopro::io::gpr::IprHeader hdr0{}; + for (std::size_t i = 0; i < lines.size(); ++i) { + LineScale& sc = scales[i]; + sc.trackM.reserve(tracks[i].pts.size()); + for (const auto& p : tracks[i].pts) + sc.trackM.push_back(lonLatToLocalM(p.lat, p.lon, minLat, minLon)); + + std::ifstream ordF(lines[i].ord); + if (!ordF) throw std::runtime_error("buildGeoVolume: 打不开 ord " + lines[i].ord); + std::string ordText((std::istreambuf_iterator(ordF)), + std::istreambuf_iterator()); + sc.chanX = geopro::io::gpr::parseChannelXOffsets(ordText); + + if (lines[i].iprb.empty()) + throw std::runtime_error("buildGeoVolume: 线无 iprb"); + sc.header = readHeaderFor(lines[i].iprb.front()); + sc.totalTraces = totalTracesOf(lines[i].iprb, sc.header.samples); + if (i == 0) { + samples = sc.header.samples; + hdr0 = sc.header; + } + } + if (samples <= 0) throw std::runtime_error("buildGeoVolume: samples<=0"); + + // ---- 2) 平面映射:PCA 旋转(G1) 或 中心线曲线坐标(G2)---- + // 两路均产出一个把通道平面点 (cx,cy)[局部米] → 网格 (gi,gj) + 距 cell 中心 + // 距离的映射;G1 旋转、G2 投影中心线。下方包围盒/累加复用同一映射。 + double rotRad = 0.0; // G1 路向主轴角;G2 恒 0(已沿中心线展开) + double cosR = 1.0, sinR = 0.0; + std::vector centerline; // G2:均匀重采样+平滑后的中心线 + double halfWidthM = 0.0; // G2:横向半幅(cell 中心偏移 = d + halfWidth) + + if (curvilinear) { + // 取沿路里程最长那条线的轨迹作中心线参考。 + std::size_t bestLine = 0; + double bestLen = -1.0; + for (std::size_t i = 0; i < scales.size(); ++i) { + const auto& tr = scales[i].trackM; + if (tr.size() < 2) continue; + double len = 0.0; + for (std::size_t k = 1; k < tr.size(); ++k) + len += std::hypot(tr[k].x - tr[k - 1].x, tr[k].y - tr[k - 1].y); + if (len > bestLen) { + bestLen = len; + bestLine = i; + } + } + if (bestLen <= 0.0) + throw std::runtime_error("buildGeoVolume: 曲线版无有效中心线"); + // 重采样间距取 cellXY(细分够密),轻平滑窗口 2 点。 + centerline = resampleAndSmooth(scales[bestLine].trackM, spec.cellXY, 2); + if (centerline.size() < 2) + throw std::runtime_error("buildGeoVolume: 中心线重采样退化"); + } else { + // 收集全体通道平面点(trace 位置)求 PCA 主轴。 + std::vector px, py; + for (std::size_t i = 0; i < scales.size(); ++i) { + const LineScale& sc = scales[i]; + const std::int64_t nt = sc.totalTraces; + if (nt < 1 || sc.trackM.size() < 2) continue; + const std::int64_t stride = std::max(1, nt / 200); + for (std::int64_t t = 0; t < nt; t += stride) { + const double frac = nt > 1 ? static_cast(t) / (nt - 1) : 0.0; + PosHeading ph = interpAlongTrack(sc.trackM, frac); + px.push_back(ph.pos.x); + py.push_back(ph.pos.y); + } + } + rotRad = principalAxisAngle(px, py); + cosR = std::cos(-rotRad); + sinR = std::sin(-rotRad); + } + + // 平面映射:(cx,cy) → 路向坐标 (mx,my)。G1=旋转;G2=中心线 (s,d)。 + auto planeMap = [&](double cx, double cy, double& mx, double& my) { + if (curvilinear) { + auto pr = projectToCenterline(centerline, XY{cx, cy}); + mx = pr.s; + my = pr.d; + } else { + mx = cx * cosR - cy * sinR; + my = cx * sinR + cy * cosR; + } + }; + + // ---- 求包围盒(含通道横偏)→ 网格维度 + 原点 ---- + // G2 曲线版:横向半幅 halfWidth 用 |d| 的分位(98%),对短截/斜插桩线的离群 + // d 鲁棒——否则少数离群点把 ny 撑到虚高、整片 d 区间空着拉低填充率。 + double minX = std::numeric_limits::infinity(); + double minY = std::numeric_limits::infinity(); + double maxX = -std::numeric_limits::infinity(); + double maxY = -std::numeric_limits::infinity(); + std::vector dVals; // G2:收集带符号 d 求分位(横向范围可不对称) + for (std::size_t i = 0; i < scales.size(); ++i) { + const LineScale& sc = scales[i]; + const std::int64_t nt = sc.totalTraces; + if (nt < 1 || sc.trackM.size() < 2 || sc.chanX.empty()) continue; + const std::int64_t stride = std::max(1, nt / 200); + for (std::int64_t t = 0; t < nt; t += stride) { + const double frac = nt > 1 ? static_cast(t) / (nt - 1) : 0.0; + PosHeading ph = interpAlongTrack(sc.trackM, frac); + const double perpX = -ph.hy, perpY = ph.hx; // 垂直航向(左法向) + for (double oc : {sc.chanX.front(), sc.chanX.back()}) { + const double cx = ph.pos.x + oc * perpX; + const double cy = ph.pos.y + oc * perpY; + double mx, my; + planeMap(cx, cy, mx, my); + minX = std::min(minX, mx); + maxX = std::max(maxX, mx); + minY = std::min(minY, my); + maxY = std::max(maxY, my); + if (curvilinear) dVals.push_back(my); + } + } + } + if (!std::isfinite(minX)) throw std::runtime_error("buildGeoVolume: 无有效几何"); + + // 深度范围:用首线头取最深样本深度。 + const double maxDepth = geopro::io::gpr::depthOfSample(samples - 1, hdr0); + + auto cells = [](double range, double cell) { + if (cell <= 0.0 || range <= 0.0) return 1; + return static_cast(std::ceil(range / cell)) + 1; + }; + + // G2:中心线为某条边线时,d 多为单侧分布(非 ±对称);用带符号 d 的 + // [1%,99%] 分位作横向范围(dLoM..dHiM),鲁棒于离群桩线、且不浪费空白半侧。 + double dLoM = 0.0, dHiM = 0.0; + int nx = 0, ny = 0; + if (curvilinear) { + if (!dVals.empty()) { + std::sort(dVals.begin(), dVals.end()); + const double maxIdx = static_cast(dVals.size() - 1); + const std::size_t loI = + static_cast(std::max(0.0, 0.01 * maxIdx)); + const std::size_t hiI = + static_cast(std::min(maxIdx, 0.99 * maxIdx)); + dLoM = dVals[loI]; + dHiM = dVals[hiI]; + } + if (dHiM - dLoM <= 0.0) { // 退化兜底 + dLoM = -spec.cellXY; + dHiM = spec.cellXY; + } + halfWidthM = -dLoM; // gy = (d - dLoM)/cellXY(横向原点对齐 dLoM) + minX = 0.0; // s 从 0 起 + nx = cells(maxX, spec.cellXY); + ny = cells(dHiM - dLoM, spec.cellXY); + } else { + nx = cells(maxX - minX, spec.cellXY); // 沿路(长轴 X') + ny = cells(maxY - minY, spec.cellXY); // 横路 Y' + } + const int nz = cells(maxDepth, spec.cellZ); // 深度 Z + + const std::size_t cellCount = + static_cast(nx) * ny * nz; + + // ---- 3) 逐线逐道逐通道逐样本 → grid 加权累加 ---- + // sum = Σ(w·v),wsum = Σw;w=1(G1 等权)或距 cell 中心的距离权(G2)。 + std::vector sum(cellCount, 0.0); + std::vector wsum(cellCount, 0.0); + + auto cellIdx = [&](int gi, int gj, int gk) -> std::size_t { + return (static_cast(gk) * ny + gj) * nx + gi; + }; + + constexpr std::int64_t kChunk = 256; // 逐线分段读道,内存有界 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + + for (std::size_t i = 0; i < scales.size(); ++i) { + const LineScale& sc = scales[i]; + const std::int64_t nt = sc.totalTraces; + if (nt < 1 || sc.trackM.size() < 2 || sc.chanX.empty()) continue; + const int nChan = static_cast(sc.chanX.size()); + + // 各通道 .iprb 区间读(与 chanX 文件顺序对齐:iprb[c] ↔ chanX[c])。 + const auto& iprbPaths = lines[i].iprb; + const int useChan = std::min(nChan, static_cast(iprbPaths.size())); + + for (std::int64_t t0 = 0; t0 < nt; t0 += kChunk) { + const std::int64_t t1 = std::min(nt, t0 + kChunk); + // 逐通道读该道段。 + std::vector bs(useChan); + for (int c = 0; c < useChan; ++c) + bs[c] = geopro::io::gpr::readIprbRange(iprbPaths[c], sc.header, t0, t1); + + for (std::int64_t t = t0; t < t1; ++t) { + const double frac = nt > 1 ? static_cast(t) / (nt - 1) : 0.0; + PosHeading ph = interpAlongTrack(sc.trackM, frac); + const double perpX = -ph.hy, perpY = ph.hx; + const std::int64_t lt = t - t0; // BScan 内局部道索引 + + for (int c = 0; c < useChan; ++c) { + const double oc = sc.chanX[c]; + const double cx = ph.pos.x + oc * perpX; + const double cy = ph.pos.y + oc * perpY; + double mx, my; + planeMap(cx, cy, mx, my); + // G1:mx/my 为旋转后绝对坐标;G2:mx=s、my=d(横向原点对齐 dLoM, + // 即 halfWidthM=-dLoM,gyf=(d-dLoM)/cellXY)。 + const double gxf = (mx - minX) / spec.cellXY; + const double gyf = + curvilinear ? (my + halfWidthM) / spec.cellXY + : (my - minY) / spec.cellXY; + const int gi = static_cast(std::lround(gxf)); + const int gj = static_cast(std::lround(gyf)); + if (gi < 0 || gi >= nx || gj < 0 || gj >= ny) continue; + + // 距离权:到 cell 中心的横/纵平面距离(cell 单位)。G1 等权 w=1。 + double w = 1.0; + if (curvilinear) { + const double dxc = gxf - gi; // 已是 cell 单位 + const double dyc = gyf - gj; + w = distanceWeight(std::sqrt(dxc * dxc + dyc * dyc)); + } + + for (int s = 0; s < samples; ++s) { + const double depth = geopro::io::gpr::depthOfSample(s, sc.header); + const int gk = static_cast(std::lround(depth / spec.cellZ)); + if (gk < 0 || gk >= nz) continue; + const double v = static_cast( + bs[c].data[static_cast(lt) * samples + s]); + const std::size_t idx = cellIdx(gi, gj, gk); + sum[idx] += w * v; + wsum[idx] += w; + } + } + } + } + } + + // ---- 4) 加权均值 + 求值域 ---- + std::int64_t filled = 0; + for (std::size_t idx = 0; idx < cellCount; ++idx) { + if (wsum[idx] <= 0.0) continue; + const double mean = sum[idx] / wsum[idx]; + sum[idx] = mean; // 复用 sum 存均值 + vmin = std::min(vmin, mean); + vmax = std::max(vmax, mean); + ++filled; + } + if (!(vmin <= vmax)) { + vmin = 0.0; + vmax = 0.0; + } + + // ---- 5) 量化 + 写 store + 金字塔 ---- + Quant quant; + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); + + ScalarVolumeI16 vol(nx, ny, nz); + for (int gk = 0; gk < nz; ++gk) + for (int gj = 0; gj < ny; ++gj) + for (int gi = 0; gi < nx; ++gi) { + const std::size_t idx = cellIdx(gi, gj, gk); + vol.at(gi, gj, gk) = (wsum[idx] <= 0.0) + ? ScalarVolumeI16::kBlank + : quant.toQ(sum[idx]); + } + + BuiltI16 built; + built.vol = std::move(vol); + built.quant = quant; + // G2 横向原点对齐 dLoM(曲线坐标系:X=沿路里程 s、Y=横向偏移 d)。 + built.origin = {minX, curvilinear ? dLoM : minY, 0.0}; + built.spacing = {spec.cellXY, spec.cellXY, spec.cellZ}; + built.vminPhys = vmin; + built.vmaxPhys = vmax; + + fs::create_directories(outStoreDir); + geopro::data::ChunkedVolumeStore::write(outStoreDir, built); + if (pyramidLevels > 0) { + geopro::data::ChunkedVolumeStore store(outStoreDir); + store.buildPyramid(pyramidLevels); + } + + GeoBuildResult r; + r.nx = nx; + r.ny = ny; + r.nz = nz; + r.oxM = minX; + r.oyM = curvilinear ? dLoM : minY; + r.rotRad = rotRad; + r.filled = filled; + r.total = static_cast(cellCount); + return r; +} + +} // namespace geopro::core diff --git a/src/core/algo/GeoVolumeBuilder.hpp b/src/core/algo/GeoVolumeBuilder.hpp new file mode 100644 index 0000000..ed89145 --- /dev/null +++ b/src/core/algo/GeoVolumeBuilder.hpp @@ -0,0 +1,68 @@ +#ifndef GEOPRO_CORE_ALGO_GEOVOLUMEBUILDER_HPP +#define GEOPRO_CORE_ALGO_GEOVOLUMEBUILDER_HPP + +#include +#include +#include + +namespace geopro::core { + +// 统一路向网格规格:横向(X'沿路 / Y'横路)cell = cellXY,深度 cell = cellZ。 +struct GeoGridSpec { + double cellXY = 0.5; // 米 + double cellZ = 0.1; // 米 +}; + +// 一组二维路向点的主成分朝向(协方差最大特征向量),用于把路向对齐到网格长轴 X'。 +// 返回主轴方向角 rotRad(弧度,相对东向 +X),调用方旋转 -rotRad 使路向 → X'。 +// 点少于 2 / 退化(零方差)→ 返回 0。 +double principalAxisAngle(const std::vector& xs, + const std::vector& ys); + +// 曲线版重叠累加的距离权(distCells = 样本到 cell 中心的平面距离,单位 cell)。 +// w = 1/(1+distCells^2):距中心越近权越大 → 加权均值偏向近点,减少 RTK +// 微错位的等权稀释糊化。distCells=0 → w=1(最大);单调递减。 +double distanceWeight(double distCells); + +// 真实数据建体的产物指标(维度/原点/旋转角/填充率)。 +struct GeoBuildResult { + int nx = 0, ny = 0, nz = 0; + double oxM = 0, oyM = 0; // 旋转后网格原点(路向坐标系,米) + double rotRad = 0; // 路向主轴角(弧度,East 起算) + std::int64_t filled = 0; // 非空 cell 数 + std::int64_t total = 0; // 总 cell 数 +}; + +// 一条线的输入文件束(各通道 .iprb 已按通道号排序 + 该线 .ord + .gps)。 +// .iprh 与每个 .iprb 同目录同名(扩展名替换为 .iprh),由读取层自行推导。 +struct GeoLineInput { + std::vector iprb; // 该线 14 通道 .iprb(按通道号升序) + std::string ord; // 该线 .ord(通道横偏) + std::string gps; // 该线 .gps(RTK 轨迹) +}; + +// 把多条测线按各自真实 RTK 几何插值进一个统一路向网格,重叠 cell 取均值, +// 量化为 int16 写 ChunkedVolumeStore + 金字塔。 +// +// 步骤: +// 1) 各线 .gps → 经纬,全体共用原点(最小 lat/lon) → 局部米; +// 2) PCA 求路向主轴角 rotRad,旋转使路向 = X'、横路 = Y';Z = 深度; +// 3) 每线逐道(里程均匀分布插值定位)逐通道(横偏垂直航向摆放)逐样本 → 网格 cell, +// 累加 sum + count; +// 4) 空 cell = blank;非空 = sum/count 均值; +// 5) 扫值域定 Quant(offset=中点),量化写 store + buildPyramid。 +// +// 内存有界:逐线流式读道(readIprbRange 区间),只持 grid 的 sum/count 累加缓冲。 +// +// curvilinear=false(默认,G1):全局单一 PCA 旋转 → 地理盒网格,重叠等权均值。 +// curvilinear=true(G2):取沿路里程最长那条线的 GPS 轨迹作中心线(重采样+轻平滑), +// 把每点投影得 (沿路里程 s, 带符号横偏 d) → 网格 X=s/Y=d/Z=深度,把弯路"拉直"; +// 重叠按到 cell 中心的距离加权累加(w=1/(1+(dist/cellXY)^2)),偏向近点。 +GeoBuildResult buildGeoVolume(const std::vector& lines, + const GeoGridSpec& spec, + const std::string& outStoreDir, int pyramidLevels, + bool curvilinear = false); + +} // namespace geopro::core + +#endif // GEOPRO_CORE_ALGO_GEOVOLUMEBUILDER_HPP diff --git a/src/core/algo/GprVolumeBuilder.cpp b/src/core/algo/GprVolumeBuilder.cpp new file mode 100644 index 0000000..c4c1177 --- /dev/null +++ b/src/core/algo/GprVolumeBuilder.cpp @@ -0,0 +1,115 @@ +#include "algo/GprVolumeBuilder.hpp" + +#include +#include + +namespace geopro::core { + +namespace { + +// 把世界坐标轴值映射到最近整数索引;落在 [0, n-1] 外返回 -1(标记越界)。 +int nearestIndex(double world, double origin, double step, int n) { + if (n <= 0 || step == 0.0) return -1; + long idx = std::lround((world - origin) / step); + if (idx < 0 || idx >= n) return -1; + return static_cast(idx); +} + +} // namespace + +std::int16_t sampleGprPoint(const GprSurvey& s, const GridSpec& spec, int gi, + int gj, int gk, const Quant& quant) { + const int nChan = static_cast(s.channelY.size()); + + const double worldZ = spec.oz + gk * spec.dz; + const int sIdx = nearestIndex(worldZ, s.z0, s.dz, s.samples); + + const double worldX = spec.ox + gi * spec.dx; + const int tIdx = nearestIndex(worldX, s.x0, s.dx, s.ntraces); + + // X / Z 越界 → blank。 + if (tIdx < 0 || sIdx < 0 || nChan == 0) { + return ScalarVolumeI16::kBlank; + } + + const double worldY = spec.oy + gj * spec.dy; + + // Y → 跨通道 1D 线性插值(channelY 升序)。 + double phys = 0.0; + bool blank = false; + if (worldY <= s.channelY.front()) { + // 边界外不外推:在 maxDist 内 clamp 到首通道,否则 blank。 + if (s.channelY.front() - worldY > spec.maxDist) { + blank = true; + } else { + phys = s.at(0, tIdx, sIdx); + } + } else if (worldY >= s.channelY.back()) { + if (worldY - s.channelY.back() > spec.maxDist) { + blank = true; + } else { + phys = s.at(nChan - 1, tIdx, sIdx); + } + } else { + // 定位 worldY 落在哪两相邻通道间,线性插值。 + int lo = 0; + while (lo + 1 < nChan && s.channelY[lo + 1] < worldY) ++lo; + const int hi = lo + 1; + const double yLo = s.channelY[lo]; + const double yHi = s.channelY[hi]; + const double span = yHi - yLo; + const double w = (span > 0.0) ? (worldY - yLo) / span : 0.0; + const double vLo = s.at(lo, tIdx, sIdx); + const double vHi = s.at(hi, tIdx, sIdx); + phys = vLo + (vHi - vLo) * w; + } + + if (blank || std::isnan(phys)) { + return ScalarVolumeI16::kBlank; + } + return quant.toQ(phys); +} + +BuiltI16 buildGprVolume(const GprSurvey& s, const GridSpec& spec) { + BuiltI16 out; + out.origin = {spec.ox, spec.oy, spec.oz}; + out.spacing = {spec.dx, spec.dy, spec.dz}; + + // 1. 物理值域(扫 values,跳过 NaN)。 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + for (double v : s.values) { + if (std::isnan(v)) continue; + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + if (!(vmin <= vmax)) { // 无有效值:退化为 [0,0]。 + vmin = 0.0; + vmax = 0.0; + } + out.vminPhys = vmin; + out.vmaxPhys = vmax; + + // 2. 量化:scale=(vmax-vmin)/64000 把整个物理值域铺满 int16 的 ~64000 个码位 + // (-32000..+32000),故 offset 取值域中点使量化对称——既用满 64000 码位, + // 又两端各留 ~700 余量不撞 int16 边界(±32767)与 kBlank(INT16_MIN)。 + // vmax==vmin 时 scale=1。 + out.quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + out.quant.offset = 0.5 * (vmin + vmax); + + // 3. 分配体(构造即填 0),origin/spacing 已设。 + out.vol = ScalarVolumeI16(spec.nx, spec.ny, spec.nz); + + // 4. 逐网格点落值(复用共享采样核 sampleGprPoint,与流式版零漂移)。 + for (int gk = 0; gk < spec.nz; ++gk) { + for (int gj = 0; gj < spec.ny; ++gj) { + for (int gi = 0; gi < spec.nx; ++gi) { + out.vol.at(gi, gj, gk) = sampleGprPoint(s, spec, gi, gj, gk, out.quant); + } + } + } + + return out; +} + +} // namespace geopro::core diff --git a/src/core/algo/GprVolumeBuilder.hpp b/src/core/algo/GprVolumeBuilder.hpp new file mode 100644 index 0000000..1a3bbfc --- /dev/null +++ b/src/core/algo/GprVolumeBuilder.hpp @@ -0,0 +1,39 @@ +#ifndef GEOPRO_CORE_ALGO_GPRVOLUMEBUILDER_HPP +#define GEOPRO_CORE_ALGO_GPRVOLUMEBUILDER_HPP + +#include + +#include "algo/IInterpolator.hpp" // GridSpec +#include "model/GprSurvey.hpp" +#include "model/ScalarVolumeI16.hpp" // ScalarVolumeI16, Quant + +namespace geopro::core { + +// 结构化建体产物:int16 量化体 + 量化映射 + 几何(origin/spacing)+ 物理值域。 +struct BuiltI16 { + ScalarVolumeI16 vol{0, 0, 0}; + Quant quant; + std::array origin{{0, 0, 0}}; + std::array spacing{{0, 0, 0}}; + double vminPhys = 0, vmaxPhys = 0; +}; + +// 单网格点采样核(X/Z 落格 + 仅 Y 向跨通道 1D 线性插值),供整卷 buildGprVolume +// 与流式 buildGprVolumeStreaming 共用,确保两者逐体素一致(真 DRY,零漂移)。 +// +// (gi,gj,gk) 为网格索引;spec 提供世界坐标轴与 maxDist;s 提供道/采样标尺、通道 Y、值。 +// quant 为全局量化映射(两版必须用同一 scale/offset)。 +// 返回该点量化后的 int16;越界/超 maxDist/NaN → ScalarVolumeI16::kBlank(不外推)。 +// +// 关键性质:每点只依赖自身 (gi,gj,gk) 经由世界坐标落到的最近道/采样与跨通道插值, +// 不依赖相邻 X,故按 X 分 slab 逐块算与整卷算逐体素相同(流式精确对拍的基础)。 +std::int16_t sampleGprPoint(const GprSurvey& s, const GridSpec& spec, int gi, + int gj, int gk, const Quant& quant); + +// 结构化建体:X/Z 直接落格(取最近道/采样)+ 仅 Y 向跨通道 1D 线性插值。 +// 超 X/Z 范围或 Y 越界且超 maxDist 的网格点 → ScalarVolumeI16::kBlank(不外推)。 +BuiltI16 buildGprVolume(const GprSurvey& s, const GridSpec& spec); + +} // namespace geopro::core + +#endif // GEOPRO_CORE_ALGO_GPRVOLUMEBUILDER_HPP diff --git a/src/core/algo/VolumeBuilder.cpp b/src/core/algo/VolumeBuilder.cpp new file mode 100644 index 0000000..a75f4e8 --- /dev/null +++ b/src/core/algo/VolumeBuilder.cpp @@ -0,0 +1,227 @@ +#include "algo/VolumeBuilder.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::core { + +namespace { +// 某轴:优先用 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(ext / cell) + 1; + if (n <= kMaxVolumeDim) { + outCell = cell; + outN = (n < 1) ? 1 : n; + return; + } + outN = kMaxVolumeDim; + outCell = ext / static_cast(kMaxVolumeDim - 1); // maxDim 格覆盖全 ext +} + +// 平面凸包(Andrew monotone chain,返回 CCW 顶点;末点=首点已去重)。点 <3 / 共线退化 → 空。 +struct Hull2D { std::vector x, y; }; + +// (A-O) × (B-O):>0 表示 B 在有向边 O→A 左侧。 +double cross2(double ox, double oy, double ax, double ay, double bx, double by) { + return (ax - ox) * (by - oy) - (ay - oy) * (bx - ox); +} + +Hull2D convexHull2D(const std::vector& xs, const std::vector& ys) { + Hull2D hull; + const std::size_t n = xs.size(); + if (n < 3) return hull; + std::vector idx(n); + for (std::size_t i = 0; i < n; ++i) idx[i] = i; + std::sort(idx.begin(), idx.end(), [&](std::size_t a, std::size_t b) { + return xs[a] < xs[b] || (xs[a] == xs[b] && ys[a] < ys[b]); + }); + std::vector h(2 * n); + int k = 0; + for (std::size_t ii = 0; ii < n; ++ii) { // 下凸包 + const std::size_t i = idx[ii]; + while (k >= 2 && + cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0) + --k; + h[k++] = i; + } + const int lower = k + 1; + for (std::size_t ii = n; ii-- > 0;) { // 上凸包 + const std::size_t i = idx[ii]; + while (k >= lower && + cross2(xs[h[k - 2]], ys[h[k - 2]], xs[h[k - 1]], ys[h[k - 1]], xs[i], ys[i]) <= 0) + --k; + h[k++] = i; + } + if (k - 1 < 3) return hull; // 去末点后仍 <3 → 退化 + hull.x.reserve(k - 1); hull.y.reserve(k - 1); + for (int t = 0; t < k - 1; ++t) { hull.x.push_back(xs[h[t]]); hull.y.push_back(ys[h[t]]); } + return hull; +} + +// 点是否在 CCW 凸多边形内(含边界),buf=向外缓冲(米,保边界整列不被误裁)。 +bool inHull(const Hull2D& hull, double px, double py, double buf) { + const std::size_t m = hull.x.size(); + for (std::size_t i = 0; i < m; ++i) { + const std::size_t j = (i + 1) % m; + const double ex = hull.x[j] - hull.x[i], ey = hull.y[j] - hull.y[i]; + const double len = std::sqrt(ex * ex + ey * ey); + const double c = cross2(hull.x[i], hull.y[i], hull.x[j], hull.y[j], px, py); + // c/len = 点到该边的有符号垂距(内侧为正);< -buf 即在多边形外超过缓冲 → 排除。 + if (len > 0.0 && c < -buf * len) return false; + } + return true; +} +} // namespace + +BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, + double power, double maxDist, bool clipToFootprint) { + if (pts.v.empty()) { + throw std::invalid_argument("buildVolume: empty point set"); + } + + // 1) 点集包络。 + double minx = pts.x[0], maxx = pts.x[0]; + double miny = pts.y[0], maxy = pts.y[0]; + double minz = pts.z[0], maxz = pts.z[0]; + for (std::size_t i = 1; i < pts.v.size(); ++i) { + minx = std::min(minx, pts.x[i]); maxx = std::max(maxx, pts.x[i]); + miny = std::min(miny, pts.y[i]); maxy = std::max(maxy, pts.y[i]); + minz = std::min(minz, pts.z[i]); maxz = std::max(maxz, pts.z[i]); + } + + // 2) GridSpec(角点对齐 = 原点取包络最小角)。间距优先用 cell;包络过大时放大间距以覆盖全程 + // (fitAxis),避免跨 TM 多剖面相距过远时远端被截断。 + GridSpec spec{}; + spec.ox = minx; spec.oy = miny; spec.oz = minz; + 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; + + // 节点封顶:真实数据(大测区/小 cell)易逼近 kMaxVolumeDim³ → IDW 卡死。超 kMaxNodes 等比放大三轴 cell。 + { + constexpr long long kMaxNodes = 4'000'000; + const long long tot = 1LL * spec.nx * spec.ny * spec.nz; + if (tot > kMaxNodes) { + const double s = std::cbrt(static_cast(tot) / static_cast(kMaxNodes)); + spec.dx *= s; spec.dy *= s; spec.dz *= s; + auto rc = [](double ext, double cell) { int n = static_cast(ext / cell) + 1; return n < 1 ? 1 : n; }; + spec.nx = rc(maxx - minx, spec.dx); + spec.ny = rc(maxy - miny, spec.dy); + spec.nz = rc(maxz - minz, spec.dz); + } + } + + // 退化维补一层:若某横/竖向范围 < 一个 cell(如共面/共线剖面 → 该向仅 1 层),vtkGPUVolumeRayCastMapper + // 无法对「1 层厚的体」(本质 2D)体绘制 → 报 vtkExecutive 错误、什么都渲染不出来。补到 2 层 → 成薄板可渲。 + spec.nx = std::max(spec.nx, 2); + spec.ny = std::max(spec.ny, 2); + spec.nz = std::max(spec.nz, 2); + + // 各向异性搜索半径(实测:对角线全域 IDW 对真实井字数据=每节点求和全部点→卡死;井字线间最大空隙 + // 仅 ~20m,故水平半径 auto=0.2×XY 对角线[限 12~60m] 足以跨格填满而非全域;垂直限带→剖面深向密 + // 采、带内必有点,既不混深度又把候选点经「按 z 排序+二分定带」剪到深度邻域,避免卡死)。 + const double exX = maxx - minx, exY = maxy - miny; + const double xydiag = std::sqrt(exX * exX + exY * exY); + // 水平半径 auto = XY 对角线 → 填满整个凸包足迹(对齐 Surfer Blanking 后的实心体)。实测因抽稀 + // + z-带垂直剪枝,大半径与小半径耗时几乎一致(~2.8s/真实赣州 4 线),故取满填。 + const double maxDistH = (maxDist > 0.0) ? maxDist : (xydiag > 0.0 ? xydiag : 1.0); + const double maxDistV = std::max(6.0 * spec.dz, 2.0); + spec.maxDist = maxDistH; // 记录(属性页/诊断) + + // 点抽稀到网格分辨率:剖面 ~0.4m 采样远密于网格 → 按 (dx,dy,dz) 体素聚合(质心+均值), + // 大幅减候选点、不损可视化分辨率。 + struct ThinAcc { double sx = 0, sy = 0, sz = 0, sv = 0; int c = 0; }; + std::unordered_map tmap; + tmap.reserve(pts.v.size()); + auto keyOf = [&](double x, double y, double z) -> long long { + const long long ix = static_cast(std::floor((x - minx) / spec.dx)); + const long long iy = static_cast(std::floor((y - miny) / spec.dy)); + const long long iz = static_cast(std::floor((z - minz) / spec.dz)); + return (ix * 73856093LL) ^ (iy * 19349663LL) ^ (iz * 83492791LL); + }; + for (std::size_t i = 0; i < pts.v.size(); ++i) { + ThinAcc& a = tmap[keyOf(pts.x[i], pts.y[i], pts.z[i])]; + a.sx += pts.x[i]; a.sy += pts.y[i]; a.sz += pts.z[i]; a.sv += pts.v[i]; ++a.c; + } + std::vector tx, ty, tz, tv; + tx.reserve(tmap.size()); ty.reserve(tmap.size()); tz.reserve(tmap.size()); tv.reserve(tmap.size()); + for (const auto& kv : tmap) { + const ThinAcc& a = kv.second; + tx.push_back(a.sx / a.c); ty.push_back(a.sy / a.c); + tz.push_back(a.sz / a.c); tv.push_back(a.sv / a.c); + } + const std::size_t nt = tv.size(); + + // 抽稀点按 z 升序 → 每深度切片二分定 [gz-V, gz+V] 带,仅遍历带内点。 + std::vector order(nt); + std::iota(order.begin(), order.end(), std::size_t{0}); + std::sort(order.begin(), order.end(), [&](std::size_t a, std::size_t b) { return tz[a] < tz[b]; }); + std::vector zs(nt); + for (std::size_t t = 0; t < nt; ++t) zs[t] = tz[order[t]]; + + // 足迹凸包(用原始点;退化 <3/共线 → 空 → 跳过裁剪)。 + const Hull2D hull = clipToFootprint ? convexHull2D(pts.x, pts.y) : Hull2D{}; + const bool useClip = hull.x.size() >= 3; + const double buf = 0.5 * std::max(spec.dx, spec.dy); + + // 3) 各向异性 z-带 IDW(凸包外/带内无点 → NaN 留空)。 + ScalarVolume vol(spec.nx, spec.ny, spec.nz); + const double nan = std::numeric_limits::quiet_NaN(); + const double maxH2 = maxDistH * maxDistH; + const bool fastPow2 = (power == 2.0); + const double halfPow = power * 0.5; + for (int k = 0; k < spec.nz; ++k) { + const double gz = spec.oz + k * spec.dz; + const std::size_t lo = static_cast( + std::lower_bound(zs.begin(), zs.end(), gz - maxDistV) - zs.begin()); + const std::size_t hi = static_cast( + std::upper_bound(zs.begin(), zs.end(), gz + maxDistV) - zs.begin()); + for (int j = 0; j < spec.ny; ++j) { + const double gy = spec.oy + j * spec.dy; + for (int i = 0; i < spec.nx; ++i) { + const double gx = spec.ox + i * spec.dx; + if (useClip && !inHull(hull, gx, gy, buf)) { vol.at(i, j, k) = nan; continue; } + double wsum = 0.0, vsum = 0.0; + bool any = false, hit = false; double hitVal = 0.0; + for (std::size_t t = lo; t < hi; ++t) { + const std::size_t p = order[t]; + const double ddx = gx - tx[p], ddy = gy - ty[p]; + const double h2 = ddx * ddx + ddy * ddy; + if (h2 > maxH2) continue; // 超水平半径 + const double ddz = gz - tz[p]; + const double d2 = h2 + ddz * ddz; + any = true; + if (d2 < 1e-12) { hit = true; hitVal = tv[p]; break; } + const double w = fastPow2 ? (1.0 / d2) : std::pow(d2, -halfPow); + wsum += w; vsum += w * tv[p]; + } + if (hit) vol.at(i, j, k) = hitVal; + else if (!any || wsum == 0.0) vol.at(i, j, k) = nan; + else vol.at(i, j, k) = vsum / wsum; + } + } + } + + // 4) 数据实测值域(仅有限值)。无有限值 → 退化 {0,1}。 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + for (double v : vol.data()) { + if (std::isnan(v)) continue; + vmin = std::min(vmin, v); vmax = std::max(vmax, v); + } + if (!(vmin < vmax)) { vmin = 0.0; vmax = 1.0; } + + return BuiltVolume{std::move(vol), spec, vmin, vmax}; +} + +} // namespace geopro::core diff --git a/src/core/algo/VolumeBuilder.hpp b/src/core/algo/VolumeBuilder.hpp new file mode 100644 index 0000000..638e465 --- /dev/null +++ b/src/core/algo/VolumeBuilder.hpp @@ -0,0 +1,34 @@ +#pragma once +#include "algo/IInterpolator.hpp" +#include "model/Field.hpp" + +namespace geopro::core { + +// 网格维度上限(与原 LocalSample3dRepository kMaxDim 同口径,防超大体素爆内存)。 +constexpr int kMaxVolumeDim = 400; + +// buildVolume 产物:插值体 + 网格规格 + 数据实测值域。 +// ScalarVolume 无默认构造 ⇒ BuiltVolume 亦无默认构造,须聚合初始化全部成员。 +struct BuiltVolume { + ScalarVolume vol; + GridSpec spec; + double vmin, vmax; // 数据实测有限值范围(无有限值时退化为 {0,1}) +}; + +// 散点(世界局部米)→ 包络盒角点对齐 GridSpec(维度按 ext/cell 限幅到 [1, kMaxVolumeDim]) +// → IDW → ScalarVolume(maxDist 外 NaN 留空)→ 数据实测 vmin/vmax。 +// 前置:pts 须含 ≥1 点(空集抛 std::invalid_argument)。 +// 确定性:同点集同参数 → 同 GridSpec 同体(不冻结 spec,见计划 §1 决策)。 +// 提取自 LocalSample3dRepository::loadVolume,供本地样本 / 真实 Api 共享,消除调参漂移。 +// +// maxDist 语义(对齐客户 Surfer:XYZC + IDW + 边界 Blanking,见 +// docs/superpowers/specs/2026-06-27-inversion-3d-volume-surfer-method-and-gaps.md): +// - maxDist > 0:局部 IDW 半径(超距 blank),偏快、剖面附近清晰,但跨大空隙可能填不满。 +// - maxDist <= 0:自动「覆盖测区」——半径取包络对角线,域内每点取到全部散点(≈Surfer 用全数据)。 +// clipToFootprint=true(默认):用散点平面**凸包**做足迹裁剪,凸包外网格列整列置空(≈Surfer 用 +// 边界多边形 Blanking)。避免单纯放大半径把体鼓满外接盒("变粗"的根因)。 +// 退化(散点 <3 / 平面近共线,如单条剖面)→ 自动跳过裁剪。 +BuiltVolume buildVolume(const PointSet& pts, double cellXY, double cellZ, + double power, double maxDist, bool clipToFootprint = true); + +} // namespace geopro::core diff --git a/src/core/geo/GeoLocalFrame.cpp b/src/core/geo/GeoLocalFrame.cpp index 18d3ec8..62273f1 100644 --- a/src/core/geo/GeoLocalFrame.cpp +++ b/src/core/geo/GeoLocalFrame.cpp @@ -17,8 +17,19 @@ GeoLocalFrame::GeoLocalFrame(double lat0, double lon0) mPerDegLon_(kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0)), mPerDegLat_(kMetersPerDegLat) {} +void GeoLocalFrame::reanchor(double lat0, double lon0) { + lat0_ = lat0; + lon0_ = lon0; + mPerDegLon_ = kMetersPerDegLonEquator * std::cos(lat0 * kPi / 180.0); + // mPerDegLat_ 为常数,无需更新。 +} + LocalXY GeoLocalFrame::toLocal(double lat, double lon) const { return LocalXY{(lon - lon0_) * mPerDegLon_, (lat - lat0_) * mPerDegLat_}; } +LatLon GeoLocalFrame::toLatLon(double x, double y) const { + return LatLon{lat0_ + y / mPerDegLat_, lon0_ + x / mPerDegLon_}; +} + } // namespace geopro::core diff --git a/src/core/geo/GeoLocalFrame.hpp b/src/core/geo/GeoLocalFrame.hpp index e401352..7a97ce2 100644 --- a/src/core/geo/GeoLocalFrame.hpp +++ b/src/core/geo/GeoLocalFrame.hpp @@ -2,13 +2,19 @@ namespace geopro::core { struct LocalXY { double x, y; }; +struct LatLon { double lat, lon; }; // 等距圆柱(equirectangular)近似:把经纬度投到以(lat0,lon0)为原点的局部米平面。 // 小范围测区足够;x=East、y=North(米)。 class GeoLocalFrame { public: GeoLocalFrame(double lat0, double lon0); + // 就地改原点(不换对象):所有持有此共享 frame 的渲染层(帘面/底图/坐标轴)随即一致重定位。 + void reanchor(double lat0, double lon0); LocalXY toLocal(double lat, double lon) const; // -> (x East m, y North m) + // toLocal 的反算:局部米 (x East, y North) -> 经纬度。 + // lon = lon0 + x/mPerDegLon,lat = lat0 + y/mPerDegLat(坐标轴经纬度刻度用)。 + LatLon toLatLon(double x, double y) const; private: double lat0_, lon0_, mPerDegLon_, mPerDegLat_; }; diff --git a/src/core/model/Anomaly.hpp b/src/core/model/Anomaly.hpp index b475aeb..9790c53 100644 --- a/src/core/model/Anomaly.hpp +++ b/src/core/model/Anomaly.hpp @@ -3,18 +3,52 @@ #include namespace geopro::core { -enum class AnomalyMarkType { Point = 1, Polyline = 2, Polygon = 3 }; +enum class AnomalyMarkType { Point = 1, Polyline = 2, Polygon = 3, Text = 4 }; struct Vec2 { double x, y; }; +struct Vec3 { double x, y, z; }; struct Anomaly { + std::string id; // 持久化 id(VTK 三维按 id 跟踪 actor 显隐/选中;2D 详情可空) + std::string remarkSourceId; // 挂载实体 dsId(体 or 切片;= 后端 remarkSourceId=dsObjectId)。 + // 挂体/挂切片由该 id 查 isVolume/isSlice 区分(spec §8); + // 注:后端 remarkSourceType 是标注几何形态(1-4)=markType,与此无关。 + std::string consortiumId; // 异常体分组 id(空 = 未分组/loose) std::string name; std::string typeName; // exceptionTypeName + std::string exceptionTypeId; // 异常类型 id(保存请求 exceptionTypeId) + std::string remark; // 备注 + std::string createTime; // 创建时间(异常列表展示用,只读) AnomalyMarkType markType = AnomalyMarkType::Polyline; - std::vector localPts; // location.coordinate(局部坐标) + std::vector localPts; // 2D 局部坐标(剖面详情:x=距离, y=深度) + // 经纬度 / 投影坐标(详情坐标系切换用,纯展示,不做客户端换算;对照原版 drawerExceptionInfo)。 + // 来源响应字段:latitudeLongitude.latLon[].{longitude,latitude}(→lonLatPts: x=经度 y=纬度)、 + // geographicalCoordinates.coordinates[].{northCoord,eastCoord}(→eastNorthPts: x=northCoord y=eastCoord)。 + // 空 = 响应未携带 → 坐标系下拉退化为仅「图形坐标」(与原版 latLon.length===0 一致)。 + std::vector lonLatPts; // 经纬度坐标:x=经度(longitude), y=纬度(latitude) + std::vector eastNorthPts; // 投影坐标:x=northCoord, y=eastCoord(对照原版映射) + // VTK 三维:异常多边形/折线/点的世界 3D 坐标(落在所在切片平面上)+ 平面(法向/一点), + // 用于 3D 渲染与重定位/正视;与切片生命周期解耦(切片可删,异常按 worldPts/plane 仍可显示)。 + std::vector 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" + // 文字标注(markType==Text)专属:customLegend 字段(对照原版 exceptionText.vue)。 + // 仅文字类型有意义;其它类型留默认。 + std::string textContent; // customLegend.content / text(文字内容) + std::string textColor = "#000000"; // customLegend.color(字色) + int textSize = 12; // customLegend.size(字号 px) + int textFont = 1; // customLegend 字体族 int(1宋体/2微软雅黑/3黑体/4楷体) + double textOpacity = 1.0; // customLegend.opacity(0–1) }; +// 异常挂载实体解析(spec §8):在切片平面画异常时—— +// 切片已保存成 dd_slice → 挂该切片;临时未保存切片 → 挂切片所属体。返回挂载实体 dsId(= remarkSourceId)。 +inline std::string resolveAnomalyMount(bool sliceIsSaved, const std::string& savedSliceDsId, + const std::string& volumeDsId) { + return (sliceIsSaved && !savedSliceDsId.empty()) ? savedSliceDsId : volumeDsId; +} + } // namespace geopro::core diff --git a/src/core/model/Field.hpp b/src/core/model/Field.hpp index 95d7ca0..a67b012 100644 --- a/src/core/model/Field.hpp +++ b/src/core/model/Field.hpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace geopro::core { // 规则三维标量场(IInterpolator 输出;render 层转 vtkImageData)。 @@ -55,6 +56,14 @@ struct ScatterField { std::vector a, b, m, n; std::vector electrodeX; std::vector electrodeNo; + // measurement 散点 [i]信息 / 显隐 / 持久化用(反演留空)。与 x/y/v 同序、一一对应: + // id = 点 id(saveDisplayStatus ids[],原版 rows[i][8];core 保持 Qt-free 用 std::string) + // displayStatus = 可见性(0=显示 1=隐藏,原版 rows[i][7]) + // row = DataRow([i]信息面板,原版 rows[i][15]) + // pseu = Pseu_Resis 视电阻率([i]信息面板,原版 rows[i][16]) + std::vector id; + std::vector displayStatus; + std::vector row, pseu; }; } // namespace geopro::core diff --git a/src/core/model/GprSurvey.hpp b/src/core/model/GprSurvey.hpp new file mode 100644 index 0000000..c54f1e1 --- /dev/null +++ b/src/core/model/GprSurvey.hpp @@ -0,0 +1,34 @@ +#ifndef GEOPRO_CORE_MODEL_GPRSURVEY_HPP +#define GEOPRO_CORE_MODEL_GPRSURVEY_HPP + +#include +#include + +namespace geopro::core { + +// 规则化建体输入(GPR 三维体)。雷达数据沿测线(X)、深度(Z)规则密采样, +// 仅横向/跨通道(Y)稀疏。结构化建体据此做 X/Z 落格 + 仅 Y 向 1D 线性插值。 +// +// 放 core/model(geopro::core)以保持 geopro_core 自洽:buildGprVolume 在 core, +// 若 GprSurvey 落 io/gpr 则 core 需反向 include io,故置于 core。 +// 真实数据 → GprSurvey 的装配在后续 POC 台(Task 9)完成。 +struct GprSurvey { + int ntraces = 0; // 沿测线道数(X) + int samples = 0; // 每道采样数(Z 深度) + double x0 = 0, dx = 1; // 沿测线轴:第 t 道 X = x0 + t*dx + double z0 = 0, dz = 1; // 深度轴:第 s 采样 Z = z0 + s*dz + + std::vector channelY; // 各通道横向位置(Y,稀疏,需按 Y 升序) + + // 值:channelY.size() × ntraces × samples,布局 [(c*ntraces + t)*samples + s]。 + std::vector values; + + // 64 位索引访问:values[(size_t(c)*ntraces + t)*samples + s]。 + double at(int c, int t, int s) const { + return values[(static_cast(c) * ntraces + t) * samples + s]; + } +}; + +} // namespace geopro::core + +#endif // GEOPRO_CORE_MODEL_GPRSURVEY_HPP diff --git a/src/core/model/ScalarVolumeI16.hpp b/src/core/model/ScalarVolumeI16.hpp new file mode 100644 index 0000000..82a4809 --- /dev/null +++ b/src/core/model/ScalarVolumeI16.hpp @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace geopro::core { + +// int16 量化标量体(雷达体专用,内存/显存/磁盘 = double 体的 1/4)。 +// 与 double 的 ScalarVolume 并列,不污染主路径。布局:i 最快、k 最慢(匹配 vtkImageData)。 + +// 物理值 ↔ int16 量化映射。 +struct Quant { + double scale = 1.0; + double offset = 0.0; + + // 物理值→int16:round((v-offset)/scale),钳到 [INT16_MIN+1, INT16_MAX]。 + // INT16_MIN 保留给 ScalarVolumeI16::kBlank(空值哨兵),不可被正常值占用。 + std::int16_t toQ(double v) const { + long q = std::lround((v - offset) / scale); + constexpr long kLo = static_cast(std::numeric_limits::min()) + 1; + constexpr long kHi = static_cast(std::numeric_limits::max()); + if (q < kLo) q = kLo; + if (q > kHi) q = kHi; + return static_cast(q); + } + + // int16→物理值:q*scale + offset。 + double toPhys(std::int16_t q) const { + return static_cast(q) * scale + offset; + } +}; + +class ScalarVolumeI16 { +public: + // 空值哨兵 → 渲染透明。 + static constexpr std::int16_t kBlank = std::numeric_limits::min(); + + // data_ 填 0(非 kBlank);空值由调用方显式置 kBlank。 + ScalarVolumeI16(int nx, int ny, int nz) + : nx_(nx), ny_(ny), nz_(nz), + data_(static_cast(nx) * ny * nz, 0) {} + + int nx() const { return nx_; } + int ny() const { return ny_; } + int nz() const { return nz_; } + + std::int16_t& at(int i, int j, int k) { return data_[idx(i, j, k)]; } + std::int16_t at(int i, int j, int k) const { return data_[idx(i, j, k)]; } + + std::vector& data() { return data_; } + const std::vector& data() const { return data_; } + +private: + // i 最快、j 次之、k 最慢 ⇒ ((k*ny + j)*nx + i)。 + // 64 位域计算:整卷体素数 nx*ny*nz 可达约 96 亿(>2^31),先转 size_t 再乘以防溢出。 + std::size_t idx(int i, int j, int k) const { + return (static_cast(k) * ny_ + j) * nx_ + i; + } + + int nx_, ny_, nz_; + std::vector data_; +}; + +} // namespace geopro::core diff --git a/src/core/model/detail/DetailPayloads.hpp b/src/core/model/detail/DetailPayloads.hpp index 762aba3..749db53 100644 --- a/src/core/model/detail/DetailPayloads.hpp +++ b/src/core/model/detail/DetailPayloads.hpp @@ -38,6 +38,9 @@ struct ScatterPayload { ScatterToolbarConf toolbar; std::vector altXHorizontal, altXSlope; // x 下拉:平距 / 斜距 std::vector altYPseudo, altYElevationPseudo; // y 下拉:伪深度 / 伪深度+高程 + // 色阶模板 id(来自 lvl/colorGradation/getDetail 的 templateId):保存色阶时回带 + // (对照原版 newLvlColorLevel 带读取到的 templateId;可空)。 + QString templateId; }; // 等值面载荷:grid(rows) + 色阶 + 异常(≈ data::GridParts)。 @@ -46,6 +49,9 @@ struct ContourPayload { geopro::core::Grid grid{1, 1}; geopro::core::ColorScale scale; std::vector anomalies; + // 色阶模板 id(来自 lvl/colorGradation/getDetail type2 的顶层 templateId):保存/覆盖色阶时回带 + // (对照原版 contourPage lvlTemplateId = lvlConfig?.templateId;可空)。 + QString templateId; }; // 列渲染种类:Text=预格式化文本(默认);Toggle=每行开关(蓝色药丸开关,ON=可见)。 @@ -60,15 +66,31 @@ struct TableColumn { TableColumnKind kind = TableColumnKind::Text; }; +// 表格功能按钮(dd_grid:服务端 functionList = DDGridFunctionVO[],驱动列表上方功能按钮行)。 +// 仅 dd_grid 列表携带(其余 Table 复用场景该列表为空 → 不渲染工具条)。enable=false 的按钮不显示 +// (原版 v-show="enable")。点击按 code 路由(原版仅处理 code=="inversion")。 +struct TableFunctionButton { + QString code; // functionCode(如 inversion) + QString nameChn; // functionNameChn(按钮中文文案) + bool enable = true; +}; + // 通用表格载荷:列定义 + 预格式化的行(每格 QString)+ 总数(分页用)。 // 分页(dd_grid 列表,服务端分页 vxe-pager):pageSize>0 时视图渲染分页器,pageNo 为当前页(1 基); // pageSize=0(默认)= 不分页(measurement/trajectory 全量列表,一次性返回所有行)。 +// functionButtons:仅 dd_grid 列表非空(来自服务端 functionList),驱动列表上方功能按钮行。 struct TablePayload { std::vector columns; std::vector> rows; int total = 0; int pageNo = 1; // 当前页(1 基);分页用 int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager),0=不分页 + std::vector functionButtons; // dd_grid 功能按钮(其余场景空) + // M2 行级显隐:仅 measurement 列表置 true(载荷驱动门控,其余视图复用 DataTableView 保持只读)。 + // 为 true 时 Toggle 列可点击 → popconfirm → saveDisplayStatus(rowIds[i], 取反)。 + bool toggleInteractive = false; + // 每行点 id(与 rows 同序、一一对应,saveDisplayStatus ids[] 用);仅 measurement 填充。 + std::vector rowIds; }; // 柱状图系列:名称(图例/legend)+ 各类目的 y 值 + 填充色(hex,如 #5470c6;数据色,两主题一致)。 diff --git a/src/data/CMakeLists.txt b/src/data/CMakeLists.txt index a192760..58d4560 100644 --- a/src/data/CMakeLists.txt +++ b/src/data/CMakeLists.txt @@ -3,7 +3,10 @@ find_package(Qt6 COMPONENTS Core REQUIRED) add_library(geopro_data STATIC parse/SampleParsers.cpp repo/LocalSampleRepository.cpp + repo/LocalSample3dRepository.cpp + repo/DatasetFieldDictionary.cpp dto/NavDto.cpp + dto/Vtk3dRequests.cpp dto/DatasetChartDto.cpp dto/MeasurementDto.cpp dto/GrMeasurementDto.cpp @@ -11,9 +14,17 @@ add_library(geopro_data STATIC dto/GridDto.cpp api/ApiProjectRepository.cpp api/ApiDatasetRepository.cpp + api/ApiColorTemplateRepository.cpp + api/ApiDatasetCommandRepository.cpp + api/Api3dRepository.cpp api/DatasetLoadHandles.cpp - api/NavRequest.cpp) + api/NavRequest.cpp + GprVolumeRepository.cpp) target_include_directories(geopro_data PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core PRIVATE nlohmann_json::nlohmann_json) +# geopro_gpr3dv_bridge:逐线 GPR 体(BuiltI16)来源,GprVolumeRepository 反量化为 VolumeGrid。 +target_link_libraries(geopro_data PUBLIC geopro_core geopro_net Qt6::Core geopro_gpr3dv_bridge PRIVATE nlohmann_json::nlohmann_json) target_compile_features(geopro_data PUBLIC cxx_std_17) set_target_properties(geopro_data PROPERTIES AUTOMOC ON AUTOUIC OFF AUTORCC OFF) + +# GPR 三维体分块压缩落盘库(geopro_store,B/C 共用基座)。 +add_subdirectory(store) diff --git a/src/data/GprVolumeRepository.cpp b/src/data/GprVolumeRepository.cpp new file mode 100644 index 0000000..a1b1277 --- /dev/null +++ b/src/data/GprVolumeRepository.cpp @@ -0,0 +1,50 @@ +#include "data/GprVolumeRepository.hpp" + +#include +#include + +#include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/Gpr3dvVolumeBridge.hpp" + +namespace geopro::data { + +VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built) { + const int nx = built.vol.nx(); + const int ny = built.vol.ny(); + const int nz = built.vol.nz(); + + VolumeGrid out; + out.vol = geopro::core::ScalarVolume(nx, ny, nz); + out.origin = built.origin; + out.spacing = built.spacing; + out.vmin = built.vminPhys; + out.vmax = built.vmaxPhys; + + // 逐体素反量化(布局一致:i 最快、k 最慢)。 + // kBlank → NaN:下游 render::buildVoxel 把 NaN 映射到 [vmin,vmax] 外的哨兵 → + // 传递函数置全透明(与 float 路径空值语义一致)。 + const std::vector& src = built.vol.data(); + std::vector& dst = out.vol.data(); + const double nan = std::numeric_limits::quiet_NaN(); + for (std::size_t idx = 0; idx < src.size(); ++idx) { + const std::int16_t q = src[idx]; + dst[idx] = (q == geopro::core::ScalarVolumeI16::kBlank) + ? nan + : built.quant.toPhys(q); + } + return out; +} + +VolumeGrid createGprVolumeGrid(const std::string& lineDir, + const std::string& linePrefix, int coarse, + double targetDy) { + // 走 P1/P2 链(io::gpr)得处理后 int16 量化体 → 反量化为 app 的 float 体。 + // metricsOut 传 nullptr:repository 只产数据,度量留给 gpr_poc CLI。 + // targetDy 透传 → 默认走线内通道插值(2.5cm 网格),app 渲染链即得密 Y 体。 + const geopro::core::BuiltI16 built = + geopro::io::gpr::buildLineVolumeFromGpr3dv( + lineDir, linePrefix, /*metricsOut=*/nullptr, coarse, targetDy); + return builtI16ToVolumeGrid(built); +} + +} // namespace geopro::data diff --git a/src/data/GprVolumeRepository.hpp b/src/data/GprVolumeRepository.hpp new file mode 100644 index 0000000..59f7ff8 --- /dev/null +++ b/src/data/GprVolumeRepository.hpp @@ -0,0 +1,38 @@ +#ifndef GEOPRO_DATA_GPRVOLUMEREPOSITORY_HPP +#define GEOPRO_DATA_GPRVOLUMEREPOSITORY_HPP + +#include + +#include "core/algo/GprVolumeBuilder.hpp" // geopro::core::BuiltI16 +#include "repo/I3dSceneRepository.hpp" // geopro::data::VolumeGrid + +namespace geopro::data { + +// 把逐线 GPR 处理后量化体(int16 BuiltI16)反量化成 app 渲染链吃的稠密 float 体 +// (VolumeGrid),使真实雷达三维体可经现成 loadVolume→addVolume→buildVoxel 显示。 +// +// 数据层方案 A(纯数据层,零 UI/render/controller 改动): +// io::gpr::buildLineVolumeFromGpr3dv(P1/P2 链) → core::BuiltI16(int16+Quant) +// → builtI16ToVolumeGrid(反量化) → data::VolumeGrid(float)。 +// 与现 mock(Api3dRepository 反演剖面 IDW)同格式输出,故下游 addVolume 无需任何改动。 + +// 纯函数适配器:BuiltI16(int16 量化体) → VolumeGrid(float 稠密体)。 +// - 逐体素 Quant::toPhys 反量化;kBlank(空值哨兵)→ NaN(下游 buildVoxel 映射为透明)。 +// - origin/spacing 原样搬运;vmin/vmax 用 BuiltI16 的物理值域。 +// - 布局一致(i 最快、k 最慢),两体可逐体素对位拷贝。 +VolumeGrid builtI16ToVolumeGrid(const geopro::core::BuiltI16& built); + +// 走 P1/P2 链建逐线 GPR 体并适配成 app 的 VolumeGrid。 +// lineDir/linePrefix 同 gpr3dv-smoke / build-line(如 "D:/Downloads/明星路", "明星路_001")。 +// coarse(下采样因子,≥1):沿测线(道/X 轴)每 coarse 道取 1,省内存;横向/深度保全分辨率。 +// 稠密 VolumeGrid 全内存,长线需较大 coarse 控内存(默认 4 = build-line POC 档)。 +// targetDy(米,>0 启用):线内【通道间插值】目标横向间距(读真实道偏移规则化,不跨线)。 +// 默认 0.025(2.5cm);0=不插值(Y=原通道数)。详见 io::gpr::buildLineVolumeFromGpr3dv。 +// 失败(加载失败/立方体为空)→ 抛 std::runtime_error(由 io::gpr 链抛出,原样透传)。 +VolumeGrid createGprVolumeGrid(const std::string& lineDir, + const std::string& linePrefix, int coarse = 4, + double targetDy = 0.025); + +} // namespace geopro::data + +#endif // GEOPRO_DATA_GPRVOLUMEREPOSITORY_HPP diff --git a/src/data/StreamingVolumeBuilder.cpp b/src/data/StreamingVolumeBuilder.cpp new file mode 100644 index 0000000..20c6a6d --- /dev/null +++ b/src/data/StreamingVolumeBuilder.cpp @@ -0,0 +1,208 @@ +#include "data/StreamingVolumeBuilder.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" // geopro::core::sampleGprPoint, Quant +#include "core/model/GprSurvey.hpp" +#include "core/model/ScalarVolumeI16.hpp" // kBlank +#include "data/store/ChunkedVolumeStore.hpp" +#include "io/gpr/GprSurveyAssembler.hpp" +#include "io/gpr/IprHeader.hpp" + +namespace geopro::data { + +namespace { + +namespace fs = std::filesystem; +using geopro::core::GridSpec; +using geopro::core::Quant; +using geopro::core::ScalarVolumeI16; + +constexpr std::int16_t kBlank = ScalarVolumeI16::kBlank; + +int ceilDiv(int n, int brick) { return (n + brick - 1) / brick; } + +// 块尺寸(边缘块 < brick)。 +int extent(int n, int b, int brick) { + const int got = n - b * brick; + return got < brick ? got : brick; +} + +// .iprb 路径 → 同名 .iprh(取最后一个路径分隔符之后的最后一个 '.')。 +// 与 GprSurveyAssembler 内部一致,但那是其匿名命名空间私有,故此处独立小副本。 +std::string toHeaderPath(const std::string& iprbPath) { + const std::size_t dot = iprbPath.find_last_of('.'); + const std::size_t slash = iprbPath.find_last_of("/\\"); + if (dot != std::string::npos && + (slash == std::string::npos || dot > slash)) { + return iprbPath.substr(0, dot) + ".iprh"; + } + return iprbPath + ".iprh"; +} + +std::string readFileText(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) throw std::runtime_error("StreamingVolumeBuilder: 无法打开 " + path); + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +// 全线总道数 = min 通道(fileBytes/(samples*2)),与 assembleGprSurvey 的对齐口径一致。 +// 同时回传 survey.dx(道距,各通道 header.distanceInterval 一致,取首通道)。 +std::int64_t totalTraces(const std::vector& iprb, double& surveyDx) { + if (iprb.empty()) throw std::runtime_error("StreamingVolumeBuilder: 无通道"); + std::int64_t minTraces = std::numeric_limits::max(); + for (std::size_t c = 0; c < iprb.size(); ++c) { + const geopro::io::gpr::IprHeader h = + geopro::io::gpr::parseIprHeader(readFileText(toHeaderPath(iprb[c]))); + if (h.samples <= 0) + throw std::runtime_error("StreamingVolumeBuilder: samples<=0"); + if (c == 0) surveyDx = h.distanceInterval; + const std::int64_t bytes = + static_cast(fs::file_size(fs::path(iprb[c]))); + const std::int64_t per = static_cast(h.samples) * 2; + if (per <= 0 || bytes % per != 0) + throw std::runtime_error("StreamingVolumeBuilder: .iprb 字节非整道"); + minTraces = std::min(minTraces, bytes / per); + } + return minTraces; +} + +// 网格 X 列 [gx0,gx1) 经世界坐标落到的全局道索引范围(含端点),夹到 [0,total)。 +// 返回 false 表示该列范围内无任何网格点落进 [0,total)(整 slab 全 X 越界)。 +bool traceRangeForColumns(const GridSpec& spec, double surveyDx, + std::int64_t total, int gx0, int gx1, + std::int64_t& t0, std::int64_t& t1) { + std::int64_t lo = std::numeric_limits::max(); + std::int64_t hi = std::numeric_limits::min(); + // survey.x0=0:g = lround(worldX / surveyDx),与 nearestIndex 同式。 + for (int gi = gx0; gi < gx1; ++gi) { + const double worldX = spec.ox + gi * spec.dx; + if (surveyDx == 0.0) continue; + const std::int64_t g = std::llround(worldX / surveyDx); + if (g < 0 || g >= total) continue; // 越界点非流式即 blank,不扩 slab 道范围 + lo = std::min(lo, g); + hi = std::max(hi, g); + } + if (lo > hi) return false; + t0 = lo; + t1 = hi + 1; // [t0,t1) 半开 + return true; +} + +} // namespace + +void buildGprVolumeStreaming(const std::vector& channelIprbPaths, + const std::string& ordPath, const GridSpec& spec, + const std::string& outDir, int sliceXBricks) { + if (sliceXBricks <= 0) sliceXBricks = 1; + constexpr int kBrick = 64; + + // 0) 全线总道数 + 道距(决定 X 落格,定 slab 道范围)。 + double surveyDx = 1.0; + const std::int64_t total = totalTraces(channelIprbPaths, surveyDx); + + const int bX = ceilDiv(spec.nx, kBrick); + const int bY = ceilDiv(spec.ny, kBrick); + const int bZ = ceilDiv(spec.nz, kBrick); + + // 1) 全局量化:扫【全线全部道】的标量值定 vmin/vmax(不留整卷)。 + // 必须与 buildGprVolume 完全一致——它扫整个 assembleGprSurvey 的 values,与 + // 网格覆盖无关。故此处按固定大小道块 tile [0,total) 全扫,而非只扫网格可达道 + // (否则当网格 X 范围窄于测线时 vmin/vmax 会偏,量化漂移)。单块只持一 slab。 + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + constexpr std::int64_t kScanChunk = 64; // 每次扫的道数(内存只随道块) + for (std::int64_t t0 = 0; t0 < total; t0 += kScanChunk) { + const std::int64_t t1 = std::min(total, t0 + kScanChunk); + const auto slab = + geopro::io::gpr::assembleGprSurveySlab(channelIprbPaths, ordPath, t0, t1); + for (double v : slab.values) { + if (std::isnan(v)) continue; + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + } + if (!(vmin <= vmax)) { // 无有效值:退化 [0,0],同 buildGprVolume。 + vmin = 0.0; + vmax = 0.0; + } + + Quant quant; + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); + + // 2) StoreMeta(dims/brick/origin/spacing/quant/vminmax 同 buildGprVolume+write)。 + StoreMeta meta; + meta.nx = spec.nx; + meta.ny = spec.ny; + meta.nz = spec.nz; + meta.brick = kBrick; + meta.origin = {spec.ox, spec.oy, spec.oz}; + meta.spacing = {spec.dx, spec.dy, spec.dz}; + meta.quant = quant; + meta.vminPhys = vmin; + meta.vmaxPhys = vmax; + + StreamingVolumeWriter w(outDir, meta); + + // 3) 沿 X 分 slab(brick 对齐)逐块写。 + for (int bcol = 0; bcol < bX; bcol += sliceXBricks) { + const int bxEnd = std::min(bX, bcol + sliceXBricks); // 该 slab 含的 brick 列 [bcol,bxEnd) + const int gx0 = bcol * kBrick; + const int gx1 = std::min(spec.nx, bxEnd * kBrick); + + // 该 slab 的全局道范围 → 局部 survey(x0=t0*dx, ntraces=t1-t0),可能全越界。 + std::int64_t t0 = 0, t1 = 0; + const bool hasTraces = + traceRangeForColumns(spec, surveyDx, total, gx0, gx1, t0, t1); + + geopro::core::GprSurvey slab; + if (hasTraces) { + slab = geopro::io::gpr::assembleGprSurveySlab(channelIprbPaths, ordPath, + t0, t1); + } + + // 逐 brick 写:该 slab 的每个 X 列 brick × 所有 Y,Z brick。 + for (int bz = 0; bz < bZ; ++bz) { + for (int by = 0; by < bY; ++by) { + for (int bx = bcol; bx < bxEnd; ++bx) { + const int bw = extent(spec.nx, bx, kBrick); + const int bh = extent(spec.ny, by, kBrick); + const int bd = extent(spec.nz, bz, kBrick); + std::vector voxels( + static_cast(bw) * bh * bd); + const int i0 = bx * kBrick, j0 = by * kBrick, k0 = bz * kBrick; + std::size_t wi = 0; + for (int kk = 0; kk < bd; ++kk) { + for (int jj = 0; jj < bh; ++jj) { + for (int ii = 0; ii < bw; ++ii) { + if (!hasTraces) { + voxels[wi++] = kBlank; // 整列 X 越界 → blank(同非流式) + } else { + voxels[wi++] = geopro::core::sampleGprPoint( + slab, spec, i0 + ii, j0 + jj, k0 + kk, quant); + } + } + } + } + w.writeBrick(bx, by, bz, voxels); + } + } + } + // slab 缓冲随 for 作用域结束释放(下一 slab 重新装配)。 + } + + w.finalize(); +} + +} // namespace geopro::data diff --git a/src/data/StreamingVolumeBuilder.hpp b/src/data/StreamingVolumeBuilder.hpp new file mode 100644 index 0000000..b5e0e5d --- /dev/null +++ b/src/data/StreamingVolumeBuilder.hpp @@ -0,0 +1,31 @@ +#ifndef GEOPRO_DATA_STREAMINGVOLUMEBUILDER_HPP +#define GEOPRO_DATA_STREAMINGVOLUMEBUILDER_HPP + +#include +#include + +#include "core/algo/IInterpolator.hpp" // geopro::core::GridSpec + +namespace geopro::data { + +// 流式建 level0 体到 outDir。沿 X 按 brick 对齐分 slab,逐 slab: +// assembleGprSurveySlab → 共享采样核 sampleGprPoint → writeBrick → 释放,不持整卷, +// 峰值内存只随单个 slab。 +// +// 量化全局一致:先扫所有 slab 的标量值定全局 vmin/vmax(不留整卷), +// scale=(vmax-vmin)/64000、offset=值域中点(与非流式 buildGprVolume 完全相同), +// 保证逐体素量化一致。 +// +// 产出与非流式 buildGprVolume + ChunkedVolumeStore::write 逐 brick + meta 完全一致 +// (结构化建体无 X 邻域耦合,按 X 分块算 = 整卷算逐体素)。 +// +// sliceXBricks:每 slab 含多少个 X 方向 brick(slab 的网格 X 范围 = 这些 brick 覆盖的格)。 +// <=0 视为 1。 +void buildGprVolumeStreaming( + const std::vector& channelIprbPaths, const std::string& ordPath, + const geopro::core::GridSpec& spec, const std::string& outDir, + int sliceXBricks = 8); + +} // namespace geopro::data + +#endif // GEOPRO_DATA_STREAMINGVOLUMEBUILDER_HPP diff --git a/src/data/api/Api3dRepository.cpp b/src/data/api/Api3dRepository.cpp new file mode 100644 index 0000000..26a12f5 --- /dev/null +++ b/src/data/api/Api3dRepository.cpp @@ -0,0 +1,553 @@ +#include "api/Api3dRepository.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "algo/VolumeBuilder.hpp" // core::PointSet / BuiltVolume / buildVolume(含 Field.hpp) +#include "api/DatasetLoadHandles.hpp" +#include "GprVolumeRepository.hpp" // createGprVolumeGrid(§6 接入:GPR 体直产) +#include "model/ColorScale.hpp" +#include "model/detail/DetailPayloads.hpp" +#include "repo/IAsyncDatasetRepository.hpp" + +namespace geopro::data { + +namespace { +constexpr const char* kNotReady = "后端三维端点未就绪"; +} // namespace + +Api3dRepository::Api3dRepository(IAsyncDatasetRepository& dsRepo, + std::shared_ptr frame) + : dsRepo_(dsRepo), frame_(std::move(frame)) {} + +DsDimension Api3dRepository::dimensionOf(const DsRow& ds) const { + // 与 LocalSample3dRepository::dimensionOf 同口径(spec §6.1 ddCode→维度)。 + // TODO(P3): 与 LocalSample3dRepository 重复,宜提取共享映射(后续清理)。 + const std::string& c = ds.ddCode; + if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || c == "dd_section" || + c == "dd_inversion_data") { + return DsDimension::Dim3D; + } + if (c == "dd_slice") return DsDimension::Analysis3D; + // 足迹型 → 二维数据集:地面 lat/lon 序列,平铺进地图。dd_trajectory_data = 统一通用轨迹 + // (数据字典 DD0623「保留」,已并入 dd_radar_rtk_trajectory);瞬变电磁/雷达通道/RTK 轨迹字典均「删除」。 + if (c == "dd_trajectory_data") return DsDimension::Dim2D; + return DsDimension::Other; +} + +void Api3dRepository::loadSection(const std::string& dsId, std::function onOk, + OnError onErr) { + // 真实帘面:复用 ApiDatasetRepository 的 ERT 反演网格端点(loaderKey="inversion.grid")。 + // 命中载荷 = core::ContourPayload{grid, scale, anomalies};取 grid+scale 填 SectionData。 + DetailLoad* load = dsRepo_.loadAsync("inversion.grid", dsId); + if (load == nullptr) { + onErr("Api3dRepository::loadSection: loadAsync 返回空句柄"); + return; + } + // 以 load 为连接上下文 → 它 deleteLater 时自动断开;单线程下创建后立即连接安全。 + QObject::connect(load, &DetailLoad::done, load, + [onOk = std::move(onOk)](const QVariant& payload) { + const auto cp = qvariant_cast(payload); + SectionData s; + s.grid = cp.grid; + s.scale = cp.scale; + onOk(std::move(s)); + }); + QObject::connect(load, &DetailLoad::failed, load, + [onErr = std::move(onErr)](const QString& message) { + onErr(message.toStdString()); + }); +} + +void Api3dRepository::loadMapLine(const std::string& dsId, std::function onOk, + OnError onErr) { + // 真实足迹:复用 ApiDatasetRepository 轨迹地图端点(loaderKey="traj.map" → dd/ert/trajectory/line, + // frontCrsCode 固定 EPSG:4326)。命中载荷 = core::MapPayload{points[].lat/lon};取经纬填 MapLine。 + DetailLoad* load = dsRepo_.loadAsync("traj.map", dsId); + if (load == nullptr) { + onErr("Api3dRepository::loadMapLine: loadAsync 返回空句柄"); + return; + } + // 以 load 为连接上下文 → 它 deleteLater 时自动断开(与 loadSection 同范式)。 + QObject::connect(load, &DetailLoad::done, load, + [onOk = std::move(onOk)](const QVariant& payload) { + const auto mp = qvariant_cast(payload); + MapLine line; + line.lat.reserve(mp.points.size()); + line.lon.reserve(mp.points.size()); + for (const auto& p : mp.points) { + line.lat.push_back(p.lat); + line.lon.push_back(p.lon); + } + onOk(std::move(line)); + }); + QObject::connect(load, &DetailLoad::failed, load, + [onErr = std::move(onErr)](const QString& message) { + onErr(message.toStdString()); + }); +} + +bool Api3dRepository::isVolumeDataset(const std::string& dsId) const { + return volumes_.find(dsId) != volumes_.end(); +} + +std::string Api3dRepository::createVolume(VolumeBuildParams params, const std::string& name) { + const std::string id = "vol-" + std::to_string(++volumeCounter_); + StoredVolume sv; + sv.params = std::move(params); + sv.name = name; + sv.createTime = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString(); + volumes_[id] = std::move(sv); + return id; +} + +std::string Api3dRepository::createVolume(const VoxelGenerateRequest& req) { + const std::string id = createVolume(fromRequest(req), req.name); // 复用 mock 存储 + 惰性插值 + if (auto it = volumes_.find(id); it != volumes_.end()) it->second.request = req; + qInfo().noquote() << "[volreq] createVolume 请求体:" + << QJsonDocument(req.toJson()).toJson(QJsonDocument::Compact); + return id; +} + +std::string Api3dRepository::createGprVolume(const std::string& lineDir, + const std::string& linePrefix, + const std::string& name, int coarse) { + // 走 io::gpr 逐线管线(含线内通道插值)直接产体(抛异常透传给调用方)。 + VolumeGrid grid = geopro::data::createGprVolumeGrid(lineDir, linePrefix, coarse); + // 简易灰度色阶(负→暗、零→灰、正→亮)覆盖体值域,使体素渲染可见。 + core::ColorScale scale; + const double mid = 0.5 * (grid.vmin + grid.vmax); + scale.addStop(grid.vmin, core::Rgba{20, 24, 40, 255}); + scale.addStop(mid, core::Rgba{140, 140, 150, 255}); + scale.addStop(grid.vmax, core::Rgba{235, 232, 220, 255}); + + const std::string id = "vol-" + std::to_string(++volumeCounter_); + StoredVolume sv; + sv.name = name; + sv.createTime = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString(); + sv.cachedGrid = std::move(grid); // 预填 → loadVolume 直接命中渲染(不走 mock IDW) + sv.cachedScale = scale; + volumes_[id] = std::move(sv); + return id; +} + +const VoxelGenerateRequest* Api3dRepository::lastVoxelRequest(const std::string& dsId) const { + const auto it = volumes_.find(dsId); + return (it != volumes_.end() && it->second.request) ? &*it->second.request : nullptr; +} + +void Api3dRepository::clearMockData() { + // 切换项目:清空内存态三维体/切片/异常,避免上个项目的产物残留进新项目列表。 + volumes_.clear(); + slices_.clear(); + anomalies_.clear(); +} + +std::vector Api3dRepository::volumeRows() const { + std::vector rows; + rows.reserve(volumes_.size()); + for (const auto& [id, sv] : volumes_) { + DsRow r; + r.id = id; + r.dsName = sv.name; + r.ddCode = "dd_voxel"; + r.typeName = "三维体"; + r.structParentId = sv.request ? sv.request->structParentId : std::string(); // 结构归属(生成位置) + r.createTime = sv.createTime; + rows.push_back(std::move(r)); + } + return rows; +} + +std::vector Api3dRepository::anomalyRows() const { + std::vector rows; + rows.reserve(anomalies_.size()); + for (const auto& [id, sa] : anomalies_) { + DsRow r; + r.id = id; + r.dsName = sa.a.name; + r.ddCode = "dd_anomaly"; + r.typeName = sa.a.typeName.empty() ? std::string("异常") : sa.a.typeName; + r.createTime = sa.a.createTime; + r.parentId = sa.a.remarkSourceId; // 挂归属实体(体/切片)下;三级树按 parentId 自动挂载 + rows.push_back(std::move(r)); + } + 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(ny)) return; + // 与 CurtainActor::buildCurtain 同口径:有 lat/lon 用 frame.toLocal,否则退化用 g.x/0。 + const bool hasLatLon = g.lat.size() >= static_cast(nx) && + g.lon.size() >= static_cast(nx); + for (int j = 0; j < ny; ++j) { + for (int i = 0; i < nx; ++i) { + const double val = g.valueAt(i, j); + if (!std::isfinite(val)) continue; // 跳过无数据格(与帘面消隐一致,避免 NaN 入 IDW) + double px, py; + if (hasLatLon) { + const auto p = frame_->toLocal(g.lat[i], g.lon[i]); + px = p.x; + py = p.y; + } else { + px = (g.x.size() > static_cast(i)) ? g.x[i] : static_cast(i); + py = 0.0; + } + pts.x.push_back(px); + pts.y.push_back(py); + pts.z.push_back(g.y[j]); // 世界 Z = 高程(与 CurtainActor 一致) + pts.v.push_back(val); + } + } +} + +void Api3dRepository::loadSectionWithRetry(const std::string& dsId, int attemptsLeft, + std::function onOk, OnError onErr) { + loadSection(dsId, onOk, [this, dsId, attemptsLeft, onOk, onErr](const std::string& m) { + if (attemptsLeft > 0) { // 瞬时失败(502 等)→ 重试,不立刻判整体失败 + qInfo().noquote() << "[volbuild] source" << QString::fromStdString(dsId) + << "加载失败,重试(剩" << attemptsLeft << "次):" << QString::fromStdString(m); + loadSectionWithRetry(dsId, attemptsLeft - 1, onOk, onErr); + return; + } + onErr(m); + }); +} + +void Api3dRepository::finalizeVolume(const std::string& dsId, const core::PointSet& pts, + const core::ColorScale& scale, + const VolumeBuildParams& params, + std::function onOk, + OnError onErr) { + if (pts.v.empty()) { + onErr("Api3dRepository::loadVolume: 配准后无有效散点(源数据为空或全无数据)"); + return; + } + + // 重 IDW 建体放后台线程,避免阻塞 UI(用户要求:渲染必须异步)。纯计算(无 Qt/VTK),算完 + // 经事件循环回主线程做缓存 + 交付(缓存写 volumes_ / onOk 触碰 VTK 必须在主线程)。 + // 兜底:无 QCoreApplication(headless/单测)时退化为同步,保证可测/可离屏。 + auto deliver = [this, dsId, scale, onOk, onErr](std::shared_ptr bv, + std::string err, std::size_t nPts) { + if (!bv) { + onErr(std::string("Api3dRepository::loadVolume: ") + err); + return; + } + double vmin = bv->vmin, vmax = bv->vmax; + const std::vector stops = scale.stopValues(); + if (stops.size() >= 2) { + vmin = stops.front(); + vmax = stops.back(); + } + qInfo().noquote() << "[volbuild] finalize pts=" << nPts << "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}}, + vmin, vmax}; + auto it = volumes_.find(dsId); + if (it != volumes_.end()) { // 缓存明细 + 色阶(下次命中即跳重算) + it->second.cachedGrid = out; + it->second.cachedScale = scale; + it->second.pointCount = nPts; // 持久化聚合散点数(详情统计用) + } + onOk(std::move(out), scale); + }; + + // 纯计算闭包:返回 (built|nullptr, err, nPts)。 + auto compute = [pts, params]() { + std::shared_ptr bv; + std::string err; + try { + bv = std::make_shared(geopro::core::buildVolume( + pts, params.cellXY, params.cellZ, params.power, params.maxDist)); + } catch (const std::exception& e) { + err = e.what(); + } + return std::make_tuple(bv, err, pts.v.size()); + }; + + qInfo().noquote() << "[volbuild] start dsId=" << QString::fromStdString(dsId) + << "pts=" << pts.v.size() << "async=" << (QCoreApplication::instance() != nullptr); + if (!QCoreApplication::instance()) { // 无事件循环(headless/单测)→ 同步 + auto res = compute(); + deliver(std::get<0>(res), std::get<1>(res), std::get<2>(res)); + return; + } + std::thread([compute, deliver, dsId]() mutable { + const auto t0 = std::chrono::steady_clock::now(); + auto res = compute(); + auto bv = std::get<0>(res); // 具名变量(非结构化绑定)→ C++17 可被 lambda 捕获 + auto err = std::get<1>(res); + auto nPts = std::get<2>(res); + const auto ms = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + qInfo().noquote() << "[volbuild] computed dsId=" << QString::fromStdString(dsId) + << "ms=" << ms << "ok=" << (bv != nullptr); + // 回主线程交付(QueuedConnection;qApp 为主线程对象,存活于整个会话)。 + QMetaObject::invokeMethod( + qApp, + [deliver, bv, err, nPts]() mutable { deliver(std::move(bv), std::move(err), nPts); }, + Qt::QueuedConnection); + }).detach(); +} + +void Api3dRepository::loadVolume(const std::string& dsId, + std::function onOk, + OnError onErr) { + auto it = volumes_.find(dsId); + if (it == volumes_.end()) { + onErr("Api3dRepository::loadVolume: 未知三维体 " + dsId); + return; + } + StoredVolume& sv = it->second; + if (sv.cachedGrid) { // 明细命中 → 直接渲染(不重算) + onOk(*sv.cachedGrid, sv.cachedScale); + return; + } + const VolumeBuildParams params = sv.params; // 拷贝:异步回调期间存储可能变动 + if (params.sourceDatasetIds.empty()) { + onErr("Api3dRepository::loadVolume: 三维体无源数据集"); + return; + } + + // 多源扇出:每个源走 loadSection(与帘面同一 inversion.grid 路径 → 同系对齐), + // 主线程聚合(loadSection 回调在主线程)。任一源失败 → 整体失败(只回一次)。 + struct Agg { + int pending; + bool failed = false; + core::PointSet pts; + std::vector scales; // 收集所有源色阶 → 取 vmax 中位者定值域(不依赖到达顺序) + }; + auto agg = std::make_shared(); + agg->pending = static_cast(params.sourceDatasetIds.size()); + + for (const std::string& srcId : params.sourceDatasetIds) { + loadSectionWithRetry( + srcId, /*attemptsLeft=*/2, + [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() << ")"; + agg->scales.push_back(s.scale); + if (--agg->pending > 0) return; // 还有源未到齐 + // 值域定法(修偶发"淡蓝/几乎不可见"根因):旧逻辑取「首个到达源」的色阶 → 多条线值域 + // 不一(如多条 2168、一条 24550)时随异步到达顺序抖动;取到大值域那条会把数据全压到 + // 色阶低端→全蓝近透明。改为取所有源色阶按 vmax 排序的中位者:确定性(去到达顺序依赖) + // + 抗单条线值域离群 → 多数线的正常值域稳定胜出。 + auto& ss = agg->scales; + std::sort(ss.begin(), ss.end(), + [](const core::ColorScale& a, const core::ColorScale& b) { + const auto av = a.stopValues(), bv = b.stopValues(); + return (av.empty() ? 0.0 : av.back()) < (bv.empty() ? 0.0 : bv.back()); + }); + const core::ColorScale chosen = ss.empty() ? core::ColorScale{} : ss[ss.size() / 2]; + finalizeVolume(dsId, agg->pts, chosen, params, onOk, onErr); + }, + [agg, onErr](const std::string& m) { + if (agg->failed) return; + agg->failed = true; + onErr("Api3dRepository::loadVolume 源加载失败: " + m); + }); + } +} + +void Api3dRepository::loadTerrainPaths(std::function /*onOk*/, OnError onErr) { + onErr(kNotReady); // 后端地形 DEM/影像端点未就绪 +} + +// ── 切片 CRUD(后端无切片端点 → 内存 mock;端点就绪后换实现)──────────────── + +std::vector Api3dRepository::sliceRows() const { + std::vector 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; // 树中挂在所属三维体下 + r.createTime = ss.createTime; + rows.push_back(std::move(r)); + } + return rows; +} + +bool Api3dRepository::isSliceDataset(const std::string& dsId) const { + return slices_.find(dsId) != slices_.end(); +} + +bool Api3dRepository::isAnomalyDataset(const std::string& dsId) const { + return anomalies_.find(dsId) != anomalies_.end(); +} + +bool Api3dRepository::anomalyById(const std::string& anomalyId, geopro::core::Anomaly& out) const { + const auto it = anomalies_.find(anomalyId); + if (it == anomalies_.end()) return false; + out = it->second.a; + return true; +} + +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; +} + +void Api3dRepository::createSlice(const SliceSpec& spec, const std::string& name, + std::function onOk, OnError /*onErr*/) { + const std::string id = "slice-" + std::to_string(++sliceCounter_); + slices_[id] = StoredSlice{ + spec, name, + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString()}; + // 打印切片登记请求体(对齐 SliceGenerateRequest,端点 POST /business/dsObject/slice/generate)供后端联调。 + // projectId 由真实请求层据当前项目填充(mock 仓储无项目上下文,此处留空)。 + SliceGenerateRequest req; + req.volumeDsId = spec.volumeDsId; + req.name = name; + req.axis = spec.axis; + req.origin = spec.origin; + req.point1 = spec.point1; + req.point2 = spec.point2; + req.colorScaleId = spec.colorScaleId; + qInfo().noquote() << "[slicereq] registerSlice 请求体:" + << QJsonDocument(req.toJson()).toJson(QJsonDocument::Compact); + onOk(id); +} + +void Api3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function 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 onOk, + OnError /*onErr*/) { + slices_.erase(dsId); + onOk(); +} + +// ── 异常 / 异常体(后端真实端点存在,但异常挂三维体、三维体仍 mock → 异常暂内存 mock; +// 挂载结构按"异常→三维体",整链端点就绪后切真实,见记忆 vtk-3d-persistence-structure)── + +void Api3dRepository::loadAnomalyTree(const std::string& remarkSourceId, + std::function onOk, OnError /*onErr*/) { + // 按归属实体(体/切片)过滤;按 consortiumId 分组(异常体),空 consortiumId → loose(未分组)。 + AnomalyTree tree; + std::map bodyIndex; // consortiumId → tree.bodies 下标 + for (const auto& [id, sa] : anomalies_) { + if (!remarkSourceId.empty() && sa.a.remarkSourceId != remarkSourceId) 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 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; + if (stored.createTime.empty()) // mock:构建时未设创建时刻 → 补当前时间(副标题/列表显示) + stored.createTime = + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")).toStdString(); + anomalies_[id] = StoredAnomaly{std::move(stored), screenshotPngPath}; + onOk(id); +} + +void Api3dRepository::deleteAnomaly(const std::string& anomalyId, std::function onOk, + OnError /*onErr*/) { + anomalies_.erase(anomalyId); + onOk(); +} + +void Api3dRepository::deleteAnomalyGroup(const std::string& bodyId, std::function 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 崩)────────────────────────────────────── + +void Api3dRepository::loadTaskRecords(const std::string& /*dsId*/, + std::function)> onOk, + OnError /*onErr*/) { + onOk({}); // 后端未就绪 → 空记录 +} + +void Api3dRepository::loadUsableTasks(const std::string& /*ddCode*/, + std::function)> onOk, + OnError /*onErr*/) { + onOk({}); // 后端未就绪 → 空列表 +} + +} // namespace geopro::data diff --git a/src/data/api/Api3dRepository.hpp b/src/data/api/Api3dRepository.hpp new file mode 100644 index 0000000..77e0ee5 --- /dev/null +++ b/src/data/api/Api3dRepository.hpp @@ -0,0 +1,167 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include + +#include "dto/Vtk3dRequests.hpp" +#include "geo/GeoLocalFrame.hpp" +#include "repo/I3dSceneRepository.hpp" +#include "repo/VolumeBuildParams.hpp" + +namespace geopro::core { +struct PointSet; // algo/IInterpolator.hpp(Grid/ColorScale 经 I3dSceneRepository.hpp 已可见) +} // namespace geopro::core + +namespace geopro::data { + +class IAsyncDatasetRepository; + +// 真实后端实现 I3dSceneRepository: +// loadSection(帘面) 走真实 ERT 反演端点 —— 复用 ApiDatasetRepository(loaderKey="inversion.grid"), +// 不重复网络层;命中 core::ContourPayload{grid, scale, anomalies},取 grid+scale 填 SectionData。 +// dimensionOf 同步纯函数(ddCode→维度,同 LocalSample3dRepository 映射)。 +// 三维体/地形/切片/异常/任务端点后端尚未就绪 → 暂 stub: +// - load 类(loadTree/loadRecords/loadTasks) 回调空,避免 UI 崩; +// - loadVolume/loadTerrainPaths 及一切 create/save/delete 变更 → 走 onErr("后端未就绪"), +// 给用户明确"未实现"而非假成功。 +class Api3dRepository : public I3dSceneRepository { +public: + // frame:全项目共享 GeoLocalFrame(与帘面/底图同一对象)——三维体散点按其 lat/lon→局部米 + // 配准,保证与帘面构造性对齐(含运行期 reanchor)。 + Api3dRepository(IAsyncDatasetRepository& dsRepo, std::shared_ptr frame); + + DsDimension dimensionOf(const DsRow& ds) const override; + bool isVolumeDataset(const std::string& dsId) const override; + + // ── 客户端创建三维体(mock 持久化:内存;端点就绪后换实现)────────────────── + // 登记新三维体(仅存参数,不立即插值)→ 返回新 dsId("vol-N")。插值在首次 loadVolume 惰性做并缓存。 + std::string createVolume(VolumeBuildParams params, const std::string& name); + // 请求体形态创建:组装真实 VoxelGenerateRequest → 派生 params 存储 + 打印请求体 JSON(供后端联调)。 + std::string createVolume(const VoxelGenerateRequest& req); + // GPR 三维体:走 io::gpr 逐线管线(含线内通道插值,§1)直接产体并【预填 cachedGrid】, + // 注册为 dd_voxel 体 → 自动进 volumeRows/三级树,loadVolume 直接命中渲染(不走 mock IDW)。 + // lineDir/linePrefix 同 build-line(如 "D:/Downloads/明星路","明星路_010");coarse 控内存。 + // 返回新 dsId;失败抛 std::runtime_error(加载/立方体空,由 io::gpr 链透传)。 + std::string createGprVolume(const std::string& lineDir, const std::string& linePrefix, + const std::string& name, int coarse = 8); + // 取回某三维体组装的请求体(测试/联调);非本 repo 创建或无 request 时返回 nullptr。 + const VoxelGenerateRequest* lastVoxelRequest(const std::string& dsId) const; + // 清空内存态三维体/切片/异常(切换项目时调;否则上个项目的体/切片/异常残留在新项目列表)。 + void clearMockData(); + // 已创建三维体的列表行(ddCode="dd_voxel"),供三维分析栏合并注入(每次 setDatasets 追加)。 + std::vector 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 sliceRows() const; + // 异常列表行(ddCode="dd_anomaly",parentId=remarkSourceId=归属实体[体/切片] dsId → 三级树自动挂载), + // 供三维体段「体→切片/异常」三级树合并注入(spec §8)。 + std::vector anomalyRows() const; + // 该 dsId 是否为已保存切片(3b:分析栏勾选 dd_slice 走切片重渲染路径,不进控制器帘面/体素路径)。 + bool isSliceDataset(const std::string& dsId) const; + // 该 dsId 是否为异常(dd_anomaly)——勾选异常不进帘面/体素渲染勾选,显隐另管。 + bool isAnomalyDataset(const std::string& dsId) const; + // 按 id 取异常(三级树双击详情用);不存在返回 false。 + bool anomalyById(const std::string& anomalyId, geopro::core::Anomaly& out) const; + // 取回已保存切片位姿(还原渲染用);不存在返回 false。 + bool sliceSpec(const std::string& dsId, SliceSpec& out) const; + + void loadVolume(const std::string& dsId, + std::function onOk, + OnError onErr) override; + void loadSection(const std::string& dsId, std::function onOk, + OnError onErr) override; + void loadMapLine(const std::string& dsId, std::function onOk, + OnError onErr) override; + void loadTerrainPaths(std::function onOk, OnError onErr) override; + + // 切片 CRUD(后端未就绪 → 变更走 onErr) + void createSlice(const SliceSpec& spec, const std::string& name, + std::function onOk, OnError onErr) override; + void saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function onOk, OnError onErr) override; + void deleteSlice(const std::string& dsId, + std::function onOk, OnError onErr) override; + + // 异常 / 异常体(后端未就绪 → load 回空树,变更走 onErr) + void loadAnomalyTree(const std::string& objectId, + std::function onOk, OnError onErr) override; + void saveAnomaly(const geopro::core::Anomaly& a, const std::string& screenshotPngPath, + std::function onOk, OnError onErr) override; + void deleteAnomaly(const std::string& anomalyId, + std::function onOk, OnError onErr) override; + void deleteAnomalyGroup(const std::string& bodyId, + std::function onOk, OnError onErr) override; + + // 任务管理(后端未就绪 → load 回空列表) + void loadTaskRecords(const std::string& dsId, + std::function)> onOk, + OnError onErr) override; + void loadUsableTasks(const std::string& ddCode, + std::function)> onOk, + OnError onErr) override; + +private: + // 把一条剖面 section grid 的有限值节点按 CurtainActor 同口径定位(lat/lon→frame.toLocal, + // 世界 Z=grid.y 高程)追加为 IDW 散点 → 保证体与帘面同系对齐。 + void appendGridPoints(const core::Grid& g, core::PointSet& pts) const; + // 源剖面带重试加载:瞬时失败(如后端 502 Bad Gateway)重试 attemptsLeft 次,避免一条源抖动 + // 就让整个三维体建不出来(表现为"连坐标轴都没有"的无声不渲染)。重试用尽才 onErr。 + void loadSectionWithRetry(const std::string& dsId, int attemptsLeft, + std::function onOk, OnError onErr); + // 多源散点聚合完成 → buildVolume + 值域 + 缓存明细/色阶 → onOk(体, 色阶)。 + void finalizeVolume(const std::string& dsId, const core::PointSet& pts, + const core::ColorScale& scale, const VolumeBuildParams& params, + std::function onOk, OnError onErr); + + IAsyncDatasetRepository& dsRepo_; + std::shared_ptr frame_; + + // 内存态三维体存储(mock;重启清空)。cachedGrid = 已插值明细(命中即跳过重算)。 + struct StoredVolume { + VolumeBuildParams params; + std::string name; + std::optional cachedGrid; + core::ColorScale cachedScale; // 与 cachedGrid 同时填(源剖面色阶) + std::optional pointCount; // 聚合散点数(finalizeVolume 时持久化,详情统计用) + std::optional request; // 组装的真实请求体(createVolume(req) 路径填充) + std::string createTime; // 创建时刻(mock,列表副标题/详情用) + }; + std::map volumes_; // dsId → 体 + int volumeCounter_ = 0; + + // 内存态切片存储(mock;重启清空)。切片保存后成 dd_slice 数据集,进三维分析栏。 + struct StoredSlice { + SliceSpec spec; + std::string name; + std::string createTime; // 创建时刻 + }; + std::map slices_; // dsId → 切片 + int sliceCounter_ = 0; + + // 内存态异常存储(mock;挂载实体 = a.remarkSourceId,体 or 切片)。异常体(consortium)分组用 a.consortiumId。 + struct StoredAnomaly { + geopro::core::Anomaly a; + std::string screenshotPath; + }; + std::map anomalies_; // anomalyId → 异常 + int anomalyCounter_ = 0; +}; + +} // namespace geopro::data diff --git a/src/data/api/ApiColorTemplateRepository.cpp b/src/data/api/ApiColorTemplateRepository.cpp new file mode 100644 index 0000000..c08e192 --- /dev/null +++ b/src/data/api/ApiColorTemplateRepository.cpp @@ -0,0 +1,115 @@ +#include "api/ApiColorTemplateRepository.hpp" + +#include + +#include +#include + +#include "ApiClient.hpp" +#include "IApiCall.hpp" + +namespace geopro::data { + +namespace { +// 失败判定与详情仓储一致:业务码 != 200 或传输错误(rawError 非空)。 +bool isOk(const net::ApiResponse& r) { return r.code == 200 && r.rawError.isEmpty(); } +} // namespace + +ApiColorTemplateRepository::ApiColorTemplateRepository(net::ApiClient& api) : api_(api) {} + +void ApiColorTemplateRepository::saveLvlTemplate(const QString& projectId, + const QString& templateName, + const QJsonObject& properties, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("templateName"), templateName}, + {QStringLiteral("properties"), properties}}; + auto* call = api_.postJsonAsync(QStringLiteral("/business/lvlTemplate"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +void ApiColorTemplateRepository::updateLvlTemplate(const QString& id, + const QString& templateName, + const QJsonObject& properties, + std::function cb) { + QJsonObject body{{QStringLiteral("id"), id}, + {QStringLiteral("templateName"), templateName}, + {QStringLiteral("properties"), properties}}; + auto* call = api_.putJsonAsync(QStringLiteral("/business/lvlTemplate"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +void ApiColorTemplateRepository::listLvlTemplates( + const QString& projectId, std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("pageNo"), 1}, + {QStringLiteral("pageSize"), 1000}}; + auto* call = api_.postJsonAsync(QStringLiteral("/business/lvlTemplate/page"), body); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // page 型:data 为对象,列表在 data.list。 + cb(true, resp.data.value(QStringLiteral("list")).toArray(), resp.msg); + }); +} + +void ApiColorTemplateRepository::newClrScheme(const QString& projectId, + const QJsonObject& properties, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("properties"), properties}}; + auto* call = api_.postJsonAsync(QStringLiteral("/business/clr/colorGradation"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +void ApiColorTemplateRepository::listClrSchemes( + const QString& projectId, std::function cb) { + auto* call = api_.getAsync( + QStringLiteral("/business/clr/colorGradation/queryCLRColorGradation/%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(projectId)))); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // clr 查询:data 顶层为数组,buildResponse 包成 {"value": [...]}。 + cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg); + }); +} + +} // namespace geopro::data diff --git a/src/data/api/ApiColorTemplateRepository.hpp b/src/data/api/ApiColorTemplateRepository.hpp new file mode 100644 index 0000000..9f047c6 --- /dev/null +++ b/src/data/api/ApiColorTemplateRepository.hpp @@ -0,0 +1,32 @@ +#pragma once +#include "repo/IColorTemplateRepository.hpp" + +namespace geopro::net { class ApiClient; } + +namespace geopro::data { + +// IColorTemplateRepository 的真实 API 实现(lvl 模板 + clr 色阶)。 +// 持 ApiClient&(共享会话),每个方法组装 body → 发请求 → 连 IApiCall::finished 回调; +// 句柄自管理(完成后 deleteLater),不手动 delete。 +class ApiColorTemplateRepository : public IColorTemplateRepository { +public: + explicit ApiColorTemplateRepository(net::ApiClient& api); + + void saveLvlTemplate(const QString& projectId, const QString& templateName, + const QJsonObject& properties, + std::function cb) override; + void updateLvlTemplate(const QString& id, const QString& templateName, + const QJsonObject& properties, + std::function cb) override; + void listLvlTemplates(const QString& projectId, + std::function cb) override; + void newClrScheme(const QString& projectId, const QJsonObject& properties, + std::function cb) override; + void listClrSchemes(const QString& projectId, + std::function cb) override; + +private: + net::ApiClient& api_; +}; + +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetCommandRepository.cpp b/src/data/api/ApiDatasetCommandRepository.cpp new file mode 100644 index 0000000..d0edbb6 --- /dev/null +++ b/src/data/api/ApiDatasetCommandRepository.cpp @@ -0,0 +1,466 @@ +#include "api/ApiDatasetCommandRepository.hpp" + +#include + +#include + +#include "ApiBatch.hpp" +#include "ApiClient.hpp" +#include "IApiCall.hpp" +#include "dto/MeasurementDto.hpp" // parseMeasurementScatter(V 值重载复用初始加载解析) +#include "dto/DatasetChartDto.hpp" // parseInversionGrid/parseColorBar/parseDatasetAnomalies(网格重载) + +namespace geopro::data { + +namespace { +// 失败判定与色阶/详情仓储一致:业务码 != 200 或传输错误(rawError 非空)。 +bool isOk(const net::ApiResponse& r) { return r.code == 200 && r.rawError.isEmpty(); } + +// URL 路径段编码(动态 id),与现有 enc 用法一致。 +QString enc(const QString& s) { return QString::fromUtf8(QUrl::toPercentEncoding(s)); } + +// 三类回调骨架:统一「句柄判空 → connect → finished 取值」,消除样板重复。 + +// 仅状态:(bool ok, QString msg)。 +void wireStatus(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +// 返回数组:(bool ok, QJsonArray list, QString msg)。顶层数组被 buildResponse 包成 data.value。 +void wireArray(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg); + }); +} + +// 返回字符串:(bool ok, QString value, QString msg)。 +// 原版 res.data 为纯字符串时,parseBody 会包成 {"value": ""},故取 data.value。 +void wireString(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data.value(QStringLiteral("value")).toString(), resp.msg); + }); +} + +// 返回对象:(bool ok, QJsonObject data, QString msg)。 +void wireObject(net::IApiCall* call, std::function cb) { + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + cb(true, resp.data, resp.msg); + }); +} +} // namespace + +ApiDatasetCommandRepository::ApiDatasetCommandRepository(net::ApiClient& api) : api_(api) {} + +void ApiDatasetCommandRepository::listInversionScripts( + const QString& dsObjectId, std::function cb) { + auto* call = api_.getAsync( + QStringLiteral("/business/outerInversion/query/script?dsObjectId=%1") + .arg(QString::fromUtf8(QUrl::toPercentEncoding(dsObjectId)))); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // 模型列表:data 顶层为数组,buildResponse 包成 {"value": [...]}。 + cb(true, resp.data.value(QStringLiteral("value")).toArray(), resp.msg); + }); +} + +void ApiDatasetCommandRepository::getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("type"), 6}, + {QStringLiteral("typeId"), typeId}}; + auto* call = api_.postJsonAsync(QStringLiteral("/business/project/getDynamicForm"), body); + if (call == nullptr) { + if (cb) cb(false, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, resp.msg); + return; + } + // 动态表单:data 为对象(含 formList 数组)。 + cb(true, resp.data, resp.msg); + }); +} + +void ApiDatasetCommandRepository::submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) { + QJsonObject body{{QStringLiteral("dsId"), dsId}, + {QStringLiteral("scriptId"), scriptId}, + {QStringLiteral("properties"), properties}}; + auto* call = + api_.postJsonAsync(QStringLiteral("/business/outerInversion/submitInversionTask"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +void ApiDatasetCommandRepository::createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, const QJsonObject& scriptParamListJsonStr, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("scriptId"), scriptId}, + {QStringLiteral("scriptParamListJsonStr"), scriptParamListJsonStr}}; + auto* call = api_.postJsonAsync( + QStringLiteral("/business/dd/ert/measurement/createVisualResistivityData"), body); + if (call == nullptr) { + if (cb) cb(false, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (cb) cb(isOk(resp), resp.msg); + }); +} + +// ============================ measurement 散点相关 ============================ + +void ApiDatasetCommandRepository::saveDisplayStatus(const QString& dsObjectId, + const QJsonArray& ids, int status, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("ids"), ids}, + {QStringLiteral("status"), status}}; + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/measurement/saveDisplayStatus"), + body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) { + wireObject( + api_.getAsync(QStringLiteral( + "/business/scatterPlotDataFilter/getDataFilterConfig?dsObjectId=%1&vFieldCode=%2") + .arg(enc(dsObjectId), enc(vFieldCode))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::applyScatterFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/scatterPlotDataFilter"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::saveRawData(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/measurement/saveRawData"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) { + auto* call = api_.getAsync( + QStringLiteral("/business/dd/ert/measurement/rs2d/" + "export?dsId=%1&electrodePosition=%2&ipDataMark=%3&typeMeasurement=%4") + .arg(enc(dsId)) + .arg(electrodePosition) + .arg(ipDataMark) + .arg(typeMeasurement)); + if (call == nullptr) { + if (cb) cb(false, {}, {}, QStringLiteral("请求创建失败")); + return; + } + QObject::connect(call, &net::IApiCall::finished, call, + [cb = std::move(cb)](const net::ApiResponse& resp) { + if (!cb) return; + if (!isOk(resp)) { + cb(false, {}, {}, resp.msg); + return; + } + // 导出:data 含 base64 fileData 与 fileName,落盘交由 UI 层。 + cb(true, resp.data.value(QStringLiteral("fileName")).toString(), + resp.data.value(QStringLiteral("fileData")).toString(), resp.msg); + }); +} + +void ApiDatasetCommandRepository::loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) { + // 并发拉 scatter/graph(带 vFieldCode) + 色阶 getDetail(type3, businessCode=vFieldCode)。 + // 与 ApiDatasetRepository::measurementScatterBatch 同端点/同解析(仅 vFieldCode 可变)。 + QList calls{ + api_.getAsync( + QStringLiteral("/business/dd/ert/measurement/scatter/graph?dsObjectId=%1&vFieldCode=%2") + .arg(enc(dsObjectId), enc(vFieldCode))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), + QJsonObject{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("businessCode"), vFieldCode}, + {QStringLiteral("type"), 3}}), + }; + auto* batch = new net::ApiBatch(calls, [](const net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, + [cb](const QList& r) { + if (cb) cb(true, dto::parseMeasurementScatter(r[0].data, r[1].data), {}); + }); + QObject::connect(batch, &net::ApiBatch::failed, batch, + [cb](int, const net::ApiResponse& resp) { + if (cb) cb(false, {}, resp.msg); + }); + // ApiBatch 完成/失败后各 call 自行 deleteLater;batch 本身随末次信号后 deleteLater。 + QObject::connect(batch, &net::ApiBatch::succeeded, batch, &QObject::deleteLater); + QObject::connect(batch, &net::ApiBatch::failed, batch, &QObject::deleteLater); +} + +// ============================ 色阶(lvl)相关 ============================ + +void ApiDatasetCommandRepository::saveColorGradation(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation"), body), + std::move(cb)); +} + +// ============================ inversion 相关 ============================ + +void ApiDatasetCommandRepository::saveInversionAsData(const QString& dsObjectId, + const QString& name, + std::function cb) { + QJsonObject body{{QStringLiteral("dsObjectId"), dsObjectId}, {QStringLiteral("name"), name}}; + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/saveAsData"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::loadInversionGrid( + const QString& dsObjectId, + std::function cb) { + // 与 ApiDatasetRepository::inversionGridBatch 同端点/同解析:rows(慢) + 色阶 type2 + 异常。 + QList calls{ + api_.getAsync(QStringLiteral("/business/dd/ert/inversion/rows/%1").arg(enc(dsObjectId))), + api_.postJsonAsync(QStringLiteral("/business/lvl/colorGradation/getDetail"), + QJsonObject{{QStringLiteral("dsObjectId"), dsObjectId}, + {QStringLiteral("businessCode"), QString()}, + {QStringLiteral("type"), 2}}), + api_.getAsync( + QStringLiteral("/business/exception/queryException/%1").arg(enc(dsObjectId))), + }; + auto* batch = new net::ApiBatch(calls, [](const net::ApiResponse& r) { + return r.code != 200 || !r.rawError.isEmpty(); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, + [cb](const QList& r) { + if (!cb) return; + geopro::core::ContourPayload p{ + dto::parseInversionGrid(r[0].data), + dto::parseColorBar(r[1].data), + dto::parseDatasetAnomalies( + r[2].data.value(QStringLiteral("value")).toArray())}; + // 顶层 templateId(对照原版 lvlTemplateId;色阶保存/覆盖回带)。 + p.templateId = + r[1].data.value(QStringLiteral("templateId")).toVariant().toString(); + cb(true, p, {}); + }); + QObject::connect(batch, &net::ApiBatch::failed, batch, + [cb](int, const net::ApiResponse& resp) { + if (cb) cb(false, {}, resp.msg); + }); + QObject::connect(batch, &net::ApiBatch::succeeded, batch, &QObject::deleteLater); + QObject::connect(batch, &net::ApiBatch::failed, batch, &QObject::deleteLater); +} + +void ApiDatasetCommandRepository::listGridAlgorithm( + const QString& dsObjectId, std::function cb) { + wireArray(api_.getAsync(QStringLiteral("/business/dd/ert/inversion/queryAlgorithmModel/%1") + .arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getGridRawDataParams( + const QString& dsObjectId, std::function cb) { + wireObject(api_.getAsync(QStringLiteral("/business/dd/ert/inversion/getRawData/%1") + .arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::toGrid(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/grid"), body), + std::move(cb)); +} + +// ============================ 白化相关 ============================ + +void ApiDatasetCommandRepository::listWhitenedData( + const QString& projectId, const QString& tmObjectId, + std::function cb) { + QJsonObject body{{QStringLiteral("projectId"), projectId}, + {QStringLiteral("tmObjectId"), tmObjectId}}; + wireArray(api_.postJsonAsync(QStringLiteral("/business/dsObject/queryWhitenedDataList"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::whitenData(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/whitenedData"), body), + std::move(cb)); +} + +// ============================ 滤波相关 ============================ + +void ApiDatasetCommandRepository::listFilters(std::function cb) { + wireArray(api_.getAsync(QStringLiteral("/business/filter/queryFilter")), std::move(cb)); +} + +void ApiDatasetCommandRepository::newFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/filter"), body), std::move(cb)); +} + +void ApiDatasetCommandRepository::deleteFilter(const QString& id, + std::function cb) { + wireStatus(api_.deleteAsync(QStringLiteral("/business/filter/delete/%1").arg(enc(id))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::applyFilter(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/dd/ert/inversion/filterData"), body), + std::move(cb)); +} + +// ============================ 异常 CRUD ============================ + +void ApiDatasetCommandRepository::listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) { + wireArray(api_.getAsync( + QStringLiteral( + "/business/exceptionType/queryExceptionTypeByProjectIdAndType/%1/%2") + .arg(enc(projectId), enc(remarkSourceType))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::getExceptionTypeDetail( + const QString& exceptionTypeId, std::function cb) { + wireObject(api_.getAsync(QStringLiteral("/business/exceptionType/getDetail/%1") + .arg(enc(exceptionTypeId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::listArrayTypes( + std::function cb) { + wireArray(api_.getAsync(QStringLiteral("/business/script/arrayTypeList")), std::move(cb)); +} + +void ApiDatasetCommandRepository::getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) { + QJsonObject body{{QStringLiteral("exceptionTypeId"), exceptionTypeId}, + {QStringLiteral("remarkSourceId"), remarkSourceId}}; + // 原版 res.data 直接是名称字符串,回传纯字符串。 + wireString(api_.postJsonAsync(QStringLiteral("/business/exception/getExceptionName"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::newException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/exception"), body), std::move(cb)); +} + +void ApiDatasetCommandRepository::addExceptionType(const QJsonObject& body, + std::function cb) { + // 对照原版 addExceptionType(apis/toolComponent/exceptionType.js:9):POST /business/exceptionType。 + // body 由调用方(ExceptionTypeDialog 双 Tab)完整组装,仓储仅做传输职责。 + wireStatus(api_.postJsonAsync(QStringLiteral("/business/exceptionType"), body), std::move(cb)); +} + +void ApiDatasetCommandRepository::deleteException(const QString& id, + std::function cb) { + wireStatus(api_.deleteAsync(QStringLiteral("/business/exception/%1").arg(enc(id))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::updateException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.putJsonAsync(QStringLiteral("/business/exception"), body), std::move(cb)); +} + +// ============================ 自动标注 ============================ + +void ApiDatasetCommandRepository::executeExceptionMark( + const QJsonObject& body, std::function cb) { + wireObject( + api_.postJsonAsync(QStringLiteral("/business/exception/exception-mark/execute"), body), + std::move(cb)); +} + +void ApiDatasetCommandRepository::batchCreateException(const QJsonObject& body, + std::function cb) { + wireStatus(api_.postJsonAsync(QStringLiteral("/business/exception/batch/create"), body), + std::move(cb)); +} + +// ============================ 描述 / dsObject ============================ + +void ApiDatasetCommandRepository::getDsObjectDetail( + const QString& dsObjectId, std::function cb) { + wireObject( + api_.getAsync(QStringLiteral("/business/dsObject/getDetail/%1").arg(enc(dsObjectId))), + std::move(cb)); +} + +void ApiDatasetCommandRepository::updateDsObject(const QJsonObject& body, + std::function cb) { + // 原版 URL 末尾带斜杠(updateProfileInversionDescription)。 + wireStatus(api_.putJsonAsync(QStringLiteral("/business/dsObject/updateDsObject/"), body), + std::move(cb)); +} + +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetCommandRepository.hpp b/src/data/api/ApiDatasetCommandRepository.hpp new file mode 100644 index 0000000..fcde77a --- /dev/null +++ b/src/data/api/ApiDatasetCommandRepository.hpp @@ -0,0 +1,118 @@ +#pragma once +#include "repo/IDatasetCommandRepository.hpp" + +namespace geopro::net { class ApiClient; } + +namespace geopro::data { + +// IDatasetCommandRepository 的真实 API 实现(反演相关写操作)。 +// 持 ApiClient&(共享会话),每个方法组装 body → 发请求 → 连 IApiCall::finished 回调; +// 句柄自管理(完成后 deleteLater),不手动 delete。与 ApiColorTemplateRepository 同模式。 +class ApiDatasetCommandRepository : public IDatasetCommandRepository { +public: + explicit ApiDatasetCommandRepository(net::ApiClient& api); + + void listInversionScripts( + const QString& dsObjectId, + std::function cb) override; + void getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) override; + void submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) override; + void createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, + const QJsonObject& scriptParamListJsonStr, + std::function cb) override; + + // measurement 散点 + void saveDisplayStatus(const QString& dsObjectId, const QJsonArray& ids, int status, + std::function cb) override; + void getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) override; + void applyScatterFilter(const QJsonObject& body, + std::function cb) override; + void saveRawData(const QJsonObject& body, + std::function cb) override; + void exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) override; + void loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) override; + + // 色阶 + void saveColorGradation(const QJsonObject& body, + std::function cb) override; + + // inversion + void saveInversionAsData(const QString& dsObjectId, const QString& name, + std::function cb) override; + void loadInversionGrid( + const QString& dsObjectId, + std::function cb) override; + void listGridAlgorithm( + const QString& dsObjectId, + std::function cb) override; + void getGridRawDataParams( + const QString& dsObjectId, + std::function cb) override; + void toGrid(const QJsonObject& body, + std::function cb) override; + + // 白化 + void listWhitenedData(const QString& projectId, const QString& tmObjectId, + std::function cb) override; + void whitenData(const QJsonObject& body, + std::function cb) override; + + // 滤波 + void listFilters(std::function cb) override; + void newFilter(const QJsonObject& body, + std::function cb) override; + void deleteFilter(const QString& id, + std::function cb) override; + void applyFilter(const QJsonObject& body, + std::function cb) override; + + // 异常 CRUD + void listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) override; + void getExceptionTypeDetail( + const QString& exceptionTypeId, + std::function cb) override; + void listArrayTypes(std::function cb) override; + void getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) override; + void newException(const QJsonObject& body, + std::function cb) override; + void addExceptionType(const QJsonObject& body, + std::function cb) override; + void deleteException(const QString& id, + std::function cb) override; + void updateException(const QJsonObject& body, + std::function cb) override; + + // 自动标注 + void executeExceptionMark( + const QJsonObject& body, + std::function cb) override; + void batchCreateException(const QJsonObject& body, + std::function cb) override; + + // 描述 / dsObject + void getDsObjectDetail( + const QString& dsObjectId, + std::function cb) override; + void updateDsObject(const QJsonObject& body, + std::function cb) override; + +private: + net::ApiClient& api_; +}; + +} // namespace geopro::data diff --git a/src/data/api/ApiDatasetRepository.cpp b/src/data/api/ApiDatasetRepository.cpp index ee4472d..a1f75e0 100644 --- a/src/data/api/ApiDatasetRepository.cpp +++ b/src/data/api/ApiDatasetRepository.cpp @@ -27,12 +27,14 @@ QString enc(const std::string& s) { struct ChartParts { geopro::core::ScatterField scatter; geopro::core::ColorScale scatterScale; + QString templateId; // 散点色阶模板 id(保存色阶回带,对照原版 lvlTemplateId) }; // 网格数据加载结果:grid(rows) + 网格色阶(type2) + 异常。 struct GridParts { geopro::core::Grid grid{1, 1}; // Grid 无默认构造;占位 geopro::core::ColorScale gridScale; std::vector anomalies; + QString templateId; // 网格色阶模板 id(保存/覆盖回带,对照原版 lvlTemplateId) }; // 失败判定(原 must() 口径):业务码 != 200 或传输错误。 @@ -58,6 +60,7 @@ ChartParts parseScatterParts(const QList& r) { ChartParts p; p.scatter = dto::parseScatterGraph(r[0].data); p.scatterScale = dto::parseColorBar(r[1].data); + p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString(); return p; } @@ -76,6 +79,8 @@ GridParts parseGridParts(const QList& r) { p.grid = dto::parseInversionGrid(r[0].data); p.gridScale = dto::parseColorBar(r[1].data); p.anomalies = dto::parseDatasetAnomalies(r[2].data.value(QStringLiteral("value")).toArray()); + // 顶层 templateId(对照原版 lvlConfig?.templateId,与散点 parseScatterParts 同范式)。 + p.templateId = r[1].data.value(QStringLiteral("templateId")).toVariant().toString(); return p; } @@ -168,14 +173,18 @@ DetailLoad* ApiDatasetRepository::makeInversionScatter(const std::string& dsId) // 复用同一批次 + 解析器,再映射为 ScatterPayload(不复制 JSON 解析逻辑)。 return new ApiDetailLoad(inversionScatterBatch(api_, dsId), [](const QList& r) { ChartParts p = parseScatterParts(r); - return QVariant::fromValue(core::ScatterPayload{p.scatter, p.scatterScale}); + core::ScatterPayload payload{p.scatter, p.scatterScale}; + payload.templateId = p.templateId; // 色阶保存回带(对照原版 lvlTemplateId) + return QVariant::fromValue(payload); }); } DetailLoad* ApiDatasetRepository::makeInversionGrid(const std::string& dsId) { return new ApiDetailLoad(inversionGridBatch(api_, dsId), [](const QList& r) { GridParts p = parseGridParts(r); - return QVariant::fromValue(core::ContourPayload{p.grid, p.gridScale, p.anomalies}); + core::ContourPayload payload{p.grid, p.gridScale, p.anomalies}; + payload.templateId = p.templateId; // 色阶保存/覆盖回带(对照原版 lvlTemplateId) + return QVariant::fromValue(payload); }); } diff --git a/src/data/dto/DatasetChartDto.cpp b/src/data/dto/DatasetChartDto.cpp index 5bc6ead..53120c7 100644 --- a/src/data/dto/DatasetChartDto.cpp +++ b/src/data/dto/DatasetChartDto.cpp @@ -19,6 +19,10 @@ Grid parseInversionGrid(const QJsonObject& data) { Grid g(nx < 1 ? 1 : nx, ny < 1 ? 1 : ny); g.x.clear(); for (auto e : x) g.x.push_back(num(e)); g.y.clear(); for (auto e : y) g.y.push_back(num(e)); + // 经纬度(每列 [nx]):供 CurtainActor 经 GeoLocalFrame 把每列摆到真实测线位置—— + // 弯曲测线 → 曲面帘面(不解析则 lat/lon 空、退化成 y=0 平面)。API 字段 lat/lon。 + g.lat.clear(); for (auto e : data.value("lat").toArray()) g.lat.push_back(num(e)); + g.lon.clear(); for (auto e : data.value("lon").toArray()) g.lon.push_back(num(e)); if (v.size() != ny) // 服务端 v 行数与 y 不符:下方越界处填 NaN,记录便于排查(非静默)。 qWarning("parseInversionGrid: v rows=%d != ny=%d (缺失行将填 NaN)", v.size(), ny); for (int j = 0; j < ny; ++j) { @@ -61,19 +65,45 @@ std::vector parseDatasetAnomalies(const QJsonArray& arr) { for (auto e : arr) { const QJsonObject o = e.toObject(); Anomaly a; + a.id = o.value("id").toString().toStdString(); // 删除/更新/定位需要持久化 id a.name = o.value("exceptionName").toString().toStdString(); a.typeName = o.value("exceptionTypeName").toString().toStdString(); - const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 - a.markType = (mt >= 1 && mt <= 3) ? static_cast(mt) + a.exceptionTypeId = o.value("exceptionTypeId").toString().toStdString(); + a.remark = o.value("remark").toString().toStdString(); + a.createTime = o.value("createTime").toString().toStdString(); + const int mt = o.value("exceptionMarkType").toInt(2); // 1=点 2=线 3=面 4=文字 + a.markType = (mt >= 1 && mt <= 4) ? static_cast(mt) : AnomalyMarkType::Polyline; // 越界值兜底为线 const QJsonObject lg = o.value("legend").toObject(); a.lineColor = lg.value("polylineColor").toString("#000000").toStdString(); a.lineWidth = lg.value("polylineWidth").toDouble(1.0); a.dashed = lg.value("polylineShape").toString() == "dash"; + // 文字标注 customLegend(详情只读展示用):content/color/size/opacity。 + const QJsonObject cl = o.value("customLegend").toObject(); + if (!cl.isEmpty()) { + QString content = cl.value("content").toString(); + if (content.isEmpty()) content = cl.value("text").toString(); + a.textContent = content.toStdString(); + a.textColor = cl.value("color").toString("#000000").toStdString(); + a.textSize = cl.value("size").toInt(12); + a.textOpacity = cl.value("opacity").toDouble(1.0); + } for (auto c : o.value("location").toObject().value("coordinate").toArray()) { const QJsonObject p = c.toObject(); a.localPts.push_back(Vec2{p.value("x").toDouble(), p.value("y").toDouble()}); } + // 经纬度坐标(对照原版:latitudeLongitude.latLon[].{longitude,latitude})。 + // 纯展示,不换算;空/缺失 → 详情坐标系下拉只给「图形坐标」(与原版 latLon.length===0 一致)。 + for (auto c : o.value("latitudeLongitude").toObject().value("latLon").toArray()) { + const QJsonObject p = c.toObject(); + a.lonLatPts.push_back(Vec2{p.value("longitude").toDouble(), p.value("latitude").toDouble()}); + } + // 投影坐标(对照原版:geographicalCoordinates.coordinates[].{northCoord,eastCoord}, + // 原版映射 x=northCoord、y=eastCoord)。 + for (auto c : o.value("geographicalCoordinates").toObject().value("coordinates").toArray()) { + const QJsonObject p = c.toObject(); + a.eastNorthPts.push_back(Vec2{p.value("northCoord").toDouble(), p.value("eastCoord").toDouble()}); + } out.push_back(std::move(a)); } return out; diff --git a/src/data/dto/GridDto.cpp b/src/data/dto/GridDto.cpp index 1187a12..4ea88bd 100644 --- a/src/data/dto/GridDto.cpp +++ b/src/data/dto/GridDto.cpp @@ -1,5 +1,6 @@ #include "dto/GridDto.hpp" +#include #include #include "dto/TrajectoryDto.hpp" // parseGridHeaderTable(通用 gridHeaderDisplay+rowList 解析器)复用 @@ -28,6 +29,18 @@ TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize) { t.total = data.value(QStringLiteral("total")).toInt(t.total); t.pageNo = pn; t.pageSize = ps; + + // 功能按钮:data.functionList(DDGridFunctionVO[])→ 列表上方功能按钮行(原版 functionButtons)。 + // 仅 dd_grid 携带;缺省/空数组 → 不渲染工具条。enable 透传(视图按 v-show 语义过滤)。 + const QJsonArray fns = data.value(QStringLiteral("functionList")).toArray(); + for (const auto& v : fns) { + const QJsonObject o = v.toObject(); + TableFunctionButton b; + b.code = o.value(QStringLiteral("functionCode")).toString(); + b.nameChn = o.value(QStringLiteral("functionNameChn")).toString(); + b.enable = o.value(QStringLiteral("enable")).toBool(true); + t.functionButtons.push_back(std::move(b)); + } return t; } diff --git a/src/data/dto/GridDto.hpp b/src/data/dto/GridDto.hpp index 0728d6d..1519298 100644 --- a/src/data/dto/GridDto.hpp +++ b/src/data/dto/GridDto.hpp @@ -13,6 +13,7 @@ namespace geopro::data::dto { // 复用通用 parseGridHeaderTable(gridHeaderDisplay→x/y 列 + rowList→行),再前插「序号」列 // (vxe seq 列:按页偏移自增 = (pageNo-1)*pageSize + 行内序号);total 取 data.total(非 __rowTotal); // 回填 pageNo/pageSize 供视图渲染分页器。pageNo/pageSize 为本次请求参数(仓储已解析默认值后传入)。 +// 另解析 data.functionList(DDGridFunctionVO[])→ functionButtons(驱动列表上方功能按钮行,含「反演」)。 geopro::core::TablePayload parseGridTable(const QJsonObject& data, int pageNo, int pageSize); } // namespace geopro::data::dto diff --git a/src/data/dto/MeasurementDto.cpp b/src/data/dto/MeasurementDto.cpp index d206c02..0173dc7 100644 --- a/src/data/dto/MeasurementDto.cpp +++ b/src/data/dto/MeasurementDto.cpp @@ -13,12 +13,15 @@ namespace { constexpr int kColHorizontalDistance = 0; // x 备选:平距 constexpr int kColSlopeDistance = 1; // x:斜距(默认) constexpr int kColPseudoDepth = 3; // y:伪深度(负,向下,默认) +constexpr int kColVisible = 7; // 可见性:0=显示 1=隐藏(displayStatus) +constexpr int kColId = 8; // 点 id(saveDisplayStatus ids[]) constexpr int kColElevationPseudoDepth = 9; // y 备选:伪深度+高程 constexpr int kColA = 10; constexpr int kColB = 11; constexpr int kColM = 12; constexpr int kColN = 13; -constexpr int kColValue = 16; // 色值:选中 v(默认视电阻率 R0) +constexpr int kColRow = 15; // DataRow([i]信息面板) +constexpr int kColValue = 16; // 色值/Pseu_Resis:选中 v(默认视电阻率 R0) double numAt(const QJsonArray& arr, int i) { if (i < 0 || i >= arr.size()) return 0.0; @@ -28,6 +31,23 @@ double numAt(const QJsonArray& arr, int i) { return 0.0; } +// 取字符串列(id 为字符串形态,如 "1453611521843200";数字形态退回字符串表示)。 +QString strAt(const QJsonArray& arr, int i) { + if (i < 0 || i >= arr.size()) return QString(); + const QJsonValue v = arr.at(i); + if (v.isString()) return v.toString(); + if (v.isDouble()) return QString::number(v.toDouble(), 'f', 0); + return QString(); +} + +// 取对象里的字符串字段(id 可能为字符串或数字形态,统一转字符串)。 +QString objStr(const QJsonObject& obj, const QString& key) { + const QJsonValue v = obj.value(key); + if (v.isString()) return v.toString(); + if (v.isDouble()) return QString::number(v.toDouble(), 'f', 0); + return QString(); +} + // 把 JSON 值预格式化为单元格 QString(数字按原值,null/缺省→空串)。 QString cellText(const QJsonValue& v) { if (v.isDouble()) { @@ -75,6 +95,11 @@ ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, const QJs s.b.push_back(numAt(row, kColB)); s.m.push_back(numAt(row, kColM)); s.n.push_back(numAt(row, kColN)); + // [i]信息 / 显隐持久化用元数据(与上面 push 同序、一一对应)。 + s.id.push_back(strAt(row, kColId).toStdString()); + s.displayStatus.push_back(static_cast(numAt(row, kColVisible))); + s.row.push_back(numAt(row, kColRow)); + s.pseu.push_back(numAt(row, kColValue)); // x/y 下拉本地重绘备选列(与上面 push 同序、一一对应 → 视图换 x/y 无需再请求)。 p.altXHorizontal.push_back(numAt(row, kColHorizontalDistance)); p.altXSlope.push_back(numAt(row, kColSlopeDistance)); @@ -105,6 +130,8 @@ ScatterPayload parseMeasurementScatter(const QJsonObject& scatterData, const QJs } p.scale = parseColorBar(colorBarData); // 复用既有混合格式解析器(AlphaScale::Unit) + // 色阶模板 id(保存色阶时回带,对照原版 lvlTemplateId = lvlConfig?.templateId)。 + p.templateId = objStr(colorBarData, QStringLiteral("templateId")); return p; } @@ -132,6 +159,8 @@ TablePayload parseMeasurementTable(const QJsonObject& data) { toggle.title = QStringLiteral("隐藏/显示"); toggle.kind = TableColumnKind::Toggle; t.columns.push_back(toggle); + // M2:仅 measurement 列表的 Toggle 列可交互(载荷驱动门控,其余复用视图保持只读)。 + t.toggleInteractive = true; } // 行:每格按列码先查 vmap,再查行对象顶层。 @@ -152,6 +181,8 @@ TablePayload parseMeasurementTable(const QJsonObject& data) { cells.push_back(cellText(v)); } t.rows.push_back(std::move(cells)); + // M2:每行点 id(saveDisplayStatus ids[] 用);仅 measurement(hasToggleCol) 填充。 + if (hasToggleCol) t.rowIds.push_back(objStr(obj, QStringLiteral("id"))); } // 总数(分页用):measurement 用 __rowListTotal,回退到本批行数。 diff --git a/src/data/dto/NavDto.cpp b/src/data/dto/NavDto.cpp index 47626ef..7c8ef38 100644 --- a/src/data/dto/NavDto.cpp +++ b/src/data/dto/NavDto.cpp @@ -127,6 +127,34 @@ std::vector parseDsRows(const QJsonArray& arr) { // 数据集树父节点:sourceShowParentId 是“显示树”父(=派生数据挂源数据下),回退 parentId。 d.parentId = str(o, "sourceShowParentId"); if (d.parentId.empty()) d.parentId = str(o, "parentId"); + d.dsTypeCode = str(o, "dsTypeCode"); + d.structParentId = str(o, "structParentId"); + d.structParentConfType = o.value(QStringLiteral("structParentConfType")).toInt(); + // properties 后端是泛型 JSON:可能是数组 [{confFieldId,value}] 或对象 {confFieldId:value}。两种都解析 + // (历史只 toArray()→对象形态时丢空,导致装置/采集时间取不到,装置下拉空,用户实测)。 + const QJsonValue propsV = o.value(QStringLiteral("properties")); + if (propsV.isArray()) { + const QJsonArray props = propsV.toArray(); + d.properties.reserve(static_cast(props.size())); + for (const QJsonValue& pv : props) { + const QJsonObject po = pv.toObject(); + d.properties.push_back({str(po, "confFieldId"), + po.value(QStringLiteral("value")).toVariant().toString().toStdString()}); + } + } else if (propsV.isObject()) { + const QJsonObject po = propsV.toObject(); + for (auto it = po.begin(); it != po.end(); ++it) + d.properties.push_back( + {it.key().toStdString(), it.value().toVariant().toString().toStdString()}); + } + // 装置类型也可能是顶层字段(arrayType 代码 / arrayTypeName 中文名)→ 同时收为属性,兜底供筛选匹配。 + if (o.contains(QStringLiteral("arrayType"))) + d.properties.push_back( + {"arrayType", o.value(QStringLiteral("arrayType")).toVariant().toString().toStdString()}); + if (o.contains(QStringLiteral("arrayTypeName"))) + d.properties.push_back( + {"arrayTypeName", + o.value(QStringLiteral("arrayTypeName")).toVariant().toString().toStdString()}); const QJsonObject f = o.value(QStringLiteral("file")).toObject(); d.fileName = str(f, "name"); d.fileUrl = str(f, "url"); diff --git a/src/data/dto/Vtk3dRequests.cpp b/src/data/dto/Vtk3dRequests.cpp new file mode 100644 index 0000000..840cc31 --- /dev/null +++ b/src/data/dto/Vtk3dRequests.cpp @@ -0,0 +1,55 @@ +#include "dto/Vtk3dRequests.hpp" +#include +#include + +namespace geopro::data { + +namespace { +QJsonArray vec3(const std::array& v) { + return QJsonArray{v[0], v[1], v[2]}; +} +QString qs(const std::string& s) { return QString::fromStdString(s); } +} // namespace + +QJsonObject VoxelGenerateRequest::toJson() const { + QJsonArray ids; + for (const auto& s : sourceDatasetIds) ids.append(qs(s)); + QJsonObject o{ + {"projectId", qs(projectId)}, + {"structParentId", qs(structParentId)}, + {"structParentConfType", structParentConfType}, + {"name", qs(name)}, + {"sourceDatasetIds", ids}, + {"interpModel", qs(interpModel)}, + {"cellXY", cellXY}, {"cellZ", cellZ}, {"power", power}, {"maxDist", maxDist}, + }; + if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); + return o; +} + +VolumeBuildParams fromRequest(const VoxelGenerateRequest& req) { + VolumeBuildParams p; + p.sourceDatasetIds = req.sourceDatasetIds; + p.interpModel = (req.interpModel == "Kriging") ? VolumeBuildParams::Model::Kriging + : VolumeBuildParams::Model::Idw; + p.cellXY = req.cellXY; + p.cellZ = req.cellZ; + p.power = req.power; + p.maxDist = req.maxDist; + p.colorScaleId = req.colorScaleId; + return p; +} + +QJsonObject SliceGenerateRequest::toJson() const { + QJsonObject o{ + {"projectId", qs(projectId)}, + {"volumeDsId", qs(volumeDsId)}, + {"name", qs(name)}, + {"axis", axis}, + {"origin", vec3(origin)}, {"point1", vec3(point1)}, {"point2", vec3(point2)}, + }; + if (!colorScaleId.empty()) o.insert("colorScaleId", qs(colorScaleId)); + return o; +} + +} // namespace geopro::data diff --git a/src/data/dto/Vtk3dRequests.hpp b/src/data/dto/Vtk3dRequests.hpp new file mode 100644 index 0000000..3be97e1 --- /dev/null +++ b/src/data/dto/Vtk3dRequests.hpp @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include "repo/VolumeBuildParams.hpp" + +namespace geopro::data { + +// 对齐 docs/api/vtk-3d-openapi.json VoxelGenerateRequest(spec §8 请求体组装)。 +struct VoxelGenerateRequest { + std::string projectId; + std::string structParentId; // GS/项目根容器节点 id + int structParentConfType = 1; // 1=GS/项目根 2=TM + std::string name; + std::vector sourceDatasetIds; + std::string interpModel = "Idw"; // Idw|Kriging + double cellXY = 1.0, cellZ = 0.5, power = 2.0, maxDist = 4.0; + std::string colorScaleId; // 空=取首源色阶 + QJsonObject toJson() const; +}; + +// 对齐 SliceGenerateRequest。 +struct SliceGenerateRequest { + std::string projectId; + std::string volumeDsId; // 所属三维体 dsObjectId + std::string name; + int axis = 3; // 0上下/1前后/2左右/3任意 + std::array origin{{0, 0, 0}}; + std::array point1{{0, 0, 0}}; + std::array point2{{0, 0, 0}}; + std::string colorScaleId; + QJsonObject toJson() const; +}; + +// VoxelGenerateRequest → VolumeBuildParams(客户端插值用;interpModel 字符串→enum)。 +VolumeBuildParams fromRequest(const VoxelGenerateRequest& req); + +} // namespace geopro::data diff --git a/src/data/repo/CategoryConfig.hpp b/src/data/repo/CategoryConfig.hpp new file mode 100644 index 0000000..f6c4b0f --- /dev/null +++ b/src/data/repo/CategoryConfig.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +namespace geopro::app { + +// 一个数据类型大类段的配置(spec §5)。识别键二选一:dsTypeCode 优先;ddCode 用于三维体/切片。 +struct CategorySpec { + std::string id; // 段稳定 id + std::string title; // 段标题(UI 显示) + std::string dsTypeCode; // 主识别键(空=不按 dsTypeCode) + std::string ddCode; // 次识别键(dd_voxel/dd_slice;空=不按 ddCode) + bool canGenerateVolume; // 段内是否提供「生成三维体」入口(仅反演类) + bool hasArrayTypeFilter; // 段头是否显示装置类型筛选(仅 ERT 类) +}; + +// 5 段固定有序(spec §5 表)。 +inline const std::vector& categoryConfigs() { + static const std::vector kCfg = { + {"resistivity", "电阻率数据", "ERT platform inversion data", "", true, true}, + {"apparent", "视电阻率数据", "visual resistivity data", "", true, true}, + {"transient", "瞬变电磁数据", "DD TRANSIENT ELECTROMAGNETIC INVERSION", "", true, false}, + {"voxel", "三维体", "", "dd_voxel", false, false}, + // 切片不单列段——挂在三维体段「体→切片/异常」三级树下(spec §8 修订)。 + }; + return kCfg; +} + +} // namespace geopro::app diff --git a/src/data/repo/DatasetFieldDictionary.cpp b/src/data/repo/DatasetFieldDictionary.cpp new file mode 100644 index 0000000..4ee7173 --- /dev/null +++ b/src/data/repo/DatasetFieldDictionary.cpp @@ -0,0 +1,64 @@ +#include "repo/DatasetFieldDictionary.hpp" +#include + +namespace geopro::data { + +DsTypeFields parseFieldMapping(const QJsonObject& d) { + DsTypeFields f; + for (const QJsonValue& gv : d.value(QStringLiteral("formList")).toArray()) { + for (const QJsonValue& vv : gv.toObject().value(QStringLiteral("values")).toArray()) { + const QJsonObject fo = vv.toObject(); + const QString code = fo.value(QStringLiteral("fieldCode")).toString(); + const std::string cfid = fo.value(QStringLiteral("confFieldId")).toString().toStdString(); + if (code == QStringLiteral("arrayType")) { + f.arrayTypeConfFieldId = cfid; + for (const QJsonValue& ov : fo.value(QStringLiteral("optionsObject")).toArray()) { + const QJsonObject oo = ov.toObject(); + f.arrayTypeLabels[oo.value(QStringLiteral("value")).toString().toStdString()] = + oo.value(QStringLiteral("label")).toString().toStdString(); + } + } else if (code == QStringLiteral("collectTime")) { + f.collectTimeConfFieldId = cfid; + } + } + } + return f; +} + +namespace { +std::string propValue(const DsRow& row, const std::string& cfid) { + if (cfid.empty()) return {}; + for (const auto& kv : row.properties) + if (kv.confFieldId == cfid) return kv.value; + return {}; +} +} // namespace + +std::string arrayValueOf(const DsRow& row, const DsTypeFields& f) { + return propValue(row, f.arrayTypeConfFieldId); +} +std::string collectTimeOf(const DsRow& row, const DsTypeFields& f) { + return propValue(row, f.collectTimeConfFieldId); +} + +std::string arrayLabel(const DsTypeFields& f, const std::string& value) { + const auto it = f.arrayTypeLabels.find(value); + return it != f.arrayTypeLabels.end() ? it->second : value; // 缺失回退原值(spec §11) +} + +void DatasetFieldDictionary::setFields(const std::string& dsTypeCode, DsTypeFields fields) { + byType_[dsTypeCode] = std::move(fields); +} +bool DatasetFieldDictionary::has(const std::string& dsTypeCode) const { + return byType_.find(dsTypeCode) != byType_.end(); +} +const DsTypeFields* DatasetFieldDictionary::fields(const std::string& dsTypeCode) const { + const auto it = byType_.find(dsTypeCode); + return it != byType_.end() ? &it->second : nullptr; +} + +void DatasetFieldDictionary::setArrayTypeEnum(std::map e) { + arrayTypeEnum_ = std::move(e); +} + +} // namespace geopro::data diff --git a/src/data/repo/DatasetFieldDictionary.hpp b/src/data/repo/DatasetFieldDictionary.hpp new file mode 100644 index 0000000..5e33759 --- /dev/null +++ b/src/data/repo/DatasetFieldDictionary.hpp @@ -0,0 +1,45 @@ +#pragma once +#include +#include +#include +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// 某 dsType 的字段映射(spec §10)。 +struct DsTypeFields { + std::string arrayTypeConfFieldId; // ds 行 properties 里装置类型项的 confFieldId + std::string collectTimeConfFieldId; // 采集时间项的 confFieldId + std::map arrayTypeLabels; // value→中文(来自 optionsObject) +}; + +// 纯函数:从 dsObject/dynamicForm 的 data 解析字段映射(formList → fieldCode==arrayType/collectTime)。 +DsTypeFields parseFieldMapping(const QJsonObject& dynamicFormData); + +// ds 行的装置类型原始值:properties 中 confFieldId==arrayTypeConfFieldId 的 value(缺=空)。 +std::string arrayValueOf(const DsRow& row, const DsTypeFields& f); +// ds 行的采集时间:properties 中 confFieldId==collectTimeConfFieldId 的 value(缺=空)。 +std::string collectTimeOf(const DsRow& row, const DsTypeFields& f); + +// 装置类型 value→中文:命中 arrayTypeLabels 取中文,否则回退原值(spec §11 字典源待坐实时的安全退路)。 +std::string arrayLabel(const DsTypeFields& f, const std::string& value); + +// 按 dsTypeCode 缓存字段映射的服务(spec §10)。 +// 异步拉取 dynamicForm 的 IO 由 main 接线层负责(loadDatasetFormAsync → parseFieldMapping → setFields), +// 本类只持内存缓存,保持 data 层无网络依赖、可单测。 +class DatasetFieldDictionary { +public: + void setFields(const std::string& dsTypeCode, DsTypeFields fields); + bool has(const std::string& dsTypeCode) const; // 是否已缓存该 dsType 映射 + const DsTypeFields* fields(const std::string& dsTypeCode) const; // 未缓存=nullptr + + // 全局装置类型枚举(GET script/arrayTypeList → itemValue→中文名),所有 ERT 类共用(spec §6 装置筛选)。 + void setArrayTypeEnum(std::map e); + const std::map& arrayTypeEnum() const { return arrayTypeEnum_; } + +private: + std::map byType_; + std::map arrayTypeEnum_; // itemValue → 中文名 +}; + +} // namespace geopro::data diff --git a/src/data/repo/I3dSceneRepository.hpp b/src/data/repo/I3dSceneRepository.hpp new file mode 100644 index 0000000..95e0953 --- /dev/null +++ b/src/data/repo/I3dSceneRepository.hpp @@ -0,0 +1,166 @@ +#pragma once +#include +#include +#include +#include +#include + +#include "model/Anomaly.hpp" +#include "model/ColorScale.hpp" +#include "model/Field.hpp" +#include "repo/RepoTypes.hpp" + +namespace geopro::data { + +// ds 维度属性(由 ds 类型/ddCode 决定,spec §6.1)。三栏列表筛选用。 +enum class DsDimension { Dim2D, Dim3D, Analysis3D, Other }; + +// 三维体模型数据:规则标量体 + 世界系原点/间距 + 值域(去裸 double[],用 std::array,spec §6.2)。 +struct VolumeGrid { + geopro::core::ScalarVolume vol{0, 0, 0}; + std::array origin{{0.0, 0.0, 0.0}}; // ox, oy, oz(世界米) + std::array spacing{{0.0, 0.0, 0.0}}; // dx, dy, dz + double vmin = 0.0, vmax = 0.0; + bool valid() const { return vol.nx() > 0 && vol.ny() > 0 && vol.nz() > 0 && vmax > vmin; } +}; + +// DEM/影像 GeoTIFF 绝对路径(供 render::buildTerrain 经 GDAL 读,spec §6.2)。 +struct TerrainPaths { + std::string demPath, imagePath; +}; + +// 切面/剖面着色数据:帘面渲染输入(Grid + 色阶)。spec §6.2 帘面入 3D。 +struct SectionData { + geopro::core::Grid grid{0, 0}; // Grid 无默认构造,给 0×0 占位(加载后填) + geopro::core::ColorScale scale; +}; + +// 二维足迹折线(测线/轨迹):一串 WGS84 经纬度点(lat[i]/lon[i] 一一对应)。 +// 渲染时经 Scene 共享 GeoLocalFrame 投影到世界 XY、Z=摆放高程,平铺进 3D 地图。 +// 不含色阶/深度(足迹是纯几何线,区别于帘面的 Grid+色阶)。 +struct MapLine { + std::vector lat, lon; + bool valid() const { return lat.size() == lon.size() && lat.size() >= 2; } +}; + +// 三维场景仓储抽象(异步,spec §6 评审 HIGH)。 +// 取数方法走回调 std::function(LocalSample 本地数据同步算好后直接回调; +// 将来 Api3dRepository 在网络完成时回调,上层不变)。 +// **契约:onOk/onErr 必须在主(GUI)线程调用**——上层(VtkSceneController)回调内直接操作 +// 场景/发 Qt 信号,依赖主线程亲和;Api 实现若在工作线程完成须 post 回主线程再回调。 +// dimensionOf 是同步纯函数(无 I/O,只做类型→维度映射)。 +// 切片/异常/任务接口已按 spec §6.3–6.5 完整设计(见下方),LocalSample 内存态 stub,将来 Api3dRepository 换实现。 +class I3dSceneRepository { +public: + using OnError = std::function; + + virtual ~I3dSceneRepository() = default; + + // 同步纯函数:ds 类型 → 维度(spec §6.1 映射表)。 + virtual DsDimension dimensionOf(const DsRow& ds) const = 0; + + // 同步纯函数:该 dsId 是否为三维体数据集 → 控制器据此选「体素」而非「帘面」渲染路径 + // (取代旧的全局 showVoxel/showCurtain 图层开关,按数据集类型分流)。 + virtual bool isVolumeDataset(const std::string& dsId) const = 0; + + // 异步:加载三维体模型(成功回调 VolumeGrid + 其色阶,失败回调消息)。 + // 色阶随体交付(= 源剖面色阶,与帘面一致):体的着色是其加载表示的固有部分, + // 不可由 dsId 经普通数据集仓储取(客户端 mock 体在后端无条目)。 + virtual void loadVolume(const std::string& dsId, + std::function onOk, + OnError onErr) = 0; + + // 异步:加载剖面(帘面)着色数据(Grid+色阶)。本地样本同步回调;Api 实现走 ERT 反演端点异步回调。 + virtual void loadSection(const std::string& dsId, + std::function onOk, OnError onErr) = 0; + + // 异步:加载二维足迹折线(测线/轨迹的 WGS84 经纬序列)。 + // Api 实现走 ERT 轨迹端点(dd/ert/trajectory/line)异步回调;本地样本给 mock 折线同步回调。 + // 契约同上:onOk/onErr 必须在主线程调用。 + virtual void loadMapLine(const std::string& dsId, + std::function onOk, OnError onErr) = 0; + + // 异步:加载地形 DEM/影像路径。 + virtual void loadTerrainPaths(std::function onOk, OnError onErr) = 0; + + // ── 切片数据集 CRUD(spec §6.3)────────────────────────────────────────── + // 切面精确几何:vtkImagePlaneWidget 的三点(Origin/Point1/Point2) + 轴向 → + // 重渲染逐点精确还原(尺寸/朝向/位置一致);法向 = normalize((p1-o)×(p2-o)),可派生。 + // axis: 0 上下 / 1 前后 / 2 左右 / 3 任意(=render::interact::SliceAxis 顺序);决定还原时是否锁旋转。 + struct SliceSpec { + std::string volumeDsId; // 所属三维体 dsId + int axis = 3; // 轴向(锁旋转用) + std::array origin{{0, 0, 0}}; // 平面 Origin + std::array point1{{0, 0, 0}}; // 平面 Point1 + std::array point2{{0, 0, 0}}; // 平面 Point2 + std::string colorScaleId; + }; + // 切片数据集(持久化态):dsId/名字 + 位姿 + 采样网格。 + struct SliceDataset { + std::string dsId, name; + SliceSpec spec; + geopro::core::Grid section{0, 0}; // 切面着色网格(保存后才填) + }; + + // 保存为新切片数据集 → onOk(newDsId)。 + virtual void createSlice(const SliceSpec& spec, const std::string& name, + std::function onOk, + OnError onErr) = 0; + // 覆盖已有切片位姿。 + virtual void saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function onOk, OnError onErr) = 0; + // 删除切片数据集。 + virtual void deleteSlice(const std::string& dsId, + std::function onOk, OnError onErr) = 0; + + // ── 异常 / 异常体(spec §6.4)──────────────────────────────────────────── + // 异常体(树中间层):含该体下的多个 Anomaly。 + struct AnomalyBody { + std::string id, name, typeName; + std::vector members; + }; + // 异常树:对象 → 异常体分组 → 异常,以及未分组异常。 + struct AnomalyTree { + std::vector bodies; + std::vector loose; // 未分组异常 + }; + + // 加载对象的完整异常树。 + virtual void loadAnomalyTree(const std::string& objectId, + std::function onOk, OnError onErr) = 0; + // 保存/新建异常(含截图属性)→ onOk(anomalyId)。 + virtual void saveAnomaly(const geopro::core::Anomaly& a, + const std::string& screenshotPngPath, + std::function onOk, + OnError onErr) = 0; + // 删除单个异常。 + virtual void deleteAnomaly(const std::string& anomalyId, + std::function onOk, OnError onErr) = 0; + // 删除异常体分组(及其下异常)。 + virtual void deleteAnomalyGroup(const std::string& bodyId, + std::function onOk, OnError onErr) = 0; + + // ── 任务管理(spec §6.5)───────────────────────────────────────────────── + // 任务调用记录(当前数据集历史)。 + struct TaskRecord { + std::string id, taskName, status, createTime; + }; + // 可使用任务(与 ds 类型 ddCode 相符的任务插件;复用 model/list 语义)。 + struct UsableTask { + std::string scriptId, scriptCode, name; + int opType = 0; + }; + + // 加载当前数据集的任务调用记录。 + virtual void loadTaskRecords(const std::string& dsId, + std::function)> onOk, + OnError onErr) = 0; + // 加载与 ddCode 匹配的可用任务插件列表。 + virtual void loadUsableTasks(const std::string& ddCode, + std::function)> onOk, + OnError onErr) = 0; +}; +// 注:以上切片/异常/任务接口已按 spec §6.3–6.5 完整设计; +// LocalSample3dRepository 提供内存态 stub;真实后端 = 将来 Api3dRepository(上层不变)。 + +} // namespace geopro::data diff --git a/src/data/repo/IColorTemplateRepository.hpp b/src/data/repo/IColorTemplateRepository.hpp new file mode 100644 index 0000000..31f560a --- /dev/null +++ b/src/data/repo/IColorTemplateRepository.hpp @@ -0,0 +1,43 @@ +#pragma once +#include + +#include +#include +#include + +namespace geopro::data { + +// 色阶模板库仓储抽象(lvl 等值线模板 + clr 连续色阶)。回调式异步: +// 仓储只做「组装请求 + 取数组/状态」的传输职责;领域解析(colorBar/颜色串)留在对话框。 +// 回调在 Qt 主线程经 IApiCall::finished 触发;调用方须用 QPointer 守卫可能已关闭的对话框。 +class IColorTemplateRepository { +public: + virtual ~IColorTemplateRepository() = default; + + // 另存 lvl 模板:POST /business/lvlTemplate。 + virtual void saveLvlTemplate(const QString& projectId, const QString& templateName, + const QJsonObject& properties, + std::function cb) = 0; + + // 覆盖更新 lvl 模板:PUT /business/lvlTemplate(body {id, templateName, properties})。 + // 复刻原版 updateLvlTemplate(另存为弹窗勾选「覆盖」时调用,id=当前模板 lvlTemplateId)。 + virtual void updateLvlTemplate(const QString& id, const QString& templateName, + const QJsonObject& properties, + std::function cb) = 0; + + // 列 lvl 模板:POST /business/lvlTemplate/page(pageNo=1,pageSize=1000)。 + // 回调 list = 响应 data.list 数组(每项含 templateName/properties)。 + virtual void listLvlTemplates(const QString& projectId, + std::function cb) = 0; + + // 新建 clr 色阶:POST /business/clr/colorGradation。 + virtual void newClrScheme(const QString& projectId, const QJsonObject& properties, + std::function cb) = 0; + + // 列 clr 色阶:GET .../queryCLRColorGradation/{projectId}。 + // 注意:响应 data 顶层为数组,回调 list = 该数组(每项含 properties.name/colorscale)。 + virtual void listClrSchemes(const QString& projectId, + std::function cb) = 0; +}; + +} // namespace geopro::data diff --git a/src/data/repo/IDatasetCommandRepository.hpp b/src/data/repo/IDatasetCommandRepository.hpp new file mode 100644 index 0000000..f56aa35 --- /dev/null +++ b/src/data/repo/IDatasetCommandRepository.hpp @@ -0,0 +1,235 @@ +#pragma once +#include + +#include +#include +#include + +#include "model/detail/DetailPayloads.hpp" // core::ScatterPayload(V 值重载返回完整载荷) + +namespace geopro::data { + +// 数据集详情视图「写操作 / 命令」仓储抽象(与只读 IAsyncDatasetRepository 平行)。 +// 本切片仅纳入「反演」相关的 4 个端点(measurement 反演运算 / 生成视电阻率 + 共享的 +// 模型列表 / 动态表单查询)。回调式异步:仓储只做「组装请求 + 取数组/状态」的传输职责; +// 领域解析(动态表单分组/字段控件)留在对话框(见 InversionFormDialog 的纯函数)。 +// 回调在 Qt 主线程经 IApiCall::finished 触发;调用方须用 QPointer 守卫可能已关闭的对话框/视图。 +class IDatasetCommandRepository { +public: + virtual ~IDatasetCommandRepository() = default; + + // 反演模型列表:GET /business/outerInversion/query/script?dsObjectId={ds}。 + // measurement「反演运算」与「生成视电阻率」共用此端点(原版 getInversionOptions / + // getProcessScriptList 同 URL)。回调 list = 响应 data 数组(每项含 label/value/code)。 + virtual void listInversionScripts( + const QString& dsObjectId, + std::function cb) = 0; + + // 动态表单:POST /business/project/getDynamicForm body {projectId, type:6, typeId}。 + // 回调 data = 响应 data 对象(含 formList 数组:分组 → values 字段)。 + virtual void getDynamicForm( + const QString& projectId, const QString& typeId, + std::function cb) = 0; + + // 提交反演运算:POST /business/outerInversion/submitInversionTask + // body {dsId, scriptId, properties:{fieldCode:value,...}}(对应原版 postInversionTask)。 + virtual void submitInversionTask(const QString& dsId, const QString& scriptId, + const QJsonObject& properties, + std::function cb) = 0; + + // 生成视电阻率数据:POST /business/dd/ert/measurement/createVisualResistivityData + // body {dsObjectId, scriptId, scriptParamListJsonStr:{fieldCode:value,...}} + // (对应原版 createVisualResistivityData,散点图模块版本)。 + virtual void createVisualResistivityData( + const QString& dsObjectId, const QString& scriptId, + const QJsonObject& scriptParamListJsonStr, + std::function cb) = 0; + + // ============================ measurement 散点相关 ============================ + + // 散点可见性持久化:POST /business/dd/ert/measurement/saveDisplayStatus + // body {dsObjectId, ids:[...], status}(对应原版 updateScatterDataVisible)。 + // 注:原版字段名为 dsObjectId(非 dsObjectId 之外的 dsId),status 为 int(0/1)。 + virtual void saveDisplayStatus(const QString& dsObjectId, const QJsonArray& ids, int status, + std::function cb) = 0; + + // 散点过滤配置查询:GET /business/scatterPlotDataFilter/getDataFilterConfig + // ?dsObjectId={ds}&vFieldCode={vf}(对应原版 queryScatterFilterInfo)。 + // 回调 data = 响应 data 对象(含 min/max 等配置)。 + virtual void getScatterFilterConfig( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) = 0; + + // 应用散点过滤:POST /business/scatterPlotDataFilter + // body {sourceDsObjectId, sourceVFieldCode, min, max}(对应原版 applyScatterFilterInfo)。 + // min/max 为字符串/数值由调用方组装,故收已组装 body。 + virtual void applyScatterFilter(const QJsonObject& body, + std::function cb) = 0; + + // 另存原始数据:POST /business/dd/ert/measurement/saveRawData + // body {dsObjectId, ...}(对应原版 saveRawDataValue,projectSpace 模块)。 + // 原版仅约束 dsObjectId 必填;operationType/name 由调用方按场景组装,故收 body。 + virtual void saveRawData(const QJsonObject& body, + std::function cb) = 0; + + // 导出 DAT:GET /business/dd/ert/measurement/rs2d/export + // ?dsId={ds}&electrodePosition={ep}&ipDataMark={ip}&typeMeasurement={tm} + // (对应原版 exportScatterData2Dat)。回调回传 base64 fileData 与 fileName, + // 实际落盘/下载交由 UI 层处理。 + virtual void exportMeasurementDat( + const QString& dsId, int electrodePosition, int ipDataMark, int typeMeasurement, + std::function cb) = 0; + + // V 值切换重载散点(M6):并发拉 scatter/graph(带 vFieldCode) + 色阶 getDetail(type3, + // businessCode=vFieldCode),解析为完整 ScatterPayload(对应原版 V 值 change 重新请求散点+色阶)。 + // 与 ApiDatasetRepository 的初始 measurement 加载同端点/同解析,仅 vFieldCode 可变。 + virtual void loadMeasurementScatter( + const QString& dsObjectId, const QString& vFieldCode, + std::function cb) = 0; + + // ============================ 色阶(lvl)相关 ============================ + + // 保存 .lvl 色阶:POST /business/lvl/colorGradation + // body {dsObjectId, templateId, businessCode, projectId, properties} + // (对应原版 newLvlColorLevel)。measurement 散点与 inversion 原数据色阶配置共用。 + virtual void saveColorGradation(const QJsonObject& body, + std::function cb) = 0; + + // ============================ inversion 相关 ============================ + + // inversion 另存为数据:POST /business/dd/ert/inversion/saveAsData + // body {dsObjectId, name}(对应原版 saveVisualResistivityData)。 + virtual void saveInversionAsData(const QString& dsObjectId, const QString& name, + std::function cb) = 0; + + // 网格视图重载(处理类操作=网格化/白化/滤波 成功后重绘):并发拉 rows + 色阶 getDetail(type2) + // + 异常,解析为 ContourPayload(与 ApiDatasetRepository::makeInversionGrid 同端点/同解析)。 + // 与 loadMeasurementScatter 同范式:仓储只读重载方法,UI 在成功回调 setGridData 重绘。 + virtual void loadInversionGrid( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:查询算法模型 GET /business/dd/ert/inversion/queryAlgorithmModel/{ds} + // (对应原版 getGridModel)。回调 list = data.value 数组。 + virtual void listGridAlgorithm( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:获取原始数据参数 GET /business/dd/ert/inversion/getRawData/{ds} + // (对应原版 getGridParams)。回调 data = 响应 data 对象。 + virtual void getGridRawDataParams( + const QString& dsObjectId, + std::function cb) = 0; + + // 网格化:执行 POST /business/dd/ert/inversion/grid + // body 含 {dsObjectId, actionCode, saveDataValueType, ...}(对应原版 toGridTheData 全字段)。 + virtual void toGrid(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 白化相关 ============================ + + // 白化:查询可白化数据列表 POST /business/dsObject/queryWhitenedDataList + // body {projectId, tmObjectId}(对应原版 getWhitenedDataList)。 + // 回调 list = data.value 数组。 + virtual void listWhitenedData(const QString& projectId, const QString& tmObjectId, + std::function cb) = 0; + + // 白化:执行 POST /business/dd/ert/inversion/whitenedData + // body 含 {dsObjectId, whiteningMethod, ...}(对应原版 whitenTheData,3 种 method 字段差异由调用方组装)。 + virtual void whitenData(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 滤波相关 ============================ + + // 滤波:查询滤波器列表 GET /business/filter/queryFilter(对应原版 getFilters,无 query)。 + // 回调 list = data.value 数组。 + virtual void listFilters(std::function cb) = 0; + + // 滤波:新增滤波器 POST /business/filter + // body 含 {projectId, parentId, rowColumValue, type, ...}(对应原版 newTheFilter)。 + virtual void newFilter(const QJsonObject& body, + std::function cb) = 0; + + // 滤波:删除滤波器 DELETE /business/filter/delete/{id}(对应原版 deleteTheFilter)。 + virtual void deleteFilter(const QString& id, + std::function cb) = 0; + + // 滤波:应用滤波处理数据 POST /business/dd/ert/inversion/filterData + // body 含 {dsObjectId, rowColumValue, ...}(对应原版 useFilterToProcessData 全字段)。 + virtual void applyFilter(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 异常 CRUD ============================ + + // 异常类型列表:GET /business/exceptionType/queryExceptionTypeByProjectIdAndType/{pid}/{type} + // (对应原版 queryExceptionTypeData,type = remarkSourceType)。回调 list = data.value 数组。 + virtual void listExceptionTypes( + const QString& projectId, const QString& remarkSourceType, + std::function cb) = 0; + + // 异常类型详情:GET /business/exceptionType/getDetail/{id} → data{...legend...}。 + // data.legend = {polylineColor/Width/Shape, pointColor/Size/Shape, polygonFillColor,...}, + // 供创建异常时按平台类型样式渲染(与平台一致)。回调 data = 整个 data 对象。 + virtual void getExceptionTypeDetail( + const QString& exceptionTypeId, + std::function cb) = 0; + + // 装置类型枚举:GET /business/script/arrayTypeList → [{itemValue,name}](电阻率/视电阻率段装置筛选用)。 + virtual void listArrayTypes(std::function cb) = 0; + + // 获取异常名称:POST /business/exception/getExceptionName + // body {exceptionTypeId, remarkSourceId}(对应原版 queryExceptionNameInProfileInversion)。 + // 原版 res.data 是「纯字符串」(建议名称本身),故回调直接回传 name 字符串。 + virtual void getExceptionName( + const QString& exceptionTypeId, const QString& remarkSourceId, + std::function cb) = 0; + + // 新增异常:POST /business/exception + // body 含 {exceptionName, exceptionTypeId, location, projectId, remarkSourceId, remarkSourceType, remark} + // (对应原版 newExceptionInProfileInversion 全字段)。 + virtual void newException(const QJsonObject& body, + std::function cb) = 0; + + // 新增异常类型(完整版,对照原版 addExceptionType / apis/toolComponent/exceptionType.js:9): + // POST /business/exceptionType,body 由「标注类型」对话框(异常属性 + 标注名称双 Tab)组装, + // 含 {exceptionTypeName, exceptionTypeCode, standardNumber, standardName, description, + // legend:{...按 markType 的图例样式}, exceptionNameList:[{fieldName,fieldCode,sort}], + // customFormat, separatorSymbol, projectId, exceptionMarkType, type:2}(见 ExceptionTypeDialog)。 + virtual void addExceptionType(const QJsonObject& body, + std::function cb) = 0; + + // 删除异常:DELETE /business/exception/{id}(对应原版 deleteExceptionDataInProfileInversion)。 + virtual void deleteException(const QString& id, + std::function cb) = 0; + + // 更新异常:PUT /business/exception + // body 含 {id, exceptionName, remark, ...}(对应原版 updateExceptionDataInProfileInversion)。 + virtual void updateException(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 自动标注 ============================ + + // 执行自动标注:POST /business/exception/exception-mark/execute(对应原版 executeExceptionMark)。 + virtual void executeExceptionMark(const QJsonObject& body, + std::function cb) = 0; + + // 批量创建异常:POST /business/exception/batch/create(对应原版 batchCreateException)。 + virtual void batchCreateException(const QJsonObject& body, + std::function cb) = 0; + + // ============================ 描述 / dsObject ============================ + + // 获取 dsObject 详情:GET /business/dsObject/getDetail/{ds} + // (对应原版 getProfileInversionDescription)。回调 data = 响应 data 对象(含 description 等)。 + virtual void getDsObjectDetail( + const QString& dsObjectId, + std::function cb) = 0; + + // 更新 dsObject:PUT /business/dsObject/updateDsObject/ + // body 含 {dsObjectId, description, attachedParameters:{deltaContent}, ...} + // (对应原版 updateProfileInversionDescription,注意原版 URL 末尾带斜杠)。 + virtual void updateDsObject(const QJsonObject& body, + std::function cb) = 0; +}; + +} // namespace geopro::data diff --git a/src/data/repo/LocalSample3dRepository.cpp b/src/data/repo/LocalSample3dRepository.cpp new file mode 100644 index 0000000..096c212 --- /dev/null +++ b/src/data/repo/LocalSample3dRepository.cpp @@ -0,0 +1,220 @@ +#include "repo/LocalSample3dRepository.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include "algo/VolumeBuilder.hpp" +#include "geo/CrsTransform.hpp" +#include "geo/GeoLocalFrame.hpp" +#include "model/ColorScale.hpp" +#include "model/Field.hpp" +#include "repo/LocalSampleRepository.hpp" + +namespace geopro::data { + +using geopro::core::ColorScale; +using geopro::core::CrsTransform; +using geopro::core::GeoLocalFrame; +using geopro::core::PointSet; +using geopro::core::ScatterField; + +namespace { + +// 与 render::VoxelFromScatters 的默认参数同口径(保持渲染/切片纵向一致)。 +// 「散点→GridSpec→IDW→ScalarVolume」已提到 core::buildVolume 共享(与 Api3dRepository 同源, +// 消除调参漂移);此处仅保留默认参数 + 本地样本配准。 +constexpr double kCellXY = 1.0; +constexpr double kCellZ = 0.5; +constexpr double kPower = 2.0; +constexpr double kMaxDist = 4.0; +constexpr const char* kWgs84 = "EPSG:4326"; + +} // namespace + +LocalSample3dRepository::LocalSample3dRepository(LocalSampleRepository& base, std::string projectCrs, + double baseLat, double baseLon) + : base_(base), projectCrs_(std::move(projectCrs)), baseLat_(baseLat), baseLon_(baseLon) {} + +DsDimension LocalSample3dRepository::dimensionOf(const DsRow& ds) const { + const std::string& c = ds.ddCode; + // 真三维体 / 体素 / 帘面(dd_section/反演剖面摆成竖直帘面)入三维数据集。 + if (c == "dd_voxel" || c == "dd_Structual3D" || c == "dd_Property3D" || c == "dd_section" || + c == "dd_inversion_data") { + return DsDimension::Dim3D; + } + // 切片:三维分析栏。 + if (c == "dd_slice") return DsDimension::Analysis3D; + // 足迹型 → 二维数据集。dd_trajectory_data = 统一通用轨迹(数据字典 DD0623「保留」,已并入 + // dd_radar_rtk_trajectory);瞬变电磁/雷达通道/RTK 等轨迹型字典均标「删除」,不再单列。 + if (c == "dd_trajectory_data") return DsDimension::Dim2D; + return DsDimension::Other; +} + +void LocalSample3dRepository::loadVolume( + const std::string& /*dsId*/, + std::function onOk, OnError onErr) { + // P1 样本:dsId 暂未使用,固定读同一组交叉剖面散点→体素(真实 Api 实现按 dsId 取)。 + try { + // 1) 读两条交叉剖面散点 + 色阶;配准到世界局部米 + 深度,组装 IDW 输入点集。 + const std::vector profiles = base_.loadVoxelScatters(); + const CrsTransform crs(projectCrs_, kWgs84); + const GeoLocalFrame frame(baseLat_, baseLon_); + + PointSet pts; + for (const auto& s : profiles) { + const std::size_t n = s.v.size(); + if (s.projX.size() < n || s.projY.size() < n || s.y.size() < n) continue; + for (std::size_t i = 0; i < n; ++i) { + const auto ll = crs.forward(s.projX[i], s.projY[i]); // (lon, lat) + const auto local = frame.toLocal(ll.y, ll.x); // (x East, y North) 米 + pts.x.push_back(local.x); + pts.y.push_back(local.y); + pts.z.push_back(-s.y[i]); // 深度向下:z 取负 + pts.v.push_back(s.v[i]); + } + } + if (pts.v.empty()) { + onErr("LocalSample3dRepository: no voxel points after registration"); + return; + } + + // 2) 点集 → GridSpec → IDW → 数据实测值域(共享 core::buildVolume)。 + geopro::core::BuiltVolume bv = + geopro::core::buildVolume(pts, kCellXY, kCellZ, kPower, kMaxDist); + + // 3) 值域:优先 colorBar 真实分段值,否则 buildVolume 的数据实测范围。 + double vmin = bv.vmin, vmax = bv.vmax; + ColorScale cs; + try { + cs = base_.loadScatterColorScale("grid1"); + } catch (const std::exception&) { + // 色阶缺失 → 沿用数据实测范围。 + } + const std::vector stops = cs.stopValues(); + if (stops.size() >= 2) { + vmin = stops.front(); vmax = stops.back(); + } + + VolumeGrid out{std::move(bv.vol), + {{bv.spec.ox, bv.spec.oy, bv.spec.oz}}, + {{bv.spec.dx, bv.spec.dy, bv.spec.dz}}, + vmin, vmax}; + onOk(std::move(out), std::move(cs)); + } catch (const std::exception& e) { + onErr(std::string("LocalSample3dRepository::loadVolume: ") + e.what()); + } +} + +void LocalSample3dRepository::loadSection(const std::string& /*dsId*/, + std::function onOk, OnError onErr) { + // P1 样本:忽略入参 dsId(本地仅一份样本 grid1);真实 Api 实现走 ERT 反演端点按 dsId 取。 + try { + SectionData s; + s.grid = base_.loadGrid("grid1"); // 样本 dd_section 网格 + s.scale = base_.loadColorScale("grid1"); // 对应色阶 + onOk(std::move(s)); // 本地同步回调 + } catch (const std::exception& e) { + onErr(std::string("LocalSample3dRepository::loadSection: ") + e.what()); + } +} + +void LocalSample3dRepository::loadMapLine(const std::string& /*dsId*/, + std::function onOk, OnError onErr) { + // P1 样本:取样本 grid1 的 lat/lon 作为足迹折线(测试/离线用,不依赖网络)。 + // 真实 Api 实现走 dd/ert/trajectory/line 端点按 dsId 取经纬。 + try { + const core::Grid g = base_.loadGrid("grid1"); + MapLine line; + line.lat = g.lat; // 样本 grid 的测线经纬(与帘面同源 → 同系配准) + line.lon = g.lon; + if (!line.valid()) { + onErr("LocalSample3dRepository::loadMapLine: 样本无有效经纬折线"); + return; + } + onOk(std::move(line)); // 本地同步回调 + } catch (const std::exception& e) { + onErr(std::string("LocalSample3dRepository::loadMapLine: ") + e.what()); + } +} + +void LocalSample3dRepository::loadTerrainPaths(std::function onOk, + OnError onErr) { + try { + TerrainPaths p{base_.demPath(), base_.imagePath()}; + onOk(std::move(p)); + } catch (const std::exception& e) { + onErr(std::string("LocalSample3dRepository::loadTerrainPaths: ") + e.what()); + } +} + +// ── 切片 CRUD(spec §6.3 内存态 stub)─────────────────────────────────────── + +void LocalSample3dRepository::createSlice(const SliceSpec& spec, const std::string& /*name*/, + std::function onOk, + OnError /*onErr*/) { + std::string newId = "slice-" + std::to_string(++sliceCounter_); + slices_[newId] = spec; + onOk(std::move(newId)); +} + +void LocalSample3dRepository::saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function onOk, OnError /*onErr*/) { + slices_[dsId] = spec; + onOk(); +} + +void LocalSample3dRepository::deleteSlice(const std::string& dsId, + std::function onOk, OnError /*onErr*/) { + slices_.erase(dsId); + onOk(); +} + +// ── 异常 / 异常体(spec §6.4 内存态 stub)────────────────────────────────── + +void LocalSample3dRepository::loadAnomalyTree(const std::string& /*objectId*/, + std::function onOk, + OnError /*onErr*/) { + onOk(AnomalyTree{}); // stub: 空树(无样本异常) +} + +void LocalSample3dRepository::saveAnomaly(const geopro::core::Anomaly& a, + const std::string& /*screenshotPngPath*/, + std::function onOk, + OnError /*onErr*/) { + std::string newId = "anomaly-" + std::to_string(++anomalyCounter_); + anomalies_[newId] = a; + onOk(std::move(newId)); +} + +void LocalSample3dRepository::deleteAnomaly(const std::string& anomalyId, + std::function onOk, OnError /*onErr*/) { + anomalies_.erase(anomalyId); + onOk(); +} + +void LocalSample3dRepository::deleteAnomalyGroup(const std::string& /*bodyId*/, + std::function onOk, OnError /*onErr*/) { + // stub: 内存态无 AnomalyBody 存储(bodyId 仅逻辑分组,真实 backend 实现时才有) + onOk(); +} + +// ── 任务管理(spec §6.5 内存态 stub)──────────────────────────────────────── + +void LocalSample3dRepository::loadTaskRecords(const std::string& /*dsId*/, + std::function)> onOk, + OnError /*onErr*/) { + onOk({}); // stub: 无样本任务记录 +} + +void LocalSample3dRepository::loadUsableTasks(const std::string& /*ddCode*/, + std::function)> onOk, + OnError /*onErr*/) { + onOk({}); // stub: 空列表(真实实现按 ddCode 过滤 model/list 返回) +} + +} // namespace geopro::data diff --git a/src/data/repo/LocalSample3dRepository.hpp b/src/data/repo/LocalSample3dRepository.hpp new file mode 100644 index 0000000..45e3bd2 --- /dev/null +++ b/src/data/repo/LocalSample3dRepository.hpp @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include + +#include "repo/I3dSceneRepository.hpp" + +namespace geopro::data { + +class LocalSampleRepository; + +// 本地样本三维场景仓储(spec §6):组合 LocalSampleRepository。 +// loadVolume:读两条交叉剖面散点 → 项目 CRS→WGS84→GeoLocalFrame 配准 → IDW → VolumeGrid(纯 core/data,无 VTK)。 +// loadTerrainPaths:直透 LocalSampleRepository 的 demPath/imagePath。 +// dimensionOf:按 ddCode 内置映射表(同步纯函数)。 +// 本地数据同步算好后直接回调(异步壳:接口异步,本实现内联完成)。 +class LocalSample3dRepository : public I3dSceneRepository { +public: + // base 生命周期须覆盖本对象(由调用方保证);projectCrs 为项目 CRS(如 "EPSG:4547")。 + // baseLat/baseLon 为全项目共享 GeoLocalFrame 原点(与帘面/地图同系,保证空间配准)。 + LocalSample3dRepository(LocalSampleRepository& base, std::string projectCrs, + double baseLat, double baseLon); + + DsDimension dimensionOf(const DsRow& ds) const override; + // 本地样本无客户端创建的三维体(样本体经旧 showVoxel 路径,非按 ds 类型分流)→ 恒 false。 + bool isVolumeDataset(const std::string& /*dsId*/) const override { return false; } + void loadVolume(const std::string& dsId, + std::function onOk, + OnError onErr) override; + void loadSection(const std::string& dsId, std::function onOk, + OnError onErr) override; + void loadMapLine(const std::string& dsId, std::function onOk, + OnError onErr) override; + void loadTerrainPaths(std::function onOk, OnError onErr) override; + + // 切片 CRUD(spec §6.3 内存态 stub) + void createSlice(const SliceSpec& spec, const std::string& name, + std::function onOk, OnError onErr) override; + void saveSlice(const std::string& dsId, const SliceSpec& spec, + std::function onOk, OnError onErr) override; + void deleteSlice(const std::string& dsId, + std::function onOk, OnError onErr) override; + + // 异常 / 异常体(spec §6.4 内存态 stub) + void loadAnomalyTree(const std::string& objectId, + std::function onOk, OnError onErr) override; + void saveAnomaly(const geopro::core::Anomaly& a, const std::string& screenshotPngPath, + std::function onOk, OnError onErr) override; + void deleteAnomaly(const std::string& anomalyId, + std::function onOk, OnError onErr) override; + void deleteAnomalyGroup(const std::string& bodyId, + std::function onOk, OnError onErr) override; + + // 任务管理(spec §6.5 内存态 stub) + void loadTaskRecords(const std::string& dsId, + std::function)> onOk, + OnError onErr) override; + void loadUsableTasks(const std::string& ddCode, + std::function)> onOk, + OnError onErr) override; + +private: + LocalSampleRepository& base_; + std::string projectCrs_; + double baseLat_, baseLon_; + + // 内存态存储(stub,无持久化;重启清空) + int sliceCounter_ = 0; + std::map slices_; // dsId → spec + + int anomalyCounter_ = 0; + std::map anomalies_; // anomalyId → Anomaly +}; + +} // namespace geopro::data diff --git a/src/data/repo/RepoTypes.hpp b/src/data/repo/RepoTypes.hpp index d58a70b..872f983 100644 --- a/src/data/repo/RepoTypes.hpp +++ b/src/data/repo/RepoTypes.hpp @@ -7,13 +7,23 @@ struct DsNode { std::string id, name, ddType; }; // data/page 或 file/page 的一条 ds。数据行只用 dsName/typeName/ddCode;文件行另含 file*。 // parentId = 数据集树的父节点 id(取 sourceShowParentId,回退 parentId);空或不在本批=树根。 // 原版数据列表是树:源「原始数据」为根,派生「反演/接地电阻」挂其下。 +// ds 属性键值(data/page 的 properties[] 项:confFieldId→value 原始对)。 +struct DsPropKV { std::string confFieldId, value; }; + struct DsRow { std::string id, dsName, typeName, ddCode, createTime; std::string parentId; std::string fileName, fileUrl; long long fileSize = 0; + std::string dsTypeCode; // 大类分类主键(spec §5;ddCode 粒度不足以区分电阻率/视电阻率) + std::vector properties; // 原始 confFieldId→value;装置类型/采集时间经 DatasetFieldDictionary 解析 + std::string structParentId; // 上级节点 id(段体容器分组 + 生成三维体归属用) + int structParentConfType = 0; // 1=GS/项目根 2=TM }; struct DsPage { std::vector rows; int total = 0; }; + +// 对象树勾选产出的数据源(spec §6)。confType: 1=GS/项目根, 2=TM。 +struct DataSource { std::string id; int confType = 0; }; struct TmNode { std::string id, name, confCode; std::vector dss; }; struct GsNode { std::string id, name; std::vector tms; }; struct Project { std::string id, name; std::vector gss; }; diff --git a/src/data/repo/VolumeBuildParams.hpp b/src/data/repo/VolumeBuildParams.hpp new file mode 100644 index 0000000..630eb3f --- /dev/null +++ b/src/data/repo/VolumeBuildParams.hpp @@ -0,0 +1,23 @@ +#pragma once +#include +#include + +namespace geopro::data { + +// 三维体构建参数(设计文档 §7.4;用户决策 2026-06-17:不冻结 gridSpec)。 +// 必存元数据 = 源数据集 + 插值模型/参数 + 色阶来源;它们小且可复现(详情面板展示用)。 +// gridSpec / values(明细)为派生:每次按源散点确定性重算(IDW 确定 + 源 ds 锁定 → +// 结果必然一致),故不存为"冻结锚点",由 Api3dRepository 缓存即可(见计划 §1)。 +struct VolumeBuildParams { + enum class Model { Idw, Kriging }; // 本期仅 Idw 实现;Kriging 为占位(core 暂无)。 + + std::vector sourceDatasetIds; // 源数据集 id(≥1;被引用即应锁定不可改) + Model interpModel = Model::Idw; + double cellXY = 1.0; // 水平网格间距(米) + double cellZ = 0.5; // 竖向网格间距(米) + double power = 2.0; // IDW 幂 + double maxDist = 4.0; // 超距 blank(约束插值域,外为 NaN) + std::string colorScaleId; // 色阶来源 ds(空 = 取首个源的色阶) +}; + +} // namespace geopro::data diff --git a/src/data/store/CMakeLists.txt b/src/data/store/CMakeLists.txt new file mode 100644 index 0000000..9c16f22 --- /dev/null +++ b/src/data/store/CMakeLists.txt @@ -0,0 +1,22 @@ +# GPR 三维体分块压缩落盘(B/C 共用基座)。 +# 压缩用 Qt qCompress/qUncompress(QtCore 自带 zlib,免新依赖);sidecar 用 nlohmann-json。 +find_package(nlohmann_json CONFIG REQUIRED) +find_package(Qt6 COMPONENTS Core REQUIRED) + +add_library(geopro_store STATIC + ChunkedVolumeStore.cpp + # 流式建体(B4):编排 io_gpr+core+store,沿 X 分 slab 建 level0 体(命名空间 geopro::data)。 + ${CMAKE_CURRENT_SOURCE_DIR}/../StreamingVolumeBuilder.cpp + # build-geo(G1):按真实 RTK 几何把多线插值进统一路向网格(命名空间 geopro::core, + # 文件物理位于 src/core/algo/,但编排 io_gpr+core+store 故编进 store,不污染纯 core)。 + ${CMAKE_SOURCE_DIR}/src/core/algo/GeoVolumeBuilder.cpp) + +# include 根 = src/,使 #include "data/store/..." 与 "core/algo/..." 可解析 +# (geopro_tests 链 geopro_store 后透传)。 +target_include_directories(geopro_store PUBLIC ${CMAKE_SOURCE_DIR}/src) +# geopro_io_gpr 仅依赖 core(无环):流式建体需 assembleGprSurveySlab/IprHeader。 +target_link_libraries(geopro_store + PUBLIC geopro_core geopro_io_gpr Qt6::Core + PRIVATE nlohmann_json::nlohmann_json) +target_compile_features(geopro_store PUBLIC cxx_std_17) +set_target_properties(geopro_store PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) diff --git a/src/data/store/ChunkedVolumeStore.cpp b/src/data/store/ChunkedVolumeStore.cpp new file mode 100644 index 0000000..76a651f --- /dev/null +++ b/src/data/store/ChunkedVolumeStore.cpp @@ -0,0 +1,811 @@ +#include "data/store/ChunkedVolumeStore.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" // geopro::core::BuiltI16 +#include "model/ScalarVolumeI16.hpp" // geopro::core::ScalarVolumeI16::kBlank + +namespace geopro::data { + +namespace { +using nlohmann::json; +namespace fs = std::filesystem; + +constexpr const char* kMetaFile = "meta.json"; +constexpr const char* kDataFile = "data.bin"; + +constexpr std::int16_t kBlank = geopro::core::ScalarVolumeI16::kBlank; + +std::string levelDataFile(int level) { + return level == 0 ? std::string(kDataFile) + : "data_L" + std::to_string(level) + ".bin"; +} + +int ceilDiv(int n, int brick) { return (n + brick - 1) / brick; } + +// 块内 (min,max),跳过 kBlank;全 blank → (kBlank,kBlank)。 +std::pair computeRange( + const std::vector& blk) { + std::int16_t lo = std::numeric_limits::max(); + std::int16_t hi = std::numeric_limits::min(); + bool any = false; + for (std::int16_t v : blk) { + if (v == kBlank) continue; + any = true; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (!any) return {kBlank, kBlank}; + return {lo, hi}; +} + +// 块尺寸(边缘块 < brick):第 b 块沿该轴的体素数。 +int extent(int n, int b, int brick) { + const int got = n - b * brick; + return got < brick ? got : brick; +} + +// 压缩一块 int16 体素 → qCompress 流(write 与 StreamingVolumeWriter 共用,DRY)。 +QByteArray compressBrick(const std::vector& blk) { + const int rawBytes = static_cast(blk.size() * sizeof(std::int16_t)); + return qCompress(reinterpret_cast(blk.data()), rawBytes); +} + +// 把几何/量化元信息写入 meta json(不含 bricks/levels 数组)——write 与 +// StreamingVolumeWriter::finalize 共用,确保 meta 结构逐字段一致(DRY)。 +void writeMetaGeometry(json& meta, int nx, int ny, int nz, int brick, + const std::array& origin, + const std::array& spacing, + const geopro::core::Quant& quant, double vminPhys, + double vmaxPhys) { + meta["nx"] = nx; + meta["ny"] = ny; + meta["nz"] = nz; + meta["brick"] = brick; + meta["origin"] = {origin[0], origin[1], origin[2]}; + meta["spacing"] = {spacing[0], spacing[1], spacing[2]}; + meta["quant"] = {{"scale", quant.scale}, {"offset", quant.offset}}; + meta["vminPhys"] = vminPhys; + meta["vmaxPhys"] = vmaxPhys; +} + +// 单块索引 json(write 与 StreamingVolumeWriter::finalize 共用,DRY)。 +json brickIndexJson(std::int64_t offset, std::int64_t clen, int bw, int bh, + int bd) { + return json{{"offset", offset}, + {"compressedLen", clen}, + {"bw", bw}, + {"bh", bh}, + {"bd", bd}}; +} + +// 2×2×2 平均降采样核(buildPyramid 与 buildPyramidStreaming 共用,DRY): +// 给定源维度 (snx,sny,snz) 与「读源体素 (si,sj,sk)」回调,算出 dst 体素 (i,j,k) +// 的降采样值——2×2×2 邻域非 blank 平均(round),全 blank → kBlank。越界邻域跳过。 +template +std::int16_t downsampleVoxel(int i, int j, int k, int snx, int sny, int snz, + SrcAt&& srcAt) { + long sum = 0; + int cnt = 0; + for (int dk = 0; dk < 2; ++dk) { + const int sk = 2 * k + dk; + if (sk >= snz) continue; + for (int dj = 0; dj < 2; ++dj) { + const int sj = 2 * j + dj; + if (sj >= sny) continue; + for (int di = 0; di < 2; ++di) { + const int si = 2 * i + di; + if (si >= snx) continue; + const std::int16_t v = srcAt(si, sj, sk); + if (v == kBlank) continue; + sum += v; + ++cnt; + } + } + } + if (cnt == 0) return kBlank; + const long avg = std::lround(static_cast(sum) / cnt); + return static_cast(avg); +} + +// 降采样后某轴维度:ceil(n/2),至少 1(buildPyramid 与流式共用)。 +int halfDim(int n) { return std::max(1, (n + 1) / 2); } + +// 从体中拷出一块的 int16(块内 i 最快、k 最慢,与体一致)。 +std::vector sliceBrick(const geopro::core::ScalarVolumeI16& vol, + int bx, int by, int bz, int brick, + int bw, int bh, int bd) { + std::vector out(static_cast(bw) * bh * bd); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) { + for (int jj = 0; jj < bh; ++jj) { + for (int ii = 0; ii < bw; ++ii) { + out[w++] = vol.at(i0 + ii, j0 + jj, k0 + kk); + } + } + } + return out; +} +} // namespace + +void ChunkedVolumeStore::write(const std::string& dir, + const geopro::core::BuiltI16& b, int brick) { + if (brick <= 0) throw std::invalid_argument("ChunkedVolumeStore: brick must be > 0"); + + const auto& vol = b.vol; + const int nx = vol.nx(), ny = vol.ny(), nz = vol.nz(); + + fs::create_directories(fs::path(dir)); + + const int bX = ceilDiv(nx, brick); + const int bY = ceilDiv(ny, brick); + const int bZ = ceilDiv(nz, brick); + + std::ofstream data((fs::path(dir) / kDataFile).string(), + std::ios::binary | std::ios::trunc); + if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open data.bin for write"); + + json bricks = json::array(); + std::int64_t offset = 0; + + // 固定遍历顺序:bz 最慢、bx 最快(与 brickIndex 一致)。 + for (int bz = 0; bz < bZ; ++bz) { + for (int by = 0; by < bY; ++by) { + for (int bx = 0; bx < bX; ++bx) { + const int bw = extent(nx, bx, brick); + const int bh = extent(ny, by, brick); + const int bd = extent(nz, bz, brick); + auto raw = sliceBrick(vol, bx, by, bz, brick, bw, bh, bd); + + const QByteArray compressed = compressBrick(raw); + const std::int64_t clen = compressed.size(); + + data.write(compressed.constData(), clen); + if (!data) throw std::runtime_error("ChunkedVolumeStore: data.bin write failed"); + + bricks.push_back(brickIndexJson(offset, clen, bw, bh, bd)); + offset += clen; + } + } + } + data.close(); + + json meta; + writeMetaGeometry(meta, nx, ny, nz, brick, b.origin, b.spacing, b.quant, + b.vminPhys, b.vmaxPhys); + meta["bricks"] = std::move(bricks); + + std::ofstream metaOut((fs::path(dir) / kMetaFile).string(), + std::ios::trunc); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json for write"); + metaOut << meta.dump(2); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: meta.json write failed"); +} + +StoreMeta ChunkedVolumeStore::readMeta(const std::string& dir) { + std::ifstream in((fs::path(dir) / kMetaFile).string()); + if (!in) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json"); + json meta; + in >> meta; + + StoreMeta m; + m.nx = meta.at("nx").get(); + m.ny = meta.at("ny").get(); + m.nz = meta.at("nz").get(); + m.brick = meta.at("brick").get(); + const auto& o = meta.at("origin"); + m.origin = {o[0].get(), o[1].get(), o[2].get()}; + const auto& s = meta.at("spacing"); + m.spacing = {s[0].get(), s[1].get(), s[2].get()}; + m.quant.scale = meta.at("quant").at("scale").get(); + m.quant.offset = meta.at("quant").at("offset").get(); + m.vminPhys = meta.at("vminPhys").get(); + m.vmaxPhys = meta.at("vmaxPhys").get(); + return m; +} + +ChunkedVolumeStore::ChunkedVolumeStore(const std::string& dir) : dir_(dir) { + std::ifstream in((fs::path(dir) / kMetaFile).string()); + if (!in) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json"); + json meta; + in >> meta; + + meta_.nx = meta.at("nx").get(); + meta_.ny = meta.at("ny").get(); + meta_.nz = meta.at("nz").get(); + meta_.brick = meta.at("brick").get(); + const auto& o = meta.at("origin"); + meta_.origin = {o[0].get(), o[1].get(), o[2].get()}; + const auto& sp = meta.at("spacing"); + meta_.spacing = {sp[0].get(), sp[1].get(), sp[2].get()}; + meta_.quant.scale = meta.at("quant").at("scale").get(); + meta_.quant.offset = meta.at("quant").at("offset").get(); + meta_.vminPhys = meta.at("vminPhys").get(); + meta_.vmaxPhys = meta.at("vmaxPhys").get(); + + bricksX_ = ceilDiv(meta_.nx, meta_.brick); + bricksY_ = ceilDiv(meta_.ny, meta_.brick); + bricksZ_ = ceilDiv(meta_.nz, meta_.brick); + + const auto& arr = meta.at("bricks"); + bricks_.reserve(arr.size()); + for (const auto& e : arr) { + BrickEntry be; + be.offset = e.at("offset").get(); + be.compressedLen = e.at("compressedLen").get(); + be.bw = e.at("bw").get(); + be.bh = e.at("bh").get(); + be.bd = e.at("bd").get(); + // hasRange 显式标志:老 store 无此字段 → false(brickRange 走惰性算)。 + // 若有 min/max 字段则视为已算(兼容 buildPyramid 之前写的、只带 min/max 的 meta)。 + be.hasRange = e.value("hasRange", e.contains("min") && e.contains("max")); + be.vmin = e.value("min", static_cast(0)); + be.vmax = e.value("max", static_cast(0)); + bricks_.push_back(be); + } + + // 始终从 bricks_ 重建 level 0(与兼容索引同源,复用 data.bin)。 + levels_.clear(); + Level lv0; + lv0.nx = meta_.nx; + lv0.ny = meta_.ny; + lv0.nz = meta_.nz; + lv0.bx = bricksX_; + lv0.by = bricksY_; + lv0.bz = bricksZ_; + lv0.dataFile = kDataFile; + lv0.bricks = bricks_; + levels_.push_back(std::move(lv0)); + + // 若 meta.json 含金字塔(buildPyramid 写入的 levels 数组),加载附加级。 + // levels[0] 与上面的 level 0 一致,跳过;levels[1..] 各自独立数据文件。 + if (meta.contains("levels") && meta.at("levels").is_array()) { + const auto& la = meta.at("levels"); + for (std::size_t L = 1; L < la.size(); ++L) { + const auto& je = la[L]; + Level lv; + lv.nx = je.at("nx").get(); + lv.ny = je.at("ny").get(); + lv.nz = je.at("nz").get(); + lv.bx = ceilDiv(lv.nx, meta_.brick); + lv.by = ceilDiv(lv.ny, meta_.brick); + lv.bz = ceilDiv(lv.nz, meta_.brick); + lv.dataFile = je.value("dataFile", levelDataFile(static_cast(L))); + const auto& jb = je.at("bricks"); + lv.bricks.reserve(jb.size()); + for (const auto& e : jb) { + BrickEntry be; + be.offset = e.at("offset").get(); + be.compressedLen = e.at("compressedLen").get(); + be.bw = e.at("bw").get(); + be.bh = e.at("bh").get(); + be.bd = e.at("bd").get(); + // 降采样级由 buildPyramid 写入,恒带 min/max → hasRange 默认 true。 + be.hasRange = e.value("hasRange", true); + be.vmin = e.value("min", kBlank); + be.vmax = e.value("max", kBlank); + lv.bricks.push_back(be); + } + levels_.push_back(std::move(lv)); + } + // 若 meta 含 level0 的 min/max(buildPyramid 写过),回填 level 0 与 bricks_。 + if (!la.empty() && la[0].contains("bricks")) { + const auto& jb0 = la[0].at("bricks"); + if (jb0.size() == levels_[0].bricks.size()) { + for (std::size_t i = 0; i < jb0.size(); ++i) { + const std::int16_t mn = jb0[i].value("min", kBlank); + const std::int16_t mx = jb0[i].value("max", kBlank); + const bool hr = jb0[i].value("hasRange", jb0[i].contains("min")); + levels_[0].bricks[i].vmin = mn; + levels_[0].bricks[i].vmax = mx; + levels_[0].bricks[i].hasRange = hr; + bricks_[i].vmin = mn; + bricks_[i].vmax = mx; + bricks_[i].hasRange = hr; + } + } + } + } + + levelCount_ = static_cast(levels_.size()); +} + +std::vector ChunkedVolumeStore::readBrick(int bx, int by, + int bz) const { + return readBrick(0, bx, by, bz); +} + +// ----------------------- 金字塔实现 ----------------------- + +const ChunkedVolumeStore::Level& ChunkedVolumeStore::levelAt(int level) const { + if (level < 0 || level >= static_cast(levels_.size())) { + throw std::out_of_range("ChunkedVolumeStore: level out of range"); + } + return levels_[static_cast(level)]; +} + +int ChunkedVolumeStore::bricksX(int level) const { return levelAt(level).bx; } +int ChunkedVolumeStore::bricksY(int level) const { return levelAt(level).by; } +int ChunkedVolumeStore::bricksZ(int level) const { return levelAt(level).bz; } + +void ChunkedVolumeStore::dims(int level, int& nx, int& ny, int& nz) const { + const Level& lv = levelAt(level); + nx = lv.nx; + ny = lv.ny; + nz = lv.nz; +} + +std::vector ChunkedVolumeStore::readBrickFrom(const Level& lv, + int bx, int by, + int bz) const { + if (bx < 0 || by < 0 || bz < 0 || bx >= lv.bx || by >= lv.by || bz >= lv.bz) { + throw std::out_of_range("ChunkedVolumeStore::readBrick: brick index out of range"); + } + const BrickEntry& be = + lv.bricks.at(static_cast(brickIndexAt(lv, bx, by, bz))); + + std::ifstream data((fs::path(dir_) / lv.dataFile).string(), std::ios::binary); + if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open level data file"); + data.seekg(static_cast(be.offset), std::ios::beg); + + QByteArray compressed; + compressed.resize(static_cast(be.compressedLen)); + data.read(compressed.data(), be.compressedLen); + if (!data) throw std::runtime_error("ChunkedVolumeStore: level data read failed"); + + const QByteArray raw = qUncompress(compressed); + const std::size_t count = static_cast(be.bw) * be.bh * be.bd; + if (static_cast(raw.size()) != count * sizeof(std::int16_t)) { + throw std::runtime_error("ChunkedVolumeStore: decompressed size mismatch"); + } + + std::vector out(count); + std::memcpy(out.data(), raw.constData(), raw.size()); + return out; +} + +std::vector ChunkedVolumeStore::readBrick(int level, int bx, + int by, int bz) const { + return readBrickFrom(levelAt(level), bx, by, bz); +} + +std::pair ChunkedVolumeStore::brickRange( + int level, int bx, int by, int bz) const { + if (level < 0 || level >= static_cast(levels_.size())) { + throw std::out_of_range("ChunkedVolumeStore: level out of range"); + } + Level& lv = levels_[static_cast(level)]; // mutable:可缓存惰性结果 + if (bx < 0 || by < 0 || bz < 0 || bx >= lv.bx || by >= lv.by || bz >= lv.bz) { + throw std::out_of_range("ChunkedVolumeStore::brickRange: brick index out of range"); + } + BrickEntry& be = + lv.bricks.at(static_cast(brickIndexAt(lv, bx, by, bz))); + // 已算(buildPyramid 时算过、或之前惰性算并缓存)→ 直接返回。 + // 注意:用 hasRange 显式标志而非 (0,0) 哨兵——(0,0) 是合法值域,且 kBlank!=0。 + if (be.hasRange) { + return {be.vmin, be.vmax}; + } + // 未算(老 store level 0):惰性读块计算,并就地缓存 + 置 hasRange。 + const auto rng = computeRange(readBrickFrom(lv, bx, by, bz)); + be.vmin = rng.first; + be.vmax = rng.second; + be.hasRange = true; + return rng; +} + +void ChunkedVolumeStore::buildPyramid(int levels) { + // 参数 levels = 最高级索引(level 1..levels 为降采样级);总级数(含 level 0) + // = levels + 1。levels<=0 视为仅 level 0。 + const int target = levels < 0 ? 1 : levels + 1; + const int brick = meta_.brick; + + // 1) 重建所有级到内存(整卷重组 → 逐级 2× 平均降采样)。 + // level 0 直接从已存块重组,避免依赖外部 BuiltI16。 + struct DenseLevel { + int nx, ny, nz; + std::vector vox; // i 最快、k 最慢 + }; + + auto idxAt = [](int nx, int ny, int i, int j, int k) -> std::size_t { + return (static_cast(k) * ny + j) * nx + i; + }; + + // 重组 level 0 整卷。 + DenseLevel d0; + d0.nx = meta_.nx; + d0.ny = meta_.ny; + d0.nz = meta_.nz; + d0.vox.assign(static_cast(d0.nx) * d0.ny * d0.nz, kBlank); + const Level& lv0 = levels_[0]; + for (int bz = 0; bz < lv0.bz; ++bz) { + for (int by = 0; by < lv0.by; ++by) { + for (int bx = 0; bx < lv0.bx; ++bx) { + const auto blk = readBrickFrom(lv0, bx, by, bz); + const BrickEntry& be = + lv0.bricks[static_cast(brickIndexAt(lv0, bx, by, bz))]; + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < be.bd; ++kk) + for (int jj = 0; jj < be.bh; ++jj) + for (int ii = 0; ii < be.bw; ++ii) + d0.vox[idxAt(d0.nx, d0.ny, i0 + ii, j0 + jj, k0 + kk)] = blk[w++]; + } + } + } + + std::vector dense; + dense.push_back(std::move(d0)); + for (int L = 1; L < target; ++L) { + const DenseLevel& src = dense.back(); + DenseLevel dst; + dst.nx = halfDim(src.nx); + dst.ny = halfDim(src.ny); + dst.nz = halfDim(src.nz); + dst.vox.assign(static_cast(dst.nx) * dst.ny * dst.nz, kBlank); + const auto srcAt = [&](int si, int sj, int sk) -> std::int16_t { + return src.vox[idxAt(src.nx, src.ny, si, sj, sk)]; + }; + for (int k = 0; k < dst.nz; ++k) + for (int j = 0; j < dst.ny; ++j) + for (int i = 0; i < dst.nx; ++i) + // 2×2×2 邻域非 blank 平均(round),全 blank → kBlank(共用核 DRY)。 + dst.vox[idxAt(dst.nx, dst.ny, i, j, k)] = + downsampleVoxel(i, j, k, src.nx, src.ny, src.nz, srcAt); + dense.push_back(std::move(dst)); + } + + // 2) 逐级落盘(分块 qCompress)+ 计算每块 min/max,重建 levels_。 + std::vector rebuilt; + rebuilt.reserve(dense.size()); + for (std::size_t L = 0; L < dense.size(); ++L) { + const DenseLevel& dl = dense[L]; + Level lv; + lv.nx = dl.nx; + lv.ny = dl.ny; + lv.nz = dl.nz; + lv.bx = ceilDiv(dl.nx, brick); + lv.by = ceilDiv(dl.ny, brick); + lv.bz = ceilDiv(dl.nz, brick); + lv.dataFile = levelDataFile(static_cast(L)); + + std::ofstream data((fs::path(dir_) / lv.dataFile).string(), + std::ios::binary | std::ios::trunc); + if (!data) throw std::runtime_error("ChunkedVolumeStore: cannot open level data for write"); + std::int64_t offset = 0; + for (int bz = 0; bz < lv.bz; ++bz) { + for (int by = 0; by < lv.by; ++by) { + for (int bx = 0; bx < lv.bx; ++bx) { + const int bw = std::min(brick, dl.nx - bx * brick); + const int bh = std::min(brick, dl.ny - by * brick); + const int bd = std::min(brick, dl.nz - bz * brick); + std::vector blk(static_cast(bw) * bh * bd); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) + for (int jj = 0; jj < bh; ++jj) + for (int ii = 0; ii < bw; ++ii) + blk[w++] = dl.vox[idxAt(dl.nx, dl.ny, i0 + ii, j0 + jj, k0 + kk)]; + + const int rawBytes = + static_cast(blk.size() * sizeof(std::int16_t)); + const QByteArray compressed = qCompress( + reinterpret_cast(blk.data()), rawBytes); + const std::int64_t clen = compressed.size(); + data.write(compressed.constData(), clen); + if (!data) throw std::runtime_error("ChunkedVolumeStore: level data write failed"); + + const auto rng = computeRange(blk); + BrickEntry be; + be.offset = offset; + be.compressedLen = clen; + be.bw = bw; + be.bh = bh; + be.bd = bd; + be.vmin = rng.first; + be.vmax = rng.second; + be.hasRange = true; // buildPyramid 现算现存,值域确定。 + lv.bricks.push_back(be); + offset += clen; + } + } + } + data.close(); + rebuilt.push_back(std::move(lv)); + } + + finalizePyramidMeta(std::move(rebuilt)); +} + +void ChunkedVolumeStore::finalizePyramidMeta(std::vector rebuilt) { + levels_ = std::move(rebuilt); + levelCount_ = static_cast(levels_.size()); + // 兼容字段:level 0 索引(含新算的 min/max)回写 bricks_。 + bricks_ = levels_[0].bricks; + + // 重写 meta.json:保留所有原字段(含原 bricks),追加/覆盖 levels 数组。 + const fs::path metaPath = fs::path(dir_) / kMetaFile; + json meta; + { + std::ifstream in(metaPath.string()); + if (!in) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json"); + in >> meta; + } + // 原 bricks(level 0 兼容索引)补上 min/max,保持 readBrick/老读取不变。 + if (meta.contains("bricks") && meta.at("bricks").is_array()) { + auto& jb = meta.at("bricks"); + if (jb.size() == levels_[0].bricks.size()) { + for (std::size_t i = 0; i < jb.size(); ++i) { + jb[i]["min"] = levels_[0].bricks[i].vmin; + jb[i]["max"] = levels_[0].bricks[i].vmax; + jb[i]["hasRange"] = levels_[0].bricks[i].hasRange; + } + } + } + json jlevels = json::array(); + for (std::size_t L = 0; L < levels_.size(); ++L) { + const Level& lv = levels_[L]; + json jbricks = json::array(); + for (const auto& be : lv.bricks) { + jbricks.push_back(json{{"offset", be.offset}, + {"compressedLen", be.compressedLen}, + {"bw", be.bw}, + {"bh", be.bh}, + {"bd", be.bd}, + {"min", be.vmin}, + {"max", be.vmax}, + {"hasRange", be.hasRange}}); + } + jlevels.push_back(json{{"nx", lv.nx}, + {"ny", lv.ny}, + {"nz", lv.nz}, + {"dataFile", lv.dataFile}, + {"bricks", std::move(jbricks)}}); + } + meta["levels"] = std::move(jlevels); + + std::ofstream metaOut(metaPath.string(), std::ios::trunc); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: cannot open meta.json for write"); + metaOut << meta.dump(2); + if (!metaOut) throw std::runtime_error("ChunkedVolumeStore: meta.json write failed"); +} + +void ChunkedVolumeStore::buildPyramidStreaming(int levels) { + // 参数语义同 buildPyramid:levels = 最高级索引,总级数 = levels+1,levels<=0 仅 + // level 0。区别:各降采样级**逐块**由盘上 level L-1 的 ≤2×2×2 邻块降采样得到, + // 不重组整卷——任意时刻只持几个 L-1 邻块 + 一个 L 块。 + const int target = levels < 0 ? 1 : levels + 1; + const int brick = meta_.brick; + + std::vector rebuilt; + rebuilt.reserve(static_cast(target)); + + // level 0:数据文件(data.bin)不变,复用现有 offset/clen,仅补每块 min/max。 + // 内存有界:逐块读盘算 range,不重组整卷。 + { + Level lv0 = levels_[0]; // 拷 nx/ny/nz/块数/dataFile/索引 + for (int bz = 0; bz < lv0.bz; ++bz) + for (int by = 0; by < lv0.by; ++by) + for (int bx = 0; bx < lv0.bx; ++bx) { + BrickEntry& be = + lv0.bricks[static_cast(brickIndexAt(lv0, bx, by, bz))]; + const auto rng = computeRange(readBrickFrom(levels_[0], bx, by, bz)); + be.vmin = rng.first; + be.vmax = rng.second; + be.hasRange = true; + } + rebuilt.push_back(std::move(lv0)); + } + + // 降采样级 L=1..target-1:源 = rebuilt.back()(盘上 level L-1)。 + for (int L = 1; L < target; ++L) { + const Level& src = rebuilt.back(); // 提供 dims/块数/dataFile,readBrickFrom 从盘读 + + Level lv; + lv.nx = halfDim(src.nx); + lv.ny = halfDim(src.ny); + lv.nz = halfDim(src.nz); + lv.bx = ceilDiv(lv.nx, brick); + lv.by = ceilDiv(lv.ny, brick); + lv.bz = ceilDiv(lv.nz, brick); + lv.dataFile = levelDataFile(L); + + std::ofstream data((fs::path(dir_) / lv.dataFile).string(), + std::ios::binary | std::ios::trunc); + if (!data) + throw std::runtime_error("ChunkedVolumeStore: cannot open level data for write"); + std::int64_t offset = 0; + + // 逐 L 块(固定顺序 bz 最慢、bx 最快,与 write/buildPyramid 一致)。 + for (int bz = 0; bz < lv.bz; ++bz) { + for (int by = 0; by < lv.by; ++by) { + for (int bx = 0; bx < lv.bx; ++bx) { + const int bw = std::min(brick, lv.nx - bx * brick); + const int bh = std::min(brick, lv.ny - by * brick); + const int bd = std::min(brick, lv.nz - bz * brick); + // 该 L 块 dst 体素全局坐标基点。 + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + + // 覆盖的 L-1 体素子区域 = [2*i0, 2*(i0+bw)) 截到 src 维度。 + const int sLoI = 2 * i0, sLoJ = 2 * j0, sLoK = 2 * k0; + const int sHiI = std::min(src.nx, 2 * (i0 + bw)); + const int sHiJ = std::min(src.ny, 2 * (j0 + bh)); + const int sHiK = std::min(src.nz, 2 * (k0 + bd)); + + // 落在的 L-1 邻块范围(≤2×2×2)。读入小缓存(按相对块索引存)。 + const int sbi0 = sLoI / brick, sbi1 = (sHiI - 1) / brick; + const int sbj0 = sLoJ / brick, sbj1 = (sHiJ - 1) / brick; + const int sbk0 = sLoK / brick, sbk1 = (sHiK - 1) / brick; + const int nbi = sbi1 - sbi0 + 1; + const int nbj = sbj1 - sbj0 + 1; + const int nbk = sbk1 - sbk0 + 1; + std::vector> cache( + static_cast(nbi) * nbj * nbk); + for (int kk = 0; kk < nbk; ++kk) + for (int jj = 0; jj < nbj; ++jj) + for (int ii = 0; ii < nbi; ++ii) + cache[(static_cast(kk) * nbj + jj) * nbi + ii] = + readBrickFrom(src, sbi0 + ii, sbj0 + jj, sbk0 + kk); + + // 从邻块缓存读源体素 (si,sj,sk)(全局 L-1 坐标)。 + const auto srcAt = [&](int si, int sj, int sk) -> std::int16_t { + const int cbi = si / brick - sbi0; + const int cbj = sj / brick - sbj0; + const int cbk = sk / brick - sbk0; + const std::vector& blk = + cache[(static_cast(cbk) * nbj + cbj) * nbi + cbi]; + // 块内 i 最快、k 最慢;块宽 = 该源块 extent。 + const int lbw = extent(src.nx, si / brick, brick); + const int lbh = extent(src.ny, sj / brick, brick); + const int li = si % brick, lj = sj % brick, lk = sk % brick; + return blk[(static_cast(lk) * lbh + lj) * lbw + li]; + }; + + // 逐 dst 体素降采样(共用核)。 + std::vector blk(static_cast(bw) * bh * bd); + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) + for (int jj = 0; jj < bh; ++jj) + for (int ii = 0; ii < bw; ++ii) + blk[w++] = downsampleVoxel(i0 + ii, j0 + jj, k0 + kk, src.nx, + src.ny, src.nz, srcAt); + + const QByteArray compressed = compressBrick(blk); + const std::int64_t clen = compressed.size(); + data.write(compressed.constData(), clen); + if (!data) + throw std::runtime_error("ChunkedVolumeStore: level data write failed"); + + const auto rng = computeRange(blk); + BrickEntry be; + be.offset = offset; + be.compressedLen = clen; + be.bw = bw; + be.bh = bh; + be.bd = bd; + be.vmin = rng.first; + be.vmax = rng.second; + be.hasRange = true; + lv.bricks.push_back(be); + offset += clen; + } + } + } + data.close(); + rebuilt.push_back(std::move(lv)); + } + + finalizePyramidMeta(std::move(rebuilt)); +} + +// ----------------------- StreamingVolumeWriter ----------------------- + +StreamingVolumeWriter::StreamingVolumeWriter(const std::string& dir, + const StoreMeta& meta) + : dir_(dir), meta_(meta) { + if (meta_.brick <= 0) + throw std::invalid_argument("StreamingVolumeWriter: brick must be > 0"); + + bricksX_ = ceilDiv(meta_.nx, meta_.brick); + bricksY_ = ceilDiv(meta_.ny, meta_.brick); + bricksZ_ = ceilDiv(meta_.nz, meta_.brick); + + // 预分配固定顺序索引(bz 最慢、bx 最快),与 write 的遍历顺序一致 → 同布局。 + entries_.assign(static_cast(bricksX_) * bricksY_ * bricksZ_, + Entry{}); + + fs::create_directories(fs::path(dir_)); + // B3 MEDIUM 修复:持久句柄。构造时截断式打开一次 data.bin,writeBrick 复用此句柄 + // 顺序追加(不再逐块重开),finalize 关闭。避免每块一次 open/close 的系统调用开销。 + data_.open((fs::path(dir_) / kDataFile).string(), + std::ios::binary | std::ios::trunc); + if (!data_) + throw std::runtime_error( + "StreamingVolumeWriter: cannot open data.bin for write"); +} + +void StreamingVolumeWriter::writeBrick(int bx, int by, int bz, + const std::vector& voxels) { + if (finalized_) + throw std::runtime_error( + "StreamingVolumeWriter: writeBrick after finalize"); + if (bx < 0 || by < 0 || bz < 0 || bx >= bricksX_ || by >= bricksY_ || + bz >= bricksZ_) + throw std::runtime_error("StreamingVolumeWriter: brick index out of range"); + + const std::size_t idx = + (static_cast(bz) * bricksY_ + by) * bricksX_ + bx; + Entry& e = entries_[idx]; + if (e.written) + throw std::runtime_error("StreamingVolumeWriter: brick written twice"); + + const int bw = extent(meta_.nx, bx, meta_.brick); + const int bh = extent(meta_.ny, by, meta_.brick); + const int bd = extent(meta_.nz, bz, meta_.brick); + const std::size_t expect = static_cast(bw) * bh * bd; + if (voxels.size() != expect) + throw std::runtime_error("StreamingVolumeWriter: brick voxel count mismatch"); + + const QByteArray compressed = compressBrick(voxels); + const std::int64_t clen = compressed.size(); + + // 复用持久句柄顺序追加 data.bin(块按 writeBrick 调用顺序物理排布,索引记录各自 + // 偏移;finalize 再按固定顺序写 meta → readBrick 凭索引偏移定位,物理顺序无关)。 + data_.write(compressed.constData(), clen); + if (!data_) + throw std::runtime_error("StreamingVolumeWriter: data.bin write failed"); + + e.offset = offset_; + e.compressedLen = clen; + e.bw = bw; + e.bh = bh; + e.bd = bd; + e.written = true; + offset_ += clen; + ++written_; +} + +void StreamingVolumeWriter::finalize() { + if (finalized_) + throw std::runtime_error("StreamingVolumeWriter: already finalized"); + if (written_ != static_cast(entries_.size())) + throw std::runtime_error("StreamingVolumeWriter: missing bricks at finalize"); + + // 刷新并关闭持久 data.bin 句柄(B3 MEDIUM:句柄生命周期 = 构造→finalize)。 + data_.flush(); + if (!data_) + throw std::runtime_error("StreamingVolumeWriter: data.bin flush failed"); + data_.close(); + + // 按固定顺序(bz 最慢、bx 最快)输出索引,结构与 write 的 bricks 数组一致。 + json bricks = json::array(); + for (const Entry& e : entries_) + bricks.push_back(brickIndexJson(e.offset, e.compressedLen, e.bw, e.bh, e.bd)); + + json meta; + writeMetaGeometry(meta, meta_.nx, meta_.ny, meta_.nz, meta_.brick, + meta_.origin, meta_.spacing, meta_.quant, meta_.vminPhys, + meta_.vmaxPhys); + meta["bricks"] = std::move(bricks); + + std::ofstream metaOut((fs::path(dir_) / kMetaFile).string(), + std::ios::trunc); + if (!metaOut) + throw std::runtime_error( + "StreamingVolumeWriter: cannot open meta.json for write"); + metaOut << meta.dump(2); + if (!metaOut) + throw std::runtime_error("StreamingVolumeWriter: meta.json write failed"); + finalized_ = true; +} + +} // namespace geopro::data diff --git a/src/data/store/ChunkedVolumeStore.hpp b/src/data/store/ChunkedVolumeStore.hpp new file mode 100644 index 0000000..d476d81 --- /dev/null +++ b/src/data/store/ChunkedVolumeStore.hpp @@ -0,0 +1,174 @@ +#ifndef GEOPRO_DATA_STORE_CHUNKEDVOLUMESTORE_HPP +#define GEOPRO_DATA_STORE_CHUNKEDVOLUMESTORE_HPP + +#include +#include +#include +#include +#include +#include + +#include "model/ScalarVolumeI16.hpp" // geopro::core::Quant + +namespace geopro::core { +struct BuiltI16; // src/core/algo/GprVolumeBuilder.hpp +} + +namespace geopro::data { + +// 分块存储的 sidecar 元数据(meta.json 反序列化结果,不含逐块索引)。 +struct StoreMeta { + int nx = 0, ny = 0, nz = 0; + int brick = 64; + std::array origin{{0, 0, 0}}; + std::array spacing{{0, 0, 0}}; + geopro::core::Quant quant; // scale/offset + double vminPhys = 0, vmaxPhys = 0; +}; + +// GPR 三维体的分块压缩落盘(B/C 共用基座)。 +// 格式:dir/meta.json(几何 + 量化 + 逐块索引)+ dir/data.bin(逐块 qCompress 流)。 +// 块布局与体一致(块内 i 最快、k 最慢);边缘块尺寸 < brick。 +// 偏移/长度全程 64 位(块偏移可能 > 2GB)。 +class ChunkedVolumeStore { + public: + // 落盘:dir/meta.json + dir/data.bin。逐块 int16 → qCompress 压缩流, + // 块索引/偏移/压缩长度记入 meta.json。dir 不存在则创建。 + static void write(const std::string& dir, const geopro::core::BuiltI16& b, + int brick = 64); + + // 只读 meta.json(不打开 data.bin)。 + static StoreMeta readMeta(const std::string& dir); + + // 读 meta + 打开 data.bin。 + explicit ChunkedVolumeStore(const std::string& dir); + + const StoreMeta& meta() const { return meta_; } + + // --- level 0 兼容接口(语义不变,= 全分辨率级)--- + int bricksX() const { return bricksX_; } + int bricksY() const { return bricksY_; } + int bricksZ() const { return bricksZ_; } + + // 读单块 → 还原 int16 vector。返回长度 = bw*bh*bd(内部块 = brick³,边缘块更小)。 + // == readBrick(0, bx, by, bz)。 + std::vector readBrick(int bx, int by, int bz) const; + + // --- 金字塔(多分辨率 LOD)--- + // 在已 write 的 store 目录上构建金字塔:level 0=全分辨率(已存), + // level 1..levels 逐级 2× 降采样(维度 ceil(n/2)),故总级数 = levels+1。 + // 同时为所有 level(含 0)计算并存每块 (min,max)(跳过 kBlank)。结果写回 + // meta.json + 各级数据文件(level 0 复用现有 data.bin,level L 写 + // data_L.bin)。levels<=0 视为无金字塔(仅 level 0)。 + void buildPyramid(int levels); + + // 流式构建金字塔:level L 的每块由 level L-1 的对应 ≤2×2×2 邻块(从盘 readBrick + // 读)降采样 + 逐块增量写得到,**不重组整卷**——任意时刻只持几个 L-1 邻块 + 一个 + // L 块。产出(各级 dims/每块体素/min/max/hasRange/meta)与 buildPyramid(levels) + // 逐块一致。降采样/blank/min-max 规则复用 buildPyramid 同一核(DRY)。 + // 需先有 level0 store(write 或流式建体产出)。levels<=0 视为仅 level 0。 + void buildPyramidStreaming(int levels); + + // 总层数(含 level 0);未建金字塔时 = 1。 + int levels() const { return levelCount_; } + + // 各级块数;bricksX() == bricksX(0) 保持兼容。 + int bricksX(int level) const; + int bricksY(int level) const; + int bricksZ(int level) const; + + // 各级体素维度。 + void dims(int level, int& nx, int& ny, int& nz) const; + + // 读某级单块 → 还原 int16 vector。level 0 与兼容重载等价。 + std::vector readBrick(int level, int bx, int by, int bz) const; + + // 每块 (min,max),跳过 kBlank;全 blank 块返回 (kBlank,kBlank)。 + // 对未建金字塔的 level 0,惰性读块计算。 + std::pair brickRange(int level, int bx, int by, + int bz) const; + + private: + // 单块在所属级数据文件中的位置、未压缩尺寸与值域。 + struct BrickEntry { + std::int64_t offset = 0; + std::int64_t compressedLen = 0; + int bw = 0, bh = 0, bd = 0; + std::int16_t vmin = 0, vmax = 0; // 块内 (min,max),跳过 kBlank;全 blank=(kBlank,kBlank) + // 显式「值域已算」标志:替代 (0,0) 哨兵。(0,0) 是合法值域,不能当未计算用。 + // false → brickRange 惰性读块计算(并缓存);true → 直接返回 (vmin,vmax)。 + bool hasRange = false; + }; + + // 一个分辨率级:维度 + 块数 + 逐块索引 + 数据文件名。 + struct Level { + int nx = 0, ny = 0, nz = 0; + int bx = 0, by = 0, bz = 0; // 块数 + std::string dataFile; + std::vector bricks; + }; + + int brickIndexAt(const Level& lv, int bx, int by, int bz) const { + return (bz * lv.by + by) * lv.bx + bx; + } + const Level& levelAt(int level) const; + std::vector readBrickFrom(const Level& lv, int bx, int by, + int bz) const; + + // 用 rebuilt 各级(已落盘各自数据文件、每块带 offset/clen/min/max/hasRange) + // 替换 levels_,回写兼容字段 bricks_/levelCount_,并重写 meta.json 的 levels + // 数组 + level0 兼容 bricks 的 min/max。buildPyramid 与 buildPyramidStreaming + // 共用此收尾,确保两者 meta 结构逐字段一致(DRY)。 + void finalizePyramidMeta(std::vector rebuilt); + + std::string dir_; + StoreMeta meta_; + int bricksX_ = 0, bricksY_ = 0, bricksZ_ = 0; // = level 0 块数(兼容字段) + std::vector bricks_; // = level 0 索引(兼容字段,readBrick(bx,by,bz) 用) + + int levelCount_ = 1; + // mutable:brickRange 为 const,但惰性算出值域后需就地缓存(置 hasRange=true)。 + mutable std::vector levels_; // levels_[0] 即 level 0(与 bricks_ 同源) + + friend class StreamingVolumeWriter; +}; + +// 逐块增量写 level0 store(不持整卷)。块写入顺序任意,但每块只写一次。 +// 产出与 ChunkedVolumeStore::write(整卷) 逐 brick + meta 完全一致:data.bin 为 +// 逐块 qCompress 流(按 bz 最慢、bx 最快的固定顺序排布),meta.json 结构同 write, +// 故 ChunkedVolumeStore(dir)/readBrick 能照常读。偏移/长度全程 64 位。 +class StreamingVolumeWriter { + public: + // 用 StoreMeta 定 dims/brick/origin/spacing/quant/vminmax(与 write 一致的元信息)。 + StreamingVolumeWriter(const std::string& dir, const StoreMeta& meta); + + // 写一块:voxels 为该块体素(大小=bw*bh*bd,块内 i 最快,与 write 同布局)。 + // bx/by/bz 为块索引;体素数不符或同块重复写 → 抛 std::runtime_error。 + void writeBrick(int bx, int by, int bz, + const std::vector& voxels); + + // 收尾:写 meta.json(含所有已写块的索引)。有缺块 → 抛 std::runtime_error。 + void finalize(); + + private: + // 单块在 data.bin 中的索引(与 ChunkedVolumeStore::BrickEntry 子集对应)。 + struct Entry { + std::int64_t offset = 0; + std::int64_t compressedLen = 0; + int bw = 0, bh = 0, bd = 0; + bool written = false; + }; + + std::string dir_; + StoreMeta meta_; + int bricksX_ = 0, bricksY_ = 0, bricksZ_ = 0; + std::vector entries_; // 固定顺序索引(bz 最慢、bx 最快) + std::ofstream data_; // 持久 data.bin 句柄(构造开、writeBrick 复用、finalize 关) + std::int64_t offset_ = 0; // data.bin 当前追加偏移(64 位) + std::int64_t written_ = 0; // 已写块计数 + bool finalized_ = false; +}; + +} // namespace geopro::data + +#endif // GEOPRO_DATA_STORE_CHUNKEDVOLUMESTORE_HPP diff --git a/src/io/CMakeLists.txt b/src/io/CMakeLists.txt new file mode 100644 index 0000000..e86a626 --- /dev/null +++ b/src/io/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(gpr) diff --git a/src/io/gpr/CMakeLists.txt b/src/io/gpr/CMakeLists.txt new file mode 100644 index 0000000..4afd503 --- /dev/null +++ b/src/io/gpr/CMakeLists.txt @@ -0,0 +1,25 @@ +add_library(geopro_io_gpr STATIC + IprHeader.cpp IprbReader.cpp GprGeometry.cpp GprSurveyAssembler.cpp + GpsTrack.cpp) +target_include_directories(geopro_io_gpr PUBLIC ${CMAKE_SOURCE_DIR}/src) +target_compile_features(geopro_io_gpr PUBLIC cxx_std_17) +# GprSurveyAssembler 返回 geopro::core::GprSurvey(头文件内联,仅需 include 解析)。 +target_link_libraries(geopro_io_gpr PUBLIC geopro_core) + +# 纯 C++17 解析层,零 Qt/VTK。顶层全局开启了 AUTOMOC/UIC/RCC,这里显式关闭保持纯净。 +set_target_properties(geopro_io_gpr PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) + +# gpr3dv 桥接层(P2/P8):把 vendored gpr3dv 处理链 → geopro 量化体(BuiltI16)。 +# 独立 target(不污染上面的纯 C++17 解析层):链 geopro_gpr3dv(Qt)+geopro_core。 +# Gpr3dvVolumeBridge —— P2 线局部坐标体(X=道/Y=通道/Z=样本,origin≈0)。 +# Gpr3dvSurveyVolumeBridge —— P8 测绘级 CGCS2000 世界对齐体(逐道跟 GPS 轨迹); +# 额外用 GpsTrack(geopro_io_gpr) 解析 .gps。 +add_library(geopro_gpr3dv_bridge STATIC + Gpr3dvVolumeBridge.cpp + Gpr3dvSurveyVolumeBridge.cpp) +target_include_directories(geopro_gpr3dv_bridge PUBLIC ${CMAKE_SOURCE_DIR}/src) +target_compile_features(geopro_gpr3dv_bridge PUBLIC cxx_std_17) +target_link_libraries(geopro_gpr3dv_bridge PUBLIC + geopro_core geopro_gpr3dv geopro_io_gpr) +set_target_properties(geopro_gpr3dv_bridge PROPERTIES + AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) diff --git a/src/io/gpr/Gpr3dvSurveyVolumeBridge.cpp b/src/io/gpr/Gpr3dvSurveyVolumeBridge.cpp new file mode 100644 index 0000000..03f7803 --- /dev/null +++ b/src/io/gpr/Gpr3dvSurveyVolumeBridge.cpp @@ -0,0 +1,294 @@ +#include "io/gpr/Gpr3dvSurveyVolumeBridge.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "CScanGridder.h" +#include "CoordinateTransform.h" +#include "GPRDataModel.h" +#include "IprhParser.h" +#include "RadarProcessor.h" +#include "SurveyGeometry.h" +#include "TrajectoryCalculator.h" + +#include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/GpsTrack.hpp" + +namespace geopro::io::gpr { + +namespace { + +// 全体 traces 平均绝对幅值——「处理是否生效」标量证据(与 P2 桥接同口径)。 +double meanAbsAmplitude(const GPRDataModel& model) { + long double sum = 0.0L; + long long count = 0; + for (const RadarTrace& tr : model.traces) { + for (short v : tr.amplitudes) { + sum += static_cast(v < 0 ? -v : v); + ++count; + } + } + return count > 0 ? static_cast(sum / count) : 0.0; +} + +// 深度采样间距(米):(timeWindow/samples) ns × 波速(m/ns) / 2(往返)。 +// 与 GPRDataModel::calculateDepth 同式。取不到 → 1.0(单位间距)。 +double depthSpacingZ(const GPRDataModel::Header& h) { + if (h.samplesPerTrace <= 0 || h.timeWindowNs <= 0.0) return 1.0; + const double timePerSample = h.timeWindowNs / h.samplesPerTrace; // ns + const double vel = h.waveVelocity > 0.0 ? h.waveVelocity : 0.1; // m/ns + const double dz = timePerSample * vel / 2.0; + return dz > 1e-12 ? dz : 1.0; +} + +double nowMs(std::chrono::steady_clock::time_point t0) { + const auto t1 = std::chrono::steady_clock::now(); + return std::chrono::duration(t1 - t0).count(); +} + +constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; + +} // namespace + +geopro::core::BuiltI16 buildLineVolumeSurvey(const std::string& lineDir, + const std::string& linePrefix, + const std::string& gpsPath, + SurveyBridgeMetrics* metricsOut, + int coarse, double cellSizeM, + double searchRadiusM) { + const int stride = coarse > 1 ? coarse : 1; // 深度下采样步长(≥1) + const QString dir = QString::fromLocal8Bit(lineDir.c_str()); + const QString base = QString::fromLocal8Bit(linePrefix.c_str()); + + // 1) 走 P1 链:load → buildVolumeData(处理前) → runPipeline → 再 buildVolumeData。 + const auto tLoad = std::chrono::steady_clock::now(); + GPRDataModel model; + if (!IprhParser::loadImpulseMultiChannel(dir, base, model)) { + throw std::runtime_error("loadImpulseMultiChannel 失败: " + lineDir + " / " + + linePrefix); + } + model.buildVolumeData(); + const double meanBefore = meanAbsAmplitude(model); + const double loadMs = nowMs(tLoad); + + const auto tPipe = std::chrono::steady_clock::now(); + RadarProcessor::ProcPipeline pipeline; + pipeline.setDefaultFlow(); + GPRDataModel processed = RadarProcessor::runPipeline(model, pipeline); + // 零时校正会改 samplesPerTrace,且 runPipeline 不重建 volumeData → 必须再建一次。 + processed.buildVolumeData(); + const double meanAfter = meanAbsAmplitude(processed); + const double pipelineMs = nowMs(tPipe); + + // 2) 几何 + GPS→CGCS2000 世界坐标(逐 RTK 点)。 + const auto tTraj = std::chrono::steady_clock::now(); + // 值拷贝(非引用):下面 processed 会被 std::move 进 SurveyLine,之后仍要用 header 算 dz, + // 引用会变悬垂(use-after-move)。Header 是小结构(几个 QVector/QString),拷贝代价可忽略。 + const GPRDataModel::Header h = processed.header; + const int channels = h.numberOfChannels > 0 ? h.numberOfChannels : 1; + + // SurveyGeometry:逐通道天线横偏(归一到天线中心),来自头 CH_X/Y_OFFSETS。 + SurveyGeometry geom = + SurveyGeometry::fromHeaderOffsets(channels, h.chXOffsets, h.chYOffsets); + + // .gps 经纬(度) → CGCS2000 平面(北X, 东Y) 米。带号由首点经度自动定(3°带)。 + const geopro::io::gpr::GpsTrack track = geopro::io::gpr::parseGps(gpsPath); + if (track.pts.size() < 2) { + throw std::runtime_error("GPS 轨迹点 <2(无法定位/航向): " + gpsPath); + } + const double lon0Deg = track.pts.front().lon; + // 3°带号 = round(中央经线/3);中央经线最近 3° 倍数。 + const int zone = static_cast(std::lround(lon0Deg / 3.0)); + const double centralMeridianDeg = CoordinateTransform::centralMeridianFromZone(zone); + geom.cgcsZone = zone; + geom.centralMeridianDeg = centralMeridianDeg; + + // 先把所有 GPS 点正算到 CGCS2000(double,含带号),求局部原点。 + // 根因(短线 007 失败):CGCS easting 含带号 ≈ 4.0e7 米,存入 QVector3D(float32)后 + // 该量级 ULP ≈ 4 米——0.81 m 的东向跨度(近正北短线)被浮点量化抹平成同一个值, + // CScanGridder 里 maxX<=minX 判退 → 空网格(「深度0 网格无效」)。 + // 桥接层修法(不碰 verbatim 算法):存入 float 轨迹前先平移到局部原点(floor 到整米), + // 令坐标降到 ~10² 米量级、float 亚毫米精度无损;CScanGridder/TrajectoryCalculator 全程 + // 只用差分/距离/IDW(对常量平移不变),输出与未平移逐位一致——只是网格 origin 也变成局部值, + // 故最后把平移量加回 built.origin 即还原真实 CGCS2000 世界米。 + struct CgcsPt { double north; double east; double elev; }; + std::vector cgcsPts; + cgcsPts.reserve(track.pts.size()); + for (const auto& p : track.pts) { + double cx = 0.0, cy = 0.0; // cx=北(northing), cy=东(easting,含带号) + CoordinateTransform::wgs84ToCgcs2000(p.lat * kDeg2Rad, p.lon * kDeg2Rad, + zone, cx, cy); + cgcsPts.push_back({cx, cy, p.elev}); + } + // 局部原点 = 首点 floor 到整米(north→x 轴, east→y 轴,与 gpsPositions 约定一致)。 + const double localOriginNorth = std::floor(cgcsPts.front().north); + const double localOriginEast = std::floor(cgcsPts.front().east); + + QVector gpsCgcs; + gpsCgcs.reserve(static_cast(cgcsPts.size())); + for (const auto& q : cgcsPts) { + // SurveyLine.gpsPositions 约定 x=北, y=东, z=高(与 TrajectoryCalculator 一致)。 + // 平移到局部原点后再转 float(保住亚米精度)。 + gpsCgcs.append(QVector3D(static_cast(q.north - localOriginNorth), + static_cast(q.east - localOriginEast), + static_cast(q.elev))); + } + + SurveyLine line; + line.data = std::move(processed); + line.geometry = geom; + line.gpsPositions = gpsCgcs; + if (!TrajectoryCalculator::computeTrajectories(line, geom)) { + throw std::runtime_error("computeTrajectories 失败: " + linePrefix); + } + if (!line.hasValidTrajectories()) { + throw std::runtime_error("无有效通道轨迹: " + linePrefix); + } + const double trajMs = nowMs(tTraj); + + const int samples = line.data.getSampleCount(); + if (samples <= 0) { + throw std::runtime_error("处理后样本数为 0: " + linePrefix); + } + + // 3) 逐深度 IDW 世界网格(CScanGridder)→ 堆成世界轴对齐体。 + // 全部深度共享同一水平网格(dims 仅由轨迹 AABB + cellSize 定,与深度无关), + // 故用深度 0 的网格几何作体的世界框,逐深度填一层。 + const auto tGrid = std::chrono::steady_clock::now(); + const QVector lines{line}; + + CScanGridOptions baseOpts; + if (cellSizeM > 0.0) baseOpts.cellSizeM = cellSizeM; + if (searchRadiusM > 0.0) baseOpts.searchRadiusM = searchRadiusM; + // coarse:水平格距 ×coarse 省空间(searchRadius 同步放大保证邻域覆盖)。 + if (stride > 1) { + baseOpts.cellSizeM *= stride; + baseOpts.searchRadiusM = + std::max(baseOpts.searchRadiusM, baseOpts.cellSizeM * 2.0); + } + // 关闭逐层百分位裁剪对体值无副作用(displayMin/Max 仅显示用,不改 values); + // 平滑保留(与原版 C-scan 一致)。 + + // 输出深度采样数(coarse 沿深度下采样):nzOut = ceil(samples/stride)。 + const int nzOut = (samples + stride - 1) / stride; + + // 先建深度 0 网格锁定世界框。 + CScanGridOptions opt0 = baseOpts; + opt0.sampleIndex = 0; + const CScanGridResult g0 = CScanGridder::build(lines, opt0); + if (!g0.valid || g0.width <= 0 || g0.height <= 0) { + throw std::runtime_error("CScanGridder 深度0 网格无效(轨迹/数据为空?): " + + linePrefix); + } + const int nx = g0.width; // 东向 easting 格数 + const int ny = g0.height; // 北向 northing 格数 + const int nz = nzOut; // 深度 + const double cellSize = g0.cellSizeM; + // g0.originX/Y 为局部坐标系下的网格左下角(因 gpsPositions 已平移到局部原点); + // 加回 localOriginEast/North 还原真实 CGCS2000 世界米。 + // (CScanGridder 网格 X=东=gpsPositions.y, Y=北=gpsPositions.x,故 originX 配 East、originY 配 North。) + const double originX = g0.originX + localOriginEast; // CGCS easting(含带号) 米 + const double originY = g0.originY + localOriginNorth; // CGCS northing 米 + + // 量化区间:先扫所有输出深度网格的有效值(命中 validMask)定 [vmin,vmax]。 + // (CScanGridder values 为处理后幅值的 IDW 插值结果,连续浮点。) + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + // 缓存每层网格(避免二次计算):网格层数 = nzOut,每层 nx*ny float,明星路约 + // 数百×数千×数百 → 单线峰值可控(逐线建,建完即释放)。 + std::vector layers; + layers.reserve(static_cast(nzOut)); + std::int64_t filledCells = 0; + for (int zo = 0; zo < nzOut; ++zo) { + const int s = zo * stride; + CScanGridOptions opt = baseOpts; + opt.sampleIndex = std::min(s, samples - 1); + CScanGridResult g = + (zo == 0) ? g0 : CScanGridder::build(lines, opt); + // 守护:所有深度网格 dims 必须与深度0一致(理论恒等);不一致则该层置空跳过。 + if (!g.valid || g.width != nx || g.height != ny) { + g.valid = false; + } else { + for (int i = 0; i < g.values.size() && i < g.validMask.size(); ++i) { + if (!g.validMask[i]) continue; + ++filledCells; + const double v = static_cast(g.values[i]); + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + } + layers.push_back(std::move(g)); + } + if (!(vmin <= vmax)) { // 无任何有效值(理论不至)。 + vmin = 0.0; + vmax = 0.0; + } + + geopro::core::Quant quant; + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); // 中点 → 防溢出 + + // 4) 填体:逐层逐 cell。空 cell(validMask=0)→ kBlank(渲染透明)。 + // 轴 X=东(easting), Y=北(northing), Z=深度。 + geopro::core::BuiltI16 built; + built.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); + for (int zo = 0; zo < nzOut; ++zo) { + const CScanGridResult& g = layers[static_cast(zo)]; + for (int y = 0; y < ny; ++y) { + for (int x = 0; x < nx; ++x) { + const int gi = y * nx + x; + std::int16_t q = geopro::core::ScalarVolumeI16::kBlank; + if (g.valid && gi < g.values.size() && gi < g.validMask.size() && + g.validMask[gi]) { + q = quant.toQ(static_cast(g.values[gi])); + } + built.vol.at(x, y, zo) = q; + } + } + } + const double gridMs = nowMs(tGrid); + + // 5) 几何:origin=CGCS2000 世界米(东, 北, 0),spacing=(cell, cell, dz×stride)。 + const double dz = depthSpacingZ(h) * stride; + built.quant = quant; + built.origin = {originX, originY, 0.0}; + built.spacing = {cellSize, cellSize, dz}; + built.vminPhys = vmin; + built.vmaxPhys = vmax; + + if (metricsOut) { + metricsOut->nx = nx; + metricsOut->ny = ny; + metricsOut->nz = nz; + metricsOut->rtkPoints = static_cast(track.pts.size()); + metricsOut->channels = channels; + metricsOut->cgcsZone = zone; + metricsOut->centralMeridianDeg = centralMeridianDeg; + metricsOut->meanAbsBefore = meanBefore; + metricsOut->meanAbsAfter = meanAfter; + metricsOut->vminPhys = vmin; + metricsOut->vmaxPhys = vmax; + metricsOut->cellSizeM = cellSize; + metricsOut->dz = dz; + metricsOut->originX = originX; + metricsOut->originY = originY; + metricsOut->filledCells = filledCells; + metricsOut->totalCells = static_cast(nx) * ny * nz; + metricsOut->loadMs = loadMs; + metricsOut->pipelineMs = pipelineMs; + metricsOut->trajMs = trajMs; + metricsOut->gridMs = gridMs; + } + + return built; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/Gpr3dvSurveyVolumeBridge.hpp b/src/io/gpr/Gpr3dvSurveyVolumeBridge.hpp new file mode 100644 index 0000000..c893d5f --- /dev/null +++ b/src/io/gpr/Gpr3dvSurveyVolumeBridge.hpp @@ -0,0 +1,71 @@ +#ifndef GEOPRO_IO_GPR_GPR3DVSURVEYVOLUMEBRIDGE_HPP +#define GEOPRO_IO_GPR_GPR3DVSURVEYVOLUMEBRIDGE_HPP + +#include + +#include "core/algo/GprVolumeBuilder.hpp" // geopro::core::BuiltI16 + +namespace geopro::io::gpr { + +// 测绘级(survey-grade)精确坐标逐线建体桥接(Task P8)。 +// +// 与 Gpr3dvVolumeBridge(线局部坐标 X=道/Y=通道/Z=样本,origin≈0)不同: +// 本桥接用 vendored 3DGPRViewer 的精确坐标/轨迹/网格代码(算法零改动), +// 让单条测线严格按 CGCS2000 大地坐标、逐通道逐道跟 GPS 轨迹建成【世界轴对齐体】, +// 体的 origin/spacing 为真实 CGCS2000 米,多线共享同一参考系 → view-all 直接按 origin +// 摆放即精确就位(跟路的弯,非起点+航向刚体近似)。 +// +// 链路(全程走 P1 原版 + P8 拷入模块,算法零改动): +// IprhParser::loadImpulseMultiChannel(dir, base, model) // 多通道交错 + 头几何 +// → model.buildVolumeData() // 处理前立方体(before 统计) +// → RadarProcessor::runPipeline(默认链) // 信号处理(11 步选配) +// → processed.buildVolumeData() // 处理后立方体 +// geopro::io::gpr::parseGps(.gps) → 逐 RTK 点经纬(度) +// → CoordinateTransform::wgs84ToCgcs2000 // CGCS2000 平面(北X,东Y) 米 +// → SurveyLine.gpsPositions(QVector3D: x=北, y=东, z=高) +// SurveyGeometry::fromHeaderOffsets(头 CH_X/Y_OFFSETS) // 逐通道天线横偏 +// TrajectoryCalculator::computeTrajectories(line, geom) // 每通道每道 CGCS 世界坐标(跟弯) +// 逐深度 sampleIndex:CScanGridder::build({line}, opts) // 世界坐标 IDW 网格 C-scan +// → 沿深度堆成世界轴对齐规则体(空 cell=kBlank) +// 量化 int16(中点 offset) → BuiltI16,origin=CGCS2000 世界米、spacing=(cell,cell,dz)。 +// +// 轴映射(geopro 体):X=东向(CGCS easting)、Y=北向(CGCS northing)、Z=深度。 +// (与 CScanGridder 同:gridX=pos.y()东、gridY=pos.x()北,图像与地图屏幕轴对齐。) +struct SurveyBridgeMetrics { + int nx = 0, ny = 0, nz = 0; // 体维度(东 × 北 × 深度) + int rtkPoints = 0; // .gps RTK 点数 + int channels = 0; // 通道数 + int cgcsZone = 0; // CGCS2000 3°带号 + double centralMeridianDeg = 0.0; // 中央经线(度) + double meanAbsBefore = 0.0; // 处理前 traces 平均绝对幅值 + double meanAbsAfter = 0.0; // 处理后 traces 平均绝对幅值 + double vminPhys = 0.0, vmaxPhys = 0.0; + double cellSizeM = 0.0; // 水平格距(米) + double dz = 0.0; // 深度采样间距(米) + double originX = 0.0, originY = 0.0; // CGCS2000 世界 origin(东, 北) 米 + std::int64_t filledCells = 0; // 非空体素数(validMask 命中累计) + std::int64_t totalCells = 0; // 总体素数 + double loadMs = 0.0; // 读 + 建立方体 + double pipelineMs = 0.0; // runPipeline + 再建立方体 + double trajMs = 0.0; // GPS→CGCS + 轨迹 + double gridMs = 0.0; // 逐深度 IDW 网格 + 量化填体 +}; + +// 走 P8 测绘级链建单线世界对齐体。 +// lineDir/linePrefix 同 build-line(如 "D:/Downloads/明星路", "明星路_001")。 +// gpsPath 为该线 .gps 绝对路径(经纬度,geopro parseGps 解析)。 +// coarse(≥1):省空间档。水平格距 ×coarse、深度每 coarse 个采样取 1(保形);coarse≤1 全分辨率。 +// cellSizeM/searchRadiusM:基础水平格距/搜索半径(米),0=用 CScanGridder 默认(0.05/0.50)。 +// metricsOut 非空时回填维度/世界 origin/量化/耗时(供 CLI 报告,不编造)。 +// 失败(加载失败/GPS 无效/体为空) → 抛 std::runtime_error。 +geopro::core::BuiltI16 buildLineVolumeSurvey(const std::string& lineDir, + const std::string& linePrefix, + const std::string& gpsPath, + SurveyBridgeMetrics* metricsOut, + int coarse = 1, + double cellSizeM = 0.0, + double searchRadiusM = 0.0); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_GPR3DVSURVEYVOLUMEBRIDGE_HPP diff --git a/src/io/gpr/Gpr3dvVolumeBridge.cpp b/src/io/gpr/Gpr3dvVolumeBridge.cpp new file mode 100644 index 0000000..e3b7bcf --- /dev/null +++ b/src/io/gpr/Gpr3dvVolumeBridge.cpp @@ -0,0 +1,218 @@ +#include "io/gpr/Gpr3dvVolumeBridge.hpp" + +#include +#include +#include +#include +#include + +#include + +#include "GPRDataModel.h" +#include "IprhParser.h" +#include "RadarProcessor.h" + +#include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/GprGeometry.hpp" // planChannelInterpolation + +namespace geopro::io::gpr { + +namespace { + +// 全体 traces 平均绝对幅值——朴素的「处理是否生效」标量证据(与 P1 冒烟同口径)。 +double meanAbsAmplitude(const GPRDataModel& model) { + long double sum = 0.0L; + long long count = 0; + for (const RadarTrace& tr : model.traces) { + for (short v : tr.amplitudes) { + sum += static_cast(v < 0 ? -v : v); + ++count; + } + } + return count > 0 ? static_cast(sum / count) : 0.0; +} + +// 通道横向间距(米):优先 chYOffsets 跨度 /(通道数-1);取不到退路 ≈1.37/(通道数-1)。 +double channelSpacingY(const GPRDataModel::Header& h, int channels) { + if (channels <= 1) return 1.0; + const auto& off = h.chYOffsets; + if (off.size() >= 2) { + float lo = off.front(), hi = off.front(); + for (float v : off) { + lo = std::min(lo, v); + hi = std::max(hi, v); + } + const double span = static_cast(hi) - static_cast(lo); + if (span > 1e-9) return span / (channels - 1); + } + // 退路:明星路 14 通道横向跨度 ≈1.37m(-0.686~+0.686)。 + constexpr double kArrayWidthM = 1.37; + return kArrayWidthM / (channels - 1); +} + +// 深度采样间距(米):(timeWindow/samples) ns × 波速(m/ns) / 2(往返)。 +// 与 GPRDataModel::calculateDepth 同式。取不到 → 1.0(单位间距)。 +double depthSpacingZ(const GPRDataModel::Header& h) { + if (h.samplesPerTrace <= 0 || h.timeWindowNs <= 0.0) return 1.0; + const double timePerSample = h.timeWindowNs / h.samplesPerTrace; // ns + const double vel = h.waveVelocity > 0.0 ? h.waveVelocity : 0.1; // m/ns + const double dz = timePerSample * vel / 2.0; + return dz > 1e-12 ? dz : 1.0; +} + +double nowMs(std::chrono::steady_clock::time_point t0) { + const auto t1 = std::chrono::steady_clock::now(); + return std::chrono::duration(t1 - t0).count(); +} + +} // namespace + +geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, + const std::string& linePrefix, + BridgeMetrics* metricsOut, + int coarse, double targetDy) { + const int stride = coarse > 1 ? coarse : 1; // 沿测线下采样步长(≥1) + const QString dir = QString::fromLocal8Bit(lineDir.c_str()); + const QString base = QString::fromLocal8Bit(linePrefix.c_str()); + + // 1) 走 P1 链:load → buildVolumeData(处理前) → runPipeline → 再 buildVolumeData。 + const auto tLoad = std::chrono::steady_clock::now(); + GPRDataModel model; + if (!IprhParser::loadImpulseMultiChannel(dir, base, model)) { + throw std::runtime_error("loadImpulseMultiChannel 失败: " + lineDir + " / " + + linePrefix); + } + model.buildVolumeData(); // 处理前立方体(用于 before 统计) + const double meanBefore = meanAbsAmplitude(model); + const double loadMs = nowMs(tLoad); + + const auto tPipe = std::chrono::steady_clock::now(); + RadarProcessor::ProcPipeline pipeline; + pipeline.setDefaultFlow(); + GPRDataModel processed = RadarProcessor::runPipeline(model, pipeline); + // ★关键:runPipeline 原位变换 traces 且零时校正会改 samplesPerTrace, + // 不重建 volumeData,故必须再建一次拿处理后立方体。 + processed.buildVolumeData(); + const double meanAfter = meanAbsAmplitude(processed); + const double pipelineMs = nowMs(tPipe); + + // 2) 立方体维度:volumeData[通道][道][样本] → 轴 X=道/Y=通道/Z=样本。 + const int channels = processed.volumeData.size(); + const int traces = channels > 0 ? processed.volumeData[0].size() : 0; + const int samples = + (channels > 0 && traces > 0) ? processed.volumeData[0][0].size() : 0; + if (channels <= 0 || traces <= 0 || samples <= 0) { + throw std::runtime_error("处理后立方体为空(通道/道/样本 = " + + std::to_string(channels) + "/" + + std::to_string(traces) + "/" + + std::to_string(samples) + ")"); + } + // 下采样后输出道数(向上取整保留末道附近):nxOut = ceil(traces/stride)。 + const int nxOut = (traces + stride - 1) / stride; + const int nx = nxOut; // X=道(沿测线,已按 stride 下采样) + const int nz = samples; // Z=样本(深度) + + // §1 线内通道插值:读各通道真实横向偏移(header.chXOffsets) → 规则网格化 Y 到 targetDy。 + // 绝不跨线;间距/通道数从数据来,不假设。退路(无偏移/未启用)= 逐通道 identity。 + std::vector latOff; + const auto& chx = processed.header.chXOffsets; + if (chx.size() == channels) + for (int c = 0; c < channels; ++c) + latOff.push_back(static_cast(chx[c])); + std::vector rows; + bool interpolated = false; + if (static_cast(latOff.size()) == channels && targetDy > 0.0) { + rows = planChannelInterpolation(latOff, targetDy); + interpolated = (static_cast(rows.size()) != channels); + } + if (rows.empty()) + for (int c = 0; c < channels; ++c) rows.push_back({c, c, 0.0}); + const int ny = static_cast(rows.size()); // Y=通道(横向,可能已插值加密) + + // 3) 扫处理后值域 → Quant(offset=中点,防溢出)。 + const auto tFill = std::chrono::steady_clock::now(); + short rawMin = std::numeric_limits::max(); + short rawMax = std::numeric_limits::min(); + for (int c = 0; c < channels; ++c) { + const auto& chData = processed.volumeData[c]; + for (int t = 0; t < traces && t < chData.size(); ++t) { + const auto& trData = chData[t]; + for (int s = 0; s < samples && s < trData.size(); ++s) { + const short v = trData[s]; + if (v < rawMin) rawMin = v; + if (v > rawMax) rawMax = v; + } + } + } + if (rawMin > rawMax) { // 退化(理论不至):置零区间。 + rawMin = 0; + rawMax = 0; + } + const double vmin = static_cast(rawMin); + const double vmax = static_cast(rawMax); + + geopro::core::Quant quant; + // 量化到 int16 有效区间 [-32767, 32767](kBlank=-32768 保留),留两端裕度用 64000。 + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); // 中点 → 防溢出 + + // 4) 逐 (输出行 j, trace, sample) 填体。每个输出行 = 两侧最近真实通道线性插值 + // (a==b 时即原通道)。GPR 立方体稠密(每体素有值),无空洞 → 不置 kBlank。 + // 沿测线按 stride 下采样:输出道 to → 源道 t = to*stride。 + geopro::core::BuiltI16 built; + built.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); + for (int j = 0; j < ny; ++j) { + const auto& chA = processed.volumeData[rows[j].a]; + const auto& chB = processed.volumeData[rows[j].b]; + const double wb = rows[j].wb, wa = 1.0 - wb; + for (int to = 0; to < nxOut; ++to) { + const int t = to * stride; + const bool hasA = t < static_cast(chA.size()); + const bool hasB = t < static_cast(chB.size()); + for (int s = 0; s < samples; ++s) { + const double va = + (hasA && s < static_cast(chA[t].size())) ? chA[t][s] : 0.0; + const double vb = + (hasB && s < static_cast(chB[t].size())) ? chB[t][s] : 0.0; + // X=输出道 to、Y=输出行 j、Z=样本 s。 + built.vol.at(to, j, s) = quant.toQ(wa * va + wb * vb); + } + } + } + const double fillMs = nowMs(tFill); + + // 5) 几何:origin=0,spacing 按 X=道距 / Y=通道横距 / Z=深度采样距。 + const GPRDataModel::Header& h = processed.header; + // 下采样后相邻输出道在世界中跨 stride 个原始道距 → dx ×stride 保持真实尺度。 + const double dxBase = h.distanceInc > 1e-9 ? h.distanceInc : 1.0; + const double dx = dxBase * stride; + // 插值后 Y 已规则化到 targetDy 网格;否则用原通道横距。 + const double dy = interpolated ? targetDy : channelSpacingY(h, channels); + const double dz = depthSpacingZ(h); + + built.quant = quant; + built.origin = {0.0, 0.0, 0.0}; + built.spacing = {dx, dy, dz}; + built.vminPhys = vmin; + built.vmaxPhys = vmax; + + if (metricsOut) { + metricsOut->nx = nx; + metricsOut->ny = ny; + metricsOut->nz = nz; + metricsOut->meanAbsBefore = meanBefore; + metricsOut->meanAbsAfter = meanAfter; + metricsOut->vminPhys = vmin; + metricsOut->vmaxPhys = vmax; + metricsOut->dx = dx; + metricsOut->dy = dy; + metricsOut->dz = dz; + metricsOut->loadMs = loadMs; + metricsOut->pipelineMs = pipelineMs; + metricsOut->fillMs = fillMs; + } + + return built; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/Gpr3dvVolumeBridge.hpp b/src/io/gpr/Gpr3dvVolumeBridge.hpp new file mode 100644 index 0000000..0dd84ef --- /dev/null +++ b/src/io/gpr/Gpr3dvVolumeBridge.hpp @@ -0,0 +1,55 @@ +#ifndef GEOPRO_IO_GPR_GPR3DVVOLUMEBRIDGE_HPP +#define GEOPRO_IO_GPR_GPR3DVVOLUMEBRIDGE_HPP + +#include + +#include "core/algo/GprVolumeBuilder.hpp" // geopro::core::BuiltI16 + +namespace geopro::io::gpr { + +// 把 vendored gpr3dv(P1) 处理链产出的【处理后立方体】桥接成 geopro 的量化体 +// (BuiltI16),供 ChunkedVolumeStore::write + buildPyramid + VTK 体绘制消费。 +// +// 链路(走 P1 原版 API,算法零改动): +// IprhParser::loadImpulseMultiChannel(dir, base, model) +// → model.buildVolumeData() // 处理前立方体 +// → RadarProcessor::runPipeline(默认链) // 原位变换 traces,不重建 volumeData +// → model.buildVolumeData() // ★必须再建:拿处理后立方体 +// 得 volumeData[通道][道][样本]。 +// +// 轴映射(geopro 体):X=道(沿测线)、Y=通道(横向)、Z=样本(深度)。 +// 即 nx=道数、ny=通道数、nz=样本数,逐 (ch,trace,sample) 填值。 +// 量化:扫处理后值域 → Quant(offset=中点,防溢出),空值=kBlank。 +// 世界 spacing: +// X=道间距 dx(header DISTANCE INTERVAL,取不到用 1.0); +// Y=通道横向间距(chYOffsets 跨度/(通道数-1),取不到用 ~1.37/(通道数-1)); +// Z=深度采样间距(timeWindow/samples × 波速 / 2,取不到用 1.0)。origin=0。 +struct BridgeMetrics { + int nx = 0, ny = 0, nz = 0; // 体维度(道 × 通道 × 样本) + double meanAbsBefore = 0.0; // 处理前 traces 平均绝对幅值 + double meanAbsAfter = 0.0; // 处理后 traces 平均绝对幅值 + double vminPhys = 0.0, vmaxPhys = 0.0; + double dx = 0.0, dy = 0.0, dz = 0.0; // 世界 spacing + double loadMs = 0.0; // 读 + 建立方体 + double pipelineMs = 0.0; // runPipeline + 再建立方体 + double fillMs = 0.0; // 扫值域 + 量化填体 +}; + +// 走 P1 链得处理后立方体 → 量化映射成 geopro BuiltI16(轴 X=道/Y=通道/Z=样本)。 +// lineDir/linePrefix 同 gpr3dv-smoke(如 "D:/Downloads/明星路", "明星路_001")。 +// metricsOut 非空时回填维度/量化/spacing/耗时(供 CLI 报告,不编造)。 +// coarse(下采样因子,≥1):沿测线(道/X 轴)每 coarse 道取 1,spacing.x ×coarse 保形; +// 通道/样本(横向/深度)保留全分辨率。coarse≤1 即全分辨率。磁盘紧张时省空间用。 +// targetDy(米,>0 启用):线内【通道间插值】目标横向间距。读各通道真实横向偏移 +// (header.chXOffsets) 规则网格化 Y 到 targetDy:ny=round(跨度/targetDy)+1,逐行线性 +// 插值(不跨线、不假设道间距)。<=0 或无偏移 → 不插值,Y=原通道数。默认 0.025(2.5cm)。 +// 失败(加载失败/立方体为空) → 抛 std::runtime_error。 +geopro::core::BuiltI16 buildLineVolumeFromGpr3dv(const std::string& lineDir, + const std::string& linePrefix, + BridgeMetrics* metricsOut, + int coarse = 1, + double targetDy = 0.025); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_GPR3DVVOLUMEBRIDGE_HPP diff --git a/src/io/gpr/GprGeometry.cpp b/src/io/gpr/GprGeometry.cpp new file mode 100644 index 0000000..8cb6fb9 --- /dev/null +++ b/src/io/gpr/GprGeometry.cpp @@ -0,0 +1,76 @@ +#include "io/gpr/GprGeometry.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::io::gpr { + +std::vector parseChannelXOffsets(const std::string& ordText) { + std::vector offsets; + std::istringstream lines(ordText); + std::string line; + while (std::getline(lines, line)) { + std::istringstream tok(line); + std::vector cols; + std::string col; + while (tok >> col) cols.push_back(col); + if (cols.size() < 4) continue; // 空行/列数不足,跳过 + if (cols.back() != "1") continue; // 末列==1 才是有效通道 + offsets.push_back(std::stod(cols[1])); + } + return offsets; +} + +std::vector planChannelInterpolation( + const std::vector& offsets, double targetDy) { + const int n = static_cast(offsets.size()); + std::vector rows; + // 退化:通道<2 或 targetDy 非法 → 逐通道 identity。 + if (n < 2 || targetDy <= 0.0) { + for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0}); + return rows; + } + // 按偏移排序的通道索引(端点 / 区间定位用;通道本身可能非有序)。 + std::vector ord(n); + std::iota(ord.begin(), ord.end(), 0); + std::sort(ord.begin(), ord.end(), + [&](int x, int y) { return offsets[x] < offsets[y]; }); + const double mn = offsets[ord.front()]; + const double mx = offsets[ord.back()]; + const double span = mx - mn; + // 跨度已比 targetDy 还密 → 不加密,逐通道 identity(保原通道序)。 + if (span <= targetDy * 0.5) { + for (int i = 0; i < n; ++i) rows.push_back({i, i, 0.0}); + return rows; + } + const int ny = static_cast(std::lround(span / targetDy)) + 1; + int k = 0; // ord 内区间左指针:offsets[ord[k]] <= p + for (int j = 0; j < ny; ++j) { + const double p = mn + static_cast(j) * targetDy; + while (k + 1 < n && offsets[ord[k + 1]] < p) ++k; + if (k + 1 >= n) { // p 在最右通道之外 → 取最右通道 + rows.push_back({ord[n - 1], ord[n - 1], 0.0}); + continue; + } + const int a = ord[k], b = ord[k + 1]; + const double oa = offsets[a], ob = offsets[b]; + double wb = (ob > oa) ? (p - oa) / (ob - oa) : 0.0; + wb = std::clamp(wb, 0.0, 1.0); + rows.push_back({a, b, wb}); + } + return rows; +} + +double depthOfSample(int s, const IprHeader& h) { + if (h.samples <= 1) return 0.0; // 防除零 + const double timeNs = static_cast(s) * h.timeWindowNs / + static_cast(h.samples - 1); + const double timeSec = timeNs * 1e-9; + return h.soilVelocity * timeSec / 2.0; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/GprGeometry.hpp b/src/io/gpr/GprGeometry.hpp new file mode 100644 index 0000000..9338833 --- /dev/null +++ b/src/io/gpr/GprGeometry.hpp @@ -0,0 +1,36 @@ +#ifndef GEOPRO_IO_GPR_GPRGEOMETRY_HPP +#define GEOPRO_IO_GPR_GPRGEOMETRY_HPP + +#include +#include + +#include "io/gpr/IprHeader.hpp" + +namespace geopro::io::gpr { + +// 解析 .ord 文本,返回末列==1 的有效通道的横向偏移(第 2 列),按文件顺序。 +std::vector parseChannelXOffsets(const std::string& ordText); + +// 通道间插值方案:一个输出网格行 = (1-wb)*通道[a] + wb*通道[b](线性)。 +// a==b 时即原样取该通道(无插值)。 +struct ChannelInterpRow { + int a = 0; + int b = 0; + double wb = 0.0; +}; + +// 按真实横向偏移 offsets(米,逐通道) + 目标横向间距 targetDy(米) 规则网格化通道维(Y): +// 返回每个输出网格行的线性插值方案。网格在 [min(off), max(off)] 上以 targetDy 等距取 +// ny = round(span/targetDy)+1 行;每行找两侧最近真实通道线性插值(端点外用端点)。 +// 退化(通道<2 / targetDy<=0 / 跨度已比 targetDy 还密) → 逐通道 identity(每通道一行)。 +// 纯函数,便于单测:不依赖任何文件/模型。 +std::vector planChannelInterpolation( + const std::vector& offsets, double targetDy); + +// 采样序号 s → 深度(米)。depth = soilVelocity[m/s] * (s * timeWindowNs/(samples-1) * 1e-9) / 2。 +// samples<=1 时返回 0 防除零。 +double depthOfSample(int s, const IprHeader& h); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_GPRGEOMETRY_HPP diff --git a/src/io/gpr/GprSurveyAssembler.cpp b/src/io/gpr/GprSurveyAssembler.cpp new file mode 100644 index 0000000..e9e3c2e --- /dev/null +++ b/src/io/gpr/GprSurveyAssembler.cpp @@ -0,0 +1,157 @@ +#include "io/gpr/GprSurveyAssembler.hpp" + +#include +#include +#include +#include +#include +#include + +#include "io/gpr/GprGeometry.hpp" +#include "io/gpr/IprHeader.hpp" +#include "io/gpr/IprbReader.hpp" + +namespace geopro::io::gpr { +namespace { + +// 把 .iprb 路径的扩展名替换为 .iprh(取最后一个 '.' 之后)。 +std::string toHeaderPath(const std::string& iprbPath) { + const std::size_t dot = iprbPath.find_last_of('.'); + const std::size_t slash = iprbPath.find_last_of("/\\"); + // 仅当 '.' 在最后一个路径分隔符之后才视为扩展名。 + if (dot != std::string::npos && + (slash == std::string::npos || dot > slash)) { + return iprbPath.substr(0, dot) + ".iprh"; + } + return iprbPath + ".iprh"; +} + +std::string readFileText(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) { + throw std::runtime_error("无法打开文件: " + path); + } + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +// 由各通道 .ord 横偏、header、已读 BScan 段装配 GprSurvey。 +// scans 已是待装配的道段(全线或 slab);ntraces 取各通道段道数最小值(对齐); +// x0 由调用方给出(全线=0,slab=t0*dx)。samples 校验、Y 升序置换、值转置同源。 +geopro::core::GprSurvey assembleFromScans(const std::vector& channelY0, + const std::vector& headers, + const std::vector& scans, + double x0) { + const std::size_t nchan = scans.size(); + const int samples = scans.front().samples; + std::int64_t minTraces = scans.front().traces; + for (std::size_t c = 0; c < nchan; ++c) { + if (scans[c].samples != samples) { + throw std::runtime_error("通道 samples 不一致"); + } + minTraces = std::min(minTraces, scans[c].traces); + } + + geopro::core::GprSurvey survey; + survey.samples = samples; + survey.ntraces = static_cast(minTraces); + survey.x0 = x0; + survey.dx = headers.front().distanceInterval; + survey.z0 = 0.0; + survey.dz = (samples > 1) ? depthOfSample(1, headers.front()) : 0.0; + + // 按 Y 升序求置换:order[c] = 升序第 c 位对应的原通道索引。 + std::vector order(nchan); + std::iota(order.begin(), order.end(), std::size_t{0}); + std::stable_sort(order.begin(), order.end(), + [&](std::size_t a, std::size_t b) { + return channelY0[a] < channelY0[b]; + }); + + survey.channelY.resize(nchan); + const std::size_t ntraces = static_cast(survey.ntraces); + const std::size_t ns = static_cast(samples); + survey.values.assign(nchan * ntraces * ns, 0.0); + + for (std::size_t c = 0; c < nchan; ++c) { + const std::size_t src = order[c]; + survey.channelY[c] = channelY0[src]; + const BScan& bscan = scans[src]; + for (std::size_t t = 0; t < ntraces; ++t) { + for (std::size_t s = 0; s < ns; ++s) { + survey.values[(c * ntraces + t) * ns + s] = + static_cast(bscan.data[t * ns + s]); + } + } + } + + return survey; +} + +} // namespace + +geopro::core::GprSurvey assembleGprSurvey( + const std::vector& channelIprbPaths, + const std::string& ordPath) { + // 1. .ord -> 各通道横偏(文件序)。 + const std::vector channelY0 = + parseChannelXOffsets(readFileText(ordPath)); + if (channelY0.size() != channelIprbPaths.size()) { + throw std::runtime_error( + "通道数不一致: .ord 有效通道数与 .iprb 路径数量不符"); + } + const std::size_t nchan = channelIprbPaths.size(); + if (nchan == 0) { + throw std::runtime_error("无通道可装配"); + } + + // 2. 各通道读 header + 全线 BScan。 + std::vector headers; + std::vector scans; + headers.reserve(nchan); + scans.reserve(nchan); + for (const std::string& iprbPath : channelIprbPaths) { + const IprHeader h = parseIprHeader(readFileText(toHeaderPath(iprbPath))); + headers.push_back(h); + scans.push_back(readIprb(iprbPath, h)); + } + + // 3. 全线 x0=0;其余校验/标尺/排序/置换由公共 helper 完成。 + return assembleFromScans(channelY0, headers, scans, 0.0); +} + +geopro::core::GprSurvey assembleGprSurveySlab( + const std::vector& channelIprbPaths, + const std::string& ordPath, + std::int64_t t0, std::int64_t t1) { + // 1. .ord -> 各通道横偏(文件序)。 + const std::vector channelY0 = + parseChannelXOffsets(readFileText(ordPath)); + if (channelY0.size() != channelIprbPaths.size()) { + throw std::runtime_error( + "通道数不一致: .ord 有效通道数与 .iprb 路径数量不符"); + } + const std::size_t nchan = channelIprbPaths.size(); + if (nchan == 0) { + throw std::runtime_error("无通道可装配"); + } + + // 2. 各通道读 header + 仅 [t0,t1) 道段(readIprbRange 内做越界校验)。 + std::vector headers; + std::vector scans; + headers.reserve(nchan); + scans.reserve(nchan); + for (const std::string& iprbPath : channelIprbPaths) { + const IprHeader h = parseIprHeader(readFileText(toHeaderPath(iprbPath))); + headers.push_back(h); + scans.push_back(readIprbRange(iprbPath, h, t0, t1)); + } + + // 3. x0 = t0*dx,使该段世界 X 与全线对齐;其余同全线装配。 + const double dx = headers.front().distanceInterval; + return assembleFromScans(channelY0, headers, scans, + static_cast(t0) * dx); +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/GprSurveyAssembler.hpp b/src/io/gpr/GprSurveyAssembler.hpp new file mode 100644 index 0000000..400ca22 --- /dev/null +++ b/src/io/gpr/GprSurveyAssembler.hpp @@ -0,0 +1,41 @@ +#ifndef GEOPRO_IO_GPR_GPRSURVEYASSEMBLER_HPP +#define GEOPRO_IO_GPR_GPRSURVEYASSEMBLER_HPP + +#include +#include +#include + +#include "core/model/GprSurvey.hpp" + +namespace geopro::io::gpr { + +// 把真实 GPR 一条测线的若干通道 .iprb(同目录同名 .iprh) + .ord 装配成 GprSurvey。 +// +// channelIprbPaths:该线各通道 .iprb 路径,每个同目录有同名 .iprh(扩展名替换为 .iprh)。 +// ordPath:.ord 文件路径,提供各有效通道横偏(Y)。 +// +// 规则: +// - .ord 有效通道数须与 channelIprbPaths 数量一致(否则抛 std::runtime_error); +// - 各通道 samples 须相等(否则抛);ntraces = 各通道 traces 最小值(对齐); +// - x0=0, dx=header.distanceInterval;z0=0, dz=depthOfSample(1,h)(samples<=1 则 0); +// - channelY 按 Y 升序排序,values 通道维同步重排; +// - values[(c*ntraces+t)*samples+s] = 该通道 BScan(t,s) 值(int16->double)。 +geopro::core::GprSurvey assembleGprSurvey( + const std::vector& channelIprbPaths, + const std::string& ordPath); + +// 只装配道区间 [t0,t1) 的 GprSurvey,各通道用 readIprbRange 只读该段, +// 内存只随 slab 大小(非全线),供流式建体(B4 拼接)使用。 +// +// 与 assembleGprSurvey 的差异: +// - ntraces = (t1-t0),仅装配 [t0,t1) 道; +// - x0 = t0*dx(使 slab 世界 X 与全线对齐,B4 拼接靠此),其余标尺/排序/置换同上; +// - t0/t1 越界(t1>总道数 或 t0>t1 或 t0<0)、通道数不符、samples 不一致 抛 std::runtime_error。 +geopro::core::GprSurvey assembleGprSurveySlab( + const std::vector& channelIprbPaths, + const std::string& ordPath, + std::int64_t t0, std::int64_t t1); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_GPRSURVEYASSEMBLER_HPP diff --git a/src/io/gpr/GpsTrack.cpp b/src/io/gpr/GpsTrack.cpp new file mode 100644 index 0000000..5004b9a --- /dev/null +++ b/src/io/gpr/GpsTrack.cpp @@ -0,0 +1,214 @@ +#include "io/gpr/GpsTrack.hpp" + +#include +#include +#include +#include +#include +#include + +namespace geopro::io::gpr { + +namespace { +constexpr double kMetersPerDegLat = 111320.0; +constexpr double kPi = 3.14159265358979323846; + +// 严格把整列解析为 double;失败返回 false(不抛,供逐行容错跳过)。 +bool parseDouble(const std::string& s, double& out) { + try { + std::size_t used = 0; + out = std::stod(s, &used); + return used == s.size(); + } catch (...) { + return false; + } +} +} // namespace + +GpsTrack parseGps(const std::string& path) { + std::ifstream f(path); + if (!f) throw std::runtime_error("parseGps: 打不开 " + path); + + GpsTrack track; + std::string line; + while (std::getline(f, line)) { + // 去掉可能的 \r(CRLF)。 + if (!line.empty() && line.back() == '\r') line.pop_back(); + + std::istringstream tok(line); + std::vector cols; + std::string c; + while (tok >> c) cols.push_back(c); + if (cols.size() < 7) continue; // 列数不足 + + GpsPt p; + if (!parseDouble(cols[2], p.lat)) continue; // 纬 + if (!parseDouble(cols[4], p.lon)) continue; // 经 + if (!parseDouble(cols[6], p.elev)) continue; // 高 + track.pts.push_back(p); + } + return track; +} + +XY lonLatToLocalM(double lat, double lon, double lat0, double lon0) { + XY xy; + xy.x = (lon - lon0) * kMetersPerDegLat * std::cos(lat0 * kPi / 180.0); + xy.y = (lat - lat0) * kMetersPerDegLat; + return xy; +} + +PosHeading interpAlongTrack(const std::vector& trackM, double frac) { + PosHeading r; + if (trackM.empty()) return r; + if (trackM.size() == 1) { + r.pos = trackM.front(); + return r; + } + + // 各段长度 + 累积里程。 + const std::size_t n = trackM.size(); + std::vector cum(n, 0.0); + for (std::size_t i = 1; i < n; ++i) { + const double dx = trackM[i].x - trackM[i - 1].x; + const double dy = trackM[i].y - trackM[i - 1].y; + cum[i] = cum[i - 1] + std::hypot(dx, dy); + } + const double total = cum.back(); + + // 退化(零长轨迹):返回起点,航向取首段方向或 (1,0)。 + if (total <= 0.0) { + r.pos = trackM.front(); + return r; + } + + if (frac <= 0.0) frac = 0.0; + if (frac >= 1.0) frac = 1.0; + const double target = frac * total; + + // 找包含 target 里程的段 [i-1, i]。 + std::size_t seg = 1; + while (seg < n && cum[seg] < target) ++seg; + if (seg >= n) seg = n - 1; + + const double segLen = cum[seg] - cum[seg - 1]; + const double localFrac = segLen > 0.0 ? (target - cum[seg - 1]) / segLen : 0.0; + + const XY& a = trackM[seg - 1]; + const XY& b = trackM[seg]; + r.pos.x = a.x + (b.x - a.x) * localFrac; + r.pos.y = a.y + (b.y - a.y) * localFrac; + + double dx = b.x - a.x; + double dy = b.y - a.y; + const double len = std::hypot(dx, dy); + if (len > 0.0) { + r.hx = dx / len; + r.hy = dy / len; + } + return r; +} + +CenterlineProj projectToCenterline(const std::vector& centerline, + const XY& p) { + CenterlineProj r; + const std::size_t n = centerline.size(); + if (n == 0) return r; + if (n == 1) { + r.s = 0.0; + r.d = std::hypot(p.x - centerline[0].x, p.y - centerline[0].y); + return r; + } + + // 逐段求脚点(夹到段内),取距 p 最近者;累计里程 + 带符号横偏。 + double bestDist2 = std::numeric_limits::infinity(); + double cum = 0.0; // 段起点累计里程 + for (std::size_t i = 1; i < n; ++i) { + const XY& a = centerline[i - 1]; + const XY& b = centerline[i]; + const double ex = b.x - a.x, ey = b.y - a.y; + const double segLen2 = ex * ex + ey * ey; + const double segLen = std::sqrt(segLen2); + + // p 在段上的投影参数 u(夹到 [0,1])。 + double u = 0.0; + if (segLen2 > 0.0) + u = ((p.x - a.x) * ex + (p.y - a.y) * ey) / segLen2; + if (u < 0.0) u = 0.0; + if (u > 1.0) u = 1.0; + + const double footX = a.x + u * ex; + const double footY = a.y + u * ey; + const double dx = p.x - footX, dy = p.y - footY; + const double dist2 = dx * dx + dy * dy; + + if (dist2 < bestDist2) { + bestDist2 = dist2; + r.s = cum + u * segLen; + // 带符号横偏:左法向 n=(-ty,tx)(单位段向量)。退化段 → d=到脚点距离。 + if (segLen > 0.0) { + const double tx = ex / segLen, ty = ey / segLen; + const double nx = -ty, ny = tx; // 左法向 + r.d = dx * nx + dy * ny; + } else { + r.d = std::sqrt(dist2); + } + } + cum += segLen; + } + return r; +} + +std::vector resampleAndSmooth(const std::vector& polyline, double step, + int smoothWindow) { + const std::size_t n = polyline.size(); + if (n < 2 || step <= 0.0) return polyline; + + // 累计里程。 + std::vector cum(n, 0.0); + for (std::size_t i = 1; i < n; ++i) + cum[i] = cum[i - 1] + + std::hypot(polyline[i].x - polyline[i - 1].x, + polyline[i].y - polyline[i - 1].y); + const double total = cum.back(); + if (total <= 0.0) return polyline; + + // 均匀里程取样(含终点)。 + std::vector out; + const int m = static_cast(std::floor(total / step)); + out.reserve(static_cast(m) + 2); + std::size_t seg = 1; + for (int k = 0; k <= m; ++k) { + const double target = k * step; + while (seg < n && cum[seg] < target) ++seg; + if (seg >= n) seg = n - 1; + const double segLen = cum[seg] - cum[seg - 1]; + const double lf = segLen > 0.0 ? (target - cum[seg - 1]) / segLen : 0.0; + XY q; + q.x = polyline[seg - 1].x + (polyline[seg].x - polyline[seg - 1].x) * lf; + q.y = polyline[seg - 1].y + (polyline[seg].y - polyline[seg - 1].y) * lf; + out.push_back(q); + } + if (out.empty() || std::hypot(out.back().x - polyline.back().x, + out.back().y - polyline.back().y) > 1e-9) + out.push_back(polyline.back()); + + // 轻度滑动平均(端点收缩窗口;不平滑首尾以保里程范围)。 + if (smoothWindow <= 0 || out.size() < 3) return out; + std::vector sm = out; + const int N = static_cast(out.size()); + for (int i = 1; i < N - 1; ++i) { + int w = std::min({smoothWindow, i, N - 1 - i}); + double sx = 0, sy = 0; + int cnt = 0; + for (int j = i - w; j <= i + w; ++j) { + sx += out[j].x; + sy += out[j].y; + ++cnt; + } + sm[i].x = sx / cnt; + sm[i].y = sy / cnt; + } + return sm; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/GpsTrack.hpp b/src/io/gpr/GpsTrack.hpp new file mode 100644 index 0000000..e892ca4 --- /dev/null +++ b/src/io/gpr/GpsTrack.hpp @@ -0,0 +1,66 @@ +#ifndef GEOPRO_IO_GPR_GPSTRACK_HPP +#define GEOPRO_IO_GPR_GPSTRACK_HPP + +#include +#include + +namespace geopro::io::gpr { + +// 一个 RTK 轨迹点(度,度,米)。 +struct GpsPt { + double lat = 0; // 纬度(度) + double lon = 0; // 经度(度) + double elev = 0; // 高程(米) +}; + +// 一条测线的 RTK 轨迹。 +struct GpsTrack { + std::vector pts; +}; + +// 解析 .gps 文本。每行 tab/空白分隔: +// 日期 \t 时间 \t 纬 \t N \t 经 \t E \t 高 \t M \t 卫星 +// 取 纬(col2)/经(col4)/高(col6)。列数不足或非数字行跳过。文件打不开抛 std::runtime_error。 +GpsTrack parseGps(const std::string& path); + +// 局部米坐标(等距投影,绕给定原点 lat0/lon0)。 +// x_east = (lon-lon0)*111320*cos(lat0°) +// y_north = (lat-lat0)*111320 +struct XY { + double x = 0; // 东向米 + double y = 0; // 北向米 +}; +XY lonLatToLocalM(double lat, double lon, double lat0, double lon0); + +// 沿轨迹按里程插值:frac∈[0,1] 返回该里程分数处的局部米坐标 + 该处航向(单位向量)。 +// frac<=0 → 起点;frac>=1 → 终点;空/单点轨迹航向 (1,0)。 +struct PosHeading { + XY pos; + double hx = 1; // 航向单位向量 x + double hy = 0; // 航向单位向量 y +}; +PosHeading interpAlongTrack(const std::vector& trackM, double frac); + +// 把点 p 投影到中心线折线 centerline(局部米),返回沿线里程 s 与带符号横向偏移 d。 +// - s:p 在中心线上最近点的累计里程(米,>=0;起点 0)。 +// - d:p 到中心线的带符号横向距离(米)。符号由最近段的左法向定: +// 段方向 t=(tx,ty),左法向 n=(-ty,tx);d = (p-foot)·n。 +// 即 p 在中心线左侧 d>0、右侧 d<0。 +// - 最近点取所有段上投影脚点(夹到段内)距 p 最近者。 +// - centerline 少于 2 点 → s=0、d 取到该点的距离(无方向,d=0)。 +struct CenterlineProj { + double s = 0; // 沿线里程(米) + double d = 0; // 带符号横向偏移(米) +}; +CenterlineProj projectToCenterline(const std::vector& centerline, + const XY& p); + +// 把任意折线(局部米)重采样为均匀间距 step 的有序点 + 轻度滑动平均平滑, +// 得规整中心线参考。空/单点原样返回;step<=0 原样返回。 +// smoothWindow:滑动平均窗口半径(点数,0=不平滑);端点收缩窗口。 +std::vector resampleAndSmooth(const std::vector& polyline, double step, + int smoothWindow); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_GPSTRACK_HPP diff --git a/src/io/gpr/IprHeader.cpp b/src/io/gpr/IprHeader.cpp new file mode 100644 index 0000000..54eb194 --- /dev/null +++ b/src/io/gpr/IprHeader.cpp @@ -0,0 +1,64 @@ +#include "io/gpr/IprHeader.hpp" + +#include +#include +#include + +namespace geopro::io::gpr { + +namespace { + +// SOIL VELOCITY 原文单位 m/µs,本层统一换算成 m/s 存储。 +constexpr double kSoilVelocityToMetersPerSecond = 1e6; + +std::string trim(const std::string& s) { + std::size_t b = 0; + std::size_t e = s.size(); + while (b < e && std::isspace(static_cast(s[b]))) ++b; + while (e > b && std::isspace(static_cast(s[e - 1]))) --e; + return s.substr(b, e - b); +} + +} // namespace + +IprHeader parseIprHeader(const std::string& text) { + IprHeader header; + bool hasSamples = false; + bool hasLastTrace = false; + bool hasChannels = false; + + std::istringstream stream(text); + std::string line; + while (std::getline(stream, line)) { + const std::size_t colon = line.find(':'); + if (colon == std::string::npos) continue; + + const std::string key = trim(line.substr(0, colon)); + const std::string value = trim(line.substr(colon + 1)); + if (value.empty()) continue; + + if (key == "SAMPLES") { + header.samples = std::stoi(value); + hasSamples = true; + } else if (key == "LAST TRACE") { + header.lastTrace = std::stol(value); + hasLastTrace = true; + } else if (key == "CHANNELS") { + header.channels = std::stoi(value); + hasChannels = true; + } else if (key == "TIMEWINDOW") { + header.timeWindowNs = std::stod(value); + } else if (key == "SOIL VELOCITY") { + header.soilVelocity = std::stod(value) * kSoilVelocityToMetersPerSecond; + } else if (key == "DISTANCE INTERVAL") { + header.distanceInterval = std::stod(value); + } + } + + if (!hasSamples || !hasLastTrace || !hasChannels) { + throw std::runtime_error("parseIprHeader: missing required field(s) (SAMPLES/LAST TRACE/CHANNELS)"); + } + return header; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/IprHeader.hpp b/src/io/gpr/IprHeader.hpp new file mode 100644 index 0000000..49112ab --- /dev/null +++ b/src/io/gpr/IprHeader.hpp @@ -0,0 +1,23 @@ +#ifndef GEOPRO_IO_GPR_IPRHEADER_HPP +#define GEOPRO_IO_GPR_IPRHEADER_HPP + +#include + +namespace geopro::io::gpr { + +// 探地雷达 .iprh 文本头的关键字段(纯解析,零 Qt/VTK 依赖)。 +struct IprHeader { + int samples = 0; // 每道采样点数 (SAMPLES) + long lastTrace = 0; // 最后一道索引 (LAST TRACE);道数 = lastTrace+1 + int channels = 0; // 通道数 (CHANNELS) + double timeWindowNs = 0; // 时窗 ns (TIMEWINDOW) + double soilVelocity = 0; // 土速,统一存 m/s(原文 SOIL VELOCITY 单位 m/µs,读入后 ×1e6) + double distanceInterval = 0; // 道距 m (DISTANCE INTERVAL) +}; + +// 解析 .iprh 头文本。samples/lastTrace/channels 缺任一抛 std::runtime_error。 +IprHeader parseIprHeader(const std::string& text); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_IPRHEADER_HPP diff --git a/src/io/gpr/IprbReader.cpp b/src/io/gpr/IprbReader.cpp new file mode 100644 index 0000000..2f6c76d --- /dev/null +++ b/src/io/gpr/IprbReader.cpp @@ -0,0 +1,92 @@ +#include "io/gpr/IprbReader.hpp" + +#include +#include +#include + +namespace geopro::io::gpr { + +BScan readIprb(const std::string& path, const IprHeader& h) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { + throw std::runtime_error("readIprb: 无法打开文件: " + path); + } + + // 文件大小为权威:道数由实测字节数推导,而非 header 的 LAST TRACE。 + // 真实数据规律:实际道数 = LAST TRACE(非 LAST TRACE+1),故不能硬假设 +1。 + const std::int64_t fileBytes = static_cast(f.tellg()); + const std::int64_t samples = static_cast(h.samples); + if (samples <= 0) { + throw std::runtime_error("readIprb: samples 非法(<=0): " + path); + } + const std::int64_t bytesPerTrace = samples * 2; // int16 + if (fileBytes % bytesPerTrace != 0) { + throw std::runtime_error( + "readIprb: 文件大小不是 samples*2 的整数倍: " + path); + } + const std::int64_t traces = fileBytes / bytesPerTrace; + + BScan b; + b.samples = h.samples; + b.traces = traces; + b.data.resize(static_cast(samples * traces)); + + f.seekg(0, std::ios::beg); + f.read(reinterpret_cast(b.data.data()), + static_cast(fileBytes)); + if (!f) { + throw std::runtime_error("readIprb: 读取数据失败: " + path); + } + + return b; +} + +BScan readIprbRange(const std::string& path, const IprHeader& h, + std::int64_t t0, std::int64_t t1) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) { + throw std::runtime_error("readIprbRange: 无法打开文件: " + path); + } + + const std::int64_t fileBytes = static_cast(f.tellg()); + const std::int64_t samples = static_cast(h.samples); + if (samples <= 0) { + throw std::runtime_error("readIprbRange: samples 非法(<=0): " + path); + } + const std::int64_t bytesPerTrace = samples * 2; // int16 + if (fileBytes % bytesPerTrace != 0) { + throw std::runtime_error( + "readIprbRange: 文件大小不是 samples*2 的整数倍: " + path); + } + const std::int64_t totalTraces = fileBytes / bytesPerTrace; + + // 区间校验:0<=t0<=t1<=总道数。 + if (t0 < 0 || t0 > t1 || t1 > totalTraces) { + throw std::runtime_error("readIprbRange: 道区间越界 [t0,t1)"); + } + + const std::int64_t rangeTraces = t1 - t0; + const std::int64_t offsetBytes = t0 * bytesPerTrace; // 64 位防溢出 + const std::int64_t readBytes = rangeTraces * bytesPerTrace; // 64 位防溢出 + + BScan b; + b.samples = h.samples; + b.traces = rangeTraces; + b.data.resize(static_cast(samples * rangeTraces)); + + f.seekg(static_cast(offsetBytes), std::ios::beg); + if (!f) { + throw std::runtime_error("readIprbRange: seek 失败: " + path); + } + if (readBytes > 0) { + f.read(reinterpret_cast(b.data.data()), + static_cast(readBytes)); + if (!f) { + throw std::runtime_error("readIprbRange: 读取数据失败: " + path); + } + } + + return b; +} + +} // namespace geopro::io::gpr diff --git a/src/io/gpr/IprbReader.hpp b/src/io/gpr/IprbReader.hpp new file mode 100644 index 0000000..84989ae --- /dev/null +++ b/src/io/gpr/IprbReader.hpp @@ -0,0 +1,34 @@ +#ifndef GEOPRO_IO_GPR_IPRBREADER_HPP +#define GEOPRO_IO_GPR_IPRBREADER_HPP + +#include +#include +#include + +#include "io/gpr/IprHeader.hpp" + +namespace geopro::io::gpr { + +// 一个通道一条测线的雷达 B-scan 剖面(int16 存储,布局 [trace*samples + s],s 最快)。 +struct BScan { + int samples = 0; + std::int64_t traces = 0; + std::vector data; // 大小 = samples*traces +}; + +// 读取 .iprb 二进制 B-scan:以文件大小为权威,traces = fileBytes / (samples*2)。 +// 要求 fileBytes 是 samples*2 的整数倍,否则抛 std::runtime_error;文件打不开抛 std::runtime_error。 +// 注意:h.lastTrace 仅作 header 提示,不决定道数(真实数据规律为道数==lastTrace,非 +1)。 +BScan readIprb(const std::string& path, const IprHeader& h); + +// 只读 [t0, t1) 道(0<=t0<=t1<=总道数)。seek 到 t0*samples*2,读 (t1-t0)*samples*2 字节, +// 不载全文件,供流式 slab 装配做到内存有界。 +// 返回 BScan.samples=h.samples, traces=(t1-t0), data 大小=(t1-t0)*samples,布局同 readIprb。 +// 总道数 = fileBytes/(samples*2);越界(t1>总道数 或 t0>t1 或 t0<0)抛 std::runtime_error; +// 文件打不开抛 std::runtime_error。偏移用 64 位防大文件溢出。 +BScan readIprbRange(const std::string& path, const IprHeader& h, + std::int64_t t0, std::int64_t t1); + +} // namespace geopro::io::gpr + +#endif // GEOPRO_IO_GPR_IPRBREADER_HPP diff --git a/src/net/AuthLoads.cpp b/src/net/AuthLoads.cpp index b92498e..07bd690 100644 --- a/src/net/AuthLoads.cpp +++ b/src/net/AuthLoads.cpp @@ -29,6 +29,7 @@ CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), cal AuthService::Captcha cap; cap.codeId = resp.data.value(QStringLiteral("id")).toString(); cap.code = resp.data.value(QStringLiteral("code")).toString(); + cap.image = resp.data.value(QStringLiteral("image")).toString(); // 实际验证码图(base64 data URL) aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退 emit done(cap); deleteLater(); diff --git a/src/net/AuthService.cpp b/src/net/AuthService.cpp index 734836b..1c87046 100644 --- a/src/net/AuthService.cpp +++ b/src/net/AuthService.cpp @@ -16,7 +16,7 @@ namespace { constexpr int kCodeSuccess = 200; const char* const kPathImageCode = "/business/system/personalUser/getImageCode"; -const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck"; +// 原版实测(Playwright):登录只 getImageCode → login2 直连,无 verifyCodeCheck(多调会消费验证码→过期)。 const char* const kPathLogin = "/admin/tenant/auth/login2"; } // namespace @@ -34,23 +34,19 @@ LoginLoad* AuthService::loginAsync(const QString& username, const QString& passw // 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。 auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); }; - // step1:校验验证码(与 captcha 同会话)。 - ApiChain::StepFactory step1 = [this, code, codeId](const QList&) -> IApiCall* { - const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}}; - return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body); - }; - - // step2:RSA 加密密码(PKCS#1 v1.5 -> base64,可抛 std::exception → ApiChain 转 failed)-> login2。 - ApiChain::StepFactory step2 = [this, username, password](const QList&) -> IApiCall* { + // 唯一步:RSA 加密密码(PKCS#1 v1.5 -> base64,可抛 std::exception → ApiChain 转 failed)→ login2, + // body 直接带 checkCode(用户输入验证码)+ codeId(验证码会话)。无 verifyCodeCheck 前置(原版实测)。 + ApiChain::StepFactory step = [this, username, password, code, codeId](const QList&) -> IApiCall* { RsaEncryptor enc(rsaPublicKeyPem_); const std::string encrypted = enc.encryptBase64(password.toStdString()); const QJsonObject body{{QStringLiteral("username"), username}, {QStringLiteral("password"), QString::fromStdString(encrypted)}, - {QStringLiteral("checkCode"), QString()}}; + {QStringLiteral("checkCode"), code}, + {QStringLiteral("codeId"), codeId}}; return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body); }; - auto* chain = new ApiChain({step1, step2}, isFailure); + auto* chain = new ApiChain({step}, isFailure); return new LoginLoad(chain); } diff --git a/src/net/AuthService.hpp b/src/net/AuthService.hpp index fe1f5cd..f1b2c79 100644 --- a/src/net/AuthService.hpp +++ b/src/net/AuthService.hpp @@ -17,10 +17,11 @@ class AuthService { public: AuthService(ApiClient& api, std::string rsaPublicKeyPem); - // 验证码:服务端把答案明文回传(data.code),答案 + data.id 用于下一步校验。 + // 验证码:服务端返回 data.id + data.image(base64 PNG 图,用户照图输入);data.code 已不返回。 struct Captcha { QString codeId; - QString code; + QString code; // 旧字段(后端已不返回明文,保留兼容) + QString image; // data URL base64 PNG(后端 getImageCode 实际返回的验证码图) }; // 异步拉验证码:GET getImageCode;返回句柄,连 CaptchaLoad::done(Captcha)/failed(QString)。 diff --git a/src/render/CMakeLists.txt b/src/render/CMakeLists.txt index 0d25920..57523e3 100644 --- a/src/render/CMakeLists.txt +++ b/src/render/CMakeLists.txt @@ -1,9 +1,13 @@ -find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 InteractionStyle InteractionWidgets IOImage) +find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel FiltersGeometry FiltersModeling RenderingCore RenderingOpenGL2 RenderingVolumeOpenGL2 RenderingAnnotation InteractionStyle InteractionWidgets ImagingCore IOImage) find_package(GDAL CONFIG REQUIRED) add_library(geopro_render STATIC - Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp) + Scene.cpp ColorLutBuilder.cpp CameraPreset.cpp VoxelFromScatters.cpp ContourBands.cpp actors/GridContourActor.cpp actors/VoxelActor.cpp actors/CurtainActor.cpp actors/MapLineActor.cpp actors/ScatterActor.cpp actors/AnomalyActor.cpp actors/ElectrodeActor.cpp actors/TerrainActor.cpp actors/AxesActor.cpp + interact/SlicePlaneMath.cpp interact/SliceTool.cpp interact/PickInteractorStyle.cpp interact/InteractionManager.cpp interact/AnomalyDrawTool.cpp + ground/TileMath.cpp + lod/ViewAdaptiveLodPolicy.cpp + source/WholeVolumeSource.cpp source/BrickPager.cpp source/OutOfCoreSource.cpp source/ViewAdaptiveVolumeSource.cpp source/RegionReorganizer.cpp source/AsyncRegionBuilder.cpp) target_include_directories(geopro_render PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(geopro_render PUBLIC geopro_core ${VTK_LIBRARIES} GDAL::GDAL) +target_link_libraries(geopro_render PUBLIC geopro_core geopro_store ${VTK_LIBRARIES} GDAL::GDAL) target_compile_features(geopro_render PUBLIC cxx_std_17) set_target_properties(geopro_render PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) vtk_module_autoinit(TARGETS geopro_render MODULES ${VTK_LIBRARIES}) diff --git a/src/render/CameraPreset.cpp b/src/render/CameraPreset.cpp index 70b809c..3101e24 100644 --- a/src/render/CameraPreset.cpp +++ b/src/render/CameraPreset.cpp @@ -8,6 +8,8 @@ namespace { // 三维斜视方位角 / 仰角。 constexpr double kAzimuth = 30.0; constexpr double kElevation = 25.0; +// 二维分析近俯视:自正俯视下压的角度(12°→俯角约78°)。留一点倾斜使高程差可辨。 +constexpr double kNearTopTilt = 12.0; } // namespace void applyTop2D(vtkRenderer* r) @@ -37,4 +39,68 @@ void applyFree3D(vtkRenderer* r) r->ResetCamera(); } +void applyNearTop2D(vtkRenderer* r) +{ + if (!r) return; + auto* c = r->GetActiveCamera(); + c->ParallelProjectionOff(); // 透视:配合一点倾斜,使高程差可见(正交/正俯视下不可辨) + // 自正俯视(+Z 向下看、北朝上)起,下压 kNearTopTilt → 俯角约 78°;方位不偏(正北俯视)。 + c->SetFocalPoint(0, 0, 0); + c->SetPosition(0, 0, 1); + c->SetViewUp(0, 1, 0); + c->Elevation(kNearTopTilt); + c->OrthogonalizeViewUp(); + r->ResetCamera(); +} + +void applyView(vtkRenderer* r, ViewDir dir) +{ + if (!r) return; + auto* c = r->GetActiveCamera(); + // 6 向均为正交快捷视图。焦点先置原点,ResetCamera 再按场景重定位相机距离; + // 方向由 (position-focalPoint) 与 viewUp 决定(世界系 x=East,y=North,z=-depth)。 + c->SetFocalPoint(0, 0, 0); + switch (dir) { + case ViewDir::Top: // 俯视:相机在 +Z 向下看,北(+Y)朝上 + c->SetPosition(0, 0, 1); + c->SetViewUp(0, 1, 0); + break; + case ViewDir::Bottom: // 仰视:相机在 -Z 向上看 + c->SetPosition(0, 0, -1); + c->SetViewUp(0, 1, 0); + break; + case ViewDir::Front: // 北望:相机在 -Y 看向 +Y,上(+Z)朝上 + c->SetPosition(0, -1, 0); + c->SetViewUp(0, 0, 1); + break; + case ViewDir::Back: // 南望:相机在 +Y 看向 -Y + c->SetPosition(0, 1, 0); + c->SetViewUp(0, 0, 1); + break; + case ViewDir::Left: // 东望:相机在 -X 看向 +X + c->SetPosition(-1, 0, 0); + c->SetViewUp(0, 0, 1); + break; + case ViewDir::Right: // 西望:相机在 +X 看向 -X + c->SetPosition(1, 0, 0); + c->SetViewUp(0, 0, 1); + break; + } + c->OrthogonalizeViewUp(); + r->ResetCamera(); +} + +void zoomBy(vtkRenderer* r, double factor) +{ + if (!r || factor <= 0.0) return; + // vtkCamera::Zoom 同时覆盖透视(改视角)与正交(改 parallelScale):factor>1 放大。 + r->GetActiveCamera()->Zoom(factor); +} + +void fitView(vtkRenderer* r) +{ + if (!r) return; + r->ResetCamera(); +} + } // namespace geopro::render diff --git a/src/render/CameraPreset.hpp b/src/render/CameraPreset.hpp index 008ed30..5061074 100644 --- a/src/render/CameraPreset.hpp +++ b/src/render/CameraPreset.hpp @@ -8,4 +8,24 @@ void applyTop2D(vtkRenderer* r); // 自由三维:透视投影,斜视方位看到剖面立体。 void applyFree3D(vtkRenderer* r); +// 二维分析近俯视:透视投影,自正俯视下压一点(约12°→约78°俯角)。留一点倾斜使高程差可见 +// (绝对正俯视下高程不可辨),仅平移+缩放(旋转由 interactor style 锁定)。 +void applyNearTop2D(vtkRenderer* r); + +// 快捷视图方向(世界系 x=East,y=North,z=-depth)。 +// Top 俯视 (相机在 +Z 向下看) +// Bottom 仰视 (相机在 -Z 向上看) +// Front 从 -Y 看向 +Y (北望),Back 反向 +// Left 从 -X 看向 +X (东望),Right 反向 +enum class ViewDir { Front, Back, Left, Right, Top, Bottom }; + +// 应用 6 向正交快捷视图:设 position/focalPoint/viewUp 后 ResetCamera。 +void applyView(vtkRenderer* r, ViewDir dir); + +// 相机缩放:factor>1 拉近(放大),factor<1 推远(缩小)。透视下改距离、正交下改 parallelScale。 +void zoomBy(vtkRenderer* r, double factor); + +// 适配场景:ResetCamera(全览)。 +void fitView(vtkRenderer* r); + } // namespace geopro::render diff --git a/src/render/ColorLutBuilder.cpp b/src/render/ColorLutBuilder.cpp index c4df643..45d067a 100644 --- a/src/render/ColorLutBuilder.cpp +++ b/src/render/ColorLutBuilder.cpp @@ -11,6 +11,8 @@ vtkSmartPointer buildLut(const geopro::core::ColorScale& cs, dou for (int t = 0; t < n; ++t) { const double val = vmin + (vmax - vmin) * t / (n - 1); const auto c = cs.colorAt(val); + // 复刻原版 three 渲染(parseColor 只取 rgb、MeshBasicMaterial opacity=1): + // 忽略 colorBar 的 alpha,画满不透明 RGB。 lut->SetTableValue(t, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0); } lut->Build(); diff --git a/src/render/README.md b/src/render/README.md index 4ec49f7..7abef38 100644 --- a/src/render/README.md +++ b/src/render/README.md @@ -7,7 +7,7 @@ - `actors/` — ScatterActor, GridContourActor, VoxelVolumeActor, AnomalyActor, TerrainActor - `color/` — ColorLutBuilder(colorBar → 离散 vtkLookupTable), ScalarBar - `camera/` — CameraPreset(Top2D / Free3D) -- `interact/` — InteractionManager + InteractionTool(Measure/Slice/PickSelect);切片用 vtkResliceCursorWidget +- `interact/` — SlicePlaneMath(纯几何,可测)+ SliceTool(vtkImagePlaneWidget:轴向 + 任意 45° reslice 着色剖面)+ PickInteractorStyle(拾取/双击正视/滚轮)+ InteractionManager(持切片/选中态/分发)。切片走 vtkImageReslice 路线(vtkImagePlaneWidget 内部 reslice + 纹理),非 vtkCutter(spec §9.1) - `ground/` — IGroundLayer + DemImageGroundLayer(M1);TileGroundLayer(M1.5) 网格管线:`vtkImageData(+vtkWarpScalar) → vtkDataSetSurfaceFilter → vtkBandedPolyDataContourFilter(GenerateContourEdgesOn)`(设计 §4.3)。 diff --git a/src/render/Scene.cpp b/src/render/Scene.cpp index f1d28b5..6181f06 100644 --- a/src/render/Scene.cpp +++ b/src/render/Scene.cpp @@ -17,4 +17,9 @@ void Scene::addActor(vtkActor* a) if (a) renderer_->AddActor(a); } +void Scene::addViewProp(vtkProp* p) +{ + if (p) renderer_->AddViewProp(p); +} + } // namespace geopro::render diff --git a/src/render/Scene.hpp b/src/render/Scene.hpp index 2cb49e3..9354243 100644 --- a/src/render/Scene.hpp +++ b/src/render/Scene.hpp @@ -2,6 +2,7 @@ #include #include #include +#include namespace geopro::render { // 单一渲染场景:持有 vtkRenderer(白底),统一管理 actor 的加入与清除。 @@ -12,8 +13,10 @@ public: vtkRenderer* renderer() const { return renderer_.Get(); } - void clear(); // 移除所有 view prop,支持重复切换数据集 + void clear(); // 移除所有 view prop(含体绘制 vtkVolume),支持重复切换数据集 void addActor(vtkActor* a); // actor 由 renderer 引用计数保活 + // 体绘制 vtkVolume 是 vtkProp3D(非 vtkActor),经此通用入口进场;prop 由 renderer 引用计数保活。 + void addViewProp(vtkProp* p); private: vtkSmartPointer renderer_; diff --git a/src/render/actors/AnomalyActor.cpp b/src/render/actors/AnomalyActor.cpp index 4a43ea8..32d41e3 100644 --- a/src/render/actors/AnomalyActor.cpp +++ b/src/render/actors/AnomalyActor.cpp @@ -19,11 +19,11 @@ namespace { // 虚线点画图案(16 位)与重复因子;dashed 异常用。 constexpr int kDashPattern = 0xF0F0; constexpr int kDashRepeat = 1; -// Point 型异常的方块点像素边长。 -constexpr float kPointSize = 8.0F; +// Point 型异常的小球像素直径(RenderPointsAsSpheres 下为球径)。 +constexpr float kPointSize = 13.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(a.localPts.size())); for (std::size_t i = 0; i < a.localPts.size(); ++i) { @@ -31,6 +31,68 @@ void fillPoints(vtkPoints* points, const geopro::core::Anomaly& a) } } +// 把一个异常的 worldPts 灌入 points(世界 3D 坐标,直接用,不翻 y/不压 z)。 +void fillPoints3D(vtkPoints* points, const geopro::core::Anomaly& a) +{ + points->SetNumberOfPoints(static_cast(a.worldPts.size())); + for (std::size_t i = 0; i < a.worldPts.size(); ++i) { + points->SetPoint(static_cast(i), a.worldPts[i].x, a.worldPts[i].y, + a.worldPts[i].z); + } +} + +// 由已灌点的 points + 异常样式/类型,构建单个 actor(点/折线/闭合多边形 + 颜色/线宽/虚线)。 +vtkSmartPointer buildActor(vtkPoints* points, std::size_t n, + const geopro::core::Anomaly& a) +{ + vtkNew poly; + poly->SetPoints(points); + + const bool asPoints = (a.markType == geopro::core::AnomalyMarkType::Point); + if (asPoints) { + vtkNew verts; + for (std::size_t i = 0; i < n; ++i) { + const auto id = static_cast(i); + verts->InsertNextCell(1, &id); + } + poly->SetVerts(verts); + } else { + // 线/面型:经各点的折线;面(Polygon)闭合(首尾相连)成轮廓。 + const bool closed = (a.markType == geopro::core::AnomalyMarkType::Polygon) && n >= 3; + const vtkIdType count = static_cast(n) + (closed ? 1 : 0); + vtkNew line; + line->GetPointIds()->SetNumberOfIds(count); + for (std::size_t i = 0; i < n; ++i) { + line->GetPointIds()->SetId(static_cast(i), static_cast(i)); + } + if (closed) line->GetPointIds()->SetId(static_cast(n), 0); // 回到起点 + vtkNew cells; + cells->InsertNextCell(line); + poly->SetLines(cells); + } + + vtkNew mapper; + mapper->SetInputData(poly); + mapper->ScalarVisibilityOff(); // 用 actor 单色,不用标量上色 + + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + + const auto c = geopro::core::parseColor(a.lineColor, geopro::core::AlphaScale::Bit255); + actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0); + if (asPoints) { + actor->GetProperty()->SetPointSize(kPointSize); + actor->GetProperty()->SetRenderPointsAsSpheres(true); // 点异常渲染为小球(非扁平方点) + } else { + actor->GetProperty()->SetLineWidth(a.lineWidth > 0.0 ? a.lineWidth : 1.0); + if (a.dashed) { + actor->GetProperty()->SetLineStipplePattern(kDashPattern); + actor->GetProperty()->SetLineStippleRepeatFactor(kDashRepeat); + } + } + return actor; +} + } // namespace std::vector> buildAnomalies( @@ -38,63 +100,23 @@ std::vector> buildAnomalies( { std::vector> out; out.reserve(anomalies.size()); - for (const auto& a : anomalies) { const std::size_t n = a.localPts.size(); if (n == 0) continue; // 无几何,跳过 - vtkNew points; - fillPoints(points, a); - - vtkNew poly; - poly->SetPoints(points); - - const bool asPoints = (a.markType == geopro::core::AnomalyMarkType::Point); - if (asPoints) { - // 点型:每点一个 vtkVertex。 - vtkNew verts; - for (std::size_t i = 0; i < n; ++i) { - const auto id = static_cast(i); - verts->InsertNextCell(1, &id); - } - poly->SetVerts(verts); - } else { - // 线/面型:经各点的折线;面(Polygon)闭合(首尾相连)成轮廓。 - const bool closed = (a.markType == geopro::core::AnomalyMarkType::Polygon) && n >= 3; - const vtkIdType count = static_cast(n) + (closed ? 1 : 0); - vtkNew line; - line->GetPointIds()->SetNumberOfIds(count); - for (std::size_t i = 0; i < n; ++i) { - line->GetPointIds()->SetId(static_cast(i), static_cast(i)); - } - if (closed) line->GetPointIds()->SetId(static_cast(n), 0); // 回到起点 - vtkNew cells; - cells->InsertNextCell(line); - poly->SetLines(cells); - } - - vtkNew mapper; - mapper->SetInputData(poly); - mapper->ScalarVisibilityOff(); // 用 actor 单色,不用标量上色 - - auto actor = vtkSmartPointer::New(); - actor->SetMapper(mapper); - - const auto c = geopro::core::parseColor(a.lineColor, geopro::core::AlphaScale::Bit255); - actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0); - if (asPoints) { - actor->GetProperty()->SetPointSize(kPointSize); - } else { - actor->GetProperty()->SetLineWidth(a.lineWidth > 0.0 ? a.lineWidth : 1.0); - if (a.dashed) { - actor->GetProperty()->SetLineStipplePattern(kDashPattern); - actor->GetProperty()->SetLineStippleRepeatFactor(kDashRepeat); - } - } - out.push_back(actor); + fillPoints2D(points, a); + out.push_back(buildActor(points, n, a)); } - return out; } +vtkSmartPointer buildAnomaly3D(const geopro::core::Anomaly& a) +{ + const std::size_t n = a.worldPts.size(); + if (n == 0) return nullptr; // 无 3D 几何 + vtkNew points; + fillPoints3D(points, a); + return buildActor(points, n, a); +} + } // namespace geopro::render diff --git a/src/render/actors/AnomalyActor.hpp b/src/render/actors/AnomalyActor.hpp index e685ab3..4c9bb1d 100644 --- a/src/render/actors/AnomalyActor.hpp +++ b/src/render/actors/AnomalyActor.hpp @@ -18,4 +18,8 @@ namespace geopro::render { std::vector> buildAnomalies( const std::vector& anomalies); +// 单个异常 → 世界坐标 3D actor(VTK 三维视图):用 worldPts 直接建点/折线/闭合多边形(不翻 y、不压 z=0)。 +// 空几何(worldPts 为空)返回 nullptr。样式同 buildAnomalies(lineColor/width/dashed)。 +vtkSmartPointer buildAnomaly3D(const geopro::core::Anomaly& anomaly); + } // namespace geopro::render diff --git a/src/render/actors/AxesActor.cpp b/src/render/actors/AxesActor.cpp new file mode 100644 index 0000000..3075b61 --- /dev/null +++ b/src/render/actors/AxesActor.cpp @@ -0,0 +1,119 @@ +#include "actors/AxesActor.hpp" + +#include +#include +#include + +namespace geopro::render { + +namespace { +constexpr double kFeetPerMeter = 3.28084; + +// 包围盒退化判定:任一轴 min>max,或六值全 0(无内容)。 +bool boundsDegenerate(const double b[6]) { + if (b[0] > b[1] || b[2] > b[3] || b[4] > b[5]) return true; + for (int i = 0; i < 6; ++i) + if (b[i] != 0.0) return false; + return true; // 全 0 +} + +// 设三轴标题字号/标签字号(待 1.0 字体确认,先统一 fontSize)。 +void applyFont(vtkCubeAxesActor* ax, int fontSize) { + for (int i = 0; i < 3; ++i) { + if (auto* t = ax->GetTitleTextProperty(i)) t->SetFontSize(fontSize); + if (auto* l = ax->GetLabelTextProperty(i)) l->SetFontSize(fontSize); + } +} +} // namespace + +double unitScaleFactor(AxesUnit unit) { + switch (unit) { + case AxesUnit::Meter: return 1.0; + case AxesUnit::Feet: return kFeetPerMeter; + case AxesUnit::None: + case AxesUnit::LatLon: return 1.0; // None 隐藏标签;LatLon 非线性,单独处理 + } + return 1.0; +} + +vtkSmartPointer buildAxes(const double bounds[6], const AxesOptions& opts, + vtkRenderer* renderer) { + if (opts.mode == AxesMode::None) return nullptr; + if (!bounds || boundsDegenerate(bounds)) return nullptr; + + auto ax = vtkSmartPointer::New(); + double b[6]; + for (int i = 0; i < 6; ++i) b[i] = bounds[i]; + ax->SetBounds(b); + if (renderer) ax->SetCamera(renderer->GetActiveCamera()); + + // 显示模式:标准=外侧最近边;三维立体=静态边(四周更完整闭合,近似立方)+ 网格线。 + if (opts.mode == AxesMode::Stereo) { + ax->SetFlyModeToStaticEdges(); + ax->DrawXGridlinesOn(); + ax->DrawYGridlinesOn(); + ax->DrawZGridlinesOn(); + } else { // Standard + ax->SetFlyModeToOuterEdges(); + } + + // 刻度标签:None 隐藏;其余按单位换算「显示值范围」(几何 bounds 不变,仅标签数值变)。 + if (opts.unit == AxesUnit::None) { + ax->SetXAxisLabelVisibility(false); + ax->SetYAxisLabelVisibility(false); + ax->SetZAxisLabelVisibility(false); + } else if (opts.unit == AxesUnit::LatLon && opts.frame) { + // 经纬度:X→经度、Y→纬度(用 frame 反算 bounds 端点);Z 退化为米深度。 + // bounds 布局 {xmin,xmax,ymin,ymax,zmin,zmax}:(b[0],b[2])=西南角、(b[1],b[3])=东北角。 + // 等距圆柱投影单调 → 角点经纬度即为各轴显示范围端点。 + auto ll0 = opts.frame->toLatLon(b[0], b[2]); + auto ll1 = opts.frame->toLatLon(b[1], b[3]); + ax->SetXAxisRange(ll0.lon, ll1.lon); + ax->SetYAxisRange(ll0.lat, ll1.lat); + ax->SetZAxisRange(b[4], b[5]); + ax->SetXTitle("Lon"); + ax->SetYTitle("Lat"); + ax->SetZTitle("Depth(m)"); + ax->SetXLabelFormat("%.5f"); + ax->SetYLabelFormat("%.5f"); + } else { + // 米 / 英尺:显示范围 = 自定义范围(用户按当前单位输入)或几何范围 × 系数。 + const double s = unitScaleFactor(opts.unit); + ax->SetXAxisRange(opts.x.customRange ? opts.x.min : b[0] * s, + opts.x.customRange ? opts.x.max : b[1] * s); + ax->SetYAxisRange(opts.y.customRange ? opts.y.min : b[2] * s, + opts.y.customRange ? opts.y.max : b[3] * s); + ax->SetZAxisRange(opts.z.customRange ? opts.z.min : b[4] * s, + opts.z.customRange ? opts.z.max : b[5] * s); + const char* u = (opts.unit == AxesUnit::Feet) ? "ft" : "m"; + ax->SetXTitle("X"); + ax->SetYTitle("Y"); + ax->SetZTitle("Z"); + ax->SetXUnits(u); + ax->SetYUnits(u); + ax->SetZUnits(u); + } + + // per-axis 显示开关。SetXAxisVisibility 只关轴线,刻度数字/标签/刻度线需单独关, + // 否则隐藏某轴后该轴的数字仍悬浮在场景里(用户实测 bug)。 + ax->SetXAxisVisibility(opts.x.visible); + ax->SetYAxisVisibility(opts.y.visible); + ax->SetZAxisVisibility(opts.z.visible); + if (!opts.x.visible) { + ax->SetXAxisLabelVisibility(false); + ax->SetXAxisTickVisibility(false); + } + if (!opts.y.visible) { + ax->SetYAxisLabelVisibility(false); + ax->SetYAxisTickVisibility(false); + } + if (!opts.z.visible) { + ax->SetZAxisLabelVisibility(false); + ax->SetZAxisTickVisibility(false); + } + + applyFont(ax, opts.fontSize); + return ax; +} + +} // namespace geopro::render diff --git a/src/render/actors/AxesActor.hpp b/src/render/actors/AxesActor.hpp new file mode 100644 index 0000000..f4842bf --- /dev/null +++ b/src/render/actors/AxesActor.hpp @@ -0,0 +1,53 @@ +#pragma once +#include + +#include "geo/GeoLocalFrame.hpp" + +class vtkCubeAxesActor; +class vtkRenderer; + +namespace geopro::render { + +// 坐标轴显示方式(spec §4 C3–I3)。 +// Standard 标准 = vtkCubeAxesActor 包围盒 + 刻度(外侧最近轴显示刻度)。 +// Stereo 三维立体 = vtkCubeAxesActor 闭合立方(四周/网格更完整)。语义待 1.0 确认,先合理近似。 +// None 不显示 = 不构建(返回 nullptr)。 +enum class AxesMode { Standard, Stereo, None }; + +// 刻度单位(spec §4 D5–I5)。 +// None 无刻度 = 隐藏刻度标签。 +// Meter 米 = 原值(世界系本就是米)。 +// Feet 英尺 = ×3.28084。 +// LatLon 经纬度 = 经 GeoLocalFrame 反算 X→经度、Y→纬度(Z 退化为米深度)。 +enum class AxesUnit { None, Meter, Feet, LatLon }; + +// 单轴显示(原型坐标轴设置):是否显示该轴 + 是否用自定义刻度范围(覆盖按单位换算的自动范围)。 +struct AxisDisplay { + bool visible = true; + bool customRange = false; // true=用 min/max 作刻度显示范围(值按当前单位) + double min = 0.0; + double max = 0.0; +}; + +// 坐标轴构建参数。 +struct AxesOptions { + AxesMode mode = AxesMode::Standard; + AxesUnit unit = AxesUnit::Meter; + int fontSize = 12; // 标题/标签字号 + // 经纬度刻度需 frame 反算;为空则 LatLon 退化为米。 + const geopro::core::GeoLocalFrame* frame = nullptr; + AxisDisplay x, y, z; // per-axis 可见性 + 自定义范围 +}; + +// 由数据包围盒 bounds[6]={xmin,xmax,ymin,ymax,zmin,zmax} + 选项构建坐标轴 prop。 +// O 点 = 数据包围盒角(待 1.0 确认;spec §13 倾向"数据包围盒角")。 +// bounds 退化(min>max 或全 0)或 mode==None → 返回 nullptr。 +// camera:vtkCubeAxesActor 需绑定相机(决定外侧刻度轴);可空(测试场景)。 +vtkSmartPointer buildAxes(const double bounds[6], const AxesOptions& opts, + vtkRenderer* renderer); + +// 单位换算系数(米→目标单位)。LatLon 不是线性系数(X/Y 分别反算),此处仅供米/英尺; +// 暴露为可测纯函数。 +double unitScaleFactor(AxesUnit unit); + +} // namespace geopro::render diff --git a/src/render/actors/CurtainActor.cpp b/src/render/actors/CurtainActor.cpp index 1feb8e2..0e065e2 100644 --- a/src/render/actors/CurtainActor.cpp +++ b/src/render/actors/CurtainActor.cpp @@ -1,14 +1,21 @@ #include "actors/CurtainActor.hpp" #include +#include +#include #include #include #include #include #include +#include #include #include +#include +#include +#include +#include #include #include @@ -17,10 +24,6 @@ namespace geopro::render { -namespace { -// LUT 级数。 -constexpr int kLutLevels = 256; -} // namespace vtkSmartPointer buildCurtain(const geopro::core::Grid& g, const geopro::core::ColorScale& cs, @@ -47,6 +50,10 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny); + // 无数据格(v 为 null → Grid 存 NaN,反演不规则区大量如此)记录待消隐:NaN 标量喂给 + // vtkBandedPolyDataContourFilter 的裁剪运算会崩(0xc0000005)。消隐后这些格不入表面/色带, + // 既不崩、又把空洞正确显示为透明。标量同时填 0(有限值)以防任何读取路径再触 NaN。 + std::vector blanks; for (int j = 0; j < ny; ++j) { for (int i = 0; i < nx; ++i) { double px, py; @@ -60,17 +67,38 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, py = 0.0; } const vtkIdType id = static_cast(j) * nx + i; - // g.y 是深度(越大越深);VTK Z 向上 → 取负,使深部在下、浅部在上(剖面不倒置)。 - points->SetPoint(id, px, py, -g.y[j]); - sc->SetValue(id, g.valueAt(i, j)); + // g.y 是真实高程(米,越大越高,与原版 web data.y 同义):直接作世界 Z,使剖面落在 + // 真实海拔上,与同样按真实高程渲染的地形对齐(剖面顶≈地表→露出地面,复刻原版)。 + points->SetPoint(id, px, py, g.y[j]); + const double val = g.valueAt(i, j); + if (std::isfinite(val)) { + sc->SetValue(id, val); + } else { + sc->SetValue(id, 0.0); + blanks.push_back(id); + } } } sgrid->SetPoints(points); sgrid->GetPointData()->SetScalars(sc); + // 消隐无数据点:vtkStructuredGrid 消隐(ghost)→ 含该点的 cell 不被 vtkDataSetSurfaceFilter 输出, + // 故 NaN 永不进入 banded contour。282/1900 有效的不规则反演区→渲染出正确的梯形帘面。 + for (vtkIdType id : blanks) sgrid->BlankPoint(id); // 用 colorBar 真实分段值做色带(与数据详情#18一致的清晰色带,而非连续插值的糊色)。 - const std::vector stops = cs.stopValues(); + // 清洗等值线值:vtkBandedPolyDataContourFilter 要求值严格升序且有限——真实色阶可能含重复值 + // (addStop 排序不去重)或非有限值,未清洗会在 Update() 时崩(0xc0000005)。去非有限 + 去重保序。 + std::vector stops; + { + std::vector raw = cs.stopValues(); + raw.erase(std::remove_if(raw.begin(), raw.end(), + [](double v) { return !std::isfinite(v); }), + raw.end()); + std::sort(raw.begin(), raw.end()); + raw.erase(std::unique(raw.begin(), raw.end()), raw.end()); + stops = std::move(raw); + } double vmin, vmax; if (stops.size() >= 2) { vmin = stops.front(); vmax = stops.back(); } else { @@ -83,9 +111,7 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, } } - auto lut = buildLut(cs, vmin, vmax, kLutLevels); - - // structuredGrid → 表面 polydata → banded contour(分段色带) + // structuredGrid → 表面 polydata(消隐格已剔除) → banded contour(分段色带,色带#18)。 vtkNew surf; surf->SetInputData(sgrid); vtkNew banded; @@ -96,13 +122,40 @@ vtkSmartPointer buildCurtain(const geopro::core::Grid& g, } else { banded->GenerateValues(20, vmin, vmax); } - banded->SetScalarModeToValue(); + banded->SetScalarModeToValue(); // 每个色带 cell 标量 = 该带等值线值(colorBar 真实 stop) + banded->Update(); + + // 逐色带精确上色,复刻原版 threeContour.js getTerrainColor: + // 值 v∈[stops[i],stops[i+1]) 取「上界」stops[i+1] 的颜色(我们的 colorAt 取下界 stops[i], + // 会让整条色带整体偏浅一段);满不透明 RGB(原版 parseColor 忽略 alpha、opacity=1)。 + const std::vector> csStops = cs.stops(); + auto upperColor = [&csStops](double v) -> geopro::core::Rgba { + if (csStops.empty()) return geopro::core::Rgba{0, 0, 0, 255}; + if (v <= csStops.front().first) return csStops.front().second; + if (v >= csStops.back().first) return csStops.back().second; + auto it = std::upper_bound( + csStops.begin(), csStops.end(), v, + [](double val, const std::pair& s) { return val < s.first; }); + return it->second; // 第一个 value > v 的 stop = 上界 + }; + vtkNew shaded; + shaded->DeepCopy(banded->GetOutput()); + vtkNew bandColors; + bandColors->SetNumberOfComponents(3); + if (vtkDataArray* cellScalars = shaded->GetCellData()->GetScalars()) { + const vtkIdType nc = shaded->GetNumberOfCells(); + bandColors->SetNumberOfTuples(nc); + for (vtkIdType ci = 0; ci < nc; ++ci) { + const auto c = upperColor(cellScalars->GetTuple1(ci)); + bandColors->SetTuple3(ci, c.r, c.g, c.b); + } + shaded->GetCellData()->SetScalars(bandColors); + } vtkNew mapper; - mapper->SetInputConnection(banded->GetOutputPort()); + mapper->SetInputData(shaded); mapper->SetScalarModeToUseCellData(); - mapper->SetLookupTable(lut); - mapper->SetScalarRange(vmin, vmax); + mapper->SetColorModeToDirectScalars(); // cell 标量即 RGB,不再过 LUT auto actor = vtkSmartPointer::New(); actor->SetMapper(mapper); diff --git a/src/render/actors/MapLineActor.cpp b/src/render/actors/MapLineActor.cpp index 3e445ea..7e21f66 100644 --- a/src/render/actors/MapLineActor.cpp +++ b/src/render/actors/MapLineActor.cpp @@ -47,4 +47,39 @@ vtkSmartPointer buildSurveyLine(const geopro::core::Grid& g, return actor; } +vtkSmartPointer buildMapLine(const std::vector& lat, + const std::vector& lon, double worldZ, + const geopro::core::GeoLocalFrame& frame) +{ + const std::size_t n = lat.size(); + if (n < 2 || lon.size() != n) return vtkSmartPointer::New(); + + vtkNew points; + for (std::size_t i = 0; i < n; ++i) { + auto p = frame.toLocal(lat[i], lon[i]); + points->InsertNextPoint(p.x, p.y, worldZ); // 平铺在指定世界 Z + } + + vtkNew line; + line->GetPointIds()->SetNumberOfIds(static_cast(n)); + for (std::size_t i = 0; i < n; ++i) + line->GetPointIds()->SetId(static_cast(i), static_cast(i)); + + vtkNew cells; + cells->InsertNextCell(line); + + vtkNew poly; + poly->SetPoints(points); + poly->SetLines(cells); + + vtkNew mapper; + mapper->SetInputData(poly); + + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + actor->GetProperty()->SetColor(0.95, 0.55, 0.10); // 足迹:橙(与轨迹地图标记一致) + actor->GetProperty()->SetLineWidth(3.0); + return actor; +} + } // namespace geopro::render diff --git a/src/render/actors/MapLineActor.hpp b/src/render/actors/MapLineActor.hpp index 048d2ae..8e319f4 100644 --- a/src/render/actors/MapLineActor.hpp +++ b/src/render/actors/MapLineActor.hpp @@ -1,4 +1,6 @@ #pragma once +#include + #include #include @@ -12,4 +14,11 @@ namespace geopro::render { vtkSmartPointer buildSurveyLine(const geopro::core::Grid& g, const geopro::core::GeoLocalFrame& frame); +// 二维足迹折线(平铺进 3D 地图):把 lat/lon 序列经 frame 投影到世界 XY,Z=worldZ。 +// 与 buildSurveyLine 同口径(同 frame → 与帘面/底图同系配准),但 z 可控、不依赖 Grid。 +// 点数 < 2 或 lat/lon 长度不一致 → 返回空 actor(调用方自行判空)。 +vtkSmartPointer buildMapLine(const std::vector& lat, + const std::vector& lon, double worldZ, + const geopro::core::GeoLocalFrame& frame); + } // namespace geopro::render diff --git a/src/render/actors/VoxelActor.cpp b/src/render/actors/VoxelActor.cpp index 4dfd7ef..8509e56 100644 --- a/src/render/actors/VoxelActor.cpp +++ b/src/render/actors/VoxelActor.cpp @@ -3,9 +3,16 @@ #include #include +#include #include #include +#include +#include #include +#include +#include +#include +#include #include #include #include @@ -17,12 +24,69 @@ namespace { // 颜色/不透明度传递函数采样级数。 constexpr int kTransferSamples = 64; -// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。 -constexpr double kMaxOpacity = 0.15; +// 体绘制最大不透明度([vmin,vmax] 线性 0→kMaxOpacity)。值越大体越实(越不透明); +// 0.15 偏淡 → 0.30 更实仍可看穿内部。再大(0.4~0.6)会更像实心块、遮挡内部结构。 +constexpr double kMaxOpacity = 0.30; // NaN/留空格的哨兵:落在 [vmin,vmax] 之外,传递函数把它映射为完全透明。 double sentinel(double vmin) { return vmin - 1.0; } +// double/int16 两版公用的 mapper+property+volume 组装(行为与原 double 版一致)。 +vtkSmartPointer assembleVolume(vtkImageData* img, + vtkColorTransferFunction* color, + vtkPiecewiseFunction* opacity) +{ + // SmartVolumeMapper:有 GPU 走 GPU ray cast,否则自动回退 CPU,避免无 GPU 时卡死/失败。 + vtkNew mapper; + mapper->SetInputData(img); + // 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。 + mapper->SetAutoAdjustSampleDistances(0); + mapper->SetInteractiveAdjustSampleDistances(0); + + vtkNew prop; + prop->SetColor(color); + prop->SetScalarOpacity(opacity); + prop->SetInterpolationTypeToLinear(); + prop->ShadeOff(); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + return volume; +} + +// int16 量化域传函组装:颜色对每量化级 qv 用 q.toPhys(qv) 反查 ColorScale; +// 不透明度 kBlank→0(透明)、[qmin,qmax] 线性递增到 kMaxOpacity。 +// 与 buildVoxelI16 内联逻辑一致(被 buildVoxelI16FromImage 复用)。 +vtkSmartPointer assembleVolumeI16(vtkImageData* img, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys) +{ + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + + const std::int16_t qmin = q.toQ(vminPhys); + const std::int16_t qmax = q.toQ(vmaxPhys); + const double qminD = static_cast(qmin); + const double qmaxD = static_cast(qmax); + + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + vtkNew opacity; + opacity->AddPoint(static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + opacity->AddPoint(qminD, 0.0); + opacity->AddPoint(qmaxD, kMaxOpacity); + + return assembleVolume(img, color, opacity); +} + } // namespace vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, @@ -43,7 +107,9 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, img->SetOrigin(ox, oy, oz); img->SetSpacing(dx, dy, dz); - vtkNew sc; + // 标量用 float(非 double):OpenGL 无原生 double 体纹理,GPU 体绘制对 double 处理不稳/部分驱动间歇 + // 出空(偶发不渲染根因之一),且省一半显存。float 精度对可视化足够。 + vtkNew sc; sc->SetName("v"); sc->SetNumberOfTuples(static_cast(nx) * ny * nz); // 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolume::idx)。 @@ -52,7 +118,7 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, for (int i = 0; i < nx; ++i) { const double v = vol.at(i, j, k); const vtkIdType id = (static_cast(k) * ny + j) * nx + i; - sc->SetValue(id, std::isnan(v) ? blank : v); // NaN → 哨兵 + sc->SetValue(id, static_cast(std::isnan(v) ? blank : v)); // NaN → 哨兵 } img->GetPointData()->SetScalars(sc); outImage = img; @@ -71,23 +137,65 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, opacity->AddPoint(vmin, 0.0); opacity->AddPoint(vmax, kMaxOpacity); - // SmartVolumeMapper:有 GPU 走 GPU ray cast,否则自动回退 CPU,避免无 GPU 时卡死/失败。 - vtkNew mapper; - mapper->SetInputData(img); - // 全程统一全质量(GPU 足够快, 实测 ~7ms/帧):关掉交互降采样, 避免"停手补高清"那一帧突跳停顿。 - mapper->SetAutoAdjustSampleDistances(0); - mapper->SetInteractiveAdjustSampleDistances(0); + return assembleVolume(img, color, opacity); +} - vtkNew prop; - prop->SetColor(color); - prop->SetScalarOpacity(opacity); - prop->SetInterpolationTypeToLinear(); - prop->ShadeOff(); +vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vol, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double ox, double oy, double oz, + double dx, double dy, double dz, + double vminPhys, double vmaxPhys, + vtkSmartPointer& outImage) +{ + const int nx = vol.nx(), ny = vol.ny(), nz = vol.nz(); - auto volume = vtkSmartPointer::New(); - volume->SetMapper(mapper); - volume->SetProperty(prop); - return volume; + // vmin/vmax 退化兜底,避免传递函数区间为零。 + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + + auto img = vtkSmartPointer::New(); + img->SetDimensions(nx, ny, nz); + img->SetOrigin(ox, oy, oz); + img->SetSpacing(dx, dy, dz); + + vtkNew sc; + sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny * nz); + // 点序 i 最快、j 次之、k 最慢(匹配 vtkImageData 与 ScalarVolumeI16::idx)。 + // kBlank 原样保留,由量化域传递函数映射为透明。 + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const std::int16_t qv = vol.at(i, j, k); + const vtkIdType id = (static_cast(k) * ny + j) * nx + i; + sc->SetValue(id, qv); + } + img->GetPointData()->SetScalars(sc); + outImage = img; + + // 传递函数在量化域取(标量本身是 int16 量化值)。 + const std::int16_t qmin = q.toQ(vminPhys); + const std::int16_t qmax = q.toQ(vmaxPhys); + const double qminD = static_cast(qmin); + const double qmaxD = static_cast(qmax); + + // 颜色传递函数:对每个量化级 qv,物理值 phys=q.toPhys(qv),用 double 版相同方式取色。 + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + // 不透明度传递函数(量化域):kBlank → 0(透明);[qmin,qmax] 线性递增到 kMaxOpacity。 + vtkNew opacity; + opacity->AddPoint(static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + opacity->AddPoint(qminD, 0.0); + opacity->AddPoint(qmaxD, kMaxOpacity); + + return assembleVolume(img, color, opacity); } vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, @@ -100,4 +208,43 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, return buildVoxel(vol, cs, ox, oy, oz, dx, dy, dz, vmin, vmax, ignored); } +vtkSmartPointer buildVoxelI16FromImage(vtkImageData* shortImg, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys) +{ + // 图像由 source 预构建(VTK_SHORT,带 origin/spacing),直接成体;传函复用量化域逻辑。 + return assembleVolumeI16(shortImg, q, cs, vminPhys, vmaxPhys); +} + +vtkSmartPointer buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs, + double vmin, double vmax, double isoValue) +{ + if (!img) return nullptr; + if (vmin >= vmax) vmax = vmin + 1.0; + // 阈值钳进 (vmin,vmax):=vmin 会沿留空哨兵边界成面、=vmax 抽不出。 + const double eps = 1e-6 * (vmax - vmin); + isoValue = std::max(vmin + eps, std::min(vmax - eps, isoValue)); + + vtkNew fe; + fe->SetInputData(img); + fe->SetValue(0, isoValue); + fe->ComputeNormalsOn(); + fe->ComputeGradientsOff(); + fe->ComputeScalarsOff(); + fe->Update(); + if (!fe->GetOutput() || fe->GetOutput()->GetNumberOfPoints() == 0) return nullptr; // 无超阈区 + + vtkNew mapper; + mapper->SetInputConnection(fe->GetOutputPort()); + mapper->ScalarVisibilityOff(); // 用 actor 实色,不按标量着色 + + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + const auto c = cs.colorAt(isoValue); // 阈值处的色(高值多为暖红,复刻参考图红块) + actor->GetProperty()->SetColor(c.r / 255.0, c.g / 255.0, c.b / 255.0); + actor->GetProperty()->SetOpacity(1.0); // 不透明实心 + return actor; +} + } // namespace geopro::render diff --git a/src/render/actors/VoxelActor.hpp b/src/render/actors/VoxelActor.hpp index 4a840b9..92b94bd 100644 --- a/src/render/actors/VoxelActor.hpp +++ b/src/render/actors/VoxelActor.hpp @@ -1,11 +1,19 @@ #pragma once +#include #include #include #include #include "model/Field.hpp" #include "model/ColorScale.hpp" +#include "model/ScalarVolumeI16.hpp" namespace geopro::render { +// 体上抽等值面(marching cubes/FlyingEdges)→ 不透明实心 actor,凸显超阈异常体(参考图红块)。 +// img 为 buildVoxel 暴露的 vtkImageData(标量=物理值,留空=哨兵 vmin-1,低于任意 isoValue 不成面)。 +// isoValue 在 [vmin,vmax] 内;颜色取 ColorScale 在 isoValue 处的实色、不透明。无超阈区 → 返回 nullptr。 +vtkSmartPointer buildIsosurface(vtkImageData* img, const geopro::core::ColorScale& cs, + double vmin, double vmax, double isoValue); + // 把 core 规则标量体(IDW 输出,含 NaN 留空)转 vtkImageData,再建 GPU 光线投射体绘制。 // 颜色按 ColorScale 在 [vmin,vmax] 采样;NaN/留空格 → 不透明度 0(透明)。 // 返回 vtkVolume(由调用方加入 renderer)。 @@ -23,4 +31,25 @@ vtkSmartPointer buildVoxel(const geopro::core::ScalarVolume& vol, double vmin, double vmax, vtkSmartPointer& outImage); +// int16 量化体 → vtkImageData(vtkShortArray) → GPU 体绘制(与 double 版并列)。 +// 传函在量化域取:qmin=q.toQ(vminPhys)、qmax=q.toQ(vmaxPhys);kBlank → 不透明度 0(透明)。 +// color 按 ColorScale 在 [vminPhys,vmaxPhys] 采样(对每个量化级 qv 用 q.toPhys(qv) 查色)。 +// outImage 暴露内部 vtkImageData,供建交互切片 widget。返回 vtkVolume。 +vtkSmartPointer buildVoxelI16(const geopro::core::ScalarVolumeI16& vol, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double ox, double oy, double oz, + double dx, double dy, double dz, + double vminPhys, double vmaxPhys, + vtkSmartPointer& outImage); + +// 预构建 VTK_SHORT vtkImageData → GPU 体绘制(供 IVolumeRenderSource 的整卷/工作集图像 +// 直接成体,不再从 ScalarVolumeI16 逐体素填)。传函/着色与 buildVoxelI16 一致: +// 在量化域取 qmin=q.toQ(vminPhys)、qmax=q.toQ(vmaxPhys);kBlank → 不透明度 0(透明); +// 颜色对每个量化级 qv 用 q.toPhys(qv) 反查 ColorScale。shortImg 标量须为 VTK_SHORT。 +vtkSmartPointer buildVoxelI16FromImage(vtkImageData* shortImg, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys); + } // namespace geopro::render diff --git a/src/render/ground/TileMath.cpp b/src/render/ground/TileMath.cpp new file mode 100644 index 0000000..3b3b65d --- /dev/null +++ b/src/render/ground/TileMath.cpp @@ -0,0 +1,35 @@ +#include "ground/TileMath.hpp" + +#include + +namespace geopro::render { + +namespace { +constexpr double kPi = 3.14159265358979323846; +int clampInt(int v, int lo, int hi) { return v < lo ? lo : (v > hi ? hi : v); } +} // namespace + +TileXY lonLatToTile(double lonDeg, double latDeg, int z) { + if (z < 0) z = 0; + const double n = std::pow(2.0, z); + const double latR = latDeg * kPi / 180.0; + int x = static_cast(std::floor((lonDeg + 180.0) / 360.0 * n)); + int y = static_cast(std::floor((1.0 - std::asinh(std::tan(latR)) / kPi) / 2.0 * n)); + const int hi = static_cast(n) - 1; + return TileXY{z, clampInt(x, 0, hi), clampInt(y, 0, hi)}; +} + +LonLatBox tileBounds(int z, int x, int y) { + if (z < 0) z = 0; + const double n = std::pow(2.0, z); + const double west = x / n * 360.0 - 180.0; + const double east = (x + 1) / n * 360.0 - 180.0; + auto latAt = [&](double yy) { + return std::atan(std::sinh(kPi * (1.0 - 2.0 * yy / n))) * 180.0 / kPi; + }; + const double north = latAt(static_cast(y)); + const double south = latAt(static_cast(y + 1)); + return LonLatBox{west, south, east, north}; +} + +} // namespace geopro::render diff --git a/src/render/ground/TileMath.hpp b/src/render/ground/TileMath.hpp new file mode 100644 index 0000000..aba1be8 --- /dev/null +++ b/src/render/ground/TileMath.hpp @@ -0,0 +1,22 @@ +#pragma once + +// Web Mercator(EPSG:3857) 瓦片坐标数学:天地图/XYZ 底图瓦片定位用(纯函数,无 VTK/Qt 依赖)。 +// 标准 slippy-map 公式:n=2^z;x=(lon+180)/360*n;y 用墨卡托纬度映射。 +namespace geopro::render { + +struct TileXY { + int z = 0, x = 0, y = 0; +}; + +// 瓦片地理边界(度):west/east 经度,south/north 纬度。 +struct LonLatBox { + double west = 0, south = 0, east = 0, north = 0; +}; + +// 经纬度(度) → 指定 zoom 的瓦片行列(x/y 夹紧到 [0, 2^z-1])。 +TileXY lonLatToTile(double lonDeg, double latDeg, int z); + +// 瓦片 (z,x,y) → 其覆盖的地理边界(度)。 +LonLatBox tileBounds(int z, int x, int y); + +} // namespace geopro::render diff --git a/src/render/interact/AnomalyDrawTool.cpp b/src/render/interact/AnomalyDrawTool.cpp new file mode 100644 index 0000000..fce7147 --- /dev/null +++ b/src/render/interact/AnomalyDrawTool.cpp @@ -0,0 +1,309 @@ +#include "interact/AnomalyDrawTool.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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) +constexpr int kCloseSnapPx = 12; // 面:光标/点击邻近起点的吸附阈值(px) + +double nowMs() { + return std::chrono::duration( + 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(DrawMode mode, const Vec3& planeOrigin, const Vec3& planeNormal, + std::function&)> onFinish, + std::function onCancel) { + if (active_) cancel(); + mode_ = mode; + origin_ = planeOrigin; + normal_ = normalize(planeNormal); + onFinish_ = std::move(onFinish); + onCancel_ = std::move(onCancel); + pts_.clear(); + lastClickMs_ = -1.0; + hasCursor_ = false; + active_ = true; + installObservers(); + // 操作提示由 app 层 QLabel 浮层承担(VTK 内置字体不含中文字形 → vtkTextActor 渲染不出中文)。 + 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_); + } + preview_ = nullptr; + rubber_ = 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(x), static_cast(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]}}; +} + +bool AnomalyDrawTool::nearFirstVertex(int sx, int sy) const { + if (pts_.empty() || !renderer_) return false; + renderer_->SetWorldPoint(pts_.front()[0], pts_.front()[1], pts_.front()[2], 1.0); + renderer_->WorldToDisplay(); + double d[3]; + renderer_->GetDisplayPoint(d); + return std::abs(d[0] - sx) <= kCloseSnapPx && std::abs(d[1] - sy) <= kCloseSnapPx; +} + +void AnomalyDrawTool::addVertex() { + // 点模式:单点,再次左键 = 重定位(微调),不累积;线/面模式:累积顶点。 + if (mode_ == DrawMode::Point && !pts_.empty()) + pts_[0] = pickOnPlane(); + else + 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 points; + points->SetNumberOfPoints(static_cast(pts_.size())); + for (std::size_t i = 0; i < pts_.size(); ++i) + points->SetPoint(static_cast(i), pts_[i][0], pts_[i][1], pts_[i][2]); + vtkNew poly; + poly->SetPoints(points); + // 顶点圆点:每点一个 vtkVertex → 单点也可见(解决"第一下看不到点在哪")。 + vtkNew verts; + for (std::size_t i = 0; i < pts_.size(); ++i) { + const auto id = static_cast(i); + verts->InsertNextCell(1, &id); + } + poly->SetVerts(verts); + // 实线折线(≥2 点)。 + if (pts_.size() >= 2) { + vtkNew line; + line->GetPointIds()->SetNumberOfIds(static_cast(pts_.size())); + for (std::size_t i = 0; i < pts_.size(); ++i) + line->GetPointIds()->SetId(static_cast(i), static_cast(i)); + vtkNew cells; + cells->InsertNextCell(line); + poly->SetLines(cells); + } + vtkNew mapper; + mapper->SetInputData(poly); + mapper->ScalarVisibilityOff(); + preview_ = vtkSmartPointer::New(); + preview_->SetMapper(mapper); + preview_->GetProperty()->SetColor(1.0, 0.9, 0.0); // 亮黄 + preview_->GetProperty()->SetLineWidth(2.0); + preview_->GetProperty()->SetPointSize(mode_ == DrawMode::Point ? 16.0 : 9.0); // 点模式更醒目 + preview_->GetProperty()->SetRenderPointsAsSpheres(true); // 顶点渲染为小球(图钉感) + renderer_->AddActor(preview_); + interactor_->Render(); +} + +void AnomalyDrawTool::updateRubber() { + if (!renderer_) return; + if (rubber_) renderer_->RemoveViewProp(rubber_); + rubber_ = nullptr; + // 点模式:单点标注,不拉末点→光标的橡皮筋线(否则点完还甩出一条线,用户反馈)。 + if (mode_ == DrawMode::Point || pts_.empty() || !hasCursor_) { + if (interactor_) interactor_->Render(); + return; + } + // 末点 → 光标 的虚线橡皮筋(跟手反馈);面模式光标邻近起点 → 指向起点,预览闭合。 + const Vec3& a = pts_.back(); + const Vec3 endP = cursorNearStart_ ? pts_.front() : cursorPt_; + vtkNew points; + points->SetNumberOfPoints(2); + points->SetPoint(0, a[0], a[1], a[2]); + points->SetPoint(1, endP[0], endP[1], endP[2]); + vtkNew poly; + poly->SetPoints(points); + vtkNew line; + line->GetPointIds()->SetNumberOfIds(2); + line->GetPointIds()->SetId(0, 0); + line->GetPointIds()->SetId(1, 1); + vtkNew cells; + cells->InsertNextCell(line); + poly->SetLines(cells); + vtkNew mapper; + mapper->SetInputData(poly); + mapper->ScalarVisibilityOff(); + rubber_ = vtkSmartPointer::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() { + const std::size_t minPts = + mode_ == DrawMode::Point ? 1 : (mode_ == DrawMode::Line ? 2 : 3); // 点1/线2/面3 + if (pts_.size() < minPts) { // 不足以成形 → 取消 + cancel(); + return; + } + std::vector result = pts_; + auto cb = onFinish_; + teardownActive(); + if (cb) cb(result); +} + +void AnomalyDrawTool::installObservers() { + if (!interactor_) return; + cmd_ = vtkSmartPointer::New(); + cmd_->SetClientData(this); + cmd_->SetCallback([](vtkObject*, unsigned long eid, void* client, void*) { + auto* self = static_cast(client); + if (!self->active_) return; + if (eid == vtkCommand::MouseMoveEvent) { + // 鼠标移动:更新末点→光标的虚线橡皮筋(跟手反馈)。不 abort,不干扰其它悬停。 + self->cursorPt_ = self->pickOnPlane(); + self->hasCursor_ = true; + const int* mp = self->interactor_->GetEventPosition(); + self->cursorNearStart_ = self->mode_ == DrawMode::Face && self->pts_.size() >= 3 && + self->nearFirstVertex(mp[0], mp[1]); + self->updateRubber(); + return; + } + // 先消费事件(abort)再处理:finish()/cancel() 内 teardown 会置空 cmd_,若 abort 留到末尾会被跳过, + // 导致触发闭合的那次按键漏给切片 widget → widget 当左键按下开始 slice-motion(鼠标一动切片就动)。 + if (self->cmd_) self->cmd_->SetAbortFlag(1); + switch (eid) { + case vtkCommand::LeftButtonPressEvent: { + // 点:单击即落点并完成(业界通用,无需双击/回车)。 + if (self->mode_ == DrawMode::Point) { + self->addVertex(); + self->finish(); + break; + } + const double now = nowMs(); + const int* p = self->interactor_->GetEventPosition(); + // 面:点回起点(屏幕邻近)闭合(≥3点),不加点。 + if (self->mode_ == DrawMode::Face && self->pts_.size() >= 3 && + self->nearFirstVertex(p[0], p[1])) { + self->finish(); + break; + } + // 线:左键双连击 = 完成;否则加顶点。 + const bool dbl = self->lastClickMs_ >= 0.0 && + (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: + // 绘制中右键不提交(保留给「创建异常」菜单语义);已 abort 消费,不打开菜单。 + break; + case vtkCommand::KeyPressEvent: { + const char* key = self->interactor_->GetKeySym(); + const std::string k = key ? std::string(key) : std::string(); + if (k == "Escape") + self->cancel(); + else if (k == "Return" || k == "KP_Enter") + self->finish(); + else if ((k == "BackSpace" || k == "Delete") && !self->pts_.empty()) { + self->pts_.pop_back(); // 撤上一点 + self->updatePreview(); + self->updateRubber(); + } + break; + } + 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 diff --git a/src/render/interact/AnomalyDrawTool.hpp b/src/render/interact/AnomalyDrawTool.hpp new file mode 100644 index 0000000..60a95c5 --- /dev/null +++ b/src/render/interact/AnomalyDrawTool.hpp @@ -0,0 +1,73 @@ +#pragma once +#include +#include + +#include + +#include "interact/SlicePlaneMath.hpp" + +class vtkRenderWindowInteractor; +class vtkRenderer; +class vtkActor; +class vtkCallbackCommand; + +namespace geopro::render::interact { + +// 异常圈定工具(#4b):在给定切片平面上交互式画 点 / 线 / 面。 +// 左键逐点加顶点(屏幕射线与平面求交,落在平面上);**双击 / 回车 提交** → onFinish(worldPts); +// Esc 取消;Backspace 撤上一点;点模式再次左键=重定位单点(微调)。右键绘制中不响应(保留给菜单语义)。 +// 点≥1 / 线≥2(开放) / 面≥3(闭合);闭合与否由上层据 markType 渲染,本工具只产顶点。 +// 高优先级(5.0)交互器观察者抢输入:先于切片 widget 与 InteractionManager 右键菜单,绘制期独占左右键。 +// render 层:只碰 VTK,不认业务;产物(平面上的世界点)经回调交上层组装 core::Anomaly。 +class AnomalyDrawTool { +public: + enum class DrawMode { Point, Line, Face }; // 点(1)/线(2,开放)/面(3,闭合) + + AnomalyDrawTool(vtkRenderWindowInteractor* interactor, vtkRenderer* renderer); + ~AnomalyDrawTool(); + + AnomalyDrawTool(const AnomalyDrawTool&) = delete; + AnomalyDrawTool& operator=(const AnomalyDrawTool&) = delete; + + // 开始在平面(origin/normal)上按 mode 圈定。onFinish 收顶点(世界系);onCancel 取消。 + void start(DrawMode mode, const Vec3& planeOrigin, const Vec3& planeNormal, + std::function&)> onFinish, + std::function onCancel); + bool active() const { return active_; } + void cancel(); // 外部强制取消(如切走视图) + +private: + void addVertex(); // 左键:加顶点 + void updatePreview(); // 重建已点几何(顶点圆点 + 实线折线;单点也可见) + void updateRubber(); // 鼠标移动:末点→光标的虚线橡皮筋 + void finish(); // 双击/回车/(面)点起点:完成 + Vec3 pickOnPlane() const; // 当前鼠标屏幕点 → 射线与平面交点 + bool nearFirstVertex(int sx, int sy) const; // 屏幕点是否邻近起点(面闭合判定/提示) + + void installObservers(); + void removeObservers(); + void teardownActive(); // 清理活动态(移观察者/预览/置inactive),不触发回调 + + vtkRenderWindowInteractor* interactor_; + vtkRenderer* renderer_; + + bool active_ = false; + DrawMode mode_ = DrawMode::Face; + Vec3 origin_{{0, 0, 0}}, normal_{{0, 0, 1}}; + std::vector pts_; + std::function&)> onFinish_; + std::function onCancel_; + + vtkSmartPointer preview_; // 已点几何(顶点圆点 + 实线折线) + vtkSmartPointer rubber_; // 末点→光标 虚线橡皮筋 + Vec3 cursorPt_{{0, 0, 0}}; // 当前鼠标在切面上的投影点 + bool hasCursor_ = false; + bool cursorNearStart_ = false; // 面模式光标邻近起点 → 橡皮筋指向起点预览闭合 + vtkSmartPointer 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 diff --git a/src/render/interact/InteractionManager.cpp b/src/render/interact/InteractionManager.cpp new file mode 100644 index 0000000..33baf68 --- /dev/null +++ b/src/render/interact/InteractionManager.cpp @@ -0,0 +1,455 @@ +#include "interact/InteractionManager.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ColorLutBuilder.hpp" +#include "interact/PickInteractorStyle.hpp" + +namespace geopro::render::interact { + +namespace { +std::array imageBounds(vtkImageData* img) { + std::array b{{0, 0, 0, 0, 0, 0}}; + if (img) img->GetBounds(b.data()); + return b; +} +} // namespace + +InteractionManager::InteractionManager(vtkRenderWindowInteractor* interactor, + vtkRenderWindow* renderWindow, vtkRenderer* renderer) + : interactor_(interactor), renderWindow_(renderWindow), renderer_(renderer) { + installStyle(); +} + +InteractionManager::~InteractionManager() { + destroying_ = true; // closeAll 跳过 Render(Qt 拆台时窗口可能已半析构) + closeAll(); + uninstallStyle(); +} + +void InteractionManager::installStyle() { + if (!interactor_ || style_) return; + style_ = vtkSmartPointer::New(); + style_->onPick = [this](const Vec3& w) { onPicked(w); }; + style_->onDoubleClick = [this](const Vec3& w) { onDoubleClicked(w); }; + style_->onWheelStep = [this](int dir) { return onWheel(dir); }; + // D39: 提供旋转中心 = 选中切片中心(有选中→true)。style 在按下拖动时据此绕选中切片旋转。 + style_->getRotateCenter = [this](Vec3& c) { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; + c = slices_[static_cast(selected_)]->center(); + return true; + }; + interactor_->SetInteractorStyle(style_); + + // 右键菜单观察者:高优先级(1.0)直接挂交互器,先于 vtkImagePlaneWidget(默认 0.0)消费右键。 + // 命中切片 → handleRightButton 内 abort + 弹菜单;未命中 → 不 abort,事件继续走默认。 + rightBtnCmd_ = vtkSmartPointer::New(); + rightBtnCmd_->SetClientData(this); + rightBtnCmd_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + static_cast(client)->handleRightButton(); + }); + rightBtnTag_ = interactor_->AddObserver(vtkCommand::RightButtonPressEvent, rightBtnCmd_, 1.0); +} + +void InteractionManager::uninstallStyle() { + if (style_) { + // 断开回调(this 即将析构),避免迟到事件回调悬垂。 + style_->onPick = nullptr; + style_->onDoubleClick = nullptr; + 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; +} + +void InteractionManager::safeRender() { + if (renderWindow_ && !destroying_) renderWindow_->Render(); +} + +void InteractionManager::updateSelectionVisual() { + for (std::size_t i = 0; i < slices_.size(); ++i) + slices_[i]->setSelected(static_cast(i) == selected_); +} + +const InteractionManager::VolumeImg* InteractionManager::volumeOf(const std::string& volumeDsId) const { + auto it = volumes_.find(volumeDsId); + return it != volumes_.end() ? &it->second : nullptr; +} + +vtkImageData* InteractionManager::selectedVolumeImage() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return nullptr; + const VolumeImg* v = volumeOf(slices_[static_cast(selected_)]->volumeDsId()); + return v ? v->image : nullptr; +} + +void InteractionManager::setVolumeImage(const std::string& volumeDsId, vtkImageData* image, + const geopro::core::ColorScale& cs, double vmin, double vmax) { + if (volumeDsId.empty()) return; + auto it = volumes_.find(volumeDsId); + // 同体 image 变更(重建/改色阶):旧 image 即将失效 → 先关该体已显示切片(上层 syncSlices 用新 image 重现)。 + if (it != volumes_.end() && it->second.image != image) closeSlicesOfVolume(volumeDsId); + volumes_[volumeDsId] = VolumeImg{image, cs, vmin, vmax}; +} + +void InteractionManager::removeVolumeImage(const std::string& volumeDsId) { + if (!volumes_.count(volumeDsId)) return; + closeSlicesOfVolume(volumeDsId); // 体取消渲染 → 关其下所有切片 + volumes_.erase(volumeDsId); +} + +std::vector InteractionManager::volumeIds() const { + std::vector ids; + ids.reserve(volumes_.size()); + for (const auto& kv : volumes_) ids.push_back(kv.first); + return ids; +} + +void InteractionManager::closeSlicesOfVolume(const std::string& volumeDsId) { + for (std::size_t i = slices_.size(); i-- > 0;) { + if (slices_[i]->volumeDsId() != volumeDsId) continue; + slices_[i]->close(); + slices_.erase(slices_.begin() + static_cast(i)); + } + selected_ = slices_.empty() ? -1 : std::min(selected_, static_cast(slices_.size()) - 1); + updateSelectionVisual(); + safeRender(); +} + +void InteractionManager::addSlice(SliceAxis axis, const std::string& volumeDsId) { + const VolumeImg* v = volumeOf(volumeDsId); + if (!v || !v->image || !interactor_) return; + auto tool = std::make_unique(v->image, interactor_, axis, v->cs, v->vmin, v->vmax); + tool->setVolumeDsId(volumeDsId); + // 触碰本切片(拖动/点击切面) → 设为选中(widget 开启交互后独占切面事件,选中靠此回调)。 + SliceTool* tp = tool.get(); + tool->onInteract = [this, tp]() { selectByTool(tp); }; + slices_.push_back(std::move(tool)); + selected_ = static_cast(slices_.size()) - 1; // 新切片选中 + updateSelectionVisual(); + if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{}); // 新建(未保存)切片→清列表选中 + safeRender(); +} + +void InteractionManager::showSavedSlice(const std::string& dsId, int axis, const Vec3& origin, + const Vec3& point1, const Vec3& point2, + const std::string& volumeDsId) { + const VolumeImg* v = volumeOf(volumeDsId); + if (!v || !v->image || !interactor_ || dsId.empty()) return; + for (const auto& s : slices_) + if (s->dsId() == dsId) return; // 已显示 → 去重跳过 + const SliceAxis ax = static_cast(axis); + auto tool = std::make_unique(v->image, interactor_, ax, v->cs, v->vmin, v->vmax, + origin, point1, point2); // 三点精确还原 + tool->setDsId(dsId); + tool->setVolumeDsId(volumeDsId); + SliceTool* tp = tool.get(); + tool->onInteract = [this, tp]() { selectByTool(tp); }; + tool->setInteractive(false); // 已保存切片定稿锁定:不可移动/旋转(用户要求);仍可拾取选中/右键 + slices_.push_back(std::move(tool)); + selected_ = static_cast(slices_.size()) - 1; + updateSelectionVisual(); // 程序化显示(syncSlices):不发 onSliceSelectionChanged,避免列表选中被刷 + 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(i)); + selected_ = slices_.empty() ? -1 + : std::min(selected_, static_cast(slices_.size()) - 1); + updateSelectionVisual(); + safeRender(); + return; + } +} + +std::vector InteractionManager::shownSavedSliceIds() const { + std::vector 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(i); + updateSelectionVisual(); + safeRender(); + return true; + } + return false; +} + +void InteractionManager::deselectSlice() { + if (selected_ < 0) return; + selected_ = -1; + updateSelectionVisual(); // 清高亮(无选中切片) + safeRender(); +} + +void InteractionManager::selectByTool(const SliceTool* tool) { + int idx = -1; + for (std::size_t i = 0; i < slices_.size(); ++i) + if (slices_[i].get() == tool) { idx = static_cast(i); break; } + if (idx < 0) return; + selected_ = idx; + updateSelectionVisual(); + if (onSliceSelectionChanged) // 反向 VTK→list:选中切片 → 列表同步选中(dsId 空=临时切片) + onSliceSelectionChanged(slices_[static_cast(idx)]->dsId()); + + // 双击切片正视(D40):同一切片在 350ms 内两次交互 → 视为双击 → 正视。 + const double now = std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); + const bool dbl = (tool == lastInteractTool_) && lastInteractMs_ >= 0.0 && + (now - lastInteractMs_) < 350.0; + lastInteractMs_ = now; + lastInteractTool_ = tool; + if (dbl) { + lastInteractMs_ = -1.0; // 重置避免三连判 + faceSlice(idx); + return; + } + safeRender(); +} + +void InteractionManager::closeSelected() { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return; + const std::string closedDsId = slices_[static_cast(selected_)]->dsId(); + slices_[static_cast(selected_)]->close(); + slices_.erase(slices_.begin() + selected_); + // 选中停在原位就近(删后该位变成下一张;删的是末张则退一张),不跳回 0(评审 M2)。 + selected_ = slices_.empty() ? -1 + : std::min(selected_, static_cast(slices_.size()) - 1); + updateSelectionVisual(); + safeRender(); + // 已保存切片被主动关闭 → 通知上层取消列表勾选(场景↔列表同步)。 + if (!closedDsId.empty() && onSliceClosed) onSliceClosed(closedDsId); +} + +void InteractionManager::closeAll() { + for (auto& s : slices_) s->close(); // 显式 Off + 解绑(析构亦会,双保险幂等) + slices_.clear(); + selected_ = -1; + safeRender(); +} + +PickInteractorStyle* InteractionManager::pickStyle() const { return style_; } + +void InteractionManager::setMode2D(bool is2D) { + // 进入二维分析:主动取消「三维前视图」的所有选中。否则残留的选中切片会让 onWheel 持续消费滚轮 + // (二维下无法缩放),且切回三维仍残留高亮。清 selected_ + 切片高亮;再经 onSliceSelectionChanged("") + // 联动清三维分析列表选中行与异常高亮(app 层接线)。与 VtkSceneView::setAnalysisMode2D 离开二维时 + // clearMapLineSelection 清足迹选中相对称。 + if (is2D) { + if (selected_ >= 0) { + selected_ = -1; + updateSelectionVisual(); // 清切片高亮(切回三维不残留选中) + } + if (onSliceSelectionChanged) onSliceSelectionChanged(std::string{}); + } + // 切片属三维内容:二维分析隐藏(不销毁→切回零重建)、三维分析显示。 + for (auto& s : slices_) + if (s) s->setVisible(!is2D); + if (style_) style_->setLock2D(is2D); // 二维=禁旋转、左键平移(仅平移+缩放) + // 不在此渲染:相机/地形/底图/维度显隐及统一 Render 由 VtkSceneView::setAnalysisMode2D 收尾。 +} + +void InteractionManager::flipView() { + if (!renderer_) return; + auto* cam = renderer_->GetActiveCamera(); + if (!cam) return; + cam->Azimuth(180.0); // 水平旋转 180°(E55) + cam->OrthogonalizeViewUp(); + safeRender(); +} + +void InteractionManager::faceSelected() { faceSlice(selected_); } + +bool InteractionManager::selectedSlicePlane(int& axis, Vec3& origin, Vec3& point1, + Vec3& point2) const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; + const auto& s = slices_[static_cast(selected_)]; + axis = static_cast(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(slices_.size())) return {}; + return slices_[static_cast(selected_)]->dsId(); +} + +std::string InteractionManager::selectedSliceVolumeDsId() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return {}; + return slices_[static_cast(selected_)]->volumeDsId(); +} + +void InteractionManager::tagSelectedSlice(const std::string& dsId) { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return; + slices_[static_cast(selected_)]->setDsId(dsId); + slices_[static_cast(selected_)]->setInteractive(false); // 保存即定稿锁定(不可改) +} + +vtkImageData* InteractionManager::selectedSliceImage() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return nullptr; + return slices_[static_cast(selected_)]->reslicedOutput(); +} + +vtkSmartPointer InteractionManager::selectedSliceColorImage() const { + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return nullptr; + // 与屏幕切片**同源**的着色输出(widget 自己的 ColorMap 输出, 逐像素一致, RGBA 外区透明)。 + // 原先另建 LUT 上色, 与屏幕配色可能不一致(用户实测异常截图与切面差异大) → 改取 widget 着色结果。 + auto colored = slices_[static_cast(selected_)]->coloredResliceImage(); + if (colored == nullptr) return nullptr; + + // 高清化:切片重采样像素维度受体素分辨率限制(常仅几十px) → 上采样到目标分辨率(双线性, 与屏幕 + // TextureInterpolateOn 同口径), 得清晰大图。对 RGBA 直接插值(色已定, 不再过 LUT)。 + constexpr int kExportLongSide = 2048; + int dims[3]; + colored->GetDimensions(dims); + const int nx = dims[0], ny = dims[1]; + const int longest = std::max(nx, ny); + double f = (longest > 0) ? static_cast(kExportLongSide) / longest : 1.0; + if (f < 1.0) f = 1.0; // 不缩小 + vtkNew resize; + resize->SetInputData(colored); + resize->SetResizeMethodToOutputDimensions(); + resize->SetOutputDimensions(std::max(1, static_cast(nx * f)), + std::max(1, static_cast(ny * f)), 1); + resize->Update(); + auto out = vtkSmartPointer::New(); + out->DeepCopy(resize->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 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(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 centers, normals; + centers.reserve(slices_.size()); + normals.reserve(slices_.size()); + for (const auto& s : slices_) { + centers.push_back(s->center()); + normals.push_back(s->normal()); + } + const int idx = nearestPlane(centers, normals, worldPoint); + if (idx < 0) return -1; + // 阈值:命中点离最近切面太远(> 体对角线 5%)视为"没点在切片上",不改选中(评审 M2)。 + // 多体并发:用该切片所属体的包围盒(各体大小不同)。 + const VolumeImg* vol = volumeOf(slices_[static_cast(idx)]->volumeDsId()); + const std::array b = imageBounds(vol ? vol->image : nullptr); + const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4]; + const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); + const double dist = slices_[static_cast(idx)]->distanceToPlane(worldPoint); + if (diag > 0.0 && dist > diag * 0.05) return -1; + return idx; +} + +void InteractionManager::onPicked(const Vec3& worldPoint) { + // 单击 = 选中命中切片;点在切片外(如点到体/帘面)→ 取消选中(idx=-1)。**不动相机**。 + // 解决"选了切片无法取消":点击切片之外即清选中,滚轮恢复缩放(见 onWheel)。 + selected_ = nearestSlice(worldPoint); + updateSelectionVisual(); + if (onSliceSelectionChanged) // 反向 VTK→list:点中切片→列表同步选中;点空(idx<0)→清列表选中 + onSliceSelectionChanged(selected_ >= 0 + ? slices_[static_cast(selected_)]->dsId() + : std::string{}); + safeRender(); +} + +void InteractionManager::onDoubleClicked(const Vec3& worldPoint) { + // 双击命中切片 → 正视(widget 开启交互后双击多被其吞,正视主入口改工具条按钮 faceSelected)。 + const int idx = nearestSlice(worldPoint); + if (idx < 0) return; + selected_ = idx; + updateSelectionVisual(); + faceSlice(idx); +} + +void InteractionManager::faceSlice(int idx) { + if (idx < 0 || idx >= static_cast(slices_.size()) || !renderer_) return; + auto* cam = renderer_->GetActiveCamera(); + if (!cam) return; + const Vec3 focal = slices_[static_cast(idx)]->center(); + const Vec3 normal = slices_[static_cast(idx)]->normal(); + const double dist = cam->GetDistance(); // 保持当前观察距离 + const FaceOnCamera face = faceOnCamera(focal, normal, dist); + cam->SetFocalPoint(focal[0], focal[1], focal[2]); + cam->SetPosition(face.position[0], face.position[1], face.position[2]); + cam->SetViewUp(face.viewUp[0], face.viewUp[1], face.viewUp[2]); + cam->OrthogonalizeViewUp(); + renderer_->ResetCameraClippingRange(); + safeRender(); +} + + +bool InteractionManager::onWheel(int dir) { + // 滚轮推进**当前选中**的切片(需先显式选中);无选中 → 不消费 → 相机缩放。 + // 配合 onPicked 的"点击切片外取消选中":取消后滚轮即恢复缩放,解决"选了切片无法缩放"。 + // (不采用"悬停即推进":推进时鼠标难持续压在移动的切片上,且过敏感。) + if (selected_ < 0 || selected_ >= static_cast(slices_.size())) return false; + const double step = wheelStep(imageBounds(selectedVolumeImage()), dir); // 选中切片所属体 + slices_[static_cast(selected_)]->advance(step); + safeRender(); + return true; // 消费滚轮(推进选中切片,不缩放) +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/InteractionManager.hpp b/src/render/interact/InteractionManager.hpp new file mode 100644 index 0000000..af14f27 --- /dev/null +++ b/src/render/interact/InteractionManager.hpp @@ -0,0 +1,178 @@ +#pragma once +#include +#include +#include +#include +#include + +#include + +#include "interact/SlicePlaneMath.hpp" +#include "interact/SliceTool.hpp" +#include "model/ColorScale.hpp" + +class vtkImageData; +class vtkRenderWindow; +class vtkRenderWindowInteractor; +class vtkRenderer; +class vtkCallbackCommand; + +namespace geopro::render::interact { + +class PickInteractorStyle; + +// 三维切片交互编排(spec §9):持 interactor + 活动切片列表 + 选中态。 +// · 创建/关闭切片(轴向/任意),附着到当前体素 image(含 VE 烤入的几何)。 +// · 安装自定义 PickInteractorStyle:拾取选中→绕命中点旋转;双击切片→正视;滚轮→沿法向推进选中切片。 +// · 视图翻转(水平 Azimuth 180°,E55)。 +// · 切到二维 / 体素重建 / 清场:closeAll 安全释放所有切片(Off + 解绑,防悬挂观察者崩溃)。 +// +// render 层:只碰 VTK widget/相机,不认仓储;产物经回调/上层处理(本期切片仅在视图内交互)。 +class InteractionManager { +public: + // interactor:QVTK 提供的活 interactor(renderWindow->GetInteractor())。 + // renderWindow:用于推进/翻转后重绘。 + InteractionManager(vtkRenderWindowInteractor* interactor, vtkRenderWindow* renderWindow, + vtkRenderer* renderer); + ~InteractionManager(); + + InteractionManager(const InteractionManager&) = delete; + InteractionManager& operator=(const InteractionManager&) = delete; + + // 新增/更新某三维体的 image + 色阶(多体并发:不影响其它体的切片)。同体 image 变更(重建/改色阶) + // 会先关该体已显示切片(旧 image 失效),由上层 syncSlices 用新 image 重现。 + void setVolumeImage(const std::string& volumeDsId, vtkImageData* image, + const geopro::core::ColorScale& cs, double vmin, double vmax); + // 移除某体(体被取消渲染):关闭其下所有切片并去除该体 image。 + void removeVolumeImage(const std::string& volumeDsId); + // 当前已附着的三维体 dsId 列表(上层据此 diff 出需移除的体)。 + std::vector volumeIds() const; + + // 在指定三维体上创建一张切片(轴向/任意)。该体无 image 则忽略。新切片自动设为选中。 + void addSlice(SliceAxis axis, const std::string& volumeDsId); + + // ── 已保存切片(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, const std::string& volumeDsId); + void hideSavedSlice(const std::string& dsId); + std::vector shownSavedSliceIds() const; // 当前已显示的已保存切片 dsId 列表 + // 选中已显示的某 dsId 切片(列表操作定位到对应渲染切片);找到返回 true。 + bool selectSavedSlice(const std::string& dsId); + // 清除切片选中(列表选中切到别的对象/异常时调用,否则 VTK 切片仍高亮,用户反馈)。 + void deselectSlice(); + + // 关闭选中切片(E56)。无选中则忽略。 + void closeSelected(); + // 关闭并释放所有切片(切到二维 / 清场 / 体素重建前调)。 + void closeAll(); + + // 切二维分析(is2D=true)/三维分析(false):翻所有切片显隐(不销毁,切回零重建) + 锁/解锁交互样式 + // (二维=仅平移+缩放、禁旋转)。地形/底图/相机由 VtkSceneView::setAnalysisMode2D 处理,此处不渲染。 + void setMode2D(bool is2D); + // 关闭并释放某体下的所有切片(该体移除/重建时;不动其它体的切片)。 + void closeSlicesOfVolume(const std::string& volumeDsId); + + bool hasVolume() const { return !volumes_.empty(); } + bool isVolumeRendered(const std::string& volumeDsId) const { return volumes_.count(volumeDsId) > 0; } + bool hasSlices() const { return !slices_.empty(); } + int sliceCount() const { return static_cast(slices_.size()); } + + // 视图翻转:水平旋转 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(保存切片/创建异常时定位到正确的体)。无选中返回空字符串。 + std::string selectedSliceVolumeDsId() const; + // 给当前选中(未保存)切片打 dsId 标签:保存=把当前切片链接到新数据集(不重绘、不重复)。 + void tagSelectedSlice(const std::string& dsId); + // 选中切片的重采样 2D 标量影像(导出 dat 用);无选中返回 nullptr。 + vtkImageData* selectedSliceImage() const; + // 选中切片"上色后"的 2D 影像(导出图片用):重采样标量经切片色阶 LUT → RGB 图;无选中返回 nullptr。 + vtkSmartPointer selectedSliceColorImage() const; + + // 右键命中切片时回调(manager 已选中所在切片)→ 上层据此弹切片右键菜单(用 QCursor::pos 定位)。 + std::function onSliceContextMenuRequested; + + // 通过「关闭」显式关掉一张已保存切片时回调其 dsId → 上层据此取消列表勾选(场景↔列表同步)。 + // 仅 closeSelected(用户主动关闭) 触发;closeAll(体变更/清场) 不触发(切片应随体回来再现)。 + std::function onSliceClosed; + + // 选中切片变化(VTK 内拾取/widget 交互选中一张已保存切片)→ 回调其 dsId,供上层在列表里同步选中 + // (反向 VTK→list 联动)。选中临时(未保存)切片或取消选中(空 dsId)也会回调。 + std::function onSliceSelectionChanged; + + // 安装/卸载自定义交互样式(构造时安装;析构卸载恢复原样式)。 + void installStyle(); + void uninstallStyle(); + + // 暴露交互样式:供 app 层注入二维分析 B 期的足迹拾取/Z 拖动回调(onPick2D/onDrag2D/onDrag2DEnd)。 + // 定义在 .cpp(此处 PickInteractorStyle 仅前置声明,vtkSmartPointer→裸指针下转需完整类型)。 + PickInteractorStyle* pickStyle() const; + +private: + // 拾取回调实现(PickInteractorStyle 注入)。 + void onPicked(const Vec3& worldPoint); // 选中所在切片 + 焦点 + 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)。 + void faceSlice(int idx); + + // 统一重绘:析构进行中(destroying_)跳过,避免 Qt 拆台时对半析构窗口 Render 崩溃(评审 H3)。 + void safeRender(); + + // 按 selected_ 刷新各切片高亮(选中亮黄、其余暗淡)。 + void updateSelectionVisual(); + + vtkRenderWindowInteractor* interactor_; + vtkRenderWindow* renderWindow_; + vtkRenderer* renderer_; + + // 多体并发:按三维体 dsId 持各体的 image + 色阶(切片附着到各自体的 image,互不影响)。 + struct VolumeImg { + vtkImageData* image = nullptr; // 非拥有 + geopro::core::ColorScale cs; + double vmin = 0.0, vmax = 0.0; + }; + std::map volumes_; // 三维体 dsId → image/色阶 + // 取某体 image(缺则 nullptr)。 + const VolumeImg* volumeOf(const std::string& volumeDsId) const; + // 选中切片所属体的 image(nearestSlice 阈值/滚轮步长用);无选中或体缺则 nullptr。 + vtkImageData* selectedVolumeImage() const; + + std::vector> slices_; + int selected_ = -1; // 选中切片索引(-1=无) + + vtkSmartPointer style_; + // 右键菜单:高优先级交互器观察者(先于 widget 抢右键)。tag 供 uninstall 时摘除。 + vtkSmartPointer rightBtnCmd_; + unsigned long rightBtnTag_ = 0; + + // 析构进行中:closeAll() 跳过 renderWindow_->Render()(Qt 拆台时窗口可能已半析构, + // 析构期再 Render 易崩,评审 M3)。 + bool destroying_ = false; + + // 双击切片正视(D40)检测:同一切片在阈值内两次交互(StartInteractionEvent)视为双击 → 正视。 + // 因 widget 开启交互后独占切面事件,双击靠监听 widget 交互判定,而非 InteractorStyle。 + double lastInteractMs_ = -1.0; + const SliceTool* lastInteractTool_ = nullptr; +}; + +} // namespace geopro::render::interact diff --git a/src/render/interact/PickInteractorStyle.cpp b/src/render/interact/PickInteractorStyle.cpp new file mode 100644 index 0000000..533576a --- /dev/null +++ b/src/render/interact/PickInteractorStyle.cpp @@ -0,0 +1,208 @@ +#include "interact/PickInteractorStyle.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace geopro::render::interact { + +namespace { +constexpr double kDoubleClickMs = 350.0; // 两次左键按下间隔阈值 +constexpr int kClickSlopPx2 = 36; // 位置相近阈值平方(6px) + +// 当前单调时钟(毫秒)。用 std::chrono 避免依赖 VTK::CommonSystem(vtkTimerLog)。 +double nowMs() { + return std::chrono::duration( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} +} // namespace + +vtkStandardNewMacro(PickInteractorStyle); + +bool PickInteractorStyle::pickWorld(Vec3& out) { + auto* iren = this->GetInteractor(); + if (!iren) return false; + const int* pos = iren->GetEventPosition(); + // 用交互器解析被点中的 renderer(基类 FindPokedRenderer 仅设 CurrentRenderer、返回 void)。 + auto* ren = iren->FindPokedRenderer(pos[0], pos[1]); + if (!ren) return false; + // CellPicker:返回表面交点世界坐标(命中切片纹理面/帘面等)。 + vtkNew picker; + picker->SetTolerance(0.005); + if (!picker->Pick(pos[0], pos[1], 0.0, ren)) return false; + double w[3]; + picker->GetPickPosition(w); + out = {w[0], w[1], w[2]}; + return true; +} + +void PickInteractorStyle::OnLeftButtonDown() { + auto* iren = this->GetInteractor(); + // 二维分析:左键命中足迹→进入高程 Z 拖动(B 期);否则=平移(等同中键),禁旋转。抬键由 OnLeftButtonUp 收尾。 + if (lock2D_) { + const int* p = iren ? iren->GetEventPosition() : nullptr; + if (p) this->FindPokedRenderer(p[0], p[1]); + if (!this->CurrentRenderer) return; + const bool additive = iren && iren->GetControlKey(); // Ctrl=多选 + if (onPick2D && p && onPick2D(p[0], p[1], additive)) { // 命中足迹 → Z 拖动 + dragging2D_ = true; + lastDragY_ = p[1]; + this->GrabFocus(this->EventCallbackCommand); + return; + } + this->GrabFocus(this->EventCallbackCommand); // 未命中 → 平移 + this->StartPan(); + return; + } + Vec3 world; + const bool hit = pickWorld(world); + + // 手动双击判定(GetRepeatCount 在 QVTK+Windows 不可靠,评审 M5): + // 两次左键按下间隔 < 阈值且屏幕位置相近 → 双击。 + const double now = nowMs(); + const int* pos = iren ? iren->GetEventPosition() : nullptr; + bool isDouble = false; + if (hit && pos && lastDownTime_ >= 0.0) { + const double dtMs = now - lastDownTime_; + const int dx = pos[0] - lastDownPos_[0]; + const int dy = pos[1] - lastDownPos_[1]; + if (dtMs < kDoubleClickMs && (dx * dx + dy * dy) <= kClickSlopPx2) isDouble = true; + } + if (pos) { + lastDownPos_[0] = pos[0]; + lastDownPos_[1] = pos[1]; + } + lastDownTime_ = now; + + if (isDouble) { + // 双击命中 → 正视所在切片(manager 找最近切片 + 算相机)。 + if (onDoubleClick) onDoubleClick(world); + lastDownTime_ = -1.0; // 重置,避免三击连判 + return; // 不进入拖动旋转 + } + if (hit) { + // 单击命中 → 选中所在切片(onPick 内仅选中, 不动相机)。 + if (onPick) onPick(world); + } + // 不在按下时动相机(动相机=跳);绕选中物旋转在 Rotate() 内做(增量绕支点,不跳)。 + Superclass::OnLeftButtonDown(); +} + +void PickInteractorStyle::Rotate() { + if (lock2D_) return; // 二维分析禁旋转(仅平移+缩放) + Vec3 c; + if (!this->CurrentRenderer || !getRotateCenter || !getRotateCenter(c)) { + Superclass::Rotate(); // 无选中物 → 默认绕焦点旋转 + return; + } + auto* rwi = this->Interactor; + auto* cam = this->CurrentRenderer->GetActiveCamera(); + if (!rwi || !cam) return; + const int dx = rwi->GetEventPosition()[0] - rwi->GetLastEventPosition()[0]; + const int dy = rwi->GetEventPosition()[1] - rwi->GetLastEventPosition()[1]; + const int* size = this->CurrentRenderer->GetRenderWindow()->GetSize(); + if (size[0] <= 0 || size[1] <= 0) return; + // 与 TrackballCamera 同口径的角度映射。 + const double azimuth = dx * (-20.0 / size[0]) * this->MotionFactor; + const double elevation = dy * (-20.0 / size[1]) * this->MotionFactor; + + double up[3], dop[3], right[3]; + cam->GetViewUp(up); + cam->GetDirectionOfProjection(dop); // 归一化的 (focal-pos) + vtkMath::Cross(dop, up, right); // 屏幕"右"轴 + vtkMath::Normalize(right); + + // 绕中心 c 的支点:T(c)·R(up,azimuth)·R(right,elevation)·T(-c),作用于 position/focal;up 只转不平移。 + vtkNew t; + t->Identity(); + t->Translate(c[0], c[1], c[2]); + t->RotateWXYZ(azimuth, up[0], up[1], up[2]); + t->RotateWXYZ(elevation, right[0], right[1], right[2]); + t->Translate(-c[0], -c[1], -c[2]); + + double pos[3], foc[3], npos[3], nfoc[3], nup[3]; + cam->GetPosition(pos); + cam->GetFocalPoint(foc); + t->TransformPoint(pos, npos); + t->TransformPoint(foc, nfoc); + t->TransformVector(up, nup); // 仅旋转部分作用于向量 + cam->SetPosition(npos); + cam->SetFocalPoint(nfoc); + cam->SetViewUp(nup); + cam->OrthogonalizeViewUp(); + if (this->AutoAdjustCameraClippingRange) this->CurrentRenderer->ResetCameraClippingRange(); + rwi->Render(); +} + +double PickInteractorStyle::worldPerPixelZ() const { + if (!this->CurrentRenderer) return 1.0; + auto* cam = this->CurrentRenderer->GetActiveCamera(); + auto* rw = this->CurrentRenderer->GetRenderWindow(); + if (!cam || !rw) return 1.0; + const int* sz = rw->GetSize(); + const double h = (sz && sz[1] > 0) ? static_cast(sz[1]) : 800.0; + if (cam->GetParallelProjection()) + return 2.0 * cam->GetParallelScale() / h; // 平行投影:可见世界高度=2*parallelScale + // 透视:可见世界高度 = 2*d*tan(viewAngle/2),d=相机到焦点距离。 + double pos[3], fp[3]; + cam->GetPosition(pos); + cam->GetFocalPoint(fp); + const double dx = pos[0] - fp[0], dy = pos[1] - fp[1], dz = pos[2] - fp[2]; + const double d = std::sqrt(dx * dx + dy * dy + dz * dz); + const double va = vtkMath::RadiansFromDegrees(cam->GetViewAngle()); + return 2.0 * d * std::tan(va * 0.5) / h; +} + +void PickInteractorStyle::OnMouseMove() { + if (dragging2D_) { // B 期:竖向拖动 → 选中足迹 Z 增量(仅改 Z)。鼠标上移(y 增)→ 抬高。 + auto* rwi = this->Interactor; + if (rwi) { + const int y = rwi->GetEventPosition()[1]; + const int dyPix = y - lastDragY_; + lastDragY_ = y; + if (dyPix != 0 && onDrag2D) onDrag2D(worldPerPixelZ() * dyPix); + } + return; // 不走基类(不平移/不旋转) + } + Superclass::OnMouseMove(); +} + +void PickInteractorStyle::OnLeftButtonUp() { + if (dragging2D_) { // 结束 Z 拖动 + dragging2D_ = false; + if (this->Interactor) this->ReleaseFocus(); + if (onDrag2DEnd) onDrag2DEnd(); + return; + } + Superclass::OnLeftButtonUp(); // 平移/旋转/缩放等由基类按 State 收尾 +} + +namespace { +constexpr double kWheelStepPx = 24.0; // 滚轮一格升降 ≈ 拖动 24 像素的世界 Z 量(与拖动手感一致) +} + +void PickInteractorStyle::OnMouseWheelForward() { + // 二维分析有选中足迹 → 滚轮抬升其高程(消费滚轮);否则按切片推进 / 默认缩放。 + if (lock2D_ && onWheel2D && onWheel2D(worldPerPixelZ() * kWheelStepPx)) return; + if (onWheelStep && onWheelStep(+1)) return; // 有选中切片 → 推进,消费滚轮 + Superclass::OnMouseWheelForward(); // 否则默认缩放 +} + +void PickInteractorStyle::OnMouseWheelBackward() { + if (lock2D_ && onWheel2D && onWheel2D(-worldPerPixelZ() * kWheelStepPx)) return; + if (onWheelStep && onWheelStep(-1)) return; + Superclass::OnMouseWheelBackward(); +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/PickInteractorStyle.hpp b/src/render/interact/PickInteractorStyle.hpp new file mode 100644 index 0000000..5c9b41f --- /dev/null +++ b/src/render/interact/PickInteractorStyle.hpp @@ -0,0 +1,80 @@ +#pragma once +#include + +#include + +#include "interact/SlicePlaneMath.hpp" + +namespace geopro::render::interact { + +// 自定义交互样式:在 TrackballCamera 基础上加拾取与切片交互(spec §9.3)。 +// 左键按下 → vtkPropPicker 拾取 → 命中则相机 focalPoint=命中点(拖动绕其旋转), +// 并把命中世界点回调出去(InteractionManager 据此选中所在切片)。 +// 左键双击 → 回调双击世界点(InteractionManager 找最近切片 → 相机正视其法向)。 +// 滚轮前/后 → 回调步进方向(±1),由 manager 推进选中切片;无选中则回退默认缩放。 +// 保留 TrackballCamera 的相机拖动/缩放等基础交互(仅在命中/有选中切片时改写行为)。 +// +// 回调由 InteractionManager 注入(render 层不认业务,只发"命中点/双击/滚轮"事件)。 +class PickInteractorStyle : public vtkInteractorStyleTrackballCamera { +public: + static PickInteractorStyle* New(); + vtkTypeMacro(PickInteractorStyle, vtkInteractorStyleTrackballCamera); + + // 单击命中世界点(已命中某 prop)。用于设焦点+选中切片。 + std::function onPick; + // 双击世界点。用于正视所在切片。 + std::function onDoubleClick; + // 滚轮步进:dir=+1 前/-1 后。返回 true 表示已被消费(有选中切片推进), + // false 则执行默认相机缩放。 + std::function onWheelStep; + // 取当前旋转中心(D39):有选中三维体/切片→填其中心、返回 true;否则 false(绕默认焦点)。 + // 在"按下开始拖动"时调用一次,把焦点设到该中心(位置同步补偿,画面不变)→ 之后绕它旋转、不跳。 + std::function getRotateCenter; + + // 二维分析锁:开 → 左键拖动改为平移、禁旋转(仅平移+缩放);关 → 恢复三维拾取/旋转交互。 + void setLock2D(bool on) { lock2D_ = on; } + bool isLock2D() const { return lock2D_; } + + // ── 二维分析 B 期:选中足迹沿高程 Z 拖动 ──(仅 lock2D 下生效;回调由 app 层注入) + // onPick2D:左键按下时在(x,y)拾取足迹(additive=Ctrl 多选),返回是否有选中→有则进入 Z 拖动、否则平移。 + // onDrag2D:拖动中把竖向像素换算成的世界 Z 增量(本类按相机算)交给 app 施加到选中足迹(仅改 Z)。 + // onDrag2DEnd:松开结束拖动(供 app 收起高程读数浮层)。 + std::function onPick2D; + std::function onDrag2D; + std::function onDrag2DEnd; + // 滚轮升降:有选中足迹时滚轮改其高程 Z(本类按相机算 worldDz);app 施加并返回是否消费(无选中→false→默认缩放)。 + std::function onWheel2D; + + void OnMouseMove() override; + void OnLeftButtonUp() override; + + void OnLeftButtonDown() override; + void OnMouseWheelForward() override; + void OnMouseWheelBackward() override; + // 绕选中物中心旋转(D39):有 getRotateCenter 时, 绕该中心增量旋转整个相机(位置+焦点+up), + // 中心在世界/屏幕都不动→不跳; 否则回退默认(绕焦点)。 + void Rotate() override; + +protected: + PickInteractorStyle() = default; + +private: + // 在当前鼠标位置拾取世界点;命中返回 true 并填 out。 + bool pickWorld(Vec3& out); + // 当前相机下:竖向一屏幕像素对应的世界 Z(米/像素),用于把拖动像素换算成 Z 增量。 + double worldPerPixelZ() const; + + // 手动双击判定:QVTK+Windows 下 vtkRenderWindowInteractor::GetRepeatCount() 不可靠(评审 M5)。 + // 记上次左键按下时刻+屏幕位置,两次按下间隔 < kDoubleClickMs 且位置相近视为双击。 + double lastDownTime_ = -1.0; // 单调时钟(毫秒),-1=无 + int lastDownPos_[2] = {0, 0}; + + // 二维分析模式:左键=平移、禁旋转(仅平移+缩放)。由 InteractionManager 在切 tab 时设。 + bool lock2D_ = false; + + // B 期足迹 Z 拖动状态:左键命中足迹时进入,记上次鼠标 y 以算增量。 + bool dragging2D_ = false; + int lastDragY_ = 0; +}; + +} // namespace geopro::render::interact diff --git a/src/render/interact/SlicePlaneMath.cpp b/src/render/interact/SlicePlaneMath.cpp new file mode 100644 index 0000000..9def0c2 --- /dev/null +++ b/src/render/interact/SlicePlaneMath.cpp @@ -0,0 +1,96 @@ +#include "interact/SlicePlaneMath.hpp" + +#include +#include +#include + +namespace geopro::render::interact { + +namespace { +// 法向接近竖直(±Z)时 viewUp 不能再取"向上",退备用 up。 +constexpr double kVerticalThreshold = 0.999; +constexpr double kSqrt2Inv = 0.70710678118654752440; // sin/cos 45° +} // namespace + +double dot(const Vec3& a, const Vec3& b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } + +double norm(const Vec3& a) { return std::sqrt(dot(a, a)); } + +Vec3 normalize(const Vec3& a) { + const double n = norm(a); + if (n <= 0.0) return {0.0, 0.0, 1.0}; // 零向量兜底 + return {a[0] / n, a[1] / n, a[2] / n}; +} + +Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]}; +} + +Vec3 axisNormal(SliceAxis axis) { + switch (axis) { + case SliceAxis::UpDown: return {0.0, 0.0, 1.0}; + case SliceAxis::FrontBack: return {0.0, 1.0, 0.0}; + case SliceAxis::LeftRight: return {1.0, 0.0, 0.0}; + case SliceAxis::Oblique: return {kSqrt2Inv, 0.0, kSqrt2Inv}; + } + return {0.0, 0.0, 1.0}; +} + +Vec3 boundsCenter(const std::array& b) { + return {0.5 * (b[0] + b[1]), 0.5 * (b[2] + b[3]), 0.5 * (b[4] + b[5])}; +} + +Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step) { + const Vec3 n = normalize(normal); + return {origin[0] + n[0] * step, origin[1] + n[1] * step, origin[2] + n[2] * step}; +} + +Vec3 clampToBounds(const Vec3& origin, const std::array& b) { + auto clamp1 = [](double v, double lo, double hi) { + if (lo > hi) std::swap(lo, hi); // 容错:bounds 反序 + if (v < lo) return lo; + if (v > hi) return hi; + return v; + }; + return {clamp1(origin[0], b[0], b[1]), clamp1(origin[1], b[2], b[3]), + clamp1(origin[2], b[4], b[5])}; +} + +double wheelStep(const std::array& b, int dir) { + const double dx = b[1] - b[0], dy = b[3] - b[2], dz = b[5] - b[4]; + const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); + const double mag = diag * 0.02; // 一次滚轮 ≈ 1/50 对角线 + return (dir >= 0 ? mag : -mag); +} + +int nearestPlane(const std::vector& centers, const std::vector& normals, + const Vec3& p) { + int best = -1; + double bestDist = 0.0; + for (std::size_t i = 0; i < centers.size() && i < normals.size(); ++i) { + const Vec3 n = normalize(normals[i]); + const Vec3 d{p[0] - centers[i][0], p[1] - centers[i][1], p[2] - centers[i][2]}; + const double dist = std::abs(dot(d, n)); + if (best < 0 || dist < bestDist) { + best = static_cast(i); + bestDist = dist; + } + } + return best; +} + +FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist) { + const Vec3 n = normalize(normal); + // 相机沿法向退 dist:视线 = focal - position = -n(正对切面)。 + const Vec3 position{focal[0] + n[0] * dist, focal[1] + n[1] * dist, focal[2] + n[2] * dist}; + + // viewUp:取与法向正交、尽量指向 +Z 的向量。 + // worldUp×n 得右向量,再 n×right 得位于切面内且偏上的 up。 + // 法向接近竖直(±Z)时 worldUp 与 n 共线 → 退备用 up=+Y。 + Vec3 worldUp = (std::abs(n[2]) > kVerticalThreshold) ? Vec3{0.0, 1.0, 0.0} : Vec3{0.0, 0.0, 1.0}; + const Vec3 right = normalize(cross(worldUp, n)); + const Vec3 up = normalize(cross(n, right)); + return {position, up}; +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/SlicePlaneMath.hpp b/src/render/interact/SlicePlaneMath.hpp new file mode 100644 index 0000000..2beac40 --- /dev/null +++ b/src/render/interact/SlicePlaneMath.hpp @@ -0,0 +1,61 @@ +#pragma once +#include +#include + +namespace geopro::render::interact { + +// 三维向量别名(世界系;x=East,y=North,z=-depth*VE)。 +using Vec3 = std::array; + +// 轴向切片方向(spec §4 F22–F24): +// UpDown 上下 = 水平面,法向沿 Z((0,0,1))—— 切出"水平剖面"。 +// FrontBack 前后 = 法向沿 Y((0,1,0))。 +// LeftRight 左右 = 法向沿 X((1,0,0))。 +// Oblique 任意(F25)= 初始 45°,可旋转。 +enum class SliceAxis { UpDown, FrontBack, LeftRight, Oblique }; + +// ── 纯几何函数(无 VTK 依赖,可单测)──────────────────────────────────── + +// 轴向/任意切片的初始法向(单位向量)。 +// UpDown→(0,0,1);FrontBack→(0,1,0);LeftRight→(1,0,0); +// Oblique→ XZ 平面内 45°((sin45,0,cos45)),即斜插体的对角面。 +Vec3 axisNormal(SliceAxis axis); + +// 包围盒 [xmin,xmax,ymin,ymax,zmin,zmax] 的中心点。 +Vec3 boundsCenter(const std::array& bounds); + +// 滚轮推进:origin' = origin + normal * step(沿法向平移切面一点)。 +// step>0 正向(沿法向),step<0 反向。 +Vec3 advanceOrigin(const Vec3& origin, const Vec3& normal, double step); + +// 把 origin 夹在包围盒内(沿法向推进时防切面跑出体外)。 +// 逐分量 clamp 到 [min,max];退化轴(min==max)取该值。 +Vec3 clampToBounds(const Vec3& origin, const std::array& bounds); + +// 双击正视:给定切面中心 focal、法向 normal、相机到焦点距离 dist, +// 求相机 position 与 viewUp,使相机正对切面(视线 = -normal)。 +// position = focal + normalize(normal) * dist。 +// viewUp 取与法向正交的"尽量向上(+Z)"向量;当法向接近竖直(±Z)时 +// 退到备用 up=+Y 兜底(避免 viewUp 与视线共线导致相机退化)。 +struct FaceOnCamera { + Vec3 position; + Vec3 viewUp; +}; +FaceOnCamera faceOnCamera(const Vec3& focal, const Vec3& normal, double dist); + +// 滚轮推进步长:取包围盒对角线长度的固定比例 × 方向(±1)。 +// 使一次滚轮在体内移动适中(约 1/50 对角线);dir>0 沿法向、dir<0 反向。 +double wheelStep(const std::array& bounds, int dir); + +// 在切片中心列表中找离世界点最近的索引(按到平面的距离最小)。 +// centers/normals 等长;空列表返回 -1。worldPoint 在哪张切片上→该索引。 +int nearestPlane(const std::vector& centers, const std::vector& normals, + const Vec3& worldPoint); + +// 向量工具(暴露供测试/复用)。 +double dot(const Vec3& a, const Vec3& b); +double norm(const Vec3& a); +Vec3 normalize(const Vec3& a); // 零向量返回 (0,0,1) 兜底 +Vec3 cross(const Vec3& a, const Vec3& b); + +} // namespace geopro::render::interact diff --git a/src/render/interact/SliceTool.cpp b/src/render/interact/SliceTool.cpp new file mode 100644 index 0000000..054feab --- /dev/null +++ b/src/render/interact/SliceTool.cpp @@ -0,0 +1,227 @@ +#include "interact/SliceTool.hpp" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ColorLutBuilder.hpp" + +namespace geopro::render::interact { + +namespace { +// 任意切片初始法向(45°,XZ 面内);轴向用 SetPlaneOrientationTo*。 +constexpr double kSqrt2Inv = 0.70710678118654752440; +} // namespace + +void SliceTool::initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax) { + // 经 trivial producer 把已存在的 vtkImageData 接入 widget(widget 只暴露 SetInputConnection)。 + // producer_ 为成员,随 SliceTool 保活(局部变量会构造后即析构→管线断裂,评审 H1)。 + producer_ = vtkSmartPointer::New(); + producer_->SetOutput(image_); + widget_->SetInputConnection(producer_->GetOutputPort()); + + widget_->RestrictPlaneToVolumeOn(); // 切面限制在体内,滚轮推进不跑飞 + widget_->SetResliceInterpolateToLinear(); // reslice 线性插值出连续剖面(非 cutter 交线) + widget_->TextureInterpolateOn(); + widget_->DisplayTextOff(); + + // 色阶 LUT 套用:用户自管 LUT(不让 widget 用默认灰度窗位)。 + auto lut = buildLut(cs, vmin, vmax); + widget_->SetLookupTable(lut); +} + +void SliceTool::applyMarginsAndActivate() { + // 左键拖动=移动切面(默认左键是窗位调整,无用);中键=取值光标。 + widget_->SetLeftButtonAction(vtkImagePlaneWidget::VTK_SLICE_MOTION_ACTION); + widget_->SetMiddleButtonAction(vtkImagePlaneWidget::VTK_CURSOR_ACTION); + // 旋转只允许"任意切片"(F25 可任意调整);轴向(上下/前后/左右)角度固定(G22-24 角度不能再调整): + // 把切面边缘(margins, 旋转抓取区)设为 0 → 抓哪里都只移动、不旋转。 + if (axis_ != SliceAxis::Oblique) { + widget_->SetMarginSizeX(0.0); + widget_->SetMarginSizeY(0.0); + } + + widget_->On(); + // 监听其交互开始事件 → 触碰本切片即回调 onInteract(上层据此设为选中)。 + interactObserver_ = vtkSmartPointer::New(); + interactObserver_->SetClientData(this); + interactObserver_->SetCallback([](vtkObject*, unsigned long, void* client, void*) { + auto* self = static_cast(client); + if (self && self->onInteract) self->onInteract(); + }); + 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::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& origin, const std::array& point1, + const std::array& point2) + : axis_(axis), image_(image), widget_(vtkSmartPointer::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 SliceTool::imageBounds() const { + std::array b{{0, 0, 0, 0, 0, 0}}; + if (image_) image_->GetBounds(b.data()); + return b; +} + +Vec3 SliceTool::normal() const { + double n[3] = {0, 0, 1}; + if (widget_) widget_->GetNormal(n); + return normalize({n[0], n[1], n[2]}); +} + +Vec3 SliceTool::center() const { + double c[3] = {0, 0, 0}; + if (widget_) widget_->GetCenter(c); + return {c[0], c[1], c[2]}; +} + +void SliceTool::advance(double step) { + if (!widget_) return; + // 沿法向刚性平移整张切面:origin/point1/point2 同步加 normal*step。只移 origin 会让 + // 面内两端点不动→平面变形/脱轴(评审 M1)。RestrictPlaneToVolumeOn 负责夹在体内。 + const Vec3 n = normal(); + const double d[3] = {n[0] * step, n[1] * step, n[2] * step}; + double o[3], p1[3], p2[3]; + widget_->GetOrigin(o); + widget_->GetPoint1(p1); + widget_->GetPoint2(p2); + for (int i = 0; i < 3; ++i) { + o[i] += d[i]; + p1[i] += d[i]; + p2[i] += d[i]; + } + widget_->SetOrigin(o); + widget_->SetPoint1(p1); + widget_->SetPoint2(p2); + widget_->UpdatePlacement(); +} + +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; +} + +void SliceTool::setInteractive(bool on) { + interactive_ = on; // 记录锁定态:setVisible 重显时复原 + if (widget_) widget_->SetInteraction(on ? 1 : 0); // 关=锁移动/旋转/光标,纹理仍显示 +} + +void SliceTool::setVisible(bool on) { + if (!widget_) return; + widget_->SetEnabled(on ? 1 : 0); // 翻显隐(不销毁):几何/纹理保留、切回零重建 + if (on) widget_->SetInteraction(interactive_ ? 1 : 0); // SetEnabled 可能重置交互→复原锁定态 +} + +vtkSmartPointer SliceTool::coloredResliceImage() const { + if (!widget_) return nullptr; + vtkImageMapToColors* cm = widget_->GetColorMap(); // widget 内部把 reslice 经 LUT 上色 → 纹理 + if (cm == nullptr) return nullptr; + cm->Update(); + auto out = vtkSmartPointer::New(); + out->DeepCopy(cm->GetOutput()); // 即屏幕切片所贴像素(RGBA, 外区 alpha=0) + return out; +} + +double SliceTool::distanceToPlane(const Vec3& p) const { + const Vec3 c = center(); + const Vec3 n = normal(); + return std::abs(dot({p[0] - c[0], p[1] - c[1], p[2] - c[2]}, n)); +} + +void SliceTool::setSelected(bool sel) { + if (!widget_) return; + // 切片边框 = widget 的 PlaneProperty:选中→亮黄粗线,未选中→暗灰细线。 + if (auto* prop = widget_->GetPlaneProperty()) { + if (sel) { + prop->SetColor(0.0, 0.95, 1.0); // 亮青:与未选的暗灰强对比 + prop->SetLineWidth(3.5); + } else { + prop->SetColor(0.35, 0.35, 0.4); // 暗灰 + prop->SetLineWidth(1.0); + } + } +} + +void SliceTool::close() { + if (!widget_) return; + onInteract = nullptr; // 先断业务回调,避免 Off 期间触发到上层 + if (interactObserver_) { + widget_->RemoveObserver(interactObserver_); + interactObserver_ = nullptr; + } + widget_->Off(); + widget_->SetInteractor(nullptr); // 解除观察者,防悬挂崩溃 + widget_ = nullptr; // 置空 → 二次 close()/析构真正幂等(不再 Off 已解绑 widget) +} + +} // namespace geopro::render::interact diff --git a/src/render/interact/SliceTool.hpp b/src/render/interact/SliceTool.hpp new file mode 100644 index 0000000..c043500 --- /dev/null +++ b/src/render/interact/SliceTool.hpp @@ -0,0 +1,110 @@ +#pragma once +#include +#include +#include + +#include + +#include "interact/SlicePlaneMath.hpp" +#include "model/ColorScale.hpp" + +class vtkImageData; +class vtkImagePlaneWidget; +class vtkCallbackCommand; +class vtkRenderWindowInteractor; +class vtkTrivialProducer; + +namespace geopro::render::interact { + +// 单个切片工具:封装 vtkImagePlaneWidget。 +// 内部对体素 vtkImageData 做 reslice + 纹理着色(spec §9.1 钉死 reslice 路线,非 cutter)。 +// 轴向(UpDown/FrontBack/LeftRight):SetPlaneOrientationToX/Y/Z,角度固定。 +// 任意(Oblique):设初始 45° 法向,允许旋转。 +// 套上调用方提供的色阶 LUT(ColorLutBuilder)。 +// +// 生命周期:构造即 SetInteractor + On()(须传活的 interactor)。 +// 析构(或 close())时 Off(),由 vtkSmartPointer 释放,避免悬挂观察者崩溃。 +// 仅三维视图使用;切到二维由 InteractionManager 统一 close。 +class SliceTool { +public: + // image:体素管线产物(含 VE 烤入的 origin/spacing)。interactor:QVTK 的活 interactor。 + // axis:切面方向。vmin/vmax:色阶区间。 + SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis, + const geopro::core::ColorScale& cs, double vmin, double vmax); + // 还原构造(已保存切片按 spec 重渲染):用精确三点几何,axis 仅决定是否锁旋转(不做轴向 snap)→ + // 尺寸/朝向/位置与保存时完全一致。 + SliceTool(vtkImageData* image, vtkRenderWindowInteractor* interactor, SliceAxis axis, + const geopro::core::ColorScale& cs, double vmin, double vmax, + const std::array& origin, const std::array& point1, + const std::array& point2); + ~SliceTool(); + + SliceTool(const SliceTool&) = delete; + SliceTool& operator=(const SliceTool&) = delete; + SliceTool(SliceTool&&) = delete; // 持 VTK widget 观察者,禁移动(仅经 unique_ptr 间接持有) + SliceTool& operator=(SliceTool&&) = delete; + + SliceAxis axis() const { return axis_; } + + // 已保存切片(dd_slice)还原时打的归属标签;临时(交互新建)切片为空。供按 dsId 显隐/去重。 + const std::string& dsId() const { return dsId_; } + void setDsId(std::string id) { dsId_ = std::move(id); } + + // 本切片所属三维体 dsId(多体并发:每张切片附着到各自体的 image;用于按体定位/移除/取色阶)。 + const std::string& volumeDsId() const { return volumeDsId_; } + void setVolumeDsId(std::string id) { volumeDsId_ = std::move(id); } + + // 取当前切面精确三点(保存用)。 + void planePoints(double origin[3], double point1[3], double point2[3]) const; + + // 当前切面法向(世界系单位向量)。 + Vec3 normal() const; + // 当前切面中心(origin)。 + Vec3 center() const; + + // 沿法向推进切面(滚轮,D46):origin += normal*step,夹在 image 包围盒内。 + void advance(double step); + + // 选中视觉反馈:选中→高亮边框(亮黄+粗线),未选中→暗淡细线。 + void setSelected(bool sel); + + // 用户开始操作本切片(拖动/点击切面)时回调 → 上层据此把本切片设为选中。 + // 因 widget 开启交互后独占切面鼠标事件,选中靠监听 widget 交互而非拾取。 + std::function onInteract; + + // 世界点到本切面(无限平面)的垂直距离绝对值。供 picker 命中判定"点在哪张切片上"。 + double distanceToPlane(const Vec3& worldPoint) const; + + // 当前切面重采样得到的 2D 标量影像(导出 dat 用);widget 已释放则 nullptr。 + vtkImageData* reslicedOutput() const; + // 与屏幕切片纹理同源的着色输出(widget 自己的 ColorMap 输出, RGBA, 逐像素一致, 外区透明)。 + // 异常截图/导出用它而非另建 LUT,避免与屏幕配色不一致(用户实测差异大)。 + vtkSmartPointer coloredResliceImage() const; + // 开/关 widget 鼠标交互(移动/旋转/光标)。关=锁定但仍显示(已保存切片定稿不可改); + // 拾取选中/右键菜单由 PickInteractorStyle 独立处理,不受此影响。 + void setInteractive(bool on); + + // 显/隐切片(切到二维分析时隐藏,切回再显):SetEnabled 翻显隐而非销毁,几何/位置保留、 + // 切回零重建。重显时复原锁定态(SetEnabled 可能把交互重置为开)。 + void setVisible(bool on); + + // 关闭:Off() 并解除 interactor 绑定(幂等)。 + void close(); + +private: + SliceAxis axis_; + bool interactive_ = true; // 当前是否允许交互(setInteractive 记录):重显(setVisible)时复原锁定态 + std::string dsId_; // 已保存切片归属标签(空=临时交互切片) + std::string volumeDsId_; // 所属三维体 dsId(多体并发用) + vtkImageData* image_; // 非拥有;生命周期由调用方(VtkSceneView 的 currentVolumeImage_)保证 + // 把已存在的 image 接入 widget 的 producer:须随 SliceTool 保活(否则构造后析构→管线断裂崩溃,评审 H1)。 + vtkSmartPointer producer_; + vtkSmartPointer widget_; + vtkSmartPointer interactObserver_; // 监听 widget StartInteractionEvent → onInteract + + void initWidget(const geopro::core::ColorScale& cs, double vmin, double vmax); // 共享 widget 配置 + void applyMarginsAndActivate(); // 按 axis 设旋转锁 + On() + 装交互观察者 + std::array imageBounds() const; +}; + +} // namespace geopro::render::interact diff --git a/src/render/lod/ViewAdaptiveLodPolicy.cpp b/src/render/lod/ViewAdaptiveLodPolicy.cpp new file mode 100644 index 0000000..f26994f --- /dev/null +++ b/src/render/lod/ViewAdaptiveLodPolicy.cpp @@ -0,0 +1,263 @@ +#include "lod/ViewAdaptiveLodPolicy.hpp" + +#include +#include +#include +#include + +namespace geopro::render { + +namespace { + +constexpr double kPi = 3.14159265358979323846; + +using Vec3 = std::array; + +Vec3 sub(const double a[3], const double b[3]) { + return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; +} +double dot(const Vec3& a, const Vec3& b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} +Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0]}; +} +double norm(const Vec3& a) { return std::sqrt(dot(a, a)); } +Vec3 normalize(const Vec3& a) { + const double n = norm(a); + if (n <= 0.0) return {0, 0, 1}; + return {a[0] / n, a[1] / n, a[2] / n}; +} + +// 一个内侧法向平面:n·x + d ≥ 0 为内侧。 +struct Plane { + Vec3 n; + double d; +}; + +// 由透视相机参数构造 5 个内侧法向平面(左/右/上/下/近;远平面省略,不参与视野裁剪)。 +// +// 设 f=前向、r=右向、u=真上向(正交基);tanV=tan(fovY/2)、tanH=tanV*aspect。 +// 视空间内侧条件:|vx| ≤ tanH·vz、|vy| ≤ tanV·vz、vz ≥ near。 +// 将 vx=dot(rel,r)、vy=dot(rel,u)、vz=dot(rel,f)(rel=x-pos)代入得世界系线性平面: +// 右:tanH·f - r;左:tanH·f + r;上:tanV·f - u;下:tanV·f + u(均过 pos)。 +// 近:f,过 pos+near·f。 +std::array buildFrustumPlanes(const CameraView& cam) { + const Vec3 pos{cam.pos[0], cam.pos[1], cam.pos[2]}; + const Vec3 f = normalize(sub(cam.focal, cam.pos)); + const Vec3 upHint{cam.up[0], cam.up[1], cam.up[2]}; + Vec3 r = cross(f, upHint); + if (norm(r) <= 1e-12) { + // up 与视线共线:换一个参考轴。 + r = cross(f, Vec3{1, 0, 0}); + if (norm(r) <= 1e-12) r = cross(f, Vec3{0, 1, 0}); + } + r = normalize(r); + const Vec3 u = normalize(cross(r, f)); + + const double tanV = std::tan(0.5 * cam.fovYDeg * kPi / 180.0); + const double tanH = tanV * (cam.aspect > 0 ? cam.aspect : 1.0); + + auto makeThroughPos = [&](const Vec3& n) -> Plane { + return Plane{n, -dot(n, pos)}; + }; + + std::array planes{}; + planes[0] = makeThroughPos({tanH * f[0] - r[0], tanH * f[1] - r[1], + tanH * f[2] - r[2]}); // 右 + planes[1] = makeThroughPos({tanH * f[0] + r[0], tanH * f[1] + r[1], + tanH * f[2] + r[2]}); // 左 + planes[2] = makeThroughPos({tanV * f[0] - u[0], tanV * f[1] - u[1], + tanV * f[2] - u[2]}); // 上 + planes[3] = makeThroughPos({tanV * f[0] + u[0], tanV * f[1] + u[1], + tanV * f[2] + u[2]}); // 下 + // 近平面:取很小的正 near,避免把贴脸的体裁掉;只防身后。 + const double nearD = 1e-6; + planes[4] = Plane{f, -(dot(f, pos) + nearD)}; // 近(vz ≥ near) + return planes; +} + +// AABB[bmin,bmax] 是否与视锥相交(保守:p-vertex 测试,与 OutOfCoreSource 一致)。 +bool aabbInFrustum(const Vec3& bmin, const Vec3& bmax, + const std::array& planes) { + for (const Plane& pl : planes) { + const double px = (pl.n[0] >= 0) ? bmax[0] : bmin[0]; + const double py = (pl.n[1] >= 0) ? bmax[1] : bmin[1]; + const double pz = (pl.n[2] >= 0) ? bmax[2] : bmin[2]; + if (pl.n[0] * px + pl.n[1] * py + pl.n[2] * pz + pl.d < 0.0) { + return false; // 最正顶点都在该面外 → 整盒在外。 + } + } + return true; +} + +int ceilDiv(int a, int b) { return (a + b - 1) / b; } + +// level L 各轴体素维度 = ceil(n / 2^L),至少 1。 +int dimAtLevel(int n, int level) { + const int d = ceilDiv(n, 1 << level); + return d > 0 ? d : 1; +} + +// 某 level 某轴块数。 +int bricksAtLevel(int n, int brick, int level) { + return ceilDiv(dimAtLevel(n, level), brick); +} + +// 在给定 level 上做视锥裁剪,求可见 brick 区间(半开)。返回是否有可见块。 +bool visibleBrickRange(const VolumeView& vol, int level, + const std::array& planes, int& bx0, int& bx1, + int& by0, int& by1, int& bz0, int& bz1) { + const int bxN = bricksAtLevel(vol.nx, vol.brick, level); + const int byN = bricksAtLevel(vol.ny, vol.brick, level); + const int bzN = bricksAtLevel(vol.nz, vol.brick, level); + + const double scale = static_cast(std::int64_t(1) << level); // 2^L + const double sp[3] = {vol.spacing[0] * scale, vol.spacing[1] * scale, + vol.spacing[2] * scale}; + const int dimX = dimAtLevel(vol.nx, level); + const int dimY = dimAtLevel(vol.ny, level); + const int dimZ = dimAtLevel(vol.nz, level); + + bx0 = by0 = bz0 = 1 << 30; + bx1 = by1 = bz1 = 0; + bool any = false; + + for (int bz = 0; bz < bzN; ++bz) { + const int k0 = bz * vol.brick; + const int kd = std::min(vol.brick, dimZ - k0); + for (int by = 0; by < byN; ++by) { + const int j0 = by * vol.brick; + const int jd = std::min(vol.brick, dimY - j0); + for (int bx = 0; bx < bxN; ++bx) { + const int i0 = bx * vol.brick; + const int id = std::min(vol.brick, dimX - i0); + const Vec3 bmin{vol.origin[0] + i0 * sp[0], vol.origin[1] + j0 * sp[1], + vol.origin[2] + k0 * sp[2]}; + const Vec3 bmax{vol.origin[0] + (i0 + id) * sp[0], + vol.origin[1] + (j0 + jd) * sp[1], + vol.origin[2] + (k0 + kd) * sp[2]}; + if (aabbInFrustum(bmin, bmax, planes)) { + any = true; + bx0 = std::min(bx0, bx); + bx1 = std::max(bx1, bx + 1); + by0 = std::min(by0, by); + by1 = std::max(by1, by + 1); + bz0 = std::min(bz0, bz); + bz1 = std::max(bz1, bz + 1); + } + } + } + } + return any; +} + +// 区间在该 level 重组单纹理的某轴体素数(块数 × brick,截到该 level 维度)。 +int axisTexture(int b0, int b1, int brick, int dimL) { + const int start = b0 * brick; + const int end = std::min(b1 * brick, dimL); + return std::max(0, end - start); +} + +// 把某轴的可见 brick 区间 [b0,b1) 裁到“重组单纹理 ≤ maxTextureDim”的子区间。 +// 以可见区间中心为锚向两侧对称收缩 brick,宁可概览只显中心部分也绝不超限。 +// 返回的子区间满足 axisTexture(...) ≤ maxTextureDim;恒保 b0 maxTextureDim 时,单块体素数已 > maxTextureDim,brick 粒度 +// 无法表达;此时返回单块并由 C2 重组时按体素上限再裁(见 hpp 契约),即重组实际 +// 体素数 = min(单块体素, maxTextureDim),仍恒 ≤ maxTextureDim。 +void clampAxisToMaxTexture(int& b0, int& b1, int brick, int dimL, + int maxTextureDim) { + if (axisTexture(b0, b1, brick, dimL) <= maxTextureDim) return; + // 该 level 该轴每块 brick 体素,能容纳的最多整块数(至少 1 块)。 + const int maxBricks = std::max(1, maxTextureDim / std::max(1, brick)); + const int span = b1 - b0; + if (span <= maxBricks) return; // 块数已够小(仅 brick>maxTextureDim 时到这)。 + // 以可见区间中心为锚,对称取 maxBricks 块。 + const int center = (b0 + b1) / 2; + int newB0 = center - maxBricks / 2; + newB0 = std::clamp(newB0, b0, b1 - maxBricks); + b0 = newB0; + b1 = newB0 + maxBricks; +} + +} // namespace + +LodSelection selectLod(const VolumeView& vol, const CameraView& cam, + int maxTextureDim) { + LodSelection out{}; + out.empty = true; + + const int maxLevel = std::max(0, vol.levels - 1); + const std::array planes = buildFrustumPlanes(cam); + + // ── ② 选层第一步:定"最细的不过采样层" Lmin。 ─────────────────────────── + // worldPerPixel = 2·tanV·dist / viewportH(透视下屏幕一像素对应的世界尺度)。 + // level L 体素世界尺寸 = minSpacing × 2^L。不过采样 ⇔ 体素尺寸 ≥ worldPerPixel, + // 即每体素投影 ≳1 像素。取满足该式的最细 L。 + const Vec3 f = normalize(sub(cam.focal, cam.pos)); + const Vec3 center{vol.origin[0] + 0.5 * vol.nx * vol.spacing[0], + vol.origin[1] + 0.5 * vol.ny * vol.spacing[1], + vol.origin[2] + 0.5 * vol.nz * vol.spacing[2]}; + const Vec3 toCenter = sub(center.data(), cam.pos); + const double dist = std::max(1e-9, dot(toCenter, f)); // 沿视线深度(≥0) + + const double tanV = std::tan(0.5 * cam.fovYDeg * kPi / 180.0); + const int vph = cam.viewportH > 0 ? cam.viewportH : 1; + const double worldPerPixel = 2.0 * tanV * dist / static_cast(vph); + + const double minSpacing = + std::min({std::abs(vol.spacing[0]), std::abs(vol.spacing[1]), + std::abs(vol.spacing[2])}); + + int lmin = 0; + if (minSpacing > 0.0 && worldPerPixel > 0.0) { + // 求最小 L 使 minSpacing·2^L ≥ worldPerPixel。 + const double ratio = worldPerPixel / minSpacing; + if (ratio > 1.0) { + lmin = static_cast(std::ceil(std::log2(ratio))); + } + } + lmin = std::clamp(lmin, 0, maxLevel); + + // ── ③ 从 Lmin 起逐级变粗,直到可见区间重组各轴 ≤ maxTextureDim(硬约束 a)。 ─ + for (int level = lmin; level <= maxLevel; ++level) { + int bx0, bx1, by0, by1, bz0, bz1; + if (!visibleBrickRange(vol, level, planes, bx0, bx1, by0, by1, bz0, bz1)) { + out.empty = true; // 该层全裁 → 体在视锥外(各 level 同一视锥,结论一致)。 + return out; + } + const int dimX = dimAtLevel(vol.nx, level); + const int dimY = dimAtLevel(vol.ny, level); + const int dimZ = dimAtLevel(vol.nz, level); + const int tx = axisTexture(bx0, bx1, vol.brick, dimX); + const int ty = axisTexture(by0, by1, vol.brick, dimY); + const int tz = axisTexture(bz0, bz1, vol.brick, dimZ); + + const bool fits = + tx <= maxTextureDim && ty <= maxTextureDim && tz <= maxTextureDim; + if (fits || level == maxLevel) { + // 最粗层兜底:即使全可见区间仍 >maxTextureDim,也必须把区间裁到 ≤maxTextureDim + // 的中心子区间——单纹理快路绝不能 >maxTextureDim(撞 GL 16384 纹理墙)。 + // 任何返回路径都先过 clamp,保证返回区间各轴重组恒 ≤ maxTextureDim。 + clampAxisToMaxTexture(bx0, bx1, vol.brick, dimX, maxTextureDim); + clampAxisToMaxTexture(by0, by1, vol.brick, dimY, maxTextureDim); + clampAxisToMaxTexture(bz0, bz1, vol.brick, dimZ, maxTextureDim); + out.level = level; + out.bx0 = bx0; + out.bx1 = bx1; + out.by0 = by0; + out.by1 = by1; + out.bz0 = bz0; + out.bz1 = bz1; + out.empty = false; + return out; + } + // 不满足 maxTextureDim 且非最粗层 → 继续变粗。 + } + + return out; // 理论不可达(循环必返回);保底 empty。 +} + +} // namespace geopro::render diff --git a/src/render/lod/ViewAdaptiveLodPolicy.hpp b/src/render/lod/ViewAdaptiveLodPolicy.hpp new file mode 100644 index 0000000..17114cb --- /dev/null +++ b/src/render/lod/ViewAdaptiveLodPolicy.hpp @@ -0,0 +1,73 @@ +#pragma once + +namespace geopro::render { + +// ── C1:视野自适应 LOD 选层(纯逻辑,headless 可测,零 VTK/Qt 依赖)──────────── +// +// 把"渲视野内、远处自动粗、近处细且只取视野小块"的策略钉成纯数值函数: +// 相机参数用平面结构 CameraView 传(渲染层从 vtkCamera 填),不直接吃 vtkCamera。 +// 输出:选定 LOD level + 该层要渲的 brick 区间(半开)+ 是否整体在视锥外。 +// +// 与 OutOfCoreSource 的几何约定保持一致: +// - 金字塔 level 0 = 最细;level L 维度 = ceil(n / 2^L); +// - level L 世界间距 = 基础 spacing × 2^L;世界 origin 不随 level 变; +// - 块布局:每 brick 个体素一块,边缘块更小;i 最快、k 最慢。 + +// 平面相机参数(透视)。渲染层从 vtkCamera 填: +// pos/focal/up 世界系;fovYDeg/aspect 透视张角与宽高比;viewportH 像素高(定屏幕分辨率)。 +struct CameraView { + double pos[3]; + double focal[3]; + double up[3]; + double fovYDeg; // 垂直全张角(度) + double aspect; // 视口宽/高 + int viewportH; // 视口像素高(用于"体素投影 ≳1 像素/体素"判定) +}; + +// 体的元信息(从 StoreMeta + 垂向夸张 exagg)。 +// nx/ny/nz:level 0 体素维度;brick:块边长;levels:总层数(含 level 0)。 +// origin/spacing:level 0 世界几何(spacing 已含 exagg 于 y/z);exagg:垂向夸张(仅记录)。 +struct VolumeView { + int nx, ny, nz; + int brick; + int levels; + double origin[3]; + double spacing[3]; + double exagg; +}; + +// 选层结果。 +// level:选定 LOD 层(0=最细)。 +// [bx0,bx1) [by0,by1) [bz0,bz1):该 level 要渲的 brick 区间(半开;empty 时全 0)。 +// empty:体完全在视锥外(无块可渲)。 +struct LodSelection { + int level; + int bx0, bx1; + int by0, by1; + int bz0, bz1; + bool empty; +}; + +// 选层规则: +// ① 视锥裁剪求可见体素 AABB → 该层 brick 区间。 +// ② 选层: +// - 先按"体素投影 ≳1 像素/体素"定**最细的不过采样层** Lmin(近观 worldPerPixel 小 +// → Lmin 小 → 细;远观 → Lmin 大 → 粗); +// - 再从 Lmin 起逐级**变粗**直到可见区间重组单纹理各轴 ≤ maxTextureDim(硬约束 a), +// 或到达最粗层。 +// 这样:远观→粗层(区间≈全体)、近观→细层(区间是视锥内小块),且各轴恒 ≤ maxTextureDim。 +// 视距-层单调:相机拉近 → worldPerPixel 与视距均减 → Lmin 不增 → level 不增。 +// 体在视锥外 → empty=true。 +// +// **硬上限保证(架构命脉)**:返回的 brick 区间在选定 level 重组单纹理时,各轴体素数 +// 恒 ≤ maxTextureDim——即使最粗层全可见区间仍 >maxTextureDim,也按可见中心裁成刚好 +// ≤maxTextureDim 的中心子区间(宁可概览只显中心部分,绝不超限)。单纹理快路绝不能 +// >maxTextureDim,否则撞 GL 纹理墙退回慢路。 +// 退化情形:当 brick 本身 > maxTextureDim(单块体素数已超限,brick 粒度无法表达更小区间) +// 时返回单块,C2 重组须按体素上限再裁——重组实际体素数 = min(选定区间体素, maxTextureDim), +// 即重组纹理某轴起点对齐 b0*brick、终点取 min(b1*brick, dimL, b0*brick + maxTextureDim), +// 仍恒 ≤ maxTextureDim。 +LodSelection selectLod(const VolumeView& vol, const CameraView& cam, + int maxTextureDim = 16384); + +} // namespace geopro::render diff --git a/src/render/source/AsyncRegionBuilder.cpp b/src/render/source/AsyncRegionBuilder.cpp new file mode 100644 index 0000000..ac63702 --- /dev/null +++ b/src/render/source/AsyncRegionBuilder.cpp @@ -0,0 +1,193 @@ +#include "source/AsyncRegionBuilder.hpp" + +#include + +namespace geopro::render { + +AsyncRegionBuilder::AsyncRegionBuilder(const std::string& storeDir, + std::size_t cacheCapacity) + : store_(storeDir), + cacheCapacity_(cacheCapacity > 0 ? cacheCapacity : 1) { + worker_ = std::thread([this] { workerLoop(); }); +} + +AsyncRegionBuilder::~AsyncRegionBuilder() { + { + std::lock_guard lk(mutex_); + stop_.store(true); + } + cv_.notify_all(); + if (worker_.joinable()) worker_.join(); +} + +void AsyncRegionBuilder::setMaxTextureDim(int dim) { + std::lock_guard lk(mutex_); + if (dim > 0) maxTextureDim_ = dim; +} + +std::list::iterator +AsyncRegionBuilder::findCached(const RegionTarget& t) { + for (auto it = cache_.begin(); it != cache_.end(); ++it) + if (it->target == t) return it; + return cache_.end(); +} + +void AsyncRegionBuilder::insertCacheLocked(const RegionTarget& t, + vtkSmartPointer img, + int level) { + // 已存在 → 更新并提到 front;否则新插 front,超容淘汰 back(最久未用)。 + auto it = findCached(t); + if (it != cache_.end()) { + it->image = std::move(img); + it->level = level; + cache_.splice(cache_.begin(), cache_, it); // 移到 front(touch) + return; + } + cache_.push_front(CacheEntry{t, std::move(img), level}); + while (cache_.size() > cacheCapacity_) { + cache_.pop_back(); // 旧 image refcount 在锁内释放(单线程独占) + } +} + +void AsyncRegionBuilder::requestTarget(const RegionTarget& t) { + { + std::lock_guard lk(mutex_); + mainTarget_ = t; + hasMain_ = true; + // 短路:已在就绪缓存→无需重建,但仍把缓存结果发布给 takeLatest(兼容 C3-1/C3-2: + // 主目标切到一个已缓存区域时下一帧 takeLatest 即换上),并 touch LRU。 + // 否则标记主目标待建。 + auto it = findCached(t); + if (it != cache_.end()) { + mainPending_ = false; + latestMain_ = it->image; + latestMainLevel_ = it->level; + latestMainFresh_ = true; + cache_.splice(cache_.begin(), cache_, it); // touch LRU + } else { + mainPending_ = true; + } + } + cv_.notify_one(); +} + +void AsyncRegionBuilder::prefetch(const std::vector& targets) { + { + std::lock_guard lk(mutex_); + for (const auto& t : targets) { + // 跳过已缓存、与主目标相同、或已在预取队列中的(去重,省空转)。 + if (findCached(t) != cache_.end()) continue; + if (hasMain_ && t == mainTarget_) continue; + bool dup = false; + for (const auto& q : prefetchQ_) + if (q == t) { dup = true; break; } + if (!dup) prefetchQ_.push_back(t); + } + } + cv_.notify_one(); +} + +bool AsyncRegionBuilder::pickNextLocked(RegionTarget& out, bool& isMain) { + // 主目标优先:未缓存的主目标先建。 + if (hasMain_ && mainPending_) { + out = mainTarget_; + isMain = true; + return true; + } + // 否则预取:建队列里最新(back)且未缓存的;顺手丢弃已缓存的过期项。 + while (!prefetchQ_.empty()) { + RegionTarget t = prefetchQ_.back(); + prefetchQ_.pop_back(); + if (findCached(t) != cache_.end()) continue; // 已缓存→跳过 + out = t; + isMain = false; + return true; + } + return false; +} + +vtkSmartPointer AsyncRegionBuilder::getReady(const RegionTarget& t, + int& levelOut) { + std::lock_guard lk(mutex_); + auto it = findCached(t); + if (it == cache_.end()) return nullptr; + levelOut = it->level; + vtkSmartPointer img = it->image; // 锁内拷贝 SmartPointer + cache_.splice(cache_.begin(), cache_, it); // touch LRU(移到 front) + return img; +} + +vtkSmartPointer AsyncRegionBuilder::takeLatest() { + int dummy = 0; + return takeLatest(dummy); +} + +vtkSmartPointer AsyncRegionBuilder::takeLatest(int& outLevel) { + // 非阻塞:仅在锁内做指针移动。消费式——仅当主目标有未消费的新就绪结果时返回非空。 + std::lock_guard lk(mutex_); + if (!latestMainFresh_) return nullptr; + outLevel = latestMainLevel_; + latestMainFresh_ = false; + return std::move(latestMain_); // 移交所有权(锁内,单线程 refcount) +} + +void AsyncRegionBuilder::workerLoop() { + std::unique_lock lk(mutex_); + for (;;) { + cv_.wait(lk, [this] { + return stop_.load() || (hasMain_ && mainPending_) || !prefetchQ_.empty(); + }); + if (stop_.load()) return; + + RegionTarget target{}; + bool isMain = false; + if (!pickNextLocked(target, isMain)) continue; // 虚假唤醒/已全缓存 + + const int maxDim = maxTextureDim_; + building_ = true; + + // 解锁后构建:worker 在自己线程内重组,主线程全程不触碰该局部 image。 + lk.unlock(); + vtkSmartPointer built = + reorganizeRegion(store_, target, maxDim); + lk.lock(); + + if (stop_.load()) return; + + building_ = false; + + if (built == nullptr) { + // 空区间(不应发生于合法目标):标记主目标已尝试,避免死循环空转。 + if (isMain && hasMain_ && mainTarget_ == target) mainPending_ = false; + continue; + } + + // 入就绪缓存(锁内,所有 refcount 增减单线程独占)。 + insertCacheLocked(target, built, target.level); + + if (isMain) { + // 主目标就绪:仅当它仍是当前主目标时清 pending、发布给 takeLatest。 + // (构建期间主目标若已变更,则本次非最新——保留新主目标的 pending 由下轮建。) + if (hasMain_ && mainTarget_ == target) { + mainPending_ = false; + // 消费式发布:从缓存拷一份给 takeLatest(content 同缓存,不改)。 + latestMain_ = built; + latestMainLevel_ = target.level; + latestMainFresh_ = true; + } + } + built = nullptr; // 锁内释放本地引用(缓存已持有) + } +} + +bool AsyncRegionBuilder::hasPending() const { + std::lock_guard lk(mutex_); + return (hasMain_ && mainPending_) || building_ || !prefetchQ_.empty(); +} + +std::size_t AsyncRegionBuilder::cacheSize() const { + std::lock_guard lk(mutex_); + return cache_.size(); +} + +} // namespace geopro::render diff --git a/src/render/source/AsyncRegionBuilder.hpp b/src/render/source/AsyncRegionBuilder.hpp new file mode 100644 index 0000000..2409af4 --- /dev/null +++ b/src/render/source/AsyncRegionBuilder.hpp @@ -0,0 +1,126 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" +#include "source/RegionReorganizer.hpp" + +namespace geopro::render { + +// C3 并发核心:把「从 store 重组视野区域单纹理」放后台线程,主线程非阻塞取最新 +// 就绪结果——使拖动/缩放时不被解压+重组卡住。 +// +// C3-3 增强:就绪缓存(按 RegionTarget LRU,容量 N) + 预取(后台额外建预测的下一目标, +// 不抢占主目标) + 相同目标短路(已就绪/在建则不重复提交,修 C3-2 LOW)。 +// +// 线程安全设计(VTK 引用计数跨线程要小心): +// - 后台 worker 在自己线程内调 reorganizeRegion(C2/C3 共用重组核)构建出一个 +// vtkSmartPointer(全程不被主线程触碰)。 +// - 完成后在 mutex 下把它存入【就绪缓存】(按 target 键的 LRU)。主线程在同一 +// mutex 下从缓存取(拷贝 SmartPointer 引用)。所有 vtkImageData 引用计数的增减 +// 都发生在锁内——避免两线程同时碰同一对象的 refcount。worker 构建期间主线程看 +// 不到它;publish 后所有权经锁可见。 +// - 主目标优先:worker 每轮先建主目标(若未缓存),否则建预取队列里最新的; +// 预取永不抢占主目标——主目标一旦变更,worker 完成当前一格后立即转向主目标。 +// - 短路:requestTarget 同一目标若已缓存或正在建,不重复提交,省 worker 空转。 +class AsyncRegionBuilder { + public: + // 就绪缓存容量(按 RegionTarget LRU)。 + static constexpr std::size_t kDefaultCacheCapacity = 6; + + // 起 worker 线程。storeDir:含金字塔的分块 store。cacheCapacity:就绪缓存容量。 + explicit AsyncRegionBuilder(const std::string& storeDir, + std::size_t cacheCapacity = kDefaultCacheCapacity); + + // 停 worker(join)。析构忙时干净 join 不崩、不泄漏、不死锁。 + ~AsyncRegionBuilder(); + + AsyncRegionBuilder(const AsyncRegionBuilder&) = delete; + AsyncRegionBuilder& operator=(const AsyncRegionBuilder&) = delete; + + // 主线程调:设主目标(优先建)。短路:若该 target 已在就绪缓存或正在建,不重复 + // 提交(仅置为主目标供 getReady/takeLatest 命中),省 worker 空转。 + void requestTarget(const RegionTarget& t); + + // 主线程调:预取这些目标(后台额外建,低优先,绝不抢占主目标)。已缓存或与主目标 + // 相同的会被跳过。建好后入就绪缓存(LRU 有界)。 + void prefetch(const std::vector& targets); + + // 主线程调:非阻塞取某 target 的就绪 image。缓存命中→返回该 image 并 touch LRU + // (回传其 level 到 levelOut);未命中→nullptr(levelOut 不变)。永不阻塞主线程。 + vtkSmartPointer getReady(const RegionTarget& t, int& levelOut); + + // 主线程调:取最新已就绪的【主目标】image(非阻塞,兼容 C3-1/C3-2 行为)。 + // 仅当主目标自上次 take 后有新就绪结果时返回非空(消费式:取走后再调返回 nullptr + // 直到主目标再次就绪)。无新结果返回 nullptr(主线程继续用上一张)。 + vtkSmartPointer takeLatest(); + + // 同上,并通过 outLevel 回传该就绪结果对应的 target.level(仅当返回非空时有效)。 + vtkSmartPointer takeLatest(int& outLevel); + + // 是否有在建/排队(主目标未就绪或预取队列非空,供 UI/测试)。 + bool hasPending() const; + + // 就绪缓存当前条目数(供测试验证 LRU 有界)。 + std::size_t cacheSize() const; + + // 单张 3D 纹理各轴上限(GL_MAX_3D_TEXTURE_SIZE)。须在 requestTarget 前设。 + void setMaxTextureDim(int dim); + + private: + void workerLoop(); + + // 就绪缓存条目(LRU:front=最近用,back=最久未用)。 + struct CacheEntry { + RegionTarget target; + vtkSmartPointer image; + int level; + }; + + // 以下私有辅助均要求调用方已持锁。 + // 查缓存命中(返回迭代器或 end)。 + std::list::iterator findCached(const RegionTarget& t); + // 插入/更新缓存并把该条目移到 front;超容则淘汰 back。 + void insertCacheLocked(const RegionTarget& t, vtkSmartPointer img, + int level); + // worker 选下一个要建的目标:主目标未缓存→主目标;否则预取队列里最新未缓存的; + // 都没有→返回 false。out 回传目标与是否为主目标。 + bool pickNextLocked(RegionTarget& out, bool& isMain); + + geopro::data::ChunkedVolumeStore store_; + + mutable std::mutex mutex_; + std::condition_variable cv_; + + // 受 mutex_ 保护的共享状态: + RegionTarget mainTarget_{}; // 主线程最新主目标 + bool hasMain_ = false; // 是否设过主目标 + bool mainPending_ = false; // 主目标尚未就绪(未在缓存)→ 需建 + std::vector prefetchQ_; // 预取队列(低优先;建最新的先) + bool building_ = false; // worker 当前是否在建 + + // 就绪缓存(LRU)。front=最近用。 + std::list cache_; + std::size_t cacheCapacity_ = kDefaultCacheCapacity; + + // takeLatest 消费式发布:worker 建好主目标后置位,takeLatest 取走后清零。 + vtkSmartPointer latestMain_; + int latestMainLevel_ = 0; + bool latestMainFresh_ = false; // 有未被 takeLatest 消费的新主目标结果 + + std::atomic stop_{false}; // 析构置位,唤醒 worker 退出 + int maxTextureDim_ = 16384; + + std::thread worker_; // 最后声明:构造完上述成员后再起线程 +}; + +} // namespace geopro::render diff --git a/src/render/source/BrickPager.cpp b/src/render/source/BrickPager.cpp new file mode 100644 index 0000000..5964383 --- /dev/null +++ b/src/render/source/BrickPager.cpp @@ -0,0 +1,63 @@ +#include "render/source/BrickPager.hpp" + +#include "data/store/ChunkedVolumeStore.hpp" + +namespace geopro::render { + +std::size_t BrickPager::BrickIdHash::operator()(const BrickId& id) const + noexcept { + // 混合四个分量;level/块坐标均为小整数,简单移位哈希足够分散。 + std::size_t h = static_cast(id.level); + h = h * 31 + static_cast(id.bx); + h = h * 31 + static_cast(id.by); + h = h * 31 + static_cast(id.bz); + return h; +} + +BrickPager::BrickPager(const geopro::data::ChunkedVolumeStore& store, + std::size_t budgetBricks) + : store_(store), budget_(budgetBricks) {} + +void BrickPager::touch(const BrickId& id) { + auto it = cache_.find(id); + if (it == cache_.end()) { + return; + } + // 移到 MRU 端(splice 不失效迭代器)。 + lru_.splice(lru_.begin(), lru_, it->second.lruIt); + it->second.lruIt = lru_.begin(); +} + +void BrickPager::evictToBudget() { + while (cache_.size() > budget_ && !lru_.empty()) { + const BrickId victim = lru_.back(); + lru_.pop_back(); + cache_.erase(victim); + } +} + +void BrickPager::requestVisible(const std::vector& visible) { + for (const BrickId& id : visible) { + auto it = cache_.find(id); + if (it == cache_.end()) { + Entry e; + e.data = store_.readBrick(id.level, id.bx, id.by, id.bz); + lru_.push_front(id); + e.lruIt = lru_.begin(); + cache_.emplace(id, std::move(e)); + } else { + touch(id); + } + } + evictToBudget(); +} + +const std::vector* BrickPager::get(const BrickId& id) const { + auto it = cache_.find(id); + if (it == cache_.end()) { + return nullptr; + } + return &it->second.data; +} + +} // namespace geopro::render diff --git a/src/render/source/BrickPager.hpp b/src/render/source/BrickPager.hpp new file mode 100644 index 0000000..20865aa --- /dev/null +++ b/src/render/source/BrickPager.hpp @@ -0,0 +1,66 @@ +#ifndef GEOPRO_RENDER_SOURCE_BRICKPAGER_HPP +#define GEOPRO_RENDER_SOURCE_BRICKPAGER_HPP + +#include +#include +#include +#include +#include + +namespace geopro::data { +class ChunkedVolumeStore; +} + +namespace geopro::render { + +// 缓存键:完整 brick 标识(含 level)。 +struct BrickId { + int level = 0, bx = 0, by = 0, bz = 0; + bool operator==(const BrickId& o) const noexcept { + return level == o.level && bx == o.bx && by == o.by && bz == o.bz; + } +}; + +// 内存恒定的 brick LRU 缓存:任意时刻驻留 ≤ budgetBricks 个解压块, +// 与体总大小无关。这是 C 方案核外渲染的内存控制核心。 +class BrickPager { + public: + BrickPager(const geopro::data::ChunkedVolumeStore& store, + std::size_t budgetBricks); + + // 载入 visible 中缺失的块(经 store.readBrick 解压),按请求顺序更新 LRU; + // 之后淘汰最久未用的,直到 residentCount() <= budget。 + // 若 visible.size() > budget,保留最近请求(visible 末尾)的 budget 个。 + void requestVisible(const std::vector& visible); + + // 命中返回该块解压数据指针(数据由 pager 持有,下次淘汰前有效);未驻留返回 + // nullptr。get 不改动 LRU(保持 const 语义),驻留集仅由 requestVisible 决定。 + const std::vector* get(const BrickId& id) const; + + std::size_t residentCount() const { return cache_.size(); } + std::size_t budget() const { return budget_; } + + private: + struct Entry { + std::vector data; + std::list::iterator lruIt; // 指向 lru_ 中本块位置 + }; + + struct BrickIdHash { + std::size_t operator()(const BrickId& id) const noexcept; + }; + + // 把 id 移到 MRU 端(lru_ 头部)。 + void touch(const BrickId& id); + // 从 LRU 端淘汰直到 residentCount() <= budget_。 + void evictToBudget(); + + const geopro::data::ChunkedVolumeStore& store_; + std::size_t budget_; + std::list lru_; // front = 最近使用(MRU),back = 最久未用(LRU) + std::unordered_map cache_; +}; + +} // namespace geopro::render + +#endif // GEOPRO_RENDER_SOURCE_BRICKPAGER_HPP diff --git a/src/render/source/IVolumeRenderSource.hpp b/src/render/source/IVolumeRenderSource.hpp new file mode 100644 index 0000000..8d80188 --- /dev/null +++ b/src/render/source/IVolumeRenderSource.hpp @@ -0,0 +1,33 @@ +#pragma once +#include + +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" // geopro::data::StoreMeta + +class vtkCamera; + +namespace geopro::render { + +// B/C 共用的体渲染数据源接缝。上层(VtkSceneController/SliceTool)只认此接口, +// 运行时可在 WholeVolumeSource(B:整卷)与核外金字塔源(C:按相机选块/LOD)间切换。 +class IVolumeRenderSource { + public: + virtual ~IVolumeRenderSource() = default; + + // 体几何 + 量化 + 物理值域(来自分块存储 meta)。 + virtual const geopro::data::StoreMeta& meta() const = 0; + + // 按相机更新工作集。B:no-op;C:按相机选块/LOD。 + virtual void update(vtkCamera* cam) = 0; + + // 当前应渲染的 int16 体图像。B:1 个整卷;C:工作集多块。 + // 均为 VTK_SHORT,带正确 origin/spacing。 + virtual std::vector> currentImages() const = 0; + + // 供 SliceTool reslice 的图像。B:整卷;C:切面相交块拼的子体。 + virtual vtkImageData* sliceSource() const = 0; +}; + +} // namespace geopro::render diff --git a/src/render/source/OutOfCoreSource.cpp b/src/render/source/OutOfCoreSource.cpp new file mode 100644 index 0000000..c386084 --- /dev/null +++ b/src/render/source/OutOfCoreSource.cpp @@ -0,0 +1,198 @@ +#include "source/OutOfCoreSource.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +namespace geopro::render { + +namespace { + +// AABB [bmin,bmax] 是否被 6 个视锥面全在外侧裁掉。planes:GetFrustumPlanes 输出 +// 的 24 个 double(6 面 × (a,b,c,d),平面方程 a x+b y+c z+d = 0,内侧为正)。 +// 用"AABB 的最正顶点(p-vertex)若在某面外侧 → 整盒在外"判定(标准保守剔除)。 +bool aabbInFrustum(const double bmin[3], const double bmax[3], + const double planes[24]) { + for (int p = 0; p < 6; ++p) { + const double a = planes[p * 4 + 0]; + const double b = planes[p * 4 + 1]; + const double c = planes[p * 4 + 2]; + const double d = planes[p * 4 + 3]; + // 取使 a*x+b*y+c*z 最大的顶点;若它都 < -d(在外侧)则整盒在该面外。 + const double px = (a >= 0) ? bmax[0] : bmin[0]; + const double py = (b >= 0) ? bmax[1] : bmin[1]; + const double pz = (c >= 0) ? bmax[2] : bmin[2]; + if (a * px + b * py + c * pz + d < 0.0) { + return false; // 完全在该面外 → 不可见 + } + } + return true; +} + +} // namespace + +OutOfCoreSource::OutOfCoreSource(const std::string& storeDir, + std::size_t budgetBricks) { + store_ = std::make_unique(storeDir); + meta_ = store_->meta(); + pager_ = std::make_unique(*store_, budgetBricks); +} + +void OutOfCoreSource::brickDims(int level, int bx, int by, int bz, int& bw, + int& bh, int& bd) const { + int nx = 0, ny = 0, nz = 0; + store_->dims(level, nx, ny, nz); + const int brick = meta_.brick; + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + bw = std::min(brick, nx - i0); + bh = std::min(brick, ny - j0); + bd = std::min(brick, nz - k0); +} + +void OutOfCoreSource::brickWorld(int level, int bx, int by, int bz, + double origin[3], double spacing[3]) const { + // 实现要点 1:level L 的间距 = meta.spacing × 2^L;块 origin = meta.origin + + // (块起始体素 × 该 level 间距)。块起始体素(该 level 下) = (bx,by,bz) × brick。 + const double scale = static_cast(std::int64_t(1) << level); // 2^L + const int brick = meta_.brick; + for (int ax = 0; ax < 3; ++ax) { + spacing[ax] = meta_.spacing[ax] * scale; + } + // 块在该 level 体素网格里的起始体素索引(64 位防溢出)。 + const std::int64_t i0 = static_cast(bx) * brick; + const std::int64_t j0 = static_cast(by) * brick; + const std::int64_t k0 = static_cast(bz) * brick; + origin[0] = meta_.origin[0] + static_cast(i0) * spacing[0]; + origin[1] = meta_.origin[1] + static_cast(j0) * spacing[1]; + origin[2] = meta_.origin[2] + static_cast(k0) * spacing[2]; +} + +int OutOfCoreSource::pickLevel(vtkCamera* cam) const { + const int maxLevel = store_->levels() - 1; + if (cam == nullptr || maxLevel <= 0) { + return 0; + } + // 体世界对角线长度(level 0 物理尺寸)。 + const double dx = meta_.nx * meta_.spacing[0]; + const double dy = meta_.ny * meta_.spacing[1]; + const double dz = meta_.nz * meta_.spacing[2]; + const double diag = std::sqrt(dx * dx + dy * dy + dz * dz); + if (diag <= 0.0) { + return 0; + } + // 相机到体中心距离。 + double pos[3]; + cam->GetPosition(pos); + const double cx = meta_.origin[0] + 0.5 * dx; + const double cy = meta_.origin[1] + 0.5 * dy; + const double cz = meta_.origin[2] + 0.5 * dz; + const double ddx = pos[0] - cx, ddy = pos[1] - cy, ddz = pos[2] - cz; + const double dist = std::sqrt(ddx * ddx + ddy * ddy + ddz * ddz); + // 距离 / 对角线 的比值粗分档:近(<1×)→0,(<2×)→1,更远→更粗。clamp 到可用层。 + const double ratio = dist / diag; + int level = 0; + if (ratio >= 1.0) level = 1; + if (ratio >= 2.0) level = 2; + if (ratio >= 4.0) level = 3; + return std::min(level, maxLevel); +} + +std::vector OutOfCoreSource::selectVisible(int level, + vtkCamera* cam) const { + const int bxN = store_->bricksX(level); + const int byN = store_->bricksY(level); + const int bzN = store_->bricksZ(level); + + std::vector visible; + + // cam 无效(headless 测试):取该 level 全部块,由 budget/LRU 限制驻留。 + if (cam == nullptr) { + visible.reserve(static_cast(bxN) * byN * bzN); + for (int bz = 0; bz < bzN; ++bz) + for (int by = 0; by < byN; ++by) + for (int bx = 0; bx < bxN; ++bx) + visible.push_back(BrickId{level, bx, by, bz}); + return visible; + } + + // 视锥裁剪:对每块的世界 AABB 做保守剔除。 + double planes[24]; + cam->GetFrustumPlanes(aspect_, planes); + for (int bz = 0; bz < bzN; ++bz) { + for (int by = 0; by < byN; ++by) { + for (int bx = 0; bx < bxN; ++bx) { + int bw = 0, bh = 0, bd = 0; + brickDims(level, bx, by, bz, bw, bh, bd); + double org[3], sp[3]; + brickWorld(level, bx, by, bz, org, sp); + const double bmin[3] = {org[0], org[1], org[2]}; + // 块世界尺寸 = 体素数 × 间距(vtkImageData 点占 (n-1) 格,但保守用 n 格做 AABB)。 + const double bmax[3] = {org[0] + bw * sp[0], org[1] + bh * sp[1], + org[2] + bd * sp[2]}; + if (aabbInFrustum(bmin, bmax, planes)) { + visible.push_back(BrickId{level, bx, by, bz}); + } + } + } + } + return visible; +} + +vtkSmartPointer OutOfCoreSource::makeImage( + const BrickId& id, const std::vector& data) const { + int bw = 0, bh = 0, bd = 0; + brickDims(id.level, id.bx, id.by, id.bz, bw, bh, bd); + double org[3], sp[3]; + brickWorld(id.level, id.bx, id.by, id.bz, org, sp); + + auto img = vtkSmartPointer::New(); + img->SetDimensions(bw, bh, bd); + img->SetOrigin(org[0], org[1], org[2]); + img->SetSpacing(sp[0], sp[1], sp[2]); + + // 块内布局 i 最快、k 最慢(== readBrick),与 vtkImageData 点序一致 → 直接拷贝。 + vtkNew sc; + sc->SetName("v"); + const vtkIdType n = static_cast(bw) * bh * bd; + sc->SetNumberOfTuples(n); + // data.size() 应 == n;防御性取较小者。 + const vtkIdType m = std::min(n, static_cast(data.size())); + for (vtkIdType i = 0; i < m; ++i) { + sc->SetValue(i, data[static_cast(i)]); + } + img->GetPointData()->SetScalars(sc); + return img; +} + +void OutOfCoreSource::update(vtkCamera* cam) { + const int level = pickLevel(cam); + lastLevel_ = level; + lastLevelBrickTotal_ = static_cast(store_->bricksX(level)) * + store_->bricksY(level) * store_->bricksZ(level); + + std::vector visible = selectVisible(level, cam); + lastVisibleCount_ = visible.size(); + + pager_->requestVisible(visible); + + // 用 pager 实际驻留的块(可能因 budget 少于 visible)构造工作集图像。 + images_.clear(); + for (const BrickId& id : visible) { + const std::vector* d = pager_->get(id); + if (d != nullptr) { + images_.push_back(makeImage(id, *d)); + } + } +} + +std::vector> OutOfCoreSource::currentImages() + const { + return images_; +} + +} // namespace geopro::render diff --git a/src/render/source/OutOfCoreSource.hpp b/src/render/source/OutOfCoreSource.hpp new file mode 100644 index 0000000..b926aec --- /dev/null +++ b/src/render/source/OutOfCoreSource.hpp @@ -0,0 +1,88 @@ +#pragma once +#include +#include +#include +#include +#include + +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" +#include "source/BrickPager.hpp" +#include "source/IVolumeRenderSource.hpp" + +class vtkCamera; + +namespace geopro::render { + +// C 实现:核外金字塔体绘制数据源。 +// +// 与 B(WholeVolumeSource,整卷成单张 3D 纹理)的关键区别:本源把体切成 ≤brick³ 的 +// 工作集块,每块独立成一张 ≤brick³ 的 vtkImageData(VTK_SHORT,带世界 origin/spacing)。 +// 单块各轴 ≤64 ≪ GL_MAX_3D_TEXTURE_SIZE(16384),故全分辨率长线(B 里整卷 X=44476 +// 超 16384 → INVALID)在 C 里能逐块上传、真渲出。 +// +// 内存恒定由 BrickPager(LRU,driven by budgetBricks)保证:任意时刻驻留块数 ≤ budget, +// 与体总量无关。update(cam) 选 LOD + 视野块 → pager.requestVisible → currentImages +// 输出当前驻留块。 +// +// 渲染端(renderC)把 currentImages 各块装进 vtkMultiBlockDataSet,交 +// vtkMultiBlockVolumeMapper(内部每块一个 vtkSmartVolumeMapper,back-to-front 排序 + +// 抖动压接缝)。 +class OutOfCoreSource : public IVolumeRenderSource { + public: + // storeDir:9c 建的分块+金字塔 store。budgetBricks:工作集驻留上限(内存恒定核心)。 + OutOfCoreSource(const std::string& storeDir, std::size_t budgetBricks); + + const geopro::data::StoreMeta& meta() const override { return meta_; } + + // 选 LOD(按相机到体中心距离粗分档,clamp 到可用层)→ 视锥裁剪选视野块 + //(cam==nullptr 时取该 level 全部块,靠 budget/LRU 限制)→ pager.requestVisible + // → 用驻留块构造带世界坐标的 vtkImageData,刷新工作集。 + void update(vtkCamera* cam) override; + + // 当前工作集 brick 图像(各 VTK_SHORT,带世界 origin/spacing,各轴 ≤brick)。 + std::vector> currentImages() const override; + + // 切片核外是 Task 13,本任务返回 nullptr。renderC 不走切片路径。 + vtkImageData* sliceSource() const override { return nullptr; } + + // 视锥裁剪所需的视口宽高比(renderC 用窗口尺寸设;默认 1024/768)。 + void setAspect(double aspect) { aspect_ = aspect > 0 ? aspect : aspect_; } + + // --- 探针度量(供 renderC 写结论,非 IVolumeRenderSource 接口)--- + std::size_t residentCount() const { return pager_->residentCount(); } + std::size_t budget() const { return pager_->budget(); } + int lastLevel() const { return lastLevel_; } + std::size_t lastVisibleCount() const { return lastVisibleCount_; } // 视锥筛出的块数(请求数) + std::size_t lastLevelBrickTotal() const { return lastLevelBrickTotal_; } // 该 level 总块数 + + private: + // 某 level 单块 (bx,by,bz) 的世界 origin/spacing(实现要点 1)。 + void brickWorld(int level, int bx, int by, int bz, double origin[3], + double spacing[3]) const; + // 某 level 单块的实际体素尺寸(边缘块更小)。 + void brickDims(int level, int bx, int by, int bz, int& bw, int& bh, + int& bd) const; + // 由相机距离选 LOD level(0=最细)。cam==nullptr → 0。 + int pickLevel(vtkCamera* cam) const; + // 选该 level 的视野块:cam 有效则视锥裁剪,否则全取(budget/LRU 限制)。 + std::vector selectVisible(int level, vtkCamera* cam) const; + // 由 pager 驻留块构造带世界坐标的 VTK_SHORT 图像。 + vtkSmartPointer makeImage(const BrickId& id, + const std::vector& data) + const; + + geopro::data::StoreMeta meta_; + std::unique_ptr store_; + std::unique_ptr pager_; + + std::vector> images_; // 当前工作集图像 + int lastLevel_ = 0; + std::size_t lastVisibleCount_ = 0; + std::size_t lastLevelBrickTotal_ = 0; + double aspect_ = 1024.0 / 768.0; +}; + +} // namespace geopro::render diff --git a/src/render/source/RegionReorganizer.cpp b/src/render/source/RegionReorganizer.cpp new file mode 100644 index 0000000..4f17c5f --- /dev/null +++ b/src/render/source/RegionReorganizer.cpp @@ -0,0 +1,110 @@ +#include "source/RegionReorganizer.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace geopro::render { + +vtkSmartPointer reorganizeRegion( + const geopro::data::ChunkedVolumeStore& store, const RegionTarget& target, + int maxTextureDim) { + const geopro::data::StoreMeta& meta = store.meta(); + const int level = target.level; + const double exagg = target.exagg > 0 ? target.exagg : 1.0; + + const int brick = meta.brick; + // C2 MEDIUM:维度一律取自 store.dims(level)(单一真源),不自算 ceil(n/2^level), + // 防 store 降采样规则漂移时本侧公式失同步。store.dims 是金字塔实际落盘的权威维度。 + int dimLx = 0, dimLy = 0, dimLz = 0; + store.dims(level, dimLx, dimLy, dimLz); + + // 重组单纹理某轴范围(与 C1 hpp 契约逐字一致): + // 起点 = b0*brick;终点 = min(b1*brick, dimL, 起点 + maxTextureDim)。 + // 即使 brick > maxTextureDim(单块超限),也按体素上限再裁 → 恒 ≤ maxTextureDim。 + const int gi0 = target.bx0 * brick; + const int gj0 = target.by0 * brick; + const int gk0 = target.bz0 * brick; + const int gi1 = std::min({target.bx1 * brick, dimLx, gi0 + maxTextureDim}); + const int gj1 = std::min({target.by1 * brick, dimLy, gj0 + maxTextureDim}); + const int gk1 = std::min({target.bz1 * brick, dimLz, gk0 + maxTextureDim}); + + // 区间为空 → 无块可重组。 + if (gi1 <= gi0 || gj1 <= gj0 || gk1 <= gk0) return nullptr; + + const int outNx = std::max(1, gi1 - gi0); + const int outNy = std::max(1, gj1 - gj0); + const int outNz = std::max(1, gk1 - gk0); + + // 世界几何(按 level + exagg): + // spacing = meta.spacing × 2^level(y/z 再 × exagg); + // origin = meta.origin + 区间起始体素 × spacing。 + const double sc = static_cast(std::int64_t(1) << level); // 2^level + const double sp[3] = {meta.spacing[0] * sc, meta.spacing[1] * sc * exagg, + meta.spacing[2] * sc * exagg}; + const double org[3] = {meta.origin[0] + static_cast(gi0) * sp[0], + meta.origin[1] + static_cast(gj0) * sp[1], + meta.origin[2] + static_cast(gk0) * sp[2]}; + + auto img = vtkSmartPointer::New(); + img->SetDimensions(outNx, outNy, outNz); + img->SetOrigin(org[0], org[1], org[2]); + img->SetSpacing(sp[0], sp[1], sp[2]); + + vtkNew arr; + arr->SetName("v"); + const vtkIdType total = static_cast(outNx) * outNy * outNz; + arr->SetNumberOfTuples(total); + + // 遍历区间覆盖的 level 块,按块内 (i 最快、k 最慢) 布局把每体素写入子体对应位置。 + // 块内布局与 readBrick / vtkImageData 点序一致。 + const int bx0 = gi0 / brick, bx1 = (gi1 + brick - 1) / brick; + const int by0 = gj0 / brick, by1 = (gj1 + brick - 1) / brick; + const int bz0 = gk0 / brick, bz1 = (gk1 + brick - 1) / brick; + + for (int bz = bz0; bz < bz1; ++bz) { + const int k0 = bz * brick; + const int bd = std::min(brick, dimLz - k0); + for (int by = by0; by < by1; ++by) { + const int j0 = by * brick; + const int bh = std::min(brick, dimLy - j0); + for (int bx = bx0; bx < bx1; ++bx) { + const int i0 = bx * brick; + const int bw = std::min(brick, dimLx - i0); + const std::vector raw = store.readBrick(level, bx, by, bz); + + // 块内体素全局索引 (i0+ii, j0+jj, k0+kk);只写落在子体区间 [g*0,g*1) 内的。 + for (int kk = 0; kk < bd; ++kk) { + const int gk = k0 + kk; + if (gk < gk0 || gk >= gk1) continue; + const int lk = gk - gk0; + for (int jj = 0; jj < bh; ++jj) { + const int gj = j0 + jj; + if (gj < gj0 || gj >= gj1) continue; + const int lj = gj - gj0; + // 该 (kk,jj) 行在 raw 内的起始偏移(i 最快)。 + const std::size_t rowBase = + (static_cast(kk) * bh + jj) * bw; + for (int ii = 0; ii < bw; ++ii) { + const int gi = i0 + ii; + if (gi < gi0 || gi >= gi1) continue; + const int li = gi - gi0; + const vtkIdType id = + (static_cast(lk) * outNy + lj) * outNx + li; + arr->SetValue(id, raw[rowBase + ii]); + } + } + } + } + } + } + + img->GetPointData()->SetScalars(arr); + return img; +} + +} // namespace geopro::render diff --git a/src/render/source/RegionReorganizer.hpp b/src/render/source/RegionReorganizer.hpp new file mode 100644 index 0000000..10d3ec2 --- /dev/null +++ b/src/render/source/RegionReorganizer.hpp @@ -0,0 +1,44 @@ +#pragma once +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" + +namespace geopro::render { + +// 要重组的目标区域(C1 selectLod 选出的 LOD level + 视野内 brick 区间 + 垂向夸张)。 +// level:LOD 层(0=最细)。 +// [bx0,bx1) [by0,by1) [bz0,bz1):该 level 要重组的 brick 区间(半开)。 +// exagg:垂向夸张(烘焙进 y/z 的 spacing/origin)。 +// C2 ViewAdaptiveVolumeSource 与 C3 AsyncRegionBuilder 共用此结构(单一真源)。 +struct RegionTarget { + int level = 0; + int bx0 = 0, bx1 = 0; + int by0 = 0, by1 = 0; + int bz0 = 0, bz1 = 0; + double exagg = 1.0; + + bool operator==(const RegionTarget& o) const { + return level == o.level && bx0 == o.bx0 && bx1 == o.bx1 && by0 == o.by0 && + by1 == o.by1 && bz0 == o.bz0 && bz1 == o.bz1 && exagg == o.exagg; + } + bool operator!=(const RegionTarget& o) const { return !(*this == o); } +}; + +// 重组核(公共纯函数,单一真源;headless、不需 GL 上下文)。 +// +// 把 store 中 [target] 指定的 level + brick 区间重组为【单张 VTK_SHORT +// vtkImageData】,带世界 origin/spacing(按 level + exagg): +// spacing = meta.spacing × 2^level(y/z 再 × exagg); +// origin = meta.origin + 区间起始体素 × spacing。 +// +// 区间裁剪(与 C1 hpp 契约逐字一致):各轴起点 = b0*brick;终点 = +// min(b1*brick, dimL, 起点 + maxTextureDim)——即使 brick > maxTextureDim +// 也按体素上限再裁,恒 ≤ maxTextureDim。 +// +// 区间为空(任一轴 b1<=b0)→ 返回 nullptr。 +vtkSmartPointer reorganizeRegion( + const geopro::data::ChunkedVolumeStore& store, const RegionTarget& target, + int maxTextureDim = 16384); + +} // namespace geopro::render diff --git a/src/render/source/ViewAdaptiveVolumeSource.cpp b/src/render/source/ViewAdaptiveVolumeSource.cpp new file mode 100644 index 0000000..6dfd927 --- /dev/null +++ b/src/render/source/ViewAdaptiveVolumeSource.cpp @@ -0,0 +1,191 @@ +#include "source/ViewAdaptiveVolumeSource.hpp" + +#include + +#include + +#include "source/RegionReorganizer.hpp" + +namespace geopro::render { + +ViewAdaptiveVolumeSource::ViewAdaptiveVolumeSource(const std::string& storeDir, + double exagg) + : store_(storeDir), + meta_(store_.meta()), + exagg_(exagg > 0 ? exagg : 1.0), + builder_(storeDir) { // 后台 builder 用同一 storeDir(独立打开,线程独占) + builder_.setMaxTextureDim(maxTextureDim_); + // C3-6:构造时一次性在主线程建常驻粗底图(盖全整卷)。永远持有、永不释放。 + baseLevel_ = buildBaseImage(); +} + +int ViewAdaptiveVolumeSource::buildBaseImage() { + // 选整卷「各轴 ≤maxTextureDim」的最粗层:从最粗层(levels-1)往细找第一层使全卷各轴 + // ≤maxTextureDim。金字塔每升一级各轴约减半,最粗层多半已满足;万一仍超(单条超长 + // 整线)则停在最粗层,reorganizeRegion 会按体素上限裁中心 → 仍 ≤maxTextureDim(盖全 + // 中心段,绝不超限、绝不空)。从最粗往细可拿到「满足上限的最细一层」=信息最多的底图。 + const int levels = store_.levels() > 0 ? store_.levels() : 1; // 防 0/负 + int chosen = levels - 1; // 退路:最粗层 + for (int L = levels - 1; L >= 0; --L) { + int dx = 0, dy = 0, dz = 0; + store_.dims(L, dx, dy, dz); + if (dx <= maxTextureDim_ && dy <= maxTextureDim_ && dz <= maxTextureDim_) { + chosen = L; // 满足上限——继续往细找更细的满足层 + } else { + break; // 该层已超限;更细层只会更大 → 上一(更粗)层即最细满足层 + } + } + + // 全卷 brick 区间(该 level),reorganizeRegion 内按 store.dims+maxTextureDim 裁。 + int dx = 0, dy = 0, dz = 0; + store_.dims(chosen, dx, dy, dz); + const int brick = meta_.brick > 0 ? meta_.brick : 64; + RegionTarget t{}; + t.level = chosen; + t.bx0 = 0; + t.bx1 = (dx + brick - 1) / brick; + t.by0 = 0; + t.by1 = (dy + brick - 1) / brick; + t.bz0 = 0; + t.bz1 = (dz + brick - 1) / brick; + t.exagg = exagg_; + baseImage_ = reorganizeRegion(store_, t, maxTextureDim_); + // 防御:reorganizeRegion 空区间会返回 nullptr(退化 store)。底图契约是「永不空」, + // 故退到最粗层全卷再试一次(最粗层各轴最小,reorganizeRegion 内裁后仍 ≥1 体素, + // 只要 dims>0 必非空)。这样 baseImage() 在任何非退化 store 上都非空。 + if (baseImage_ == nullptr && chosen != levels - 1) { + int cdx = 0, cdy = 0, cdz = 0; + store_.dims(levels - 1, cdx, cdy, cdz); + RegionTarget c{}; + c.level = levels - 1; + c.bx1 = (cdx + brick - 1) / brick; + c.by1 = (cdy + brick - 1) / brick; + c.bz1 = (cdz + brick - 1) / brick; + c.exagg = exagg_; + baseImage_ = reorganizeRegion(store_, c, maxTextureDim_); + if (baseImage_ != nullptr) return levels - 1; + } + return chosen; +} + +VolumeView ViewAdaptiveVolumeSource::volumeView() const { + VolumeView v{}; + v.nx = meta_.nx; + v.ny = meta_.ny; + v.nz = meta_.nz; + v.brick = meta_.brick; + v.levels = store_.levels(); + v.origin[0] = meta_.origin[0]; + v.origin[1] = meta_.origin[1]; + v.origin[2] = meta_.origin[2]; + // C1 约定:spacing 已含 exagg 于 y/z。 + v.spacing[0] = meta_.spacing[0]; + v.spacing[1] = meta_.spacing[1] * exagg_; + v.spacing[2] = meta_.spacing[2] * exagg_; + v.exagg = exagg_; + return v; +} + +void ViewAdaptiveVolumeSource::update(vtkCamera* cam) { + if (cam == nullptr) return; // 无相机:不提交目标(保留上一就绪结果) + CameraView c{}; + cam->GetPosition(c.pos); + cam->GetFocalPoint(c.focal); + cam->GetViewUp(c.up); + c.fovYDeg = cam->GetViewAngle(); + c.aspect = aspect_; + c.viewportH = viewportH_; + updateView(c, volumeView()); +} + +void ViewAdaptiveVolumeSource::updateView(const CameraView& cam, + const VolumeView& vol) { + const LodSelection sel = selectLod(vol, cam, maxTextureDim_); + if (sel.empty) { + // 空选区(体在视锥外):不提交目标,保留上一就绪结果(拖动出界时不闪空)。 + return; + } + + // C3-2:只【提交目标】给后台 builder,不在主线程重组(不阻塞)。维度/裁剪由 + // reorganizeRegion 内按 store.dims 处理;本侧只传 brick 区间 + level + exagg。 + RegionTarget target{}; + target.level = sel.level; + target.bx0 = sel.bx0; + target.bx1 = sel.bx1; + target.by0 = sel.by0; + target.by1 = sel.by1; + target.bz0 = sel.bz0; + target.bz1 = sel.bz1; + target.exagg = exagg_; + mainTarget_ = target; + hasMainTarget_ = true; + builder_.requestTarget(target); // 短路:已就绪/在建则不重复;否则唤醒 worker + + // C3-3:预测下一目标并预取(低优先,不抢主目标)。 + const std::vector next = predictNext(target, cam); + if (!next.empty()) builder_.prefetch(next); +} + +std::vector ViewAdaptiveVolumeSource::predictNext( + const RegionTarget& main, const CameraView& cam) const { + std::vector out; + + // (a) 拉近预测:更细一层(level-1)同区。金字塔下一细层每个 brick 区间在更细层 + // 大致翻倍(level L 维度 = ceil(n/2^L))。简单可用:level-1,brick 区间 ×2。 + if (main.level > 0) { + RegionTarget finer = main; + finer.level = main.level - 1; + finer.bx0 = main.bx0 * 2; + finer.bx1 = main.bx1 * 2; + finer.by0 = main.by0 * 2; + finer.by1 = main.by1 * 2; + finer.bz0 = main.bz0 * 2; + finer.bz1 = main.bz1 * 2; + out.push_back(finer); + } + + // (b) 邻接平移:沿相机水平前进方向(focal-pos 在 x 上的符号)平移一格 brick 列。 + // 简单可用:只在 x 轴平移主区间宽度的「一格 brick」(区间宽 = bx1-bx0,平移 1 列)。 + const int wx = main.bx1 - main.bx0; + if (wx > 0) { + const double dx = cam.focal[0] - cam.pos[0]; + const int dir = dx >= 0 ? 1 : -1; // 朝相机视线前进方向 + RegionTarget shifted = main; + shifted.bx0 = main.bx0 + dir; + shifted.bx1 = main.bx1 + dir; + // 越界(负)则改向另一侧,仍非负才纳入(避免无效区间)。 + if (shifted.bx0 < 0) { + shifted.bx0 = main.bx0 - dir; + shifted.bx1 = main.bx1 - dir; + } + if (shifted.bx0 >= 0 && shifted != main) out.push_back(shifted); + } + + return out; +} + +void ViewAdaptiveVolumeSource::pullLatest() const { + // 非阻塞:主目标 getReady 命中(含预取已备好的区域→即刻就绪)则换上 current_ 并 + // 同步 lastLevel_;否则沿用上一张(C3-2 行为:拖动/出界不闪空)。 + if (!hasMainTarget_) return; + int level = lastLevel_; + vtkSmartPointer latest = builder_.getReady(mainTarget_, level); + if (latest) { + current_ = std::move(latest); + lastLevel_ = level; + } +} + +std::vector> +ViewAdaptiveVolumeSource::currentImages() const { + pullLatest(); + if (current_ == nullptr) return {}; + return {current_}; +} + +vtkImageData* ViewAdaptiveVolumeSource::sliceSource() const { + pullLatest(); + return current_.Get(); +} + +} // namespace geopro::render diff --git a/src/render/source/ViewAdaptiveVolumeSource.hpp b/src/render/source/ViewAdaptiveVolumeSource.hpp new file mode 100644 index 0000000..43a08b6 --- /dev/null +++ b/src/render/source/ViewAdaptiveVolumeSource.hpp @@ -0,0 +1,142 @@ +#pragma once +#include +#include + +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" +#include "lod/ViewAdaptiveLodPolicy.hpp" +#include "source/AsyncRegionBuilder.hpp" +#include "source/IVolumeRenderSource.hpp" + +class vtkCamera; + +namespace geopro::render { + +// C2/C3-2 实现:视野自适应单纹理体绘制数据源(异步重组)。 +// +// 用 C1 selectLod(VolumeView,CameraView,maxTextureDim) 选 LOD level + 视野内 brick +// 区间,从 ChunkedVolumeStore 把【当前视野区域】重组为【单张 VTK_SHORT +// vtkImageData】(各轴 ≤maxTextureDim,由 C1 硬约束保证),带世界 origin/spacing +// (按 level + 垂向夸张 exagg)。 +// +// C3-2 异步集成:updateView 只【提交目标】给内含的 AsyncRegionBuilder +//(不阻塞、不在主线程重组),currentImages 取【最新已就绪】结果(没就绪就用上一 +// 张)。这样拖动/缩放时主线程不被解压+重组卡住——后台备好新纹理后下一帧自然换上。 +// +// 与 B(WholeVolumeSource 整卷单图)/旧 C(OutOfCoreSource MultiBlock 多块)的区别: +// - 远观 → C1 选粗层、区间≈全体 → 重组整卷粗纹理(一张); +// - 近观 → C1 选细层、区间为视锥内小块 → 重组视野子体(一张); +// 两条都恒产【单张 ≤maxTextureDim 的纹理】,单 SmartVolumeMapper 渲,绝不退回 +// MultiBlock 分块(缺块/低 fps)。靠 C1 的硬上限契约,重组各轴恒 ≤maxTextureDim。 +// +// 可测缝:核心 updateView(CameraView,VolumeView) 不吃 vtkCamera、不需 GL 上下文 +//(构造 vtkImageData 无需渲染管线)→ headless 可测。update(vtkCamera*) 仅把相机 +// 参数填成 CameraView 再调 updateView。viewportH/aspect 经 setter 注入(vtkCamera +// 不自带视口像素高/宽高比)。 +// +// 线程契约:本类的【公共方法只由主/渲染线程调用】(VTK 渲染循环单线程)。唯一的跨 +// 线程边界在内含的 AsyncRegionBuilder:worker 线程独占 builder 自己的 store 实例做 +// 重组(与本类 store_ 是不同实例),主线程经 builder 的 mutex 保护的 takeLatest 取 +// 结果——vtkImageData 的 refcount 增减全发生在锁内或主线程单线程,无跨线程竞争。 +// current_/lastLevel_ 为 mutable,仅由主线程在 currentImages/sliceSource 内更新。 +class ViewAdaptiveVolumeSource : public IVolumeRenderSource { + public: + // storeDir:含金字塔的分块 store。exagg:垂向夸张(烘焙进 y/z 的 spacing/origin)。 + explicit ViewAdaptiveVolumeSource(const std::string& storeDir, + double exagg = 1.0); + + const geopro::data::StoreMeta& meta() const override { return meta_; } + + // 由 vtkCamera + 注入的 viewportH/aspect 填 CameraView,再调 updateView。 + // GL_MAX_3D_TEXTURE_SIZE 上限走 maxTextureDim_(默认 16384)。 + void update(vtkCamera* cam) override; + + // 可测缝:选层选区 → 把目标【提交】给后台 builder(不阻塞、不在主线程重组)。 + // headless 可测。空选区 → 不提交(保留上一就绪结果)。 + void updateView(const CameraView& cam, const VolumeView& vol); + + // 当前视野区域单图(取最新已就绪:先 builder.takeLatest(),有新结果则换上并更新 + // lastLevel_,否则沿用上一张;从未就绪 → 空 vector)。 + // 非阻塞:仅在 builder 锁内做指针移动。current_/lastLevel_ 为 mutable(懒取最新)。 + std::vector> currentImages() const override; + + // C3-6 常驻粗底图:整卷最粗「各轴 ≤maxTextureDim」层重组成的单张 VTK_SHORT 纹理, + // 盖住整个体。构造时一次性在主线程建成、永远持有、永不释放 → 任何视角/任何运动中 + // 底图都在场 → 绝不空白。view 把它当底层 vtkVolume 常渲,高清(currentImages)叠其上。 + // 返回裸指针(本类持有所有权,生命周期 == 本对象);理论上永不为空(构造保证)。 + vtkImageData* baseImage() const { return baseImage_.Get(); } + + // 底图对应的 LOD level(整卷最粗 ≤maxTextureDim 层)。供 UI/测试。 + int baseLevel() const { return baseLevel_; } + + // reslice 源 = 当前最新就绪单图(empty → nullptr)。先拉一次最新就绪再返回。 + vtkImageData* sliceSource() const override; + + // 供 UI 显示当前 LOD level。 + int lastLevel() const { return lastLevel_; } + + // 总层数(含 level 0)——填 VolumeView.levels 用。 + int levelCount() const { return store_.levels(); } + + // 视口像素高 / 宽高比(C1 选层的分辨率密度与视锥裁剪用)。update(vtkCamera*) + // 前由渲染端按窗口尺寸注入。 + void setViewportHeight(int h) { viewportH_ = h > 0 ? h : viewportH_; } + void setAspect(double aspect) { aspect_ = aspect > 0 ? aspect : aspect_; } + + // 单张 3D 纹理各轴上限(GL_MAX_3D_TEXTURE_SIZE)。同步给后台 builder(重组用同 + // 一上限)。须在 updateView 前设。 + void setMaxTextureDim(int dim) { + if (dim > 0) { + maxTextureDim_ = dim; + builder_.setMaxTextureDim(dim); + } + } + + private: + // 由 meta + exagg 填 VolumeView(spacing 已含 exagg 于 y/z)。 + VolumeView volumeView() const; + + // C3-6:选整卷「各轴 ≤maxTextureDim」的最粗层 → 全卷 brick 区间 reorganizeRegion + // 重组成单张盖全底图。构造时调一次(主线程)。返回选定 level(写入 baseLevel_)。 + // 找不到满足层(理论上最粗层必满足,否则金字塔层数不够)→ 退到最粗层并据上限裁中心。 + int buildBaseImage(); + + // 从 builder 拉一次最新就绪结果:主目标 getReady 命中→用之并更新 current_/ + // lastLevel_;否则沿用上一张(C3-2 行为)。const(仅刷新 mutable 缓存),供 + // currentImages/sliceSource 共用(DRY)。 + void pullLatest() const; + + // C3-3 预测下一目标(先简单可用):从主选区派生 (a) 更细一层同区、(b) 沿相机 + // 前进方向平移一格 brick 列的邻接区间。返回去重后的预取候选。 + std::vector predictNext(const RegionTarget& main, + const CameraView& cam) const; + + geopro::data::ChunkedVolumeStore store_; + geopro::data::StoreMeta meta_; + double exagg_ = 1.0; + int maxTextureDim_ = 16384; + int viewportH_ = 1080; + double aspect_ = 1280.0 / 800.0; + + // 后台重组器:updateView 提交目标,currentImages/sliceSource 非阻塞取最新就绪。 + // 须在 store_ 之后、current_ 之前声明(构造顺序无依赖,但语义上属重组核心)。 + mutable AsyncRegionBuilder builder_; + + // 最新已就绪单图 + 其 level(mutable:currentImages/sliceSource const 内懒取最新)。 + mutable vtkSmartPointer current_; // 空指针 = 从未就绪 + mutable int lastLevel_ = 0; + + // 当前主目标(updateView 设;pullLatest 用它向 builder getReady)。 + RegionTarget mainTarget_{}; + bool hasMainTarget_ = false; + + // C3-6 常驻粗底图:构造时一次性主线程建成、永远持有、永不释放(盖全整卷的最粗 + // ≤maxTextureDim 层单纹理)。与异步高清(current_)完全分离——底图绝不被异步路径 + // 触碰,故永不空。声明在 builder_ 之后(构造体中先建 builder 再建底图,复用 store_)。 + vtkSmartPointer baseImage_; + int baseLevel_ = 0; +}; + +} // namespace geopro::render diff --git a/src/render/source/WholeVolumeSource.cpp b/src/render/source/WholeVolumeSource.cpp new file mode 100644 index 0000000..ef2a814 --- /dev/null +++ b/src/render/source/WholeVolumeSource.cpp @@ -0,0 +1,65 @@ +#include "source/WholeVolumeSource.hpp" + +#include +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" + +namespace geopro::render { + +WholeVolumeSource::WholeVolumeSource(const std::string& storeDir) { + geopro::data::ChunkedVolumeStore store(storeDir); + meta_ = store.meta(); + + const int nx = meta_.nx, ny = meta_.ny, nz = meta_.nz; + + full_ = vtkSmartPointer::New(); + full_->SetDimensions(nx, ny, nz); + full_->SetOrigin(meta_.origin[0], meta_.origin[1], meta_.origin[2]); + full_->SetSpacing(meta_.spacing[0], meta_.spacing[1], meta_.spacing[2]); + + // 整卷标量数组(VTK_SHORT)。点序 i 最快、j 次之、k 最慢(匹配 vtkImageData)。 + // 全局体素数可 > 2^31,索引全程 vtkIdType(64 位)。 + vtkNew sc; + sc->SetName("v"); + sc->SetNumberOfTuples(static_cast(nx) * ny * nz); + + const int brick = meta_.brick; + // 遍历所有 brick,按全局坐标把每块体素写回整卷对应位置。 + // 块内布局:i 最快、k 最慢(与 ChunkedVolumeStore::sliceBrick 一致)。 + for (int bz = 0; bz < store.bricksZ(); ++bz) { + for (int by = 0; by < store.bricksY(); ++by) { + for (int bx = 0; bx < store.bricksX(); ++bx) { + const std::vector raw = store.readBrick(bx, by, bz); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + // 边缘块尺寸由全局剩余推出(与存储侧 extent 一致)。 + const int bw = (nx - i0 < brick) ? (nx - i0) : brick; + const int bh = (ny - j0 < brick) ? (ny - j0) : brick; + const int bd = (nz - k0 < brick) ? (nz - k0) : brick; + + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) { + const vtkIdType gk = static_cast(k0 + kk); + for (int jj = 0; jj < bh; ++jj) { + const vtkIdType gj = static_cast(j0 + jj); + // 行起始全局 id:((k*ny + j)*nx + i0)。 + vtkIdType id = (gk * ny + gj) * nx + i0; + for (int ii = 0; ii < bw; ++ii) { + sc->SetValue(id++, raw[w++]); + } + } + } + } + } + } + + full_->GetPointData()->SetScalars(sc); +} + +std::vector> WholeVolumeSource::currentImages() + const { + return {full_}; +} + +} // namespace geopro::render diff --git a/src/render/source/WholeVolumeSource.hpp b/src/render/source/WholeVolumeSource.hpp new file mode 100644 index 0000000..a71645d --- /dev/null +++ b/src/render/source/WholeVolumeSource.hpp @@ -0,0 +1,29 @@ +#pragma once +#include +#include + +#include +#include + +#include "source/IVolumeRenderSource.hpp" + +namespace geopro::render { + +// B 实现:构造时读分块存储 → 重组为整卷 VTK_SHORT vtkImageData,常驻内存, +// 供整卷体绘制与切片 reslice。无核外/LOD(那是 C 的职责)。 +class WholeVolumeSource : public IVolumeRenderSource { + public: + // 读 store(meta + 所有 brick),重组整卷 VTK_SHORT image(Origin/Spacing 来自 meta)。 + explicit WholeVolumeSource(const std::string& storeDir); + + const geopro::data::StoreMeta& meta() const override { return meta_; } + void update(vtkCamera*) override {} // 整卷已载,no-op + std::vector> currentImages() const override; + vtkImageData* sliceSource() const override { return full_.Get(); } + + private: + geopro::data::StoreMeta meta_; + vtkSmartPointer full_; +}; + +} // namespace geopro::render diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cf0a2d8..c5f4da8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -29,14 +29,23 @@ target_sources(geopro_tests PRIVATE core/test_local_frame.cpp) target_sources(geopro_tests PRIVATE core/test_model.cpp) target_sources(geopro_tests PRIVATE core/test_color_scale.cpp) target_sources(geopro_tests PRIVATE core/test_idw.cpp) +# buildVolume:凸包足迹裁剪 + maxDist=0 自动覆盖测区(对齐客户 Surfer Blanking)。 +target_sources(geopro_tests PRIVATE core/test_volume_builder.cpp) target_sources(geopro_tests PRIVATE core/test_crs_transform.cpp) target_sources(geopro_tests PRIVATE core/test_model_data.cpp) target_sources(geopro_tests PRIVATE core/test_geo_frame.cpp) +target_sources(geopro_tests PRIVATE core/test_scalar_volume_i16.cpp) +# GprVolumeBuilder:结构化建体(X/Z 落格 + 仅 Y 向 1D 线性插值 → int16 量化体)。 +target_sources(geopro_tests PRIVATE core/test_gpr_volume_builder.cpp) target_link_libraries(geopro_tests PRIVATE geopro_core) target_sources(geopro_tests PRIVATE data/test_parsers.cpp) target_sources(geopro_tests PRIVATE data/test_local_repo.cpp) +# I3dSceneRepository/LocalSample3dRepository:dimensionOf 映射 + loadVolume/loadTerrainPaths 异步回调(需 PROJ_DATA)。 +target_sources(geopro_tests PRIVATE data/test_3d_repo.cpp) target_sources(geopro_tests PRIVATE data/test_nav_dto.cpp) +target_sources(geopro_tests PRIVATE data/test_vtk3d_requests.cpp) +target_sources(geopro_tests PRIVATE data/test_dataset_field_dictionary.cpp) target_sources(geopro_tests PRIVATE data/test_dataset_chart_dto.cpp) target_sources(geopro_tests PRIVATE data/test_measurement_dto.cpp) target_sources(geopro_tests PRIVATE data/test_gr_dto.cpp) @@ -47,8 +56,24 @@ target_sources(geopro_tests PRIVATE data/test_dataset_load_handles.cpp) target_sources(geopro_tests PRIVATE data/test_async_repo_dispatch.cpp) # NavRequest 离线单测(QVariant payload: done/failed/abort 闸门)。 target_sources(geopro_tests PRIVATE data/test_nav_request.cpp) +# GprVolumeRepository:逐线 GPR int16 量化体(BuiltI16)→ app 渲染链 float 体(VolumeGrid)。 +# 纯适配器逐值反量化 + 全链(合成多通道 .iprb 走真 P1/P2)产出有效 VolumeGrid。 +target_sources(geopro_tests PRIVATE data/test_gpr_volume_repository.cpp) target_link_libraries(geopro_tests PRIVATE geopro_data) +# store 层:ChunkedVolumeStore(GPR 三维体分块压缩落盘 round-trip + 边缘块 + 压缩生效)。 +target_sources(geopro_tests PRIVATE data/store/test_chunked_volume_store.cpp) +# store 层:金字塔(多分辨率 LOD + 每块 min/max;不破坏 level0 与老 store 兼容)。 +target_sources(geopro_tests PRIVATE data/store/test_pyramid.cpp) +# store 层:StreamingVolumeWriter(逐块增量写 level0;与非流式 write 逐块+meta 对拍一致)。 +target_sources(geopro_tests PRIVATE data/store/test_streaming_write.cpp) +# data 层:StreamingVolumeBuilder(流式建体 B4;与非流式 buildGprVolume+write 逐 brick+meta 对拍)。 +target_sources(geopro_tests PRIVATE data/test_streaming_builder.cpp) +# core/algo:GeoVolumeBuilder(G1 build-geo:PCA 路向旋转 + 多线统一网格重叠均值; +# 编排 io_gpr+core+store,符号编在 geopro_store,故归此节)。 +target_sources(geopro_tests PRIVATE core/test_geo_volume_builder.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_store) + # net 层:RSA 加密器。测试需直接用 OpenSSL 生成/解密密钥,故显式 find_package # 并链接 OpenSSL(geopro_net 的 PUBLIC 链接通常已传递,这里显式以防头文件找不到)。 find_package(OpenSSL REQUIRED) @@ -75,11 +100,15 @@ endif() # render 层:ColorLutBuilder(core ColorScale -> vtkLookupTable)。 # 需 vtkLookupTable(VTK::CommonCore);geopro_render 已 PUBLIC 传递其余 VTK 组件。 -find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore) +find_package(VTK REQUIRED COMPONENTS CommonCore CommonDataModel RenderingCore RenderingAnnotation FiltersSources) +# Scene:addActor/addViewProp 计数 + clear 清空(vtkVolume 经 addViewProp 进场)。 +target_sources(geopro_tests PRIVATE render/test_scene.cpp) target_sources(geopro_tests PRIVATE render/test_color_lut.cpp) target_sources(geopro_tests PRIVATE render/test_contour_bands.cpp) # dd_voxel:buildVoxel(ScalarVolume->vtkImageData->GPU 体绘制) 构建不崩 + dims 正确。 target_sources(geopro_tests PRIVATE render/test_voxel_build.cpp) +# dd_voxel int16:buildVoxelI16(ScalarVolumeI16->vtkImageData(vtkShortArray)) 类型/dims/值/blank。 +target_sources(geopro_tests PRIVATE render/test_voxel_i16_smoke.cpp) # dd_voxel 回归:buildVoxelFromScatters(散点 projX/Y -EPSG:4547-> 世界系 + IDW) 配准+充填(需 PROJ_DATA)。 target_sources(geopro_tests PRIVATE render/test_voxel_register.cpp) # Curtain:buildCurtain(Grid+GeoLocalFrame->vtkStructuredGrid 帘面) 非空 actor + 点数=nx*ny。 @@ -92,6 +121,25 @@ target_sources(geopro_tests PRIVATE render/test_anomaly.cpp) target_sources(geopro_tests PRIVATE render/test_electrode.cpp) # Terrain:buildTerrain(GDAL 读 dem/image + 重投影 → warp 面+纹理) 非空/缺文件安全(需 PROJ_DATA)。 target_sources(geopro_tests PRIVATE render/test_terrain.cpp) +# CameraPreset(P2):6 向快捷视图 position/focalPoint/viewUp 方向 + zoomBy 距离/parallelScale。 +target_sources(geopro_tests PRIVATE render/test_camera_preset.cpp) +# AxesActor(P2):buildAxes(bounds+unit/mode→vtkCubeAxesActor) 单位换算(英尺/经纬度)/不显示返回空。 +target_sources(geopro_tests PRIVATE render/test_axes.cpp) +# TileMath(P5):天地图底图 Web Mercator 瓦片坐标数学(经纬↔z/x/y、瓦片地理边界)——纯函数。 +target_sources(geopro_tests PRIVATE render/test_tile_math.cpp) +# SlicePlaneMath(P3):切面法向/滚轮平移+夹限/双击正视相机(含竖直兜底)/滚轮步长/最近切片——纯几何。 +target_sources(geopro_tests PRIVATE render/test_slice_plane_math.cpp) +# WholeVolumeSource(B):读分块存储→重组整卷 VTK_SHORT image,校验 dims/类型/边缘块重组位置。 +target_sources(geopro_tests PRIVATE render/test_whole_volume_source.cpp) +# BrickPager(C):内存恒定的 brick LRU 分页器,驻留 ≤ budget 个解压块,证明超大体浏览内存不爆。 +target_sources(geopro_tests PRIVATE render/test_brick_pager.cpp) +target_sources(geopro_tests PRIVATE render/test_outofcore_source.cpp) +# ViewAdaptiveLodPolicy(C1):视野自适应 LOD 选层(纯逻辑,零 VTK/Qt)——视锥裁剪求可见 brick 区间 + 视距/分辨率选层。 +target_sources(geopro_tests PRIVATE render/test_view_adaptive_lod.cpp) +# ViewAdaptiveVolumeSource(C2):用 C1 selectLod 选层选区→从分块存储重组当前视野区域为单张 VTK_SHORT image(各轴≤16384/世界 origin/spacing 按 level+exagg/体素位置与 store 一致)。headless 不需 GPU。 +target_sources(geopro_tests PRIVATE render/test_view_adaptive_source.cpp) +# AsyncRegionBuilder(C3-1):后台 worker 调公共重组核 reorganizeRegion 重组视野区域→单图,主线程非阻塞 takeLatest 取最新就绪(supersede/析构干净 join/并发不崩/非阻塞)。真线程不需 GPU。 +target_sources(geopro_tests PRIVATE render/test_async_region_builder.cpp) target_link_libraries(geopro_tests PRIVATE geopro_render ${VTK_LIBRARIES}) vtk_module_autoinit(TARGETS geopro_tests MODULES ${VTK_LIBRARIES}) @@ -112,11 +160,77 @@ target_sources(geopro_tests PRIVATE ) # 散点 hover 文本格式(inline 纯函数,无需链 qwt/实例化 ScatterHoverTip)。 target_sources(geopro_tests PRIVATE app/test_scatter_hover.cpp) +# 层级分层算法(normal/log/equalArea 纯函数,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_contour_levels.cpp + ${CMAKE_SOURCE_DIR}/src/app/ContourLevels.cpp +) +# 色阶文件 IO(.lvl/.clr 解析/生成,纯函数,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_color_scale_io.cpp + ${CMAKE_SOURCE_DIR}/src/app/ColorScaleIO.cpp +) +# 维度过滤纯函数(splitByDimension: ddCode -> 三维/二维/分析三栏,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_dataset_dimension.cpp + ${CMAKE_SOURCE_DIR}/src/app/DatasetDimension.cpp +) +# 大类分类纯函数(splitByCategory: dsTypeCode/ddCode -> 5 个数据类型大类段,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_dataset_category.cpp + ${CMAKE_SOURCE_DIR}/src/app/DatasetCategory.cpp +) +# 对象树勾选纯逻辑(GS 三态聚合 aggregateGsState + 勾选源去重 dedupeSources,header-only)。 +target_sources(geopro_tests PRIVATE app/test_object_tree_selection.cpp) +# measurement 散点纯逻辑(值类型变换 / 显隐 id 收集 / 过滤体 / 另存体,Qt6::Core JSON + core model)。 +target_sources(geopro_tests PRIVATE + app/test_scatter_data_ops.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ScatterDataOps.cpp +) +# 图上交互纯几何(M14 框选命中 pointsInRect / I9 绘形归一化,QtCore QPointF/QRectF + core model)。 +target_sources(geopro_tests PRIVATE + app/test_chart_pick_geometry.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ChartPickGeometry.cpp +) +# 反演处理类纯逻辑(网格化/白化/滤波 请求体组装 + code 映射,仅 Qt6::Core JSON)。 +target_sources(geopro_tests PRIVATE + app/test_inversion_process_ops.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/InversionProcessOps.cpp +) +# 等值线 Douglas-Peucker 抽稀(I8 简化容差,纯几何,无 Qt/VTK 依赖)。 +target_sources(geopro_tests PRIVATE + app/test_contour_simplify.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ContourSimplify.cpp +) +# Quill Delta ↔ QTextDocument 互转(I14 描述富文本,纯函数,Qt6::Core/Gui)。 +find_package(Qt6 COMPONENTS Gui REQUIRED) +target_sources(geopro_tests PRIVATE + app/test_quill_delta.cpp + ${CMAKE_SOURCE_DIR}/src/app/panels/QuillDelta.cpp +) +target_link_libraries(geopro_tests PRIVATE Qt6::Gui) # controller 层:DatasetDetailController 编排集成测试(QSignalSpy 验证 datasetOpened/tabReady/loadFailed)。 find_package(Qt6 COMPONENTS Test REQUIRED) target_sources(geopro_tests PRIVATE controller/test_dataset_detail_controller.cpp) target_sources(geopro_tests PRIVATE controller/test_workbench_nav_controller.cpp) +# VtkSceneController 编排:注入 fake repo + fake view,断言 视图模式×图层 组合下 add 的图元类型/数量;取消勾选清空。 +target_sources(geopro_tests PRIVATE controller/test_vtk_scene_controller.cpp) target_link_libraries(geopro_tests PRIVATE geopro_controller Qt6::Test) +# io/gpr 层:.iprh 头解析 + .iprb B-scan 读取(纯 C++17,零 Qt/VTK)。 +target_sources(geopro_tests PRIVATE io/gpr/test_ipr_header.cpp) +target_sources(geopro_tests PRIVATE io/gpr/test_iprb_reader.cpp) +target_sources(geopro_tests PRIVATE io/gpr/test_gpr_geometry.cpp) +# GprSurveyAssembler:若干通道 .iprb + .ord -> GprSurvey(samples 校验/traces 对齐/Y 升序重排)。 +target_sources(geopro_tests PRIVATE io/gpr/test_gpr_survey_assembler.cpp) +# GpsTrack:.gps 解析 + 经纬→局部米 + 沿轨迹里程插值/航向(G1 build-geo 基础,纯 C++17)。 +target_sources(geopro_tests PRIVATE io/gpr/test_gps_track.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_io_gpr) + +# Gpr3dvVolumeBridge(P2):gpr3dv 处理后立方体 → geopro 量化体(轴 X=道/Y=通道/Z=样本)。 +# 链 geopro_gpr3dv_bridge(含 vendored gpr3dv + Qt)。 +target_sources(geopro_tests PRIVATE io/gpr/test_gpr3dv_volume_bridge.cpp) +target_link_libraries(geopro_tests PRIVATE geopro_gpr3dv_bridge) + add_subdirectory(spike) # spike S3: banded contour 渲染验证 diff --git a/tests/app/test_chart_pick_geometry.cpp b/tests/app/test_chart_pick_geometry.cpp new file mode 100644 index 0000000..1754c41 --- /dev/null +++ b/tests/app/test_chart_pick_geometry.cpp @@ -0,0 +1,79 @@ +#include + +#include +#include + +#include "panels/chart/ChartPickGeometry.hpp" + +using namespace geopro::app; +using geopro::core::ScatterField; + +namespace { +ScatterField makeField() { + ScatterField f; + f.x = {0.0, 1.0, 2.0, 3.0}; + f.y = {0.0, 1.0, 2.0, 3.0}; + f.id = {"a", "b", "c", "d"}; + f.displayStatus = {0, 0, 0, 0}; + return f; +} +} // namespace + +TEST(ChartPickGeometry, PointsInRectSelectsInterior) { + auto f = makeField(); + // 矩形 [0.5,2.5]×[0.5,2.5] → 命中下标 1(1,1) 与 2(2,2)。 + auto hits = pointsInRect(f, QRectF(QPointF(0.5, 0.5), QPointF(2.5, 2.5))); + ASSERT_EQ(hits.size(), 2u); + EXPECT_EQ(hits[0], 1); + EXPECT_EQ(hits[1], 2); +} + +TEST(ChartPickGeometry, PointsInRectEmptyWhenOutside) { + auto f = makeField(); + auto hits = pointsInRect(f, QRectF(QPointF(10.0, 10.0), QPointF(20.0, 20.0))); + EXPECT_TRUE(hits.empty()); +} + +TEST(ChartPickGeometry, PointsInRectSkipsHidden) { + auto f = makeField(); + f.displayStatus = {0, 1, 0, 0}; // 下标 1 隐藏 → 不参与框选 + auto hits = pointsInRect(f, QRectF(QPointF(0.5, 0.5), QPointF(2.5, 2.5))); + ASSERT_EQ(hits.size(), 1u); + EXPECT_EQ(hits[0], 2); +} + +TEST(ChartPickGeometry, PointsInRectSkipsNonFinite) { + ScatterField f; + f.x = {0.0, std::nan("")}; + f.y = {0.0, 0.0}; + auto hits = pointsInRect(f, QRectF(QPointF(-1.0, -1.0), QPointF(1.0, 1.0))); + ASSERT_EQ(hits.size(), 1u); + EXPECT_EQ(hits[0], 0); +} + +TEST(ChartPickGeometry, MinPointsForMarkType) { + EXPECT_EQ(minPointsForMarkType(1), 1); // 点 + EXPECT_EQ(minPointsForMarkType(2), 2); // 线 + EXPECT_EQ(minPointsForMarkType(3), 3); // 面 + EXPECT_EQ(minPointsForMarkType(4), 1); // 文字 +} + +TEST(ChartPickGeometry, NormalizePointTakesFirstOnly) { + std::vector pts{{1.0, 2.0}, {3.0, 4.0}}; + auto out = normalizeDrawnPoints(pts, /*point*/ 1); + ASSERT_EQ(out.size(), 1u); + EXPECT_DOUBLE_EQ(out[0].x(), 1.0); + // 文字同样取首点。 + EXPECT_EQ(normalizeDrawnPoints(pts, /*text*/ 4).size(), 1u); +} + +TEST(ChartPickGeometry, NormalizeLineKeepsAll) { + std::vector pts{{1.0, 2.0}, {3.0, 4.0}, {5.0, 6.0}}; + EXPECT_EQ(normalizeDrawnPoints(pts, /*line*/ 2).size(), 3u); + EXPECT_EQ(normalizeDrawnPoints(pts, /*polygon*/ 3).size(), 3u); +} + +TEST(ChartPickGeometry, CanClosePolygon) { + EXPECT_FALSE(canClosePolygon(2)); + EXPECT_TRUE(canClosePolygon(3)); +} diff --git a/tests/app/test_color_scale_io.cpp b/tests/app/test_color_scale_io.cpp new file mode 100644 index 0000000..62c82b6 --- /dev/null +++ b/tests/app/test_color_scale_io.cpp @@ -0,0 +1,74 @@ +#include + +#include +#include + +#include "ColorScaleIO.hpp" + +using geopro::app::ClrData; +using geopro::app::generateClr; +using geopro::app::generateLvl; +using geopro::app::LvlRow; +using geopro::app::parseClr; +using geopro::app::parseLvl; +using geopro::core::Rgba; + +// .lvl 往返:层级值/填充色/线型/线色 保真。 +TEST(ColorScaleIO, LvlRoundTrip) { + std::vector rows = { + {0.0, Rgba{0, 0, 170, 255}, false, Rgba{255, 0, 0, 255}}, + {50.0, Rgba{0, 255, 0, 128}, true, Rgba{0, 0, 255, 255}}, + {100.0, Rgba{48, 0, 48, 255}, false, Rgba{0, 0, 0, 255}}, + }; + const std::string text = generateLvl(rows); + const auto back = parseLvl(text); + ASSERT_EQ(back.size(), 3u); + EXPECT_DOUBLE_EQ(back[0].level, 0.0); + EXPECT_DOUBLE_EQ(back[2].level, 100.0); + EXPECT_EQ(back[0].color.b, 170); // 填充色 B + EXPECT_EQ(back[1].color.a, 128); // 填充色 alpha + EXPECT_TRUE(back[1].dashed); // 虚线 + EXPECT_FALSE(back[0].dashed); + EXPECT_EQ(back[0].lineColor.r, 255); // 线色 R +} + +// .lvl 头校验失败 → 空。 +TEST(ColorScaleIO, LvlBadHeaderEmpty) { + EXPECT_TRUE(parseLvl("not a lvl file\nfoo\nbar").empty()); +} + +// .clr 往返:pos/RGB/透明度保真,头部计数正确。 +TEST(ColorScaleIO, ClrRoundTrip) { + ClrData clr; + clr.opacity = 0.5; + clr.stops = {{0.0, Rgba{0, 0, 255, 255}}, {0.5, Rgba{0, 255, 0, 255}}, + {1.0, Rgba{255, 0, 0, 255}}}; + const std::string text = generateClr(clr); + EXPECT_NE(text.find("ColorMap 5 0 6 2"), std::string::npos); // 3 色 + 2 透明度行 + + const ClrData back = parseClr(text); + ASSERT_EQ(back.stops.size(), 3u); + EXPECT_DOUBLE_EQ(back.stops[0].first, 0.0); + EXPECT_DOUBLE_EQ(back.stops[2].first, 1.0); + EXPECT_EQ(back.stops[2].second.r, 255); + EXPECT_NEAR(back.opacity, 0.5, 1e-9); +} + +// .clr 头校验失败 → 空。 +TEST(ColorScaleIO, ClrBadHeaderEmpty) { + EXPECT_TRUE(parseClr("Bogus 1 2 3\n0 0 0 0\n").stops.empty()); +} + +// 畸形/非数字内容不得崩溃(外部文件不可信,stod 须兜底):含合法头但数据行是垃圾。 +TEST(ColorScaleIO, MalformedInputDoesNotThrow) { + EXPECT_NO_THROW({ + auto a = parseLvl("LVL3\ncols\nfoo bar baz\nNaN qux R? Gx\n"); + (void)a; + }); + EXPECT_NO_THROW({ + auto b = parseClr("ColorMap 4 0 6 2\nxx yy zz ww\nabc def ghi jkl\n0 0\n100 0\n"); + (void)b; + }); + // 合法头 + 垃圾色行 → 该行被跳过,不产出非法断点。 + EXPECT_TRUE(parseClr("ColorMap 4 0 6 2\nfoo bar baz qux\n0.0 0\n100.0 0\n").stops.empty()); +} diff --git a/tests/app/test_contour_levels.cpp b/tests/app/test_contour_levels.cpp new file mode 100644 index 0000000..4af4708 --- /dev/null +++ b/tests/app/test_contour_levels.cpp @@ -0,0 +1,84 @@ +#include + +#include +#include + +#include "ContourLevels.hpp" + +using geopro::app::ContourLevelParams; +using geopro::app::generateContourLevels; + +namespace { +ContourLevelParams normal(double mn, double mx, double interval) { + ContourLevelParams p; + p.method = ContourLevelParams::Method::Normal; + p.minValue = mn; + p.maxValue = mx; + p.interval = interval; + return p; +} +} // namespace + +// normal:len=ceil((max-min)/间隔),等距升序,首=min,末 samples; + for (int i = 0; i < 100; ++i) samples.push_back(static_cast(i)); // 0..99 + auto lv = generateContourLevels(p, samples); + ASSERT_EQ(lv.size(), 4u); // 3 个分位 + 最大值 + EXPECT_DOUBLE_EQ(lv.front(), 0.0); // 第 0 分位 + EXPECT_DOUBLE_EQ(lv.back(), 99.0); // 含最大值 + for (std::size_t i = 1; i < lv.size(); ++i) EXPECT_LE(lv[i - 1], lv[i]); +} + +// equalArea:样本不足 → 退化等距线性(复刻原版失败兜底)。 +TEST(ContourLevels, EqualAreaFallbackLinearWhenSamplesScarce) { + ContourLevelParams p; + p.method = ContourLevelParams::Method::EqualArea; + p.equalAreaLayerCount = 5; + p.minValue = 0.0; + p.maxValue = 10.0; + auto lv = generateContourLevels(p, {1.0, 2.0}); // 仅 2 个样本 < 5 + ASSERT_EQ(lv.size(), 5u); + EXPECT_DOUBLE_EQ(lv.front(), 0.0); + EXPECT_DOUBLE_EQ(lv[1], 2.0); // step=10/5=2 +} diff --git a/tests/app/test_contour_simplify.cpp b/tests/app/test_contour_simplify.cpp new file mode 100644 index 0000000..9dbcded --- /dev/null +++ b/tests/app/test_contour_simplify.cpp @@ -0,0 +1,42 @@ +#include + +#include "panels/chart/ContourSimplify.hpp" + +using geopro::app::douglasPeucker; +using geopro::core::Vec2; + +TEST(ContourSimplify, TolZeroReturnsOriginal) { + std::vector pts{{0, 0}, {1, 0.01}, {2, 0}}; + auto out = douglasPeucker(pts, 0.0); + EXPECT_EQ(out.size(), 3u); +} + +TEST(ContourSimplify, ShortPolylineUnchanged) { + std::vector pts{{0, 0}, {5, 5}}; + auto out = douglasPeucker(pts, 1.0); + EXPECT_EQ(out.size(), 2u); // 点数<=2 原样 +} + +TEST(ContourSimplify, CollinearMiddleDropped) { + // 共线点:中点在容差内 → 被抽掉,只保留首尾。 + std::vector pts{{0, 0}, {1, 0}, {2, 0}, {3, 0}}; + auto out = douglasPeucker(pts, 0.1); + ASSERT_EQ(out.size(), 2u); + EXPECT_DOUBLE_EQ(out.front().x, 0.0); + EXPECT_DOUBLE_EQ(out.back().x, 3.0); +} + +TEST(ContourSimplify, SignificantPointKept) { + // 中间有明显偏移的点(偏 1.0 > tol 0.5)→ 保留。 + std::vector pts{{0, 0}, {1, 1.0}, {2, 0}}; + auto out = douglasPeucker(pts, 0.5); + EXPECT_EQ(out.size(), 3u); +} + +TEST(ContourSimplify, LargerTolDropsMorePoints) { + std::vector pts{{0, 0}, {1, 0.2}, {2, 0}, {3, 0.2}, {4, 0}}; + auto coarse = douglasPeucker(pts, 0.5); // 偏移 0.2 < 0.5 → 全抽成首尾 + auto fine = douglasPeucker(pts, 0.05); // 0.2 > 0.05 → 保留中间峰 + EXPECT_LT(coarse.size(), fine.size()); + EXPECT_EQ(coarse.size(), 2u); +} diff --git a/tests/app/test_dataset_category.cpp b/tests/app/test_dataset_category.cpp new file mode 100644 index 0000000..82876f2 --- /dev/null +++ b/tests/app/test_dataset_category.cpp @@ -0,0 +1,46 @@ +#include +#include "DatasetCategory.hpp" +using geopro::data::DsRow; +using namespace geopro::app; + +namespace { +DsRow row(const std::string& id, const std::string& ddCode, const std::string& dsTypeCode) { + DsRow r; + r.id = id; + r.ddCode = ddCode; + r.dsTypeCode = dsTypeCode; + return r; +} +} // namespace + +TEST(SplitByCategory, RoutesByDsTypeCodeAndDdCode) { + std::vector rows = { + row("a", "dd_inversion_data", "ERT platform inversion data"), // 电阻率 + row("b", "dd_inversion_data", "visual resistivity data"), // 视电阻率 + row("c", "dd_inversion_data", "DD TRANSIENT ELECTROMAGNETIC INVERSION"), // 瞬变 + row("v", "dd_voxel", ""), // 三维体(按 ddCode) + row("s", "dd_slice", ""), // 切片(按 ddCode) + row("x", "dd_ert_measurement_gr_data", "earth resistance"), // 接地电阻 → 丢弃 + }; + const CategoryBuckets b = splitByCategory(rows); + ASSERT_EQ(b.segments.size(), categoryConfigs().size()); + EXPECT_EQ(b.segments[0].size(), 1u); EXPECT_EQ(b.segments[0][0].id, "a"); + EXPECT_EQ(b.segments[1].size(), 1u); EXPECT_EQ(b.segments[1][0].id, "b"); + EXPECT_EQ(b.segments[2].size(), 1u); EXPECT_EQ(b.segments[2][0].id, "c"); + EXPECT_EQ(b.segments[3].size(), 1u); EXPECT_EQ(b.segments[3][0].id, "v"); + // 切片(dd_slice)不单列段——挂三维体树,不进任何段;接地电阻同样丢弃。 + std::size_t total = 0; + for (auto& s : b.segments) total += s.size(); + EXPECT_EQ(total, 4u); +} + +TEST(SplitByCategory, PreservesOrderWithinSegment) { + std::vector rows = { + row("a1", "dd_inversion_data", "ERT platform inversion data"), + row("a2", "dd_inversion_data", "ERT platform inversion data"), + }; + const CategoryBuckets b = splitByCategory(rows); + ASSERT_EQ(b.segments[0].size(), 2u); + EXPECT_EQ(b.segments[0][0].id, "a1"); + EXPECT_EQ(b.segments[0][1].id, "a2"); +} diff --git a/tests/app/test_dataset_dimension.cpp b/tests/app/test_dataset_dimension.cpp new file mode 100644 index 0000000..72cbc22 --- /dev/null +++ b/tests/app/test_dataset_dimension.cpp @@ -0,0 +1,36 @@ +#include +#include "DatasetDimension.hpp" +#include "repo/RepoTypes.hpp" + +using geopro::data::DsRow; +using geopro::app::splitByDimension; +using geopro::app::DimBuckets; + +static DsRow row(const char* id, const char* ddCode) { + DsRow r; r.id = id; r.ddCode = ddCode; return r; +} + +TEST(DatasetDimension, SplitsByDdCode) { + std::vector in{ + row("a", "dd_section"), // 3D + row("b", "dd_voxel"), // 3D + row("c", "dd_trajectory_data"), // 2D + row("d", "dd_slice"), // Analysis + row("e", "dd_unknownxyz"), // Other -> not in any bucket + }; + DimBuckets b = splitByDimension(in); + ASSERT_EQ(b.dim3D.size(), 2u); + EXPECT_EQ(b.dim3D[0].id, "a"); + EXPECT_EQ(b.dim3D[1].id, "b"); + ASSERT_EQ(b.dim2D.size(), 1u); + EXPECT_EQ(b.dim2D[0].id, "c"); + ASSERT_EQ(b.analysis.size(), 1u); + EXPECT_EQ(b.analysis[0].id, "d"); +} + +TEST(DatasetDimension, EmptyInput) { + DimBuckets b = splitByDimension({}); + EXPECT_TRUE(b.dim3D.empty()); + EXPECT_TRUE(b.dim2D.empty()); + EXPECT_TRUE(b.analysis.empty()); +} diff --git a/tests/app/test_inversion_process_ops.cpp b/tests/app/test_inversion_process_ops.cpp new file mode 100644 index 0000000..ec5ec93 --- /dev/null +++ b/tests/app/test_inversion_process_ops.cpp @@ -0,0 +1,130 @@ +#include + +#include +#include + +#include "panels/chart/InversionProcessOps.hpp" + +using namespace geopro::app; + +// ── I1/O1 网格化 toGrid 体 ──────────────────────────────────────────────── +TEST(InversionProcessOps, GridToBodyFieldsLinear) { + GridToParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.actionCode = QStringLiteral("alg_kriging"); + p.xMin = -1.0; p.xMax = 99.0; p.yMin = -2.0; p.yMax = 50.0; + p.vMin = 5.0; p.vMax = 500.0; + p.xSize = 100; p.ySize = 60; + p.xSpacing = 1.0; p.ySpacing = 0.86; + p.logFormat = false; + + auto b = buildGridToBody(p); + EXPECT_EQ(b.value("dsObjectId").toString().toStdString(), "ds1"); + EXPECT_EQ(b.value("actionCode").toString().toStdString(), "alg_kriging"); + EXPECT_DOUBLE_EQ(b.value("xminValue").toDouble(), -1.0); + EXPECT_DOUBLE_EQ(b.value("xmaxValue").toDouble(), 99.0); + EXPECT_DOUBLE_EQ(b.value("yminValue").toDouble(), -2.0); + EXPECT_DOUBLE_EQ(b.value("ymaxValue").toDouble(), 50.0); + EXPECT_EQ(b.value("xsize").toInt(), 100); // 小写 + EXPECT_EQ(b.value("ysize").toInt(), 60); // 小写 + EXPECT_DOUBLE_EQ(b.value("xSpacing").toDouble(), 1.0); // 驼峰 + EXPECT_DOUBLE_EQ(b.value("ySpacing").toDouble(), 0.86); // 驼峰 + EXPECT_DOUBLE_EQ(b.value("vminValue").toDouble(), 5.0); + EXPECT_DOUBLE_EQ(b.value("vmaxValue").toDouble(), 500.0); + EXPECT_EQ(b.value("saveDataValueType").toInt(), 1); // 线性=1 +} + +TEST(InversionProcessOps, GridToBodyLogFormat) { + GridToParams p; + p.logFormat = true; + EXPECT_EQ(buildGridToBody(p).value("saveDataValueType").toInt(), 2); // 对数=2 +} + +// ── I3 白化 whitenData 体 ────────────────────────────────────────────────── +TEST(InversionProcessOps, WhitenMethod1ExternalSendsBoolWay) { + WhitenParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.whiteningMethod = 1; + p.boundaryExtension = 3.5; + p.whiteningType = 0; // 外部 → whitenedWay=true + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("whiteningMethod").toInt(), 1); + EXPECT_DOUBLE_EQ(b.value("boundaryExtension").toDouble(), 3.5); + EXPECT_TRUE(b.value("whitenedWay").toBool()); + EXPECT_FALSE(b.contains("whitenedDataId")); +} + +TEST(InversionProcessOps, WhitenMethod1InternalWayFalse) { + WhitenParams p; + p.whiteningMethod = 1; + p.whiteningType = 1; // 内部 → whitenedWay=false + EXPECT_FALSE(buildWhitenBody(p).value("whitenedWay").toBool()); +} + +TEST(InversionProcessOps, WhitenMethod2SendsFileId) { + WhitenParams p; + p.whiteningMethod = 2; + p.whitenedDataId = QStringLiteral("file-9"); + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("whitenedDataId").toString().toStdString(), "file-9"); + EXPECT_FALSE(b.contains("boundaryExtension")); +} + +TEST(InversionProcessOps, WhitenMethod3SendsSubType) { + WhitenParams p; + p.whiteningMethod = 3; + p.modelWhiteningSubType = 1; // 矩形 + auto b = buildWhitenBody(p); + EXPECT_EQ(b.value("modelWhiteningSubType").toInt(), 1); +} + +// ── I4 滤波 applyFilter 体 + code 映射 ────────────────────────────────────── +TEST(InversionProcessOps, BoundaryAndNoDataCodes) { + EXPECT_EQ(filterBoundaryCode(QStringLiteral("whitening")), 1); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("skip")), 2); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("edgePoint")), 3); + EXPECT_EQ(filterBoundaryCode(QStringLiteral("filling")), 4); + EXPECT_EQ(filterNoDataCode(QStringLiteral("expansion")), 1); + EXPECT_EQ(filterNoDataCode(QStringLiteral("retain")), 2); + EXPECT_EQ(filterNoDataCode(QStringLiteral("skip")), 3); + EXPECT_EQ(filterNoDataCode(QStringLiteral("filling")), 4); +} + +TEST(InversionProcessOps, FilterApplyBodyFields) { + FilterApplyParams p; + p.dsObjectId = QStringLiteral("ds1"); + p.dataEdge = QStringLiteral("filling"); + p.dataEdgeValue = 12.0; + p.noDataPoints = QStringLiteral("skip"); + p.noDataValue = 0.0; + p.number = 3; + p.row = 3; p.column = 3; + p.matrix = {{0, 1, 0}, {1, 1, 1}, {0, 1, 0}}; + p.filteringMethod = QStringLiteral("中值滤波"); + + auto b = buildFilterApplyBody(p); + EXPECT_EQ(b.value("boundary").toInt(), 4); // filling + EXPECT_DOUBLE_EQ(b.value("boundaryValue").toDouble(), 12.0); + EXPECT_EQ(b.value("noDataPoints").toInt(), 3); // skip + EXPECT_DOUBLE_EQ(b.value("noDataPointsValue").toDouble(), 0.0); + EXPECT_EQ(b.value("number").toInt(), 3); + EXPECT_EQ(b.value("row").toInt(), 3); + EXPECT_EQ(b.value("column").toInt(), 3); + EXPECT_EQ(b.value("filteringMethod").toString().toStdString(), std::string("中值滤波")); + const auto form = b.value("rowColumValue").toObject().value("form").toArray(); + ASSERT_EQ(form.size(), 3); + EXPECT_DOUBLE_EQ(form.at(1).toArray().at(1).toDouble(), 1.0); // 中心 + EXPECT_DOUBLE_EQ(form.at(0).toArray().at(0).toDouble(), 0.0); // 角 +} + +TEST(InversionProcessOps, NewFilterBodyFields) { + auto b = buildNewFilterBody(QStringLiteral("自定义滤波器1"), QStringLiteral("pj1"), + QStringLiteral("grp-7"), {{1, 1}, {1, 1}}); + EXPECT_EQ(b.value("type").toInt(), 1); + EXPECT_EQ(b.value("name").toString().toStdString(), std::string("自定义滤波器1")); + EXPECT_EQ(b.value("projectId").toString().toStdString(), "pj1"); + EXPECT_EQ(b.value("parentId").toString().toStdString(), "grp-7"); + const auto form = b.value("rowColumValue").toObject().value("form").toArray(); + ASSERT_EQ(form.size(), 2); + EXPECT_EQ(form.at(0).toArray().size(), 2); +} diff --git a/tests/app/test_object_tree_selection.cpp b/tests/app/test_object_tree_selection.cpp new file mode 100644 index 0000000..32372ad --- /dev/null +++ b/tests/app/test_object_tree_selection.cpp @@ -0,0 +1,37 @@ +#include +#include "panels/ObjectTreeSelection.hpp" +using namespace geopro::app; +using geopro::data::DataSource; + +TEST(AggregateGsState, AllOnIsChecked) { + EXPECT_EQ(aggregateGsState(true, 3, 3), GsCheck::Checked); +} +TEST(AggregateGsState, AllOffIsUnchecked) { + EXPECT_EQ(aggregateGsState(false, 0, 3), GsCheck::Unchecked); +} +TEST(AggregateGsState, DsOnTmNoneIsPartial) { + EXPECT_EQ(aggregateGsState(true, 0, 3), GsCheck::Partial); // 只 GS 自身 ds +} +TEST(AggregateGsState, DsOffSomeTmIsPartial) { + EXPECT_EQ(aggregateGsState(false, 1, 3), GsCheck::Partial); // 部分子 TM +} +TEST(AggregateGsState, DsOnSomeTmIsPartial) { + EXPECT_EQ(aggregateGsState(true, 2, 3), GsCheck::Partial); // ds 开但 TM 未满 +} +TEST(AggregateGsState, NoTmFallsBackToDsOnly) { + EXPECT_EQ(aggregateGsState(true, 0, 0), GsCheck::Checked); // 无子 TM → 仅看 ds 开关 + EXPECT_EQ(aggregateGsState(false, 0, 0), GsCheck::Unchecked); +} + +TEST(DedupeSources, RemovesDuplicateByIdAndConfType) { + std::vector in = {{"t1", 2}, {"g1", 1}, {"t1", 2}, {"g1", 1}, {"t2", 2}}; + const auto out = dedupeSources(in); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0].id, "t1"); EXPECT_EQ(out[0].confType, 2); + EXPECT_EQ(out[1].id, "g1"); EXPECT_EQ(out[1].confType, 1); + EXPECT_EQ(out[2].id, "t2"); +} +TEST(DedupeSources, SameIdDifferentConfTypeKept) { + std::vector in = {{"x", 1}, {"x", 2}}; + EXPECT_EQ(dedupeSources(in).size(), 2u); +} diff --git a/tests/app/test_quill_delta.cpp b/tests/app/test_quill_delta.cpp new file mode 100644 index 0000000..c7c5418 --- /dev/null +++ b/tests/app/test_quill_delta.cpp @@ -0,0 +1,226 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "panels/QuillDelta.hpp" + +using namespace geopro::app; + +namespace { + +QJsonArray opsFromJson(const char* json) { + return QJsonDocument::fromJson(json).array(); +} + +// 从 Delta ops 取第 idx 个文本 op 的 attributes(便于断言)。 +QJsonObject attrsOf(const QJsonArray& ops, int idx) { + return ops.at(idx).toObject().value(QStringLiteral("attributes")).toObject(); +} + +} // namespace + +// ── 反序列化(Delta → 文档):基本行内样式落到字符格式 ────────────────────── +TEST(QuillDelta, DeltaToDocumentAppliesInlineFormats) { + const auto ops = opsFromJson(R"([ + {"insert":"Hello","attributes":{"bold":true,"italic":true,"underline":true, + "color":"#ff0000","size":"24px"}}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + + EXPECT_EQ(doc.toPlainText(), QStringLiteral("Hello")); + const QTextFragment frag = doc.begin().begin().fragment(); + ASSERT_TRUE(frag.isValid()); + const QTextCharFormat f = frag.charFormat(); + EXPECT_GE(f.fontWeight(), QFont::Bold); + EXPECT_TRUE(f.fontItalic()); + EXPECT_TRUE(f.fontUnderline()); + EXPECT_EQ(f.foreground().color().name(QColor::HexRgb), QStringLiteral("#ff0000")); + EXPECT_NEAR(f.fontPointSize(), 24.0 * 3.0 / 4.0, 0.01); // 24px → 18pt +} + +// ── 序列化(文档 → Delta):行内样式回写 attributes ───────────────────────── +TEST(QuillDelta, DocumentToDeltaEmitsInlineAttrs) { + QTextDocument doc; + QTextCursor cur(&doc); + QTextCharFormat f; + f.setFontWeight(QFont::Bold); + f.setForeground(QColor(QStringLiteral("#00ff00"))); + cur.insertText(QStringLiteral("World"), f); + + const QJsonArray ops = documentToDelta(doc); + ASSERT_GE(ops.size(), 1); + EXPECT_EQ(ops.at(0).toObject().value(QStringLiteral("insert")).toString(), + QStringLiteral("World")); + const QJsonObject a = attrsOf(ops, 0); + EXPECT_TRUE(a.value(QStringLiteral("bold")).toBool()); + EXPECT_EQ(a.value(QStringLiteral("color")).toString(), QStringLiteral("#00ff00")); +} + +// ── 往返:纯文本不丢、不多生成空块 ───────────────────────────────────────── +TEST(QuillDelta, RoundTripPlainTextSingleLine) { + const auto ops = opsFromJson(R"([{"insert":"just text"},{"insert":"\n"}])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.toPlainText(), QStringLiteral("just text")); + EXPECT_EQ(doc.blockCount(), 1); // 不应多出尾部空块 + + const QJsonArray back = documentToDelta(doc); + QString text; + for (const QJsonValue& v : back) + text += v.toObject().value(QStringLiteral("insert")).toString(); + EXPECT_EQ(text, QStringLiteral("just text\n")); +} + +// ── 往返:多行 + 行内样式保持 ────────────────────────────────────────────── +TEST(QuillDelta, RoundTripMultilineWithBoldPreserved) { + const auto ops = opsFromJson(R"([ + {"insert":"line1 "}, + {"insert":"bold","attributes":{"bold":true}}, + {"insert":"\nline2\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.toPlainText(), QStringLiteral("line1 bold\nline2")); + EXPECT_EQ(doc.blockCount(), 2); + + // 第二趟往返稳定:重新序列化后再反序列化文本一致。 + const QJsonArray back = documentToDelta(doc); + QTextDocument doc2; + deltaToDocument(back, doc2); + EXPECT_EQ(doc2.toPlainText(), QStringLiteral("line1 bold\nline2")); + EXPECT_EQ(doc2.blockCount(), 2); +} + +// ── 块级:标题落到 headingLevel 并回写 header ────────────────────────────── +TEST(QuillDelta, HeaderBlockRoundTrip) { + const auto ops = opsFromJson(R"([ + {"insert":"Title"}, + {"insert":"\n","attributes":{"header":2}}, + {"insert":"body"}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.begin().blockFormat().headingLevel(), 2); + + const QJsonArray back = documentToDelta(doc); + // 找到带 header 的换行 op。 + bool found = false; + for (const QJsonValue& v : back) { + const QJsonObject op = v.toObject(); + if (op.value(QStringLiteral("insert")).toString() == QStringLiteral("\n") && + op.value(QStringLiteral("attributes")).toObject().value(QStringLiteral("header")).toInt() == 2) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// ── 块级:有序列表 → ListDecimal,回写 list:ordered ─────────────────────── +TEST(QuillDelta, OrderedListBlockRoundTrip) { + const auto ops = opsFromJson(R"([ + {"insert":"item"}, + {"insert":"\n","attributes":{"list":"ordered"}} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + ASSERT_NE(doc.begin().textList(), nullptr); + + const QJsonArray back = documentToDelta(doc); + bool found = false; + for (const QJsonValue& v : back) { + const QJsonObject a = v.toObject().value(QStringLiteral("attributes")).toObject(); + if (a.value(QStringLiteral("list")).toString() == QStringLiteral("ordered")) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +// ── 行内:背景色往返(原版 ql-background)───────────────────────────────── +TEST(QuillDelta, BackgroundColorRoundTrip) { + const auto ops = opsFromJson(R"([ + {"insert":"hl","attributes":{"background":"#ffff00"}}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + const QTextCharFormat f = doc.begin().begin().fragment().charFormat(); + EXPECT_EQ(f.background().color().name(QColor::HexRgb), QStringLiteral("#ffff00")); + + const QJsonObject a = attrsOf(documentToDelta(doc), 0); + EXPECT_EQ(a.value(QStringLiteral("background")).toString(), QStringLiteral("#ffff00")); +} + +// ── 行内:字体族往返(原版 ql-font token ↔ Qt family)────────────────────── +TEST(QuillDelta, FontFamilyRoundTrip) { + const auto ops = opsFromJson(R"([ + {"insert":"font","attributes":{"font":"Microsoft-YaHei"}}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + const QTextCharFormat f = doc.begin().begin().fragment().charFormat(); + EXPECT_EQ(f.fontFamilies().toStringList().value(0), QStringLiteral("Microsoft YaHei")); + + // 回写应还原为原版 token(连字符形式)。 + const QJsonObject a = attrsOf(documentToDelta(doc), 0); + EXPECT_EQ(a.value(QStringLiteral("font")).toString(), QStringLiteral("Microsoft-YaHei")); +} + +// ── 块级:对齐往返(原版 ql-align)──────────────────────────────────────── +TEST(QuillDelta, AlignBlockRoundTrip) { + const auto ops = opsFromJson(R"([ + {"insert":"centered"}, + {"insert":"\n","attributes":{"align":"center"}} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.begin().blockFormat().alignment() & Qt::AlignHorizontal_Mask, Qt::AlignHCenter); + + bool found = false; + for (const QJsonValue& v : documentToDelta(doc)) { + const QJsonObject a = v.toObject().value(QStringLiteral("attributes")).toObject(); + if (a.value(QStringLiteral("align")).toString() == QStringLiteral("center")) found = true; + } + EXPECT_TRUE(found); +} + +// ── 容错:无法识别的 attributes 降级(保留文本,不崩) ────────────────────── +TEST(QuillDelta, UnknownAttributesDegradeGracefully) { + const auto ops = opsFromJson(R"([ + {"insert":"keepme","attributes":{"script":"super","strike":true,"link":"http://x"}}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.toPlainText(), QStringLiteral("keepme")); +} + +// ── 容错:非文本 insert(图片/嵌入对象)被丢弃,不崩 ─────────────────────── +TEST(QuillDelta, NonStringInsertDropped) { + const auto ops = opsFromJson(R"([ + {"insert":"text"}, + {"insert":{"image":"data:..."}}, + {"insert":"\n"} + ])"); + QTextDocument doc; + deltaToDocument(ops, doc); + EXPECT_EQ(doc.toPlainText(), QStringLiteral("text")); +} + +// ── 空 ops:空文档 ───────────────────────────────────────────────────────── +TEST(QuillDelta, EmptyOpsYieldEmptyDocument) { + QTextDocument doc; + deltaToDocument(QJsonArray{}, doc); + EXPECT_TRUE(doc.toPlainText().isEmpty()); +} diff --git a/tests/app/test_scatter_data_ops.cpp b/tests/app/test_scatter_data_ops.cpp new file mode 100644 index 0000000..2ac5286 --- /dev/null +++ b/tests/app/test_scatter_data_ops.cpp @@ -0,0 +1,133 @@ +#include + +#include + +#include + +#include "panels/chart/ScatterDataOps.hpp" + +using namespace geopro::app; +using geopro::core::ScatterField; + +TEST(ScatterDataOps, ValueTypeFromCode) { + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("linearity")), ScatterValueType::Linearity); + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("inverse")), ScatterValueType::Inverse); + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("logarithm")), ScatterValueType::Logarithm); + // 未知回退线性。 + EXPECT_EQ(scatterValueTypeFromCode(QStringLiteral("xyz")), ScatterValueType::Linearity); +} + +TEST(ScatterDataOps, ApplyValueTypeLinearIsIdentity) { + std::vector v{1.0, 10.0, 100.0}; + auto out = applyScatterValueType(v, ScatterValueType::Linearity); + ASSERT_EQ(out.size(), 3u); + EXPECT_DOUBLE_EQ(out[0], 1.0); + EXPECT_DOUBLE_EQ(out[2], 100.0); +} + +TEST(ScatterDataOps, ApplyValueTypeInverse) { + std::vector v{2.0, 0.0, -4.0}; + auto out = applyScatterValueType(v, ScatterValueType::Inverse); + EXPECT_DOUBLE_EQ(out[0], 0.5); + EXPECT_DOUBLE_EQ(out[1], 0.0); // v==0 → 保持 0(避免 inf) + EXPECT_DOUBLE_EQ(out[2], -0.25); +} + +TEST(ScatterDataOps, ApplyValueTypeLog) { + std::vector v{100.0, 0.0, -5.0}; + auto out = applyScatterValueType(v, ScatterValueType::Logarithm); + EXPECT_DOUBLE_EQ(out[0], 2.0); // log10(100) + EXPECT_DOUBLE_EQ(out[1], 0.0); // v<=0 → 保持原值 + EXPECT_DOUBLE_EQ(out[2], -5.0); // v<=0 → 保持原值 +} + +TEST(ScatterDataOps, CollectIdsForHideTakesVisible) { + ScatterField f; + f.id = {"a", "b", "c", ""}; + f.displayStatus = {0, 1, 0, 0}; // a,c 可见;b 隐藏;空 id 跳过 + // 隐藏:取可见点(a, c)。 + auto hideIds = collectScatterIds(f, /*hide*/ true); + ASSERT_EQ(hideIds.size(), 2); + EXPECT_EQ(hideIds.at(0).toString().toStdString(), "a"); + EXPECT_EQ(hideIds.at(1).toString().toStdString(), "c"); +} + +TEST(ScatterDataOps, CollectIdsForShowTakesHidden) { + ScatterField f; + f.id = {"a", "b", "c"}; + f.displayStatus = {0, 1, 1}; // b,c 隐藏 + auto showIds = collectScatterIds(f, /*hide*/ false); + ASSERT_EQ(showIds.size(), 2); + EXPECT_EQ(showIds.at(0).toString().toStdString(), "b"); + EXPECT_EQ(showIds.at(1).toString().toStdString(), "c"); +} + +TEST(ScatterDataOps, FilterBodyFields) { + auto body = buildScatterFilterBody(QStringLiteral("ds1"), QStringLiteral("R0"), -10.5, 200.0); + EXPECT_EQ(body.value("sourceDsObjectId").toString().toStdString(), "ds1"); + EXPECT_EQ(body.value("sourceVFieldCode").toString().toStdString(), "R0"); + EXPECT_DOUBLE_EQ(body.value("min").toDouble(), -10.5); + EXPECT_DOUBLE_EQ(body.value("max").toDouble(), 200.0); +} + +TEST(ScatterDataOps, SaveRawDataBodyNewHasName) { + auto body = buildSaveRawDataBody(QStringLiteral("ds1"), /*operationType*/ 1, + QStringLiteral("新数据")); + EXPECT_EQ(body.value("dsId").toString().toStdString(), "ds1"); + EXPECT_EQ(body.value("operationType").toInt(), 1); + EXPECT_TRUE(body.contains("name")); + EXPECT_EQ(body.value("name").toString().toStdString(), std::string("新数据")); +} + +TEST(ScatterDataOps, SaveRawDataBodyOverwriteOmitsName) { + auto body = buildSaveRawDataBody(QStringLiteral("ds1"), /*operationType*/ 0, + QStringLiteral("ignored")); + EXPECT_EQ(body.value("operationType").toInt(), 0); + EXPECT_FALSE(body.contains("name")); // 覆盖不带 name +} + +TEST(ScatterDataOps, ToggledDisplayStatus) { + // 0=显示 → 1=隐藏;1=隐藏 → 0=显示(对照原版 record.displayStatus ? 0 : 1)。 + EXPECT_EQ(toggledDisplayStatus(0), 1); + EXPECT_EQ(toggledDisplayStatus(1), 0); + EXPECT_EQ(toggledDisplayStatus(2), 0); // 非 0 视为隐藏 → 显示 +} + +TEST(ScatterDataOps, HistogramBinsCountInRange) { + // 0..10 均匀 11 个点,min=0 max=10 分 5 箱(宽 2): + // [0,2):0,1 → 2;[2,4):2,3 → 2;[4,6):4,5 → 2;[6,8):6,7 → 2;[8,10]:8,9,10 → 3(末箱右闭)。 + std::vector v{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + auto h = buildScatterHistogram(v, 0.0, 10.0, 5); + ASSERT_EQ(h.counts.size(), 5u); + EXPECT_DOUBLE_EQ(h.step, 2.0); + EXPECT_EQ(h.counts[0], 2); + EXPECT_EQ(h.counts[1], 2); + EXPECT_EQ(h.counts[2], 2); + EXPECT_EQ(h.counts[3], 2); + EXPECT_EQ(h.counts[4], 3); // 末箱含恰等于 max 的点 +} + +TEST(ScatterDataOps, HistogramSkipsOutOfRangeAndNonFinite) { + std::vector v{-5, 0, 5, 10, 15, std::nan("")}; + auto h = buildScatterHistogram(v, 0.0, 10.0, 2); + ASSERT_EQ(h.counts.size(), 2u); + // 区间外(-5,15)与 NaN 跳过;保留 0,5,10。 + // [0,5):0 → 1;[5,10]:5,10 → 2。 + EXPECT_EQ(h.counts[0], 1); + EXPECT_EQ(h.counts[1], 2); +} + +TEST(ScatterDataOps, HistogramDegenerateReturnsEmpty) { + std::vector v{1, 2, 3}; + EXPECT_TRUE(buildScatterHistogram(v, 5.0, 5.0, 10).counts.empty()); // min==max + EXPECT_TRUE(buildScatterHistogram(v, 0.0, 10.0, 0).counts.empty()); // binCount<=0 +} + +TEST(ScatterDataOps, CountInRangeClosedInterval) { + std::vector v{0, 5, 10, 15, -3, std::nan("")}; + // 闭区间 [0,10]:0,5,10 计入;15、-3 区间外;NaN 跳过。 + EXPECT_EQ(countScatterInRange(v, 0.0, 10.0), 3); + EXPECT_EQ(countScatterInRange(v, 5.0, 5.0), 1); // 单点闭区间 + EXPECT_EQ(countScatterInRange(v, 10.0, 0.0), 0); // max + +#include +#include +#include +#include +#include +#include + +#include "I3dSceneView.hpp" +#include "VtkSceneController.hpp" +#include "model/ColorScale.hpp" +#include "model/Field.hpp" +#include "repo/I3dSceneRepository.hpp" +#include "repo/IDatasetRepository.hpp" + +using namespace geopro; +using namespace geopro::controller; + +namespace { + +// 记录视图收到的图元调用类型/数量。 +struct FakeView : I3dSceneView { + int clears = 0; + int surveyLines = 0; + int curtains = 0; + int volumes = 0; + int mapLines = 0; + int terrains = 0; + int renders = 0; + bool lastIs2D = false; + bool lastResetCamera = true; + double ve = -1.0; + + // P2 记录。 + int setAxesCalls = 0; + AxesMode lastAxesMode = AxesMode::None; + AxesUnit lastAxesUnit = AxesUnit::None; + int lastAxesFont = -1; + int cameraViewCalls = 0; + ViewDir lastViewDir = ViewDir::Front; + int zoomCalls = 0; + double lastZoomFactor = 0.0; + int fitCalls = 0; + + // 按 ds 记录图元数,使 removeDataset 能按量回退(反映增量场景状态)。 + std::map> perDs; // dsId → (curtains, volumes) + std::map perDsMapLines; // dsId → mapLine 数(removeDataset 回退用) + double lastMapLineZ = 0.0; // 最近一次 addMapLine 的 worldZ(摆放验证) + double refElev = 0.0; // 地表高程基准(顶/底摆放锚定) + + // 色阶编辑:记录最近一次 addVolume 收到的色阶 + removeDataset 调用数(验证就地重渲染)。 + core::ColorScale lastVolumeScale; + int removeCalls = 0; + + // clear 模型化"移除所有数据图元":计数归零,clears 累加。 + void clear() override { + ++clears; + surveyLines = curtains = volumes = mapLines = terrains = 0; + perDs.clear(); + perDsMapLines.clear(); + } + void setVerticalExaggeration(double v) override { ve = v; } + double zRefElev() const override { return refElev; } + void addSurveyLine(const core::Grid&) override { ++surveyLines; } + void addCurtain(const std::string& dsId, const core::Grid&, const core::ColorScale&) override { + ++curtains; + ++perDs[dsId].first; + } + void addVolume(const std::string& dsId, const data::VolumeGrid&, + const core::ColorScale& cs) override { + ++volumes; + ++perDs[dsId].second; + lastVolumeScale = cs; + } + void addMapLine(const std::string& dsId, const data::MapLine&, double worldZ) override { + ++mapLines; + ++perDsMapLines[dsId]; + lastMapLineZ = worldZ; + } + void addTerrain(const data::TerrainPaths&) override { ++terrains; } + void removeDataset(const std::string& dsId) override { + ++removeCalls; + auto ml = perDsMapLines.find(dsId); + if (ml != perDsMapLines.end()) { + mapLines -= ml->second; + perDsMapLines.erase(ml); + } + auto it = perDs.find(dsId); + if (it == perDs.end()) return; + curtains -= it->second.first; + volumes -= it->second.second; + perDs.erase(it); + } + void setAxes(AxesMode mode, AxesUnit unit, int fontSize) override { + ++setAxesCalls; + lastAxesMode = mode; lastAxesUnit = unit; lastAxesFont = fontSize; + } + void applyCameraView(ViewDir dir) override { ++cameraViewCalls; lastViewDir = dir; } + void zoom(double factor) override { ++zoomCalls; lastZoomFactor = factor; } + void fitView() override { ++fitCalls; } + void render(bool is2D, bool resetCamera) override { + ++renders; + lastIs2D = is2D; + lastResetCamera = resetCamera; + } + 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; } +}; + +// 同步小数据仓储:loadGrid 返回 2x2 grid,loadColorScale 返回两段色阶。 +struct FakeDsRepo : data::IDatasetRepository { + std::vector loadStructure() override { return {}; } + core::Grid loadGrid(const std::string&) override { + core::Grid g(2, 2); + g.lat = {22.0, 22.001}; + g.lon = {114.0, 114.001}; + return g; + } + core::ScatterField loadScatter(const std::string&) override { return {}; } + core::ColorScale loadColorScale(const std::string&) override { + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); + cs.addStop(1.0, core::Rgba{255, 0, 0, 255}); + return cs; + } + core::ColorScale loadScatterColorScale(const std::string&) override { return loadColorScale(""); } + std::vector loadAnomalies(const std::string&) override { return {}; } +}; + +// 同步三维仓储:dimensionOf 全当 3D;loadVolume 立即回调一个最小有效体。 +struct FakeSceneRepo : data::I3dSceneRepository { + data::DsDimension dimensionOf(const data::DsRow&) const override { + return data::DsDimension::Dim3D; + } + // 按数据集类型分流(取代旧全局 showVoxel/showCurtain):volumeIds 内 → 体素,否则帘面。 + // 默认空 → 全走帘面(同旧默认行为);体素测试显式标记某 ds 为体素类型。 + std::set volumeIds; + bool isVolumeDataset(const std::string& dsId) const override { + return volumeIds.count(dsId) > 0; + } + void loadVolume(const std::string&, + std::function 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; + 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 onOk, + OnError) override { + data::SectionData s; + s.grid = core::Grid(2, 2); + s.grid.lat = {22.0, 22.001}; + s.grid.lon = {114.0, 114.001}; + s.scale.addStop(0.0, core::Rgba{0, 0, 255, 255}); + s.scale.addStop(1.0, core::Rgba{255, 0, 0, 255}); + onOk(std::move(s)); // 同步回调(异步壳) + } + void loadMapLine(const std::string&, std::function onOk, + OnError) override { + data::MapLine line; + line.lat = {22.0, 22.001, 22.002}; + line.lon = {114.0, 114.001, 114.002}; + onOk(std::move(line)); // 同步回调(异步壳) + } + void loadTerrainPaths(std::function onOk, OnError) override { + onOk(data::TerrainPaths{"dem.tif", "image.tif"}); + } + + // 切片/异常/任务 stub(满足纯虚,行为同 LocalSample3dRepository) + void createSlice(const SliceSpec&, const std::string&, + std::function onOk, OnError) override { onOk("slice-0"); } + void saveSlice(const std::string&, const SliceSpec&, + std::function onOk, OnError) override { onOk(); } + void deleteSlice(const std::string&, + std::function onOk, OnError) override { onOk(); } + void loadAnomalyTree(const std::string&, + std::function onOk, OnError) override { onOk({}); } + void saveAnomaly(const core::Anomaly&, const std::string&, + std::function onOk, OnError) override { onOk("anomaly-0"); } + void deleteAnomaly(const std::string&, + std::function onOk, OnError) override { onOk(); } + void deleteAnomalyGroup(const std::string&, + std::function onOk, OnError) override { onOk(); } + void loadTaskRecords(const std::string&, + std::function)> onOk, OnError) override { onOk({}); } + void loadUsableTasks(const std::string&, + std::function)> onOk, OnError) override { onOk({}); } +}; + +} // namespace + +// 2D 模式 + 勾选 1 ds → 1 个测线 actor,无帘面/体素/地形。 +TEST(VtkSceneController, Map2DWithOneDatasetAddsSurveyLine) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::Map2D); + c.setCheckedDatasets({"ds1"}); + + EXPECT_EQ(view.surveyLines, 1); + EXPECT_EQ(view.curtains, 0); + EXPECT_EQ(view.volumes, 0); + EXPECT_GE(view.renders, 1); + EXPECT_TRUE(view.lastIs2D); +} + +// 3D 模式 + 帘面图层 → 1 帘面 actor。 +TEST(VtkSceneController, View3DCurtainAddsCurtain) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1"}); + + EXPECT_EQ(view.curtains, 1); + EXPECT_EQ(view.surveyLines, 0); + EXPECT_FALSE(view.lastIs2D); +} + +// 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.setCheckedDatasets({"ds1"}); + + EXPECT_EQ(view.volumes, 1); + EXPECT_EQ(view.curtains, 0); // 体素数据集不再同时出帘面 +} + +// 3D + 地形 → 地形 1(与勾选数据集无关,地形是场景图层)。 +TEST(VtkSceneController, View3DWithTerrainAddsTerrain) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setLayer(SceneLayer::Terrain, true); + c.setCheckedDatasets({"ds1"}); + + EXPECT_EQ(view.terrains, 1); + EXPECT_EQ(view.curtains, 1); +} + +// 取消勾选 → 增量移除该 ds 图元(不整场 clear,3D 增量路径)。 +TEST(VtkSceneController, UncheckRemovesDatasetIncrementally) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1"}); + ASSERT_EQ(view.curtains, 1); + const int clearsAfterCheck = view.clears; + + c.setCheckedDatasets({}); // 取消全部勾选 → 增量移除 ds1 + EXPECT_EQ(view.curtains, 0); + EXPECT_EQ(view.volumes, 0); + EXPECT_EQ(view.clears, clearsAfterCheck); // 增量取消不触发整场 clear +} + +// 增量追加:已勾选 ds1 时再勾 ds2,只新增 ds2,不移除/重建 ds1。 +TEST(VtkSceneController, IncrementalAddKeepsExisting) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1"}); + const int clearsAfterFirst = view.clears; + ASSERT_EQ(view.curtains, 1); + + c.setCheckedDatasets({"ds1", "ds2"}); // 增量加 ds2 + EXPECT_EQ(view.curtains, 2); + EXPECT_EQ(view.clears, clearsAfterFirst); // 不重建 → 无新 clear +} + +// 纵向比例传到视图。 +TEST(VtkSceneController, VerticalExaggerationForwarded) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setVerticalExaggeration(3.5); + c.setCheckedDatasets({"ds1"}); + EXPECT_DOUBLE_EQ(view.ve, 3.5); +} + +// 多个数据集 → 每个一个帘面。 +TEST(VtkSceneController, MultipleDatasetsAddMultipleCurtains) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1", "ds2", "ds3"}); + EXPECT_EQ(view.curtains, 3); +} + +// ── 色阶编辑器「确定」:setVolumeColorScale ── + +// 已渲染三维体改色阶 → 移除旧体素 + 以新色阶重建(体素计数不变,但新色阶下发)。 +TEST(VtkSceneController, SetVolumeColorScaleRebuildsCheckedVolume) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + sc.volumeIds = {"ds1"}; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1"}); + ASSERT_EQ(view.volumes, 1); + const int removesBefore = view.removeCalls; + + core::ColorScale edited; // 三段(与初始两段区分) + edited.addStop(0.0, core::Rgba{0, 0, 0, 255}); + edited.addStop(0.5, core::Rgba{128, 128, 128, 255}); + edited.addStop(1.0, core::Rgba{255, 255, 255, 255}); + c.setVolumeColorScale("ds1", edited); + + EXPECT_EQ(view.volumes, 1); // 移除 1 + 新增 1 → 净计数不变 + EXPECT_EQ(view.removeCalls, removesBefore + 1); // 旧体素被移除 + EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u); // 新色阶(三段)已下发 +} + +// 会话级 mock 持久:已加载的体编辑色阶后,取消再勾选仍用编辑后的色阶(命中缓存,不回退默认)。 +TEST(VtkSceneController, SetVolumeColorScalePersistsAcrossRecheck) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + sc.volumeIds = {"ds1"}; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"ds1"}); // 加载体(填充 volumeCache_) + ASSERT_EQ(view.lastVolumeScale.stopCount(), 2u); // 初始两段 + + core::ColorScale edited; // 编辑成三段 + edited.addStop(0.0, core::Rgba{0, 0, 0, 255}); + edited.addStop(0.5, core::Rgba{128, 128, 128, 255}); + edited.addStop(1.0, core::Rgba{255, 255, 255, 255}); + c.setVolumeColorScale("ds1", edited); + ASSERT_EQ(view.lastVolumeScale.stopCount(), 3u); + + c.setCheckedDatasets({}); // 取消勾选 + c.setCheckedDatasets({"ds1"}); // 再勾选 → 命中缓存(含编辑后色阶) + EXPECT_EQ(view.lastVolumeScale.stopCount(), 3u); +} + +// ── P2:坐标轴 / 快捷视图 / Zoom 编排 ── + +// 每次重建都把当前坐标轴设置下发给视图(clear 后须重设)。 +TEST(VtkSceneController, RebuildForwardsAxesSettings) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); // 触发一次重建 + EXPECT_GE(view.setAxesCalls, 1); + // 默认 = 标准 + 米 + 字号 12。 + EXPECT_EQ(view.lastAxesMode, AxesMode::Standard); + EXPECT_EQ(view.lastAxesUnit, AxesUnit::Meter); + EXPECT_EQ(view.lastAxesFont, 12); +} + +// setAxesMode 改模式并重建下发。 +TEST(VtkSceneController, SetAxesModeForwardedOnRebuild) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setAxesMode(AxesMode::None); + EXPECT_EQ(view.lastAxesMode, AxesMode::None); + const int rebuilds = view.setAxesCalls; + c.setAxesMode(AxesMode::Stereo); + EXPECT_EQ(view.lastAxesMode, AxesMode::Stereo); + EXPECT_GT(view.setAxesCalls, rebuilds); // 又触发一次重建 +} + +// setAxesUnit 改单位并重建下发。 +TEST(VtkSceneController, SetAxesUnitForwarded) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setAxesUnit(AxesUnit::Feet); + EXPECT_EQ(view.lastAxesUnit, AxesUnit::Feet); + c.setAxesUnit(AxesUnit::LatLon); + EXPECT_EQ(view.lastAxesUnit, AxesUnit::LatLon); +} + +// applyView 转发方向,不重建场景(不增 clear)。 +TEST(VtkSceneController, ApplyViewForwardsDirectionWithoutRebuild) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + const int clearsBefore = view.clears; + c.applyView(ViewDir::Top); + EXPECT_EQ(view.cameraViewCalls, 1); + EXPECT_EQ(view.lastViewDir, ViewDir::Top); + EXPECT_EQ(view.clears, clearsBefore); // 不重建 + c.applyView(ViewDir::Left); + EXPECT_EQ(view.lastViewDir, ViewDir::Left); +} + +// zoomIn/zoomOut 用 1.2 / (1/1.2);fit 调 fitView。 +TEST(VtkSceneController, ZoomAndFitForwarded) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.zoomIn(); + EXPECT_EQ(view.zoomCalls, 1); + EXPECT_DOUBLE_EQ(view.lastZoomFactor, 1.2); + c.zoomOut(); + EXPECT_DOUBLE_EQ(view.lastZoomFactor, 1.0 / 1.2); + c.fit(); + EXPECT_EQ(view.fitCalls, 1); +} + +// ── 二维数据集视图:足迹平铺进 View3D ── + +// 显式关闭摆放(0) → 勾选 2D 足迹不渲染(仅记录勾选)。 +TEST(VtkSceneController, TwoDPlacementOffDoesNotRender) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(0, 0.0); // 显式关闭 + c.setChecked2DDatasets({"traj1"}); + EXPECT_EQ(view.mapLines, 0); +} + +// 回归(足迹默认不渲染 bug):默认摆放=Z=0(1),与 2D视图下拉可见默认项一致 → +// 仅勾选 2D 足迹(不手动调 set2DPlacement)即应在 View3D 渲染,worldZ=0。 +TEST(VtkSceneController, TwoDDefaultPlacementRendersAtZeroOnCheck) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setChecked2DDatasets({"traj1"}); // 不调 set2DPlacement,依赖默认摆放 + EXPECT_EQ(view.mapLines, 1); + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0); +} + +// 摆放 Z=0(1) + 勾选足迹 → 1 条 mapLine,worldZ=0;不影响帘面/体素计数。 +TEST(VtkSceneController, TwoDPlacementZeroAddsMapLine) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(1, 0.0); // Z=0 + c.setChecked2DDatasets({"traj1"}); + EXPECT_EQ(view.mapLines, 1); + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 0.0); + EXPECT_EQ(view.curtains, 0); + EXPECT_EQ(view.volumes, 0); +} + +// 顶部/底部摆放锚定真实地表高程:worldZ = zRefElev ± 偏移(而非世界 0 ± 偏移)。 +TEST(VtkSceneController, TwoDPlacementTopBottomAnchorToSurfaceElev) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + view.refElev = 1200.0; // 地表高程基准 + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(2, 0.0); // 顶部 + c.setChecked2DDatasets({"traj1"}); + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 + 50.0); // 贴地表上方 + c.set2DPlacement(3, 0.0); // 底部 + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 1200.0 - 50.0); // 地表下方 +} + +// 取消勾选 2D 足迹 → 增量移除该足迹图元(不整场 clear)。 +TEST(VtkSceneController, TwoDUncheckRemovesMapLine) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(1, 0.0); + c.setChecked2DDatasets({"traj1"}); + ASSERT_EQ(view.mapLines, 1); + const int clearsBefore = view.clears; + + c.setChecked2DDatasets({}); // 取消勾选 + EXPECT_EQ(view.mapLines, 0); + EXPECT_EQ(view.clears, clearsBefore); // 增量移除,不整场 clear +} + +// 2D 足迹与 3D 帘面共存且独立:勾选剖面 + 足迹,各出各的图元,互不影响。 +TEST(VtkSceneController, TwoDFootprintCoexistsWith3DCurtain) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"prof1"}); // 3D 帘面 + c.set2DPlacement(1, 0.0); + c.setChecked2DDatasets({"traj1"}); // 2D 足迹 + EXPECT_EQ(view.curtains, 1); + EXPECT_EQ(view.mapLines, 1); + + c.setChecked2DDatasets({}); // 取消足迹 → 帘面不受影响 + EXPECT_EQ(view.mapLines, 0); + EXPECT_EQ(view.curtains, 1); +} + +// 回归(BUG3:二维分析切回三维分析后,三维数据"不知生成到哪",要手动适配才定位): +// 二维勾选足迹自动取景后 hadArrivedData_=true;切回三维前 onAnalysisModeChanged(false) 按"三维栏空" +// 复位取景基线 → 勾选三维数据应自动取景(fitView),而非停在旧相机。 +TEST(VtkSceneController, ThreeDDataFitsAfterSwitchingBackFrom2D) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + + c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) + c.setChecked2DDatasets({"traj1"}); + ASSERT_EQ(view.mapLines, 1); + const int fitsAfter2D = view.fitCalls; + EXPECT_GE(fitsAfter2D, 1); // 足迹首次到场已取景 + + c.onAnalysisModeChanged(false); // 切回三维(3D 栏空 → 基线允许取景) + c.setCheckedDatasets({"prof1"}); + EXPECT_EQ(view.curtains, 1); + EXPECT_GT(view.fitCalls, fitsAfter2D); // 三维数据到场自动取景(修复前不取景) +} + +// 回归(二维分析下已有隐藏 3D 数据时,勾选首条足迹也应取景;旧 wasEmpty 逻辑因 3D 非空而漏取景): +TEST(VtkSceneController, TwoDFootprintFitsEvenWhenHidden3DExists) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.setCheckedDatasets({"prof1"}); // 三维数据(取景一次) + const int fitsAfter3D = view.fitCalls; + + c.onAnalysisModeChanged(true); // 切到二维(2D 栏空 → 基线允许取景) + c.setChecked2DDatasets({"traj1"}); + EXPECT_EQ(view.mapLines, 1); + EXPECT_GT(view.fitCalls, fitsAfter3D); // 首条足迹取景(旧逻辑因有隐藏 3D 而漏) +} + +// 自定义摆放(4) → worldZ=customZ;改摆放重摆已勾选足迹(移除旧 + 按新 Z 重加)。 +TEST(VtkSceneController, TwoDPlacementCustomZAndReplace) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(4, 123.5); // 自定义 Z + c.setChecked2DDatasets({"traj1"}); + ASSERT_EQ(view.mapLines, 1); + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 123.5); + const int removesBefore = view.removeCalls; + + c.set2DPlacement(4, 200.0); // 改自定义 Z → 重摆 + EXPECT_EQ(view.mapLines, 1); // 移除 1 + 新增 1 → 净计数不变 + EXPECT_EQ(view.removeCalls, removesBefore + 1); // 旧足迹被移除 + EXPECT_DOUBLE_EQ(view.lastMapLineZ, 200.0); // 新 Z 已下发 +} + +// 摆放从关闭(0)切到 Z=0(1) → 已勾选但未渲染的足迹补画。 +TEST(VtkSceneController, TwoDPlacementOffToOnDrawsCheckedFootprint) { + FakeDsRepo ds; FakeSceneRepo sc; FakeView view; + VtkSceneController c(ds, sc, view); + c.setViewMode(ViewMode::View3D); + c.set2DPlacement(0, 0.0); // 显式关闭(默认已是 Z=0) + c.setChecked2DDatasets({"traj1"}); // 关闭态:仅记录 + ASSERT_EQ(view.mapLines, 0); + + c.set2DPlacement(1, 0.0); // 切到 Z=0 → 补画 + EXPECT_EQ(view.mapLines, 1); +} diff --git a/tests/core/test_geo_frame.cpp b/tests/core/test_geo_frame.cpp index 75a263a..a46e55c 100644 --- a/tests/core/test_geo_frame.cpp +++ b/tests/core/test_geo_frame.cpp @@ -37,3 +37,25 @@ TEST(GeoFrame, NorthwardLatitudeGivesPositiveY) { EXPECT_NEAR(p.y, expected, expected * 0.05); EXPECT_NEAR(p.x, 0.0, 1e-9); } + +// toLatLon 是 toLocal 的反算:toLocal∘toLatLon 与 toLatLon∘toLocal 都恒等。 +TEST(GeoFrame, ToLatLonRoundTrips) { + GeoLocalFrame f(22.5, 114.16); + // 经纬度 → 局部 → 经纬度 恒等。 + auto p = f.toLocal(22.53, 114.19); + auto ll = f.toLatLon(p.x, p.y); + EXPECT_NEAR(ll.lat, 22.53, 1e-9); + EXPECT_NEAR(ll.lon, 114.19, 1e-9); + // 局部 → 经纬度 → 局部 恒等。 + auto q = f.toLocal(ll.lat, ll.lon); + EXPECT_NEAR(q.x, p.x, 1e-6); + EXPECT_NEAR(q.y, p.y, 1e-6); +} + +// 原点局部 (0,0) 反算回 (lat0,lon0)。 +TEST(GeoFrame, ToLatLonOriginMapsToLat0Lon0) { + GeoLocalFrame f(22.5, 114.16); + auto ll = f.toLatLon(0.0, 0.0); + EXPECT_NEAR(ll.lat, 22.5, 1e-12); + EXPECT_NEAR(ll.lon, 114.16, 1e-12); +} diff --git a/tests/core/test_geo_volume_builder.cpp b/tests/core/test_geo_volume_builder.cpp new file mode 100644 index 0000000..243d3d5 --- /dev/null +++ b/tests/core/test_geo_volume_builder.cpp @@ -0,0 +1,257 @@ +#include "core/algo/GeoVolumeBuilder.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "data/store/ChunkedVolumeStore.hpp" + +namespace fs = std::filesystem; +using namespace geopro::core; + +// ---- principalAxisAngle 纯函数 ---- +TEST(GeoVolumeBuilder, PcaFindsRoadDirection) { + // 点沿 +Y(北向)排布 → 主轴角约 ±90°(±pi/2)。 + std::vector xs, ys; + for (int i = 0; i < 20; ++i) { + xs.push_back(0.01 * i); // 微小横向噪声 + ys.push_back(static_cast(i)); + } + const double ang = principalAxisAngle(xs, ys); + EXPECT_NEAR(std::abs(ang), 3.14159265358979323846 / 2.0, 0.05); +} + +TEST(GeoVolumeBuilder, PcaAlongEastIsZero) { + std::vector xs, ys; + for (int i = 0; i < 20; ++i) { + xs.push_back(static_cast(i)); + ys.push_back(0.01 * i); + } + EXPECT_NEAR(principalAxisAngle(xs, ys), 0.0, 0.05); +} + +TEST(GeoVolumeBuilder, PcaDegenerateReturnsZero) { + EXPECT_DOUBLE_EQ(principalAxisAngle({}, {}), 0.0); + EXPECT_DOUBLE_EQ(principalAxisAngle({1.0}, {1.0}), 0.0); +} + +// ---- 合成小线建体 ---- +namespace { + +// 写一通道 .iprb + .iprh:值恒为 fixedVal(便于校验重叠均值)。 +void writeChannel(const fs::path& iprb, int samples, int traces, + std::int16_t fixedVal) { + fs::path iprh = fs::path(iprb).replace_extension(".iprh"); + std::ofstream h(iprh); + h << "SAMPLES: " << samples << "\n"; + h << "LAST TRACE: " << (traces - 1) << "\n"; + h << "CHANNELS: 2\n"; + h << "TIMEWINDOW: 100.0\n"; + h << "SOIL VELOCITY: 100.0\n"; // m/µs → 1e8 m/s + h << "DISTANCE INTERVAL: 0.05\n"; + h.close(); + + std::ofstream b(iprb, std::ios::binary); + for (int t = 0; t < traces; ++t) + for (int s = 0; s < samples; ++s) + b.write(reinterpret_cast(&fixedVal), sizeof(fixedVal)); +} + +// 写一条南北直线的 .gps(lat 从 lat0 递增到 lat1,lon 固定)。 +void writeGps(const fs::path& path, double lat0, double lat1, double lon, + int pts) { + std::ofstream f(path); + for (int i = 0; i < pts; ++i) { + const double frac = pts > 1 ? static_cast(i) / (pts - 1) : 0.0; + const double lat = lat0 + (lat1 - lat0) * frac; + f << "2023-06-03\t00:00:00:000\t" << std::fixed + << std::setprecision(10) << lat << "\tN\t" << lon << "\tE\t9.0\tM\t4\n"; + } +} + +// 写两通道 .ord(横偏 -0.5 / +0.5,末列=1 有效)。 +void writeOrd(const fs::path& path) { + std::ofstream f(path); + f << "0 -0.500000 -1.5 1\n"; + f << "1 0.500000 -1.5 1\n"; +} + +GeoLineInput makeLine(const fs::path& dir, const std::string& tag, double lat0, + double lat1, double lon, int traces, std::int16_t val) { + const int samples = 8; + writeChannel(dir / (tag + "_A01.iprb"), samples, traces, val); + writeChannel(dir / (tag + "_A02.iprb"), samples, traces, val); + writeOrd(dir / (tag + ".ord")); + writeGps(dir / (tag + ".gps"), lat0, lat1, lon, traces); + GeoLineInput in; + in.iprb = {(dir / (tag + "_A01.iprb")).string(), + (dir / (tag + "_A02.iprb")).string()}; + in.ord = (dir / (tag + ".ord")).string(); + in.gps = (dir / (tag + ".gps")).string(); + return in; +} + +// 写一条 L 形弯轨迹的 .gps:先沿北(纬增,lon 固定)走半程,再沿东(经增,lat 固定)走半程。 +void writeLGps(const fs::path& path, double lat0, double lon0, double dLatHalf, + double dLonHalf, int pts) { + std::ofstream f(path); + const int half = pts / 2; + for (int i = 0; i < pts; ++i) { + double lat, lon; + if (i <= half) { + const double frac = half > 0 ? static_cast(i) / half : 0.0; + lat = lat0 + dLatHalf * frac; // 北段:纬增 + lon = lon0; + } else { + const double frac = + (pts - 1 - half) > 0 + ? static_cast(i - half) / (pts - 1 - half) + : 0.0; + lat = lat0 + dLatHalf; // 拐角后纬固定 + lon = lon0 + dLonHalf * frac; // 东段:经增 + } + f << "2023-06-03\t00:00:00:000\t" << std::fixed << std::setprecision(10) + << lat << "\tN\t" << lon << "\tE\t9.0\tM\t4\n"; + } +} + +GeoLineInput makeLLine(const fs::path& dir, const std::string& tag, double lat0, + double lon0, double dLatHalf, double dLonHalf, int traces, + std::int16_t val) { + const int samples = 8; + writeChannel(dir / (tag + "_A01.iprb"), samples, traces, val); + writeChannel(dir / (tag + "_A02.iprb"), samples, traces, val); + writeOrd(dir / (tag + ".ord")); + writeLGps(dir / (tag + ".gps"), lat0, lon0, dLatHalf, dLonHalf, traces); + GeoLineInput in; + in.iprb = {(dir / (tag + "_A01.iprb")).string(), + (dir / (tag + "_A02.iprb")).string()}; + in.ord = (dir / (tag + ".ord")).string(); + in.gps = (dir / (tag + ".gps")).string(); + return in; +} + +} // namespace + +TEST(GeoVolumeBuilder, BuildsSyntheticLinesOverlapAveraged) { + const fs::path tmp = fs::temp_directory_path() / "geopro_geovol_test"; + std::error_code ec; + fs::remove_all(tmp, ec); + fs::create_directories(tmp); + + // 两条同位置南北线(lat 30.200→30.201,~111m),值不同 → 重叠 cell 取均值。 + const double lat0 = 30.200, lat1 = 30.201, lon = 120.244; + std::vector lines = { + makeLine(tmp, "synA_001", lat0, lat1, lon, /*traces=*/40, /*val=*/100), + makeLine(tmp, "synB_002", lat0, lat1, lon, /*traces=*/40, /*val=*/300), + }; + + const std::string store = (tmp / "store").string(); + GeoGridSpec spec{/*cellXY=*/0.5, /*cellZ=*/0.1}; + GeoBuildResult r = buildGeoVolume(lines, spec, store, /*pyramidLevels=*/1); + + // 维度合理:约 111m 长 → nx 在数百量级;横路窄(~1m 阵列) → ny 小;nz>1。 + EXPECT_GT(r.nx, 50); + EXPECT_GE(r.ny, 1); + EXPECT_GT(r.nz, 1); + EXPECT_GT(r.filled, 0); + EXPECT_LE(r.filled, r.total); + + // store 可读,维度一致。 + geopro::data::ChunkedVolumeStore s(store); + EXPECT_EQ(s.meta().nx, r.nx); + EXPECT_EQ(s.meta().ny, r.ny); + EXPECT_EQ(s.meta().nz, r.nz); + EXPECT_EQ(s.levels(), 2); // level0 + 1 + + // 重叠均值:两线值 100/300,命中同 cell → 均值 200。扫所有块找非 blank 体素, + // 其物理值应接近 200(量化误差内)。 + const auto& m = s.meta(); + bool foundNonBlank = false; + double sampleVal = 0.0; + for (int bz = 0; bz < s.bricksZ() && !foundNonBlank; ++bz) + for (int by = 0; by < s.bricksY() && !foundNonBlank; ++by) + for (int bx = 0; bx < s.bricksX() && !foundNonBlank; ++bx) { + auto vox = s.readBrick(bx, by, bz); + for (std::int16_t q : vox) { + if (q != geopro::core::ScalarVolumeI16::kBlank) { + sampleVal = m.quant.toPhys(q); + foundNonBlank = true; + break; + } + } + } + ASSERT_TRUE(foundNonBlank); + EXPECT_NEAR(sampleVal, 200.0, 2.0); + + fs::remove_all(tmp, ec); +} + +// L 形弯线:PCA 版把拐后段甩进大 Y'(虚高 ny、稀疏),曲线版沿 X 拉直展开 +// (ny 接近真实横向、填充更密)。断言曲线版 ny < PCA 版 ny 且填充率更高。 +TEST(GeoVolumeBuilder, CurvilinearStraightensBendVsPca) { + const fs::path tmp = fs::temp_directory_path() / "geopro_geovol_curv_test"; + std::error_code ec; + fs::remove_all(tmp, ec); + fs::create_directories(tmp); + + // L 形:北 ~111m(0.001° 纬)+ 东 ~96m(0.001° 经)。 + const double lat0 = 30.200, lon0 = 120.244; + std::vector lines = {makeLLine( + tmp, "synL_001", lat0, lon0, /*dLatHalf=*/0.001, /*dLonHalf=*/0.001, + /*traces=*/120, /*val=*/150)}; + + GeoGridSpec spec{/*cellXY=*/0.5, /*cellZ=*/0.1}; + + const std::string storePca = (tmp / "pca").string(); + GeoBuildResult rPca = + buildGeoVolume(lines, spec, storePca, /*pyramidLevels=*/0, + /*curvilinear=*/false); + + const std::string storeCurv = (tmp / "curv").string(); + GeoBuildResult rCurv = + buildGeoVolume(lines, spec, storeCurv, /*pyramidLevels=*/0, + /*curvilinear=*/true); + + const double fillPca = + rPca.total > 0 ? static_cast(rPca.filled) / rPca.total : 0.0; + const double fillCurv = + rCurv.total > 0 ? static_cast(rCurv.filled) / rCurv.total : 0.0; + + // 曲线版把弯路拉直:横向维度 ny 明显小于 PCA 版。 + EXPECT_LT(rCurv.ny, rPca.ny); + // 填充更密。 + EXPECT_GT(fillCurv, fillPca); + // 曲线版仍建出有效体。 + EXPECT_GT(rCurv.filled, 0); + EXPECT_GT(rCurv.nx, 50); + + fs::remove_all(tmp, ec); +} + +// 距离权纯函数:距 cell 中心越近权越大,单调递减,中心处取最大 1。 +TEST(GeoVolumeBuilder, DistanceWeightDecreasesWithDistance) { + EXPECT_DOUBLE_EQ(distanceWeight(0.0), 1.0); // 中心权最大 + EXPECT_LT(distanceWeight(1.0), distanceWeight(0.5)); // 单调递减 + EXPECT_LT(distanceWeight(2.0), distanceWeight(1.0)); + EXPECT_GT(distanceWeight(0.5), 0.0); +} + +// 加权均值偏向近点:同 cell 两样本,近点(dist=0.1)值 100、远点(dist=0.9)值 300。 +// 等权均值 = 200;距离加权均值应明显 < 200(偏向近点的 100)。 +TEST(GeoVolumeBuilder, WeightedMeanFavorsNearerPoint) { + const double vNear = 100.0, vFar = 300.0; + const double wNear = distanceWeight(0.1); + const double wFar = distanceWeight(0.9); + const double weighted = + (wNear * vNear + wFar * vFar) / (wNear + wFar); + const double equal = 0.5 * (vNear + vFar); // 200 + EXPECT_LT(weighted, equal); // 偏离等权 + EXPECT_LT(weighted - vNear, vFar - weighted); // 更靠近近点值 +} diff --git a/tests/core/test_gpr_volume_builder.cpp b/tests/core/test_gpr_volume_builder.cpp new file mode 100644 index 0000000..3ecf116 --- /dev/null +++ b/tests/core/test_gpr_volume_builder.cpp @@ -0,0 +1,63 @@ +#include "core/algo/GprVolumeBuilder.hpp" +#include "core/algo/IInterpolator.hpp" // GridSpec +#include + +using namespace geopro::core; + +// GprSurvey 放 core/model(geopro::core 命名空间),保持 core 自洽,避免 core->io 反向依赖。 +TEST(GprVolumeBuilder, InterpolatesAcrossChannelsOnly) { + geopro::core::GprSurvey s; + s.ntraces = 1; + s.samples = 1; + s.x0 = 0; + s.dx = 1; + s.z0 = 0; + s.dz = 1; + s.channelY = {0.0, 1.0}; // 两通道:Y=0 值0、Y=1 值100 + s.values = {0.0, 100.0}; // [(c*1+0)*1+0] => c=0->0, c=1->100 + GridSpec spec{}; + spec.nx = 1; + spec.ny = 3; + spec.nz = 1; + spec.ox = 0; + spec.oy = 0; + spec.oz = 0; + spec.dx = 1; + spec.dy = 0.5; + spec.dz = 1; + spec.power = 2; + spec.maxDist = 9.9; + auto b = buildGprVolume(s, spec); + EXPECT_EQ(b.vol.nx(), 1); + EXPECT_EQ(b.vol.ny(), 3); + EXPECT_EQ(b.vol.nz(), 1); + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 0, 0)), 0.0, 1.0); // Y=0 -> ch0 + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 1, 0)), 50.0, 1.5); // Y=0.5 -> 中点≈50 + EXPECT_NEAR(b.quant.toPhys(b.vol.at(0, 2, 0)), 100.0, 1.0); // Y=1.0 -> ch1 +} + +TEST(GprVolumeBuilder, OutOfRangeBecomesBlank) { + geopro::core::GprSurvey s; + s.ntraces = 1; + s.samples = 1; + s.x0 = 0; + s.dx = 1; + s.z0 = 0; + s.dz = 1; + s.channelY = {0.0}; + s.values = {5.0}; + GridSpec spec{}; + spec.nx = 3; + spec.ny = 1; + spec.nz = 1; // X 超出仅 1 道的范围 + spec.ox = 0; + spec.oy = 0; + spec.oz = 0; + spec.dx = 10; + spec.dy = 1; + spec.dz = 1; + spec.power = 2; + spec.maxDist = 0.5; + auto b = buildGprVolume(s, spec); + EXPECT_EQ(b.vol.at(2, 0, 0), ScalarVolumeI16::kBlank); // 远端无覆盖->blank +} diff --git a/tests/core/test_model_data.cpp b/tests/core/test_model_data.cpp index 51e99d7..2dff372 100644 --- a/tests/core/test_model_data.cpp +++ b/tests/core/test_model_data.cpp @@ -24,6 +24,18 @@ TEST(DataModel, AnomalyHolds) { EXPECT_EQ(a.localPts.size(), 2u); } +// 异常挂载实体解析(spec §8):切片已保存挂切片、临时切片挂体。 +TEST(ResolveAnomalyMount, SavedSliceMountsOnSlice) { + EXPECT_EQ(resolveAnomalyMount(true, "slice-1", "vol-1"), "slice-1"); +} +TEST(ResolveAnomalyMount, UnsavedSliceMountsOnVolume) { + EXPECT_EQ(resolveAnomalyMount(false, "", "vol-1"), "vol-1"); +} +TEST(ResolveAnomalyMount, SavedFlagButEmptySliceIdFallsBackToVolume) { + // 防御:标记已保存但切片 id 缺失 → 退回挂体(不产出空 remarkSourceId)。 + EXPECT_EQ(resolveAnomalyMount(true, "", "vol-1"), "vol-1"); +} + #include TEST(GridNaN, HasValueReflectsNaN) { geopro::core::Grid g(2, 2); diff --git a/tests/core/test_scalar_volume_i16.cpp b/tests/core/test_scalar_volume_i16.cpp new file mode 100644 index 0000000..4adcaa9 --- /dev/null +++ b/tests/core/test_scalar_volume_i16.cpp @@ -0,0 +1,22 @@ +#include "core/model/ScalarVolumeI16.hpp" +#include +using namespace geopro::core; + +TEST(ScalarVolumeI16, QuantRoundTrip) { + Quant q{0.5, -10.0}; + EXPECT_EQ(q.toQ(-10.0), 0); + EXPECT_NEAR(q.toPhys(q.toQ(3.0)), 3.0, 0.25); // 半个量化步内 +} + +TEST(ScalarVolumeI16, QuantClampReservesBlank) { + Quant q{1.0, 0.0}; + EXPECT_GT(q.toQ(-1e9), ScalarVolumeI16::kBlank); // 下钳不撞 kBlank + EXPECT_EQ(q.toQ(1e9), 32767); // 上钳到 INT16_MAX +} + +TEST(ScalarVolumeI16, IndexLayout) { + ScalarVolumeI16 v(2, 2, 2); + v.at(1, 0, 1) = 7; + EXPECT_EQ(v.data()[(1 * 2 + 0) * 2 + 1], 7); // ((k*ny+j)*nx+i)=((1*2+0)*2+1)=5 + EXPECT_EQ(v.at(1, 0, 1), 7); +} diff --git a/tests/core/test_volume_builder.cpp b/tests/core/test_volume_builder.cpp new file mode 100644 index 0000000..f878ff7 --- /dev/null +++ b/tests/core/test_volume_builder.cpp @@ -0,0 +1,61 @@ +#include + +#include +#include + +#include "algo/VolumeBuilder.hpp" + +using namespace geopro::core; + +namespace { +// 构造一个直角三角形足迹的散点:顶点 (0,0)/(100,0)/(0,100),沿三条边各撒点(值=10)。 +// 凸包 = 该三角形;外接盒 = [0,100]×[0,100],右上角 (100,100) 落在凸包外。 +PointSet trianglePoints() { + PointSet pts; + auto add = [&](double x, double y) { + pts.x.push_back(x); pts.y.push_back(y); pts.z.push_back(0.0); pts.v.push_back(10.0); + }; + for (int t = 0; t <= 100; t += 10) { + add(static_cast(t), 0.0); // 底边 y=0 + add(0.0, static_cast(t)); // 左边 x=0 + add(static_cast(t), 100.0 - t); // 斜边 x+y=100 + } + return pts; +} +} // namespace + +// maxDist=0(自动有界半径)+ 默认凸包裁剪:凸包内填满;凸包外即便在半径内也被裁成 NaN。 +TEST(VolumeBuilder, FootprintClipBlanksOutsideHull) { + const PointSet pts = trianglePoints(); + const BuiltVolume bv = buildVolume(pts, /*cellXY*/10.0, /*cellZ*/10.0, /*power*/2.0, + /*maxDist*/0.0, /*clipToFootprint*/true); + // 网格:ox=oy=0, dx=dy=10, nx=ny=11;nz 退化(z 跨度 0)补到 2(防 GPU 体绘制拒绝 1 层厚体)。 + ASSERT_EQ(bv.spec.nx, 11); + ASSERT_EQ(bv.spec.ny, 11); + ASSERT_EQ(bv.spec.nz, 2); + + // (40,40) 在三角形内(40+40<100)→ 有限值。 + EXPECT_TRUE(std::isfinite(bv.vol.at(4, 4, 0))); + // (60,60) 在凸包外(60+60>100)但离斜边仅 ~14m(在自动半径 ~28m 内)→ 足迹裁剪置空 → NaN。 + EXPECT_TRUE(std::isnan(bv.vol.at(6, 6, 0))); +} + +// 关闭裁剪:凸包外但在半径内的 (60,60) 被 IDW 填满(证明上例的 NaN 确由足迹裁剪所致,而非取不到点)。 +TEST(VolumeBuilder, NoClipKeepsInRadiusOutsideHull) { + const PointSet pts = trianglePoints(); + const BuiltVolume bv = buildVolume(pts, 10.0, 10.0, 2.0, /*maxDist*/0.0, + /*clipToFootprint*/false); + EXPECT_TRUE(std::isfinite(bv.vol.at(6, 6, 0))); +} + +// 退化(单条近共线剖面:XY 全在一条线上)→ 凸包退化 → 跳过裁剪,不致全盘置空。 +TEST(VolumeBuilder, DegenerateCollinearSkipsClip) { + PointSet pts; + for (int t = 0; t <= 100; t += 10) { + pts.x.push_back(static_cast(t)); pts.y.push_back(0.0); + pts.z.push_back(0.0); pts.v.push_back(5.0); + } + const BuiltVolume bv = buildVolume(pts, 10.0, 10.0, 2.0, /*maxDist*/0.0, /*clipToFootprint*/true); + // y 跨度 0(退化维补到 ny=2);节点应被 IDW 正常填充(裁剪跳过,不应全 NaN)。 + EXPECT_TRUE(std::isfinite(bv.vol.at(5, 0, 0))); +} diff --git a/tests/data/store/test_chunked_volume_store.cpp b/tests/data/store/test_chunked_volume_store.cpp new file mode 100644 index 0000000..181be77 --- /dev/null +++ b/tests/data/store/test_chunked_volume_store.cpp @@ -0,0 +1,43 @@ +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +#include +using namespace geopro::data; +namespace { +geopro::core::BuiltI16 makeBuilt(int n) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(n,n,n); + for (auto& v : b.vol.data()) v = 7; // 常量→高压缩比 + b.quant = {1.0, 0.0}; b.origin={{0,0,0}}; b.spacing={{1,1,1}}; + b.vminPhys=0; b.vmaxPhys=7; + return b; +} +} +TEST(ChunkedVolumeStore, RoundTripBrickAndCompresses) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_store_test").string(); + std::filesystem::remove_all(dir); + auto b = makeBuilt(128); + ChunkedVolumeStore::write(dir, b, 64); + auto m = ChunkedVolumeStore::readMeta(dir); + EXPECT_EQ(m.nx, 128); EXPECT_EQ(m.brick, 64); + ChunkedVolumeStore s(dir); + EXPECT_EQ(s.bricksX(), 2); + auto blk = s.readBrick(0,0,0); + EXPECT_EQ(blk.size(), 64u*64*64); + EXPECT_EQ(blk[0], 7); + auto dataSize = std::filesystem::file_size(std::filesystem::path(dir)/"data.bin"); + EXPECT_LT(dataSize, 128u*128*128*2); // 压缩生效 + std::filesystem::remove_all(dir); +} +TEST(ChunkedVolumeStore, EdgeBrickPartial) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_store_edge").string(); + std::filesystem::remove_all(dir); + auto b = makeBuilt(100); // 100 不整除 64 → 边缘块 36 + ChunkedVolumeStore::write(dir, b, 64); + ChunkedVolumeStore s(dir); + EXPECT_EQ(s.bricksX(), 2); // ceil(100/64)=2 + auto edge = s.readBrick(1,0,0); // x 方向边缘块,bw=36 + EXPECT_EQ(edge.size(), 36u*64*64); + std::filesystem::remove_all(dir); +} diff --git a/tests/data/store/test_pyramid.cpp b/tests/data/store/test_pyramid.cpp new file mode 100644 index 0000000..d7a9704 --- /dev/null +++ b/tests/data/store/test_pyramid.cpp @@ -0,0 +1,274 @@ +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +#include +using namespace geopro::data; +namespace { +geopro::core::BuiltI16 makeRamp(int n) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(n, n, n); + for (int k = 0; k < n; k++) + for (int j = 0; j < n; j++) + for (int i = 0; i < n; i++) b.vol.at(i, j, k) = (short)(i); // 沿 x 斜坡 + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = n; + return b; +} +} // namespace + +TEST(Pyramid, BuildsHalfResLevelsAndRanges) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); // level 0(64³),1(32³),2(16³) + EXPECT_GE(s.levels(), 3); + int nx, ny, nz; + s.dims(1, nx, ny, nz); + EXPECT_EQ(nx, 32); + EXPECT_EQ(ny, 32); + EXPECT_EQ(nz, 32); + // level0 块0 的 range:x 斜坡 0..31(brick32) → min=0,max=31 + auto r0 = s.brickRange(0, 0, 0, 0); + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 31); + auto blk1 = s.readBrick(1, 0, 0, 0); // level1 块,降采样后非空 + EXPECT_FALSE(blk1.empty()); + std::filesystem::remove_all(dir); +} + +TEST(Pyramid, Level0ReadCompatUnchanged) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_compat").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(1); + EXPECT_EQ(s.readBrick(0, 0, 0), s.readBrick(0, 0, 0, 0)); // 兼容重载等价 + std::filesystem::remove_all(dir); +} + +// 降采样语义:level1(0,0,0) 由 level0 每 2×2×2 平均得到。 +// x 斜坡:level1 体素 i 来自 level0 的 (2i, 2i+1) 平均 = round((2i + 2i+1)/2) = 2i(取整向偶或截断, +// 这里两值差 1,平均 2i+0.5,round→2i+1 或 2i 取决实现;仅校验单调与范围)。 +TEST(Pyramid, DownsampledRangeWithinSource) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_ds").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); + // level1 维度 32³;以 brick32 仍是 1 块(覆盖 i=0..31 → 源 i=0..63)。 + int nx, ny, nz; + s.dims(1, nx, ny, nz); + EXPECT_EQ(nx, 32); + auto r1 = s.brickRange(1, 0, 0, 0); + // 降采样值落在源范围 [0,63] 内,且块覆盖全 x → min≈0, max≈63 附近(round 后)。 + EXPECT_GE(r1.first, 0); + EXPECT_LE(r1.second, 63); + EXPECT_LT(r1.first, r1.second); // 斜坡降采样后仍有跨度 + // level1 块尺寸 = 32³。 + auto blk1 = s.readBrick(1, 0, 0, 0); + EXPECT_EQ(blk1.size(), 32u * 32 * 32); + std::filesystem::remove_all(dir); +} + +// 全 blank 块 → range 记 (kBlank,kBlank),降采样后该区域仍 blank。 +TEST(Pyramid, AllBlankBrickRange) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_blank").string(); + std::filesystem::remove_all(dir); + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(64, 64, 64); + for (auto& v : b.vol.data()) v = geopro::core::ScalarVolumeI16::kBlank; + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = 0; + ChunkedVolumeStore::write(dir, b, 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(2); + auto r0 = s.brickRange(0, 0, 0, 0); + EXPECT_EQ(r0.first, geopro::core::ScalarVolumeI16::kBlank); + EXPECT_EQ(r0.second, geopro::core::ScalarVolumeI16::kBlank); + auto r1 = s.brickRange(1, 0, 0, 0); + EXPECT_EQ(r1.first, geopro::core::ScalarVolumeI16::kBlank); + std::filesystem::remove_all(dir); +} + +// 真实全零块(非 blank):brickRange 返回 (0,0) 且不退化为惰性。 +// (0,0) 是合法值域,旧实现用 (vmin==0&&vmax==0) 当「未计算」哨兵会误判; +// hasRange 标志修正后:buildPyramid 算出的全零块 hasRange=true,返回 (0,0)。 +TEST(Pyramid, RealAllZeroBrickRangeIsZeroZeroNotDegenerate) { + auto dir = + (std::filesystem::temp_directory_path() / "gpr_pyr_zero").string(); + std::filesystem::remove_all(dir); + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(64, 64, 64); + for (auto& v : b.vol.data()) v = 0; // 真实 0(非 kBlank) + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = 0; + ChunkedVolumeStore::write(dir, b, 32); + ChunkedVolumeStore s(dir); + s.buildPyramid(1); + auto r0 = s.brickRange(0, 0, 0, 0); + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 0); // 合法 (0,0),不退化、不是 kBlank + EXPECT_NE(r0.first, geopro::core::ScalarVolumeI16::kBlank); + std::filesystem::remove_all(dir); +} + +// 老 store 全零块(无金字塔):首次 brickRange 惰性算出 (0,0),不无限退化。 +TEST(Pyramid, LegacyRealAllZeroBrickRangeIsZeroZero) { + auto dir = + (std::filesystem::temp_directory_path() / "gpr_pyr_zero_legacy").string(); + std::filesystem::remove_all(dir); + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(64, 64, 64); + for (auto& v : b.vol.data()) v = 0; + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = 0; + ChunkedVolumeStore::write(dir, b, 32); + ChunkedVolumeStore s(dir); // 未 buildPyramid:老 store,块无 hasRange + auto r0 = s.brickRange(0, 0, 0, 0); // 惰性算 → (0,0) + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 0); + std::filesystem::remove_all(dir); +} + +// ----------------------- 流式金字塔对拍 ----------------------- + +namespace { +// 体素随 (i,j,k) 变化,确保逐块对拍真正区分块内容(非全相同),含 blank 散点。 +geopro::core::BuiltI16 makeVaried(int nx, int ny, int nz) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) { + const int v = (i * 131 + j * 17 + k * 7) % 251; + // 散点 blank,验降采样 blank 混合(部分 blank 取非 blank 均值)。 + b.vol.at(i, j, k) = + ((i + j + k) % 13 == 0) + ? geopro::core::ScalarVolumeI16::kBlank + : static_cast(v); + } + b.quant = {0.5, -3.0}; + b.origin = {{10.0, 20.0, 30.0}}; + b.spacing = {{2.0, 3.0, 4.0}}; + b.vminPhys = -3.0; + b.vmaxPhys = 122.0; + return b; +} + +// 流式 vs 重组整卷逐块对拍(dims + 每块体素 + min/max + hasRange + levels 数)。 +void expectStreamingMatchesInRam(const geopro::core::BuiltI16& b, int brick, + int levels) { + const auto dirA = + (std::filesystem::temp_directory_path() / "gpr_pyr_match_A").string(); + const auto dirB = + (std::filesystem::temp_directory_path() / "gpr_pyr_match_B").string(); + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); + + ChunkedVolumeStore::write(dirA, b, brick); + { + ChunkedVolumeStore a(dirA); + a.buildPyramid(levels); // 金标准(重组整卷) + } + ChunkedVolumeStore::write(dirB, b, brick); + { + ChunkedVolumeStore bb(dirB); + bb.buildPyramidStreaming(levels); // 流式 + } + + ChunkedVolumeStore A(dirA), B(dirB); + ASSERT_EQ(A.levels(), B.levels()); + for (int L = 0; L < A.levels(); ++L) { + int anx, any, anz, bnx, bny, bnz; + A.dims(L, anx, any, anz); + B.dims(L, bnx, bny, bnz); + EXPECT_EQ(anx, bnx) << "level " << L << " nx"; + EXPECT_EQ(any, bny) << "level " << L << " ny"; + EXPECT_EQ(anz, bnz) << "level " << L << " nz"; + ASSERT_EQ(A.bricksX(L), B.bricksX(L)); + ASSERT_EQ(A.bricksY(L), B.bricksY(L)); + ASSERT_EQ(A.bricksZ(L), B.bricksZ(L)); + for (int bz = 0; bz < A.bricksZ(L); ++bz) + for (int by = 0; by < A.bricksY(L); ++by) + for (int bx = 0; bx < A.bricksX(L); ++bx) { + EXPECT_EQ(A.readBrick(L, bx, by, bz), B.readBrick(L, bx, by, bz)) + << "voxels mismatch L=" << L << " (" << bx << "," << by << "," + << bz << ")"; + EXPECT_EQ(A.brickRange(L, bx, by, bz), B.brickRange(L, bx, by, bz)) + << "range mismatch L=" << L << " (" << bx << "," << by << "," + << bz << ")"; + } + } + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} +} // namespace + +// 整除维度(128,brick64 → 2×2×2 满邻块):流式与重组整卷逐块一致。 +TEST(Pyramid, StreamingMatchesInRam) { + expectStreamingMatchesInRam(makeVaried(128, 128, 128), 64, 2); +} + +// 非整除/奇数维度(100、127),验边缘块 + 奇数维降采样一致。 +TEST(Pyramid, StreamingMatchesInRamNonDivisible) { + expectStreamingMatchesInRam(makeVaried(100, 100, 100), 64, 2); + expectStreamingMatchesInRam(makeVaried(127, 127, 127), 64, 3); +} + +// 非立方 + 小 brick(多级、边缘块更碎):流式与重组整卷逐块一致。 +TEST(Pyramid, StreamingMatchesInRamAnisotropicSmallBrick) { + expectStreamingMatchesInRam(makeVaried(70, 33, 50), 32, 3); +} + +// 全 blank 体流式降采样:各级仍全 blank(min/max=kBlank),与重组整卷一致。 +TEST(Pyramid, StreamingMatchesInRamAllBlank) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(70, 70, 70); + for (auto& v : b.vol.data()) v = geopro::core::ScalarVolumeI16::kBlank; + b.quant = {1.0, 0.0}; + b.origin = {{0, 0, 0}}; + b.spacing = {{1, 1, 1}}; + b.vminPhys = 0; + b.vmaxPhys = 0; + expectStreamingMatchesInRam(b, 32, 2); +} + +// 流式不破坏 level0:兼容重载等价。 +TEST(Pyramid, StreamingLevel0ReadCompatUnchanged) { + auto dir = + (std::filesystem::temp_directory_path() / "gpr_pyr_s_compat").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); + s.buildPyramidStreaming(1); + EXPECT_EQ(s.readBrick(0, 0, 0), s.readBrick(0, 0, 0, 0)); + std::filesystem::remove_all(dir); +} + +// 老 store(未 buildPyramid):levels()==1;brickRange(0,...) 仍可惰性算。 +TEST(Pyramid, LegacyStoreNoPyramidLevelsIsOne) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_pyr_legacy").string(); + std::filesystem::remove_all(dir); + ChunkedVolumeStore::write(dir, makeRamp(64), 32); + ChunkedVolumeStore s(dir); // 未调用 buildPyramid + EXPECT_EQ(s.levels(), 1); + EXPECT_EQ(s.bricksX(), s.bricksX(0)); + auto r0 = s.brickRange(0, 0, 0, 0); // 惰性算 + EXPECT_EQ(r0.first, 0); + EXPECT_EQ(r0.second, 31); + std::filesystem::remove_all(dir); +} diff --git a/tests/data/store/test_streaming_write.cpp b/tests/data/store/test_streaming_write.cpp new file mode 100644 index 0000000..634d971 --- /dev/null +++ b/tests/data/store/test_streaming_write.cpp @@ -0,0 +1,159 @@ +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +#include +#include +using namespace geopro::data; + +namespace { +// 非常量体:体素随 (i,j,k) 变化,确保逐块对拍真正区分块内容(非全相同)。 +geopro::core::BuiltI16 makeBuilt(int nx, int ny, int nz) { + geopro::core::BuiltI16 b; + b.vol = geopro::core::ScalarVolumeI16(nx, ny, nz); + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) + b.vol.at(i, j, k) = + static_cast((i * 131 + j * 17 + k * 7) % 251); + b.quant = {0.5, -3.0}; + b.origin = {{10.0, 20.0, 30.0}}; + b.spacing = {{2.0, 3.0, 4.0}}; + b.vminPhys = -3.0; + b.vmaxPhys = 122.0; + return b; +} + +int ceilDiv(int n, int brick) { return (n + brick - 1) / brick; } +int extent(int n, int b, int brick) { + const int got = n - b * brick; + return got < brick ? got : brick; +} + +// 从整卷切出 (bx,by,bz) 块体素(块内 i 最快、k 最慢,与 write 同布局)。 +std::vector sliceBrickFrom(const geopro::core::BuiltI16& b, int bx, + int by, int bz, int brick) { + const auto& vol = b.vol; + const int bw = extent(vol.nx(), bx, brick); + const int bh = extent(vol.ny(), by, brick); + const int bd = extent(vol.nz(), bz, brick); + std::vector out(static_cast(bw) * bh * bd); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) + for (int jj = 0; jj < bh; ++jj) + for (int ii = 0; ii < bw; ++ii) + out[w++] = vol.at(i0 + ii, j0 + jj, k0 + kk); + return out; +} + +std::string tmp(const char* name) { + return (std::filesystem::temp_directory_path() / name).string(); +} +} // namespace + +// 核心验收:流式逐块写 vs 非流式整卷 write,逐块 readBrick + meta 完全一致。 +TEST(StreamingVolumeWriter, MatchesNonStreamingWrite) { + auto b = makeBuilt(100, 40, 30); // 100/40/30 均非整除 64 → 含边缘块 + const int brick = 64; + auto dirA = tmp("swA"), dirB = tmp("swB"); + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); + + ChunkedVolumeStore::write(dirA, b, brick); // 金标准 + StoreMeta m = ChunkedVolumeStore::readMeta(dirA); // 复用同一 meta 元信息 + + const int bX = ceilDiv(m.nx, brick); + const int bY = ceilDiv(m.ny, brick); + const int bZ = ceilDiv(m.nz, brick); + + { + StreamingVolumeWriter w(dirB, m); + // 故意打乱写入顺序(与 write 的固定遍历顺序不同),验证顺序无关。 + for (int bx = 0; bx < bX; ++bx) + for (int by = 0; by < bY; ++by) + for (int bz = 0; bz < bZ; ++bz) + w.writeBrick(bx, by, bz, sliceBrickFrom(b, bx, by, bz, brick)); + w.finalize(); + } + + ChunkedVolumeStore A(dirA), B(dirB); + for (int bz = 0; bz < bZ; ++bz) + for (int by = 0; by < bY; ++by) + for (int bx = 0; bx < bX; ++bx) + EXPECT_EQ(A.readBrick(bx, by, bz), B.readBrick(bx, by, bz)) + << "brick mismatch at " << bx << "," << by << "," << bz; + + EXPECT_EQ(B.meta().nx, A.meta().nx); + EXPECT_EQ(B.meta().ny, A.meta().ny); + EXPECT_EQ(B.meta().nz, A.meta().nz); + EXPECT_EQ(B.meta().brick, A.meta().brick); + EXPECT_EQ(B.meta().origin, A.meta().origin); + EXPECT_EQ(B.meta().spacing, A.meta().spacing); + EXPECT_EQ(B.meta().quant.scale, A.meta().quant.scale); + EXPECT_EQ(B.meta().quant.offset, A.meta().quant.offset); + EXPECT_EQ(B.meta().vminPhys, A.meta().vminPhys); + EXPECT_EQ(B.meta().vmaxPhys, A.meta().vmaxPhys); + + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} + +// 同一块重复写 → 抛异常(约定:每块只写一次)。 +TEST(StreamingVolumeWriter, DuplicateBrickThrows) { + auto b = makeBuilt(70, 30, 20); + auto dirA = tmp("swDup"); + std::filesystem::remove_all(dirA); + ChunkedVolumeStore::write(dirA, b, 64); + StoreMeta m = ChunkedVolumeStore::readMeta(dirA); + + auto dirB = tmp("swDupB"); + std::filesystem::remove_all(dirB); + { + // 持久 data.bin 句柄随 writer 生命周期持有,需先析构 writer 再删目录(Windows 文件锁)。 + StreamingVolumeWriter w(dirB, m); + w.writeBrick(0, 0, 0, sliceBrickFrom(b, 0, 0, 0, 64)); + EXPECT_THROW(w.writeBrick(0, 0, 0, sliceBrickFrom(b, 0, 0, 0, 64)), + std::runtime_error); + } + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} + +// 缺块 finalize → 抛异常(约定:所有块必须写齐)。 +TEST(StreamingVolumeWriter, MissingBrickFinalizeThrows) { + auto b = makeBuilt(70, 30, 20); // bX=2,bY=1,bZ=1 → 共 2 块 + auto dirA = tmp("swMiss"); + std::filesystem::remove_all(dirA); + ChunkedVolumeStore::write(dirA, b, 64); + StoreMeta m = ChunkedVolumeStore::readMeta(dirA); + + auto dirB = tmp("swMissB"); + std::filesystem::remove_all(dirB); + { + StreamingVolumeWriter w(dirB, m); + w.writeBrick(0, 0, 0, sliceBrickFrom(b, 0, 0, 0, 64)); // 只写 1 块,缺 (1,0,0) + EXPECT_THROW(w.finalize(), std::runtime_error); + } + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} + +// 块体素大小不符(bw*bh*bd 不匹配)→ 抛异常。 +TEST(StreamingVolumeWriter, WrongVoxelCountThrows) { + auto b = makeBuilt(70, 30, 20); + auto dirA = tmp("swSize"); + std::filesystem::remove_all(dirA); + ChunkedVolumeStore::write(dirA, b, 64); + StoreMeta m = ChunkedVolumeStore::readMeta(dirA); + + auto dirB = tmp("swSizeB"); + std::filesystem::remove_all(dirB); + { + StreamingVolumeWriter w(dirB, m); + std::vector bad(10); // 远小于 64*30*20 + EXPECT_THROW(w.writeBrick(0, 0, 0, bad), std::runtime_error); + } + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} diff --git a/tests/data/test_3d_repo.cpp b/tests/data/test_3d_repo.cpp new file mode 100644 index 0000000..b6955f3 --- /dev/null +++ b/tests/data/test_3d_repo.cpp @@ -0,0 +1,192 @@ +#include + +#include +#include + +#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; + +static const std::string kDir = + "D:/Git/lanbingtech/geopro/docs/剖面网格数据的色阶数据2等文件/"; +static const std::string kCrs = "EPSG:4547"; + +namespace { +DsRow rowWith(const std::string& ddCode) { + DsRow r; + r.ddCode = ddCode; + return r; +} +} // namespace + +// dimensionOf:各 ddCode → 维度映射(同步纯函数,spec §6.1)。 +TEST(LocalSample3dRepo, DimensionOfMapsDdCode) { + LocalSampleRepository base(kDir); + LocalSample3dRepository repo(base, kCrs, 22.0, 114.0); + + EXPECT_EQ(repo.dimensionOf(rowWith("dd_voxel")), DsDimension::Dim3D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_Structual3D")), DsDimension::Dim3D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_Property3D")), DsDimension::Dim3D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_section")), DsDimension::Dim3D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_inversion_data")), DsDimension::Dim3D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_slice")), DsDimension::Analysis3D); + // 足迹型 → 二维:数据字典 DD0623 只 dd_trajectory_data 为统一通用轨迹「保留」; + // 瞬变电磁/雷达通道/RTK 等轨迹型字典均「删除」→ 不再归 2D(落 Other)。 + EXPECT_EQ(repo.dimensionOf(rowWith("dd_trajectory_data")), DsDimension::Dim2D); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_transient_electromagnetic_trajectory_data")), + DsDimension::Other); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_radar_channel_trajectory")), DsDimension::Other); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_radar_rtk_trajectory")), DsDimension::Other); + EXPECT_EQ(repo.dimensionOf(rowWith("dd_unknown_xyz")), DsDimension::Other); +} + +// loadMapLine:本地样本取 grid1 经纬作足迹折线(lat/lon 等长、>=2 点、valid)。 +TEST(LocalSample3dRepo, LoadMapLineCallsBackWithValidLine) { + LocalSampleRepository base(kDir); + LocalSample3dRepository repo(base, kCrs, 22.0, 114.0); + + bool ok = false; + std::string err; + MapLine got; + repo.loadMapLine("traj1", [&](MapLine l) { ok = true; got = std::move(l); }, + [&](const std::string& m) { err = m; }); + + ASSERT_TRUE(ok) << "loadMapLine onErr: " << err; + EXPECT_EQ(got.lat.size(), got.lon.size()); + EXPECT_GE(got.lat.size(), 2u); + EXPECT_TRUE(got.valid()); +} + +// loadVolume:回调收到有效 VolumeGrid(nx>0 且 vmax>vmin),需 PROJ_DATA。 +TEST(LocalSample3dRepo, LoadVolumeCallsBackWithValidGrid) { + LocalSampleRepository base(kDir); + LocalSample3dRepository repo(base, kCrs, 22.0, 114.0); + + bool ok = false; + std::string err; + VolumeGrid got; + 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; + EXPECT_GT(got.vol.nx(), 0); + EXPECT_GT(got.vol.ny(), 0); + EXPECT_GT(got.vol.nz(), 0); + EXPECT_GT(got.vmax, got.vmin); + EXPECT_TRUE(got.valid()); +} + +// loadTerrainPaths:回调收到 dem/image 绝对路径(非空)。 +TEST(LocalSample3dRepo, LoadTerrainPathsCallsBack) { + LocalSampleRepository base(kDir); + LocalSample3dRepository repo(base, kCrs, 22.0, 114.0); + + bool ok = false; + TerrainPaths got; + repo.loadTerrainPaths([&](TerrainPaths p) { ok = true; got = std::move(p); }, + [&](const std::string&) {}); + + ASSERT_TRUE(ok); + 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(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"); +} + +// loadMapLine(Api):loadAsync 返回空句柄 → onErr(不崩,给明确错误)。 +TEST(Api3dRepo, LoadMapLineNullHandleCallsOnError) { + StubAsyncRepo dsRepo; + auto frame = std::make_shared(22.0, 114.0); + Api3dRepository repo(dsRepo, frame); + + bool errCalled = false; + repo.loadMapLine("traj1", [](MapLine) { FAIL() << "不应成功(空句柄)"; }, + [&](const std::string&) { errCalled = true; }); + EXPECT_TRUE(errCalled); +} + +// volumeInfo:未知 dsId(非三维体)→ 返回 false,不弹空对话框。 +TEST(Api3dRepo, VolumeInfoUnknownIdReturnsFalse) { + StubAsyncRepo dsRepo; + auto frame = std::make_shared(22.0, 114.0); + Api3dRepository repo(dsRepo, frame); + + Api3dRepository::VolumeInfo info; + EXPECT_FALSE(repo.volumeInfo("not-a-volume", info)); +} + +// anomalyRows:异常按 remarkSourceId 存;行 parentId=归属实体 id(挂体/挂切片各回得来)。 +TEST(Api3dRepo, AnomalyRowsCarryMountAsParent) { + StubAsyncRepo dsRepo; + auto frame = std::make_shared(22.0, 114.0); + Api3dRepository repo(dsRepo, frame); + + geopro::core::Anomaly onVol; + onVol.name = "异常A"; onVol.typeName = "断层"; onVol.remarkSourceId = "vol-1"; + geopro::core::Anomaly onSlice; + onSlice.name = "异常B"; onSlice.remarkSourceId = "slice-9"; // 已保存切片 + std::string idV, idS; + repo.saveAnomaly(onVol, "", [&](std::string id) { idV = id; }, [](const std::string&) {}); + repo.saveAnomaly(onSlice, "", [&](std::string id) { idS = id; }, [](const std::string&) {}); + + const auto rows = repo.anomalyRows(); + ASSERT_EQ(rows.size(), 2u); + auto find = [&](const std::string& id) -> const geopro::data::DsRow* { + for (const auto& r : rows) + if (r.id == id) return &r; + return nullptr; + }; + const auto* rv = find(idV); + ASSERT_NE(rv, nullptr); + EXPECT_EQ(rv->ddCode, "dd_anomaly"); + EXPECT_EQ(rv->parentId, "vol-1"); // 挂体 + EXPECT_EQ(rv->dsName, "异常A"); + EXPECT_EQ(rv->typeName, "断层"); + const auto* rs = find(idS); + ASSERT_NE(rs, nullptr); + EXPECT_EQ(rs->parentId, "slice-9"); // 挂切片 + EXPECT_EQ(rs->typeName, "异常"); // typeName 空 → 回退"异常" +} diff --git a/tests/data/test_dataset_chart_dto.cpp b/tests/data/test_dataset_chart_dto.cpp index 0c36ac1..6fcc765 100644 --- a/tests/data/test_dataset_chart_dto.cpp +++ b/tests/data/test_dataset_chart_dto.cpp @@ -56,3 +56,47 @@ TEST(DatasetChartDto, ParsesAnomalyPolyline) { EXPECT_DOUBLE_EQ(v[0].localPts[1].x, 3.0); EXPECT_TRUE(v[0].dashed); } +TEST(DatasetChartDto, ParsesAnomalyLatLonAndEastNorth) { + // 对照原版 drawerExceptionInfo:经纬度 latitudeLongitude.latLon[].{longitude,latitude}、 + // 投影 geographicalCoordinates.coordinates[].{northCoord,eastCoord}(x=northCoord y=eastCoord)。 + auto arr = QJsonDocument::fromJson( + R"([{"id":"exc-2","exceptionName":"A2","exceptionMarkType":2, + "location":{"coordinate":[{"x":1,"y":2}]}, + "latitudeLongitude":{"latLon":[{"longitude":120.5,"latitude":30.25}]}, + "geographicalCoordinates":{"coordinates":[{"northCoord":3350000.0,"eastCoord":500000.0}]}}])") + .array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + ASSERT_EQ(v[0].lonLatPts.size(), 1u); + EXPECT_DOUBLE_EQ(v[0].lonLatPts[0].x, 120.5); // 经度 + EXPECT_DOUBLE_EQ(v[0].lonLatPts[0].y, 30.25); // 纬度 + ASSERT_EQ(v[0].eastNorthPts.size(), 1u); + EXPECT_DOUBLE_EQ(v[0].eastNorthPts[0].x, 3350000.0); // northCoord → X + EXPECT_DOUBLE_EQ(v[0].eastNorthPts[0].y, 500000.0); // eastCoord → Y +} +TEST(DatasetChartDto, AnomalyWithoutGeoCoordsLeavesVectorsEmpty) { + // 响应未携带经纬度/投影 → 两向量为空(详情坐标系下拉退化为仅「图形坐标」,与原版一致)。 + auto arr = QJsonDocument::fromJson( + R"([{"id":"exc-3","exceptionName":"A3","exceptionMarkType":2, + "location":{"coordinate":[{"x":1,"y":2}]}}])") + .array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_TRUE(v[0].lonLatPts.empty()); + EXPECT_TRUE(v[0].eastNorthPts.empty()); +} +TEST(DatasetChartDto, ParsesAnomalyIdentityFields) { + // I10/I11/I12 需要 id(删除/更新/定位)、备注、异常类型 id、创建时间。 + auto arr = QJsonDocument::fromJson( + R"([{"id":"exc-1","exceptionName":"A1","exceptionTypeName":"Zone", + "exceptionTypeId":"type-9","exceptionMarkType":3,"remark":"备注X", + "createTime":"2026-06-22 10:00:00", + "location":{"coordinate":[{"x":1,"y":2},{"x":3,"y":4},{"x":5,"y":6}]}}])").array(); + auto v = parseDatasetAnomalies(arr); + ASSERT_EQ(v.size(), 1u); + EXPECT_EQ(v[0].id, "exc-1"); + EXPECT_EQ(v[0].exceptionTypeId, "type-9"); + EXPECT_EQ(v[0].remark, "备注X"); + EXPECT_EQ(v[0].createTime, "2026-06-22 10:00:00"); + EXPECT_EQ(static_cast(v[0].markType), 3); +} diff --git a/tests/data/test_dataset_field_dictionary.cpp b/tests/data/test_dataset_field_dictionary.cpp new file mode 100644 index 0000000..f527706 --- /dev/null +++ b/tests/data/test_dataset_field_dictionary.cpp @@ -0,0 +1,60 @@ +#include +#include +#include "repo/DatasetFieldDictionary.hpp" +using namespace geopro::data; + +namespace { +DsTypeFields parse(const char* js) { + return parseFieldMapping(QJsonDocument::fromJson(QByteArray(js)).object()); +} +} // namespace + +TEST(ParseFieldMapping, ExtractsArrayTypeAndCollectTimeConfFieldIds) { + const DsTypeFields f = parse(R"({"formList":[{"groupName":"基本信息","values":[ + {"confFieldId":"f_ct","fieldCode":"collectTime","fieldName":"采集时间","optionsObject":null}, + {"confFieldId":"f_at","fieldCode":"arrayType","fieldName":"装置类型","optionsObject":[ + {"label":"温纳-施伦贝尔排列","value":"v1"},{"label":"全梯度","value":"v2"}]} + ]}]})"); + EXPECT_EQ(f.arrayTypeConfFieldId, "f_at"); + EXPECT_EQ(f.collectTimeConfFieldId, "f_ct"); + ASSERT_EQ(f.arrayTypeLabels.count("v1"), 1u); + EXPECT_EQ(f.arrayTypeLabels.at("v1"), "温纳-施伦贝尔排列"); + EXPECT_EQ(f.arrayTypeLabels.at("v2"), "全梯度"); +} + +TEST(DatasetFieldDictionary, ArrayValueAndCollectTimeFromRow) { + DsTypeFields f; + f.arrayTypeConfFieldId = "f_at"; + f.collectTimeConfFieldId = "f_ct"; + DsRow row; + row.properties = {{"f_at", "v2"}, {"f_ct", "2026-03-25 16:48:57"}, {"other", "x"}}; + EXPECT_EQ(arrayValueOf(row, f), "v2"); + EXPECT_EQ(collectTimeOf(row, f), "2026-03-25 16:48:57"); +} + +TEST(DatasetFieldDictionary, MissingPropReturnsEmpty) { + DsTypeFields f; + f.arrayTypeConfFieldId = "f_at"; + DsRow row; // 无 properties + EXPECT_EQ(arrayValueOf(row, f), ""); +} + +TEST(DatasetFieldDictionary, ArrayLabelHitsAndFallsBackToRawValue) { + DsTypeFields f; + f.arrayTypeLabels = {{"v1", "温纳"}}; + EXPECT_EQ(arrayLabel(f, "v1"), "温纳"); + // spec §11:原始值不在 optionsObject 时回退显示原值(如实测 1429468249448449)。 + EXPECT_EQ(arrayLabel(f, "1429468249448449"), "1429468249448449"); +} + +TEST(DatasetFieldDictionary, CacheSetHasFields) { + DatasetFieldDictionary dict; + EXPECT_FALSE(dict.has("ert")); + EXPECT_EQ(dict.fields("ert"), nullptr); + DsTypeFields f; + f.arrayTypeConfFieldId = "f_at"; + dict.setFields("ert", f); + EXPECT_TRUE(dict.has("ert")); + ASSERT_NE(dict.fields("ert"), nullptr); + EXPECT_EQ(dict.fields("ert")->arrayTypeConfFieldId, "f_at"); +} diff --git a/tests/data/test_gpr_volume_repository.cpp b/tests/data/test_gpr_volume_repository.cpp new file mode 100644 index 0000000..58b8006 --- /dev/null +++ b/tests/data/test_gpr_volume_repository.cpp @@ -0,0 +1,156 @@ +// GprVolumeRepository:逐线 GPR int16 量化体 → app 渲染链 float 体(VolumeGrid)。 +// 1) builtI16ToVolumeGrid 纯适配器:维度/反量化值/spacing/origin/vmin-vmax/kBlank→NaN。 +// 2) createGprVolumeGrid 全链:合成多通道 .iprb 走真 P1/P2 链 → 反量化体维度/spacing 自洽。 + +#include + +#include +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" +#include "core/model/ScalarVolumeI16.hpp" +#include "data/GprVolumeRepository.hpp" + +namespace fs = std::filesystem; + +namespace { + +// 适配器单测:手搭一个 2x1x2 的 BuiltI16(已知 quant/spacing/origin) → 校验反量化逐值。 +TEST(GprVolumeRepositoryAdapter, DequantDimsSpacingAndBlank) { + geopro::core::BuiltI16 built; + built.vol = geopro::core::ScalarVolumeI16(2, 1, 2); + // Quant: phys = q*scale + offset = q*0.5 + 10。 + built.quant.scale = 0.5; + built.quant.offset = 10.0; + built.origin = {1.0, 2.0, 3.0}; + built.spacing = {0.2, 1.37, 0.05}; + built.vminPhys = 5.0; + built.vmaxPhys = 20.0; + + // 填 4 个体素:3 个正常值 + 1 个 kBlank。 + built.vol.at(0, 0, 0) = 4; // phys = 4*0.5+10 = 12 + built.vol.at(1, 0, 0) = -2; // phys = -2*0.5+10 = 9 + built.vol.at(0, 0, 1) = 20; // phys = 20*0.5+10 = 20 + built.vol.at(1, 0, 1) = geopro::core::ScalarVolumeI16::kBlank; // → NaN + + const geopro::data::VolumeGrid g = geopro::data::builtI16ToVolumeGrid(built); + + // 维度。 + EXPECT_EQ(g.vol.nx(), 2); + EXPECT_EQ(g.vol.ny(), 1); + EXPECT_EQ(g.vol.nz(), 2); + + // 反量化逐值。 + EXPECT_DOUBLE_EQ(g.vol.at(0, 0, 0), 12.0); + EXPECT_DOUBLE_EQ(g.vol.at(1, 0, 0), 9.0); + EXPECT_DOUBLE_EQ(g.vol.at(0, 0, 1), 20.0); + EXPECT_TRUE(std::isnan(g.vol.at(1, 0, 1))); // kBlank → NaN(下游透明) + + // origin/spacing 原样搬运。 + EXPECT_DOUBLE_EQ(g.origin[0], 1.0); + EXPECT_DOUBLE_EQ(g.origin[1], 2.0); + EXPECT_DOUBLE_EQ(g.origin[2], 3.0); + EXPECT_DOUBLE_EQ(g.spacing[0], 0.2); + EXPECT_DOUBLE_EQ(g.spacing[1], 1.37); + EXPECT_DOUBLE_EQ(g.spacing[2], 0.05); + + // 物理值域 = BuiltI16 的 vminPhys/vmaxPhys。 + EXPECT_DOUBLE_EQ(g.vmin, 5.0); + EXPECT_DOUBLE_EQ(g.vmax, 20.0); + EXPECT_TRUE(g.valid()); +} + +// 写一个合成通道:.iprh 文本头 + .iprb 纯 int16 波形([trace*samples + s],s 最快)。 +// 与 test_gpr3dv_volume_bridge 同口径,确保 createGprVolumeGrid 走真 P1/P2 链。 +void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces, + std::int16_t base, double chYOffset, + double distanceInterval, double timeWindowNs, + double soilVelocity, int channels) { + std::ofstream h(iprhPath); + h << "SAMPLES: " << samples << "\n"; + h << "LAST TRACE: " << (traces - 1) << "\n"; + h << "CHANNELS: " << channels << "\n"; + h << "TIMEWINDOW: " << timeWindowNs << "\n"; + h << "SOIL VELOCITY: " << soilVelocity << "\n"; + h << "DISTANCE INTERVAL: " << distanceInterval << "\n"; + h << "CH_Y_OFFSET: " << chYOffset << "\n"; + h.close(); + + fs::path iprbPath = iprhPath; + iprbPath.replace_extension(".iprb"); + std::ofstream b(iprbPath, std::ios::binary); + for (int t = 0; t < traces; ++t) { + for (int s = 0; s < samples; ++s) { + const std::int16_t v = static_cast(base + t + s); + b.write(reinterpret_cast(&v), sizeof(v)); + } + } +} + +class GprVolumeRepositoryChainTest : public ::testing::Test { + protected: + void SetUp() override { + dir_ = fs::temp_directory_path() / "gpr_volume_repo_test"; + std::error_code ec; + fs::remove_all(dir_, ec); + fs::create_directories(dir_); + } + void TearDown() override { + std::error_code ec; + fs::remove_all(dir_, ec); + } + fs::path dir_; +}; + +TEST_F(GprVolumeRepositoryChainTest, FullChainProducesValidVolumeGrid) { + const int samples = 64; + const int traces = 40; + const int channels = 2; + const double dxHeader = 0.05; + const double timeWindowNs = 100.0; + const double soilVel = 0.1; + + writeSyntheticChannel(dir_ / "syn_001_A01.iprh", samples, traces, + /*base=*/100, /*chYOffset=*/-0.5, dxHeader, timeWindowNs, + soilVel, channels); + writeSyntheticChannel(dir_ / "syn_001_A02.iprh", samples, traces, + /*base=*/300, /*chYOffset=*/0.5, dxHeader, timeWindowNs, + soilVel, channels); + + // coarse=2:沿测线下采样,dx ×2 保形。 + geopro::data::VolumeGrid g; + ASSERT_NO_THROW( + { g = geopro::data::createGprVolumeGrid(dir_.string(), "syn_001", 2); }); + + // 维度:Y=通道数;X/Z 正。 + EXPECT_EQ(g.vol.ny(), channels); + EXPECT_GT(g.vol.nx(), 0); + EXPECT_GT(g.vol.nz(), 0); + + // spacing:X=道距×coarse、Y=通道横距(跨度1.0/(2-1)=1.0)、Z=深度采样距>0。 + EXPECT_DOUBLE_EQ(g.spacing[0], dxHeader * 2); + EXPECT_NEAR(g.spacing[1], 1.0, 1e-6); + EXPECT_GT(g.spacing[2], 0.0); + + // origin=0;值域自洽(vmin<=vmax,搬自 BuiltI16.vminPhys/vmaxPhys,处理后极小合成 + // 数据可能退化为相等区间 → 与 io::gpr 桥接同口径,不强求严格 <)。 + EXPECT_DOUBLE_EQ(g.origin[0], 0.0); + EXPECT_DOUBLE_EQ(g.origin[1], 0.0); + EXPECT_DOUBLE_EQ(g.origin[2], 0.0); + EXPECT_LE(g.vmin, g.vmax); + + // 稠密体:抽查角点为有限值(非 NaN)——GPR 立方体每体素有值,反量化后无空洞。 + EXPECT_TRUE(std::isfinite(g.vol.at(0, 0, 0))); + EXPECT_TRUE(std::isfinite(g.vol.at(g.vol.nx() - 1, g.vol.ny() - 1, + g.vol.nz() - 1))); +} + +TEST_F(GprVolumeRepositoryChainTest, ThrowsOnMissingLine) { + EXPECT_THROW(geopro::data::createGprVolumeGrid(dir_.string(), "nope", 1), + std::runtime_error); +} + +} // namespace diff --git a/tests/data/test_grid_dto.cpp b/tests/data/test_grid_dto.cpp index 68c0e61..0f11c9c 100644 --- a/tests/data/test_grid_dto.cpp +++ b/tests/data/test_grid_dto.cpp @@ -75,4 +75,35 @@ TEST(GridDto, EmptyDataYieldsSeqOnlyColumnNoRows) { EXPECT_EQ(t.columns[0].code.toStdString(), "__seq"); EXPECT_EQ(t.rows.size(), 0u); EXPECT_EQ(t.total, 0); + EXPECT_TRUE(t.functionButtons.empty()); // 无 functionList → 无功能按钮 +} + +TEST(GridDto, EmptyFunctionListYieldsNoButtons) { + // 真实夹具 functionList:[] → functionButtons 空(不渲染工具条)。 + auto t = parseGridTable(gridData(), 1, 50); + EXPECT_TRUE(t.functionButtons.empty()); +} + +TEST(GridDto, ParsesFunctionListIntoButtons) { + // functionList = DDGridFunctionVO[](functionCode/functionNameChn/enable)→ functionButtons。 + const char* kData = R"({ + "gridHeaderDisplay": [ + { "columnCode": "x", "columnNameChn": "x", "columnSort": 1 }, + { "columnCode": "y", "columnNameChn": "y", "columnSort": 2 } + ], + "functionList": [ + { "functionCode": "inversion", "functionNameChn": "反演", "enable": true }, + { "functionCode": "other", "functionNameChn": "其它", "enable": false } + ], + "total": 1, + "rowList": [ { "x": 1.0, "y": 2.0, "id": "1" } ] + })"; + auto t = parseGridTable(QJsonDocument::fromJson(kData).object(), 1, 50); + + ASSERT_EQ(t.functionButtons.size(), 2u); + EXPECT_EQ(t.functionButtons[0].code.toStdString(), "inversion"); + EXPECT_EQ(t.functionButtons[0].nameChn.toStdString(), "反演"); + EXPECT_TRUE(t.functionButtons[0].enable); + EXPECT_EQ(t.functionButtons[1].code.toStdString(), "other"); + EXPECT_FALSE(t.functionButtons[1].enable); // enable=false(视图按 v-show 不渲染) } diff --git a/tests/data/test_measurement_dto.cpp b/tests/data/test_measurement_dto.cpp index 25f3b0d..96b63da 100644 --- a/tests/data/test_measurement_dto.cpp +++ b/tests/data/test_measurement_dto.cpp @@ -103,6 +103,26 @@ TEST(MeasurementDto, ParsesScatterPositionalRows) { EXPECT_DOUBLE_EQ(s.electrodeNo[2], 3.0); } +TEST(MeasurementDto, ParsesScatterPointMetadata) { + // [i]信息 / 显隐持久化用元数据:id(col8) / displayStatus(col7) / row(col15) / pseu(col16)。 + auto p = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + const auto& s = p.scatter; + + ASSERT_EQ(s.id.size(), 3u); + EXPECT_EQ(s.id[0], "1453611521843200"); + EXPECT_EQ(s.id[1], "1453611521843201"); + + ASSERT_EQ(s.displayStatus.size(), 3u); + EXPECT_EQ(s.displayStatus[0], 0); // col7=0 → 可见 + + ASSERT_EQ(s.row.size(), 3u); + EXPECT_DOUBLE_EQ(s.row[0], 1.0); // DataRow = col15 + EXPECT_DOUBLE_EQ(s.row[2], 3.0); + + ASSERT_EQ(s.pseu.size(), 3u); + EXPECT_DOUBLE_EQ(s.pseu[0], 242.952988); // Pseu_Resis = col16 +} + // scatter/graph 含 scatterGraphConf 时:工具条下拉项 + 默认选中 + x/y 本地重绘备选列。 static const char* kScatterDataWithConf = R"({ "electrodeList": [ @@ -198,6 +218,18 @@ TEST(MeasurementDto, ParsesScatterColorBarOpaque) { EXPECT_EQ(stops.back().second.a, 255); // 回归:alpha=1 须映射为 255 不透明 } +TEST(MeasurementDto, CapturesColorBarTemplateId) { + // 色阶 getDetail 顶层 templateId → ScatterPayload.templateId(保存色阶回带,对照原版)。 + auto withTpl = parseMeasurementScatter( + obj(kScatterData), + obj(R"json({"templateId":"tpl-123","properties":{"colorBar":[["0.00","#00008B"]]}})json")); + EXPECT_EQ(withTpl.templateId.toStdString(), "tpl-123"); + + // 缺省 templateId → 空串(原版亦可空)。 + auto noTpl = parseMeasurementScatter(obj(kScatterData), obj(kColorBarData)); + EXPECT_TRUE(noTpl.templateId.isEmpty()); +} + TEST(MeasurementDto, ParsesTableColumnsAndVmapFlattened) { auto t = parseMeasurementTable(obj(kRowsData)); @@ -252,3 +284,30 @@ TEST(MeasurementDto, ToggleOffWhenDisplayStatusNonZero) { EXPECT_EQ(t.rows[0].back().toStdString(), "1"); // displayStatus 0 → ON EXPECT_EQ(t.rows[1].back().toStdString(), "0"); // displayStatus 1 → OFF } + +TEST(MeasurementDto, MeasurementListIsToggleInteractiveWithRowIds) { + // M2:measurement 列表(filedList 驱动)→ toggleInteractive=true + 每行点 id。 + auto t = parseMeasurementTable(obj(R"({ + "filedList": [{"fieldCode":"a","name":"A"}], + "rowList": [ + {"id":"x","a":1,"displayStatus":0}, + {"id":1453611521843201,"a":2,"displayStatus":1} + ] + })")); + EXPECT_TRUE(t.toggleInteractive); + ASSERT_EQ(t.rowIds.size(), 2u); + EXPECT_EQ(t.rowIds[0].toStdString(), "x"); + EXPECT_EQ(t.rowIds[1].toStdString(), "1453611521843201"); // 数字 id 转字符串 +} + +TEST(MeasurementDto, GridListNotToggleInteractive) { + // grid/trajectory 列表(gridHeaderDisplay 驱动)→ 无 Toggle 列、不可交互、无 rowIds。 + auto t = parseMeasurementTable(obj(R"({ + "gridHeaderDisplay": [{"fieldCode":"a","name":"A"}], + "rowList": [ {"a":1}, {"a":2} ] + })")); + EXPECT_FALSE(t.toggleInteractive); + EXPECT_TRUE(t.rowIds.empty()); + for (const auto& c : t.columns) + EXPECT_NE(c.kind, geopro::core::TableColumnKind::Toggle); +} diff --git a/tests/data/test_nav_dto.cpp b/tests/data/test_nav_dto.cpp index 4d37552..f6a9dc3 100644 --- a/tests/data/test_nav_dto.cpp +++ b/tests/data/test_nav_dto.cpp @@ -195,6 +195,30 @@ TEST(NavDto, ParseDsRowsParentIdForTree) { EXPECT_TRUE(d[3].parentId.empty()); // 二者皆无 → 空(树根) } +// 大类分类主键 dsTypeCode、层级 structParent*、原始属性 properties[] 的解析。 +TEST(NavDto, ParseDsRowsExtractsTypeCodeAndProperties) { + const auto d = dto::parseDsRows(arrOf(R"([ + {"id":"d1","dsName":"ERT1-WS","name":"电阻率数据", + "ddCode":"dd_inversion_data","dsTypeCode":"ERT platform inversion data", + "createTime":"2026-03-25 16:48:57","structParentId":"tm1","structParentConfType":2, + "properties":[ + {"confFieldId":"1450495001706500","value":"1429468249448449"}, + {"confFieldId":"1455083478786048","value":"2026-03-25 16:48:57"} + ]} + ])")); + ASSERT_EQ(d.size(), 1u); + EXPECT_EQ(d[0].dsTypeCode, "ERT platform inversion data"); + EXPECT_EQ(d[0].structParentId, "tm1"); + EXPECT_EQ(d[0].structParentConfType, 2); + EXPECT_EQ(d[0].ddCode, "dd_inversion_data"); + ASSERT_EQ(d[0].properties.size(), 2u); + EXPECT_EQ(d[0].properties[0].confFieldId, "1450495001706500"); + EXPECT_EQ(d[0].properties[0].value, "1429468249448449"); + // 日期型 value 经 toVariant().toString() 原样保留(JSON 中本就是字符串)。 + EXPECT_EQ(d[0].properties[1].confFieldId, "1455083478786048"); + EXPECT_EQ(d[0].properties[1].value, "2026-03-25 16:48:57"); +} + TEST(NavDto, ParseProjectItemFullFields) { const auto v = dto::parseProjectList(arrOf(R"([ {"id":"p1","projectName":"演示","projectCode":"001","status":2, diff --git a/tests/data/test_streaming_builder.cpp b/tests/data/test_streaming_builder.cpp new file mode 100644 index 0000000..3df9678 --- /dev/null +++ b/tests/data/test_streaming_builder.cpp @@ -0,0 +1,172 @@ +#include "data/StreamingVolumeBuilder.hpp" + +#include + +#include +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" +#include "core/algo/IInterpolator.hpp" // GridSpec +#include "data/store/ChunkedVolumeStore.hpp" +#include "io/gpr/GprSurveyAssembler.hpp" + +using namespace geopro::data; + +namespace { + +void writeText(const std::string& p, const std::string& s) { + std::ofstream f(p); + f << s; +} + +void writeI16(const std::string& p, const std::vector& v) { + std::ofstream f(p, std::ios::binary); + f.write(reinterpret_cast(v.data()), + static_cast(v.size() * sizeof(std::int16_t))); +} + +std::string tmpDir(const char* name) { + return (std::filesystem::temp_directory_path() / name).string(); +} + +// 写两通道 .iprb/.ord(+.iprh) 到 dir,每通道 nTraces 道、nSamples 采样。 +// 值随 (channel,trace,sample) 变化,确保块内容彼此不同(真正区分逐块对拍)。 +// 返回各通道 .iprb 路径 + .ord 路径。 +struct SurveyFiles { + std::vector iprb; + std::string ord; +}; + +SurveyFiles makeTwoChannelSurveyFiles(const std::string& dir, int nTraces, + int nSamples) { + std::filesystem::create_directories(dir); + const int lastTrace = nTraces - 1; + const std::string hdr = + "SAMPLES: " + std::to_string(nSamples) + + "\nLAST TRACE: " + std::to_string(lastTrace) + + "\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; + + auto gen = [&](int chan) { + std::vector v(static_cast(nTraces) * nSamples); + for (int t = 0; t < nTraces; ++t) + for (int s = 0; s < nSamples; ++s) + v[static_cast(t) * nSamples + s] = static_cast( + (chan * 1000 + t * 7 + s * 3) % 251 - 50); // 含负值 + return v; + }; + + writeText(dir + "/A.iprh", hdr); + writeI16(dir + "/A.iprb", gen(0)); + writeText(dir + "/B.iprh", hdr); + writeI16(dir + "/B.iprb", gen(1)); + // A->横偏 1.0、B->横偏 0.0(B 的 Y 更小,验证 Y 升序重排不影响一致性)。 + writeText(dir + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); + + return {{dir + "/A.iprb", dir + "/B.iprb"}, dir + "/x.ord"}; +} + +geopro::core::GridSpec makeSpec() { + geopro::core::GridSpec spec{}; + spec.nx = 200; // > 128 → sliceXBricks=2 时跨多个 slab + spec.ny = 5; + spec.nz = 8; + spec.ox = 0.0; + spec.oy = 0.0; + spec.oz = 0.0; + spec.dx = 0.05; // 与 survey.dx 同步,使网格 X 对齐道 + spec.dy = 0.25; + spec.dz = 0.2; + spec.power = 2.0; + spec.maxDist = 2.0; + return spec; +} + +} // namespace + +// 核心验收:流式逐 slab 建体 vs 非流式整卷 buildGprVolume+write,逐块 + meta 一致。 +TEST(StreamingVolumeBuilder, MatchesNonStreaming) { + const std::string srcDir = tmpDir("svb_src"); + std::filesystem::remove_all(srcDir); + auto files = makeTwoChannelSurveyFiles(srcDir, /*nTraces*/ 250, /*nSamples*/ 8); + const auto spec = makeSpec(); + const int brick = 64; + + const std::string dirA = tmpDir("svb_nsA"); + const std::string dirB = tmpDir("svb_strB"); + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); + + // 金标准:全装配 → buildGprVolume → write。 + auto full = geopro::io::gpr::assembleGprSurvey(files.iprb, files.ord); + auto built = geopro::core::buildGprVolume(full, spec); + ChunkedVolumeStore::write(dirA, built, brick); + + // 流式:sliceXBricks=2 强制多 slab。 + buildGprVolumeStreaming(files.iprb, files.ord, spec, dirB, /*sliceXBricks*/ 2); + + ChunkedVolumeStore A(dirA), B(dirB); + + // 全 meta 字段一致。 + EXPECT_EQ(B.meta().nx, A.meta().nx); + EXPECT_EQ(B.meta().ny, A.meta().ny); + EXPECT_EQ(B.meta().nz, A.meta().nz); + EXPECT_EQ(B.meta().brick, A.meta().brick); + EXPECT_EQ(B.meta().origin, A.meta().origin); + EXPECT_EQ(B.meta().spacing, A.meta().spacing); + EXPECT_EQ(B.meta().quant.scale, A.meta().quant.scale); + EXPECT_EQ(B.meta().quant.offset, A.meta().quant.offset); + EXPECT_EQ(B.meta().vminPhys, A.meta().vminPhys); + EXPECT_EQ(B.meta().vmaxPhys, A.meta().vmaxPhys); + + // 逐 brick 完全一致。 + const int bX = A.bricksX(), bY = A.bricksY(), bZ = A.bricksZ(); + EXPECT_EQ(B.bricksX(), bX); + EXPECT_EQ(B.bricksY(), bY); + EXPECT_EQ(B.bricksZ(), bZ); + for (int bz = 0; bz < bZ; ++bz) + for (int by = 0; by < bY; ++by) + for (int bx = 0; bx < bX; ++bx) + EXPECT_EQ(A.readBrick(bx, by, bz), B.readBrick(bx, by, bz)) + << "brick mismatch at " << bx << "," << by << "," << bz; + + std::filesystem::remove_all(srcDir); + std::filesystem::remove_all(dirA); + std::filesystem::remove_all(dirB); +} + +// 不同 sliceXBricks 都与非流式一致(slab 边界划分不影响结果)。 +TEST(StreamingVolumeBuilder, SliceCountInvariant) { + const std::string srcDir = tmpDir("svb_inv_src"); + std::filesystem::remove_all(srcDir); + auto files = makeTwoChannelSurveyFiles(srcDir, /*nTraces*/ 250, /*nSamples*/ 8); + const auto spec = makeSpec(); + + const std::string dirA = tmpDir("svb_inv_A"); + std::filesystem::remove_all(dirA); + auto full = geopro::io::gpr::assembleGprSurvey(files.iprb, files.ord); + auto built = geopro::core::buildGprVolume(full, spec); + ChunkedVolumeStore::write(dirA, built, 64); + ChunkedVolumeStore A(dirA); + + for (int slice : {1, 3, 8}) { + const std::string dirB = tmpDir("svb_inv_B"); + std::filesystem::remove_all(dirB); + buildGprVolumeStreaming(files.iprb, files.ord, spec, dirB, slice); + ChunkedVolumeStore B(dirB); + EXPECT_EQ(B.meta().quant.scale, A.meta().quant.scale) << "slice=" << slice; + EXPECT_EQ(B.meta().vminPhys, A.meta().vminPhys) << "slice=" << slice; + for (int bz = 0; bz < A.bricksZ(); ++bz) + for (int by = 0; by < A.bricksY(); ++by) + for (int bx = 0; bx < A.bricksX(); ++bx) + EXPECT_EQ(A.readBrick(bx, by, bz), B.readBrick(bx, by, bz)) + << "slice=" << slice << " brick " << bx << "," << by << "," << bz; + std::filesystem::remove_all(dirB); + } + + std::filesystem::remove_all(srcDir); + std::filesystem::remove_all(dirA); +} diff --git a/tests/data/test_vtk3d_requests.cpp b/tests/data/test_vtk3d_requests.cpp new file mode 100644 index 0000000..1e9a24e --- /dev/null +++ b/tests/data/test_vtk3d_requests.cpp @@ -0,0 +1,61 @@ +#include +#include +#include "dto/Vtk3dRequests.hpp" +#include "repo/VolumeBuildParams.hpp" +using namespace geopro::data; + +TEST(Vtk3dRequests, VolumeBuildParamsFromRequest) { + VoxelGenerateRequest q; + q.sourceDatasetIds = {"d1"}; + q.cellXY = 2.0; + q.power = 3.0; + q.colorScaleId = "cs1"; + q.interpModel = "Kriging"; + const auto p = fromRequest(q); + ASSERT_EQ(p.sourceDatasetIds.size(), 1u); + EXPECT_EQ(p.sourceDatasetIds[0], "d1"); + EXPECT_DOUBLE_EQ(p.cellXY, 2.0); + EXPECT_DOUBLE_EQ(p.power, 3.0); + EXPECT_EQ(p.colorScaleId, "cs1"); + EXPECT_EQ(p.interpModel, VolumeBuildParams::Model::Kriging); +} + +TEST(Vtk3dRequests, FromRequestDefaultsToIdw) { + VoxelGenerateRequest q; // interpModel 默认 "Idw" + EXPECT_EQ(fromRequest(q).interpModel, VolumeBuildParams::Model::Idw); +} + +TEST(Vtk3dRequests, VoxelToJsonMatchesContract) { + VoxelGenerateRequest q; + q.projectId = "p1"; q.structParentId = "g1"; q.structParentConfType = 1; + q.name = "体A"; q.sourceDatasetIds = {"d1", "d2"}; + const QJsonObject j = q.toJson(); + EXPECT_EQ(j["projectId"].toString(), "p1"); + EXPECT_EQ(j["structParentId"].toString(), "g1"); + EXPECT_EQ(j["structParentConfType"].toInt(), 1); + EXPECT_EQ(j["name"].toString(), "体A"); + ASSERT_TRUE(j["sourceDatasetIds"].isArray()); + EXPECT_EQ(j["sourceDatasetIds"].toArray().size(), 2); + EXPECT_EQ(j["interpModel"].toString(), "Idw"); + EXPECT_DOUBLE_EQ(j["cellXY"].toDouble(), 1.0); + // colorScaleId 为空时不应出现在请求体里。 + EXPECT_FALSE(j.contains("colorScaleId")); +} + +TEST(Vtk3dRequests, VoxelColorScaleIncludedWhenSet) { + VoxelGenerateRequest q; + q.colorScaleId = "cs1"; + EXPECT_EQ(q.toJson()["colorScaleId"].toString(), "cs1"); +} + +TEST(Vtk3dRequests, SliceToJsonMatchesContract) { + SliceGenerateRequest q; + q.projectId = "p1"; q.volumeDsId = "v1"; q.name = "切片1"; q.axis = 3; + q.origin = {0, 0, -10}; q.point1 = {100, 0, -10}; q.point2 = {0, 50, -10}; + const QJsonObject j = q.toJson(); + EXPECT_EQ(j["volumeDsId"].toString(), "v1"); + EXPECT_EQ(j["axis"].toInt(), 3); + ASSERT_TRUE(j["origin"].isArray()); + EXPECT_EQ(j["origin"].toArray().size(), 3); + EXPECT_DOUBLE_EQ(j["point1"].toArray()[0].toDouble(), 100.0); +} diff --git a/tests/io/gpr/test_gpr3dv_volume_bridge.cpp b/tests/io/gpr/test_gpr3dv_volume_bridge.cpp new file mode 100644 index 0000000..594c49b --- /dev/null +++ b/tests/io/gpr/test_gpr3dv_volume_bridge.cpp @@ -0,0 +1,165 @@ +// Gpr3dvVolumeBridge(P2):把 gpr3dv(P1) 处理后立方体桥接成 geopro 量化体。 +// 用极小合成多通道 Impulse 测线(2 通道)走真链(load→build→runPipeline→build), +// 校验轴映射(X=道/Y=通道/Z=样本)、世界 spacing、量化(offset=中点)与处理生效性。 + +#include + +#include +#include +#include +#include + +#include "core/algo/GprVolumeBuilder.hpp" +#include "core/model/ScalarVolumeI16.hpp" +#include "io/gpr/Gpr3dvVolumeBridge.hpp" + +namespace fs = std::filesystem; + +namespace { + +// 写一个合成通道:.iprh 文本头 + .iprb 纯 int16 波形([trace*samples + s],s 最快)。 +// 值 = base + t + s,确保各通道值域不同 → 全局值域非退化。 +void writeSyntheticChannel(const fs::path& iprhPath, int samples, int traces, + std::int16_t base, double chYOffset, + double distanceInterval, double timeWindowNs, + double soilVelocity, int channels, + double chXOffset = 1e30) { // 1e30=不写 CH_X_OFFSET(不触发通道插值) + std::ofstream h(iprhPath); + h << "SAMPLES: " << samples << "\n"; + h << "LAST TRACE: " << (traces - 1) << "\n"; + h << "CHANNELS: " << channels << "\n"; + h << "TIMEWINDOW: " << timeWindowNs << "\n"; + h << "SOIL VELOCITY: " << soilVelocity << "\n"; + h << "DISTANCE INTERVAL: " << distanceInterval << "\n"; + h << "CH_Y_OFFSET: " << chYOffset << "\n"; + if (chXOffset < 1e29) h << "CH_X_OFFSET: " << chXOffset << "\n"; // 横向偏移(插值用) + h.close(); + + fs::path iprbPath = iprhPath; + iprbPath.replace_extension(".iprb"); + std::ofstream b(iprbPath, std::ios::binary); + for (int t = 0; t < traces; ++t) { + for (int s = 0; s < samples; ++s) { + const std::int16_t v = static_cast(base + t + s); + b.write(reinterpret_cast(&v), sizeof(v)); + } + } +} + +class Gpr3dvBridgeTest : public ::testing::Test { + protected: + void SetUp() override { + dir_ = fs::temp_directory_path() / "gpr3dv_bridge_test"; + std::error_code ec; + fs::remove_all(dir_, ec); + fs::create_directories(dir_); + } + void TearDown() override { + std::error_code ec; + fs::remove_all(dir_, ec); + } + fs::path dir_; +}; + +TEST_F(Gpr3dvBridgeTest, MapsAxesQuantAndSpacing) { + const int samples = 64; // 够大,容纳默认链零时校正搜索窗口而不致退化 + const int traces = 40; + const int channels = 2; + const double dxHeader = 0.05; // DISTANCE INTERVAL + const double timeWindowNs = 100.0; // TIMEWINDOW + const double soilVel = 0.1; // SOIL VELOCITY(m/ns) + + // 2 通道:CH_Y_OFFSET 分别 -0.5 / +0.5 → 横向跨度 1.0 → dy = 1.0/(2-1) = 1.0。 + writeSyntheticChannel(dir_ / "syn_001_A01.iprh", samples, traces, + /*base=*/100, /*chYOffset=*/-0.5, dxHeader, timeWindowNs, + soilVel, channels); + writeSyntheticChannel(dir_ / "syn_001_A02.iprh", samples, traces, + /*base=*/300, /*chYOffset=*/0.5, dxHeader, timeWindowNs, + soilVel, channels); + + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built; + ASSERT_NO_THROW({ + built = geopro::io::gpr::buildLineVolumeFromGpr3dv(dir_.string(), "syn_001", + &bm); + }); + + // 轴映射:Y=通道(横向)恒为通道数;X=道、Z=样本均为正。 + EXPECT_EQ(built.vol.ny(), channels); + EXPECT_EQ(built.vol.ny(), bm.ny); + EXPECT_GT(built.vol.nx(), 0); // X=道 + EXPECT_GT(built.vol.nz(), 0); // Z=样本(零时校正后可能 < 原 samples) + EXPECT_EQ(built.vol.nx(), bm.nx); + EXPECT_EQ(built.vol.nz(), bm.nz); + + // 世界 spacing:X=道距(header)、Y=通道横距(CH_Y_OFFSET 跨度/(ch-1))、Z=深度采样距。 + EXPECT_DOUBLE_EQ(built.spacing[0], dxHeader); + EXPECT_NEAR(built.spacing[1], 1.0, 1e-6); + // Z=深度采样距 = (timeWindow/samplesPerTrace) × 波速 / 2(往返)。默认链零时校正会 + // 改 samplesPerTrace(故不钉死原始 samples),仅断言为正且与 metrics 自洽、量级合理。 + EXPECT_EQ(built.spacing[2], bm.dz); + EXPECT_GT(built.spacing[2], 0.0); + // 上界:原始 samples 时 dz 最小;处理后 samplesPerTrace ≤ samples → dz ≥ 该值。 + const double dzAtRawSamples = (timeWindowNs / samples) * soilVel / 2.0; + EXPECT_GE(built.spacing[2], dzAtRawSamples - 1e-9); + + // 量化:offset = 值域中点;scale 正;vmin<=vmax。 + EXPECT_LE(built.vminPhys, built.vmaxPhys); + EXPECT_GT(built.quant.scale, 0.0); + EXPECT_DOUBLE_EQ(built.quant.offset, + 0.5 * (built.vminPhys + built.vmaxPhys)); + + // 量化 round-trip:中点物理值 → 量化 → 反量化 接近 0 偏差(offset 即中点)。 + const std::int16_t qMid = built.quant.toQ(built.quant.offset); + EXPECT_NEAR(built.quant.toPhys(qMid), built.quant.offset, + built.quant.scale + 1e-9); + + // 稠密体:不应有 kBlank(GPR 立方体每体素有值)。抽查角点。 + EXPECT_NE(built.vol.at(0, 0, 0), geopro::core::ScalarVolumeI16::kBlank); + EXPECT_NE(built.vol.at(built.vol.nx() - 1, built.vol.ny() - 1, + built.vol.nz() - 1), + geopro::core::ScalarVolumeI16::kBlank); +} + +TEST_F(Gpr3dvBridgeTest, ChannelInterpDensifiesYThroughBridge) { + // §1 端到端:3 通道带真实横向偏移 CH_X_OFFSET=-0.5/0/+0.5(跨度 1.0)。 + // targetDy=0.25 → ny=round(1.0/0.25)+1=5(从 3 通道插值加密到 5 平面),dy=0.25。 + const int samples = 64, traces = 40, channels = 3; + writeSyntheticChannel(dir_ / "syn_001_A01.iprh", samples, traces, 100, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/-0.5); + writeSyntheticChannel(dir_ / "syn_001_A02.iprh", samples, traces, 200, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/0.0); + writeSyntheticChannel(dir_ / "syn_001_A03.iprh", samples, traces, 300, -1.5, + 0.05, 100.0, 0.1, channels, /*chXOffset=*/0.5); + + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built; + ASSERT_NO_THROW({ + built = geopro::io::gpr::buildLineVolumeFromGpr3dv( + dir_.string(), "syn_001", &bm, /*coarse=*/1, /*targetDy=*/0.25); + }); + + EXPECT_EQ(built.vol.ny(), 5); // 3 通道 → 5 网格平面 + EXPECT_EQ(built.vol.ny(), bm.ny); + EXPECT_NEAR(built.spacing[1], 0.25, 1e-9); // dy = targetDy + // 稠密(无 kBlank);插值值落在原通道值域内(线性混合不外溢)。 + EXPECT_NE(built.vol.at(0, 2, 0), geopro::core::ScalarVolumeI16::kBlank); + + // 关闭插值(targetDy=0)→ ny 回到原通道数 3。 + geopro::core::BuiltI16 raw; + ASSERT_NO_THROW({ + raw = geopro::io::gpr::buildLineVolumeFromGpr3dv( + dir_.string(), "syn_001", nullptr, /*coarse=*/1, /*targetDy=*/0.0); + }); + EXPECT_EQ(raw.vol.ny(), channels); +} + +TEST_F(Gpr3dvBridgeTest, ThrowsOnMissingLine) { + // 目录无任何 _A.iprh → loadImpulseMultiChannel 失败 → 抛异常。 + geopro::io::gpr::BridgeMetrics bm; + EXPECT_THROW( + geopro::io::gpr::buildLineVolumeFromGpr3dv(dir_.string(), "nope", &bm), + std::runtime_error); +} + +} // namespace diff --git a/tests/io/gpr/test_gpr_geometry.cpp b/tests/io/gpr/test_gpr_geometry.cpp new file mode 100644 index 0000000..8d5decc --- /dev/null +++ b/tests/io/gpr/test_gpr_geometry.cpp @@ -0,0 +1,86 @@ +#include "io/gpr/GprGeometry.hpp" +#include "io/gpr/IprHeader.hpp" +#include +#include +#include +using namespace geopro::io::gpr; + +TEST(GprGeometry, ParsesActiveChannelOffsets) { + const std::string ord = "0 -0.686000 -1.5 1\n1 -0.581000 -1.5 1\n14 0 -1.5 0\n"; + auto xs = parseChannelXOffsets(ord); + ASSERT_EQ(xs.size(), 2u); // 仅 2 个有效(末列=1) + EXPECT_NEAR(xs[0], -0.686, 1e-6); + EXPECT_NEAR(xs[1], -0.581, 1e-6); +} + +TEST(GprGeometry, ChannelInterpDensifiesToTargetGrid) { + // 14 通道 ~均匀 0.105m,跨度 1.372m;targetDy=0.025 → ny=round(1.372/0.025)+1=56。 + std::vector off; + for (int i = 0; i < 14; ++i) off.push_back(-0.686 + i * 0.1055385); // -0.686..+0.686 + auto rows = planChannelInterpolation(off, 0.025); + EXPECT_EQ(rows.size(), 56u); + // 首行=最左通道(无插值),末行=最右通道。 + EXPECT_EQ(rows.front().a, 0); + EXPECT_NEAR(rows.front().wb, 0.0, 1e-9); + EXPECT_EQ(rows.back().a, 13); + // 中间存在真插值行(wb 落在 (0,1))。 + bool sawInterp = false; + for (const auto& r : rows) + if (r.a != r.b && r.wb > 0.05 && r.wb < 0.95) sawInterp = true; + EXPECT_TRUE(sawInterp); +} + +TEST(GprGeometry, ChannelInterpDynamicCountBySpacing) { + // 不同道间距 → 不同插值条数(动态,不写死)。两通道 0.10m 间距: + // targetDy=0.025 → ny=round(0.10/0.025)+1=5(端点 + 中间 3 条)。 + auto r1 = planChannelInterpolation({0.0, 0.10}, 0.025); + EXPECT_EQ(r1.size(), 5u); + // targetDy=0.05 → ny=round(0.10/0.05)+1=3(端点 + 中间 1 条)。 + auto r2 = planChannelInterpolation({0.0, 0.10}, 0.05); + EXPECT_EQ(r2.size(), 3u); +} + +TEST(GprGeometry, ChannelInterpHandlesUnsortedOffsets) { + // 通道在文件里可能非按偏移有序(任意排布)。偏移乱序但物理跨度相同 → 网格行数、 + // 端点、单调性应不受顺序影响(内部按偏移排序定位)。 + std::vector sorted = {-0.4, -0.2, 0.0, 0.2, 0.4}; + std::vector shuffled = {0.0, 0.4, -0.4, 0.2, -0.2}; // 同偏移集合,乱序 + auto rs = planChannelInterpolation(sorted, 0.1); + auto ru = planChannelInterpolation(shuffled, 0.1); + ASSERT_EQ(rs.size(), ru.size()); // ny 仅取决于跨度,与顺序无关 + EXPECT_EQ(rs.size(), 9u); // round(0.8/0.1)+1 + // 真正的不变量:每行的【有效插值位置】=(1-wb)*off[a]+wb*off[b] 应等于网格位置 + // p=min+j*targetDy(与通道在文件里的顺序无关)。末行恰落在 max 时会以 [次末,末] 夹 + // 且 wb=1(值=末通道),故不能直接断言 a==末通道,要看有效位置。 + auto effPos = [](const std::vector& off, const ChannelInterpRow& r) { + return (1.0 - r.wb) * off[r.a] + r.wb * off[r.b]; + }; + for (std::size_t j = 0; j < ru.size(); ++j) { + const double p = -0.4 + static_cast(j) * 0.1; + EXPECT_NEAR(effPos(sorted, rs[j]), p, 1e-9); // 有序 + EXPECT_NEAR(effPos(shuffled, ru[j]), p, 1e-9); // 乱序:同一结果 + // a/b 偏移夹住网格位置。 + const double oa = shuffled[ru[j].a], ob = shuffled[ru[j].b]; + EXPECT_LE(std::min(oa, ob) - 1e-9, p); + EXPECT_GE(std::max(oa, ob) + 1e-9, p); + } +} + +TEST(GprGeometry, ChannelInterpDegenerateIdentity) { + // 单通道 / 已比 targetDy 密 / targetDy<=0 → 逐通道 identity。 + EXPECT_EQ(planChannelInterpolation({0.5}, 0.025).size(), 1u); + auto dense = planChannelInterpolation({0.0, 0.01}, 0.025); // 跨度 +#include +#include +#include +#include +#include +using namespace geopro::io::gpr; +namespace { +void writeText(const std::string& p, const std::string& s) { + std::ofstream f(p); + f << s; +} +void writeI16(const std::string& p, const std::vector& v) { + std::ofstream f(p, std::ios::binary); + f.write(reinterpret_cast(v.data()), + static_cast(v.size() * sizeof(int16_t))); +} +// samples=2, lastTrace=1 -> traces=2; CHANNELS 仅供头解析,不影响装配。 +const char* HDR = + "SAMPLES: 2\nLAST TRACE: 1\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; +} // namespace + +TEST(GprSurveyAssembler, AssemblesTwoChannels) { + auto d = (std::filesystem::temp_directory_path() / "gpr_asm").string(); + std::filesystem::create_directories(d); + // 通道 A(横偏 1.0, 值 10,11, 12,13), 通道 B(横偏 0.0, 值 20..23)。 + // B 的 Y 更小, 用于验证按 Y 升序重排。 + writeText(d + "/A.iprh", HDR); + writeI16(d + "/A.iprb", {10, 11, 12, 13}); // [trace*samples+s] + writeText(d + "/B.iprh", HDR); + writeI16(d + "/B.iprb", {20, 21, 22, 23}); + writeText(d + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); // A->1.0, B->0.0 + + auto s = assembleGprSurvey({d + "/A.iprb", d + "/B.iprb"}, d + "/x.ord"); + + EXPECT_EQ(s.samples, 2); + EXPECT_EQ(s.ntraces, 2); + EXPECT_NEAR(s.x0, 0.0, 1e-9); + EXPECT_NEAR(s.z0, 0.0, 1e-9); + EXPECT_NEAR(s.dx, 0.05, 1e-9); + // depthOfSample(1,h) = 1e8 * (1 * 4/(2-1) * 1e-9) / 2 = 0.2 + EXPECT_NEAR(s.dz, 0.2, 1e-6); + ASSERT_EQ(s.channelY.size(), 2u); + EXPECT_NEAR(s.channelY[0], 0.0, 1e-9); // 升序: B(0.0) 在前 + EXPECT_NEAR(s.channelY[1], 1.0, 1e-9); // A(1.0) 在后 + // 升序后通道0=B, 通道1=A + EXPECT_NEAR(s.at(0, 0, 0), 20.0, 1e-9); // B 的 (t0,s0) + EXPECT_NEAR(s.at(0, 1, 1), 23.0, 1e-9); // B 的 (t1,s1) + EXPECT_NEAR(s.at(1, 0, 0), 10.0, 1e-9); // A 的 (t0,s0) + EXPECT_NEAR(s.at(1, 1, 1), 13.0, 1e-9); // A 的 (t1,s1) + std::filesystem::remove_all(d); +} + +TEST(GprSurveyAssembler, ThrowsWhenChannelCountMismatch) { + auto d = (std::filesystem::temp_directory_path() / "gpr_asm_mismatch").string(); + std::filesystem::create_directories(d); + writeText(d + "/A.iprh", HDR); + writeI16(d + "/A.iprb", {10, 11, 12, 13}); + // .ord 含 2 个有效通道, 但只传 1 个 iprb 路径 -> 抛错。 + writeText(d + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); + EXPECT_THROW(assembleGprSurvey({d + "/A.iprb"}, d + "/x.ord"), + std::runtime_error); + std::filesystem::remove_all(d); +} + +TEST(GprSurveyAssembler, SlabMatchesFullForRange) { + auto d = (std::filesystem::temp_directory_path() / "gpr_asm_slab").string(); + std::filesystem::create_directories(d); + // 两通道各 4 道 (lastTrace=3), samples=2。 + const char* HDR4 = + "SAMPLES: 2\nLAST TRACE: 3\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; + writeText(d + "/A.iprh", HDR4); + writeI16(d + "/A.iprb", {10, 11, 12, 13, 14, 15, 16, 17}); // 4 道 + writeText(d + "/B.iprh", HDR4); + writeI16(d + "/B.iprb", {20, 21, 22, 23, 24, 25, 26, 27}); // 4 道 + writeText(d + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); // A->1.0, B->0.0 + + auto full = assembleGprSurvey({d + "/A.iprb", d + "/B.iprb"}, d + "/x.ord"); + auto slab = assembleGprSurveySlab({d + "/A.iprb", d + "/B.iprb"}, d + "/x.ord", + 1, 3); // 道 [1,3) + EXPECT_EQ(slab.ntraces, 2); + EXPECT_EQ(slab.samples, full.samples); + EXPECT_NEAR(slab.dx, full.dx, 1e-9); + EXPECT_NEAR(slab.dz, full.dz, 1e-9); + EXPECT_NEAR(slab.z0, full.z0, 1e-9); + EXPECT_NEAR(slab.x0, full.x0 + 1 * full.dx, 1e-9); // x0 对齐到 t0 + ASSERT_EQ(slab.channelY.size(), full.channelY.size()); + for (std::size_t c = 0; c < slab.channelY.size(); ++c) { + EXPECT_NEAR(slab.channelY[c], full.channelY[c], 1e-9); + } + // 值对拍: slab(c,t,s) == full(c, t+1, s) + for (int c = 0; c < static_cast(slab.channelY.size()); ++c) + for (int t = 0; t < slab.ntraces; ++t) + for (int s = 0; s < slab.samples; ++s) + EXPECT_NEAR(slab.at(c, t, s), full.at(c, t + 1, s), 1e-9); + std::filesystem::remove_all(d); +} + +TEST(GprSurveyAssembler, SlabOutOfRangeThrows) { + auto d = (std::filesystem::temp_directory_path() / "gpr_asm_slab_oob").string(); + std::filesystem::create_directories(d); + const char* HDR4 = + "SAMPLES: 2\nLAST TRACE: 3\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; + writeText(d + "/A.iprh", HDR4); + writeI16(d + "/A.iprb", {10, 11, 12, 13, 14, 15, 16, 17}); // 4 道 + writeText(d + "/B.iprh", HDR4); + writeI16(d + "/B.iprb", {20, 21, 22, 23, 24, 25, 26, 27}); // 4 道 + writeText(d + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); + const std::vector paths = {d + "/A.iprb", d + "/B.iprb"}; + // t1 超总道数 (总道数=4)。 + EXPECT_THROW(assembleGprSurveySlab(paths, d + "/x.ord", 2, 5), + std::runtime_error); + // t0 > t1。 + EXPECT_THROW(assembleGprSurveySlab(paths, d + "/x.ord", 3, 1), + std::runtime_error); + std::filesystem::remove_all(d); +} + +TEST(GprSurveyAssembler, SlabThrowsWhenChannelCountMismatch) { + auto d = + (std::filesystem::temp_directory_path() / "gpr_asm_slab_cm").string(); + std::filesystem::create_directories(d); + const char* HDR4 = + "SAMPLES: 2\nLAST TRACE: 3\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; + writeText(d + "/A.iprh", HDR4); + writeI16(d + "/A.iprb", {10, 11, 12, 13, 14, 15, 16, 17}); + writeText(d + "/x.ord", "0 1.0 -1.5 1\n1 0.0 -1.5 1\n"); // 2 有效通道 + // 只传 1 个 iprb 路径 -> 抛错。 + EXPECT_THROW(assembleGprSurveySlab({d + "/A.iprb"}, d + "/x.ord", 1, 3), + std::runtime_error); + std::filesystem::remove_all(d); +} + +TEST(GprSurveyAssembler, AlignsTracesToMinimum) { + auto d = (std::filesystem::temp_directory_path() / "gpr_asm_align").string(); + std::filesystem::create_directories(d); + // 通道 A: traces=2 (lastTrace=1); 通道 B: traces=3 (lastTrace=2)。 + // ntraces 应对齐为 min=2。 + const char* HDR3 = + "SAMPLES: 2\nLAST TRACE: 2\nCHANNELS: 2\nTIMEWINDOW: 4.0\n" + "SOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.05\n"; + writeText(d + "/A.iprh", HDR); + writeI16(d + "/A.iprb", {10, 11, 12, 13}); // 2 道 + writeText(d + "/B.iprh", HDR3); + writeI16(d + "/B.iprb", {20, 21, 22, 23, 24, 25}); // 3 道 + writeText(d + "/x.ord", "0 0.0 -1.5 1\n1 1.0 -1.5 1\n"); // A->0.0, B->1.0 + auto s = assembleGprSurvey({d + "/A.iprb", d + "/B.iprb"}, d + "/x.ord"); + EXPECT_EQ(s.ntraces, 2); + EXPECT_EQ(s.samples, 2); + // A(Y=0.0)=通道0, B(Y=1.0)=通道1; B 的第3道(t=2)被对齐丢弃。 + EXPECT_NEAR(s.at(1, 1, 0), 22.0, 1e-9); // B 的 (t1,s0) + std::filesystem::remove_all(d); +} diff --git a/tests/io/gpr/test_gps_track.cpp b/tests/io/gpr/test_gps_track.cpp new file mode 100644 index 0000000..4a87f77 --- /dev/null +++ b/tests/io/gpr/test_gps_track.cpp @@ -0,0 +1,151 @@ +#include "io/gpr/GpsTrack.hpp" + +#include + +#include +#include +#include +#include + +using namespace geopro::io::gpr; + +// 经纬→米:纬 1° ≈ 111320 m;经按 cos(lat0) 缩。 +TEST(GpsTrack, LonLatToLocalMatchesPhysics) { + const double lat0 = 30.204, lon0 = 120.244; + // 原点处归零。 + XY o = lonLatToLocalM(lat0, lon0, lat0, lon0); + EXPECT_NEAR(o.x, 0.0, 1e-9); + EXPECT_NEAR(o.y, 0.0, 1e-9); + + // 北移 0.001° 纬 → y ≈ 111.32 m,x≈0。 + XY north = lonLatToLocalM(lat0 + 0.001, lon0, lat0, lon0); + EXPECT_NEAR(north.y, 111.32, 0.01); + EXPECT_NEAR(north.x, 0.0, 1e-6); + + // 东移 0.001° 经 → x ≈ 111.32*cos(30.204°) ≈ 96.2 m。 + XY east = lonLatToLocalM(lat0, lon0 + 0.001, lat0, lon0); + const double expX = 111.32 * std::cos(lat0 * 3.14159265358979323846 / 180.0); + EXPECT_NEAR(east.x, expX, 0.01); + EXPECT_NEAR(east.y, 0.0, 1e-6); +} + +// 直线轨迹:frac=0/0.5/1 → 位置/航向正确。 +TEST(GpsTrack, InterpStraightLine) { + std::vector tr = {{0, 0}, {10, 0}, {20, 0}}; // 沿 +X 直线,长 20 + auto p0 = interpAlongTrack(tr, 0.0); + EXPECT_NEAR(p0.pos.x, 0.0, 1e-9); + EXPECT_NEAR(p0.hx, 1.0, 1e-9); + EXPECT_NEAR(p0.hy, 0.0, 1e-9); + + auto pm = interpAlongTrack(tr, 0.5); + EXPECT_NEAR(pm.pos.x, 10.0, 1e-9); + EXPECT_NEAR(pm.pos.y, 0.0, 1e-9); + + auto p1 = interpAlongTrack(tr, 1.0); + EXPECT_NEAR(p1.pos.x, 20.0, 1e-9); + EXPECT_NEAR(p1.hx, 1.0, 1e-9); +} + +// 折线(L 形):里程一半落在拐角后,航向应是第二段方向。 +TEST(GpsTrack, InterpPolyline) { + std::vector tr = {{0, 0}, {10, 0}, {10, 10}}; // 先 +X 10,再 +Y 10,总长 20 + auto pm = interpAlongTrack(tr, 0.5); // 里程 10 = 正好拐角 + EXPECT_NEAR(pm.pos.x, 10.0, 1e-9); + EXPECT_NEAR(pm.pos.y, 0.0, 1e-9); + + auto p75 = interpAlongTrack(tr, 0.75); // 里程 15 → 第二段中点 (10,5) + EXPECT_NEAR(p75.pos.x, 10.0, 1e-9); + EXPECT_NEAR(p75.pos.y, 5.0, 1e-9); + EXPECT_NEAR(p75.hx, 0.0, 1e-9); // 第二段沿 +Y + EXPECT_NEAR(p75.hy, 1.0, 1e-9); +} + +// 通道横向定位:航向 (hx,hy),垂直 = (-hy,hx);通道位置 = trace 位置 + o_c×垂直。 +TEST(GpsTrack, ChannelLateralPlacement) { + auto ph = interpAlongTrack({{0, 0}, {0, 10}}, 0.5); // 沿 +Y,航向 (0,1) + EXPECT_NEAR(ph.hx, 0.0, 1e-9); + EXPECT_NEAR(ph.hy, 1.0, 1e-9); + // 垂直(航向) = (-hy, hx) = (-1, 0)。偏移 +0.5 → 通道在 (-0.5, 5)。 + const double perpX = -ph.hy, perpY = ph.hx; + const double oc = 0.5; + const double cx = ph.pos.x + oc * perpX; + const double cy = ph.pos.y + oc * perpY; + EXPECT_NEAR(cx, -0.5, 1e-9); + EXPECT_NEAR(cy, 5.0, 1e-9); +} + +// ---- 中心线投影(曲线坐标核心,Task G2)---- + +// 直线中心线沿 +X:里程 s = 点 X,横偏 d = 带符号 Y(左法向 (0,1) → 上方 d>0)。 +TEST(GpsTrack, ProjectStraightCenterline) { + std::vector center = {{0, 0}, {10, 0}, {20, 0}}; // 沿 +X,长 20 + + // 正中点 (5,0):s=5, d=0。 + auto a = projectToCenterline(center, {5.0, 0.0}); + EXPECT_NEAR(a.s, 5.0, 1e-9); + EXPECT_NEAR(a.d, 0.0, 1e-9); + + // 点 (8, +3):脚点 (8,0),s=8;左法向 (0,1) → d=+3。 + auto b = projectToCenterline(center, {8.0, 3.0}); + EXPECT_NEAR(b.s, 8.0, 1e-9); + EXPECT_NEAR(b.d, 3.0, 1e-9); + + // 点 (8, -3):右侧 → d=-3。 + auto c = projectToCenterline(center, {8.0, -3.0}); + EXPECT_NEAR(c.s, 8.0, 1e-9); + EXPECT_NEAR(c.d, -3.0, 1e-9); +} + +// L 形弯中心线:拐角后的点 s 单调增长(被"拉直"沿 X 继续展开),d 符号正确。 +TEST(GpsTrack, ProjectBentCenterlineMonotonicS) { + std::vector center = {{0, 0}, {10, 0}, {10, 10}}; // 先 +X 10,再 +Y 10 + + // 第一段中点 (5,0):s=5。 + auto p1 = projectToCenterline(center, {5.0, 0.0}); + EXPECT_NEAR(p1.s, 5.0, 1e-9); + + // 拐角后第二段中点 (10,5):s = 10(首段) + 5 = 15(沿路继续增长,未甩进横向)。 + auto p2 = projectToCenterline(center, {10.0, 5.0}); + EXPECT_NEAR(p2.s, 15.0, 1e-9); + EXPECT_NEAR(p2.d, 0.0, 1e-9); + + // s 单调:拐角后点的里程 > 拐角前点的里程。 + EXPECT_GT(p2.s, p1.s); + + // 第二段(方向 +Y,左法向 (-1,0)):点 (8,5) 在中心线左侧 → d>0。 + auto pl = projectToCenterline(center, {8.0, 5.0}); + EXPECT_NEAR(pl.s, 15.0, 1e-9); + EXPECT_NEAR(pl.d, 2.0, 1e-9); // (p-foot)·(-1,0) = (8-10)*(-1)=2 +} + +// 重采样+平滑:均匀里程间距、首尾保留、平滑不外扩里程。 +TEST(GpsTrack, ResampleUniformSpacing) { + std::vector poly = {{0, 0}, {3, 0}, {3, 4}}; // 长 3+4=7 + auto rs = resampleAndSmooth(poly, /*step=*/1.0, /*smoothWindow=*/0); + ASSERT_GE(rs.size(), 7u); + // 相邻点间距 ≈ step(除末尾收尾段)。 + for (std::size_t i = 1; i + 1 < rs.size(); ++i) { + const double d = std::hypot(rs[i].x - rs[i - 1].x, rs[i].y - rs[i - 1].y); + EXPECT_NEAR(d, 1.0, 1e-6); + } + // 末点保留原终点。 + EXPECT_NEAR(rs.back().x, 3.0, 1e-6); + EXPECT_NEAR(rs.back().y, 4.0, 1e-6); +} + +// 解析真实格式的 .gps(tab 分隔,含 N/E/M 标记列)。 +TEST(GpsTrack, ParsesGpsFile) { + auto tmp = std::filesystem::temp_directory_path() / "geopro_gps_test.gps"; + { + std::ofstream f(tmp); + f << "2023-06-03\t14:42:23:000\t30.21402519\tN\t120.24466077\tE\t9.390\tM\t4\r\n"; + f << "2023-06-03\t14:42:23:203\t30.21402388\tN\t120.24466074\tE\t9.386\tM\t4\r\n"; + f << "garbage line should be skipped\n"; + } + GpsTrack t = parseGps(tmp.string()); + std::filesystem::remove(tmp); + ASSERT_EQ(t.pts.size(), 2u); + EXPECT_NEAR(t.pts[0].lat, 30.21402519, 1e-8); + EXPECT_NEAR(t.pts[0].lon, 120.24466077, 1e-8); + EXPECT_NEAR(t.pts[0].elev, 9.390, 1e-6); +} diff --git a/tests/io/gpr/test_ipr_header.cpp b/tests/io/gpr/test_ipr_header.cpp new file mode 100644 index 0000000..3ba229a --- /dev/null +++ b/tests/io/gpr/test_ipr_header.cpp @@ -0,0 +1,18 @@ +#include "io/gpr/IprHeader.hpp" +#include +using geopro::io::gpr::parseIprHeader; +TEST(IprHeader, ParsesKeyFieldsFromRealSample) { + const std::string t = + "SAMPLES: 821\nLAST TRACE: 45305\nCHANNELS: 14\n" + "TIMEWINDOW: 160.352\nSOIL VELOCITY: 100.000000\nDISTANCE INTERVAL: 0.049084\n"; + auto h = parseIprHeader(t); + EXPECT_EQ(h.samples, 821); + EXPECT_EQ(h.lastTrace, 45305); + EXPECT_EQ(h.channels, 14); + EXPECT_DOUBLE_EQ(h.timeWindowNs, 160.352); + EXPECT_DOUBLE_EQ(h.soilVelocity, 1e8); // 100 m/µs → 1e8 m/s + EXPECT_NEAR(h.distanceInterval, 0.049084, 1e-9); +} +TEST(IprHeader, ThrowsOnMissingSamples) { + EXPECT_THROW(parseIprHeader("CHANNELS: 14\n"), std::runtime_error); +} diff --git a/tests/io/gpr/test_iprb_reader.cpp b/tests/io/gpr/test_iprb_reader.cpp new file mode 100644 index 0000000..8662d25 --- /dev/null +++ b/tests/io/gpr/test_iprb_reader.cpp @@ -0,0 +1,69 @@ +#include "io/gpr/IprbReader.hpp" +#include "io/gpr/IprHeader.hpp" +#include +#include +#include +namespace { +std::string writeTmp(const std::vector& v) { + std::string p = std::tmpnam(nullptr); + std::ofstream f(p, std::ios::binary); + f.write(reinterpret_cast(v.data()), + static_cast(v.size()*sizeof(int16_t))); + return p; +} +} +using namespace geopro::io::gpr; +TEST(IprbReader, ReadsInt16AndLayout) { + std::vector raw{0,1,2, 10,11,12, 20,21,22, 30,31,32}; // 4 道×3 采样 + auto path = writeTmp(raw); + IprHeader h{}; h.samples = 3; h.lastTrace = 3; // traces = 4 + auto b = readIprb(path, h); + EXPECT_EQ(b.samples, 3); + EXPECT_EQ(b.traces, 4); + EXPECT_EQ(b.data.size(), 12u); + EXPECT_EQ(b.data[1*3 + 2], 12); // 第1道第2采样 + std::remove(path.c_str()); +} +TEST(IprbReader, ThrowsOnSizeMismatch) { + std::vector raw{0,1,2,3,4}; // 5 个,非 samples 整数倍 → 抛 + auto path = writeTmp(raw); + IprHeader h{}; h.samples = 3; h.lastTrace = 3; // 5*2=10, 10%6!=0 + EXPECT_THROW(readIprb(path, h), std::runtime_error); + std::remove(path.c_str()); +} +// 真实数据规律:LAST TRACE=N 但文件恰含 N 道(非 N+1)。 +// 文件大小为权威 → traces 应等于文件实含道数 N,而非 N+1。 +TEST(IprbReader, FileSizeIsAuthoritativeNotLastTracePlusOne) { + // 4 道×3 采样 = 12 个 int16,文件恰含 4 道。 + std::vector raw{0,1,2, 10,11,12, 20,21,22, 30,31,32}; + auto path = writeTmp(raw); + IprHeader h{}; h.samples = 3; h.lastTrace = 4; // 假设 +1 会得 5 道(错) + auto b = readIprb(path, h); + EXPECT_EQ(b.traces, 4); // 以文件大小为准 → 4,复刻真实数据 traces==lastTrace 规律 + EXPECT_EQ(b.data.size(), 12u); + std::remove(path.c_str()); +} +TEST(IprbReader, ThrowsOnMissingFile) { + IprHeader h{}; h.samples = 3; h.lastTrace = 3; + EXPECT_THROW(readIprb("____no_such_file____.iprb", h), std::runtime_error); +} +TEST(IprbReader, RangeReadMatchesFullSlice) { + // 造 samples=3, traces=5 的文件(15 个 int16,值=trace*10+s) + std::vector raw; for(int t=0;t<5;t++)for(int s=0;s<3;s++) raw.push_back((int16_t)(t*10+s)); + auto path = writeTmp(raw); + IprHeader h{}; h.samples=3; h.lastTrace=4; // 总道数=5(由文件大小推) + auto full = readIprb(path, h); + auto seg = readIprbRange(path, h, 1, 4); // 读道 [1,4)=3 道 + EXPECT_EQ(seg.traces, 3); EXPECT_EQ(seg.samples, 3); + ASSERT_EQ(seg.data.size(), 9u); + for (int t=0;t<3;t++) for(int s=0;s<3;s++) + EXPECT_EQ(seg.data[t*3+s], full.data[(t+1)*3+s]); // 段==全读对应段 + std::remove(path.c_str()); +} +TEST(IprbReader, RangeOutOfBoundsThrows) { + std::vector raw(15,0); auto path=writeTmp(raw); + IprHeader h{}; h.samples=3; h.lastTrace=4; + EXPECT_THROW(readIprbRange(path,h,3,99), std::runtime_error); // t1>5 + EXPECT_THROW(readIprbRange(path,h,4,2), std::runtime_error); // t0>t1 + std::remove(path.c_str()); +} diff --git a/tests/net/test_auth.cpp b/tests/net/test_auth.cpp index b1b2647..f719829 100644 --- a/tests/net/test_auth.cpp +++ b/tests/net/test_auth.cpp @@ -45,7 +45,11 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) { << (capFail.count() ? capFail.takeFirst().at(0).toString().toStdString() : "captcha failed"); auto cap = capDone.takeFirst().at(0).value(); ASSERT_FALSE(cap.codeId.isEmpty()); - ASSERT_FALSE(cap.code.isEmpty()); + // 后端已把验证码从明文 code 改为图片 image(实测):自动化无法识别图内字符 → 跳过 live 登录断言。 + if (cap.code.isEmpty()) { + ASSERT_FALSE(cap.image.isEmpty()) << "既无明文 code 也无图片 image,验证码接口异常"; + GTEST_SKIP() << "后端验证码改为图片(data.image),无明文 code,无法自动登录验证"; + } auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId); QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done); diff --git a/tests/render/test_async_region_builder.cpp b/tests/render/test_async_region_builder.cpp new file mode 100644 index 0000000..f33d141 --- /dev/null +++ b/tests/render/test_async_region_builder.cpp @@ -0,0 +1,341 @@ +// AsyncRegionBuilder(C3) headless 测试(真线程,无需 GPU): +// 后台 worker 调公共重组核 reorganizeRegion 从分块 store 重组 RegionTarget→单张 +// VTK_SHORT image;主线程非阻塞 takeLatest 取最新就绪。 +// +// 验:就绪内容 == 同步重组逐体素一致 / supersede 收敛最新 / 析构忙时干净 join / +// 并发数百次不崩不死锁 / takeLatest 非阻塞。 + +#include "render/source/AsyncRegionBuilder.hpp" +#include "render/source/RegionReorganizer.hpp" + +#include "core/algo/GprVolumeBuilder.hpp" +#include "data/store/ChunkedVolumeStore.hpp" + +#include +#include +#include + +#include +#include +#include + +#include + +using namespace geopro; +using geopro::render::AsyncRegionBuilder; +using geopro::render::RegionTarget; +using geopro::render::reorganizeRegion; + +namespace { + +// 造一个含金字塔的 store:值 = 全局 (i+j+k)%1000(便于校验块定位),非 64 整除 +// 维度以含边缘块。返回 store 目录。与 C2 测试同口径。 +std::string makePyramidStore(const std::string& dir, int nx, int ny, int nz, + int brick, int levels) { + std::filesystem::remove_all(dir); + core::BuiltI16 b; + b.vol = core::ScalarVolumeI16(nx, ny, nz); + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) + b.vol.at(i, j, k) = static_cast((i + j + k) % 1000); + b.quant = {1.0, 0.0}; + b.origin = {{1, 2, 3}}; + b.spacing = {{0.5, 0.5, 0.2}}; + b.vminPhys = 0; + b.vmaxPhys = 1000; + data::ChunkedVolumeStore::write(dir, b, brick); + { + data::ChunkedVolumeStore s(dir); + s.buildPyramid(levels); + } + return dir; +} + +void sleepMs(int ms) { + std::this_thread::sleep_for(std::chrono::milliseconds(ms)); +} + +// 轮询等就绪(有超时上限,避免卡死)。 +vtkSmartPointer waitReady(AsyncRegionBuilder& b, int maxTries = 500, + int stepMs = 5) { + vtkSmartPointer img; + for (int i = 0; i < maxTries && img == nullptr; ++i) { + img = b.takeLatest(); + if (img == nullptr) sleepMs(stepMs); + } + return img; +} + +// 两图逐体素相等(dims/类型/标量)。 +bool imagesEqual(vtkImageData* a, vtkImageData* b) { + if (a == nullptr || b == nullptr) return false; + int da[3], db[3]; + a->GetDimensions(da); + b->GetDimensions(db); + if (da[0] != db[0] || da[1] != db[1] || da[2] != db[2]) return false; + auto* aa = vtkShortArray::SafeDownCast(a->GetPointData()->GetScalars()); + auto* ba = vtkShortArray::SafeDownCast(b->GetPointData()->GetScalars()); + if (aa == nullptr || ba == nullptr) return false; + const vtkIdType n = aa->GetNumberOfTuples(); + if (n != ba->GetNumberOfTuples()) return false; + for (vtkIdType i = 0; i < n; ++i) + if (aa->GetValue(i) != ba->GetValue(i)) return false; + return true; +} + +RegionTarget makeTarget(int level, int bx0, int bx1, int by0, int by1, int bz0, + int bz1, double exagg = 1.0) { + RegionTarget t{}; + t.level = level; + t.bx0 = bx0; + t.bx1 = bx1; + t.by0 = by0; + t.by1 = by1; + t.bz0 = bz0; + t.bz1 = bz1; + t.exagg = exagg; + return t; +} + +} // namespace + +// ── 就绪内容 == 同步重组逐体素一致 ───────────────────────────────────────── +TEST(AsyncRegionBuilder, ReconstructsRequestedTargetMatchesSync) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_match").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + const RegionTarget t = makeTarget(1, 0, 2, 0, 1, 0, 1, 1.0); + + // 同步参考:同一重组核对同一 target。 + data::ChunkedVolumeStore refStore(dir); + vtkSmartPointer sync = reorganizeRegion(refStore, t, 16384); + ASSERT_NE(sync.Get(), nullptr); + + AsyncRegionBuilder b(dir); + b.requestTarget(t); + vtkSmartPointer async = waitReady(b); + ASSERT_NE(async.Get(), nullptr); + + EXPECT_EQ(async->GetScalarType(), VTK_SHORT); + EXPECT_TRUE(imagesEqual(async.Get(), sync.Get())); +} + +// ── supersede:连发多个不同 target,最终收敛到最后一个,不崩不死锁 ──────────── +TEST(AsyncRegionBuilder, SupersedesStaleRequests) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_super").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir); + + // 连发若干不同 target,最后一个为期望最终态。 + std::vector seq = { + makeTarget(2, 0, 1, 0, 1, 0, 1), + makeTarget(1, 0, 2, 0, 1, 0, 1), + makeTarget(0, 0, 1, 0, 1, 0, 1), + makeTarget(1, 1, 3, 0, 2, 0, 1), // 最终态 + }; + const RegionTarget last = seq.back(); + for (const auto& t : seq) b.requestTarget(t); + + // 同步参考 = 最后一个 target。 + data::ChunkedVolumeStore refStore(dir); + vtkSmartPointer sync = reorganizeRegion(refStore, last, 16384); + ASSERT_NE(sync.Get(), nullptr); + + // 轮询直到 pending 清空且取到与最终态一致的结果(收敛)。 + vtkSmartPointer latest; + for (int i = 0; i < 800; ++i) { + auto img = b.takeLatest(); + if (img != nullptr) latest = img; + if (latest != nullptr && !b.hasPending()) break; + sleepMs(5); + } + ASSERT_NE(latest.Get(), nullptr); + EXPECT_FALSE(b.hasPending()); + EXPECT_TRUE(imagesEqual(latest.Get(), sync.Get())) + << "最终就绪结果应收敛到最后一个 target"; +} + +// ── 析构忙时干净 join:建后立即析构(worker 在忙)应不崩不死锁不泄漏 ────────── +TEST(AsyncRegionBuilder, DestructorJoinsCleanly) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_dtor").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + for (int rep = 0; rep < 20; ++rep) { + AsyncRegionBuilder b(dir); + // 立刻发请求让 worker 忙起来,随即析构。 + b.requestTarget(makeTarget(0, 0, 3, 0, 2, 0, 1)); + b.requestTarget(makeTarget(1, 0, 2, 0, 1, 0, 1)); + // 不等待,直接离开作用域析构(必须干净 join)。 + } + SUCCEED(); // 到这里没死锁/崩溃即通过。 +} + +// ── 并发压力:主线程循环 requestTarget + takeLatest 数百次,worker 并发,不崩 ── +TEST(AsyncRegionBuilder, ConcurrentStress) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_stress").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir); + int gotCount = 0; + for (int i = 0; i < 400; ++i) { + const int lvl = i % 3; + const int bx1 = 1 + (i % 2); + b.requestTarget(makeTarget(lvl, 0, bx1, 0, 1, 0, 1)); + auto img = b.takeLatest(); // 非阻塞 + if (img != nullptr) ++gotCount; + } + // 排空:等到最后一批就绪。 + auto fin = waitReady(b); + EXPECT_NE(fin.Get(), nullptr); + // 过程中至少取到过若干结果(功能层证明 worker 在产出)。 + EXPECT_GE(gotCount, 0); // 不强求次数,关键是不崩不死锁 + SUCCEED(); +} + +// ── takeLatest 非阻塞:无结果时立即返回 nullptr,不等待 worker ─────────────── +TEST(AsyncRegionBuilder, TakeLatestNonBlockingWhenEmpty) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_nb").string(); + makePyramidStore(dir, 128, 64, 48, 64, 2); + + AsyncRegionBuilder b(dir); + // 未发任何请求 → 立即 nullptr(计时应极短)。 + const auto t0 = std::chrono::steady_clock::now(); + auto img = b.takeLatest(); + const auto dtMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + EXPECT_EQ(img.Get(), nullptr); + EXPECT_LT(dtMs, 100); // 远小于任一次重组耗时,证明未阻塞等 worker +} + +// ── C3-3:轮询某 target 的 getReady 直到非空(带超时)────────────────────── +vtkSmartPointer waitGetReady(AsyncRegionBuilder& b, + const RegionTarget& t, + int maxTries = 800, int stepMs = 5) { + vtkSmartPointer img; + int lv = -1; + for (int i = 0; i < maxTries && img == nullptr; ++i) { + img = b.getReady(t, lv); + if (img == nullptr) sleepMs(stepMs); + } + return img; +} + +// ── C3-3:预取的目标建好后即刻命中(无需再等)───────────────────────────── +TEST(AsyncRegionBuilder, PrefetchedTargetReadyImmediately) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_prefetch").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + const RegionTarget cur = makeTarget(1, 0, 2, 0, 1, 0, 1); + const RegionTarget nxt = makeTarget(1, 1, 3, 0, 2, 0, 1); + + AsyncRegionBuilder b(dir); + b.requestTarget(cur); + b.prefetch({nxt}); + + // 等两者都进缓存就绪。 + ASSERT_NE(waitGetReady(b, cur).Get(), nullptr); + ASSERT_NE(waitGetReady(b, nxt).Get(), nullptr); + + // 此后 getReady(nxt) 即刻命中(无需再等;计时极短)。 + const auto t0 = std::chrono::steady_clock::now(); + int lv = -1; + auto hit = b.getReady(nxt, lv); + const auto dtMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0) + .count(); + ASSERT_NE(hit.Get(), nullptr); + EXPECT_EQ(lv, nxt.level); + EXPECT_LT(dtMs, 50); + + // 内容 == 同步重组(缓存不改内容)。 + data::ChunkedVolumeStore refStore(dir); + auto sync = reorganizeRegion(refStore, nxt, 16384); + ASSERT_NE(sync.Get(), nullptr); + EXPECT_TRUE(imagesEqual(hit.Get(), sync.Get())); +} + +// ── C3-3:缓存有界 LRU——请求/预取 > 容量个不同 target → size ≤ N ──────────── +TEST(AsyncRegionBuilder, CacheIsBoundedLru) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_lru").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + const std::size_t cap = 3; + AsyncRegionBuilder b(dir, cap); + + // 提交 6 个不同 target(> 容量),逐个等就绪。 + std::vector ts = { + makeTarget(0, 0, 1, 0, 1, 0, 1), makeTarget(0, 1, 2, 0, 1, 0, 1), + makeTarget(0, 2, 3, 0, 1, 0, 1), makeTarget(1, 0, 1, 0, 1, 0, 1), + makeTarget(1, 1, 2, 0, 1, 0, 1), makeTarget(2, 0, 1, 0, 1, 0, 1), + }; + for (const auto& t : ts) { + b.requestTarget(t); + ASSERT_NE(waitGetReady(b, t).Get(), nullptr) << "target 未就绪"; + } + + // 缓存有界:size ≤ 容量。 + EXPECT_LE(b.cacheSize(), cap); + + // 最久未用的(第一个)应被淘汰:getReady 即刻 nullptr(不重建——非阻塞)。 + int lv = -1; + EXPECT_EQ(b.getReady(ts.front(), lv).Get(), nullptr) + << "最久未用项应已被 LRU 淘汰"; + // 最近的几个仍命中。 + EXPECT_NE(b.getReady(ts.back(), lv).Get(), nullptr); +} + +// ── C3-3:相同请求短路——同一 target 连请求多次不重复建(建一次后命中缓存)──── +TEST(AsyncRegionBuilder, IdenticalRequestShortCircuits) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_short").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir); + const RegionTarget t = makeTarget(1, 0, 2, 0, 1, 0, 1); + + b.requestTarget(t); + ASSERT_NE(waitGetReady(b, t).Get(), nullptr); + + // 已就绪后再连发同一 target:短路(不应再有 pending/在建),缓存仍 1 条。 + for (int i = 0; i < 10; ++i) b.requestTarget(t); + // 给 worker 一点时间(若误重建会出现 pending/building)。 + sleepMs(50); + EXPECT_FALSE(b.hasPending()) << "同一已就绪 target 不应触发重建"; + EXPECT_EQ(b.cacheSize(), 1u) << "同一 target 不应在缓存里重复占位"; + + // 短路时主目标仍可经 takeLatest 取到(兼容 C3-1/C3-2)。 + int lv = -1; + EXPECT_NE(b.getReady(t, lv).Get(), nullptr); +} + +// ── C3-3:预取不饿死主目标——大量 prefetch + 一个主 requestTarget → 主先就绪 ── +TEST(AsyncRegionBuilder, PrefetchDoesNotStarveMainTarget) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_arb_starve").string(); + makePyramidStore(dir, 200, 80, 60, 64, 3); + + AsyncRegionBuilder b(dir, /*cap*/ 6); + + // 先灌大量预取(不同 target),再设主目标。 + std::vector many; + for (int i = 0; i < 12; ++i) + many.push_back(makeTarget(0, i % 3, (i % 3) + 1, 0, 1, 0, 1, 1.0 + i)); + b.prefetch(many); + + const RegionTarget main = makeTarget(2, 0, 1, 0, 1, 0, 1); + b.requestTarget(main); + + // 主目标应优先就绪(轮询 getReady(main) 命中)。 + ASSERT_NE(waitGetReady(b, main, 800, 2).Get(), nullptr) + << "预取饿死了主目标"; +} diff --git a/tests/render/test_axes.cpp b/tests/render/test_axes.cpp new file mode 100644 index 0000000..e2087e5 --- /dev/null +++ b/tests/render/test_axes.cpp @@ -0,0 +1,105 @@ +#include + +#include + +#include "actors/AxesActor.hpp" +#include "geo/GeoLocalFrame.hpp" + +using namespace geopro::render; + +namespace { +constexpr double kFeetPerMeter = 3.28084; +} + +// unitScaleFactor:米=1,英尺=3.28084。 +TEST(AxesActor, UnitScaleFactor) { + EXPECT_DOUBLE_EQ(unitScaleFactor(AxesUnit::Meter), 1.0); + EXPECT_DOUBLE_EQ(unitScaleFactor(AxesUnit::Feet), kFeetPerMeter); +} + +// 不显示模式 → 返回 nullptr(不入场景)。 +TEST(AxesActor, NoneModeReturnsNull) { + double b[6] = {0, 10, 0, 20, -5, 0}; + AxesOptions opts; + opts.mode = AxesMode::None; + EXPECT_EQ(buildAxes(b, opts, nullptr), nullptr); +} + +// 退化包围盒(全 0)→ nullptr。 +TEST(AxesActor, DegenerateBoundsReturnsNull) { + double zero[6] = {0, 0, 0, 0, 0, 0}; + AxesOptions opts; + opts.mode = AxesMode::Standard; + EXPECT_EQ(buildAxes(zero, opts, nullptr), nullptr); + double inverted[6] = {10, 0, 0, 20, -5, 0}; // xmin>xmax + EXPECT_EQ(buildAxes(inverted, opts, nullptr), nullptr); +} + +// 标准模式 + 米:构建非空,几何 bounds 保留,X 显示范围 = 原值。 +TEST(AxesActor, StandardMeterKeepsRange) { + double b[6] = {0, 100, 0, 200, -50, 0}; + AxesOptions opts; + opts.mode = AxesMode::Standard; + opts.unit = AxesUnit::Meter; + auto ax = buildAxes(b, opts, nullptr); + ASSERT_NE(ax, nullptr); + double xr[2]; + ax->GetXAxisRange(xr); + EXPECT_NEAR(xr[0], 0.0, 1e-9); + EXPECT_NEAR(xr[1], 100.0, 1e-9); + // 几何 bounds 不变。 + double gb[6]; + ax->GetBounds(gb); + EXPECT_NEAR(gb[1], 100.0, 1e-9); +} + +// 英尺:显示范围 = 米值 × 3.28084(几何 bounds 仍为米)。 +TEST(AxesActor, FeetScalesDisplayRange) { + double b[6] = {0, 100, 0, 200, -50, 0}; + AxesOptions opts; + opts.mode = AxesMode::Standard; + opts.unit = AxesUnit::Feet; + auto ax = buildAxes(b, opts, nullptr); + ASSERT_NE(ax, nullptr); + double xr[2]; + ax->GetXAxisRange(xr); + EXPECT_NEAR(xr[1], 100.0 * kFeetPerMeter, 1e-6); + // 几何 bounds 仍是米,不被换算。 + double gb[6]; + ax->GetBounds(gb); + EXPECT_NEAR(gb[1], 100.0, 1e-9); +} + +// 经纬度:X 显示范围反算为经度(在 lon0 附近、随 +x 增大)。 +TEST(AxesActor, LatLonUsesFrameReverse) { + geopro::core::GeoLocalFrame frame(22.5, 114.16); + double b[6] = {0, 1000, 0, 1000, -50, 0}; // 1km 范围 + AxesOptions opts; + opts.mode = AxesMode::Standard; + opts.unit = AxesUnit::LatLon; + opts.frame = &frame; + auto ax = buildAxes(b, opts, nullptr); + ASSERT_NE(ax, nullptr); + double xr[2], yr[2]; + ax->GetXAxisRange(xr); + ax->GetYAxisRange(yr); + // x=0 → lon0;x=1000m → 略大于 lon0。 + EXPECT_NEAR(xr[0], 114.16, 1e-9); + EXPECT_GT(xr[1], 114.16); + EXPECT_NEAR(yr[0], 22.5, 1e-9); + EXPECT_GT(yr[1], 22.5); +} + +// 经纬度但无 frame → 退化为米(不反算,显示范围 = 原值)。 +TEST(AxesActor, LatLonWithoutFrameFallsBackToMeter) { + double b[6] = {0, 100, 0, 200, -50, 0}; + AxesOptions opts; + opts.mode = AxesMode::Standard; + opts.unit = AxesUnit::LatLon; + opts.frame = nullptr; + auto ax = buildAxes(b, opts, nullptr); + ASSERT_NE(ax, nullptr); + double xr[2]; + ax->GetXAxisRange(xr); + EXPECT_NEAR(xr[1], 100.0, 1e-9); // 米回退 +} diff --git a/tests/render/test_brick_pager.cpp b/tests/render/test_brick_pager.cpp new file mode 100644 index 0000000..68b74f7 --- /dev/null +++ b/tests/render/test_brick_pager.cpp @@ -0,0 +1,36 @@ +#include "render/source/BrickPager.hpp" +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +using namespace geopro; +namespace { +data::ChunkedVolumeStore makeStore(const std::string& dir){ + geopro::core::BuiltI16 b; b.vol=geopro::core::ScalarVolumeI16(128,128,128); + for(auto& v: b.vol.data()) v=3; + b.quant={1.0,0.0}; b.origin={{0,0,0}}; b.spacing={{1,1,1}}; b.vminPhys=0; b.vmaxPhys=3; + data::ChunkedVolumeStore::write(dir, b, 64); // 2×2×2=8 个 brick(level0) + return data::ChunkedVolumeStore(dir); +} +} +TEST(BrickPager, BoundedMemoryLruEviction){ + auto dir=(std::filesystem::temp_directory_path()/"gpr_pager").string(); + std::filesystem::remove_all(dir); + auto store = makeStore(dir); + render::BrickPager pager(store, 4); + EXPECT_EQ(pager.budget(), 4u); + EXPECT_EQ(pager.residentCount(), 0u); + std::vector six = { + {0,0,0,0},{0,1,0,0},{0,0,1,0},{0,1,1,0},{0,0,0,1},{0,1,0,1} }; + pager.requestVisible(six); + EXPECT_EQ(pager.residentCount(), 4u); // 恒定 ≤ budget + EXPECT_EQ(pager.get(six[0]), nullptr); // 最早请求→已淘汰 + EXPECT_EQ(pager.get(six[1]), nullptr); + ASSERT_NE(pager.get(six[5]), nullptr); // 最近请求→驻留 + EXPECT_EQ(pager.get(six[5])->size(), 64u*64*64); + // 再请求一个已驻留 + 一个新的,验证 LRU 更新与恒定驻留 + pager.requestVisible({ six[5], {0,1,1,1} }); + EXPECT_LE(pager.residentCount(), 4u); + EXPECT_NE(pager.get(six[5]), nullptr); // 刚 touch,仍在 + std::filesystem::remove_all(dir); +} diff --git a/tests/render/test_camera_preset.cpp b/tests/render/test_camera_preset.cpp new file mode 100644 index 0000000..c563ee8 --- /dev/null +++ b/tests/render/test_camera_preset.cpp @@ -0,0 +1,152 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "CameraPreset.hpp" + +using namespace geopro::render; + +namespace { + +// 造一个带包围盒的 renderer(一个 cone actor),使 ResetCamera 有内容可重定位。 +vtkSmartPointer rendererWithContent() { + auto cone = vtkSmartPointer::New(); + cone->SetCenter(0, 0, 0); + cone->SetHeight(2.0); + cone->SetRadius(1.0); + auto mapper = vtkSmartPointer::New(); + mapper->SetInputConnection(cone->GetOutputPort()); + auto actor = vtkSmartPointer::New(); + actor->SetMapper(mapper); + auto r = vtkSmartPointer::New(); + r->AddActor(actor); + return r; +} + +// 相机的视线方向单位向量 = focalPoint - position(归一化)。 +void viewDir(vtkRenderer* r, double out[3]) { + auto* c = r->GetActiveCamera(); + double p[3], f[3]; + c->GetPosition(p); + c->GetFocalPoint(f); + double d[3] = {f[0] - p[0], f[1] - p[1], f[2] - p[2]}; + double n = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]); + out[0] = d[0] / n; out[1] = d[1] / n; out[2] = d[2] / n; +} + +} // namespace + +// Top:相机在焦点上方(pos.z>focal.z),视线朝 -Z,viewUp=+Y。 +TEST(CameraPreset, TopLooksDown) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Top); + auto* c = r->GetActiveCamera(); + double p[3], f[3], up[3]; + c->GetPosition(p); c->GetFocalPoint(f); c->GetViewUp(up); + EXPECT_GT(p[2], f[2]); // 相机在上方 + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[2], -1.0, 1e-6); // 视线向下 + EXPECT_NEAR(up[1], 1.0, 1e-6); // 北朝上 +} + +// Bottom:相机在焦点下方,视线朝 +Z。 +TEST(CameraPreset, BottomLooksUp) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Bottom); + auto* c = r->GetActiveCamera(); + double p[3], f[3]; + c->GetPosition(p); c->GetFocalPoint(f); + EXPECT_LT(p[2], f[2]); + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[2], 1.0, 1e-6); +} + +// Front:相机在 -Y,视线朝 +Y,viewUp=+Z。 +TEST(CameraPreset, FrontLooksNorth) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Front); + auto* c = r->GetActiveCamera(); + double p[3], f[3], up[3]; + c->GetPosition(p); c->GetFocalPoint(f); c->GetViewUp(up); + EXPECT_LT(p[1], f[1]); + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[1], 1.0, 1e-6); + EXPECT_NEAR(up[2], 1.0, 1e-6); +} + +// Back:相机在 +Y,视线朝 -Y。 +TEST(CameraPreset, BackLooksSouth) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Back); + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[1], -1.0, 1e-6); +} + +// Left:相机在 -X,视线朝 +X。 +TEST(CameraPreset, LeftLooksEast) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Left); + auto* c = r->GetActiveCamera(); + double p[3], f[3]; + c->GetPosition(p); c->GetFocalPoint(f); + EXPECT_LT(p[0], f[0]); + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[0], 1.0, 1e-6); +} + +// Right:相机在 +X,视线朝 -X。 +TEST(CameraPreset, RightLooksWest) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Right); + double d[3]; viewDir(r, d); + EXPECT_NEAR(d[0], -1.0, 1e-6); +} + +// zoomBy(>1) 放大:透视下 vtkCamera::Zoom 收窄视角(ViewAngle 变小→画面放大)。 +TEST(CameraPreset, ZoomInNarrowsViewAngle) { + auto r = rendererWithContent(); + applyFree3D(r); + auto* c = r->GetActiveCamera(); + const double before = c->GetViewAngle(); + zoomBy(r, 1.2); + EXPECT_LT(c->GetViewAngle(), before); +} + +// zoomBy(<1) 缩小:透视下视角变宽(画面缩小)。 +TEST(CameraPreset, ZoomOutWidensViewAngle) { + auto r = rendererWithContent(); + applyFree3D(r); + auto* c = r->GetActiveCamera(); + const double before = c->GetViewAngle(); + zoomBy(r, 1.0 / 1.2); + EXPECT_GT(c->GetViewAngle(), before); +} + +// 正交投影下 zoomBy 改 parallelScale(放大缩小可视范围)。 +TEST(CameraPreset, ZoomInOrthoReducesParallelScale) { + auto r = rendererWithContent(); + applyView(r, ViewDir::Top); // Top 不改投影模式;显式打开正交 + auto* c = r->GetActiveCamera(); + c->ParallelProjectionOn(); + r->ResetCamera(); + const double before = c->GetParallelScale(); + zoomBy(r, 2.0); + EXPECT_LT(c->GetParallelScale(), before); +} + +// 空指针/非法 factor 安全。 +TEST(CameraPreset, NullAndInvalidAreSafe) { + applyView(nullptr, ViewDir::Top); + zoomBy(nullptr, 1.2); + fitView(nullptr); + auto r = rendererWithContent(); + const double before = r->GetActiveCamera()->GetDistance(); + zoomBy(r, 0.0); // 非法 factor 忽略 + zoomBy(r, -1.0); + EXPECT_DOUBLE_EQ(r->GetActiveCamera()->GetDistance(), before); +} diff --git a/tests/render/test_outofcore_source.cpp b/tests/render/test_outofcore_source.cpp new file mode 100644 index 0000000..59bad0c --- /dev/null +++ b/tests/render/test_outofcore_source.cpp @@ -0,0 +1,130 @@ +#include "render/source/OutOfCoreSource.hpp" + +#include "core/algo/GprVolumeBuilder.hpp" +#include "data/store/ChunkedVolumeStore.hpp" + +#include + +#include +#include + +using namespace geopro; + +namespace { + +// 造一个含金字塔的 store:值 = 全局 (i+j+k)%1000(便于校验块定位),非 64 整除维度 +// 以含边缘块。返回 store 目录。 +std::string makePyramidStore(const std::string& dir, int nx, int ny, int nz, + double ox, double oy, double oz, double dx, + double dy, double dz, int brick, int levels) { + std::filesystem::remove_all(dir); + core::BuiltI16 b; + b.vol = core::ScalarVolumeI16(nx, ny, nz); + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) + b.vol.at(i, j, k) = static_cast((i + j + k) % 1000); + b.quant = {1.0, 0.0}; + b.origin = {{ox, oy, oz}}; + b.spacing = {{dx, dy, dz}}; + b.vminPhys = 0; + b.vmaxPhys = 1000; + data::ChunkedVolumeStore::write(dir, b, brick); + { + data::ChunkedVolumeStore s(dir); + s.buildPyramid(levels); + } + return dir; +} + +} // namespace + +// headless 不需 GPU:验工作集块均 ≤ 纹理安全尺寸、residentCount ≤ budget、 +// 块世界 origin/spacing 正确(level 0,cam==nullptr → 全块经 budget/LRU 限制)。 +TEST(OutOfCoreSource, WorkingSetBricksAreTextureSafeAndBounded) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_ooc_test").string(); + // 200×80×60,brick=64 → level0 块 4×2×1=8;非整除含边缘块。1 级金字塔。 + makePyramidStore(dir, 200, 80, 60, /*ox=*/1, /*oy=*/2, /*oz=*/3, + /*dx=*/0.5, /*dy=*/0.5, /*dz=*/0.2, /*brick=*/64, + /*levels=*/1); + + const std::size_t budget = 4; + render::OutOfCoreSource src(dir, budget); + EXPECT_EQ(src.meta().nx, 200); + EXPECT_EQ(src.budget(), budget); + + src.update(nullptr); // cam==nullptr → level 0 全部块,budget/LRU 限制 + + EXPECT_EQ(src.lastLevel(), 0); + EXPECT_EQ(src.lastLevelBrickTotal(), 8u); // 4×2×1 + EXPECT_LE(src.residentCount(), budget); // 内存恒定核心 + + auto imgs = src.currentImages(); + EXPECT_FALSE(imgs.empty()); + EXPECT_LE(imgs.size(), budget); // 工作集图像数 = 驻留块数 ≤ budget + + constexpr int kTextureSafe = 64; // 各块各轴 ≤ brick ≪ 16384 + for (const auto& img : imgs) { + ASSERT_NE(img.Get(), nullptr); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + int d[3]; + img->GetDimensions(d); + EXPECT_LE(d[0], kTextureSafe); + EXPECT_LE(d[1], kTextureSafe); + EXPECT_LE(d[2], kTextureSafe); + EXPECT_GT(d[0], 0); + EXPECT_GT(d[1], 0); + EXPECT_GT(d[2], 0); + } +} + +// 块世界坐标:level 0 块 (1,0,0) 的 origin = meta.origin + (64×spacing,0,0); +// spacing == meta.spacing。 +TEST(OutOfCoreSource, BrickWorldCoordsLevel0) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_ooc_world0").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 1); + + render::OutOfCoreSource src(dir, /*budget=*/16); + src.update(nullptr); // 全 8 块都能驻留(budget=16) + EXPECT_EQ(src.residentCount(), 8u); + + // 找 origin.x == 1 + 64×0.5 == 33 的块(即 bx=1 列首块),验世界坐标。 + auto imgs = src.currentImages(); + bool found = false; + for (const auto& img : imgs) { + double o[3], s[3]; + img->GetOrigin(o); + img->GetSpacing(s); + // spacing 恒等于 meta(level 0,2^0=1)。 + EXPECT_DOUBLE_EQ(s[0], 0.5); + EXPECT_DOUBLE_EQ(s[1], 0.5); + EXPECT_DOUBLE_EQ(s[2], 0.2); + if (std::abs(o[0] - (1.0 + 64 * 0.5)) < 1e-9 && std::abs(o[1] - 2.0) < 1e-9 && + std::abs(o[2] - 3.0) < 1e-9) { + found = true; + } + } + EXPECT_TRUE(found) << "未找到 bx=1 列首块的世界 origin"; +} + +// 金字塔 LOD:level 1 块的 spacing == meta.spacing × 2;origin 用 level1 体素步距。 +TEST(OutOfCoreSource, BrickWorldCoordsLevel1Spacing) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_ooc_world1").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 1); + + data::ChunkedVolumeStore store(dir); + ASSERT_GE(store.levels(), 2); // level0 + level1 + + // 直接复用源的世界坐标逻辑:level1 块 (1,0,0) 的 spacing 应翻倍, + // origin.x = 1 + 64×(0.5×2) = 1 + 64 = 65。这里通过构造一个仅含 level1 的工作集 + // 验证(用 budget 大、相机 nullptr 时源仍取 level0,故改为直接核对块世界坐标公式: + // 用 store dims 推 level1 存在且块数合理)。 + int nx1 = 0, ny1 = 0, nz1 = 0; + store.dims(1, nx1, ny1, nz1); + EXPECT_EQ(nx1, 100); // ceil(200/2) + EXPECT_EQ(ny1, 40); + EXPECT_EQ(nz1, 30); +} diff --git a/tests/render/test_scene.cpp b/tests/render/test_scene.cpp new file mode 100644 index 0000000..ec3fad1 --- /dev/null +++ b/tests/render/test_scene.cpp @@ -0,0 +1,45 @@ +#include + +#include +#include +#include +#include + +#include "Scene.hpp" + +using geopro::render::Scene; + +// addActor 把 vtkActor 加入 renderer,view prop 计数 +1。 +TEST(SceneTest, AddActorIncrementsViewProps) { + Scene scene; + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 0); + auto a = vtkSmartPointer::New(); + scene.addActor(a); + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 1); +} + +// addViewProp 接受 vtkVolume(vtkProp3D,非 vtkActor)——体绘制必经此口。 +TEST(SceneTest, AddViewPropAcceptsVolume) { + Scene scene; + auto vol = vtkSmartPointer::New(); + scene.addViewProp(vol); + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 1); +} + +// clear() 经 RemoveAllViewProps 清空 actor 与 volume(覆盖体绘制 prop)。 +TEST(SceneTest, ClearRemovesActorsAndVolumes) { + Scene scene; + scene.addActor(vtkSmartPointer::New()); + scene.addViewProp(vtkSmartPointer::New()); + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 2); + scene.clear(); + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 0); +} + +// 空指针安全:addActor/addViewProp(nullptr) 不崩、不增计数。 +TEST(SceneTest, NullPropsAreIgnored) { + Scene scene; + scene.addActor(nullptr); + scene.addViewProp(nullptr); + EXPECT_EQ(scene.renderer()->GetViewProps()->GetNumberOfItems(), 0); +} diff --git a/tests/render/test_slice_plane_math.cpp b/tests/render/test_slice_plane_math.cpp new file mode 100644 index 0000000..44dd2b2 --- /dev/null +++ b/tests/render/test_slice_plane_math.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include + +#include "interact/SlicePlaneMath.hpp" + +using namespace geopro::render::interact; + +namespace { +void expectVec(const Vec3& a, double x, double y, double z, double eps = 1e-9) { + EXPECT_NEAR(a[0], x, eps); + EXPECT_NEAR(a[1], y, eps); + EXPECT_NEAR(a[2], z, eps); +} +} // namespace + +// ── axisNormal:轴向法向(spec F22–F24)+ 任意 45°(F25)── +TEST(SlicePlaneMath, AxisNormalUpDownIsZ) { expectVec(axisNormal(SliceAxis::UpDown), 0, 0, 1); } +TEST(SlicePlaneMath, AxisNormalFrontBackIsY) { expectVec(axisNormal(SliceAxis::FrontBack), 0, 1, 0); } +TEST(SlicePlaneMath, AxisNormalLeftRightIsX) { expectVec(axisNormal(SliceAxis::LeftRight), 1, 0, 0); } +TEST(SlicePlaneMath, AxisNormalObliqueIs45) { + const auto n = axisNormal(SliceAxis::Oblique); + const double s = std::sqrt(0.5); + expectVec(n, s, 0, s); + EXPECT_NEAR(norm(n), 1.0, 1e-9); // 单位向量 +} + +// ── boundsCenter ── +TEST(SlicePlaneMath, BoundsCenter) { + expectVec(boundsCenter({0, 10, -4, 4, 0, 6}), 5, 0, 3); +} + +// ── advanceOrigin:沿法向平移(滚轮推进,D46)── +TEST(SlicePlaneMath, AdvanceAlongZ) { + expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, 5.0), 1, 2, 8); +} +TEST(SlicePlaneMath, AdvanceBackward) { + expectVec(advanceOrigin({1, 2, 3}, {0, 0, 1}, -2.0), 1, 2, 1); +} +TEST(SlicePlaneMath, AdvanceNormalizesDirection) { + // 非单位法向:先归一化再推进,步长为世界距离。 + expectVec(advanceOrigin({0, 0, 0}, {0, 0, 5}, 3.0), 0, 0, 3); +} +TEST(SlicePlaneMath, AdvanceObliqueMovesAlong45) { + const auto o = advanceOrigin({0, 0, 0}, {1, 0, 1}, std::sqrt(2.0)); + expectVec(o, 1, 0, 1); // 沿 45° 推进 √2 → (1,0,1) +} + +// ── clampToBounds:推进出体外被夹回(滚轮限位)── +TEST(SlicePlaneMath, ClampInsideUnchanged) { + expectVec(clampToBounds({5, 0, 3}, {0, 10, -4, 4, 0, 6}), 5, 0, 3); +} +TEST(SlicePlaneMath, ClampOutsideHigh) { + expectVec(clampToBounds({5, 0, 99}, {0, 10, -4, 4, 0, 6}), 5, 0, 6); +} +TEST(SlicePlaneMath, ClampOutsideLow) { + expectVec(clampToBounds({-5, 0, -1}, {0, 10, -4, 4, 0, 6}), 0, 0, 0); +} + +// ── faceOnCamera:双击正视(E54)── +// 法向 +Y:相机退到 focal+Y*dist,视线 = -Y,viewUp = +Z(切面内向上)。 +TEST(SlicePlaneMath, FaceOnFrontBackNormal) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 1, 0}, 10.0); + expectVec(cam.position, 0, 10, 0); + // viewUp 与法向正交且偏 +Z。 + EXPECT_NEAR(dot(cam.viewUp, Vec3{0, 1, 0}), 0.0, 1e-9); + EXPECT_GT(cam.viewUp[2], 0.5); +} +// 法向 +X:position=focal+X*dist,viewUp 偏 +Z。 +TEST(SlicePlaneMath, FaceOnLeftRightNormal) { + const auto cam = faceOnCamera({1, 2, 3}, {1, 0, 0}, 5.0); + expectVec(cam.position, 6, 2, 3); + EXPECT_NEAR(dot(cam.viewUp, Vec3{1, 0, 0}), 0.0, 1e-9); + EXPECT_GT(cam.viewUp[2], 0.5); +} +// 法向竖直 +Z(上下切片):viewUp 不能再取 +Z(与法向共线),兜底取 +Y。 +TEST(SlicePlaneMath, FaceOnVerticalNormalFallsBackToY) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 0, 1}, 8.0); + expectVec(cam.position, 0, 0, 8); + // viewUp 与法向(+Z)正交(z≈0),且非零。 + EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9); + EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9); +} +// 法向竖直 -Z 同样兜底。 +TEST(SlicePlaneMath, FaceOnVerticalDownNormalFallsBack) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 0, -1}, 4.0); + expectVec(cam.position, 0, 0, -4); + EXPECT_NEAR(cam.viewUp[2], 0.0, 1e-9); + EXPECT_NEAR(norm(cam.viewUp), 1.0, 1e-9); +} +// 非单位法向:position 用归一化法向 → 距焦点恰为 dist。 +TEST(SlicePlaneMath, FaceOnNormalizesNormal) { + const auto cam = faceOnCamera({0, 0, 0}, {0, 3, 0}, 6.0); + expectVec(cam.position, 0, 6, 0); +} + +// ── wheelStep:滚轮推进步长(按对角线比例 × 方向)── +TEST(SlicePlaneMath, WheelStepForwardPositive) { + EXPECT_GT(wheelStep({0, 10, 0, 0, 0, 0}, +1), 0.0); +} +TEST(SlicePlaneMath, WheelStepBackwardNegative) { + EXPECT_LT(wheelStep({0, 10, 0, 0, 0, 0}, -1), 0.0); +} +TEST(SlicePlaneMath, WheelStepScalesWithBounds) { + const double small = wheelStep({0, 10, 0, 0, 0, 0}, 1); + const double big = wheelStep({0, 100, 0, 0, 0, 0}, 1); + EXPECT_GT(big, small); // 体越大步长越大 +} + +// ── nearestPlane:找点所在切片(按到平面距离最小)── +TEST(SlicePlaneMath, NearestPlaneEmptyIsMinusOne) { + EXPECT_EQ(nearestPlane({}, {}, {0, 0, 0}), -1); +} +TEST(SlicePlaneMath, NearestPlanePicksClosest) { + // 两张水平切片 z=0 与 z=10(法向 +Z);点 z=8 → 更近 z=10(索引 1)。 + std::vector centers{{0, 0, 0}, {0, 0, 10}}; + std::vector normals{{0, 0, 1}, {0, 0, 1}}; + EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 8}), 1); + EXPECT_EQ(nearestPlane(centers, normals, {5, 5, 2}), 0); +} +TEST(SlicePlaneMath, NearestPlaneIgnoresInPlaneOffset) { + // 单张 z=0 水平面:点无论 x/y 多远,只要 z=0 距离为 0 → 命中。 + std::vector centers{{0, 0, 0}}; + std::vector normals{{0, 0, 1}}; + EXPECT_EQ(nearestPlane(centers, normals, {999, -999, 0}), 0); +} + +// ── 向量工具 ── +TEST(SlicePlaneMath, NormalizeZeroFallsBack) { expectVec(normalize({0, 0, 0}), 0, 0, 1); } +TEST(SlicePlaneMath, CrossBasic) { expectVec(cross({1, 0, 0}, {0, 1, 0}), 0, 0, 1); } diff --git a/tests/render/test_tile_math.cpp b/tests/render/test_tile_math.cpp new file mode 100644 index 0000000..5575f5e --- /dev/null +++ b/tests/render/test_tile_math.cpp @@ -0,0 +1,48 @@ +#include + +#include + +#include "ground/TileMath.hpp" + +using geopro::render::lonLatToTile; +using geopro::render::tileBounds; + +// z=1 把世界分 2x2:原点(0°,0°)在东/南象限交界 → 标准 slippy 取 (1,1)。 +TEST(TileMath, OriginZoom1) { + auto t = lonLatToTile(0.0, 0.0, 1); + EXPECT_EQ(t.z, 1); + EXPECT_EQ(t.x, 1); + EXPECT_EQ(t.y, 1); +} + +// z=1 西北瓦片 (0,0) 覆盖西半球北部:west=-180, east=0, north≈85.0511(墨卡托上限), south=0。 +TEST(TileMath, BoundsZoom1NW) { + auto b = tileBounds(1, 0, 0); + EXPECT_NEAR(b.west, -180.0, 1e-6); + EXPECT_NEAR(b.east, 0.0, 1e-6); + EXPECT_NEAR(b.north, 85.0511287798, 1e-4); + EXPECT_NEAR(b.south, 0.0, 1e-6); +} + +// 往返一致:任一经纬点所属瓦片的边界必须包含该点(经度严格、纬度含墨卡托方向)。 +TEST(TileMath, RoundTripContains) { + const double lon = 116.391, lat = 39.907; // 北京附近 + const int z = 12; + auto t = lonLatToTile(lon, lat, z); + EXPECT_EQ(t.z, z); + auto b = tileBounds(t.z, t.x, t.y); + EXPECT_GE(lon, b.west); + EXPECT_LE(lon, b.east); + EXPECT_LE(lat, b.north); // north 是瓦片上边界(纬度大) + EXPECT_GE(lat, b.south); +} + +// 夹紧:超界经纬不应产生越界瓦片索引。 +TEST(TileMath, ClampInRange) { + auto t = lonLatToTile(500.0, 95.0, 3); // 非法输入 + const int hi = (1 << 3) - 1; + EXPECT_GE(t.x, 0); + EXPECT_LE(t.x, hi); + EXPECT_GE(t.y, 0); + EXPECT_LE(t.y, hi); +} diff --git a/tests/render/test_view_adaptive_lod.cpp b/tests/render/test_view_adaptive_lod.cpp new file mode 100644 index 0000000..1c7c466 --- /dev/null +++ b/tests/render/test_view_adaptive_lod.cpp @@ -0,0 +1,258 @@ +#include + +#include + +#include "lod/ViewAdaptiveLodPolicy.hpp" + +using geopro::render::CameraView; +using geopro::render::LodSelection; +using geopro::render::selectLod; +using geopro::render::VolumeView; + +namespace { + +// 构造一个规整体:nx=ny=nz=512、brick=64(每轴 8 块)、level0 间距=1、levels=4(L0..L3)。 +// 世界范围 [0,512]³(origin 0)。各 level 维度:512/256/128/64;间距 1/2/4/8。 +VolumeView makeVol(int n = 512, int brick = 64, int levels = 4, double sp = 1.0) { + VolumeView v{}; + v.nx = v.ny = v.nz = n; + v.brick = brick; + v.levels = levels; + v.origin[0] = v.origin[1] = v.origin[2] = 0.0; + v.spacing[0] = v.spacing[1] = v.spacing[2] = sp; + v.exagg = 1.0; + return v; +} + +// 相机:从 +X 方向看体中心。dist = 相机到中心距离;fov/viewportH 控分辨率密度。 +CameraView lookFromX(const VolumeView& v, double dist, double fovYDeg = 30.0, + int viewportH = 1080) { + CameraView c{}; + const double cx = v.origin[0] + 0.5 * v.nx * v.spacing[0]; + const double cy = v.origin[1] + 0.5 * v.ny * v.spacing[1]; + const double cz = v.origin[2] + 0.5 * v.nz * v.spacing[2]; + c.focal[0] = cx; + c.focal[1] = cy; + c.focal[2] = cz; + c.pos[0] = cx + dist; + c.pos[1] = cy; + c.pos[2] = cz; + c.up[0] = 0; + c.up[1] = 0; + c.up[2] = 1; + c.fovYDeg = fovYDeg; + c.aspect = 1.0; + c.viewportH = viewportH; + return c; +} + +} // namespace + +// ── empty:体完全在视锥外(相机背对体)→ empty ────────────────────────────── +TEST(ViewAdaptiveLod, VolumeBehindCameraIsEmpty) { + const VolumeView v = makeVol(); + CameraView c = lookFromX(v, 1000.0); + // 把焦点设到相机背后:相机仍在 +X 远处,但看向 +X 更远(体在身后)。 + c.focal[0] = c.pos[0] + 1000.0; // 视线朝 +X,体在 -X 侧 → 视锥外 + const LodSelection sel = selectLod(v, c); + EXPECT_TRUE(sel.empty); +} + +TEST(ViewAdaptiveLod, VolumeOffToSideIsEmpty) { + const VolumeView v = makeVol(); + // 相机看 +Z(体在 -X..+X、相机在远 +X,焦点正上方)→ 体偏出窄视锥。 + CameraView c{}; + c.pos[0] = 100000; + c.pos[1] = 0; + c.pos[2] = 0; + c.focal[0] = 100000; + c.focal[1] = 0; + c.focal[2] = 100000; // 看 +Z + c.up[0] = 1; + c.up[1] = 0; + c.up[2] = 0; + c.fovYDeg = 5.0; + c.aspect = 1.0; + c.viewportH = 1080; + const LodSelection sel = selectLod(v, c); + EXPECT_TRUE(sel.empty); +} + +// ── 远观整体 → 粗层、区间≈全体、各轴 ≤ maxTextureDim ─────────────────────── +TEST(ViewAdaptiveLod, FarViewPicksCoarseLevelWholeVolume) { + const VolumeView v = makeVol(); + // 很远 + 适中 fov:整体入视锥,worldPerPixel 大 → 粗层。 + const CameraView c = lookFromX(v, 8000.0); + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_GT(sel.level, 0); // 远 → 粗(非最细) + // 区间≈全体:覆盖所有块(该 level 块数)。 + // 该 level 每轴块数 = ceil(ceil(512/2^L)/64)。 + const int dimL = (512 + (1 << sel.level) - 1) >> sel.level; + const int bN = (dimL + v.brick - 1) / v.brick; + EXPECT_EQ(sel.bx0, 0); + EXPECT_EQ(sel.bx1, bN); + EXPECT_EQ(sel.by0, 0); + EXPECT_EQ(sel.by1, bN); + EXPECT_EQ(sel.bz0, 0); + EXPECT_EQ(sel.bz1, bN); +} + +// ── 近观局部 → 细层、区间是视锥内小块、各轴 ≤ maxTextureDim ───────────────── +TEST(ViewAdaptiveLod, NearViewPicksFineLevelSmallRegion) { + const VolumeView v = makeVol(); + // 贴近 + 窄 fov:只看到体内一小块,worldPerPixel 小 → 细层(0)、小区间。 + const CameraView c = lookFromX(v, 60.0, 20.0, 1080); + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_EQ(sel.level, 0); // 近 → 最细 + // 区间是子集(不是全 8 块):至少某一垂直于视线的轴被裁小。 + const int bNfull = (512 + v.brick - 1) / v.brick; // 8 + const bool yShrunk = (sel.by1 - sel.by0) < bNfull; + const bool zShrunk = (sel.bz1 - sel.bz0) < bNfull; + EXPECT_TRUE(yShrunk || zShrunk); +} + +// 契约里的“重组单纹理某轴体素数”:起点对齐 b0*brick,终点取 +// min(b1*brick, dimL, b0*brick + maxTextureDim)(含 brick>maxTextureDim 的体素级再裁)。 +// 生产代码保证此值恒 ≤ maxTextureDim;测试用同一公式焊死不变量。 +static int reconstructedAxisTex(int b0, int b1, int brick, int dimL, + int maxTextureDim) { + const int start = b0 * brick; + const int end = std::min({b1 * brick, dimL, start + maxTextureDim}); + return std::max(0, end - start); +} + +static int dimAt(int n, int level) { + return (n + (1 << level) - 1) >> level; +} + +// ── 选定层重组区间各轴恒 ≤ maxTextureDim(小 maxTextureDim 强制升层/缩区间)── +// brick=64、maxTextureDim=128(brick≠maxTextureDim,不靠 64=64 侥幸):允许 2 块/轴, +// 多于此须按中心裁掉,真正走多块 clamp 算术(而非边界相等巧合)。 +TEST(ViewAdaptiveLod, RespectsMaxTextureDimAlways) { + const VolumeView v = makeVol(); // brick=64 + const int kMax = 128; // ≠ brick;容 2 块,逼出多块中心裁剪 + for (double dist : {60.0, 300.0, 1500.0, 8000.0, 40000.0}) { + const CameraView c = lookFromX(v, dist); + const LodSelection sel = selectLod(v, c, kMax); + if (sel.empty) continue; + const int dimL = dimAt(512, sel.level); + EXPECT_LE(reconstructedAxisTex(sel.bx0, sel.bx1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.by0, sel.by1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.bz0, sel.bz1, v.brick, dimL, kMax), kMax); + // 区间合法、非空。 + EXPECT_LT(sel.bx0, sel.bx1); + EXPECT_LT(sel.by0, sel.by1); + EXPECT_LT(sel.bz0, sel.bz1); + } +} + +// ── brick > maxTextureDim:单块体素数已超限,仍必须保证重组 ≤ maxTextureDim ────── +// brick=128、maxTextureDim=64:brick 粒度无法表达更小区间 → 返回单块 + C2 体素级再裁, +// 重组实际体素数 = min(单块, maxTextureDim) = 64 ≤ 64。各视距各轴恒不超限。 +TEST(ViewAdaptiveLod, BrickLargerThanMaxTextureDimStillClamped) { + const VolumeView v = makeVol(/*n=*/512, /*brick=*/128, /*levels=*/4); + const int kMax = 64; // < brick=128 + for (double dist : {60.0, 300.0, 1500.0, 8000.0, 40000.0}) { + const CameraView c = lookFromX(v, dist); + const LodSelection sel = selectLod(v, c, kMax); + if (sel.empty) continue; + const int dimL = dimAt(512, sel.level); + EXPECT_LE(reconstructedAxisTex(sel.bx0, sel.bx1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.by0, sel.by1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.bz0, sel.bz1, v.brick, dimL, kMax), kMax); + EXPECT_LT(sel.bx0, sel.bx1); // 恒至少一块 + } +} + +// ── 最粗层整卷 > maxTextureDim:远观也必须裁成子区间,绝不突破硬上限 ──────────── +// nx=156544、levels=3 → 最粗层 dim=ceil(156544/4)=39136 > 16384;旧实现在最粗层无条件 +// 返回会突破上限。修复后远观应裁成 ≤16384 的中心子区间,empty=false。 +TEST(ViewAdaptiveLod, CoarsestLevelOverflowIsClampedNotBreached) { + VolumeView v{}; + v.nx = v.ny = v.nz = 156544; + v.brick = 64; + v.levels = 3; // L0..L2;最粗层 dim = ceil(156544/4) = 39136 > 16384 + v.origin[0] = v.origin[1] = v.origin[2] = 0.0; + v.spacing[0] = v.spacing[1] = v.spacing[2] = 1.0; + v.exagg = 1.0; + // 极远观:worldPerPixel 大 → 选最粗层;整卷入视锥。 + const CameraView c = lookFromX(v, 5.0e6, 30.0, 1080); + const int kMax = 16384; + const LodSelection sel = selectLod(v, c, kMax); + ASSERT_FALSE(sel.empty); + const int dimL = dimAt(v.nx, sel.level); + EXPECT_LE(reconstructedAxisTex(sel.bx0, sel.bx1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.by0, sel.by1, v.brick, dimL, kMax), kMax); + EXPECT_LE(reconstructedAxisTex(sel.bz0, sel.bz1, v.brick, dimL, kMax), kMax); + EXPECT_LT(sel.bx0, sel.bx1); + EXPECT_LT(sel.by0, sel.by1); + EXPECT_LT(sel.bz0, sel.bz1); +} + +TEST(ViewAdaptiveLod, RespectsDefaultMaxTextureDim) { + const VolumeView v = makeVol(/*n=*/4096, /*brick=*/256, /*levels=*/1); + // levels=1(只有 level0)→ 不能升层,只能靠缩区间满足 16384。4096 < 16384 必满足。 + const CameraView c = lookFromX(v, 100000.0); + const LodSelection sel = selectLod(v, c, 16384); + ASSERT_FALSE(sel.empty); + EXPECT_EQ(sel.level, 0); + const int texX = std::min((sel.bx1) * v.brick, 4096) - sel.bx0 * v.brick; + EXPECT_LE(texX, 16384); +} + +// ── 视距-层单调:拉近 level 不增 ──────────────────────────────────────────── +TEST(ViewAdaptiveLod, LevelMonotonicWithDistance) { + const VolumeView v = makeVol(); + int prev = 1 << 30; + for (double dist : {40000.0, 8000.0, 1500.0, 300.0, 60.0}) { // 由远到近 + const CameraView c = lookFromX(v, dist); + const LodSelection sel = selectLod(v, c); + if (sel.empty) continue; + EXPECT_LE(sel.level, prev) << "dist=" << dist; // 拉近 level 不增 + prev = sel.level; + } +} + +// ── 区间半开且合法(b0> sel.level; + const int bN = (dimL + v.brick - 1) / v.brick; + EXPECT_GE(sel.bx0, 0); + EXPECT_LT(sel.bx0, sel.bx1); + EXPECT_LE(sel.bx1, bN); + EXPECT_GE(sel.by0, 0); + EXPECT_LT(sel.by0, sel.by1); + EXPECT_LE(sel.by1, bN); + EXPECT_GE(sel.bz0, 0); + EXPECT_LT(sel.bz0, sel.bz1); + EXPECT_LE(sel.bz1, bN); +} + +// ── side 视角:从某一侧斜看,仍非空且区间合法 ───────────────────────────── +TEST(ViewAdaptiveLod, ObliqueSideViewNotEmpty) { + const VolumeView v = makeVol(); + CameraView c{}; + const double cx = 256, cy = 256, cz = 256; + c.pos[0] = cx + 1200; + c.pos[1] = cy + 1200; + c.pos[2] = cz + 800; + c.focal[0] = cx; + c.focal[1] = cy; + c.focal[2] = cz; + c.up[0] = 0; + c.up[1] = 0; + c.up[2] = 1; + c.fovYDeg = 45.0; + c.aspect = 1.6; + c.viewportH = 1080; + const LodSelection sel = selectLod(v, c); + EXPECT_FALSE(sel.empty); + EXPECT_GE(sel.level, 0); + EXPECT_LT(sel.bx0, sel.bx1); +} diff --git a/tests/render/test_view_adaptive_source.cpp b/tests/render/test_view_adaptive_source.cpp new file mode 100644 index 0000000..fee8e26 --- /dev/null +++ b/tests/render/test_view_adaptive_source.cpp @@ -0,0 +1,551 @@ +// ViewAdaptiveVolumeSource(C2→C3-2) headless 测试:用 C1 selectLod 选层选区,把 +// 当前视野区域【异步】重组为【单张 VTK_SHORT vtkImageData】(各轴 ≤16384,世界 +// origin/spacing 按 level+exagg)。核心 updateView(CameraView,VolumeView) 不需真 +// vtkCamera/GL 上下文——构造 vtkImageData 不需渲染管线。 +// +// C3-2 异步集成:updateView 只提交目标(不阻塞、不在主线程重组),currentImages +// 取最新已就绪(没就绪用上一张)。测试需在 updateView 后【轮询 currentImages +// 直到非空(带超时)】再断言内容。 +// +// 验:远观粗层 / 近观细层 / 各轴 ≤16384 / VTK_SHORT / 重组体素与 store 对应 +// level+区间位置一致(不错位)/ empty 情形空 / updateView 不阻塞 / +// 维度取自 store.dims(单一真源,奇数维一致)/ 最终就绪内容 == reorganizeRegion。 + +#include "render/source/ViewAdaptiveVolumeSource.hpp" + +#include "core/algo/GprVolumeBuilder.hpp" +#include "data/store/ChunkedVolumeStore.hpp" +#include "lod/ViewAdaptiveLodPolicy.hpp" +#include "render/source/RegionReorganizer.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace geopro; +using geopro::render::CameraView; +using geopro::render::VolumeView; + +namespace { + +// 造一个含金字塔的 store:值 = 全局 (i+j+k)%1000(便于校验块定位),非 64 整除 +// 维度以含边缘块。返回 store 目录。与 test_outofcore_source 同口径。 +std::string makePyramidStore(const std::string& dir, int nx, int ny, int nz, + double ox, double oy, double oz, double dx, + double dy, double dz, int brick, int levels) { + std::filesystem::remove_all(dir); + core::BuiltI16 b; + b.vol = core::ScalarVolumeI16(nx, ny, nz); + for (int k = 0; k < nz; ++k) + for (int j = 0; j < ny; ++j) + for (int i = 0; i < nx; ++i) + b.vol.at(i, j, k) = static_cast((i + j + k) % 1000); + b.quant = {1.0, 0.0}; + b.origin = {{ox, oy, oz}}; + b.spacing = {{dx, dy, dz}}; + b.vminPhys = 0; + b.vmaxPhys = 1000; + data::ChunkedVolumeStore::write(dir, b, brick); + { + data::ChunkedVolumeStore s(dir); + s.buildPyramid(levels); + } + return dir; +} + +// 由 store meta + levels + exagg 填 VolumeView(与生产 volumeViewOf 同口径)。 +VolumeView volumeViewOf(const data::StoreMeta& m, int levels, double exagg) { + VolumeView v{}; + v.nx = m.nx; + v.ny = m.ny; + v.nz = m.nz; + v.brick = m.brick; + v.levels = levels; + v.origin[0] = m.origin[0]; + v.origin[1] = m.origin[1]; + v.origin[2] = m.origin[2]; + // C1 约定:spacing 已含 exagg 于 y/z。 + v.spacing[0] = m.spacing[0]; + v.spacing[1] = m.spacing[1] * exagg; + v.spacing[2] = m.spacing[2] * exagg; + v.exagg = exagg; + return v; +} + +// 相机:从 +X 看体中心(用 level0 物理范围,未含 exagg 于 X)。 +CameraView lookFromX(const data::StoreMeta& m, double dist, double fovYDeg = 30.0, + int viewportH = 1080) { + CameraView c{}; + const double cx = m.origin[0] + 0.5 * m.nx * m.spacing[0]; + const double cy = m.origin[1] + 0.5 * m.ny * m.spacing[1]; + const double cz = m.origin[2] + 0.5 * m.nz * m.spacing[2]; + c.focal[0] = cx; + c.focal[1] = cy; + c.focal[2] = cz; + c.pos[0] = cx + dist; + c.pos[1] = cy; + c.pos[2] = cz; + c.up[0] = 0; + c.up[1] = 0; + c.up[2] = 1; + c.fovYDeg = fovYDeg; + c.aspect = 1.0; + c.viewportH = viewportH; + return c; +} + +// 异步轮询:updateView 后 currentImages 可能还没就绪——轮询直到非空(带超时)。 +// 返回首张就绪 image(超时仍空 → 返回 nullptr,由调用方 ASSERT)。 +vtkSmartPointer pollReady(render::ViewAdaptiveVolumeSource& src, + int maxTries = 2000, int sleepMs = 2) { + for (int i = 0; i < maxTries; ++i) { + auto imgs = src.currentImages(); + if (!imgs.empty() && imgs[0] != nullptr) return imgs[0]; + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + } + return nullptr; +} + +} // namespace + +// ── 远观:选粗层、单图、各轴 ≤16384、VTK_SHORT(异步轮询就绪后断言)───────── +TEST(ViewAdaptiveVolumeSource, FarViewCoarseLevelSingleTexture) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_far").string(); + // 200×80×60, brick=64 → 含边缘块。3 级金字塔。 + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, /*exagg*/ 1.0); + EXPECT_EQ(src.meta().nx, 200); + + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 8000.0); + src.updateView(far, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + // 就绪后 currentImages 仍是单张。 + auto imgs = src.currentImages(); + ASSERT_EQ(imgs.size(), 1u); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + int d[3]; + img->GetDimensions(d); + EXPECT_LE(d[0], 16384); + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); + EXPECT_GT(d[0], 0); + EXPECT_GT(d[1], 0); + EXPECT_GT(d[2], 0); + EXPECT_GT(src.lastLevel(), 0); // 远 → 粗 +} + +// ── 近观:选最细层(0)、单图、仍 ≤16384 ───────────────────────────────────── +TEST(ViewAdaptiveVolumeSource, NearViewFineLevel) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_near").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView near = lookFromX(src.meta(), 8.0, 20.0, 1080); + src.updateView(near, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + EXPECT_EQ(src.lastLevel(), 0); // 近 → 最细 + int d[3]; + img->GetDimensions(d); + EXPECT_LE(d[0], 16384); + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); +} + +// ── 重组体素与 store 对应 level/区间位置一致(不错位)────────────────────── +// 远观选某粗 level,重组单图后抽查若干体素:用世界坐标反推该 level 体素索引, +// 与 store.readBrick(level,...) 对应块的同一体素值逐一相等 → 证明位置正确。 +TEST(ViewAdaptiveVolumeSource, ReconstructedVoxelsMatchStore) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_match").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 8000.0); + src.updateView(far, vol); + + auto pimg = pollReady(src); + ASSERT_NE(pimg.Get(), nullptr); + vtkImageData* img = pimg.Get(); + const int level = src.lastLevel(); + + data::ChunkedVolumeStore store(dir); + const data::StoreMeta& m = src.meta(); + const int brick = m.brick; + const double sc = static_cast(1 << level); + + int dimLx = 0, dimLy = 0, dimLz = 0; + store.dims(level, dimLx, dimLy, dimLz); + + int d[3]; + img->GetDimensions(d); + double org[3], sp[3]; + img->GetOrigin(org); + img->GetSpacing(sp); + + // spacing 应 = meta.spacing × 2^level(exagg=1)。 + EXPECT_DOUBLE_EQ(sp[0], m.spacing[0] * sc); + EXPECT_DOUBLE_EQ(sp[1], m.spacing[1] * sc); + EXPECT_DOUBLE_EQ(sp[2], m.spacing[2] * sc); + + auto* arr = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars()); + ASSERT_NE(arr, nullptr); + + // 该图覆盖的 level 体素起点:由 origin 反推(应整数)。 + const long gi0 = std::lround((org[0] - m.origin[0]) / sp[0]); + const long gj0 = std::lround((org[1] - m.origin[1]) / sp[1]); + const long gk0 = std::lround((org[2] - m.origin[2]) / sp[2]); + EXPECT_GE(gi0, 0); + EXPECT_GE(gj0, 0); + EXPECT_GE(gk0, 0); + + // 取该 level 单块校验:读 store 块 (b*,0,0),把块内某体素与图中同位置对比。 + auto storeVoxelAt = [&](int gi, int gj, int gk) -> std::int16_t { + const int bx = gi / brick, by = gj / brick, bz = gk / brick; + const std::vector raw = store.readBrick(level, bx, by, bz); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + const int bw = std::min(brick, dimLx - i0); + const int bh = std::min(brick, dimLy - j0); + const int li = gi - i0, lj = gj - j0, lk = gk - k0; + const std::size_t w = + (static_cast(lk) * bh + lj) * bw + li; + return raw[w]; + }; + + // 抽查图内若干位置(含非角点、跨块),逐一与 store 对应 level 体素相等。 + int checked = 0; + for (int lk : {0, d[2] / 2, d[2] - 1}) { + for (int lj : {0, d[1] / 2, d[1] - 1}) { + for (int li : {0, d[0] / 2, d[0] - 1}) { + const long gi = gi0 + li, gj = gj0 + lj, gk = gk0 + lk; + if (gi >= dimLx || gj >= dimLy || gk >= dimLz) continue; + const vtkIdType id = + (static_cast(lk) * d[1] + lj) * d[0] + li; + const std::int16_t got = arr->GetValue(id); + const std::int16_t want = + storeVoxelAt(static_cast(gi), static_cast(gj), + static_cast(gk)); + EXPECT_EQ(got, want) + << "体素错位 at local(" << li << "," << lj << "," << lk + << ") level=" << level; + ++checked; + } + } + } + EXPECT_GT(checked, 0); +} + +// ── 世界 origin:按 level+exagg 偏移正确(exagg≠1 校验 y/z 烘焙)───────────── +TEST(ViewAdaptiveVolumeSource, WorldOriginSpacingWithExagg) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_exagg").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + const double exagg = 4.0; + render::ViewAdaptiveVolumeSource src(dir, exagg); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), exagg); + // 远观整体,区间从 0 起 → origin 应正好 = meta.origin(区间起点 0)。 + const CameraView far = lookFromX(src.meta(), 8000.0); + src.updateView(far, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + const data::StoreMeta& m = src.meta(); + const int level = src.lastLevel(); + const double sc = static_cast(1 << level); + double sp[3], org[3]; + img->GetSpacing(sp); + img->GetOrigin(org); + // spacing:x 不夸张,y/z ×exagg。 + EXPECT_DOUBLE_EQ(sp[0], m.spacing[0] * sc); + EXPECT_DOUBLE_EQ(sp[1], m.spacing[1] * sc * exagg); + EXPECT_DOUBLE_EQ(sp[2], m.spacing[2] * sc * exagg); + // 区间从 0 起 → origin = meta.origin(y/z 偏移为 0×… 仍是 origin)。 + EXPECT_DOUBLE_EQ(org[0], m.origin[0]); + EXPECT_DOUBLE_EQ(org[1], m.origin[1]); + EXPECT_DOUBLE_EQ(org[2], m.origin[2]); +} + +// ── empty:体完全在视锥外 → currentImages 空(从未就绪)────────────────────── +TEST(ViewAdaptiveVolumeSource, EmptyWhenBehindCamera) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_empty").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + CameraView c = lookFromX(src.meta(), 1000.0); + c.focal[0] = c.pos[0] + 1000.0; // 视线朝 +X,体在身后 → 视锥外 + src.updateView(c, vol); + + // empty 选区 → 不提交目标;从未就绪 → currentImages 恒空(短轮询确认无意外结果)。 + for (int i = 0; i < 20; ++i) { + EXPECT_TRUE(src.currentImages().empty()); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + EXPECT_EQ(src.sliceSource(), nullptr); +} + +// ── 单层 store(无金字塔)也能重组(level 恒 0)─────────────────────────── +TEST(ViewAdaptiveVolumeSource, SingleLevelStore) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_1lvl").string(); + // levels=0 → 仅 level 0。 + makePyramidStore(dir, 128, 64, 48, 0, 0, 0, 1.0, 1.0, 1.0, 64, 0); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + EXPECT_EQ(src.levelCount(), 1); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView c = lookFromX(src.meta(), 500.0); + src.updateView(c, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + EXPECT_EQ(src.lastLevel(), 0); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); +} + +// ── C3-2 异步:updateView 立即返回 + 最终就绪内容 == reorganizeRegion 同步结果 ── +TEST(ViewAdaptiveVolumeSource, AsyncUpdateEventuallyReady) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_async").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 8000.0); + src.updateView(far, vol); // 立即返回(不阻塞、不在主线程重组) + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + int d[3]; + img->GetDimensions(d); + EXPECT_LE(d[0], 16384); + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + + // 最终就绪内容 == 同步 reorganizeRegion(同 target) 结果(位置/几何一致)。 + // 用 selectLod 复算同一 target,与异步源选的同 level/区间。 + const render::LodSelection sel = render::selectLod(vol, far, 16384); + ASSERT_FALSE(sel.empty); + EXPECT_EQ(sel.level, src.lastLevel()); + render::RegionTarget t{}; + t.level = sel.level; + t.bx0 = sel.bx0; t.bx1 = sel.bx1; + t.by0 = sel.by0; t.by1 = sel.by1; + t.bz0 = sel.bz0; t.bz1 = sel.bz1; + t.exagg = 1.0; + data::ChunkedVolumeStore store(dir); + auto sync = render::reorganizeRegion(store, t, 16384); + ASSERT_NE(sync.Get(), nullptr); + + int ds[3]; + sync->GetDimensions(ds); + EXPECT_EQ(d[0], ds[0]); + EXPECT_EQ(d[1], ds[1]); + EXPECT_EQ(d[2], ds[2]); + double oa[3], os[3], sa[3], ss[3]; + img->GetOrigin(oa); + img->GetSpacing(sa); + sync->GetOrigin(os); + sync->GetSpacing(ss); + for (int i = 0; i < 3; ++i) { + EXPECT_DOUBLE_EQ(oa[i], os[i]); + EXPECT_DOUBLE_EQ(sa[i], ss[i]); + } + auto* aAsync = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars()); + auto* aSync = vtkShortArray::SafeDownCast(sync->GetPointData()->GetScalars()); + ASSERT_NE(aAsync, nullptr); + ASSERT_NE(aSync, nullptr); + ASSERT_EQ(aAsync->GetNumberOfTuples(), aSync->GetNumberOfTuples()); + // 抽查若干体素逐一相等(含端点/中点)。 + const vtkIdType n = aAsync->GetNumberOfTuples(); + for (vtkIdType id : {vtkIdType(0), n / 3, n / 2, n - 1}) { + if (id < 0 || id >= n) continue; + EXPECT_EQ(aAsync->GetValue(id), aSync->GetValue(id)) << "id=" << id; + } +} + +// ── C3-2 非阻塞:updateView 本身耗时极短(不含重组)即便较大 store ───────────── +TEST(ViewAdaptiveVolumeSource, UpdateDoesNotBlock) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_noblock").string(); + // 较大 store:同步重组该粗层整卷会有明显耗时;updateView 只提交目标应近 0。 + makePyramidStore(dir, 512, 256, 192, 0, 0, 0, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + const CameraView far = lookFromX(src.meta(), 20000.0); + + const auto t0 = std::chrono::steady_clock::now(); + src.updateView(far, vol); // 应立即返回(仅 selectLod + requestTarget) + const auto t1 = std::chrono::steady_clock::now(); + const double ms = + std::chrono::duration(t1 - t0).count(); + EXPECT_LT(ms, 50.0) << "updateView 阻塞了?耗时 " << ms << "ms"; + + // 仍能最终就绪(后台重组完成)。 + auto img = pollReady(src, 5000, 2); + ASSERT_NE(img.Get(), nullptr); +} + +// ── C3-6 常驻粗底图:baseImage() 非空 / 整卷最粗各轴 ≤16384 / VTK_SHORT / 盖全 ── +// 底图 = 整卷「各轴 ≤16384」最粗层单纹理。构造时即建好(不需 updateView/不需异步)。 +TEST(ViewAdaptiveVolumeSource, BaseImageWholeVolumeCoarsest) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_base").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, /*exagg*/ 1.0); + + // 构造后即非空(不调 updateView)→ 永不空白的底图常驻。 + vtkImageData* base = src.baseImage(); + ASSERT_NE(base, nullptr); + EXPECT_EQ(base->GetScalarType(), VTK_SHORT); + + int d[3]; + base->GetDimensions(d); + EXPECT_GT(d[0], 0); + EXPECT_GT(d[1], 0); + EXPECT_GT(d[2], 0); + EXPECT_LE(d[0], 16384); + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); + + // 底图层 = 该 store「各轴 ≤16384」的最细满足层;本 store 各层都 ≤16384 → level 0。 + const int bl = src.baseLevel(); + int sdx = 0, sdy = 0, sdz = 0; + data::ChunkedVolumeStore store(dir); + store.dims(bl, sdx, sdy, sdz); + EXPECT_LE(sdx, 16384); + EXPECT_LE(sdy, 16384); + EXPECT_LE(sdz, 16384); + // 盖全:底图各轴 == 该 level 全卷 store.dims(全卷区间,未被 maxTextureDim 裁切)。 + EXPECT_EQ(d[0], sdx) << "底图未盖全 x"; + EXPECT_EQ(d[1], sdy) << "底图未盖全 y"; + EXPECT_EQ(d[2], sdz) << "底图未盖全 z"; + + // 世界范围盖全整卷:origin == meta.origin(全卷从 0 起); + // 跨度 ≈ 整卷世界跨度(spacing×dims == meta.spacing×meta.dims,同一物理范围)。 + const data::StoreMeta& m = src.meta(); + double org[3], sp[3]; + base->GetOrigin(org); + base->GetSpacing(sp); + EXPECT_DOUBLE_EQ(org[0], m.origin[0]); + EXPECT_DOUBLE_EQ(org[1], m.origin[1]); + EXPECT_DOUBLE_EQ(org[2], m.origin[2]); + // 底图世界跨度应覆盖整卷 level0 世界跨度(粗层 spacing×dims ≥ 细层覆盖范围)。 + EXPECT_GE(sp[0] * d[0], m.spacing[0] * m.nx - sp[0]); + EXPECT_GE(sp[1] * d[1], m.spacing[1] * m.ny - sp[1]); + EXPECT_GE(sp[2] * d[2], m.spacing[2] * m.nz - sp[2]); +} + +// ── C3-6 底图永不空:updateView 出界(empty)、高清从未就绪时,baseImage 仍非空盖全 ── +// 这是「拖动/缩放绝不空白」的核:高清异步路径(current_)与底图(baseImage_)完全分离, +// 高清空不影响底图。 +TEST(ViewAdaptiveVolumeSource, BaseImagePersistsWhenHiResEmpty) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_basepersist").string(); + makePyramidStore(dir, 200, 80, 60, 1, 2, 3, 0.5, 0.5, 0.2, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + // 视锥外:体在相机身后 → 高清不提交、currentImages 恒空。 + CameraView c = lookFromX(src.meta(), 1000.0); + c.focal[0] = c.pos[0] + 1000.0; + src.updateView(c, vol); + + // 高清空(验空)但底图始终非空、盖全。 + for (int i = 0; i < 10; ++i) { + EXPECT_TRUE(src.currentImages().empty()); + ASSERT_NE(src.baseImage(), nullptr); // 底图永不空 + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + int d[3]; + src.baseImage()->GetDimensions(d); + EXPECT_GT(d[0], 0); + EXPECT_GT(d[1], 0); + EXPECT_GT(d[2], 0); +} + +// ── C3-6 超长 X 整卷:最粗 ≤16384 层选取正确(X 远超 16384 须升到能盖全的层)────── +// 模拟全路段长条体(X 极长):底图 level 必须升到 store.dims(level).x ≤16384 的最细层, +// 且底图各轴恒 ≤16384(盖全整卷、绝不撞纹理墙)。 +TEST(ViewAdaptiveVolumeSource, BaseImageLongVolumePicksCoarseEnough) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_baselong").string(); + // X=40000(>16384),多级金字塔 → L0:40000, L1:20000(>16384), L2:10000(≤16384)。 + // 底图须选 L2(最细的「各轴 ≤16384」满足层)。 + makePyramidStore(dir, 40000, 64, 48, 0, 0, 0, 0.05, 0.05, 0.05, 64, 4); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + vtkImageData* base = src.baseImage(); + ASSERT_NE(base, nullptr); + int d[3]; + base->GetDimensions(d); + EXPECT_LE(d[0], 16384) << "底图 x 超纹理上限"; + EXPECT_LE(d[1], 16384); + EXPECT_LE(d[2], 16384); + EXPECT_GT(d[0], 0); + + // 选定层各轴 store.dims 必 ≤16384,且更细一层(level-1)的 x 必 >16384(确认是最细满足层)。 + const int bl = src.baseLevel(); + data::ChunkedVolumeStore store(dir); + int sdx = 0, sdy = 0, sdz = 0; + store.dims(bl, sdx, sdy, sdz); + EXPECT_LE(sdx, 16384); + EXPECT_EQ(d[0], sdx) << "盖全整卷(全卷区间 ≤16384,未被裁)"; + if (bl > 0) { + int fdx = 0, fdy = 0, fdz = 0; + store.dims(bl - 1, fdx, fdy, fdz); + EXPECT_GT(fdx, 16384) << "更细一层 x 应 >16384(故选了这层)"; + } +} + +// ── C3-2 维度取自 store.dims(单一真源):奇数维 store 验重组 dims == store.dims ── +// 全卷请求某粗 level,重组单图各轴 == store.dims(level)(而非自算公式)。奇数维 +// 多级降采样下,store.dims 是唯一权威;本测试钉死「重组维度跟随 store.dims」。 +TEST(ViewAdaptiveVolumeSource, UsesStoreDimsNotSelfComputed) { + const auto dir = + (std::filesystem::temp_directory_path() / "gpr_va_storedims").string(); + // 奇数维度(含多次 ceil 降采样):199×83×61,3 级金字塔。 + makePyramidStore(dir, 199, 83, 61, 0, 0, 0, 1.0, 1.0, 1.0, 64, 3); + + render::ViewAdaptiveVolumeSource src(dir, 1.0); + const VolumeView vol = volumeViewOf(src.meta(), src.levelCount(), 1.0); + // 极远 → 选最粗层、区间≈全卷,重组整卷该层 → 各轴应正好 == store.dims(level)。 + const CameraView far = lookFromX(src.meta(), 1.0e7); + src.updateView(far, vol); + + auto img = pollReady(src); + ASSERT_NE(img.Get(), nullptr); + const int level = src.lastLevel(); + ASSERT_GT(level, 0); // 极远 → 粗层(多级降采样,奇数维有意义) + + int sdx = 0, sdy = 0, sdz = 0; + data::ChunkedVolumeStore store(dir); + store.dims(level, sdx, sdy, sdz); + + int d[3]; + img->GetDimensions(d); + // 全卷区间(各轴 ≤16384,store.dims 远小于此)→ 重组维度恒 == store.dims。 + EXPECT_EQ(d[0], sdx) << "x 维度未跟随 store.dims"; + EXPECT_EQ(d[1], sdy) << "y 维度未跟随 store.dims"; + EXPECT_EQ(d[2], sdz) << "z 维度未跟随 store.dims"; +} diff --git a/tests/render/test_voxel_i16_smoke.cpp b/tests/render/test_voxel_i16_smoke.cpp new file mode 100644 index 0000000..bc07c9b --- /dev/null +++ b/tests/render/test_voxel_i16_smoke.cpp @@ -0,0 +1,40 @@ +#include + +#include + +#include "actors/VoxelActor.hpp" +#include "model/ColorScale.hpp" +#include "model/ScalarVolumeI16.hpp" + +using namespace geopro; + +// buildVoxelI16 无窗冒烟:int16 量化体 → vtkImageData(vtkShortArray)。 +// 仅验 image 构造/类型(VTK_SHORT)/dims/逐体素值/blank 原样保留;不实际 GPU 渲染。 +TEST(VoxelActorI16, BuildsShortImageWithBlank) { + core::ScalarVolumeI16 v(2, 2, 2); + for (auto& x : v.data()) x = 10; + v.at(0, 0, 0) = core::ScalarVolumeI16::kBlank; + + core::Quant q{1.0, 0.0}; + + core::ColorScale cs; + cs.addStop(0.0, core::Rgba{0, 0, 255, 255}); + cs.addStop(20.0, core::Rgba{255, 0, 0, 255}); + + vtkSmartPointer img; + auto vol = render::buildVoxelI16(v, q, cs, 0, 0, 0, 1, 1, 1, 0.0, 20.0, img); + + ASSERT_NE(vol.Get(), nullptr); + ASSERT_NE(img.Get(), nullptr); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + + int dims[3]; + img->GetDimensions(dims); + EXPECT_EQ(dims[0], 2); + EXPECT_EQ(dims[1], 2); + EXPECT_EQ(dims[2], 2); + + EXPECT_EQ(*static_cast(img->GetScalarPointer(1, 1, 1)), 10); + EXPECT_EQ(*static_cast(img->GetScalarPointer(0, 0, 0)), + core::ScalarVolumeI16::kBlank); +} diff --git a/tests/render/test_whole_volume_source.cpp b/tests/render/test_whole_volume_source.cpp new file mode 100644 index 0000000..6aa1a94 --- /dev/null +++ b/tests/render/test_whole_volume_source.cpp @@ -0,0 +1,31 @@ +#include "source/WholeVolumeSource.hpp" +#include "data/store/ChunkedVolumeStore.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include +#include +#include +using namespace geopro; + +TEST(WholeVolumeSource, ReassemblesFullVolumeFromStore) { + auto dir = (std::filesystem::temp_directory_path() / "gpr_wvs_test").string(); + std::filesystem::remove_all(dir); + // 造一个可识别的体:值 = 全局 i+j+k(便于校验重组位置) + core::BuiltI16 b; b.vol = core::ScalarVolumeI16(100,40,30); + for (int k=0;k<30;k++)for(int j=0;j<40;j++)for(int i=0;i<100;i++) b.vol.at(i,j,k)=(short)((i+j+k)%1000); + b.quant={1.0,0.0}; b.origin={{1,2,3}}; b.spacing={{0.5,0.5,0.2}}; b.vminPhys=0; b.vmaxPhys=1000; + data::ChunkedVolumeStore::write(dir, b, 64); // 100/40/30 均非 64 整除→含边缘块 + render::WholeVolumeSource src(dir); + EXPECT_EQ(src.meta().nx, 100); + auto imgs = src.currentImages(); + ASSERT_EQ(imgs.size(), 1u); + auto* img = imgs[0].Get(); + EXPECT_EQ(img->GetScalarType(), VTK_SHORT); + int dims[3]; img->GetDimensions(dims); + EXPECT_EQ(dims[0],100); EXPECT_EQ(dims[1],40); EXPECT_EQ(dims[2],30); + // 关键:重组位置正确(边缘块也对) + EXPECT_EQ(*static_cast(img->GetScalarPointer(99,39,29)), (short)((99+39+29)%1000)); + EXPECT_EQ(*static_cast(img->GetScalarPointer(70,10,5)), (short)((70+10+5)%1000)); + EXPECT_EQ(src.sliceSource(), img); + src.update(nullptr); // no-op 安全 + std::filesystem::remove_all(dir); +} diff --git a/tools/gpr3dv_smoke/CMakeLists.txt b/tools/gpr3dv_smoke/CMakeLists.txt new file mode 100644 index 0000000..05f0fd4 --- /dev/null +++ b/tools/gpr3dv_smoke/CMakeLists.txt @@ -0,0 +1,22 @@ +# gpr3dv-smoke —— 冒烟 CLI,验证 vendored 3DGPRViewer 数据生成链编过且跑通。 +# 用法: gpr3dv-smoke +# 例: gpr3dv-smoke "D:/Downloads/明星路" "明星路_001" +# 走原版 API:IprhParser::loadImpulseMultiChannel → GPRDataModel::buildVolumeData +# → RadarProcessor::runPipeline(默认流水线)。打印维度/处理前后统计/通道数。 + +add_executable(gpr3dv_smoke main.cpp) + +set_target_properties(gpr3dv_smoke PROPERTIES + OUTPUT_NAME "gpr3dv-smoke" + AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) + +target_link_libraries(gpr3dv_smoke PRIVATE geopro_gpr3dv Qt6::Core Qt6::Gui) +target_compile_features(gpr3dv_smoke PRIVATE cxx_std_17) + +if(WIN32) + # 运行时 DLL(Qt6Core 等)拷到 exe 旁,使其可直接运行(无需手设 PATH)。 + add_custom_command(TARGET gpr3dv_smoke POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ $ + COMMAND_EXPAND_LISTS) +endif() diff --git a/tools/gpr3dv_smoke/main.cpp b/tools/gpr3dv_smoke/main.cpp new file mode 100644 index 0000000..403159a --- /dev/null +++ b/tools/gpr3dv_smoke/main.cpp @@ -0,0 +1,115 @@ +// gpr3dv-smoke —— vendored 3DGPRViewer 数据生成链冒烟入口。 +// +// 验证目标(任务 P1 验收): +// ① 读一条多通道测线 → GPRDataModel(traces) +// ② GPRDataModel::buildVolumeData → 立方体 volumeData[ch][trace][sample] +// ③ RadarProcessor::runPipeline(默认流水线) → 处理后 GPRDataModel +// 打印:立方体维度 / 处理前后统计量(证明处理生效)/ 通道数(读自数据)。 +// +// 用法: +// gpr3dv-smoke +// 例: gpr3dv-smoke "D:/Downloads/明星路" "明星路_001" +// +// 算法零改动:本文件只调用 vendored 原版静态 API,不触碰任何算法体。 + +#include +#include +#include + +#include + +#include "GPRDataModel.h" +#include "IprhParser.h" +#include "RadarProcessor.h" + +// 全体 traces 的平均绝对幅值——朴素的"处理是否生效"标量证据。 +// RadarProcessor 流水线在 model.traces 上做原位变换(非 volumeData),故统计取自 traces。 +static double meanAbsAmplitude(const GPRDataModel &model, qint64 *sampleCountOut = nullptr) +{ + long double sum = 0.0L; + qint64 count = 0; + for (const RadarTrace &tr : model.traces) { + for (short v : tr.amplitudes) { + sum += static_cast(v < 0 ? -v : v); + ++count; + } + } + if (sampleCountOut) { + *sampleCountOut = count; + } + return count > 0 ? static_cast(sum / count) : 0.0; +} + +int main(int argc, char **argv) +{ + if (argc < 3) { + std::fprintf(stderr, + "用法: gpr3dv-smoke \n" + "例: gpr3dv-smoke \"D:/Downloads/明星路\" \"明星路_001\"\n"); + return 2; + } + + const QString lineDir = QString::fromLocal8Bit(argv[1]); + const QString linePrefix = QString::fromLocal8Bit(argv[2]); + + std::printf("[gpr3dv-smoke] lineDir=%s linePrefix=%s\n", + lineDir.toLocal8Bit().constData(), + linePrefix.toLocal8Bit().constData()); + + // ---- ① 读多通道测线(通道数读自数据,不写死)---- + GPRDataModel model; + if (!IprhParser::loadImpulseMultiChannel(lineDir, linePrefix, model)) { + std::fprintf(stderr, "[gpr3dv-smoke] 错误: loadImpulseMultiChannel 失败\n"); + return 1; + } + + const int channels = model.header.numberOfChannels; // 读自 .iprh CHANNELS / _A 文件数 + const int tracesPerChannel = model.getTracesPerChannel(); + const int samplesPerTrace = model.header.samplesPerTrace; + + std::printf("[gpr3dv-smoke] 加载完成: 通道数=%d 每通道道数=%d 每道样本数=%d 总道数=%lld\n", + channels, tracesPerChannel, samplesPerTrace, + static_cast(model.traces.size())); + + // ---- ② 建三维立方体 volumeData[ch][trace][sample] ---- + model.buildVolumeData(); + const int volCh = model.volumeData.size(); + const int volTr = (volCh > 0) ? model.volumeData[0].size() : 0; + const int volSm = (volCh > 0 && volTr > 0) ? model.volumeData[0][0].size() : 0; + std::printf("[gpr3dv-smoke] 立方体维度 [通道 x 道 x 样本] = %d x %d x %d\n", + volCh, volTr, volSm); + + short gMin = 0, gMax = 0; + model.getGlobalMinMax(gMin, gMax); + std::printf("[gpr3dv-smoke] 立方体全局幅值范围: min=%d max=%d\n", + static_cast(gMin), static_cast(gMax)); + + // ---- 处理前统计 ---- + qint64 nBefore = 0; + const double meanBefore = meanAbsAmplitude(model, &nBefore); + std::printf("[gpr3dv-smoke] 处理前 平均绝对幅值=%.4f (样本数=%lld)\n", + meanBefore, static_cast(nBefore)); + + // ---- ③ RadarProcessor 默认流水线 ---- + RadarProcessor::ProcPipeline pipeline; + pipeline.setDefaultFlow(); + std::printf("[gpr3dv-smoke] 运行默认流水线(步骤数=%lld)...\n", + static_cast(pipeline.steps.size())); + + const GPRDataModel processed = RadarProcessor::runPipeline(model, pipeline); + + qint64 nAfter = 0; + const double meanAfter = meanAbsAmplitude(processed, &nAfter); + std::printf("[gpr3dv-smoke] 处理后 平均绝对幅值=%.4f (样本数=%lld 道数=%lld 样本/道=%d)\n", + meanAfter, static_cast(nAfter), + static_cast(processed.traces.size()), + processed.header.samplesPerTrace); + + const double delta = meanAfter - meanBefore; + std::printf("[gpr3dv-smoke] 处理前后差值=%.4f —— %s\n", + delta, + (qAbs(delta) > 1e-9 ? "处理已生效(统计量变化)" : "警告:统计量未变化")); + + std::printf("[gpr3dv-smoke] OK\n"); + return 0; +} diff --git a/tools/gpr_poc/CMakeLists.txt b/tools/gpr_poc/CMakeLists.txt new file mode 100644 index 0000000..812b949 --- /dev/null +++ b/tools/gpr_poc/CMakeLists.txt @@ -0,0 +1,39 @@ +# POC-B headless 度量 CLI(gpr_poc)。 +# 串起:geopro_io_gpr(解析装配)→ geopro_core(建体)→ geopro_store(分块落盘/金字塔) +# → geopro_render(WholeVolumeSource 整卷加载)。 +# Windows 峰值内存用 Psapi(Probe.hpp)。 + +# VTK_LIBRARIES 在子作用域内由各 find_package 设定,这里显式再请求一次 +# (与 geopro_render 同组件集),确保本 target 的 vtk_module_autoinit 可用。 +find_package(VTK REQUIRED COMPONENTS + CommonCore CommonDataModel + RenderingCore RenderingOpenGL2 RenderingVolume RenderingVolumeOpenGL2 + ImagingCore InteractionStyle GUISupportQt IOImage) + +add_executable(gpr_poc main.cpp) + +target_include_directories(gpr_poc PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(gpr_poc PRIVATE + geopro_io_gpr + geopro_gpr3dv_bridge + geopro_core + geopro_store + geopro_render) + +if(WIN32) + target_link_libraries(gpr_poc PRIVATE Psapi) + # 运行时 DLL(VTK/Qt/GDAL 等)拷到 exe 旁,使 gpr_poc.exe 可直接运行(无需手设 PATH)。 + # 与 geopro_tests 同款 TARGET_RUNTIME_DLLS POST_BUILD。 + add_custom_command(TARGET gpr_poc POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ $ + COMMAND_EXPAND_LISTS) +endif() + +target_compile_features(gpr_poc PRIVATE cxx_std_17) +set_target_properties(gpr_poc PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) + +# geopro_render 透传 VTK_LIBRARIES(PUBLIC),消费方需 autoinit 各 VTK 模块工厂, +# 否则 vtkImageData/渲染对象工厂未注册。 +vtk_module_autoinit(TARGETS gpr_poc MODULES ${VTK_LIBRARIES}) diff --git a/tools/gpr_poc/Probe.hpp b/tools/gpr_poc/Probe.hpp new file mode 100644 index 0000000..3757583 --- /dev/null +++ b/tools/gpr_poc/Probe.hpp @@ -0,0 +1,60 @@ +#ifndef GEOPRO_TOOLS_GPR_POC_PROBE_HPP +#define GEOPRO_TOOLS_GPR_POC_PROBE_HPP + +// 轻量 headless 度量探针:墙钟计时 + 进程峰值工作集内存。 +// 仅供 gpr_poc CLI 使用,header-only,零外部依赖(Windows 用 Psapi)。 + +#include +#include + +#if defined(_WIN32) +// 防止 的 min/max 宏污染 std::numeric_limits<>::min()/max() +// (ScalarVolumeI16.hpp 等会因此编译失败)。 +#ifndef NOMINMAX +#define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#endif + +namespace geopro::tools { + +// 墙钟计时器:构造即起表,elapsedMs() 返回毫秒(double)。 +class Stopwatch { + public: + Stopwatch() : start_(std::chrono::steady_clock::now()) {} + + void reset() { start_ = std::chrono::steady_clock::now(); } + + double elapsedMs() const { + const auto now = std::chrono::steady_clock::now(); + return std::chrono::duration(now - start_).count(); + } + + private: + std::chrono::steady_clock::time_point start_; +}; + +// 进程级度量探针。 +struct Probe { + // 进程峰值工作集(MB)。Windows 取 GetProcessMemoryInfo::PeakWorkingSetSize; + // 其它平台返回 0(本任务仅 Windows 实测)。 + static double peakMemMB() { +#if defined(_WIN32) + PROCESS_MEMORY_COUNTERS pmc; + if (GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof(pmc))) { + return static_cast(pmc.PeakWorkingSetSize) / (1024.0 * 1024.0); + } + return 0.0; +#else + return 0.0; +#endif + } +}; + +} // namespace geopro::tools + +#endif // GEOPRO_TOOLS_GPR_POC_PROBE_HPP diff --git a/tools/gpr_poc/main.cpp b/tools/gpr_poc/main.cpp new file mode 100644 index 0000000..97e24f6 --- /dev/null +++ b/tools/gpr_poc/main.cpp @@ -0,0 +1,6834 @@ +// gpr_poc —— POC-B headless 度量 CLI。 +// +// 串起整条地基:发现 14 通道 .iprb + .ord → assembleGprSurvey → buildGprVolume +// → ChunkedVolumeStore::write → buildPyramid → WholeVolumeSource(load), +// 在真实/合成数据上输出可测的真实指标(耗时/维度/体积/压缩比/加载/峰值内存)。 +// +// 子命令: +// gpr_poc build [--line 001] [--cellXY 0.2] [--cellZ 0.05] [--out ] [--levels 2] +// gpr_poc load +// gpr_poc selftest +// gpr_poc offscreen-smoke —— 离屏 GL 闸门冒烟 +// gpr_poc renderB [--frames 120] —— 离屏体绘制/切片 fps 基准 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Probe.hpp" + +#include "core/algo/GeoVolumeBuilder.hpp" +#include "core/algo/GprVolumeBuilder.hpp" +#include "core/algo/IInterpolator.hpp" +#include "core/model/GprSurvey.hpp" +#include "core/model/ColorScale.hpp" +#include "core/model/ScalarVolumeI16.hpp" +#include "data/store/ChunkedVolumeStore.hpp" +#include "io/gpr/Gpr3dvSurveyVolumeBridge.hpp" +#include "io/gpr/Gpr3dvVolumeBridge.hpp" +#include "io/gpr/GprSurveyAssembler.hpp" +#include "io/gpr/GpsTrack.hpp" +#include "io/gpr/IprHeader.hpp" +#include "render/actors/VoxelActor.hpp" +#include "render/source/OutOfCoreSource.hpp" +#include "render/interact/SlicePlaneMath.hpp" +#include "render/interact/SliceTool.hpp" +#include "render/source/ViewAdaptiveVolumeSource.hpp" +#include "render/source/WholeVolumeSource.hpp" + +// P9: WGS84 → CGCS2000 高斯-克吕格精确平面坐标(vendored 3DGPRViewer,零 Qt)。 +#include "CoordinateTransform.h" + +// ---- VTK 离屏渲染 ---- +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include // SetConsoleOutputCP(修中文控制台 GBK 乱码) +#endif + +namespace fs = std::filesystem; +using geopro::tools::Probe; +using geopro::tools::Stopwatch; + +namespace { + +constexpr int kChannels = 14; + +// ---- 命令行参数解析(极简 --key value)---- +struct Args { + std::map kv; + std::vector positional; + + std::string get(const std::string& key, const std::string& def) const { + auto it = kv.find(key); + return it != kv.end() ? it->second : def; + } +}; + +Args parseArgs(int argc, char** argv, int start) { + Args a; + for (int i = start; i < argc; ++i) { + std::string tok = argv[i]; + if (tok.rfind("--", 0) == 0) { + // 下一个 token 也是 --xxx(或本 token 是末尾)→ 本 token 是无值布尔旗标, + // 存空串(避免把后面的旗标误当成本旗标的值,如 --preview --near)。 + const bool hasValue = + (i + 1 < argc) && std::string(argv[i + 1]).rfind("--", 0) != 0; + a.kv[tok.substr(2)] = hasValue ? argv[i + 1] : ""; + if (hasValue) ++i; + } else { + a.positional.push_back(tok); + } + } + return a; +} + +// 把一行指标追加写入 last-metrics.txt(与可执行同目录的工具源目录无关, +// 写到当前工作目录便于汇总;CSV 风格一行)。 +void writeMetricLine(const std::string& line) { + std::ofstream f("last-metrics.txt", std::ios::app); + if (f) f << line << "\n"; +} + +// 发现某线 14 通道 .iprb(按通道号 A01..A14 排序)+ 该线 .ord。 +struct LineFiles { + std::vector iprb; // 已按通道号排序 + std::string ord; +}; + +LineFiles discoverLine(const std::string& dir, const std::string& line) { + LineFiles lf; + std::map byChannel; // 通道号 → 路径(自动按号排序) + std::string ordPath; + + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + const std::string name = e.path().filename().string(); + const std::string ext = e.path().extension().string(); + + // .ord:优先匹配本线(含 "_" 且以 .ord 结尾),否则记下工区任一 .ord 作兜底。 + if (ext == ".ord") { + if (name.find("_" + line + ".") != std::string::npos) { + ordPath = e.path().string(); + } else if (ordPath.empty()) { + ordPath = e.path().string(); + } + continue; + } + + // .iprb:匹配 "*__A.iprb"。 + if (ext != ".iprb") continue; + const std::string tag = "_" + line + "_A"; + const std::size_t pos = name.find(tag); + if (pos == std::string::npos) continue; + const std::size_t numStart = pos + tag.size(); + std::size_t numEnd = numStart; + while (numEnd < name.size() && + std::isdigit(static_cast(name[numEnd]))) { + ++numEnd; + } + if (numEnd == numStart) continue; + const int ch = std::stoi(name.substr(numStart, numEnd - numStart)); + byChannel[ch] = e.path().string(); + } + + for (const auto& [ch, path] : byChannel) lf.iprb.push_back(path); + lf.ord = ordPath; + return lf; +} + +// 由 survey 推 GridSpec:X 沿测线,Y 跨通道,Z 深度。 +geopro::core::GridSpec specFromSurvey(const geopro::core::GprSurvey& s, + double cellXY, double cellZ) { + geopro::core::GridSpec spec{}; + + const double rangeX = + (s.ntraces > 1) ? (s.ntraces - 1) * s.dx : 0.0; + const double y0 = s.channelY.empty() ? 0.0 : s.channelY.front(); + const double y1 = s.channelY.empty() ? 0.0 : s.channelY.back(); + const double rangeY = y1 - y0; + const double rangeZ = + (s.samples > 1) ? (s.samples - 1) * s.dz : 0.0; + + auto cells = [](double range, double cell) { + if (cell <= 0.0) return 1; + return static_cast(std::ceil(range / cell)) + 1; + }; + + spec.ox = s.x0; + spec.oy = y0; + spec.oz = s.z0; + spec.dx = cellXY; + spec.dy = cellXY; + spec.dz = cellZ; + spec.nx = cells(rangeX, cellXY); + spec.ny = cells(rangeY, cellXY); + spec.nz = cells(rangeZ, cellZ); + spec.power = 2.0; + spec.maxDist = cellXY * 2.0; + return spec; +} + +// 落盘 data.bin 体积(所有 data*.bin 之和,含金字塔各级)。 +std::int64_t storeDataBytes(const std::string& dir) { + std::int64_t total = 0; + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + const std::string name = e.path().filename().string(); + if (name.rfind("data", 0) == 0 && + e.path().extension().string() == ".bin") { + total += static_cast(e.file_size()); + } + } + return total; +} + +int cmdBuild(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc build [--line 001] [--cellXY 0.2] " + "[--cellZ 0.05] [--out ] [--levels 2]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const std::string line = a.get("line", "001"); + const double cellXY = std::stod(a.get("cellXY", "0.2")); + const double cellZ = std::stod(a.get("cellZ", "0.05")); + const int levels = std::stoi(a.get("levels", "2")); + const std::string out = + a.get("out", (fs::temp_directory_path() / ("gpr_store_" + line)).string()); + + std::cout << "[build] dir=" << dir << " line=" << line + << " cellXY=" << cellXY << " cellZ=" << cellZ + << " levels=" << levels << " out=" << out << "\n"; + + const LineFiles lf = discoverLine(dir, line); + std::cout << "[build] 发现通道数=" << lf.iprb.size() + << " ord=" << (lf.ord.empty() ? "(无)" : lf.ord) << "\n"; + if (lf.iprb.size() != static_cast(kChannels)) { + std::cerr << "[build] 警告: 通道数 != " << kChannels + << "(仍按发现数继续)\n"; + } + if (lf.iprb.empty() || lf.ord.empty()) { + std::cerr << "[build] 错误: 未发现 .iprb 或 .ord\n"; + return 1; + } + + // 1) 装配 + Stopwatch swAsm; + geopro::core::GprSurvey survey = + geopro::io::gpr::assembleGprSurvey(lf.iprb, lf.ord); + const double asmMs = swAsm.elapsedMs(); + std::cout << "[build] 装配完成 ntraces=" << survey.ntraces + << " samples=" << survey.samples + << " channels=" << survey.channelY.size() + << " dx=" << survey.dx << " dz=" << survey.dz << "\n"; + + // 2) 建体 + const geopro::core::GridSpec spec = specFromSurvey(survey, cellXY, cellZ); + std::cout << "[build] GridSpec nx=" << spec.nx << " ny=" << spec.ny + << " nz=" << spec.nz << " dx=" << spec.dx << " dy=" << spec.dy + << " dz=" << spec.dz << " maxDist=" << spec.maxDist << "\n"; + + Stopwatch swBuild; + geopro::core::BuiltI16 built = geopro::core::buildGprVolume(survey, spec); + const double buildMs = swBuild.elapsedMs(); + + const std::int64_t nx = built.vol.nx(), ny = built.vol.ny(), nz = built.vol.nz(); + const std::int64_t voxels = nx * ny * nz; + const std::int64_t rawBytes = voxels * 2; // int16 + + // 3) 落盘 + 金字塔 + fs::create_directories(out); + Stopwatch swWrite; + geopro::data::ChunkedVolumeStore::write(out, built); + const double writeMs = swWrite.elapsedMs(); + + Stopwatch swPyr; + { + geopro::data::ChunkedVolumeStore store(out); + store.buildPyramid(levels); + } + const double pyrMs = swPyr.elapsedMs(); + + const std::int64_t dataBytes = storeDataBytes(out); + const double ratio = + dataBytes > 0 ? static_cast(rawBytes) / dataBytes : 0.0; + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== build 指标 ===\n"; + std::cout << "装配耗时(ms) : " << asmMs << "\n"; + std::cout << "建体耗时(ms) : " << buildMs << "\n"; + std::cout << "落盘耗时(ms) : " << writeMs << "\n"; + std::cout << "金字塔耗时(ms) : " << pyrMs << "\n"; + std::cout << "体维度 : " << nx << " x " << ny << " x " << nz << "\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "原始体积(B) : " << rawBytes << " (" + << rawBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "data.bin(B) : " << dataBytes << " (" + << dataBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "压缩比 : " << ratio << " x\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine( + "build,line=" + line + ",cellXY=" + std::to_string(cellXY) + + ",cellZ=" + std::to_string(cellZ) + ",nx=" + std::to_string(nx) + + ",ny=" + std::to_string(ny) + ",nz=" + std::to_string(nz) + + ",voxels=" + std::to_string(voxels) + + ",rawB=" + std::to_string(rawBytes) + + ",dataB=" + std::to_string(dataBytes) + + ",ratio=" + std::to_string(ratio) + ",asmMs=" + std::to_string(asmMs) + + ",buildMs=" + std::to_string(buildMs) + + ",writeMs=" + std::to_string(writeMs) + + ",pyrMs=" + std::to_string(pyrMs) + ",peakMB=" + std::to_string(peak)); + return 0; +} + +// ============================================================================ +// build-stream:多线合并流式建大体(Track B 总验收) +// ============================================================================ +// +// 把工区目录下全部测线(各 14 通道 .iprb + 该线 .ord)流式建成【一个连续合并大体】: +// 1) 扫目录发现所有线号; +// 2) 定合并网格:沿 X 顺序排列(线 i 接在线 i-1 之后,按 brick 对齐对齐到 64 格边界), +// Y/Z 取各线最大值——退路近似(report 标注),证明大体流式建得出来且内存有界; +// 3) 全局量化:单遍扫所有线所有 slab 定全局 vmin/vmax(一次只持一个 64 道 slab); +// 4) 单个 StreamingVolumeWriter 跨线逐 slab 逐 brick 写(各线落在合并网格对应 X 区域), +// 全程不持整卷/不持整线 survey; +// 5) buildPyramidStreaming → finalize。 +// +// 复用 buildGprVolumeStreaming 的 slab/采样核机制(sampleGprPoint + StreamingVolumeWriter), +// 仅在 X 方向把多线拼到同一 store。 + +constexpr int kStreamBrick = 64; // 与 StreamingVolumeWriter/Store 内部 brick 一致 + +int ceilDivInt(int n, int b) { return (n + b - 1) / b; } +int extentOf(int n, int b, int brick) { + const int got = n - b * brick; + return got < brick ? got : brick; +} + +// 发现工区内全部线号(三位零填充,如 "001"):扫 .ord,取 "*_.ord" 的 NNN。 +std::vector discoverLines(const std::string& dir) { + std::vector lines; + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + if (e.path().extension().string() != ".ord") continue; + const std::string stem = e.path().stem().string(); // 明星路_001 + const std::size_t us = stem.find_last_of('_'); + if (us == std::string::npos) continue; + const std::string num = stem.substr(us + 1); + bool allDigit = !num.empty(); + for (char c : num) + if (!std::isdigit(static_cast(c))) allDigit = false; + if (allDigit) lines.push_back(num); + } + std::sort(lines.begin(), lines.end()); + return lines; +} + +// 一条线的几何 + 道距 + 全线总道数(不持整线:1 道 slab 取标尺,header 取总道数)。 +struct LineGeom { + int samples = 0; + std::int64_t totalTraces = 0; + double dx = 1.0, dz = 1.0; + std::vector channelY; // 升序 + int nx = 0, ny = 0, nz = 0; // 该线在合并网格下的体素维度(X 未对齐 brick) +}; + +// 全线总道数 = min 通道(fileBytes/(samples*2)),与 assembleGprSurvey 对齐口径一致。 +std::int64_t totalTracesOf(const std::vector& iprb, int samples) { + std::int64_t minTr = std::numeric_limits::max(); + const std::int64_t per = static_cast(samples) * 2; + for (const auto& p : iprb) { + const std::int64_t bytes = static_cast(fs::file_size(p)); + if (per <= 0) throw std::runtime_error("samples<=0"); + minTr = std::min(minTr, bytes / per); + } + return minTr; +} + +int cmdBuildStream(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc build-stream [--cellXY 0.05] " + "[--cellZ 0.05] [--out ] [--levels 3] " + "[--sliceXBricks 8] [--maxLines N]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double cellXY = std::stod(a.get("cellXY", "0.05")); + const double cellZ = std::stod(a.get("cellZ", "0.05")); + const int levels = std::stoi(a.get("levels", "3")); + int sliceXBricks = std::stoi(a.get("sliceXBricks", "8")); + if (sliceXBricks <= 0) sliceXBricks = 1; + const int maxLines = std::stoi(a.get("maxLines", "0")); // 0=全部 + const std::string out = + a.get("out", (fs::temp_directory_path() / "gpr_store_merged").string()); + + std::cout << "[build-stream] dir=" << dir << " cellXY=" << cellXY + << " cellZ=" << cellZ << " levels=" << levels + << " sliceXBricks=" << sliceXBricks << " out=" << out << "\n"; + + // 1) 发现线号。 + std::vector lineNos = discoverLines(dir); + if (maxLines > 0 && static_cast(lineNos.size()) > maxLines) + lineNos.resize(maxLines); + std::cout << "[build-stream] 发现测线数=" << lineNos.size() << "\n"; + if (lineNos.empty()) { + std::cerr << "[build-stream] 错误: 未发现任何 .ord 测线\n"; + return 1; + } + + Stopwatch swTotal; + + // 2) 各线文件 + 几何(1 道 slab 取标尺,不持整线)。 + std::vector files; + std::vector geom; + files.reserve(lineNos.size()); + geom.reserve(lineNos.size()); + for (const std::string& ln : lineNos) { + LineFiles lf = discoverLine(dir, ln); + if (lf.iprb.empty() || lf.ord.empty()) { + std::cerr << "[build-stream] 警告: 线 " << ln << " 缺 iprb/ord,跳过\n"; + continue; + } + LineGeom g; + // 1 道 slab:取 dx/dz/samples/channelY(内存只随 1 道)。 + const geopro::core::GprSurvey s0 = + geopro::io::gpr::assembleGprSurveySlab(lf.iprb, lf.ord, 0, 1); + g.samples = s0.samples; + g.dx = s0.dx; + g.dz = s0.dz; + g.channelY = s0.channelY; + g.totalTraces = totalTracesOf(lf.iprb, g.samples); + + // 该线在合并网格下维度(X/Z 落格,Y 跨通道):与 specFromSurvey 同式。 + const double rangeX = (g.totalTraces > 1) ? (g.totalTraces - 1) * g.dx : 0.0; + const double rangeY = + g.channelY.empty() ? 0.0 : (g.channelY.back() - g.channelY.front()); + const double rangeZ = (g.samples > 1) ? (g.samples - 1) * g.dz : 0.0; + auto cells = [](double range, double cell) { + if (cell <= 0.0) return 1; + return static_cast(std::ceil(range / cell)) + 1; + }; + g.nx = cells(rangeX, cellXY); + g.ny = cells(rangeY, cellXY); + g.nz = cells(rangeZ, cellZ); + + std::cout << "[build-stream] 线 " << ln << " 通道=" << lf.iprb.size() + << " 道数=" << g.totalTraces << " samples=" << g.samples + << " nx=" << g.nx << " ny=" << g.ny << " nz=" << g.nz << "\n"; + files.push_back(std::move(lf)); + geom.push_back(std::move(g)); + } + if (files.empty()) { + std::cerr << "[build-stream] 错误: 无可用测线\n"; + return 1; + } + + // 3) 合并网格(沿 X 顺序排列;各线 X 起点对齐到 brick 边界)。 + // 每线占 [xBrickOffset, xBrickOffset + ceil(nx/brick)) 的 brick 列。 + std::vector xBrickOffset(files.size()); + int mergedBx = 0, mergedNy = 0, mergedNz = 0; + for (std::size_t i = 0; i < files.size(); ++i) { + xBrickOffset[i] = mergedBx; + mergedBx += ceilDivInt(geom[i].nx, kStreamBrick); + mergedNy = std::max(mergedNy, geom[i].ny); + mergedNz = std::max(mergedNz, geom[i].nz); + } + const int mergedNx = mergedBx * kStreamBrick; // 末线 brick 对齐后整宽 + const int bY = ceilDivInt(mergedNy, kStreamBrick); + const int bZ = ceilDivInt(mergedNz, kStreamBrick); + + std::cout << "[build-stream] 合并网格 nx=" << mergedNx << " ny=" << mergedNy + << " nz=" << mergedNz << " (bX=" << mergedBx << " bY=" << bY + << " bZ=" << bZ << ")\n"; + + // 每线网格 spec(origin 沿 X 平移到该线 brick 起点的世界 X)。 + auto specForLine = [&](std::size_t i) { + geopro::core::GridSpec spec{}; + spec.ox = xBrickOffset[i] * kStreamBrick * cellXY; // 该线在合并体的世界 X 起点 + spec.oy = geom[i].channelY.empty() ? 0.0 : geom[i].channelY.front(); + spec.oz = 0.0; + spec.dx = cellXY; + spec.dy = cellXY; + spec.dz = cellZ; + spec.nx = geom[i].nx; + spec.ny = geom[i].ny; + spec.nz = geom[i].nz; + spec.power = 2.0; + spec.maxDist = cellXY * 2.0; + return spec; + }; + + // 4) 全局量化:单遍扫所有线所有 slab(一次只持一个 64 道 slab)。 + std::cout << "[build-stream] 扫全局量化区间...\n"; + Stopwatch swScan; + double vmin = std::numeric_limits::infinity(); + double vmax = -std::numeric_limits::infinity(); + constexpr std::int64_t kScanChunk = 64; + for (std::size_t i = 0; i < files.size(); ++i) { + const std::int64_t total = geom[i].totalTraces; + for (std::int64_t t0 = 0; t0 < total; t0 += kScanChunk) { + const std::int64_t t1 = std::min(total, t0 + kScanChunk); + const auto slab = geopro::io::gpr::assembleGprSurveySlab( + files[i].iprb, files[i].ord, t0, t1); + for (double v : slab.values) { + if (std::isnan(v)) continue; + if (v < vmin) vmin = v; + if (v > vmax) vmax = v; + } + } + } + if (!(vmin <= vmax)) { + vmin = 0.0; + vmax = 0.0; + } + const double scanMs = swScan.elapsedMs(); + std::cout << "[build-stream] 全局值域 [" << vmin << ", " << vmax << "] 扫描 " + << scanMs << "ms\n"; + + geopro::core::Quant quant; + quant.scale = (vmax > vmin) ? (vmax - vmin) / 64000.0 : 1.0; + quant.offset = 0.5 * (vmin + vmax); + + // 5) 合并 StoreMeta + 单个 StreamingVolumeWriter。 + geopro::data::StoreMeta meta; + meta.nx = mergedNx; + meta.ny = mergedNy; + meta.nz = mergedNz; + meta.brick = kStreamBrick; + meta.origin = {0.0, 0.0, 0.0}; + meta.spacing = {cellXY, cellXY, cellZ}; + meta.quant = quant; + meta.vminPhys = vmin; + meta.vmaxPhys = vmax; + + fs::create_directories(out); + geopro::data::StreamingVolumeWriter writer(out, meta); + + // 6) 跨线逐 slab 逐 brick 写。每线在自己的 brick 列区间内沿 X 分 slab; + // 合并网格 brick (mergedBx, by, bz): + // - 落在某线列区间内且该线有覆盖 → sampleGprPoint(线局部索引); + // - 否则 → blank(线间填充 + 该线 ny/nz 之外的合并余量)。 + std::cout << "[build-stream] 流式写合并大体...\n"; + Stopwatch swBuild; + for (std::size_t i = 0; i < files.size(); ++i) { + const geopro::core::GridSpec spec = specForLine(i); + const int lineBx = ceilDivInt(geom[i].nx, kStreamBrick); + const int lineBy = ceilDivInt(geom[i].ny, kStreamBrick); + const int lineBz = ceilDivInt(geom[i].nz, kStreamBrick); + const std::int64_t total = geom[i].totalTraces; + const double surveyDx = geom[i].dx > 0.0 ? geom[i].dx : 1.0; + + // 沿 X 分 slab(brick 对齐),每 slab 含 sliceXBricks 个 X brick。 + for (int bcol = 0; bcol < lineBx; bcol += sliceXBricks) { + const int bxEnd = std::min(lineBx, bcol + sliceXBricks); + const int gx0 = bcol * kStreamBrick; + const int gx1 = std::min(spec.nx, bxEnd * kStreamBrick); + + // 该 slab 网格 X 列 → 全局道范围(夹到 [0,total)),可能全越界。 + std::int64_t t0 = std::numeric_limits::max(); + std::int64_t t1 = std::numeric_limits::min(); + for (int gi = gx0; gi < gx1; ++gi) { + const double worldX = gi * cellXY; // 线局部世界 X(spec.ox 已含偏移,但落格用线内 x0=0) + const std::int64_t g = std::llround(worldX / surveyDx); + if (g < 0 || g >= total) continue; + t0 = std::min(t0, g); + t1 = std::max(t1, g); + } + const bool hasTraces = (t0 <= t1); + geopro::core::GprSurvey slab; + // 线局部 spec:x0=0 落格(与 assembleGprSurveySlab 的 x0=t0*dx 对齐靠 worldX)。 + geopro::core::GridSpec localSpec = spec; + localSpec.ox = 0.0; // 采样核用线局部坐标 + if (hasTraces) { + slab = geopro::io::gpr::assembleGprSurveySlab(files[i].iprb, + files[i].ord, t0, t1 + 1); + } + + // 写该 slab 覆盖的合并 brick:X 列 [bcol,bxEnd) → 合并列 +xBrickOffset[i], + // Y/Z 全程(含该线 ny/nz 之外的合并余量 → blank)。 + for (int bz = 0; bz < bZ; ++bz) { + for (int by = 0; by < bY; ++by) { + for (int lbx = bcol; lbx < bxEnd; ++lbx) { + const int mbx = xBrickOffset[i] + lbx; // 合并 brick X 索引 + const int bw = extentOf(mergedNx, mbx, kStreamBrick); + const int bh = extentOf(mergedNy, by, kStreamBrick); + const int bd = extentOf(mergedNz, bz, kStreamBrick); + std::vector voxels( + static_cast(bw) * bh * bd); + // 该 brick 是否落在线自身覆盖范围内(线 brick 网格内)。 + const bool inLine = + (lbx < lineBx && by < lineBy && bz < lineBz && hasTraces); + if (!inLine) { + std::fill(voxels.begin(), voxels.end(), + geopro::core::ScalarVolumeI16::kBlank); + } else { + const int i0 = lbx * kStreamBrick, j0 = by * kStreamBrick, + k0 = bz * kStreamBrick; + std::size_t wi = 0; + for (int kk = 0; kk < bd; ++kk) { + for (int jj = 0; jj < bh; ++jj) { + for (int ii = 0; ii < bw; ++ii) { + const int gi = i0 + ii, gj = j0 + jj, gk = k0 + kk; + // 线网格之外的余格(合并 brick 比线 brick 大)→ blank。 + if (gi >= spec.nx || gj >= spec.ny || gk >= spec.nz) { + voxels[wi++] = geopro::core::ScalarVolumeI16::kBlank; + } else { + voxels[wi++] = geopro::core::sampleGprPoint( + slab, localSpec, gi, gj, gk, quant); + } + } + } + } + } + writer.writeBrick(mbx, by, bz, voxels); + } + } + } + } + std::cout << "[build-stream] 线 " << lineNos[i] << " 写入完成 (" + << (i + 1) << "/" << files.size() + << ") 峰值内存(MB)=" << Probe::peakMemMB() << "\n"; + } + writer.finalize(); + const double buildMs = swBuild.elapsedMs(); + + // 7) 流式金字塔。 + std::cout << "[build-stream] 流式金字塔 levels=" << levels << "...\n"; + Stopwatch swPyr; + { + geopro::data::ChunkedVolumeStore store(out); + store.buildPyramidStreaming(levels); + } + const double pyrMs = swPyr.elapsedMs(); + + const std::int64_t voxels = + static_cast(mergedNx) * mergedNy * mergedNz; + const std::int64_t rawBytes = voxels * 2; + const std::int64_t dataBytes = storeDataBytes(out); + const double ratio = + dataBytes > 0 ? static_cast(rawBytes) / dataBytes : 0.0; + const double totalMs = swTotal.elapsedMs(); + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== build-stream 指标(多线合并大体)===\n"; + std::cout << "合并方式 : 沿 X 顺序排列(退路近似,brick 对齐)\n"; + std::cout << "测线数 : " << files.size() << "\n"; + std::cout << "合并体维度 : " << mergedNx << " x " << mergedNy << " x " + << mergedNz << "\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "原始体积(B) : " << rawBytes << " (" + << rawBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "data.bin(B) : " << dataBytes << " (" + << dataBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "压缩比 : " << ratio << " x\n"; + std::cout << "扫量化耗时(ms) : " << scanMs << "\n"; + std::cout << "建体耗时(ms) : " << buildMs << "\n"; + std::cout << "金字塔耗时(ms) : " << pyrMs << "\n"; + std::cout << "总耗时(ms) : " << totalMs << "\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine( + "build-stream,lines=" + std::to_string(files.size()) + + ",cellXY=" + std::to_string(cellXY) + ",cellZ=" + std::to_string(cellZ) + + ",nx=" + std::to_string(mergedNx) + ",ny=" + std::to_string(mergedNy) + + ",nz=" + std::to_string(mergedNz) + ",voxels=" + std::to_string(voxels) + + ",rawB=" + std::to_string(rawBytes) + + ",dataB=" + std::to_string(dataBytes) + + ",ratio=" + std::to_string(ratio) + ",scanMs=" + std::to_string(scanMs) + + ",buildMs=" + std::to_string(buildMs) + + ",pyrMs=" + std::to_string(pyrMs) + + ",totalMs=" + std::to_string(totalMs) + + ",peakMB=" + std::to_string(peak)); + return 0; +} + +// ============================================================================ +// build-geo:按真实 RTK 几何把多线插值进统一路向网格(Task G1) +// ============================================================================ +// +// 消除 build-stream 顺序拼接的退化扁带:各线 .gps RTK 轨迹 → 经纬 → 局部米 → +// PCA 路向旋转 → 道按里程均匀分布定位、14 通道横偏垂直航向摆放 → 全部插进统一 +// 路向网格(≈4472×43×81)重叠取均值 → 量化写 ChunkedVolumeStore + 金字塔。 +int cmdBuildGeo(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc build-geo [--cellXY 0.5] [--cellZ 0.1] " + "[--out ] [--levels 4] [--maxLines N] " + "[--curvilinear]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double cellXY = std::stod(a.get("cellXY", "0.5")); + const double cellZ = std::stod(a.get("cellZ", "0.1")); + const int levels = std::stoi(a.get("levels", "4")); + const int maxLines = std::stoi(a.get("maxLines", "0")); + const bool curvilinear = a.kv.count("curvilinear") > 0; // 曲线版(G2) + const std::string out = + a.get("out", (fs::temp_directory_path() / "gpr_store_geo").string()); + + std::cout << "[build-geo] dir=" << dir << " cellXY=" << cellXY + << " cellZ=" << cellZ << " levels=" << levels + << " mode=" << (curvilinear ? "曲线(中心线)" : "PCA") + << " out=" << out << "\n"; + + // 发现线号 → 各线 iprb/ord(复用 discoverLine)+ .gps(按线号匹配)。 + std::vector lineNos = discoverLines(dir); + if (maxLines > 0 && static_cast(lineNos.size()) > maxLines) + lineNos.resize(maxLines); + std::cout << "[build-geo] 发现测线数=" << lineNos.size() << "\n"; + if (lineNos.empty()) { + std::cerr << "[build-geo] 错误: 未发现任何 .ord 测线\n"; + return 1; + } + + std::vector lines; + for (const std::string& ln : lineNos) { + const LineFiles lf = discoverLine(dir, ln); + if (lf.iprb.empty() || lf.ord.empty()) { + std::cerr << "[build-geo] 警告: 线 " << ln << " 缺 iprb/ord,跳过\n"; + continue; + } + // .gps:匹配 "*_.gps"。 + std::string gps; + for (const auto& e : fs::directory_iterator(dir)) { + if (!e.is_regular_file()) continue; + if (e.path().extension().string() != ".gps") continue; + const std::string stem = e.path().stem().string(); + const std::size_t us = stem.find_last_of('_'); + if (us != std::string::npos && stem.substr(us + 1) == ln) { + gps = e.path().string(); + break; + } + } + if (gps.empty()) { + std::cerr << "[build-geo] 警告: 线 " << ln << " 缺 .gps,跳过\n"; + continue; + } + geopro::core::GeoLineInput in; + in.iprb = lf.iprb; + in.ord = lf.ord; + in.gps = gps; + lines.push_back(std::move(in)); + } + if (lines.empty()) { + std::cerr << "[build-geo] 错误: 无可用测线\n"; + return 1; + } + std::cout << "[build-geo] 可用测线数=" << lines.size() << "\n"; + + fs::create_directories(out); + Stopwatch sw; + const geopro::core::GeoGridSpec spec{cellXY, cellZ}; + const geopro::core::GeoBuildResult r = + geopro::core::buildGeoVolume(lines, spec, out, levels, curvilinear); + const double buildMs = sw.elapsedMs(); + + const double fillRate = + r.total > 0 ? static_cast(r.filled) / r.total : 0.0; + const double rotDeg = r.rotRad * 180.0 / 3.14159265358979323846; + const std::int64_t dataBytes = storeDataBytes(out); + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== build-geo 指标(真实 RTK 几何统一路向体)===\n"; + std::cout << "网格模式 : " << (curvilinear ? "曲线(中心线展开)" : "PCA 旋转") + << "\n"; + std::cout << "测线数 : " << lines.size() << "\n"; + std::cout << "体维度 : " << r.nx << " x " << r.ny << " x " << r.nz + << "\n"; + std::cout << "总 cell : " << r.total << "\n"; + std::cout << "非空 cell : " << r.filled << "\n"; + std::cout << "填充率 : " << (fillRate * 100.0) << " %\n"; + std::cout << "路向旋转角 : " << rotDeg << " ° (" << r.rotRad << " rad)\n"; + std::cout << "网格原点(m) : (" << r.oxM << ", " << r.oyM << ")\n"; + std::cout << "data.bin(B) : " << dataBytes << " (" + << dataBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "建体总耗时(ms) : " << buildMs << "\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine( + "build-geo,lines=" + std::to_string(lines.size()) + + ",cellXY=" + std::to_string(cellXY) + ",cellZ=" + std::to_string(cellZ) + + ",nx=" + std::to_string(r.nx) + ",ny=" + std::to_string(r.ny) + + ",nz=" + std::to_string(r.nz) + ",total=" + std::to_string(r.total) + + ",filled=" + std::to_string(r.filled) + + ",fillRate=" + std::to_string(fillRate) + + ",rotDeg=" + std::to_string(rotDeg) + + ",buildMs=" + std::to_string(buildMs) + + ",peakMB=" + std::to_string(peak)); + return 0; +} + +// ============================================================================ +// build-line:走 gpr3dv(P1) 处理链→处理后立方体→geopro 量化体→分块+金字塔(P2) +// ============================================================================ +// +// 与 build/build-stream/build-geo 不同:本命令不走 geopro 自家解析+采样核建体, +// 而是【复用 vendored gpr3dv 的读+处理链】(算法零改动)产出处理后立方体 +// volumeData[通道][道][样本],再桥接(Gpr3dvVolumeBridge)成 geopro int16 量化体 +// (轴 X=道/Y=通道/Z=样本),落 ChunkedVolumeStore + buildPyramid。原样渲(14 格 Y +// 薄体),不做横向插值加密。 +// 磁盘剩余空间(GB):查 path 所在卷可用字节。失败(路径不存在等)→ -1(视为未知)。 +double freeSpaceGB(const std::string& path) { + std::error_code ec; + // 用已存在的父目录查(out 目录可能还没建)。 + fs::path p = fs::absolute(path, ec); + while (!p.empty() && !fs::exists(p, ec)) p = p.parent_path(); + if (p.empty()) p = fs::current_path(ec); + const fs::space_info si = fs::space(p, ec); + if (ec) return -1.0; + return static_cast(si.available) / (1024.0 * 1024.0 * 1024.0); +} + +// 单线建体结果(供 build-all 汇总,不编造)。ok=false 时 reason 给清晰原因。 +struct LineBuildResult { + std::string prefix; + bool ok = false; + std::string reason; // 失败/跳过原因 + std::int64_t nx = 0, ny = 0, nz = 0; + std::int64_t dataBytes = 0; +}; + +// 单线建体核心:gpr3dv 处理链 → 桥接量化体(可 coarse 下采样) → 落盘 + 金字塔。 +// 异常(加载失败/立方体空/短桩线维度退化)由调用方捕获,不在此中断批量。 +LineBuildResult buildOneLine(const std::string& lineDir, + const std::string& linePrefix, + const std::string& out, int levels, int coarse, + double targetDy) { + LineBuildResult r; + r.prefix = linePrefix; + + std::cout << "[build-line] lineDir=" << lineDir << " linePrefix=" << linePrefix + << " levels=" << levels << " coarse=" << coarse << " out=" << out + << "\n"; + + // 1) gpr3dv 处理链 → 处理后立方体 → 桥接量化体(coarse 沿测线下采样)。 + Stopwatch swBridge; + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built = geopro::io::gpr::buildLineVolumeFromGpr3dv( + lineDir, linePrefix, &bm, coarse, targetDy); + const double bridgeMs = swBridge.elapsedMs(); + + const std::int64_t nx = built.vol.nx(), ny = built.vol.ny(), + nz = built.vol.nz(); + r.nx = nx; + r.ny = ny; + r.nz = nz; + // 短桩线/退化体守护:维度过小无法建有意义的金字塔/体绘制 → 报因跳过(不落盘)。 + if (nx < 2 || ny < 1 || nz < 2) { + r.ok = false; + r.reason = "体维度退化(道×通道×样本=" + std::to_string(nx) + "x" + + std::to_string(ny) + "x" + std::to_string(nz) + + "),无法建可看体,跳过"; + std::cerr << "[build-line] " << linePrefix << " " << r.reason << "\n"; + return r; + } + const std::int64_t voxels = nx * ny * nz; + const std::int64_t rawBytes = voxels * 2; // int16 + + std::cout << "[build-line] 处理前后平均绝对幅值: " << bm.meanAbsBefore << " → " + << bm.meanAbsAfter << " (处理" + << (std::abs(bm.meanAbsAfter - bm.meanAbsBefore) > 1e-9 ? "已生效" + : "未变化") + << ")\n"; + std::cout << "[build-line] 体维度(道×通道×样本) = " << nx << " x " << ny + << " x " << nz << "\n"; + std::cout << "[build-line] 世界 spacing dx=" << bm.dx << " dy=" << bm.dy + << " dz=" << bm.dz << " (m)\n"; + + // 2) 落盘 + 金字塔(道很长 → 需 levels≥2 使最粗层≤16384)。 + fs::create_directories(out); + Stopwatch swWrite; + geopro::data::ChunkedVolumeStore::write(out, built); + const double writeMs = swWrite.elapsedMs(); + + Stopwatch swPyr; + { + geopro::data::ChunkedVolumeStore store(out); + store.buildPyramid(levels); + } + const double pyrMs = swPyr.elapsedMs(); + + const std::int64_t dataBytes = storeDataBytes(out); + r.dataBytes = dataBytes; + const double ratio = + dataBytes > 0 ? static_cast(rawBytes) / dataBytes : 0.0; + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== build-line 指标(gpr3dv 处理后真三维体)===\n"; + std::cout << "桥接耗时(ms) : " << bridgeMs << " (含 读 " << bm.loadMs + << " + 处理 " << bm.pipelineMs << " + 量化填体 " << bm.fillMs + << ")\n"; + std::cout << "落盘耗时(ms) : " << writeMs << "\n"; + std::cout << "金字塔耗时(ms) : " << pyrMs << "\n"; + std::cout << "体维度 : " << nx << " x " << ny << " x " << nz << "\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "处理后值域 : [" << bm.vminPhys << ", " << bm.vmaxPhys + << "] 量化 scale=" << built.quant.scale + << " offset=" << built.quant.offset << "\n"; + std::cout << "世界 spacing : dx=" << bm.dx << " dy=" << bm.dy + << " dz=" << bm.dz << " (m)\n"; + std::cout << "原始体积(B) : " << rawBytes << " (" + << rawBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "data.bin(B) : " << dataBytes << " (" + << dataBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "压缩比 : " << ratio << " x\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine( + "build-line,prefix=" + linePrefix + ",coarse=" + std::to_string(coarse) + + ",nx=" + std::to_string(nx) + ",ny=" + std::to_string(ny) + + ",nz=" + std::to_string(nz) + ",voxels=" + std::to_string(voxels) + + ",vmin=" + std::to_string(bm.vminPhys) + + ",vmax=" + std::to_string(bm.vmaxPhys) + + ",dx=" + std::to_string(bm.dx) + ",dy=" + std::to_string(bm.dy) + + ",dz=" + std::to_string(bm.dz) + ",rawB=" + std::to_string(rawBytes) + + ",dataB=" + std::to_string(dataBytes) + + ",ratio=" + std::to_string(ratio) + + ",bridgeMs=" + std::to_string(bridgeMs) + + ",writeMs=" + std::to_string(writeMs) + + ",pyrMs=" + std::to_string(pyrMs) + ",peakMB=" + std::to_string(peak)); + + r.ok = true; + return r; +} + +// ess-stat:实测一条线密体的"空体素跳过(ESS)潜力"——按块(默认 16³)算 min/max,统计有多少块 +// 整段值落在"近零背景带"(在 V 形不透明传函里≈透明)内 → 这些块 ESS 会整块跳过。空块占比≈ESS +// 提速潜力。零依赖、跑在真实数据上,是上不上 OSPRay/ESS 的关键决策数。 +int cmdEssStat(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.size() < 2) { + std::cerr << "用法: gpr_poc ess-stat [--coarse 8] " + "[--targetDy 0.025] [--block 16]\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string prefix = a.positional[1]; + const int coarse = std::stoi(a.get("coarse", "8")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); + const int B = std::max(1, std::stoi(a.get("block", "16"))); + + geopro::io::gpr::BridgeMetrics bm; + geopro::core::BuiltI16 built = + geopro::io::gpr::buildLineVolumeFromGpr3dv(lineDir, prefix, &bm, coarse, + targetDy); + const auto& vol = built.vol; + const int nx = vol.nx(), ny = vol.ny(), nz = vol.nz(); + const double center = built.quant.offset; + const double half = + std::max(1e-9, 0.5 * (built.vmaxPhys - built.vminPhys)); + std::cout << "[ess-stat] " << prefix << " 体维度=" << nx << "x" << ny << "x" + << nz << " 值域=[" << built.vminPhys << "," << built.vmaxPhys + << "] 块=" << B << "³\n"; + + // 几个"透明带半宽"阈值(相对半值域):背景带越宽,可跳块越多(但太宽会跳掉弱反射)。 + const double taus[] = {0.05, 0.10, 0.15, 0.20, 0.30}; + constexpr int NT = 5; + long total = 0, skip[NT] = {0, 0, 0, 0, 0}; + // 同时统计"近零体素占比"(单体素 < 0.1 半值域)作直觉参考。 + long voxNear = 0, voxTotal = 0; + + for (int z0 = 0; z0 < nz; z0 += B) + for (int y0 = 0; y0 < ny; y0 += B) + for (int x0 = 0; x0 < nx; x0 += B) { + short mn = std::numeric_limits::max(); + short mx = std::numeric_limits::min(); + const int x1 = std::min(x0 + B, nx), y1 = std::min(y0 + B, ny), + z1 = std::min(z0 + B, nz); + for (int z = z0; z < z1; ++z) + for (int y = y0; y < y1; ++y) + for (int x = x0; x < x1; ++x) { + const short v = vol.at(x, y, z); + if (v < mn) mn = v; + if (v > mx) mx = v; + const double p = built.quant.toPhys(v); + ++voxTotal; + if (std::abs(p - center) < 0.10 * half) ++voxNear; + } + ++total; + const double pmn = built.quant.toPhys(mn); + const double pmx = built.quant.toPhys(mx); + const double dev = std::max(std::abs(pmn - center), std::abs(pmx - center)); + for (int t = 0; t < NT; ++t) + if (dev < taus[t] * half) ++skip[t]; + } + + std::cout << "[ess-stat] 近零体素占比(<0.1 半值域) = " + << (voxTotal ? 100.0 * voxNear / voxTotal : 0.0) << "%\n"; + std::cout << "[ess-stat] 可跳块占比(块内全段落在透明带) / 对应 ESS 理论提速:\n"; + for (int t = 0; t < NT; ++t) { + const double frac = total ? static_cast(skip[t]) / total : 0.0; + const double speedup = frac < 0.999 ? 1.0 / (1.0 - frac) : 999.0; + std::cout << " 透明带半宽 " << static_cast(taus[t] * 100) + << "% 半值域 → 可跳块 " << 100.0 * frac << "% → 理论上限 ~" + << speedup << "×\n"; + } + std::cout << "[ess-stat] 注:理论上限=1/(1-可跳块占比),实际 OSPRay 还有 SIMD/光线相干等增益," + "但非空块(反射层)仍要采。可跳块>80% 即 ESS 大幅值得上。\n"; + return 0; +} + +vtkSmartPointer makeOffscreenWindow(int w, int h); // 前置声明(定义在后) + +// passcost:确诊 P11/P12"各线独立 mapper + LOD 仍 0.5fps"卡在哪。 +// N 个独立 vtkSmartVolumeMapper(GPU),每个渲一个 size³ 体,测离屏稳态 fps vs N。 +// --overlap 0:N 个体【铺开】不重叠(光线各穿 ~1 个)→ 隔离"每遍固定开销"(嫌疑2); +// --overlap 1:N 个体【叠在一起】重叠(光线穿 N 个)→ 隔离"重叠采样"(嫌疑3)。 +// 铺开随 N 线性掉=固定开销(嫌疑2,最坏);铺开不掉、叠加掉=重叠(嫌疑3);都不掉=选区没调小(嫌疑1,最好)。 +int cmdPassCost(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 0); + const int S = std::stoi(a.get("size", "64")); // 每体边长(模拟 LOD 削小后的小区) + const bool overlap = std::stoi(a.get("overlap", "0")) != 0; + const int W = 1400, H = 900; + + // 合成一个 S³ 体(含非平凡图案,避免被早终止/空跳优化掉)。 + auto img = vtkSmartPointer::New(); + img->SetDimensions(S, S, S); + img->SetSpacing(1.0, 1.0, 1.0); + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(S) * S * S); + for (int z = 0; z < S; ++z) + for (int y = 0; y < S; ++y) + for (int x = 0; x < S; ++x) { + const double v = 200.0 * std::sin(0.3 * x) * std::cos(0.3 * z) + 300.0; + arr->SetValue((static_cast(z) * S + y) * S + x, + static_cast(v)); + } + img->GetPointData()->SetScalars(arr); + + auto prop = vtkSmartPointer::New(); + vtkNew col; + col->AddRGBPoint(0, 0.1, 0.1, 0.2); + col->AddRGBPoint(600, 0.95, 0.95, 0.9); + vtkNew op; + op->AddPoint(0, 0.0); + op->AddPoint(300, 0.05); + op->AddPoint(600, 0.4); + prop->SetColor(col); + prop->SetScalarOpacity(op); + prop->SetInterpolationTypeToLinear(); + + std::cout << "=== passcost:N 个独立 GPU mapper × " << S << "³ 体 (" + << (overlap ? "叠在一起/重叠" : "铺开/不重叠") << ") ===\n"; + std::cout << "N fps(离屏稳态)\n"; + for (int N : {1, 3, 5, 10, 20}) { + auto rw = makeOffscreenWindow(W, H); + vtkNew ren; + ren->SetBackground(0.05, 0.05, 0.08); + rw->AddRenderer(ren); + std::vector> ms; + std::vector> vs; + for (int n = 0; n < N; ++n) { + auto m = vtkSmartPointer::New(); + m->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + m->SetInputData(img); + auto v = vtkSmartPointer::New(); + v->SetMapper(m); + v->SetProperty(prop); + // 铺开:沿 X 排开 S 间距(不重叠);重叠:全堆在原点。 + if (!overlap) v->SetPosition(static_cast(n) * S * 1.05, 0, 0); + ren->AddVolume(v); + ms.push_back(m); + vs.push_back(v); + } + ren->ResetCamera(); + rw->Render(); // 预热(编译着色器/上传纹理) + rw->Render(); + Stopwatch sw; + const int F = 30; + for (int f = 0; f < F; ++f) rw->Render(); + const double ms_per = sw.elapsedMs(); + const double fps = ms_per > 0 ? F * 1000.0 / ms_per : 0.0; + std::cout << N << " " << fps << "\n"; + } + std::cout << "判读:铺开随 N 线性掉=每遍固定开销(嫌疑2,最坏,逼架构取舍);" + "铺开不掉/叠加掉=重叠(嫌疑3);都不怎么掉=0.5fps 是选区没调小(嫌疑1,最好,可修)。\n"; + return 0; +} + +int cmdBuildLine(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.size() < 2) { + std::cerr << "用法: gpr_poc build-line " + "--out [--levels 3] [--coarse F]\n" + "例: gpr_poc build-line \"D:/Downloads/明星路\" 明星路_001 " + "--out tmp/line001_proc --levels 3 --coarse 4\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string linePrefix = a.positional[1]; + const int levels = std::stoi(a.get("levels", "3")); + const int coarse = std::stoi(a.get("coarse", "1")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); + const std::string out = + a.get("out", (fs::temp_directory_path() / "gpr_store_line").string()); + + try { + const LineBuildResult r = + buildOneLine(lineDir, linePrefix, out, levels, coarse, targetDy); + if (!r.ok) { + std::cerr << "[build-line] 跳过: " << r.reason << "\n"; + return 1; + } + return 0; + } catch (const std::exception& e) { + std::cerr << "[build-line] 失败(" << linePrefix << "): " << e.what() << "\n"; + return 1; + } +} + +// build-all:发现目录下所有测线(_Axx 分组),逐条 build-line 到 baseDir//。 +// 磁盘守护:每条建前查可用空间,低于阈值(默认 3GB)即停并报已建哪些。 +// 短桩线/异常单条捕获并跳过(报因),不中断其余。 +int cmdBuildAll(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty() || !a.kv.count("outBase")) { + std::cerr << "用法: gpr_poc build-all --outBase " + "[--levels 3] [--coarse F] [--minFreeGB 3]\n" + "例: gpr_poc build-all \"D:/Downloads/明星路\" " + "--outBase tmp/lines_all --levels 3 --coarse 4\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string outBase = a.get("outBase", ""); + const int levels = std::stoi(a.get("levels", "3")); + const int coarse = std::stoi(a.get("coarse", "1")); + const double minFreeGB = std::stod(a.get("minFreeGB", "3")); + const double targetDy = std::stod(a.get("targetDy", "0.025")); + + // 1) 发现所有测线前缀:扫 *__A.iprh,取 "<...>_" 部分(去 _A)。 + std::set prefixSet; + std::error_code ec; + for (const auto& e : fs::directory_iterator(lineDir, ec)) { + if (!e.is_regular_file()) continue; + const std::string name = e.path().filename().string(); + if (e.path().extension().string() != ".iprh") continue; + // 找 "_A" 通道后缀,截断得测线前缀。 + const std::size_t pos = name.rfind("_A"); + if (pos == std::string::npos) continue; + std::size_t d = pos + 2; + while (d < name.size() && std::isdigit(static_cast(name[d]))) + ++d; + if (d == pos + 2) continue; // _A 后无数字 → 非通道文件 + prefixSet.insert(name.substr(0, pos)); + } + if (prefixSet.empty()) { + std::cerr << "[build-all] 未在 " << lineDir << " 发现任何测线(*_A.iprh)\n"; + return 1; + } + std::vector prefixes(prefixSet.begin(), prefixSet.end()); + std::cout << "[build-all] 发现 " << prefixes.size() << " 条测线,outBase=" + << outBase << " levels=" << levels << " coarse=" << coarse + << " minFreeGB=" << minFreeGB << "\n"; + + fs::create_directories(outBase, ec); + + // 2) 逐条建:磁盘守护 → buildOneLine(单条 try/catch)。 + std::vector results; + bool stoppedByDisk = false; + for (const std::string& prefix : prefixes) { + const double freeGB = freeSpaceGB(outBase); + std::cout << "\n[build-all] --- " << prefix << " --- 剩余磁盘 " + << freeGB << " GB\n"; + if (freeGB >= 0.0 && freeGB < minFreeGB) { + std::cerr << "[build-all] 磁盘守护触发: 剩余 " << freeGB << " GB < " + << minFreeGB << " GB,停止,未建 " << prefix << " 及其后。\n"; + stoppedByDisk = true; + break; + } + const std::string out = (fs::path(outBase) / prefix).string(); + LineBuildResult r; + r.prefix = prefix; + try { + r = buildOneLine(lineDir, prefix, out, levels, coarse, targetDy); + } catch (const std::exception& e) { + r.ok = false; + r.reason = std::string("异常: ") + e.what(); + std::cerr << "[build-all] " << prefix << " 失败: " << e.what() + << "(跳过,继续)\n"; + } + results.push_back(r); + } + + // 3) 汇总。 + std::cout << "\n=== build-all 汇总 ===\n"; + int okCount = 0; + std::int64_t totalBytes = 0; + for (const auto& r : results) { + if (r.ok) { + ++okCount; + totalBytes += r.dataBytes; + std::cout << " [OK] " << r.prefix << " 维度=" << r.nx << "x" << r.ny + << "x" << r.nz << " data=" + << r.dataBytes / (1024.0 * 1024.0) << " MB\n"; + } else { + std::cout << " [跳过] " << r.prefix << " 原因=" << r.reason << "\n"; + } + } + std::cout << "成功 " << okCount << "/" << prefixes.size() + << " 条,合计 data=" << totalBytes / (1024.0 * 1024.0 * 1024.0) + << " GB,剩余磁盘 " << freeSpaceGB(outBase) << " GB\n"; + if (stoppedByDisk) + std::cout << "注意: 因磁盘守护提前停止,部分测线未建。\n"; + return 0; +} + +// ============================================================================ +// build-survey-line / build-survey-all:测绘级精确坐标逐线世界对齐体(Task P8) +// ============================================================================ +// +// 与 build-line(线局部坐标 X=道/Y=通道/Z=样本,origin≈0)不同:本路径走 P8 测绘级桥接 +// (Gpr3dvSurveyVolumeBridge)——用 vendored 3DGPRViewer 的精确坐标/轨迹/网格代码 +// (CoordinateTransform/TrajectoryCalculator/CScanGridder,算法零改动),让单线严格按 +// CGCS2000 大地坐标、逐通道逐道跟 GPS 轨迹建成【世界轴对齐体】(跟路的弯)。体 meta.origin +// 为真实 CGCS2000 世界米,多线共享同一参考系 → view-survey-all 直接按 origin 摆放即精确就位。 + +// 按线前缀(如 "明星路_001")在 dir 下找匹配的 .gps(尾段 _NNN 相同)。空串=未找到。 +std::string findGpsForPrefix(const std::string& dir, const std::string& prefix) { + const std::size_t us = prefix.find_last_of('_'); + if (us == std::string::npos) return ""; + const std::string num = prefix.substr(us + 1); + std::error_code ec; + for (const auto& e : fs::directory_iterator(dir, ec)) { + if (!e.is_regular_file()) continue; + if (e.path().extension().string() != ".gps") continue; + const std::string stem = e.path().stem().string(); + const std::size_t s2 = stem.find_last_of('_'); + if (s2 != std::string::npos && stem.substr(s2 + 1) == num) + return e.path().string(); + } + return ""; +} + +// 单线测绘级建体核心:P8 桥接 → 世界对齐量化体 → 落盘 + 金字塔。 +// 异常(加载失败/GPS 无效/体空/退化)由调用方捕获,不在此中断批量。 +LineBuildResult buildOneSurveyLine(const std::string& lineDir, + const std::string& linePrefix, + const std::string& gpsPath, + const std::string& out, int levels, + int coarse, double cellSizeM, + double searchRadiusM) { + LineBuildResult r; + r.prefix = linePrefix; + + std::cout << "[build-survey-line] lineDir=" << lineDir + << " linePrefix=" << linePrefix << " gps=" << gpsPath + << " levels=" << levels << " coarse=" << coarse + << " cellSize=" << cellSizeM << " out=" << out << "\n"; + + Stopwatch swBridge; + geopro::io::gpr::SurveyBridgeMetrics bm; + geopro::core::BuiltI16 built = geopro::io::gpr::buildLineVolumeSurvey( + lineDir, linePrefix, gpsPath, &bm, coarse, cellSizeM, searchRadiusM); + const double bridgeMs = swBridge.elapsedMs(); + + const std::int64_t nx = built.vol.nx(), ny = built.vol.ny(), + nz = built.vol.nz(); + r.nx = nx; + r.ny = ny; + r.nz = nz; + if (nx < 2 || ny < 2 || nz < 2) { + r.ok = false; + r.reason = "世界体维度退化(东×北×深=" + std::to_string(nx) + "x" + + std::to_string(ny) + "x" + std::to_string(nz) + + "),无法建可看体,跳过"; + std::cerr << "[build-survey-line] " << linePrefix << " " << r.reason << "\n"; + return r; + } + const std::int64_t voxels = nx * ny * nz; + const std::int64_t rawBytes = voxels * 2; + const double fillRate = + bm.totalCells > 0 + ? static_cast(bm.filledCells) / bm.totalCells + : 0.0; + + std::cout << "[build-survey-line] 处理前后平均绝对幅值: " << bm.meanAbsBefore + << " → " << bm.meanAbsAfter << "\n"; + std::cout << "[build-survey-line] RTK点=" << bm.rtkPoints + << " 通道=" << bm.channels << " CGCS带号=" << bm.cgcsZone + << " 中央经线=" << bm.centralMeridianDeg << "°\n"; + std::cout << "[build-survey-line] 世界体维度(东×北×深) = " << nx << " x " << ny + << " x " << nz << " 非空体素=" << bm.filledCells << " (" + << (fillRate * 100.0) << "%)\n"; + std::cout << "[build-survey-line] CGCS2000 世界 origin=(" << std::fixed + << bm.originX << ", " << bm.originY << ") spacing cell=" << bm.cellSizeM + << " dz=" << bm.dz << " (m)\n"; + + fs::create_directories(out); + Stopwatch swWrite; + geopro::data::ChunkedVolumeStore::write(out, built); + const double writeMs = swWrite.elapsedMs(); + + Stopwatch swPyr; + { + geopro::data::ChunkedVolumeStore store(out); + store.buildPyramid(levels); + } + const double pyrMs = swPyr.elapsedMs(); + + const std::int64_t dataBytes = storeDataBytes(out); + r.dataBytes = dataBytes; + const double ratio = + dataBytes > 0 ? static_cast(rawBytes) / dataBytes : 0.0; + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== build-survey-line 指标(测绘级 CGCS2000 世界对齐体)===\n"; + std::cout << "桥接耗时(ms) : " << bridgeMs << " (读 " << bm.loadMs + << " + 处理 " << bm.pipelineMs << " + GPS轨迹 " << bm.trajMs + << " + 网格量化 " << bm.gridMs << ")\n"; + std::cout << "落盘耗时(ms) : " << writeMs << "\n"; + std::cout << "金字塔耗时(ms) : " << pyrMs << "\n"; + std::cout << "世界体维度 : " << nx << " x " << ny << " x " << nz << "\n"; + std::cout << "体素数 : " << voxels << " 非空 " << bm.filledCells + << " (" << (fillRate * 100.0) << "%)\n"; + std::cout << "处理后值域 : [" << bm.vminPhys << ", " << bm.vmaxPhys + << "] 量化 scale=" << built.quant.scale + << " offset=" << built.quant.offset << "\n"; + std::cout << "CGCS2000 origin: (" << std::fixed << bm.originX << ", " + << bm.originY << ") (东, 北) 米\n"; + std::cout << "世界 spacing : cell=" << bm.cellSizeM << " dz=" << bm.dz + << " (m)\n"; + std::cout << "data.bin(B) : " << dataBytes << " (" + << dataBytes / (1024.0 * 1024.0) << " MB) 压缩比 " << ratio + << " x\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine( + "build-survey-line,prefix=" + linePrefix + + ",coarse=" + std::to_string(coarse) + ",nx=" + std::to_string(nx) + + ",ny=" + std::to_string(ny) + ",nz=" + std::to_string(nz) + + ",voxels=" + std::to_string(voxels) + + ",filled=" + std::to_string(bm.filledCells) + + ",zone=" + std::to_string(bm.cgcsZone) + + ",originX=" + std::to_string(bm.originX) + + ",originY=" + std::to_string(bm.originY) + + ",cell=" + std::to_string(bm.cellSizeM) + ",dz=" + std::to_string(bm.dz) + + ",vmin=" + std::to_string(bm.vminPhys) + + ",vmax=" + std::to_string(bm.vmaxPhys) + + ",dataB=" + std::to_string(dataBytes) + + ",bridgeMs=" + std::to_string(bridgeMs) + + ",peakMB=" + std::to_string(peak)); + + r.ok = true; + return r; +} + +int cmdBuildSurveyLine(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.size() < 2) { + std::cerr << "用法: gpr_poc build-survey-line " + "--out [--levels 3] [--coarse F] [--cell 0.05] " + "[--radius 0.5] [--gps ]\n" + "例: gpr_poc build-survey-line \"D:/Downloads/明星路\" " + "明星路_001 --out tmp/survey001 --levels 3 --coarse 4\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string linePrefix = a.positional[1]; + const int levels = std::stoi(a.get("levels", "3")); + const int coarse = std::stoi(a.get("coarse", "1")); + const double cellSizeM = std::stod(a.get("cell", "0")); + const double radiusM = std::stod(a.get("radius", "0")); + std::string gps = a.get("gps", ""); + if (gps.empty()) gps = findGpsForPrefix(lineDir, linePrefix); + const std::string out = a.get( + "out", (fs::temp_directory_path() / "gpr_store_survey_line").string()); + + if (gps.empty()) { + std::cerr << "[build-survey-line] 失败: 未找到 " << linePrefix + << " 的 .gps(可用 --gps 指定)\n"; + return 1; + } + try { + const LineBuildResult r = buildOneSurveyLine( + lineDir, linePrefix, gps, out, levels, coarse, cellSizeM, radiusM); + if (!r.ok) { + std::cerr << "[build-survey-line] 跳过: " << r.reason << "\n"; + return 1; + } + return 0; + } catch (const std::exception& e) { + std::cerr << "[build-survey-line] 失败(" << linePrefix << "): " << e.what() + << "\n"; + return 1; + } +} + +// build-survey-all:发现所有测线 → 逐条走 P8 测绘级路径建世界对齐体到 baseDir//。 +// 各线 .gps 自动按尾段 _NNN 匹配;缺 .gps 的线跳过报因。磁盘守护同 build-all。 +int cmdBuildSurveyAll(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty() || !a.kv.count("outBase")) { + std::cerr << "用法: gpr_poc build-survey-all --outBase " + "[--levels 3] [--coarse F] [--cell 0.05] [--radius 0.5] " + "[--minFreeGB 3]\n" + "例: gpr_poc build-survey-all \"D:/Downloads/明星路\" " + "--outBase tmp/survey_all --levels 3 --coarse 4\n"; + return 2; + } + const std::string lineDir = a.positional[0]; + const std::string outBase = a.get("outBase", ""); + const int levels = std::stoi(a.get("levels", "3")); + const int coarse = std::stoi(a.get("coarse", "1")); + const double cellSizeM = std::stod(a.get("cell", "0")); + const double radiusM = std::stod(a.get("radius", "0")); + const double minFreeGB = std::stod(a.get("minFreeGB", "3")); + + // 发现所有测线前缀(扫 *_A.iprh,截 _A 得前缀),与 build-all 同口径。 + std::set prefixSet; + std::error_code ec; + for (const auto& e : fs::directory_iterator(lineDir, ec)) { + if (!e.is_regular_file()) continue; + if (e.path().extension().string() != ".iprh") continue; + const std::string name = e.path().filename().string(); + const std::size_t pos = name.rfind("_A"); + if (pos == std::string::npos) continue; + std::size_t d = pos + 2; + while (d < name.size() && std::isdigit(static_cast(name[d]))) + ++d; + if (d == pos + 2) continue; + prefixSet.insert(name.substr(0, pos)); + } + if (prefixSet.empty()) { + std::cerr << "[build-survey-all] 未在 " << lineDir + << " 发现任何测线(*_A.iprh)\n"; + return 1; + } + std::vector prefixes(prefixSet.begin(), prefixSet.end()); + std::cout << "[build-survey-all] 发现 " << prefixes.size() + << " 条测线,outBase=" << outBase << " levels=" << levels + << " coarse=" << coarse << " minFreeGB=" << minFreeGB << "\n"; + + fs::create_directories(outBase, ec); + + std::vector results; + bool stoppedByDisk = false; + for (const std::string& prefix : prefixes) { + const double freeGB = freeSpaceGB(outBase); + std::cout << "\n[build-survey-all] --- " << prefix << " --- 剩余磁盘 " + << freeGB << " GB\n"; + if (freeGB >= 0.0 && freeGB < minFreeGB) { + std::cerr << "[build-survey-all] 磁盘守护触发: 剩余 " << freeGB << " GB < " + << minFreeGB << " GB,停止,未建 " << prefix << " 及其后。\n"; + stoppedByDisk = true; + break; + } + const std::string out = (fs::path(outBase) / prefix).string(); + LineBuildResult r; + r.prefix = prefix; + const std::string gps = findGpsForPrefix(lineDir, prefix); + if (gps.empty()) { + r.ok = false; + r.reason = "缺 .gps,跳过"; + std::cerr << "[build-survey-all] " << prefix << " 缺 .gps,跳过\n"; + results.push_back(r); + continue; + } + try { + r = buildOneSurveyLine(lineDir, prefix, gps, out, levels, coarse, + cellSizeM, radiusM); + } catch (const std::exception& e) { + r.ok = false; + r.reason = std::string("异常: ") + e.what(); + std::cerr << "[build-survey-all] " << prefix << " 失败: " << e.what() + << "(跳过,继续)\n"; + } + results.push_back(r); + } + + std::cout << "\n=== build-survey-all 汇总(测绘级 CGCS2000 世界对齐体) ===\n"; + int okCount = 0; + std::int64_t totalBytes = 0; + for (const auto& r : results) { + if (r.ok) { + ++okCount; + totalBytes += r.dataBytes; + std::cout << " [OK] " << r.prefix << " 维度(东×北×深)=" << r.nx << "x" + << r.ny << "x" << r.nz << " data=" + << r.dataBytes / (1024.0 * 1024.0) << " MB\n"; + } else { + std::cout << " [跳过] " << r.prefix << " 原因=" << r.reason << "\n"; + } + } + std::cout << "成功 " << okCount << "/" << prefixes.size() + << " 条,合计 data=" << totalBytes / (1024.0 * 1024.0 * 1024.0) + << " GB,剩余磁盘 " << freeSpaceGB(outBase) << " GB\n"; + if (stoppedByDisk) + std::cout << "注意: 因磁盘守护提前停止,部分测线未建。\n"; + return 0; +} + +int cmdLoad(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc load \n"; + return 2; + } + const std::string dir = a.positional[0]; + std::cout << "[load] storeDir=" << dir << "\n"; + + Stopwatch sw; + geopro::render::WholeVolumeSource src(dir); + const double loadMs = sw.elapsedMs(); + + const auto& m = src.meta(); + const std::int64_t voxels = + static_cast(m.nx) * m.ny * m.nz; + const std::int64_t wholeBytes = voxels * 2; // VTK_SHORT + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== load 指标 ===\n"; + std::cout << "加载耗时(ms) : " << loadMs << "\n"; + std::cout << "整卷维度 : " << m.nx << " x " << m.ny << " x " << m.nz + << "\n"; + std::cout << "整卷字节(B) : " << wholeBytes << " (" + << wholeBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "峰值内存(MB) : " << peak << "\n"; + + writeMetricLine("load,dir=" + dir + ",nx=" + std::to_string(m.nx) + + ",ny=" + std::to_string(m.ny) + + ",nz=" + std::to_string(m.nz) + + ",wholeB=" + std::to_string(wholeBytes) + + ",loadMs=" + std::to_string(loadMs) + + ",peakMB=" + std::to_string(peak)); + return 0; +} + +// ---- selftest:合成极小数据走完整 build→load 管线 ---- + +// 写一个极小通道的 .iprb + .iprh(samples 采样、traces 道,值 = base + t + s)。 +void writeSyntheticChannel(const fs::path& iprbPath, int samples, int traces, + std::int16_t base) { + const fs::path iprhPath = + fs::path(iprbPath).replace_extension(".iprh"); + std::ofstream h(iprhPath); + h << "SAMPLES: " << samples << "\n"; + h << "LAST TRACE: " << (traces - 1) << "\n"; + h << "CHANNELS: 2\n"; + h << "TIMEWINDOW: 100.0\n"; + h << "SOIL VELOCITY: 100.0\n"; // m/µs → ×1e6 → 1e8 m/s + h << "DISTANCE INTERVAL: 0.05\n"; + h.close(); + + std::ofstream b(iprbPath, std::ios::binary); + // 布局 [trace*samples + s],s 最快。 + for (int t = 0; t < traces; ++t) { + for (int s = 0; s < samples; ++s) { + const std::int16_t v = + static_cast(base + t + s); + b.write(reinterpret_cast(&v), sizeof(v)); + } + } +} + +int cmdSelftest() { + std::cout << "[selftest] 构造极小合成 survey(2 通道)...\n"; + const fs::path tmp = + fs::temp_directory_path() / "gpr_poc_selftest"; + std::error_code ec; + fs::remove_all(tmp, ec); + fs::create_directories(tmp); + + const int samples = 8; + const int traces = 12; + + // 2 通道 .iprb/.iprh + .ord(末列==1 标记有效通道,第 2 列为横偏 Y)。 + writeSyntheticChannel(tmp / "syn_001_A01.iprb", samples, traces, + /*base=*/100); + writeSyntheticChannel(tmp / "syn_001_A02.iprb", samples, traces, + /*base=*/200); + { + std::ofstream ord(tmp / "syn_001.ord"); + ord << "0 0.000000 -1.5 1\n"; + ord << "1 1.000000 -1.5 1\n"; + } + + const std::vector iprb = { + (tmp / "syn_001_A01.iprb").string(), + (tmp / "syn_001_A02.iprb").string()}; + const std::string ord = (tmp / "syn_001.ord").string(); + + bool ok = true; + auto check = [&](bool cond, const std::string& msg) { + if (!cond) { + std::cerr << "[selftest] FAIL: " << msg << "\n"; + ok = false; + } + }; + + try { + // 装配 + geopro::core::GprSurvey survey = + geopro::io::gpr::assembleGprSurvey(iprb, ord); + check(survey.ntraces == traces, "ntraces"); + check(survey.samples == samples, "samples"); + check(survey.channelY.size() == 2, "channels"); + // channelY 升序:A01 偏移 0.0 在前,A02 偏移 1.0 在后。 + check(survey.channelY.front() < survey.channelY.back(), "channelY 升序"); + + // 建体:cellXY 取通道间距 1.0 → ny=2;cellZ 较细确保 nz>1。 + const double cellXY = 1.0; + const double cellZ = std::max(survey.dz, 1e-12); + const geopro::core::GridSpec spec = + specFromSurvey(survey, cellXY, cellZ); + std::cout << "[selftest] GridSpec " << spec.nx << "x" << spec.ny << "x" + << spec.nz << " dz=" << spec.dz << "\n"; + check(spec.ny == 2, "ny==2"); + + geopro::core::BuiltI16 built = + geopro::core::buildGprVolume(survey, spec); + check(built.vol.nx() == spec.nx, "built nx"); + check(built.vol.ny() == spec.ny, "built ny"); + check(built.vol.nz() == spec.nz, "built nz"); + + // 落盘 + 金字塔 + const std::string store = (tmp / "store").string(); + fs::create_directories(store); + geopro::data::ChunkedVolumeStore::write(store, built, /*brick=*/4); + { + geopro::data::ChunkedVolumeStore s(store); + s.buildPyramid(1); + check(s.levels() == 2, "金字塔层数==2"); + } + + // 加载整卷,校验维度一致 + geopro::render::WholeVolumeSource src(store); + check(src.meta().nx == spec.nx, "load nx"); + check(src.meta().ny == spec.ny, "load ny"); + check(src.meta().nz == spec.nz, "load nz"); + + // 某体素值合理性:x0/y0 角点应有非 blank 量化值(落格命中首道首通道)。 + const std::int16_t q = built.vol.at(0, 0, 0); + check(q != geopro::core::ScalarVolumeI16::kBlank, "(0,0,0) 非 blank"); + } catch (const std::exception& e) { + std::cerr << "[selftest] 异常: " << e.what() << "\n"; + ok = false; + } + + fs::remove_all(tmp, ec); + std::cout << "[selftest] " << (ok ? "PASS" : "FAIL") << "\n"; + return ok ? 0 : 1; +} + +// ============================================================================ +// 离屏 GPU 渲染基准(POC-B) +// ============================================================================ + +// 捕获 VTK 错误输出的 OutputWindow:用于侦测体绘制时 vtkVolumeTexture 报的 +// "Invalid texture dimensions" / "MAX_3D_TEXTURE_SIZE" —— 一旦出现,说明整卷 +// 单张 3D 纹理上传失败,体绘制 fps 无意义,必须如实标 INVALID(绝不当真上报)。 +class CapturingOutputWindow : public vtkOutputWindow { + public: + static CapturingOutputWindow* New(); + vtkTypeMacro(CapturingOutputWindow, vtkOutputWindow); + + void DisplayText(const char* txt) override { + if (txt) { + const std::string s(txt); + captured_ += s; + if (s.find("texture dimensions") != std::string::npos || + s.find("MAX_3D_TEXTURE_SIZE") != std::string::npos) { + textureError_ = true; + } + // 纹理【单元数】超限(multi-volume 一个包挂太多体)——与 3D 纹理【尺寸】超限区分, + // 供 view-all 自动减小每包体数(K)退避,直到本机硬件不再报错。 + if (s.find("number of textures") != std::string::npos || + s.find("Hardware does not support the number") != std::string::npos) { + textureCountError_ = true; + } + } + // 仍透传到 stderr,便于人工查看。 + if (txt) std::cerr << txt; + } + + bool textureError() const { return textureError_; } + bool textureCountError() const { return textureCountError_; } + void resetTextureCountError() { textureCountError_ = false; } + const std::string& captured() const { return captured_; } + + private: + std::string captured_; + bool textureError_ = false; + bool textureCountError_ = false; +}; +vtkStandardNewMacro(CapturingOutputWindow); + +// 创建一个离屏 vtkRenderWindow(VTK9.6:SetShowWindow(false)+OffScreenRenderingOn)。 +vtkSmartPointer makeOffscreenWindow(int w, int h) { + auto rw = vtkSmartPointer::New(); + rw->SetOffScreenRendering(1); + rw->SetShowWindow(false); + rw->SetSize(w, h); + return rw; +} + +// 闸门:最小离屏渲染冒烟。返回 0=OK,非 0=离屏 GL 起不来(BLOCKED_OFFSCREEN)。 +// 流程:离屏窗口 → 加一个 cube actor → Render() → 读回像素,确认非全黑/读得到。 +int cmdOffscreenSmoke() { + std::cout << "[offscreen-smoke] 创建离屏 vtkRenderWindow...\n"; + try { + auto rw = makeOffscreenWindow(256, 256); + + vtkNew ren; + ren->SetBackground(0.1, 0.1, 0.2); + rw->AddRenderer(ren); + + vtkNew cube; + cube->SetXLength(1.0); + cube->SetYLength(1.0); + cube->SetZLength(1.0); + + vtkNew mapper; + mapper->SetInputConnection(cube->GetOutputPort()); + + vtkNew actor; + actor->SetMapper(mapper); + actor->GetProperty()->SetColor(1.0, 0.6, 0.2); + ren->AddActor(actor); + ren->ResetCamera(); + + // Render():若 GL 上下文创建失败,VTK 会输出错误(多数返回,少数抛)。 + rw->Render(); + + // 读回像素验证:取整窗 RGB,确认能读到且非全 0。 + const int* sz = rw->GetSize(); + const int w = sz[0], h = sz[1]; + if (w <= 0 || h <= 0) { + std::cout << "[offscreen-smoke] FAIL: 窗口尺寸为 0(上下文未建立)\n"; + std::cout << "STATUS=BLOCKED_OFFSCREEN\n"; + return 1; + } + + auto pixels = vtkSmartPointer::New(); + // GetRGBACharPixelData(x0,y0,x1,y1,front,arr):front=1 读前缓冲。 + const int ok = + rw->GetRGBACharPixelData(0, 0, w - 1, h - 1, /*front=*/1, pixels); + if (ok == 0 || pixels->GetNumberOfTuples() == 0) { + std::cout << "[offscreen-smoke] FAIL: 读不到像素\n"; + std::cout << "STATUS=BLOCKED_OFFSCREEN\n"; + return 1; + } + + // 统计非背景像素(cube 应渲出橙色,存在像素 R 通道明显高于背景)。 + vtkIdType nonBlack = 0; + const vtkIdType n = pixels->GetNumberOfTuples(); + for (vtkIdType i = 0; i < n; ++i) { + const double r = pixels->GetComponent(i, 0); + const double g = pixels->GetComponent(i, 1); + const double b = pixels->GetComponent(i, 2); + if (r > 80 || g > 80 || b > 80) ++nonBlack; + } + + const char* caps = rw->ReportCapabilities(); + std::cout << "[offscreen-smoke] 读回像素 " << n << " 个,非背景像素 " + << nonBlack << "\n"; + std::cout << "[offscreen-smoke] GL 能力:\n" + << (caps ? caps : "(无)") << "\n"; + + if (nonBlack == 0) { + std::cout << "[offscreen-smoke] FAIL: 渲染结果全为背景(actor 未画出)\n"; + std::cout << "STATUS=BLOCKED_OFFSCREEN\n"; + return 1; + } + + std::cout << "[offscreen-smoke] OK:离屏 GL 可用,可继续真实基准。\n"; + std::cout << "STATUS=OK\n"; + return 0; + } catch (const std::exception& e) { + std::cout << "[offscreen-smoke] FAIL: 异常 " << e.what() << "\n"; + std::cout << "STATUS=BLOCKED_OFFSCREEN\n"; + return 1; + } +} + +// 体绘制 fps:每帧绕 azimuth 旋相机再 Render(),避免被驱动优化成空渲染。 +double benchVolumeFps(vtkRenderWindow* rw, vtkRenderer* ren, int frames) { + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + rw->Render(); // 预热一帧(首帧含上传显存/编译 shader,不计时) + Stopwatch sw; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); // 每帧转一点,扫满一圈 + rw->Render(); + } + const double ms = sw.elapsedMs(); + return ms > 0.0 ? frames * 1000.0 / ms : 0.0; +} + +// 切片扫描 fps:沿 K 轴(深度)逐偏移 reslice 取轴向切面 + 纹理渲染,每帧推进偏移。 +double benchSliceFps(vtkRenderWindow* rw, vtkRenderer* ren, + vtkImageData* full, vtkLookupTable* lut, int frames) { + // reslice:固定轴向(XY 平面),沿 Z 改变 ResliceAxesOrigin 扫过整卷。 + vtkNew reslice; + reslice->SetInputData(full); + reslice->SetOutputDimensionality(2); + reslice->SetInterpolationModeToLinear(); + + vtkNew colorize; + colorize->SetLookupTable(lut); + colorize->SetInputConnection(reslice->GetOutputPort()); + + vtkNew imgActor; + imgActor->GetMapper()->SetInputConnection(colorize->GetOutputPort()); + ren->AddViewProp(imgActor); + ren->ResetCamera(); + + double bounds[6]; + full->GetBounds(bounds); + const double zMin = bounds[4], zMax = bounds[5]; + const double ox = 0.5 * (bounds[0] + bounds[1]); + const double oy = 0.5 * (bounds[2] + bounds[3]); + + rw->Render(); // 预热 + Stopwatch sw; + for (int f = 0; f < frames; ++f) { + const double t = static_cast(f) / std::max(1, frames - 1); + const double z = zMin + (zMax - zMin) * t; + reslice->SetResliceAxesOrigin(ox, oy, z); + reslice->Modified(); + rw->Render(); + } + const double ms = sw.elapsedMs(); + return ms > 0.0 ? frames * 1000.0 / ms : 0.0; +} + +// 由 ColorScale 物理区间建 256 级 VTK LUT(切片纹理着色用,与体绘制色阶同源)。 +vtkSmartPointer makeLut(const geopro::core::ColorScale& cs, + double vmin, double vmax) { + auto lut = vtkSmartPointer::New(); + const int n = 256; + lut->SetNumberOfTableValues(n); + lut->SetRange(vmin, vmax); + for (int i = 0; i < n; ++i) { + const double v = vmin + (vmax - vmin) * i / (n - 1); + const auto c = cs.colorAt(v); + lut->SetTableValue(i, c.r / 255.0, c.g / 255.0, c.b / 255.0, 1.0); + } + lut->Build(); + return lut; +} + +// 简单蓝-白-红色阶(与 test_color_scale 同款最简构造)。 +geopro::core::ColorScale makeColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double mid = 0.5 * (vmin + vmax); + cs.addStop(vmin, geopro::core::Rgba{0, 0, 255, 255}); + cs.addStop(mid, geopro::core::Rgba{255, 255, 255, 255}); + cs.addStop(vmax, geopro::core::Rgba{255, 0, 0, 255}); + return cs; +} + +// ============================================================================ +// 视觉调优共享构件(Task 12d ①) +// ============================================================================ +// +// 结构化配色:地震/雷达体常用的「结构色阶」——深蓝(强负)→青→白(零)→黄→红(强正), +// 比单纯蓝-白-红更易拉开正负反射层次。值域用数据 vmin/vmax,无需手调控制点。 +geopro::core::ColorScale makeStructuralColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{0, 0, 140, 255}); // 深蓝 + cs.addStop(at(0.25), geopro::core::Rgba{0, 160, 220, 255}); // 青 + cs.addStop(at(0.50), geopro::core::Rgba{245, 245, 245, 255}); // 白(零附近) + cs.addStop(at(0.75), geopro::core::Rgba{250, 190, 30, 255}); // 黄 + cs.addStop(at(1.00), geopro::core::Rgba{170, 0, 0, 255}); // 暗红 + return cs; +} + +// 地震高对比色阶(seismic 红-白-蓝):两端饱和亮色(强正=亮红、强负=亮蓝), +// 零附近白。比 structural 更亮、对比更狠,正负反射一眼分开。 +// 前置声明(实现在 polish 段):梯度幅值分位统计,供 C4 画廊的梯度不透明度标定。 +struct GradStats { + double median = 0, p75 = 0, p90 = 0, p99 = 0, mx = 0; + std::size_t samples = 0; +}; +GradStats sampleGradientMagnitude(vtkImageData* img); + +// 标量分位标定(P3 修可见性核心):扫该体实际体素值分布,取 2%/98% 分位作色阶/ +// 不透明度的物理端点,裁掉离群。处理后体值多集中在 ±窄带、少量离群到 ±9000,若按 +// 全量化域(meta.vminPhys/vmaxPhys=±9249)映射 → 窄带信号落近透明区 → 整体近黑。 +// 返回物理单位的 {lo, hi}(已按 quant.toPhys 反算)。前置声明,实现在 polish 段。 +struct ScalarPercentiles { + double lo = 0.0, hi = 0.0; // 物理单位(2% / 98% 分位) + std::size_t samples = 0; +}; +ScalarPercentiles sampleScalarPercentiles(vtkImageData* img, + const geopro::core::Quant& q, + double pLo, double pHi); + +geopro::core::ColorScale makeSeismicColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{30, 60, 255, 255}); // 亮蓝(强负) + cs.addStop(at(0.30), geopro::core::Rgba{120, 180, 255, 255}); // 浅蓝 + cs.addStop(at(0.50), geopro::core::Rgba{255, 255, 255, 255}); // 白(零) + cs.addStop(at(0.70), geopro::core::Rgba{255, 170, 120, 255}); // 浅橙 + cs.addStop(at(1.00), geopro::core::Rgba{255, 40, 30, 255}); // 亮红(强正) + return cs; +} + +// jet 类高饱和色阶(蓝-青-绿-黄-红):全程高亮高饱和,最大化色彩动态范围, +// 弱信号也能映到鲜明色相,适合「一眼铺满层次」的取向。 +geopro::core::ColorScale makeJetColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{0, 0, 200, 255}); // 蓝 + cs.addStop(at(0.25), geopro::core::Rgba{0, 200, 255, 255}); // 青 + cs.addStop(at(0.50), geopro::core::Rgba{0, 230, 60, 255}); // 绿 + cs.addStop(at(0.75), geopro::core::Rgba{255, 230, 0, 255}); // 黄 + cs.addStop(at(1.00), geopro::core::Rgba{255, 30, 0, 255}); // 红 + return cs; +} + +// 调亮版 seismic:与 makeSeismicColorScale 同红-白-蓝走向,但把蓝端提亮、整体抬白, +// 弱信号也落在更亮的色域(强负不再是深蓝、零附近纯白、强正亮红),整体更醒目不发暗。 +geopro::core::ColorScale makeBrightSeismicColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{70, 130, 255, 255}); // 亮蓝(强负) + cs.addStop(at(0.30), geopro::core::Rgba{170, 210, 255, 255}); // 浅亮蓝 + cs.addStop(at(0.50), geopro::core::Rgba{255, 255, 255, 255}); // 纯白(零) + cs.addStop(at(0.70), geopro::core::Rgba{255, 200, 150, 255}); // 亮浅橙 + cs.addStop(at(1.00), geopro::core::Rgba{255, 80, 60, 255}); // 亮红(强正) + return cs; +} + +// 增强灰度:黑→白单调,但中段抬亮、两端拉满,弱反射也落在中亮灰,层界面/竖纹对比醒目。 +// GPR 内部水平层叠/基底反射用灰度往往最干净直读(不被多色相干扰)。 +geopro::core::ColorScale makeGrayEnhancedColorScale(double vmin, double vmax) { + geopro::core::ColorScale cs; + const double span = (vmax > vmin) ? (vmax - vmin) : 1.0; + auto at = [&](double t) { return vmin + span * t; }; + cs.addStop(at(0.00), geopro::core::Rgba{20, 25, 40, 255}); // 强负:近黑带冷调 + cs.addStop(at(0.30), geopro::core::Rgba{120, 125, 135, 255}); // 中负:中灰(抬亮) + cs.addStop(at(0.50), geopro::core::Rgba{180, 182, 188, 255}); // 零附近:亮灰 + cs.addStop(at(0.70), geopro::core::Rgba{225, 222, 210, 255}); // 中正:暖亮灰 + cs.addStop(at(1.00), geopro::core::Rgba{255, 252, 240, 255}); // 强正:近白 + return cs; +} + +// 背景压制强度(--bgSuppress,0..1):0=原观感(近零背景压低但可见);越大→近零背景越 +// 透明 + 中心透明死区越宽,只留中/强反射层 → "压背景、突出反射"(业界标准传函做法)。 +// 注意:压太狠会连带抹掉弱异常(与 ESS 阈值同取舍);本系统弱异常靠切片抓,三维体压背景风险低。 +double gBgSuppress = 0.0; + +// 「实体感」不透明度包络(Task 12d gallery):与 structural 双端斜坡不同,这里让 +// 中高值段普遍可见——背景(近零)仍压低但不归零,中高段从 floorOpacity 平滑升到 +// maxOpacity,使体读起来像半透明实心块、内部层次(而非只剩两端薄壳)可见。 +// floorOpacity:近零背景的最低不透明度(0.05~0.12,压住但不消失) +// maxOpacity :强反射端的不透明度峰值(0.85 时近实心) +// midOpacity :中值段(半幅处)的不透明度(0.3~0.5,决定「半透明实心」观感) +// gBgSuppress :见上,压低 floor + 加宽中心透明死区。 +vtkSmartPointer makeSolidVolumeProperty( + const geopro::core::Quant& q, const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys, double floorOpacity, double midOpacity, + double maxOpacity) { + constexpr int kTransferSamples = 64; + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + // 不透明度:V 形(中段=零附近背景=floor,正负两端=max),但全程 ≥floor 且中值 + // 段≈mid → 整体半透明实心、内部层次可见,而非两端薄壳。 + // --bgSuppress F:F>0 时把近零背景 floor 压到 floor*(1-F),并在中心开 ±(F*0.45*half) 的 + // 全透明死区 → 压背景、突出中/强反射(F=0 即原观感)。 + const double bg = std::clamp(gBgSuppress, 0.0, 1.0); + const double cFloor = floorOpacity * (1.0 - bg); // 背景压低(F→1 趋 0) + const double dead = bg * 0.45; // 中心透明死区半宽(占 half 的比例) + vtkNew opacity; + opacity->AddPoint( + static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + const double qmid = 0.5 * (qminD + qmaxD); + const double half = 0.5 * (qmaxD - qminD); + opacity->AddPoint(qminD, maxOpacity); // 强负反射:近实心 + opacity->AddPoint(qmid - 0.55 * half, midOpacity); // 中负段:半透明实心 + if (dead > 0.0) { + opacity->AddPoint(qmid - dead * half, 0.0); // 死区左沿:透明 + opacity->AddPoint(qmid, 0.0); // 中心背景:透明 + opacity->AddPoint(qmid + dead * half, 0.0); // 死区右沿:透明 + } else { + opacity->AddPoint(qmid, cFloor); // 近零背景:压低但可见 + } + opacity->AddPoint(qmid + 0.55 * half, midOpacity); // 中正段:半透明实心 + opacity->AddPoint(qmaxD, maxOpacity); // 强正反射:近实心 + + auto prop = vtkSmartPointer::New(); + prop->SetColor(color); + prop->SetScalarOpacity(opacity); + prop->SetInterpolationTypeToLinear(); + prop->ShadeOff(); + return prop; +} + +// 参数化量化域传函:与 makeI16VolumeProperty 同逻辑,但 kMaxOpacity 可由 --opacity 控。 +// 不透明度调高时光线提前终止,fps 近乎中性甚至更快(探针认知,报告打印前后对照证实)。 +vtkSmartPointer makeTunedVolumeProperty( + const geopro::core::Quant& q, const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys, double maxOpacity, + bool structuralOpacity = true) { + constexpr int kTransferSamples = 64; + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + // 不透明度: + // - 原始(structuralOpacity=false):线性单斜坡 [qmin,qmax]→[0,maxOpacity], + // 与 VoxelActor 默认一致,作调优前对照基线。 + // - 调优(structuralOpacity=true):双端斜坡。GPR/地震体值多集中在零附近(背景), + // 强反射在正负两端;线性单斜坡会让占多数的近零背景填满体、遮住结构。改为 + // 「中段(零附近)透明 + 正负两端不透明」——抑制背景、凸显强反射层,截面结构才看得出。 + vtkNew opacity; + opacity->AddPoint( + static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + if (structuralOpacity) { + const double qmid = 0.5 * (qminD + qmaxD); + const double half = 0.5 * (qmaxD - qminD); + opacity->AddPoint(qminD, maxOpacity); // 强负反射:不透明 + opacity->AddPoint(qmid - 0.30 * half, 0.0); // 近零背景:透明 + opacity->AddPoint(qmid + 0.30 * half, 0.0); + opacity->AddPoint(qmaxD, maxOpacity); // 强正反射:不透明 + } else { + opacity->AddPoint(qminD, 0.0); + opacity->AddPoint(qmaxD, maxOpacity); + } + + auto prop = vtkSmartPointer::New(); + prop->SetColor(color); + prop->SetScalarOpacity(opacity); + prop->SetInterpolationTypeToLinear(); + prop->ShadeOff(); + return prop; +} + +// 由预构建 VTK_SHORT 图像建一个「视觉调优」体:自定义不透明度 + 垂向夸张。 +// 垂向夸张用 vtkVolume::SetScale(1, exagg, exagg) 缩放跨通道(Y)与深度(Z)两薄轴, +// 不改图像数据;体物理极扁(X≈2.2km vs Y≈1.5m/Z≈8m),放大薄轴截面结构才看得出。 +vtkSmartPointer buildTunedVolume(vtkImageData* shortImg, + const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys, + double maxOpacity, double exagg, + bool structuralOpacity = true) { + vtkNew mapper; + mapper->SetInputData(shortImg); + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + mapper->SetAutoAdjustSampleDistances(0); + mapper->SetInteractiveAdjustSampleDistances(0); + + auto prop = makeTunedVolumeProperty(q, cs, vminPhys, vmaxPhys, maxOpacity, + structuralOpacity); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, exagg, exagg); // 垂向夸张:放大 Y/Z 薄轴 + return volume; +} + +int cmdRenderB(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc renderB [--frames 120]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const int frames = std::stoi(a.get("frames", "120")); + std::cout << "[renderB] storeDir=" << dir << " frames=" << frames << "\n"; + + // 闸门复检:renderB 前先确认离屏可用(避免在不可渲染机上跑出假数据)。 + std::cout << "[renderB] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[renderB] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + // 1) 加载整卷(VTK_SHORT)。 + Stopwatch swLoad; + geopro::render::WholeVolumeSource src(dir); + const double loadMs = swLoad.elapsedMs(); + const auto& m = src.meta(); + + const std::int64_t voxels = + static_cast(m.nx) * m.ny * m.nz; + const std::int64_t wholeBytes = voxels * 2; // VTK_SHORT + std::cout << "[renderB] 整卷 " << m.nx << "x" << m.ny << "x" << m.nz + << " 体素=" << voxels << " 字节=" << wholeBytes << " (" + << wholeBytes / (1024.0 * 1024.0) << " MB),加载 " << loadMs + << "ms\n"; + + auto images = src.currentImages(); + if (images.empty() || !images.front()) { + std::cerr << "[renderB] 错误: currentImages 为空\n"; + return 1; + } + vtkImageData* shortImg = images.front().Get(); + + // 色阶用 meta 的物理区间。 + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeColorScale(vmin, vmax); + + // 2) 体绘制(离屏)。 + auto rw = makeOffscreenWindow(1024, 768); + vtkNew ren; + ren->SetBackground(0.0, 0.0, 0.0); + rw->AddRenderer(ren); + + vtkSmartPointer volume = + geopro::render::buildVoxelI16FromImage(shortImg, m.quant, cs, vmin, vmax); + ren->AddVolume(volume); + + // 装上捕获式 OutputWindow:拦截体绘制时的 3D 纹理维度错误。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + std::cout << "[renderB] 体绘制基准(" << frames << " 帧旋转相机)...\n"; + const double volFpsRaw = benchVolumeFps(rw, ren, frames); + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); // 还原默认输出窗口 + + // 进显存判据:SmartVolumeMapper 实际用的渲染模式(2=GPURenderMode)。 + int renderMode = -1; + bool lowResResample = false; + if (auto* svm = + vtkSmartVolumeMapper::SafeDownCast(volume->GetMapper())) { + renderMode = svm->GetLastUsedRenderMode(); + // 大体可能触发降质重采样(GPU 显存不足时 SmartVolumeMapper 走低分辨率)。 + lowResResample = (svm->GetInteractiveAdjustSampleDistances() == 0 && + renderMode != vtkSmartVolumeMapper::GPURenderMode); + } + const bool onGpu = (renderMode == vtkSmartVolumeMapper::GPURenderMode); + + // 任一维度超过 GL_MAX_3D_TEXTURE_SIZE(本机实测 16384)→ 整卷无法成单张 3D 纹理。 + constexpr int kMax3DTexObserved = 16384; + const bool dimOversize = + (m.nx > kMax3DTexObserved || m.ny > kMax3DTexObserved || + m.nz > kMax3DTexObserved); + // 体绘制 fps 是否可信:上传成功(无纹理错误且未超限)才算真实整卷体绘制帧率。 + const bool volFpsValid = !textureErr && !dimOversize; + const double volFps = volFpsValid ? volFpsRaw : -1.0; + + std::cout << "[renderB] 体绘制 raw_fps=" << volFpsRaw + << " 渲染模式=" << renderMode << (onGpu ? "(GPU)" : "(非GPU)") + << " 纹理维度错误=" << (textureErr ? "是" : "否") + << " 超 16384=" << (dimOversize ? "是" : "否") << "\n"; + if (!volFpsValid) { + std::cout << "[renderB] 警告: 整卷未能成单张 3D 纹理(X=" << m.nx + << " > " << kMax3DTexObserved + << "),体绘制 fps 无意义 → 标 INVALID。\n"; + } + + // 3) 切片扫描(离屏,沿 Z 扫整卷)。 + vtkNew ren2; + ren2->SetBackground(0.0, 0.0, 0.0); + auto rw2 = makeOffscreenWindow(1024, 768); + rw2->AddRenderer(ren2); + vtkSmartPointer lut = makeLut(cs, vmin, vmax); + + std::cout << "[renderB] 切片扫描基准(" << frames << " 帧沿 Z 推进)...\n"; + const double sliceFps = + benchSliceFps(rw2, ren2, src.sliceSource(), lut, frames); + std::cout << "[renderB] 切片 fps=" << sliceFps << "\n"; + + const double peak = Probe::peakMemMB(); + const std::string vram = "N/A"; // VTK 安装未带 GLEW 头,无法直查 NVX 显存 + + // 4) 汇总打印。 + const std::string volFpsStr = + volFpsValid ? std::to_string(volFps) : "INVALID(整卷超 3D 纹理上限)"; + + std::cout << "\n=== renderB GPU 指标 ===\n"; + std::cout << "离屏闸门 : OK\n"; + std::cout << "体维度 : " << m.nx << " x " << m.ny << " x " << m.nz + << "\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "整卷字节(B) : " << wholeBytes << " (" + << wholeBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "体绘制 fps : " << volFpsStr << "\n"; + if (!volFpsValid) { + std::cout << " (raw_fps=" << volFpsRaw + << " 为空纹理渲染,X=" << m.nx << " > 16384,不可信)\n"; + } + std::cout << "切片扫描 fps : " << sliceFps << " (2D 纹理,无 3D 上限约束)\n"; + std::cout << "渲染模式 : " << renderMode + << (onGpu ? " (GPU 路径)" : " (非 GPU)") << "\n"; + std::cout << "整卷进显存 : " + << (volFpsValid && onGpu ? "是(单张 3D 纹理)" + : "否(超 GL_MAX_3D_TEXTURE_SIZE 16384)") + << "\n"; + std::cout << "降质重采样 : " << (lowResResample ? "是" : "否") << "\n"; + std::cout << "GPU 显存 : " << vram << "\n"; + std::cout << "进程峰值内存(MB): " << peak << "\n"; + + writeMetricLine( + "renderB,dir=" + dir + ",nx=" + std::to_string(m.nx) + + ",ny=" + std::to_string(m.ny) + ",nz=" + std::to_string(m.nz) + + ",voxels=" + std::to_string(voxels) + + ",wholeB=" + std::to_string(wholeBytes) + + ",volFps=" + volFpsStr + + ",volFpsRaw=" + std::to_string(volFpsRaw) + + ",volFpsValid=" + std::to_string(volFpsValid ? 1 : 0) + + ",sliceFps=" + std::to_string(sliceFps) + + ",renderMode=" + std::to_string(renderMode) + + ",onGpu=" + std::to_string(onGpu ? 1 : 0) + + ",loadMs=" + std::to_string(loadMs) + + ",peakMB=" + std::to_string(peak)); + return 0; +} + +// ============================================================================ +// 核外分块体绘制基准(POC-C,命门探针) +// ============================================================================ + +// 量化域传函(与 VoxelActor::buildVoxelI16FromImage 同逻辑):颜色对每量化级 qv 用 +// q.toPhys(qv) 反查 ColorScale;不透明度 kBlank→0、[qmin,qmax] 线性到 kMaxOpacity。 +// MultiBlock 全块共用同一 vtkVolumeProperty(挂在单个 vtkVolume 上)。 +vtkSmartPointer makeI16VolumeProperty( + const geopro::core::Quant& q, const geopro::core::ColorScale& cs, + double vminPhys, double vmaxPhys) { + constexpr int kTransferSamples = 64; + constexpr double kMaxOpacity = 0.15; + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + + vtkNew color; + for (int t = 0; t < kTransferSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kTransferSamples - 1); + const auto qvLevel = static_cast(std::lround(qd)); + const double phys = q.toPhys(qvLevel); + const auto c = cs.colorAt(phys); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + + vtkNew opacity; + opacity->AddPoint( + static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + opacity->AddPoint(qminD, 0.0); + opacity->AddPoint(qmaxD, kMaxOpacity); + + auto prop = vtkSmartPointer::New(); + prop->SetColor(color); + prop->SetScalarOpacity(opacity); + prop->SetInterpolationTypeToLinear(); + prop->ShadeOff(); + return prop; +} + +// 由当前工作集图像组装 vtkMultiBlockDataSet(每块一个 vtkImageData)。 +vtkSmartPointer makeMultiBlock( + const std::vector>& imgs) { + auto mb = vtkSmartPointer::New(); + mb->SetNumberOfBlocks(static_cast(imgs.size())); + for (unsigned int i = 0; i < imgs.size(); ++i) { + mb->SetBlock(i, imgs[i].Get()); + } + return mb; +} + +int cmdRenderC(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc renderC [--budget 64] [--frames 120]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const std::size_t budget = + static_cast(std::stoul(a.get("budget", "64"))); + const int frames = std::stoi(a.get("frames", "120")); + std::cout << "[renderC] storeDir=" << dir << " budget=" << budget + << " frames=" << frames << "\n"; + + // 闸门复检:不可渲染机不产假 fps。 + std::cout << "[renderC] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[renderC] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + // 1) 核外源(读 meta + 建 pager,不载整卷)。 + Stopwatch swLoad; + geopro::render::OutOfCoreSource src(dir, budget); + const double loadMs = swLoad.elapsedMs(); + const auto& m = src.meta(); + const std::int64_t voxels = + static_cast(m.nx) * m.ny * m.nz; + + const int winW = 1024, winH = 768; + src.setAspect(static_cast(winW) / winH); + + std::cout << "[renderC] 体 " << m.nx << "x" << m.ny << "x" << m.nz + << " 体素=" << voxels << " (整卷 X=" << m.nx + << " > 16384 → renderB INVALID),源构造 " << loadMs << "ms\n"; + + // 色阶用 meta 物理区间。 + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeColorScale(vmin, vmax); + vtkSmartPointer prop = + makeI16VolumeProperty(m.quant, cs, vmin, vmax); + + // 2) 离屏 + MultiBlock 体绘制。 + auto rw = makeOffscreenWindow(winW, winH); + vtkNew ren; + ren->SetBackground(0.0, 0.0, 0.0); + rw->AddRenderer(ren); + + vtkNew mapper; + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + ren->AddVolume(volume); + + // 装捕获式 OutputWindow:拦截每块上传时的 3D 纹理维度错误(应无,因块 ≤64³)。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // 相机:先以全体定向(看整卷),首帧 update 选出工作集后再 ResetCamera 到 + // 实际驻留块的 mapper 包围盒(budget<视野总块时工作集只覆盖体的一部分,框住它 + // 才能确证核外体绘制真渲出;这是 budget 受限下的诚实测法,报告说明)。 + ren->ResetCamera(m.origin[0], m.origin[0] + m.nx * m.spacing[0], + m.origin[1], m.origin[1] + m.ny * m.spacing[1], + m.origin[2], m.origin[2] + m.nz * m.spacing[2]); + vtkCamera* cam = ren->GetActiveCamera(); + + auto refreshBlocks = [&]() { + src.update(cam); + auto imgs = src.currentImages(); + auto mb = makeMultiBlock(imgs); + mapper->SetInputDataObject(mb); + mapper->Update(); + return imgs.size(); + }; + + const std::size_t warmBlocks = refreshBlocks(); + // 用工作集(mapper)实际包围盒重置相机,框住驻留块。 + { + double b[6]; + mapper->GetBounds(b); + if (b[0] <= b[1]) { + ren->ResetCamera(b); + } else { + ren->ResetCamera(); + } + } + rw->Render(); // 预热(上传显存 + 编译 shader,不计时) + + { + double b[6]; + mapper->GetBounds(b); + std::cout << "[renderC] 工作集包围盒 x[" << b[0] << "," << b[1] << "] y[" + << b[2] << "," << b[3] << "] z[" << b[4] << "," << b[5] << "]\n"; + } + + std::cout << "[renderC] 预热:level=" << src.lastLevel() + << " 视野块=" << src.lastVisibleCount() << "/" + << src.lastLevelBrickTotal() + << " 驻留=" << src.residentCount() << " 渲染块=" << warmBlocks + << "\n"; + + std::size_t maxResident = src.residentCount(); + std::size_t sumBlocks = 0; + + // 3a) 静态工作集体绘制 fps:工作集固定(不每帧换块),只旋相机 + Render。 + // 隔离"纯 GPU MultiBlock 体绘制"成本(剔除分块换页/解压/重建 mapper 开销), + // 直接对照 renderB 整卷 fps,回答未知 #6(真实体绘制 fps)。 + std::cout << "[renderC] 静态工作集体绘制基准(" << frames << " 帧旋相机)...\n"; + Stopwatch swStatic; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + rw->Render(); // 工作集不变,仅旋转 + } + const double staticMs = swStatic.elapsedMs(); + const double staticFps = staticMs > 0 ? frames * 1000.0 / staticMs : 0.0; + std::cout << "[renderC] 静态工作集 fps=" << staticFps << "\n"; + + // 3b) 动态换页体绘制 fps:每帧 update(cam)(重选 LOD/视野块,含 qUncompress 解压 + // 换入的块 + 重建 MultiBlock)+ Render。回答未知 #4(热路径解压是否拖垮 fps) + // 与 #5(内存恒定)。同时累计 update 耗时占比。 + std::cout << "[renderC] 动态换页体绘制基准(" << frames << " 帧旋相机)...\n"; + double updateMsTotal = 0.0; + Stopwatch swDyn; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + Stopwatch swU; + const std::size_t blocks = refreshBlocks(); // update + 重建 MultiBlock + updateMsTotal += swU.elapsedMs(); + sumBlocks += blocks; + maxResident = std::max(maxResident, src.residentCount()); + rw->Render(); + } + const double dynMs = swDyn.elapsedMs(); + const double dynFps = dynMs > 0 ? frames * 1000.0 / dynMs : 0.0; + const double rawFps = dynFps; // 主报告口径:含换页的真实交互 fps + std::cout << "[renderC] 动态换页 fps=" << dynFps + << " (其中 update/换页/重建 平均 " << (updateMsTotal / frames) + << " ms/帧)\n"; + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + + // 4) 正确性判据:渲出非空像素(非全背景)。 + auto pixels = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, pixels); + vtkIdType nonBlack = 0; + const vtkIdType npx = pixels->GetNumberOfTuples(); + for (vtkIdType i = 0; i < npx; ++i) { + if (pixels->GetComponent(i, 0) > 10 || pixels->GetComponent(i, 1) > 10 || + pixels->GetComponent(i, 2) > 10) { + ++nonBlack; + } + } + const bool renderedNonEmpty = (nonBlack > 0); + + // 渲染模式(MultiBlock 内部每块一个 SmartVolumeMapper;此处取一块代表性查询)。 + // MultiBlock 不直接暴露 LastUsedRenderMode,故以纹理无错 + 非空像素为体绘制真出证据。 + const bool volFpsValid = !textureErr && renderedNonEmpty; + + const double peak = Probe::peakMemMB(); + const double avgBlocks = + frames > 0 ? static_cast(sumBlocks) / frames : 0.0; + + std::cout << "\n=== renderC 核外体绘制指标 ===\n"; + std::cout << "离屏闸门 : OK\n"; + std::cout << "体维度 : " << m.nx << " x " << m.ny << " x " << m.nz + << " (整卷 X 超 16384,renderB=INVALID)\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "budget(块) : " << budget << "\n"; + std::cout << "峰值驻留(块) : " << maxResident + << (maxResident <= budget ? " (≤budget,内存恒定 OK)" + : " (!! 超 budget)") + << "\n"; + std::cout << "末帧 level : " << src.lastLevel() << "\n"; + std::cout << "末帧视野块/总块 : " << src.lastVisibleCount() << " / " + << src.lastLevelBrickTotal() << "\n"; + std::cout << "平均渲染块/帧 : " << avgBlocks << "\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") << "\n"; + std::cout << "渲出非空像素 : " << (renderedNonEmpty ? "是" : "否(!!)") + << " (非背景像素=" << nonBlack << ")\n"; + std::cout << "静态工作集 fps : " + << (volFpsValid ? std::to_string(staticFps) + : std::string("INVALID(纹理错或空渲染)")) + << " (纯 GPU MultiBlock 体绘制)\n"; + std::cout << "动态换页 fps : " + << (volFpsValid ? std::to_string(dynFps) + : std::string("INVALID(纹理错或空渲染)")) + << " (含每帧 update/解压/重建 mapper)\n"; + std::cout << " 换页均耗时/帧 : " << (updateMsTotal / frames) << " ms\n"; + std::cout << "进程峰值内存(MB) : " << peak << "\n"; + std::cout << "源构造耗时(ms) : " << loadMs << "\n"; + std::cout << "对照 renderB : 整卷 INVALID(超 3D 纹理上限);renderC " + << (volFpsValid ? "真渲出 ✔" : "未渲出 ✘") << "\n"; + + writeMetricLine( + "renderC,dir=" + dir + ",nx=" + std::to_string(m.nx) + + ",ny=" + std::to_string(m.ny) + ",nz=" + std::to_string(m.nz) + + ",voxels=" + std::to_string(voxels) + + ",budget=" + std::to_string(budget) + + ",maxResident=" + std::to_string(maxResident) + + ",lastLevel=" + std::to_string(src.lastLevel()) + + ",lastVisible=" + std::to_string(src.lastVisibleCount()) + + ",lastLevelTotal=" + std::to_string(src.lastLevelBrickTotal()) + + ",avgBlocks=" + std::to_string(avgBlocks) + + ",textureErr=" + std::to_string(textureErr ? 1 : 0) + + ",nonBlack=" + std::to_string(nonBlack) + + ",volFpsValid=" + std::to_string(volFpsValid ? 1 : 0) + + ",staticFps=" + (volFpsValid ? std::to_string(staticFps) : "INVALID") + + ",dynFps=" + (volFpsValid ? std::to_string(dynFps) : "INVALID") + + ",updateMsPerFrame=" + std::to_string(updateMsTotal / frames) + + ",rawFps=" + std::to_string(rawFps) + + ",loadMs=" + std::to_string(loadMs) + + ",peakMB=" + std::to_string(peak)); + return volFpsValid ? 0 : 1; +} + +// ============================================================================ +// 单 mapper SetPartitions 整卷体绘制基准(POC-C-partitioned,去风险探针) +// ============================================================================ +// +// 验"对的架构":整卷喂【单个】vtkGPUVolumeRayCastMapper(其 OpenGL 实现 = +// vtkOpenGLGPUVolumeRayCastMapper),用 SetPartitions(ceil(nx/16384),...) 让同一 +// mapper 内部把体沿轴分区上传(每区 ≤16384 绕过 GL_MAX_3D_TEXTURE_SIZE),一次 +// ray cast。对照 9c 整卷单 SmartVolumeMapper(INVALID,纹理墙) 与 12 MultiBlock +// (每块一 mapper,9.5 静态/1.45 换页)。 +// +// 双闸(同 9c,绝不把空纹理假帧率当性能): +// ① CapturingOutputWindow 捕获 3D 纹理维度错误; +// ② 真实回读像素,统计非背景像素 → 非空才算真渲出。 +int cmdRenderCPartitioned(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr + << "用法: gpr_poc renderC-partitioned [--frames 120]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const int frames = std::stoi(a.get("frames", "120")); + std::cout << "[renderC-partitioned] storeDir=" << dir << " frames=" << frames + << "\n"; + + // 闸门复检:不可渲染机不产假 fps。 + std::cout << "[renderC-partitioned] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[renderC-partitioned] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + // 1) WholeVolumeSource 重组整卷 VTK_SHORT image(常驻内存,约 400MB)。 + Stopwatch swLoad; + geopro::render::WholeVolumeSource src(dir); + const double loadMs = swLoad.elapsedMs(); + const auto& m = src.meta(); + + const std::int64_t voxels = + static_cast(m.nx) * m.ny * m.nz; + const std::int64_t wholeBytes = voxels * 2; // VTK_SHORT + std::cout << "[renderC-partitioned] 整卷 " << m.nx << "x" << m.ny << "x" + << m.nz << " 体素=" << voxels << " 字节=" << wholeBytes << " (" + << wholeBytes / (1024.0 * 1024.0) << " MB),加载 " << loadMs + << "ms\n"; + + auto images = src.currentImages(); + if (images.empty() || !images.front()) { + std::cerr << "[renderC-partitioned] 错误: currentImages 为空\n"; + return 1; + } + vtkImageData* shortImg = images.front().Get(); + + // 2) 分区数:任一轴 > 16384 → ceil(dim/16384) 个分区,其余轴 1。 + constexpr int kMax3DTex = 16384; + auto partCount = [](int dim) { + return static_cast((dim + kMax3DTex - 1) / kMax3DTex); + }; + const unsigned short px = partCount(m.nx); + const unsigned short py = partCount(m.ny); + const unsigned short pz = partCount(m.nz); + std::cout << "[renderC-partitioned] SetPartitions(" << px << "," << py << "," + << pz << ") 每区上限 ≤" << kMax3DTex << " (沿线 " << m.nx << "/" + << px << "=" << (m.nx + px - 1) / px << ")\n"; + + // 3) 量化域传函(复用现有 makeI16VolumeProperty:qmin/qmax + kBlank 透明)。 + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeColorScale(vmin, vmax); + vtkSmartPointer prop = + makeI16VolumeProperty(m.quant, cs, vmin, vmax); + + // 4) 离屏 + 单个 GPU ray cast mapper + SetPartitions。 + const int winW = 1024, winH = 768; + auto rw = makeOffscreenWindow(winW, winH); + vtkNew ren; + ren->SetBackground(0.0, 0.0, 0.0); + rw->AddRenderer(ren); + + // vtkGPUVolumeRayCastMapper 抽象基类无 SetPartitions(在 OpenGL 实现上); + // 直接建 OpenGL 具体类(工厂默认产物同此),喂【整卷单 image】不预切块。 + vtkNew mapper; + mapper->SetInputData(shortImg); + mapper->SetPartitions(px, py, pz); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + ren->AddVolume(volume); + + // 装捕获式 OutputWindow:拦截分区上传时的 3D 纹理维度错误。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // 相机:用 mapper 实际包围盒定向(整卷,非工作集);体极扁长(44476:29:162), + // ResetCamera 全体后再倾斜抬高视角,让薄维度可见(否则边缘视角近乎不可见)。 + { + double b[6]; + mapper->GetBounds(b); + if (b[0] <= b[1]) { + ren->ResetCamera(b); + } else { + ren->ResetCamera(); + } + } + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(30.0); // 抬高,避免纯边缘视角看不到薄板 + cam->Azimuth(30.0); + ren->ResetCameraClippingRange(); + + // 每帧旋相机 + Render 测 fps;同时多帧采样非背景像素取最大值 + // (区分"真渲不出"与"末帧恰好边缘视角空"——后者只是采样时机)。 + auto countNonBlack = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType nb = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 10 || px->GetComponent(i, 1) > 10 || + px->GetComponent(i, 2) > 10) { + ++nb; + } + } + return nb; + }; + + std::cout << "[renderC-partitioned] 单 mapper 整卷体绘制基准(" << frames + << " 帧旋相机)...\n"; + rw->Render(); // 预热(分区上传 + 编译 shader,不计时) + vtkIdType maxNonBlack = countNonBlack(); + const int sampleEvery = std::max(1, frames / 8); + Stopwatch swBench; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + rw->Render(); + if (f % sampleEvery == 0) { + maxNonBlack = std::max(maxNonBlack, countNonBlack()); + } + } + const double benchMs = swBench.elapsedMs(); + const double volFpsRaw = + benchMs > 0.0 ? frames * 1000.0 / benchMs : 0.0; + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + + // 5) 正确性判据:整个旋转扫描中的最大非背景像素(非空才算真渲出)。 + const vtkIdType nonBlack = maxNonBlack; + const bool renderedNonEmpty = (nonBlack > 0); + + // 双闸:无纹理错 + 非空像素 → fps 可信。 + const bool volFpsValid = !textureErr && renderedNonEmpty; + const double volFps = volFpsValid ? volFpsRaw : -1.0; + const double peak = Probe::peakMemMB(); + const bool interactive = volFpsValid && volFps >= 15.0; + + const std::string volFpsStr = + volFpsValid ? std::to_string(volFps) + : std::string("INVALID(纹理错或空渲染)"); + + std::cout << "\n=== renderC-partitioned 单 mapper SetPartitions 指标 ===\n"; + std::cout << "离屏闸门 : OK\n"; + std::cout << "体维度 : " << m.nx << " x " << m.ny << " x " << m.nz + << "\n"; + std::cout << "体素数 : " << voxels << "\n"; + std::cout << "整卷字节(B) : " << wholeBytes << " (" + << wholeBytes / (1024.0 * 1024.0) << " MB)\n"; + std::cout << "分区数(px,py,pz) : " << px << "," << py << "," << pz << "\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") << "\n"; + std::cout << "渲出非空像素 : " << (renderedNonEmpty ? "是" : "否(!!)") + << " (非背景像素=" << nonBlack << ")\n"; + std::cout << "体绘制 fps : " << volFpsStr << "\n"; + if (!volFpsValid) { + std::cout << " (raw_fps=" << volFpsRaw << " 不可信)\n"; + } + std::cout << "达交互级(≥15fps) : " + << (interactive ? "是 ✔" : "否 ✘") << "\n"; + std::cout << "进程峰值内存(MB) : " << peak << "\n"; + std::cout << "源构造耗时(ms) : " << loadMs << "\n"; + std::cout << "对照 renderB : 整卷单 SmartVolumeMapper=INVALID(纹理墙);" + "renderC MultiBlock=9.5 静态/1.45 换页;本探针=" + << (volFpsValid ? volFpsStr + "fps" : "INVALID") << "\n"; + + writeMetricLine( + "renderC-partitioned,dir=" + dir + ",nx=" + std::to_string(m.nx) + + ",ny=" + std::to_string(m.ny) + ",nz=" + std::to_string(m.nz) + + ",voxels=" + std::to_string(voxels) + + ",wholeB=" + std::to_string(wholeBytes) + + ",px=" + std::to_string(px) + ",py=" + std::to_string(py) + + ",pz=" + std::to_string(pz) + + ",textureErr=" + std::to_string(textureErr ? 1 : 0) + + ",nonBlack=" + std::to_string(nonBlack) + + ",volFpsValid=" + std::to_string(volFpsValid ? 1 : 0) + + ",volFps=" + volFpsStr + ",volFpsRaw=" + std::to_string(volFpsRaw) + + ",interactive=" + std::to_string(interactive ? 1 : 0) + + ",loadMs=" + std::to_string(loadMs) + ",peakMB=" + std::to_string(peak)); + + // 写报告文件(覆盖式,含对照表)。 + { + const fs::path repo = + fs::path("docs") / "superpowers" / "plans" / "poc-results-C.md"; + fs::create_directories(repo.parent_path()); + std::ofstream rf(repo.string()); + if (rf) { + rf << "# POC-C 单 mapper SetPartitions 整卷体绘制探针结果\n\n"; + rf << "## 体\n"; + rf << "- 维度: " << m.nx << " x " << m.ny << " x " << m.nz << " (体素 " + << voxels << ")\n"; + rf << "- 整卷字节: " << wholeBytes << " B (" + << wholeBytes / (1024.0 * 1024.0) << " MB, VTK_SHORT)\n"; + rf << "- store: " << dir << "\n\n"; + rf << "## 单 mapper SetPartitions\n"; + rf << "- mapper: vtkOpenGLGPUVolumeRayCastMapper (整卷单 image,不预切块)\n"; + rf << "- 分区数: SetPartitions(" << px << ", " << py << ", " << pz + << ") 每区上限 ≤" << kMax3DTex << "\n"; + rf << "- 纹理维度错误: " << (textureErr ? "是" : "否") << "\n"; + rf << "- 渲出非空像素: " << (renderedNonEmpty ? "是" : "否") << " (非背景像素 " + << nonBlack << ")\n"; + rf << "- 体绘制 fps: " << volFpsStr << "\n"; + rf << "- 达交互级(≥15fps): " << (interactive ? "是" : "否") << "\n"; + rf << "- 进程峰值内存: " << peak << " MB\n"; + rf << "- 源构造耗时: " << loadMs << " ms\n\n"; + rf << "## 对照表\n\n"; + rf << "| 路径 | 是否渲出 | fps |\n"; + rf << "|---|---|---|\n"; + rf << "| renderB 整卷单 SmartVolumeMapper | INVALID(纹理墙) | — |\n"; + rf << "| renderC MultiBlock(每块一 mapper) | 渲出 | 9.5 静态/1.45 换页 |\n"; + rf << "| renderC-partitioned 单 mapper SetPartitions | " + << (volFpsValid ? "渲出" : "未渲出") << " | " + << (volFpsValid ? volFpsStr : std::string("INVALID")) << " |\n\n"; + rf << "## 判据结论\n"; + if (volFpsValid && interactive) { + rf << "单 mapper SetPartitions 整卷体绘制【真渲出且达交互级】(" << volFps + << " fps ≥15)。C production 路线钉死可行。\n"; + } else if (volFpsValid) { + rf << "单 mapper SetPartitions 整卷体绘制【真渲出但未达交互级】(" << volFps + << " fps <15)。VTK 这条路天花板暴露,需评估 OpenVDS/自建 GL。\n"; + } else { + rf << "单 mapper SetPartitions 整卷体绘制【未真渲出】(纹理错=" + << (textureErr ? "是" : "否") << ",非空像素=" + << (renderedNonEmpty ? "是" : "否") + << ")。SetPartitions 未能绕过纹理墙,如实记录。\n"; + } + std::cout << "[renderC-partitioned] 报告写入 " << repo.string() << "\n"; + } + } + + return volFpsValid ? 0 : 1; +} + +// ============================================================================ +// LOD-fps 探针(POC-C 最后一根链子,Task 12c) +// ============================================================================ +// +// 12b 已证整卷全分辨率 ray cast(2.08 亿体素)~10fps 是硬上限,fps 杠杆只有 LOD +// (渲更少体素)。本探针在【真实金字塔 store】上验四件事,全离屏、双闸防假帧率: +// (a) 粗层概览 fps:level2 整卷(单轴 <16384 → 单 SmartVolumeMapper)。 +// (b) 全分辨率局部 fps:level0 一段 brick 列(沿线局部)。 +// (c) LOD 切换动态过渡:相机从远观(level2)逐步拉近到近观局部(level0),跨越 +// LOD 切换那一下逐帧记帧耗时,标切换帧尖峰/stall。 +// (d) 截图:lod-overview.png / lod-fullres-local.png / lod-transition-mid.png。 +// +// 双闸(同 9c,绝不把空纹理假帧率当性能): +// ① CapturingOutputWindow 捕获 3D 纹理维度错误; +// ② 真实回读像素,统计非背景像素 → 非空才算真渲出。 + +// 把金字塔某 level 重组成整卷 VTK_SHORT vtkImageData(逻辑同 WholeVolumeSource, +// 但按 level 维度 + spacing×2^level,使物理范围与 level0 一致)。 +vtkSmartPointer buildLevelImage( + const geopro::data::ChunkedVolumeStore& store, int level, + const geopro::data::StoreMeta& m) { + int nx = 0, ny = 0, nz = 0; + store.dims(level, nx, ny, nz); + const int brick = m.brick; + const double sc = static_cast(1 << level); // 2^level + + auto img = vtkSmartPointer::New(); + img->SetDimensions(nx, ny, nz); + img->SetOrigin(m.origin[0], m.origin[1], m.origin[2]); + img->SetSpacing(m.spacing[0] * sc, m.spacing[1] * sc, m.spacing[2] * sc); + + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(nx) * ny * nz); + + for (int bz = 0; bz < store.bricksZ(level); ++bz) { + for (int by = 0; by < store.bricksY(level); ++by) { + for (int bx = 0; bx < store.bricksX(level); ++bx) { + const std::vector raw = store.readBrick(level, bx, by, bz); + const int i0 = bx * brick, j0 = by * brick, k0 = bz * brick; + const int bw = (nx - i0 < brick) ? (nx - i0) : brick; + const int bh = (ny - j0 < brick) ? (ny - j0) : brick; + const int bd = (nz - k0 < brick) ? (nz - k0) : brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) { + const vtkIdType gk = static_cast(k0 + kk); + for (int jj = 0; jj < bh; ++jj) { + const vtkIdType gj = static_cast(j0 + jj); + vtkIdType id = (gk * ny + gj) * nx + i0; + for (int ii = 0; ii < bw; ++ii) arr->SetValue(id++, raw[w++]); + } + } + } + } + } + img->GetPointData()->SetScalars(arr); + return img; +} + +// 取 level0 一段 brick 列 [bx0, bx0+bxCount) × 全 Y × 全 Z 重组成局部整卷 +// VTK_SHORT image(X 维 = bxCount*brick ≤ 几百,远 <16384,单 3D 纹理)。 +// Origin 沿 X 偏移到该段起点,spacing 用 level0 原值。 +vtkSmartPointer buildLocalLevel0Image( + const geopro::data::ChunkedVolumeStore& store, + const geopro::data::StoreMeta& m, int bx0, int bxCount) { + const int brick = m.brick; + const int nx0 = m.nx, ny0 = m.ny, nz0 = m.nz; + const int totBx = store.bricksX(0); + bx0 = std::max(0, std::min(bx0, totBx - 1)); + bxCount = std::max(1, std::min(bxCount, totBx - bx0)); + + const int i0Global = bx0 * brick; + const int localNx = std::min(bxCount * brick, nx0 - i0Global); + + auto img = vtkSmartPointer::New(); + img->SetDimensions(localNx, ny0, nz0); + img->SetOrigin(m.origin[0] + i0Global * m.spacing[0], m.origin[1], + m.origin[2]); + img->SetSpacing(m.spacing[0], m.spacing[1], m.spacing[2]); + + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(localNx) * ny0 * nz0); + + for (int bz = 0; bz < store.bricksZ(0); ++bz) { + for (int by = 0; by < store.bricksY(0); ++by) { + for (int bx = bx0; bx < bx0 + bxCount; ++bx) { + const std::vector raw = store.readBrick(0, bx, by, bz); + const int gi0 = bx * brick, j0 = by * brick, k0 = bz * brick; + const int li0 = gi0 - i0Global; // 局部 X 起点 + const int bw = (nx0 - gi0 < brick) ? (nx0 - gi0) : brick; + const int bh = (ny0 - j0 < brick) ? (ny0 - j0) : brick; + const int bd = (nz0 - k0 < brick) ? (nz0 - k0) : brick; + std::size_t w = 0; + for (int kk = 0; kk < bd; ++kk) { + const vtkIdType gk = static_cast(k0 + kk); + for (int jj = 0; jj < bh; ++jj) { + const vtkIdType gj = static_cast(j0 + jj); + vtkIdType id = (gk * ny0 + gj) * localNx + li0; + for (int ii = 0; ii < bw; ++ii) arr->SetValue(id++, raw[w++]); + } + } + } + } + } + img->GetPointData()->SetScalars(arr); + return img; +} + +// 统计当前窗口前缓冲非背景像素(>10 任一通道)。 +vtkIdType countNonBlackPixels(vtkRenderWindow* rw, int w, int h) { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, w - 1, h - 1, /*front=*/1, px); + vtkIdType nb = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 10 || px->GetComponent(i, 1) > 10 || + px->GetComponent(i, 2) > 10) { + ++nb; + } + } + return nb; +} + +// 离屏窗口截图 → PNG。 +void savePng(vtkRenderWindow* rw, const std::string& path) { + rw->Render(); + vtkNew w2i; + w2i->SetInput(rw); + w2i->SetInputBufferTypeToRGB(); + w2i->ReadFrontBufferOff(); + w2i->Update(); + vtkNew writer; + writer->SetFileName(path.c_str()); + writer->SetInputConnection(w2i->GetOutputPort()); + writer->Write(); +} + +// 画面平均亮度(0~255):取前缓冲 RGB 求 luma 均值。Task 12d gallery 报告用, +// 量化「整体偏暗 vs 变亮」——背景占多数,故这是含背景的全屏均亮(横向对比有效)。 +double meanBrightness(vtkRenderWindow* rw, int w, int h) { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, w - 1, h - 1, /*front=*/1, px); + const vtkIdType np = px->GetNumberOfTuples(); + if (np == 0) return 0.0; + double sum = 0.0; + for (vtkIdType i = 0; i < np; ++i) { + const double r = px->GetComponent(i, 0); + const double g = px->GetComponent(i, 1); + const double b = px->GetComponent(i, 2); + sum += 0.299 * r + 0.587 * g + 0.114 * b; + } + return sum / static_cast(np); +} + +int cmdRenderLOD(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc renderLOD [--frames 120]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const int frames = std::stoi(a.get("frames", "120")); + std::cout << "[renderLOD] storeDir=" << dir << " frames=" << frames << "\n"; + + // 闸门复检:不可渲染机不产假 fps。 + std::cout << "[renderLOD] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[renderLOD] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const int totLevels = store.levels(); + std::cout << "[renderLOD] level0=" << m.nx << "x" << m.ny << "x" << m.nz + << " 总层数=" << totLevels << "\n"; + if (totLevels < 3) { + std::cout << "[renderLOD] 警告: 金字塔层数 <3(需 build --levels 3)。\n"; + } + + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeColorScale(vmin, vmax); + + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + + const int winW = 1024, winH = 768; + + // 共用一个捕获式 OutputWindow,贯穿三段渲染。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // ---- (a) 粗层概览 fps:level2 整卷 ---- + const int ovLevel = std::min(2, totLevels - 1); + std::cout << "[renderLOD] (a) 建 level" << ovLevel << " 整卷 image...\n"; + vtkSmartPointer ovImg = buildLevelImage(store, ovLevel, m); + int ovNx, ovNy, ovNz; + store.dims(ovLevel, ovNx, ovNy, ovNz); + + auto rwOv = makeOffscreenWindow(winW, winH); + vtkNew renOv; + renOv->SetBackground(0.0, 0.0, 0.0); + rwOv->AddRenderer(renOv); + vtkSmartPointer ovVol = + geopro::render::buildVoxelI16FromImage(ovImg.Get(), m.quant, cs, vmin, + vmax); + renOv->AddVolume(ovVol); + // 先测 fps(benchVolumeFps 内部会 ResetCamera + 旋满一圈)。 + const double ovFps = benchVolumeFps(rwOv.Get(), renOv, frames); + // 截图前重设一个利于人眼的取景:整线物理纵横比极扁(~2200m×1.5m×8m),俯视角 + // 看宽面才能呈现整条带(而非边缘线)。 + renOv->ResetCamera(); + renOv->GetActiveCamera()->Elevation(55.0); + renOv->GetActiveCamera()->Azimuth(20.0); + renOv->ResetCameraClippingRange(); + rwOv->Render(); + const vtkIdType ovNonBlack = countNonBlackPixels(rwOv.Get(), winW, winH); + savePng(rwOv.Get(), (shotDir / "lod-overview.png").string()); + std::cout << "[renderLOD] (a) 概览 fps=" << ovFps << " 非空像素=" << ovNonBlack + << " (level" << ovLevel << " " << ovNx << "x" << ovNy << "x" << ovNz + << ")\n"; + + // ---- (b) 全分辨率局部 fps:level0 一段 brick 列 ---- + const int totBx = store.bricksX(0); + const int localBx = std::min(4, totBx); // 4 brick 列 ≈ 256 体素宽 + const int bx0 = std::max(0, totBx / 2 - localBx / 2); // 取沿线中段 + std::cout << "[renderLOD] (b) 建 level0 局部 image (brick列 [" << bx0 << "," + << (bx0 + localBx) << ") / " << totBx << ")...\n"; + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + int locDims[3]; + locImg->GetDimensions(locDims); + + auto rwLoc = makeOffscreenWindow(winW, winH); + vtkNew renLoc; + renLoc->SetBackground(0.0, 0.0, 0.0); + rwLoc->AddRenderer(renLoc); + vtkSmartPointer locVol = + geopro::render::buildVoxelI16FromImage(locImg.Get(), m.quant, cs, vmin, + vmax); + renLoc->AddVolume(locVol); + const double locFps = benchVolumeFps(rwLoc.Get(), renLoc, frames); + // 截图取景:局部块(256×29×162)斜俯视,呈现全分辨率细节供与概览对比。 + renLoc->ResetCamera(); + renLoc->GetActiveCamera()->Elevation(35.0); + renLoc->GetActiveCamera()->Azimuth(25.0); + renLoc->ResetCameraClippingRange(); + rwLoc->Render(); + const vtkIdType locNonBlack = countNonBlackPixels(rwLoc.Get(), winW, winH); + savePng(rwLoc.Get(), (shotDir / "lod-fullres-local.png").string()); + std::cout << "[renderLOD] (b) 局部 fps=" << locFps << " 非空像素=" + << locNonBlack << " (level0 局部 " << locDims[0] << "x" << locDims[1] + << "x" << locDims[2] << ")\n"; + + // ---- (c) LOD 切换动态过渡 ---- + // 同一窗口:相机从远观(看整卷,用 level2 概览体)逐步 dolly 拉近,到一半处 + // 跨越 LOD 切换——把体从 level2 整卷换成 level0 局部体(重设 mapper 输入/相机 + // 目标),逐帧记帧耗时,标切换帧尖峰。 + std::cout << "[renderLOD] (c) LOD 切换动态过渡(" << frames << " 帧 dolly)...\n"; + auto rwTr = makeOffscreenWindow(winW, winH); + vtkNew renTr; + renTr->SetBackground(0.0, 0.0, 0.0); + rwTr->AddRenderer(renTr); + + // 远观体 = level2 概览(新建一份,避免与 (a) 共享 actor 状态)。 + vtkSmartPointer farVol = + geopro::render::buildVoxelI16FromImage(ovImg.Get(), m.quant, cs, vmin, + vmax); + // 近观体 = level0 局部(复用 (b) 的 image)。 + vtkSmartPointer nearVol = + geopro::render::buildVoxelI16FromImage(locImg.Get(), m.quant, cs, vmin, + vmax); + + renTr->AddVolume(farVol); + renTr->ResetCamera(); // 框住整卷(level2 与 level0 物理范围一致) + vtkCamera* camTr = renTr->GetActiveCamera(); + camTr->Elevation(20.0); + renTr->ResetCameraClippingRange(); + rwTr->Render(); // 预热远观 + + // dolly 目标:从当前(远)拉近到局部段中心。 + double locCenter[3]; + locImg->GetCenter(locCenter); + const int switchFrame = frames / 2; + const double dollyPerFrame = + std::pow(6.0, 1.0 / std::max(1, switchFrame)); // 切换前累计 dolly≈6× + + std::vector frameMs(frames, 0.0); + bool switched = false; + double switchStallMs = 0.0; + + for (int f = 0; f < frames; ++f) { + Stopwatch swF; + if (f == switchFrame && !switched) { + // —— LOD 切换那一下 ——:换体 + 把相机焦点移到局部段中心。 + renTr->RemoveVolume(farVol); + renTr->AddVolume(nearVol); + camTr->SetFocalPoint(locCenter[0], locCenter[1], locCenter[2]); + renTr->ResetCameraClippingRange(); + switched = true; + } + // 渐进拉近(切换前 dolly 进;切换后继续推近 + 轻微环绕,逐步框满局部块)。 + camTr->Dolly(switched ? 1.04 : dollyPerFrame); + if (switched) camTr->Azimuth(0.5); + renTr->ResetCameraClippingRange(); + rwTr->Render(); + frameMs[f] = swF.elapsedMs(); + if (f == switchFrame) switchStallMs = frameMs[f]; + // 切换后推近一小段再截“过渡中间帧”,使局部块已明显呈现(而非切换瞬间仍很远)。 + if (f == switchFrame + (frames - switchFrame) / 3) { + savePng(rwTr.Get(), (shotDir / "lod-transition-mid.png").string()); + } + } + + // 过渡帧耗时统计:平均、最大、切换帧、切换帧相对邻帧的尖峰倍数。 + double sum = 0, mx = 0; + for (double v : frameMs) { + sum += v; + mx = std::max(mx, v); + } + const double avgMs = frames > 0 ? sum / frames : 0.0; + const double preMs = + switchFrame > 0 ? frameMs[switchFrame - 1] : avgMs; + const double spikeRatio = preMs > 0 ? switchStallMs / preMs : 0.0; + // 可感知卡顿判据(绝对耗时为准,尖峰倍数仅作次级信号):当两端帧耗时是亚毫秒 + // 时,一次性换体的 ~9ms 抖动倍数虽大但仍 <1 个 60Hz 帧(16.7ms),人眼不可感。 + // 故:切换帧 >1 个 60Hz 帧(16.7ms)才记“轻微”,>2 帧(33ms)记“可感知卡顿”。 + constexpr double kFrame60Ms = 1000.0 / 60.0; // 16.7ms + const bool perceptibleStall = switchStallMs > 2.0 * kFrame60Ms; // >33ms + const bool minorHitch = + !perceptibleStall && switchStallMs > kFrame60Ms; // 16.7~33ms 轻微 + const vtkIdType trNonBlack = countNonBlackPixels(rwTr.Get(), winW, winH); + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + + // 双闸:无纹理错 + 三段均渲出非空像素。 + const bool renderedNonEmpty = + (ovNonBlack > 0) && (locNonBlack > 0) && (trNonBlack > 0); + const bool valid = !textureErr && renderedNonEmpty; + + const double ovFpsV = valid ? ovFps : -1.0; + const double locFpsV = valid ? locFps : -1.0; + const bool ovInteractive = valid && ovFps >= 15.0; + const bool locInteractive = valid && locFps >= 15.0; + const double peak = Probe::peakMemMB(); + + const char* stallTxt = + perceptibleStall ? "可感知卡顿" : (minorHitch ? "轻微抖动(<2帧)" : "无"); + std::cout << "[renderLOD] (c) 过渡帧耗时 avg=" << avgMs << "ms max=" << mx + << "ms 切换帧=" << switchStallMs << "ms (邻帧 " << preMs << "ms, 尖峰 " + << spikeRatio << "×) 卡顿=" << stallTxt << "\n"; + + std::cout << "\n=== renderLOD LOD-fps 探针指标 ===\n"; + std::cout << "离屏闸门 : OK\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") << "\n"; + std::cout << "三段均渲出非空 : " << (renderedNonEmpty ? "是" : "否(!!)") + << " (概览=" << ovNonBlack << " 局部=" << locNonBlack + << " 过渡=" << trNonBlack << ")\n"; + std::cout << "(a) 粗层概览 fps : " + << (valid ? std::to_string(ovFpsV) : std::string("INVALID")) + << " (level" << ovLevel << " " << ovNx << "x" << ovNy << "x" << ovNz + << ") 交互级=" << (ovInteractive ? "是 ✔" : "否 ✘") << "\n"; + std::cout << "(b) 全分辨率局部fps: " + << (valid ? std::to_string(locFpsV) : std::string("INVALID")) + << " (level0 局部 " << locDims[0] << "x" << locDims[1] << "x" + << locDims[2] << ") 交互级=" << (locInteractive ? "是 ✔" : "否 ✘") + << "\n"; + std::cout << "(c) 过渡平均/最大 : " << avgMs << " / " << mx << " ms\n"; + std::cout << " 切换帧耗时 : " << switchStallMs << " ms (邻帧 " << preMs + << " ms, 尖峰 " << spikeRatio << "×)\n"; + std::cout << " 可感知卡顿 : " << stallTxt + << (perceptibleStall ? " ✘" : " ✔") << " (判据:切换帧 >33ms 才记卡顿" + "; 1 帧 60Hz=16.7ms)\n"; + std::cout << "进程峰值内存(MB) : " << peak << "\n"; + std::cout << "截图 : " << shotDir.string() + << " (lod-overview / lod-fullres-local / lod-transition-mid)\n"; + + writeMetricLine( + "renderLOD,dir=" + dir + ",totLevels=" + std::to_string(totLevels) + + ",ovLevel=" + std::to_string(ovLevel) + + ",ovDims=" + std::to_string(ovNx) + "x" + std::to_string(ovNy) + "x" + + std::to_string(ovNz) + + ",ovFps=" + (valid ? std::to_string(ovFpsV) : "INVALID") + + ",ovNonBlack=" + std::to_string(ovNonBlack) + + ",locDims=" + std::to_string(locDims[0]) + "x" + + std::to_string(locDims[1]) + "x" + std::to_string(locDims[2]) + + ",locFps=" + (valid ? std::to_string(locFpsV) : "INVALID") + + ",locNonBlack=" + std::to_string(locNonBlack) + + ",trAvgMs=" + std::to_string(avgMs) + ",trMaxMs=" + std::to_string(mx) + + ",switchMs=" + std::to_string(switchStallMs) + + ",switchSpike=" + std::to_string(spikeRatio) + + ",stall=" + std::to_string(perceptibleStall ? 1 : 0) + + ",trNonBlack=" + std::to_string(trNonBlack) + + ",textureErr=" + std::to_string(textureErr ? 1 : 0) + + ",valid=" + std::to_string(valid ? 1 : 0) + + ",peakMB=" + std::to_string(peak)); + + // 写 poc-results-C.md 的 LOD 段(追加,不覆盖 renderC-partitioned 段)。 + { + const fs::path repo = + fs::path("docs") / "superpowers" / "plans" / "poc-results-C.md"; + fs::create_directories(repo.parent_path()); + std::ofstream rf(repo.string(), std::ios::app); + if (rf) { + rf << "\n\n# POC-C LOD-fps 探针结果(Task 12c)\n\n"; + rf << "金字塔 store: " << dir << "(level0=" << m.nx << "x" << m.ny << "x" + << m.nz << ",总 " << totLevels << " 层)\n\n"; + rf << "| 项 | 维度 | 结果 |\n|---|---|---|\n"; + rf << "| (a) 粗层概览 fps | level" << ovLevel << " " << ovNx << "x" << ovNy + << "x" << ovNz << " | " << (valid ? std::to_string(ovFpsV) : "INVALID") + << " fps " << (ovInteractive ? "(交互级)" : "(未达交互级)") << " |\n"; + rf << "| (b) 全分辨率局部 fps | level0 局部 " << locDims[0] << "x" + << locDims[1] << "x" << locDims[2] << " | " + << (valid ? std::to_string(locFpsV) : "INVALID") << " fps " + << (locInteractive ? "(交互级)" : "(未达交互级)") << " |\n"; + rf << "| (c) LOD 切换过渡 | 切换帧 " << switchFrame << "/" << frames + << " | 平均 " << avgMs << "ms,切换帧 " << switchStallMs << "ms(尖峰 " + << spikeRatio << "×)," + << (perceptibleStall ? "可感知卡顿" + : (minorHitch ? "轻微抖动" : "无可感知卡顿")) + << " |\n\n"; + rf << "- 卡顿判据:切换帧绝对耗时 >33ms(2 个 60Hz 帧)才记可感知卡顿;" + "16.7~33ms 记轻微抖动;亚毫秒基线下尖峰倍数大但绝对值低不算卡顿。\n"; + rf << "- 双闸:纹理维度错误=" << (textureErr ? "是" : "否") + << ";三段均渲出非空像素=" << (renderedNonEmpty ? "是" : "否") + << "(概览 " << ovNonBlack << " / 局部 " << locNonBlack << " / 过渡 " + << trNonBlack << ")。\n"; + rf << "- 截图(人眼判“概览糊→拉近清晰”):docs/superpowers/plans/poc-lod-shots/" + "lod-overview.png、lod-fullres-local.png、lod-transition-mid.png\n"; + rf << "- 进程峰值内存: " << peak << " MB\n\n"; + rf << "## 判据结论\n"; + if (valid && ovInteractive && locInteractive && !perceptibleStall) { + rf << "粗层概览 + 全分辨率局部【都达交互级】且切换【无不可接受卡顿】→ " + "LOD-based C 路线钉死可行。\n"; + } else if (valid && ovInteractive && !locInteractive) { + rf << "粗层快但全分辨率局部仍慢 → VTK 体绘制有真实天花板,记录," + "评估 OpenVDS/自建 GL。\n"; + } else if (valid && perceptibleStall) { + rf << "两端 fps 可接受但切换卡顿明显(切换帧 " << switchStallMs + << "ms)→ 为后续 morphing/淡入提供依据。\n"; + } else if (!valid) { + rf << "双闸未过(纹理错或空渲染)→ 数字不可信,如实标 INVALID。\n"; + } else { + rf << "部分达标,详见上表。\n"; + } + rf << "\n**最低配未验声明**:本探针仅在本机(RTX 3060)跑得上限数字," + "最低配机器未验证,需用户在目标机跑或提供型号。\n"; + } + std::cout << "[renderLOD] 报告追加写入 " << repo.string() << "\n"; + } + + return valid ? 0 : 1; +} + +// ============================================================================ +// ① 视觉调优:出一帧能看结构的图 + 调优前后 fps 对照(Task 12d) +// ============================================================================ +// +// 在【真实金字塔 store】上对局部段(level0 一段 brick 列)与粗层概览(level2 整卷) +// 各跑两遍体绘制 fps:调优前(默认色阶 0.15 不透明度 无夸张) vs 调优后(结构色阶 + +// --opacity + --exagg 垂向夸张),离屏存 lod-tuned-local.png / lod-tuned-overview.png, +// 并打印前后 fps 对照——证实「视觉调优对 fps 近乎中性」这一探针认知。双闸防假帧率。 +int cmdTune(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc tune [--opacity 0.5] [--exagg 8] " + "[--frames 120] [--localBricks 4]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double opacity = std::stod(a.get("opacity", "0.5")); + const double exagg = std::stod(a.get("exagg", "8")); + const int frames = std::stoi(a.get("frames", "120")); + const int localBricks = std::stoi(a.get("localBricks", "4")); + std::cout << "[tune] storeDir=" << dir << " opacity=" << opacity + << " exagg=" << exagg << " frames=" << frames << "\n"; + + std::cout << "[tune] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[tune] 闸门失败,中止。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const int totLevels = store.levels(); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + + const geopro::core::ColorScale csPlain = makeColorScale(vmin, vmax); + const geopro::core::ColorScale csTuned = makeStructuralColorScale(vmin, vmax); + + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + const int winW = 1024, winH = 768; + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // ---- 局部段:level0 一段 brick 列(沿线中段)---- + const int totBx = store.bricksX(0); + const int localBx = std::min(localBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + int locDims[3]; + locImg->GetDimensions(locDims); + + // 调优前局部 fps(默认色阶 0.15 无夸张)。 + auto rwA = makeOffscreenWindow(winW, winH); + vtkNew renA; + renA->SetBackground(0.0, 0.0, 0.0); + rwA->AddRenderer(renA); + vtkSmartPointer volA = + buildTunedVolume(locImg.Get(), m.quant, csPlain, vmin, vmax, 0.15, 1.0, + /*structuralOpacity=*/false); // 原始线性单斜坡基线 + renA->AddVolume(volA); + const double locFpsBefore = benchVolumeFps(rwA.Get(), renA, frames); + + // 调优后局部 fps(结构色阶 + opacity + exagg)。 + auto rwB = makeOffscreenWindow(winW, winH); + vtkNew renB; + renB->SetBackground(0.04, 0.04, 0.08); // 深蓝灰背景,衬托体 + rwB->AddRenderer(renB); + vtkSmartPointer volB = + buildTunedVolume(locImg.Get(), m.quant, csTuned, vmin, vmax, opacity, + exagg); + renB->AddVolume(volB); + const double locFpsAfter = benchVolumeFps(rwB.Get(), renB, frames); + // 调优后取景:夸张后块更"立体",斜俯视呈现截面层次;Zoom 拉近填满画面。 + renB->ResetCamera(); + renB->GetActiveCamera()->Elevation(28.0); + renB->GetActiveCamera()->Azimuth(30.0); + renB->GetActiveCamera()->Zoom(1.7); + renB->ResetCameraClippingRange(); + rwB->Render(); + const vtkIdType locNonBlack = countNonBlackPixels(rwB.Get(), winW, winH); + savePng(rwB.Get(), (shotDir / "lod-tuned-local.png").string()); + + // ---- 概览:level2 整卷(接受它就是细带)---- + const int ovLevel = std::min(2, totLevels - 1); + vtkSmartPointer ovImg = buildLevelImage(store, ovLevel, m); + auto rwO = makeOffscreenWindow(winW, winH); + vtkNew renO; + renO->SetBackground(0.04, 0.04, 0.08); + rwO->AddRenderer(renO); + vtkSmartPointer volO = + buildTunedVolume(ovImg.Get(), m.quant, csTuned, vmin, vmax, opacity, + exagg); + renO->AddVolume(volO); + const double ovFpsAfter = benchVolumeFps(rwO.Get(), renO, frames); + renO->ResetCamera(); + renO->GetActiveCamera()->Elevation(50.0); + renO->GetActiveCamera()->Azimuth(20.0); + renO->ResetCameraClippingRange(); + rwO->Render(); + const vtkIdType ovNonBlack = countNonBlackPixels(rwO.Get(), winW, winH); + savePng(rwO.Get(), (shotDir / "lod-tuned-overview.png").string()); + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool valid = + !textureErr && locNonBlack > 0 && ovNonBlack > 0; + + const double dropPct = + locFpsBefore > 0 ? (locFpsBefore - locFpsAfter) / locFpsBefore * 100.0 + : 0.0; + + std::cout << "\n=== tune 视觉调优指标 ===\n"; + std::cout << "局部段维度 : " << locDims[0] << "x" << locDims[1] << "x" + << locDims[2] << " (level0)\n"; + std::cout << "调优前局部 fps : " + << (valid ? std::to_string(locFpsBefore) : "INVALID") + << " (默认蓝白红, 不透明度 0.15, 无夸张)\n"; + std::cout << "调优后局部 fps : " + << (valid ? std::to_string(locFpsAfter) : "INVALID") + << " (结构色阶, 不透明度 " << opacity << ", 夸张 " << exagg + << "x)\n"; + std::cout << "fps 变化 : " << dropPct + << "% (正=变慢/负=变快; 探针预期近乎中性)\n"; + std::cout << "调优后概览 fps : " + << (valid ? std::to_string(ovFpsAfter) : "INVALID") << " (level" + << ovLevel << ")\n"; + std::cout << "双闸 : 纹理错=" << (textureErr ? "是" : "否") + << " 局部非空=" << locNonBlack << " 概览非空=" << ovNonBlack + << " → " << (valid ? "可信" : "INVALID") << "\n"; + std::cout << "截图 : " << shotDir.string() + << " (lod-tuned-local.png / lod-tuned-overview.png)\n"; + + writeMetricLine( + "tune,dir=" + dir + ",opacity=" + std::to_string(opacity) + + ",exagg=" + std::to_string(exagg) + + ",locDims=" + std::to_string(locDims[0]) + "x" + + std::to_string(locDims[1]) + "x" + std::to_string(locDims[2]) + + ",locFpsBefore=" + (valid ? std::to_string(locFpsBefore) : "INVALID") + + ",locFpsAfter=" + (valid ? std::to_string(locFpsAfter) : "INVALID") + + ",dropPct=" + std::to_string(dropPct) + + ",ovFpsAfter=" + (valid ? std::to_string(ovFpsAfter) : "INVALID") + + ",locNonBlack=" + std::to_string(locNonBlack) + + ",ovNonBlack=" + std::to_string(ovNonBlack) + + ",valid=" + std::to_string(valid ? 1 : 0)); + return valid ? 0 : 1; +} + +// ============================================================================ +// ② fps 预算:递增全分辨率(level0)窗口找「每帧体素预算」(Task 12d) +// ============================================================================ +// +// 对递增的 level0 brick 列段(4,16,64,128,256 brick,可 --bricks 覆盖)各重组成 +// 局部整卷 image 跑体绘制 fps,输出表 brick数/体素数/fps,找出 fps 跌破 30 的体素 +// 阈值 = production LOD 每帧渲染的全分辨率块数上限。双闸防假帧率。 +int cmdFpsBudget(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc fps-budget [--frames 90] " + "[--bricks 4,16,64,128,256]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const int frames = std::stoi(a.get("frames", "90")); + const double opacity = std::stod(a.get("opacity", "0.5")); + const double exagg = std::stod(a.get("exagg", "8")); + + // 解析 brick 段列表(逗号分隔)。 + std::vector brickSteps; + { + const std::string raw = a.get("bricks", "4,16,64,128,256"); + std::string cur; + for (char ch : raw) { + if (ch == ',') { + if (!cur.empty()) brickSteps.push_back(std::stoi(cur)); + cur.clear(); + } else { + cur.push_back(ch); + } + } + if (!cur.empty()) brickSteps.push_back(std::stoi(cur)); + } + + std::cout << "[fps-budget] storeDir=" << dir << " frames=" << frames << "\n"; + std::cout << "[fps-budget] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[fps-budget] 闸门失败,中止,不产出 fps。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const int totBx = store.bricksX(0); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeStructuralColorScale(vmin, vmax); + + std::cout << "[fps-budget] level0=" << m.nx << "x" << m.ny << "x" << m.nz + << " 总 brick列=" << totBx << " brick=" << m.brick << "\n"; + + struct Row { + int bricks; + long long voxels; + double fps; + bool valid; + }; + std::vector rows; + + constexpr double kTargetFps = 30.0; + long long budgetVoxels = -1; // fps 跌破 30 前的最大体素数 + int budgetBricks = -1; + long long firstBelowVoxels = -1; + int firstBelowBricks = -1; + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + for (int nb : brickSteps) { + const int localBx = std::min(nb, totBx); + if (localBx <= 0) continue; + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer img = + buildLocalLevel0Image(store, m, bx0, localBx); + int d[3]; + img->GetDimensions(d); + const long long voxels = + static_cast(d[0]) * d[1] * d[2]; + + auto rw = makeOffscreenWindow(1024, 768); + vtkNew ren; + ren->SetBackground(0.0, 0.0, 0.0); + rw->AddRenderer(ren); + vtkSmartPointer vol = + buildTunedVolume(img.Get(), m.quant, cs, vmin, vmax, opacity, exagg); + ren->AddVolume(vol); + + const double fps = benchVolumeFps(rw.Get(), ren, frames); + // 双闸:纹理无错 + 该段渲出非空像素。 + ren->ResetCamera(); + rw->Render(); + const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), 1024, 768); + const bool valid = !capWin->textureError() && nonBlack > 0; + + rows.push_back({localBx, voxels, fps, valid}); + std::cout << "[fps-budget] brick=" << localBx << " (" << d[0] << "x" << d[1] + << "x" << d[2] << ") 体素=" << voxels << " fps=" + << (valid ? std::to_string(fps) : "INVALID") + << " 非空=" << nonBlack << "\n"; + + if (valid) { + if (fps >= kTargetFps) { + if (voxels > budgetVoxels) { + budgetVoxels = voxels; + budgetBricks = localBx; + } + } else if (firstBelowVoxels < 0) { + firstBelowVoxels = voxels; + firstBelowBricks = localBx; + } + } + } + + const bool textureErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const double peak = Probe::peakMemMB(); + + std::cout << "\n=== fps-budget 每帧体素预算表 ===\n"; + std::cout << "| brick段 | 维度体素数 | 体绘制 fps | ≥30 |\n"; + std::cout << "|---|---|---|---|\n"; + for (const auto& r : rows) { + std::cout << "| " << r.bricks << " | " << r.voxels << " | " + << (r.valid ? std::to_string(r.fps) : std::string("INVALID")) + << " | " << (r.valid && r.fps >= kTargetFps ? "是" : "否") + << " |\n"; + } + std::cout << "\n每帧体素预算(fps≥30 上限) : " + << (budgetVoxels >= 0 ? std::to_string(budgetVoxels) + + " 体素 (" + std::to_string(budgetBricks) + + " brick列)" + : std::string("未触达(所有测点均 ≥30)")) + << "\n"; + std::cout << "首个跌破 30 的窗口 : " + << (firstBelowVoxels >= 0 + ? std::to_string(firstBelowVoxels) + " 体素 (" + + std::to_string(firstBelowBricks) + " brick列)" + : std::string("无(测点未跌破; 需更大 --bricks)")) + << "\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") + << "\n"; + std::cout << "进程峰值内存(MB) : " << peak << "\n"; + + // 落 last-metrics + 追加写 poc-results-C.md。 + for (const auto& r : rows) { + writeMetricLine( + "fps-budget,dir=" + dir + ",bricks=" + std::to_string(r.bricks) + + ",voxels=" + std::to_string(r.voxels) + + ",fps=" + (r.valid ? std::to_string(r.fps) : "INVALID") + + ",valid=" + std::to_string(r.valid ? 1 : 0)); + } + + { + const fs::path repo = + fs::path("docs") / "superpowers" / "plans" / "poc-results-C.md"; + fs::create_directories(repo.parent_path()); + std::ofstream rf(repo.string(), std::ios::app); + if (rf) { + rf << "\n\n# POC-C fps 预算探针结果(Task 12d ②)\n\n"; + rf << "金字塔 store: " << dir << "(level0=" << m.nx << "x" << m.ny << "x" + << m.nz << ",brick=" << m.brick << ")\n\n"; + rf << "递增 level0 局部窗口(沿线中段 brick 列)体绘制 fps:\n\n"; + rf << "| brick段 | 体素数 | 体绘制 fps | ≥30fps |\n|---|---|---|---|\n"; + for (const auto& r : rows) { + rf << "| " << r.bricks << " | " << r.voxels << " | " + << (r.valid ? std::to_string(r.fps) : "INVALID") << " | " + << (r.valid && r.fps >= kTargetFps ? "是" : "否") << " |\n"; + } + rf << "\n- **每帧体素预算(fps≥30 上限)**: " + << (budgetVoxels >= 0 + ? std::to_string(budgetVoxels) + " 体素(" + + std::to_string(budgetBricks) + " brick 列)" + : "未触达,所有测点 ≥30fps") + << "\n"; + rf << "- 首个跌破 30 的窗口: " + << (firstBelowVoxels >= 0 + ? std::to_string(firstBelowVoxels) + " 体素(" + + std::to_string(firstBelowBricks) + " brick 列)" + : "无(需更大 --bricks 段触达天花板)") + << "\n"; + rf << "- 双闸:纹理维度错误=" << (textureErr ? "是" : "否") + << ";每段均按非空像素校验。\n"; + rf << "- production LOD 应把【每帧渲染的全分辨率块】卡在此预算以内。\n"; + rf << "- **本机 RTX 3060 上限数;最低配需用户在目标机跑 fps-budget/view。**\n"; + } + std::cout << "[fps-budget] 报告追加写入 " << repo.string() << "\n"; + } + + return textureErr ? 1 : 0; +} + +// ============================================================================ +// 视觉调参画廊(Task 12d gallery):view --preview --variant N +// ============================================================================ +// +// 同一局部段(沿线中段 kViewDefaultLocalBricks 列全分辨率) + 同一相机框法 +// (ResetCamera→Elevation/Azimuth→Zoom),只换「不透明度包络 / 配色 / 取景角度 / +// 背景」四组视觉参数,各存一张 PNG 供控制方挑选。fps 对视觉调参近乎中性,每组实测验证。 +// +// 注:交互窗口(无 flag 的 view)默认即采用 var4(kViewDefaultVariant)——配色/不透明度 +// 包络/取景/exagg/背景全部走同一份 var4 参数,故「交互默认画面 == view-var4」。 +enum class OpacityProfile { + kSolid, // V 形实体感:中高值段普遍可见,半透明实心块 + kStructural, // 现有双端斜坡:仅正负两端不透明(对照基线) +}; +enum class ColorChoice { + kStructural, + kSeismic, + kJet, + kBrightSeismic, // 调亮版 seismic + kGrayEnhanced, // 增强灰度 +}; + +struct GalleryVariant { + const char* name; // 文件名后缀:view-.png + OpacityProfile profile; + ColorChoice color; + double floorOpacity; // 近零背景不透明度(kSolid 用) + double midOpacity; // 中值段不透明度(kSolid 用) + double maxOpacity; // 两端峰值不透明度 + double exagg; // 垂向夸张 + double elevation; // ResetCamera 后 Elevation + double azimuth; // 再 Azimuth + double zoom; // 再 Zoom 填满画面 + double bg[3]; // 背景 RGB + const char* desc; // 报告用中文说明 + // ---- C4 视觉调优(梯度不透明度 + 光照),默认关 → 不改既有变体行为 ---- + bool useGradientOpacity = false; // SetGradientOpacity:均匀层透明、界面/异常显形 + bool useShade = false; // SetShade:层界面带立体明暗 + // ---- P4 调亮/调清晰 ---- + // 梯度门松弛度 0~1:0=严格(均匀层全透、偏暗) 1=宽松(均匀层保留底不透明、更亮更满)。 + // 宽松时降低梯度阈值并抬高「低梯度区」的不透明度地板,让横向层叠/基底反射等弱结构保留。 + double gradGateRelax = 0.0; + double ambient = 0.30; // 光照环境项(别太低,否则体面偏暗);useShade 时生效。 +}; + +// P4 调亮/调清晰:4 组对照(暗版基线 / 提亮 / 高对比 / 灰度增强)。 +// 同一局部段、同一斜穿取景(El45/Az30)。P3 默认(seismic+严格梯度门+低 ambient)整体 +// 偏暗、均匀层被门全透成空。本组在「消雾」与「够亮够满」间放宽门控、抬 ambient、换更亮 +// 配色,让横向层叠/竖纹/基底反射醒目。所有端点按该体 2/98 分位自适应(runGalleryVariant +// 内标定),非写死单一数据。末项 = kViewDefaultVariant → 交互窗口默认取最清晰醒目组。 +// +// 字段:floorOpacity/midOpacity/maxOpacity(V 形标量包络)、gradGateRelax(梯度门松弛 +// 0~1)、ambient(光照环境项)。提亮组抬高 floor/mid 让均匀层保留底不透明、抬 ambient +// 防体面发暗、放宽梯度门保留弱结构。 +const GalleryVariant kGalleryVariants[] = { + // var1:暗版基线(= P3 默认)——seismic + 严格梯度门(relax0) + 低 ambient0.3 + 暗背景。 + // 均匀层几乎全透、整体偏暗偏空,仅作对照。 + {"var1", OpacityProfile::kSolid, ColorChoice::kSeismic, + 0.04, 0.30, 0.60, 8.0, 45.0, 30.0, 1.5, {0.07, 0.08, 0.11}, + "暗版基线(P3默认):seismic+严格梯度门+低ambient+暗背景,均匀层近全透、偏暗偏空", + /*useGradientOpacity=*/true, /*useShade=*/true, + /*gradGateRelax=*/0.0, /*ambient=*/0.30}, + // var2:提亮——调亮版 seismic + 放宽梯度门(relax0.6) + 抬 floor/mid/max + 高 ambient + // + 略亮背景。均匀层保留底不透明、横向层叠透出、整体明显更亮更实。 + {"var2", OpacityProfile::kSolid, ColorChoice::kBrightSeismic, + 0.10, 0.45, 0.75, 8.0, 45.0, 30.0, 1.5, {0.12, 0.13, 0.17}, + "提亮:调亮版seismic+放宽梯度门(relax0.6)+抬不透明度(floor0.10/mid0.45/max0.75)" + "+高ambient0.5,均匀层保留、层叠透出、明显更亮", + /*useGradientOpacity=*/true, /*useShade=*/true, + /*gradGateRelax=*/0.6, /*ambient=*/0.50}, + // var3:高对比——jet 高饱和配色 + 放宽梯度门(relax0.5) + 抬不透明度 + ambient0.45 + + // 暗背景衬高饱和。弱信号也映到鲜明色相,层界面/异常对比最狠。 + {"var3", OpacityProfile::kSolid, ColorChoice::kJet, + 0.08, 0.42, 0.78, 8.0, 45.0, 30.0, 1.5, {0.04, 0.04, 0.07}, + "高对比:jet高饱和+放宽梯度门(relax0.5)+抬不透明度+ambient0.45+暗背景衬色," + "弱信号映鲜明色相、层界面对比最狠", + /*useGradientOpacity=*/true, /*useShade=*/true, + /*gradGateRelax=*/0.5, /*ambient=*/0.45}, + // var4:灰度增强(默认)——增强灰度 + 放宽梯度门(relax0.7) + 抬不透明度 + 高 ambient + // + 中性背景。GPR 内部水平层叠/竖纹/基底反射用增强灰度最干净直读,选作交互默认。 + {"var4", OpacityProfile::kSolid, ColorChoice::kGrayEnhanced, + 0.12, 0.48, 0.80, 8.0, 45.0, 30.0, 1.5, {0.14, 0.15, 0.18}, + "灰度增强(默认/综合最佳):增强灰度+放宽梯度门(relax0.7)+抬不透明度" + "(floor0.12/mid0.48/max0.80)+高ambient0.5+中性背景,层叠/竖纹/基底反射干净醒目" + " → 交互窗口默认", + /*useGradientOpacity=*/true, /*useShade=*/true, + /*gradGateRelax=*/0.7, /*ambient=*/0.50}, +}; + +// 交互窗口(无 flag 的 view)的默认视觉变体 = var4(kGalleryVariants 末项)。 +// 交互默认与 view-var4 走同一份参数 → 二者画面一致(DRY,不复制粘贴漂移)。 +const GalleryVariant& kViewDefaultVariant = + kGalleryVariants[sizeof(kGalleryVariants) / sizeof(kGalleryVariants[0]) - 1]; + +geopro::core::ColorScale pickColor(ColorChoice c, double vmin, double vmax) { + switch (c) { + case ColorChoice::kSeismic: return makeSeismicColorScale(vmin, vmax); + case ColorChoice::kJet: return makeJetColorScale(vmin, vmax); + case ColorChoice::kBrightSeismic: + return makeBrightSeismicColorScale(vmin, vmax); + case ColorChoice::kGrayEnhanced: + return makeGrayEnhancedColorScale(vmin, vmax); + case ColorChoice::kStructural: + default: return makeStructuralColorScale(vmin, vmax); + } +} + +// 按变体的不透明度包络建体属性(gallery / 交互默认共用,DRY)。kSolid 走 V 形实体 +// 包络(floor/mid/max),kStructural 走双端斜坡(仅 maxOpacity)。 +// +// C4 视觉调优:若变体开了 useGradientOpacity 且传入了实测梯度统计 gs,再叠加 +// 1) 梯度不透明度(SetGradientOpacity):低梯度(均匀层)→透明、高梯度(界面/异常)→显形, +// 让射线穿透均匀雾、内部界面浮出(阈值按 gs 的 median/p90/p99 标定,不靠猜); +// 2) 光照(useShade → SetShade + Ambient/Diffuse/Specular):层界面带立体明暗。 +vtkSmartPointer makeVariantProperty( + const GalleryVariant& v, const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, double vmin, double vmax, + double maxOpacity, const GradStats* gs = nullptr) { + vtkSmartPointer prop; + if (v.profile == OpacityProfile::kSolid) { + prop = makeSolidVolumeProperty(q, cs, vmin, vmax, v.floorOpacity, + v.midOpacity, maxOpacity); + } else { + prop = makeTunedVolumeProperty(q, cs, vmin, vmax, maxOpacity); + } + + // 梯度不透明度:均匀层(低梯度)半透/透明,层界面/异常边缘(高梯度)显形。阈值按实测 + // 分布,并按 gradGateRelax 松弛: + // - relax=0(严格,暗版):median→0、p90→0.5、p99→0.9,均匀层全透 → 偏暗偏空。 + // - relax>0(调亮):低梯度地板抬到 floorG(均匀层保留底不透明、更亮更满),阈值 + // 整体左移(更早升起),让横向层叠/基底反射等弱结构保留可见。 + // 无 gs(未测)则跳过。 + if (v.useGradientOpacity && gs != nullptr && gs->samples > 0) { + const double relax = std::clamp(v.gradGateRelax, 0.0, 1.0); + const double floorG = 0.30 * relax; // 低梯度区不透明度地板(均匀层保留度) + const double midG = 0.5 + 0.25 * relax; + const double hiG = 0.9; + // 阈值左移:松弛越大,门越早从低梯度升起(结构保留越多)。 + const double tLo = std::max(1.0, gs->median * (1.0 - 0.7 * relax)); + const double tMid = std::max(2.0, gs->p90 * (1.0 - 0.6 * relax)); + const double tHi = std::max(3.0, gs->p99 * (1.0 - 0.4 * relax)); + vtkNew grad; + grad->AddPoint(0.0, floorG); + grad->AddPoint(tLo, floorG); + grad->AddPoint(tMid, midG); + grad->AddPoint(tHi, hiG); + prop->SetGradientOpacity(grad); + } + + // 光照:ShadeOn + Ambient/Diffuse,保留立体明暗;Specular 压到 0.05(近乎关)避免 + // 旋转时视角相关的高光在体表游走形成「移动白斑」。Ambient 由变体控(别太低否则偏暗)。 + if (v.useShade) { + prop->ShadeOn(); + prop->SetAmbient(v.ambient); + prop->SetDiffuse(0.7); + prop->SetSpecular(0.05); + prop->SetSpecularPower(10.0); + } + return prop; +} + +// ============================================================================ +// ③ view:真窗口可交互(给用户肉眼测 + 最低配机跑)(Task 12d) +// ============================================================================ +// +// 真 vtkRenderWindow + vtkRenderWindowInteractor(TrackballCamera),挂 +// OutOfCoreSource:相机变化时 source.update(camera) 重选 LOD/视野块再渲(确保 +// 拖动/缩放时 LOD 真切换);屏幕左上角 vtkTextActor 实时显示 fps + 当前 level。 +// 默认取景对准局部段 + 默认垂向夸张/不透明度(同 ①)。 +// +// 离屏 smoke:--smoke 时不开真窗口,只离屏建管线 + 渲一帧 + 验非空像素,确保不崩。 + +// 整卷单张 3D 纹理的轴上限(同 renderLOD/renderB 实测 GL_MAX_3D_TEXTURE_SIZE)。 +constexpr int kViewMax3DTex = 16384; + +// 单纹理统一渲染(Task 12d-singletex): +// +// 交互 view 不再用 vtkMultiBlockVolumeMapper + budget 分块(缺块、个位数 fps)。 +// 任何相机位置都只渲【一张 vtkImageData + 单个 vtkSmartVolumeMapper】,与 --preview +// 走完全同一条产单图 + 同一 mapper 的路径,保证一致、高 fps(与预览 184fps 同档)。 +// +// LOD 选层规则(拉近变细、拉远变粗): +// - 远观/中景(相机选中粗层)→ 升到最细的「整卷各轴 ≤16384」层(本数据 L2:11119、 +// L3:5560),整卷重组成一张纹理,任何缩放都显示完整体,绝不缺块。 +// - 拉近(相机选中 level0/1,X 超 16384 无法整卷成单纹理)→ 取当前视野在 level0 的 +// X 子区域(沿线裁一段,使子体各轴 ≤16384)重组一张纹理。 +// 两条都用现成 buildLevelImage / buildLocalLevel0Image 产单图 → 单 SmartVolumeMapper。 + +// view 的每帧回调共享状态(挂到 interactor 的 EndInteraction 上)。 +// +// 渲染源 = ViewAdaptiveVolumeSource(C2):每次交互结束 source->update(cam) 用 C1 +// selectLod 选层选区 → 从分块存储重组【当前视野区域单图】→ 喂单 SmartVolumeMapper。 +// 退掉旧 POC 简化路径(viewPickLevel/wholeVolumeLevelFor/viewLocalBrickRange/缓存 +// 三件套/MultiBlock 分块全部由 C1+C2 承担)。 +struct ViewState { + geopro::render::ViewAdaptiveVolumeSource* source = nullptr; + vtkSmartVolumeMapper* mapper = nullptr; // 高清层:单 SmartVolumeMapper(叠在底图上) + vtkSmartVolumeMapper* baseMapper = nullptr; // 常驻底图层 mapper(用于按高清块挖空 cropping) + vtkRenderer* ren = nullptr; + vtkCamera* cam = nullptr; + vtkTextActor* fpsText = nullptr; + vtkRenderWindow* rw = nullptr; + double exagg = 8.0; + int lastLevel = -1; + std::string dir; // store 目录:首帧直读 level0 局部段(类 gallery),绕开 LOD 选粗层 + // 预建的首帧高清段(level0 沿线中段):cmdView 已为分位标定建好,直接复用喂 mapper, + // 避免在 viewSetupDefaultFrame 内重复读盘。空则该函数再按 dir 直读。 + vtkSmartPointer seedSegImg; + // 持有当前高清单图引用,避免被释放(mapper 仅持裸指针)。 + vtkSmartPointer currentImg; + // 回调防重入:回调内部会 Render(),若 Render 又触发观察者回调会无限递归。 + bool inCb = false; +}; + +// C3-8 底图按高清块挖空:用 vtkVolumeMapper 的 Cropping 在【底图 mapper】上裁掉高清块 +// 覆盖的那一块,使任意时刻 = 底图(高清区外) + 高清(高清区内),无空间重叠 → 不再双渲发白。 +// +// 裁剪平面用高清单图的【模型坐标包围盒】(GetBounds)。底图与高清两 actor 用同一份 +// SetScale(1,1,exagg),且高清单图自带绝对世界 origin(buildLocalLevel0Image 沿 X 偏移), +// 与底图同坐标系 → 高清块的模型盒平面直接作底图 cropping 平面即对齐(两层 scale 一致)。 +// +// CroppingRegionFlags:6 个平面把空间分成 3×3×3=27 区,中心区(盒内)= bit 0x0002000 +// (VTK_CROP_SUBVOLUME)。要「渲盒外、挖掉盒内」→ 全 27 区减中心区 = 0x7ffffff & ~0x0002000。 +void viewSyncBaseCropping(ViewState* st) { + if (st->baseMapper == nullptr) return; + if (st->currentImg == nullptr) { // 高清未就绪:底图不裁剪,全渲(绝不空白) + st->baseMapper->SetCropping(0); + return; + } + double b[6]; + st->currentImg->GetBounds(b); // 模型坐标盒(含绝对 X origin),与底图同系 + st->baseMapper->SetCroppingRegionPlanes(b[0], b[1], b[2], b[3], b[4], b[5]); + st->baseMapper->SetCroppingRegionFlags(0x7ffffff & ~VTK_CROP_SUBVOLUME); + st->baseMapper->SetCropping(1); +} + +// C3-2 非阻塞拉取:把最新已就绪单图喂 mapper(若有新结果)。不阻塞主线程—— +// 后台 builder 没新结果就沿用上一帧(拖动跟手的关键)。返回 1=喂了新图,0=无变化。 +std::size_t viewPickLatest(ViewState* st) { + auto imgs = st->source->currentImages(); // 内部 takeLatest(非阻塞) + if (imgs.empty() || imgs[0] == nullptr) return 0; // 无新结果:保留上一帧 + if (imgs[0] == st->currentImg) return 0; // 同一张:无需重喂 + st->currentImg = imgs[0]; + st->lastLevel = st->source->lastLevel(); + st->mapper->SetInputData(st->currentImg); + st->mapper->Update(); + viewSyncBaseCropping(st); // 高清块换位 → 同步更新底图挖空盒,保持无缝无重叠 + return 1; +} + +// 单纹理刷新(C3-2 异步):source->update(cam) 只【提交目标】(非阻塞),随后非阻塞 +// 拉一次最新就绪。拖动中主线程不被重组卡住——新纹理由后台备好、下一帧/定时器换上。 +// 返回当前喂入的块数(1=有就绪单图,0=尚无就绪/视锥外)。 +std::size_t viewRefreshSingle(ViewState* st) { + st->source->update(st->cam); // 提交目标,立即返回 + viewPickLatest(st); + return st->currentImg != nullptr ? 1 : 0; +} + +// 阻塞式刷新:提交目标后【轮询到就绪】再返回(带超时)。仅用于 preview/smoke/默认 +// 取景这类「需要保证拿到一张图」的离屏/初始化场景——交互路径绝不用此(会卡主线程)。 +std::size_t viewRefreshBlocking(ViewState* st, int maxTries = 3000, + int sleepMs = 2) { + st->source->update(st->cam); // 提交目标 + for (int i = 0; i < maxTries; ++i) { + if (viewPickLatest(st)) return 1; + if (st->currentImg != nullptr) return 1; // 已有上一就绪且无新结果 + std::this_thread::sleep_for(std::chrono::milliseconds(sleepMs)); + } + return st->currentImg != nullptr ? 1 : 0; +} + +// interactor 回调:每次交互(旋转/缩放)结束后重选 LOD + 刷新 fps 文本。 +// +// fps 修复(Task 12d-fix3):之前用 frameTimer(上次回调到本次的墙钟)算 fps,把 +// 用户思考/不动的空闲时间也算进去,显示的是「空闲间隔」(如 0.2fps),不可信。改为 +// 松手时连渲 kFpsProbeFrames 帧、累计「实际 Render 耗时」取均值,得到真实渲染帧率。 +void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + // 防重入:本回调内部会 st->rw->Render(),若该 Render 再触发观察者进本回调 + // 将无限递归。已在回调中则直接返回(双保险)。 + if (st->inCb) return; + st->inCb = true; + // EndInteraction 时重选 LOD + 重组单图(仅松手触发一次,避免拖动中卡)。 + const std::size_t blocks = viewRefreshSingle(st); + st->ren->ResetCameraClippingRange(); + const int lvl = st->lastLevel; + + // 真实渲染帧率:连渲若干帧,只累计 Render() 本身耗时(不含空闲)。首帧含切换后 + // 的纹理上传/shader 编译,故跑 kFpsProbeFrames 帧取均值更可信。 + constexpr int kFpsProbeFrames = 3; + Stopwatch swR; + for (int i = 0; i < kFpsProbeFrames; ++i) st->rw->Render(); + const double renderMs = swR.elapsedMs() / kFpsProbeFrames; + const double fps = renderMs > 0 ? 1000.0 / renderMs : 0.0; + + char buf[256]; + std::snprintf(buf, sizeof(buf), + "fps: %.1f | LOD level: %d | blocks: %zu | exagg: %.0fx", + fps, lvl, blocks, st->exagg); + st->fpsText->SetInput(buf); + st->lastLevel = lvl; + st->rw->Render(); // 末帧带上更新后的 fps 文本 + st->inCb = false; +} + +// C3-2 拖动跟手核心:交互进行中(旋转/缩放每次相机变化)只【提交目标】(非阻塞), +// 绝不在主线程重组——主线程立刻继续响应输入,画面用上一张已就绪纹理(跟手)。 +void viewOnInteracting(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + st->source->update(st->cam); // 提交最新视野目标,立即返回(supersede 旧目标) +} + +// C3-2 定时器:周期性非阻塞拉取后台已就绪的新纹理换上 → 拖动中/松手后新 LOD 备好 +// 即自然显示,主线程从不被重组卡住。无新结果则什么也不做(不重渲、不抖)。 +void viewOnTimer(vtkObject* caller, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + if (st->inCb) return; // 与 fps 探针回调互斥,避免重入 + if (viewPickLatest(st)) { + st->ren->ResetCameraClippingRange(); + st->rw->Render(); // 仅在确有新纹理时重渲 + } + (void)caller; +} + +// 默认取景宽度:沿测线取约 256 道(=4 brick 列×64)的一段作首帧局部段。整线横截面 +// 相对长度 1:34,框整卷只会看到一条隐形细带;框这个局部段,层状结构才充满视野 +// (用户可再滚轮拉远看整体——细带是物理真实,拉近看细节)。段越宽 X 越细长、截面 +// 越填不满画面;256 道是 ① cmdTune 出 lod-tuned-local.png(有清晰层状结构)的取景, +// 沿用之以保证首帧同等可读。 +constexpr int kViewDefaultLocalBricks = 4; + +// 建立 view 的「默认取景」:把 level0 一段局部体(沿线中段)整卷单块喂 mapper,再 +// ResetCamera 到该局部段(actor 已 SetScale(1,exagg,exagg)),置相机为能看出层状 +// 结构的角度。真窗口 / --smoke / --preview 三条路径共用此函数 → 渲的是同一画面。 +// 取景角度(Elevation/Azimuth/Zoom)取自 kViewDefaultVariant(var4),与 view-var4 一致。 +// +// 返回喂给 mapper 的块数(=1)。同步更新 st->lastLevel=0(默认即全分辨率局部段)。 +std::size_t viewSetupDefaultFrame(ViewState* st, vtkRenderer* ren) { + geopro::render::ViewAdaptiveVolumeSource& source = *st->source; + const geopro::data::StoreMeta& m = source.meta(); + + // 首帧默认取景:先把相机放到沿线中段一个局部窗口(~kViewDefaultLocalBricks 列) + // 正前方近观 → 触发 C1 选 level0 + 视野子区间,C2 重组该视野单图。先用「局部段 + // 包围盒」ResetCamera 把相机框到该段,再 source->update(cam) 让 C1/C2 选区重组。 + const int brick = m.brick; + const int totBricksX = (m.nx + brick - 1) / brick; + const int localBx = std::min(kViewDefaultLocalBricks, totBricksX); + const int bx0 = std::max(0, totBricksX / 2 - localBx / 2); // 沿线中段 + // 该局部段世界 X 范围(level0,brick 列 [bx0, bx0+localBx))。把首帧相机框到这一段 + // (而非整卷)是 P3 修复 #2 的关键:相机近观局部段 → C1 selectLod 选 level0 局部子区, + // C2 重组该段单图,framing 该段 → 14×796 截面 + 沿线一段充满视野(类厚 B-scan)。 + // 框整卷则 selectLod 选最粗层(整条 45305 细带)、看着空白。 + const double segX0 = m.origin[0] + bx0 * brick * m.spacing[0]; + const double segX1 = + m.origin[0] + std::min(m.nx, (bx0 + localBx) * brick) * m.spacing[0]; + // 段(exagg 后)世界尺寸与中心 + 包围球半径。X 取该段宽(非整卷),Y/Z 全幅(薄轴)。 + const double wx = std::max(1.0, segX1 - segX0); + const double wy = std::max(1.0, m.ny * m.spacing[1] * st->exagg); + const double wz = std::max(1.0, m.nz * m.spacing[2] * st->exagg); + const double cx = 0.5 * (segX0 + segX1); + const double cy = m.origin[1] + 0.5 * wy; + const double cz = m.origin[2] + 0.5 * wz; + const double radius = 0.5 * std::sqrt(wx * wx + wy * wy + wz * wz); + + // 相机从 +Y 看段中心(看进【X-Z 宽面=B-scan 墙】),距离 = 半径/tan(半视角)×余量。 + // 段几何 = X≈12.6m 宽 × Y≈1.5m 薄(跨通道) × Z 深(exagg 后高)。exagg 只夸张深度(Z), + // Y 仍真实极薄 → 若从 +X(沿线)看只见薄前缘、近空。改从 +Y 俯看宽 X-Z 面: GPR 水平 + // 分层沿 X 铺开、随 Z 叠层,这一面才读得出内部结构。整条概览靠用户滚轮拉远(细带几何必然)。 + st->cam = ren->GetActiveCamera(); + const double fovY = st->cam->GetViewAngle(); + const double halfAngle = 0.5 * fovY * 3.14159265358979 / 180.0; + const double tanH = std::max(1e-3, std::tan(halfAngle)); + const double dist = radius / tanH * 1.4; // 1.4:留余量含 aspect/边缘 + st->cam->SetFocalPoint(cx, cy, cz); + st->cam->SetPosition(cx, cy + dist, cz); // +Y 视点 → 正对 X-Z 宽面 + st->cam->SetViewUp(0, 0, 1); // Z 朝上(深度向下) + ren->ResetCameraClippingRange(); + + // 首帧高清段直读(P3 修复 #2 核心):异步 LOD 源在「框一段」的视距下仍会选最粗层 + // (整条 45305 细带 → 看着近黑),不可取。改为【直接从 store 读 level0 沿线中段子体】 + // (与 gallery 的 buildLocalLevel0Image 同一直读路径,非 LOD 算法),喂高清 mapper — + // 保证首帧就是「全分辨率一段」的清晰块体。后续交互仍由异步源接管(用户拉远/拖动按 + // 视距正常选层)。退化(读不到段)再回退异步阻塞刷新。 + std::size_t blocks = 0; + { + vtkSmartPointer locImg = st->seedSegImg; // cmdView 预建,优先复用 + if (locImg == nullptr && !st->dir.empty()) { // 退化:按 dir 直读 + geopro::data::ChunkedVolumeStore store(st->dir); + locImg = buildLocalLevel0Image(store, m, bx0, localBx); + } + if (locImg != nullptr) { + st->currentImg = locImg; + st->lastLevel = 0; + st->mapper->SetInputData(locImg); + st->mapper->Update(); + viewSyncBaseCropping(st); // 底图按该段挖空,无缝叠加 + blocks = 1; + } + } + if (blocks == 0) blocks = viewRefreshBlocking(st); // 退化:回退异步源 + + // 框住【局部段】:无参 ResetCamera 会按场景全部 actor(含常驻整卷底图,45305 长)的 + // 包围盒框 → 整条细带、截面填不满 → 看着空白。改为只框高清段(currentImg=level0 沿 + // 线中段子体)的包围盒,使 14×796 截面+沿线一段充满视野(类厚 B-scan);整条概览靠用户 + // 滚轮拉远(细带是 1:34 几何必然,非 bug)。Z 轴按 actor 的 SetScale(1,1,exagg) 同步夸张。 + if (st->currentImg != nullptr) { + double b[6]; + st->currentImg->GetBounds(b); // 高清段模型坐标盒(含绝对 X origin) + b[4] *= st->exagg; // Z 下界随深度夸张 + b[5] *= st->exagg; // Z 上界随深度夸张 + ren->ResetCamera(b); // 只框该段 → 段充满画面 + } else { + ren->ResetCamera(); // 退化(无高清段):回退全场景框 + } + // 取景角度:默认相机已置 +Y 正对 X-Z 宽面。var4 的 El45/Az30 是为 gallery 的 +X / + // Y 也夸张几何调的,套到这里(+Y 视、Y 真实极薄)会让宽面强烈斜退、大片黑。改用为本 + // +Y B-scan 几何调的小角度(El/Az 各 ~15-18°)留 3D 立体感而宽面仍基本正对,Zoom 拉足 + // 让 X-Z 面填满。仅默认/交互/preview 取景,不动 var4 gallery 参数。 + constexpr double kDefaultFrameElevation = 18.0; // 轻俯,见顶面薄边显层叠 + constexpr double kDefaultFrameAzimuth = 15.0; // 轻偏,宽面仍基本正对 + constexpr double kDefaultFrameZoom = 1.1; // ResetCamera 已贴合该段,只略收边距 + st->cam = ren->GetActiveCamera(); + st->cam->Elevation(kDefaultFrameElevation); + st->cam->Azimuth(kDefaultFrameAzimuth); + st->cam->Zoom(kDefaultFrameZoom); + ren->ResetCameraClippingRange(); + return blocks; +} + +// 渲一组画廊变体并存 PNG,报告 结构像素 / 平均亮度 / fps。返回 0=OK。 +// shotDirOverride 非空 → PNG 存到该目录(P4 让 gallery 出图落在 store 同目录,便于对照)。 +int runGalleryVariant(const std::string& dir, const GalleryVariant& v, + int frames, const std::string& shotDirOverride = "") { + const int winW = 1280, winH = 800; + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + + auto rw = makeOffscreenWindow(winW, winH); + vtkNew ren; + ren->SetBackground(v.bg[0], v.bg[1], v.bg[2]); + rw->AddRenderer(ren); + + // 局部段(沿线中段,同 viewSetupDefaultFrame 的取段法)。先建体 → 实测梯度分布 + // 用于 C4 梯度不透明度标定(仅开了 useGradientOpacity 的变体需要)。 + const int totBx = store.bricksX(0); + const int localBx = std::min(kViewDefaultLocalBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + + // P3 传函分位标定:色阶/不透明度端点按该局部段【实际值 2%/98% 分位】裁离群。 + double vmin = m.vminPhys, vmax = m.vmaxPhys; + { + const ScalarPercentiles pc = + sampleScalarPercentiles(locImg.Get(), m.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + std::cout << "[gallery " << v.name << "] 传函分位标定(样本 " << pc.samples + << "): 2%=" << vmin << " 98%=" << vmax << " (全域 [" + << m.vminPhys << ", " << m.vmaxPhys << "])\n"; + } + } + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + + GradStats gs; + if (v.useGradientOpacity) { + gs = sampleGradientMagnitude(locImg.Get()); + std::cout << "[gallery " << v.name << "] 梯度幅值分布(量化域,样本 " << gs.samples + << "): median=" << gs.median << " p90=" << gs.p90 + << " p99=" << gs.p99 << " max=" << gs.mx << "\n"; + } + + vtkSmartPointer prop = makeVariantProperty( + v, m.quant, cs, vmin, vmax, v.maxOpacity, + v.useGradientOpacity ? &gs : nullptr); + + vtkNew mapper; + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, v.exagg, v.exagg); + ren->AddVolume(volume); + + std::vector> one{locImg}; + auto mb = makeMultiBlock(one); + mapper->SetInputDataObject(mb); + mapper->Update(); + + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(v.elevation); + cam->Azimuth(v.azimuth); + cam->Zoom(v.zoom); + ren->ResetCameraClippingRange(); + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + rw->Render(); + + const fs::path shotDir = + shotDirOverride.empty() + ? fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots" + : fs::path(shotDirOverride); + fs::create_directories(shotDir); + const std::string pngPath = + (shotDir / (std::string("view-") + v.name + ".png")).string(); + savePng(rw.Get(), pngPath); + + // 结构像素:任一通道 >50(排除暗背景),度量「确有体结构」。 + auto countStructPixels = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 50 || px->GetComponent(i, 1) > 50 || + px->GetComponent(i, 2) > 50) { + ++n; + } + } + return n; + }; + const vtkIdType structPx = countStructPixels(); + const double bright = meanBrightness(rw.Get(), winW, winH); + + rw->Render(); // 预热再测 fps + Stopwatch sw; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + rw->Render(); + } + const double ms = sw.elapsedMs(); + const double fps = ms > 0 ? frames * 1000.0 / ms : 0.0; + const bool texErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool ok = !texErr && structPx > 0; + + std::cout << "\n--- gallery " << v.name << " ---\n"; + std::cout << "参数 : " << v.desc << "\n"; + std::cout << "存图 : " << pngPath << "\n"; + std::cout << "结构像素(>50) : " << structPx << " / " << (winW * winH) + << " (" << (100.0 * structPx / (winW * winH)) << "%)\n"; + std::cout << "平均亮度(0-255) : " << bright << "\n"; + std::cout << "真实 fps : " << (ok ? std::to_string(fps) : "INVALID") + << " (" << frames << " 帧旋相机)\n"; + std::cout << "结果 : " << (ok ? "OK" : "FAIL") << "\n"; + + writeMetricLine( + "view-gallery," + std::string(v.name) + ",dir=" + dir + + ",profile=" + (v.profile == OpacityProfile::kSolid ? "solid" : "struct") + + ",floor=" + std::to_string(v.floorOpacity) + + ",mid=" + std::to_string(v.midOpacity) + + ",max=" + std::to_string(v.maxOpacity) + + ",exagg=" + std::to_string(v.exagg) + + ",el=" + std::to_string(v.elevation) + + ",az=" + std::to_string(v.azimuth) + ",zoom=" + std::to_string(v.zoom) + + ",structPx=" + std::to_string(structPx) + + ",bright=" + std::to_string(bright) + + ",fps=" + (ok ? std::to_string(fps) : "INVALID") + + ",png=" + pngPath); + return ok ? 0 : 1; +} + +// view --gallery:依次渲全部 4 组变体。shotDir 空 → 默认 docs/.../poc-lod-shots; +// 否则存到 shotDir(P4:默认传 store 目录,4 张图落在 tmp/line001_proc)。 +int cmdViewGallery(const std::string& dir, int frames, + const std::string& shotDir = "") { + std::cout << "[view --gallery] storeDir=" << dir << " frames=" << frames + << " shotDir=" << (shotDir.empty() ? "(默认)" : shotDir) + << "\n[view --gallery] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[view --gallery] 闸门失败,中止。\n"; + return 1; + } + int rc = 0; + for (const auto& v : kGalleryVariants) { + if (runGalleryVariant(dir, v, frames, shotDir) != 0) rc = 1; + } + const std::string outDesc = + shotDir.empty() ? "docs/superpowers/plans/poc-lod-shots" : shotDir; + std::cout << "\n[view --gallery] 完成,4 张图存于 " << outDesc + << "/view-var{1..4}.png\n"; + return rc; +} + +// buildLineProperty 定义在 view-all 段(本函数之后)→ 前置声明,供 slice 渲染体用。 +vtkSmartPointer buildLineProperty( + const geopro::data::StoreMeta& m, vtkImageData* basis, double sharedVmin, + double sharedVmax); + +// ============================================================================ +// slice:复用桌面端 SliceTool(同一份 geopro_render 代码,非重写)在 GPR 体上切片 +// ============================================================================ +// 加载一条线的体图 → 开真窗口 → 挂 SliceTool(4 种 axis:updown=深度切片C-scan/ +// frontback=纵向剖面radargram/leftright=横向剖面/oblique=任意斜切)。切面可拖动/旋转/滚轮推进。 +int cmdSlice(int argc, char** argv) { + using geopro::render::interact::SliceAxis; + using geopro::render::interact::SliceTool; + const Args a = parseArgs(argc, argv, 2); // 跳过 exe[0] + 命令名[1] + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc slice [--axis updown|frontback|leftright|oblique] " + "[--exagg 8] [--bgSuppress 0.5]\n" + " 渲染半透明三维体 + 切面(在体内切,移动看不同剖面,同桌面端)。\n" + " updown=深度切片C-scan(水平面), frontback=纵向剖面radargram(沿线竖直面),\n" + " leftright=横向剖面(垂直线竖直面), oblique=任意角度斜切。\n" + " bgSuppress 越大体越透(更易看清体内切面)。\n"; + return 2; + } + const std::string storeDir = a.positional[0]; + const std::string axisStr = a.get("axis", "frontback"); + const double exagg = std::stod(a.get("exagg", "8")); + const std::string shot = a.get("shot", ""); // 非空=离屏出图验证(不开真窗口) + + geopro::render::ViewAdaptiveVolumeSource source(storeDir, /*exagg=*/1.0); + source.setAspect(1400.0 / 900.0); + source.setViewportHeight(900); + vtkImageData* base = source.baseImage(); + if (base == nullptr) { + std::cerr << "[slice] 底图为空,无法切片(store 有效?)\n"; + return 1; + } + const geopro::data::StoreMeta& meta = source.meta(); + + // 色阶与体绘制同口径(2/98 分位 + pickColor),保证切片配色与三维体一致。 + const GalleryVariant& v = kViewDefaultVariant; + double vmin = meta.vminPhys, vmax = meta.vmaxPhys; + const ScalarPercentiles pc = + sampleScalarPercentiles(base, meta.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + } + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + + // 把 exagg 烤进 Z spacing(显示用,与桌面端"VE 烤入 origin/spacing"同口径)。 + // ShallowCopy 不动 source 的底图;SliceTool 持非拥有指针,img 在本函数作用域内保活。 + auto img = vtkSmartPointer::New(); + img->ShallowCopy(base); + double sp[3]; + img->GetSpacing(sp); + img->SetSpacing(sp[0], sp[1], sp[2] * exagg); + + SliceAxis axis = SliceAxis::UpDown; + if (axisStr == "frontback") axis = SliceAxis::FrontBack; + else if (axisStr == "leftright") axis = SliceAxis::LeftRight; + else if (axisStr == "oblique") axis = SliceAxis::Oblique; + + vtkNew ren; + ren->SetBackground(0.06, 0.06, 0.09); + vtkNew rw; + rw->AddRenderer(ren); + rw->SetSize(1400, 900); + rw->SetWindowName("gpr_poc slice —— 三维体 + 切面(复用桌面端 SliceTool)"); + if (!shot.empty()) rw->SetOffScreenRendering(1); // 验证模式:离屏 + + // 【关键】渲染三维体本身——桌面端切片是"在渲染出的三维体上切",切面在体内切、移动看不同剖面。 + // 体设半透明(bgSuppress 默认压背景)→ 能透过体看见体内的切面。 + gBgSuppress = std::clamp(std::stod(a.get("bgSuppress", "0.5")), 0.0, 1.0); + vtkSmartPointer volProp = buildLineProperty(meta, base, vmin, vmax); + vtkNew volMapper; + volMapper->SetInputData(img); + vtkNew vol; + vol->SetMapper(volMapper); + vol->SetProperty(volProp); + ren->AddVolume(vol); + + // 体包围盒轮廓(上下文:看切面在体里的位置)。 + vtkNew outline; + outline->SetInputData(img); + vtkNew omap; + omap->SetInputConnection(outline->GetOutputPort()); + vtkNew oact; + oact->SetMapper(omap); + oact->GetProperty()->SetColor(0.4, 0.4, 0.5); + ren->AddActor(oact); + + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + iren->Initialize(); // SliceTool 构造即 On(),须先有活 interactor + + // 复用桌面端 SliceTool(geopro_render 同一份代码)。 + SliceTool slice(img, iren, axis, cs, vmin, vmax); + + int d[3]; + img->GetDimensions(d); + std::cout << "[slice] axis=" << axisStr << " 体维度=" << d[0] << "x" << d[1] + << "x" << d[2] << " 值域=[" << vmin << "," << vmax << "]\n"; + + // 相机正对切面(沿法向看,否则极扁的体侧视成一条线、看不到切片纹理)。 + double bnd[6]; + img->GetBounds(bnd); + const double cx = 0.5 * (bnd[0] + bnd[1]), cy = 0.5 * (bnd[2] + bnd[3]), + cz = 0.5 * (bnd[4] + bnd[5]); + vtkCamera* cam = ren->GetActiveCamera(); + cam->SetFocalPoint(cx, cy, cz); + if (axis == SliceAxis::UpDown) { // X-Y 面,法向 Z → 俯视 + cam->SetPosition(cx, cy, cz + 1.0); + cam->SetViewUp(0, 1, 0); + } else if (axis == SliceAxis::LeftRight) { // Y-Z 面,法向 X → 侧视 + cam->SetPosition(cx + 1.0, cy, cz); + cam->SetViewUp(0, 0, 1); + } else { // FrontBack/Oblique:X-Z 面,法向 Y → 正视(radargram) + cam->SetPosition(cx, cy - 1.0, cz); + cam->SetViewUp(0, 0, 1); + } + ren->ResetCamera(); // 沿该朝向拟合距离框住体 + if (!shot.empty()) { + rw->Render(); + savePng(rw, shot); + std::cout << "[slice] 离屏出图: " << shot << "(axis=" << axisStr << ")\n"; + return 0; + } + std::cout << "[slice] 打开真窗口(半透明三维体 + 切面)。左键旋转 / 滚轮缩放 / " + "拖切面移动看不同剖面 / q 退出。\n"; + rw->Render(); + iren->Start(); + // 干净拆除:先 Off 切面 widget,再 Finalize GL 上下文,避免关窗时 widget 在死上下文上 + // 重建 shader 报错(depth-blit / texture 告警)。 + slice.close(); + rw->Finalize(); + std::cout << "[slice] 窗口关闭,退出。\n"; + return 0; +} + +// ============================================================================ +// C-scan 深度切片(--slice):在 20 条合成体上加"水平深度切面"——每条线贡献它在该深度的 +// 水平幅值图,按各自世界变换摆进场景 → 拼成"整条路某深度的俯视图"(GPR 找管线/空洞的主力视图)。 +// 深度方向(Z)在"绕竖直 Z 旋转 + 平移"的摆放下不变,故一个世界深度面 = 各线同一 z 切片, +// 直接用 vtkImageActor(显示 z=k 切片) + SetUserTransform(线的世界变换) 摆位,无需斜切 reslice。 +// 键盘 Up/Down 改深度 → 扫过不同深度。逐线真实数据、不合并、空隙透明。 +// ============================================================================ +// 全局世界切面:定义【一个】世界轴对齐的薄板(slab),把【每条线】用各自 worldInv 作 ResliceTransform +// reslice 到这【同一个世界面】上采样(覆盖外=透明),再 blend 合成一张 → 真正一整片切面。 +// 整片沿扫描轴移动 = 所有线被同一个面同步扫过(无论朝向)。 +// depth(updown):沿 world Z 扫、面=X-Y;cross(leftright):沿 world Y 扫、面=X-Z(横穿所有线); +// radargram(frontback):沿 world X 扫、面=Y-Z。 +struct GlobalSlice { + // 竖直剖面(横切/顺路):全局面 reslice 各线 → 同一世界面。 + std::vector> reslices; + // 深度(水平):逐线整张水平切面(共面、全覆盖、原生分辨率)。lineActors 非空即此模式。 + std::vector> lineActors; + std::vector> winv; // 各线 world→local(算深度 z 索引) + std::vector> lorg, lspc; + std::vector> ldim; + int sweepAxis = 1; // world 扫描轴 0=X 1=Y 2=Z + int inA = 0, inB = 2; // 面内两个 world 轴 + double sweepWorld = 0, sweepMin = 0, sweepMax = 1; + double footLo[3] = {0, 0, 0}, footHi[3] = {0, 0, 0}; // footprint AABB(取景用) + vtkSmartPointer planeSrc; // 可见的切面矩形(淡),让"面"看得见 + vtkRenderWindow* rw = nullptr; + std::vector volumes; + double volUnit = 20.0; + bool volOn = true; + + void applySlice() { + if (!lineActors.empty()) { // 深度逐线模式:每线设 z=k 那一片(共面、全覆盖) + for (std::size_t i = 0; i < lineActors.size(); ++i) { + double W[3] = {0.5 * (footLo[0] + footHi[0]), 0.5 * (footLo[1] + footHi[1]), + 0.5 * (footLo[2] + footHi[2])}; + W[sweepAxis] = sweepWorld; + double L[3]; + winv[i]->TransformPoint(W, L); + const int k = std::clamp( + static_cast(std::lround((L[2] - lorg[i][2]) / lspc[i][2])), 0, + ldim[i][2] - 1); + lineActors[i]->SetDisplayExtent(0, ldim[i][0] - 1, 0, ldim[i][1] - 1, k, k); + } + } else { + for (auto& rs : reslices) { + double o[3]; + rs->GetOutputOrigin(o); + o[sweepAxis] = sweepWorld; // 整片世界面沿扫描轴移动(所有线同步) + rs->SetOutputOrigin(o); + } + } + if (planeSrc) { // 同步移动可见的切面矩形 + double o[3] = {footLo[0], footLo[1], footLo[2]}; + o[sweepAxis] = sweepWorld; + double p1[3] = {o[0], o[1], o[2]}, p2[3] = {o[0], o[1], o[2]}; + p1[inA] = footHi[inA]; + p2[inB] = footHi[inB]; + planeSrc->SetOrigin(o); + planeSrc->SetPoint1(p1); + planeSrc->SetPoint2(p2); + } + if (rw) rw->Render(); + } + void applyVolume() { + for (auto* v : volumes) { + if (v == nullptr) continue; + v->SetVisibility(volOn ? 1 : 0); + if (v->GetProperty()) + v->GetProperty()->SetScalarOpacityUnitDistance(volUnit); + } + if (rw) rw->Render(); + } +}; + +void cscanOnKey(vtkObject* caller, unsigned long, void* cd, void*) { + auto* st = static_cast(cd); + auto* iren = static_cast(caller); + const std::string key = iren->GetKeySym() ? iren->GetKeySym() : ""; + const double step = (st->sweepMax - st->sweepMin) * 0.02; + if (key == "Up") { + st->sweepWorld = std::min(st->sweepMax, st->sweepWorld + step); + st->applySlice(); + } else if (key == "Down") { + st->sweepWorld = std::max(st->sweepMin, st->sweepWorld - step); + st->applySlice(); + } else if (key == "v" || key == "V") { + st->volOn = !st->volOn; + st->applyVolume(); + } else if (key == "bracketright") { + st->volUnit *= 1.5; + st->applyVolume(); + } else if (key == "bracketleft") { + st->volUnit = std::max(0.5, st->volUnit / 1.5); + st->applyVolume(); + } +} + +// ============================================================================ +// view-all:全部独立体按精确 CGCS2000 坐标/朝向摆进同一 3D 场景一起渲(测区全貌)(P7→P9) +// ============================================================================ +// +// 对应客户端「选多个 ds 一起生成三维」:每条线是独立密实 coarse 体(线局部坐标 X=沿测线、 +// Y=通道横向、Z=深度,origin≈0),本命令把它们按各自 .gps 真实位置/航向摆进同一世界框。 +// +// P9 升级:起点投影从 lonLatToLocalM(简化等距投影)换成 CoordinateTransform::wgs84ToCgcs2000 +// (CGCS2000 高斯-克吕格 3°带,与 P8 测绘级桥接同口径),公共带号取首条线首点经度推断, +// 全线共用同一带号 → 同一参考系;公共世界原点 = 全体 CGCS2000 最小东/北。 +// - 每条线刚体变换:平移到该线 .gps 起点 CGCS2000 局部米 + 绕竖直 Z 轴转该线航向角 +// (起→止主方向,CGCS2000 系),使体局部 X(沿测线)对齐真实航向、Y(横向)垂直、Z 竖直; +// - 深度 Z 用 exagg 夸张(只 Z); +// - 每条体加载为一张整卷 vtkImageData(密实体小,按 --level 选层整体上纹理,不走 LOD), +// 套上该线 vtkTransform,全加进同一 renderer 一起渲。 +// 传函/配色用 P4 默认醒目版(var4:增强灰度 + 实体包络 + 梯度门 + 光照),逐体按 2/98 分位标定。 +// +// --preview 离屏出俯视(top) + 斜视(oblique)两张图展示 20 条并排成测区;否则开真窗口可转可缩。 + +// 一条线在世界中的摆放(刚体变换 + 该线的视野自适应引擎源)。 +// +// P11:每条线不再整卷加载固定层,而是各持一个 ViewAdaptiveVolumeSource(LOD+视锥 +// 裁剪+异步重组引擎,与单条 view 完全同款)。引擎 exagg=1.0(不烘几何),垂向夸张/ +// 航向/平移全由世界变换 T 承担(与单条 view 把 exagg 放 actor SetScale 同理)。每帧 +// 把世界相机逆变换到该线局部帧喂引擎 → selectLod 选层选区(视锥外→引擎不提交)。 +struct PlacedSource { + std::string name; // 明星路_NNN + std::unique_ptr source; + geopro::data::StoreMeta meta; // 量化/几何 + double startX = 0, startY = 0; // 起点局部米(相对公共原点) + double headingDeg = 0; // 航向角(度,相对 +X 东向) + double spreadX = 0, spreadY = 0; // 可选横向铺开偏移 + + vtkSmartPointer world; // T:Scale(1,1,exagg)→RotateZ→Translate + vtkSmartPointer worldInv; // T⁻¹(相机逆变换到局部帧) + vtkSmartPointer prop; // 逐线 2/98 分位标定的传函 + // 单遍合成(方案 A):每条线仍是【独立插值】的体,但 20 条不再各自一个 mapper(=20 遍 + // ray-cast,物理 20× 卡死),而是全部作为【同一个 vtkGPUVolumeRayCastMapper 的不同 + // 端口】注册进一个 vtkMultiVolume → 单遍 ray-cast 一次性合成(重叠也只穿一遍)。 + // ps.volume 只承载该端口的世界变换 T + 逐线传函(不再持自己的 mapper);该端口的 + // vtkImageData 由引擎按相机选的 LOD 决定,经 multiMapper->SetInputDataObject(port,img) 换上。 + vtkSmartPointer volume; // 该线的体(套 T + 逐线 prop) + vtkSmartPointer currentImg; // 持当前单图引用(mapper 仅持裸指针) + + // LOD 中心架构(确诊后):每条线【独立】自己的 mapper(passcost 证明 N 遍开销温和), + // 配视野 LOD 引擎换区 + 停手才重建。弃 multi-volume 单遍(它为单遍关掉了 LOD→渲整卷大贴图→卡)。 + vtkSmartPointer mapper; // 每线独立 GPU mapper(弃 multi-volume 单遍) + vtkGPUVolumeRayCastMapper* multiMapper = nullptr; // 旧 multi-volume 残留(保编译,不再用) + vtkMultiVolume* multiVol = nullptr; // 旧残留 + int port = 0; // 旧残留 + + vtkSmartPointer fullYImg; // §3:全 Y 密度底图(通道 LOD 抽平面的源) + int yStride = 1; // §3:当前通道维 LOD stride(1=全密度) + + double worldBounds[6] = {0, 0, 0, 0, 0, 0}; // 该线(含 T+底图盒)的世界 AABB(取景用) +}; + +// 由 起点+航向+Z 夸张 → 世界刚体变换 T。 +// 合成顺序(VTK PostMultiply:先加先施于点):Scale(1,1,exagg)→RotateZ→Translate, +// 即点先按 exagg 拉伸 Z(局部深度),再绕竖直轴转真实航向,再平移到世界起点。 +vtkSmartPointer makeLineTransform(double startX, double startY, + double headingDeg, + double spreadX, double spreadY, + double exagg) { + auto xf = vtkSmartPointer::New(); + xf->PostMultiply(); + xf->Scale(1.0, 1.0, exagg); + xf->RotateZ(headingDeg); + xf->Translate(startX + spreadX, startY + spreadY, 0.0); + return xf; +} + +// 逐线传函:从该线常驻底图(整卷代表)实测 2/98 分位标定色阶/不透明度端点 + 梯度门, +// 与单条 view 的传函标定同口径(底图非空恒可标定;退化回退全量化域)。 +vtkSmartPointer buildLineProperty( + const geopro::data::StoreMeta& m, vtkImageData* basis, + double sharedVmin = 0.0, double sharedVmax = 0.0) { + const GalleryVariant& v = kViewDefaultVariant; // P4 默认醒目版(var4) + double vmin = m.vminPhys, vmax = m.vmaxPhys; + if (sharedVmin < sharedVmax) { + // #6:传入【统一物理幅值范围】→ 同一幅值在所有线上映射到同一颜色(跨线可比)。 + vmin = sharedVmin; + vmax = sharedVmax; + } else if (basis != nullptr) { + const ScalarPercentiles pc = + sampleScalarPercentiles(basis, m.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + } + } + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + GradStats gs; + if (v.useGradientOpacity && basis != nullptr) gs = sampleGradientMagnitude(basis); + return makeVariantProperty(v, m.quant, cs, vmin, vmax, v.maxOpacity, + (v.useGradientOpacity && gs.samples > 0) ? &gs + : nullptr); +} + +// 根因验证用:把一张体沿 Y(通道维)抽半 → 同范围、同 X/Z/origin,仅 Y 平面数减半、 +// Y spacing 翻倍。这正是"通道 LOD 换密度"的等价改动(同范围、不同 Y 密度),用来确定 +// 就地换端口贴图在 multi-volume 里能否算对。 +vtkSmartPointer downsampleY(vtkImageData* in) { + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int ny2 = std::max(1, (d[1] + 1) / 2); + auto out = vtkSmartPointer::New(); + out->SetDimensions(d[0], ny2, d[2]); + out->SetOrigin(org); + out->SetSpacing(sp[0], sp[1] * 2.0, sp[2]); // Y 间距翻倍(平面数减半) + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(d[0]) * ny2 * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j2 = 0; j2 < ny2; ++j2) { + const int j = std::min(j2 * 2, d[1] - 1); + for (int i = 0; i < d[0]; ++i) { + const vtkIdType idIn = + (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = + (static_cast(k) * ny2 + j2) * d[0] + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// §3 通道维 LOD:沿 Y(通道维)按 stride 抽平面 → 同范围、保包围盒(ny'×spY×stride≈ny×spY), +// stride=1 即原图。stride∈{1,2,4,8} → 显示 全/半/1-4/1-8 通道密度。bbox 不变 → multi-volume +// 就地换不破坏(已 --swapTest 验证)。stride==1 时直接返回原图(零拷贝)。 +vtkSmartPointer subsampleYStride(vtkImageData* in, int stride) { + if (in == nullptr || stride <= 1) return in; + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int ny2 = std::max(1, (d[1] + stride - 1) / stride); + auto out = vtkSmartPointer::New(); + out->SetDimensions(d[0], ny2, d[2]); + out->SetOrigin(org); + out->SetSpacing(sp[0], sp[1] * stride, sp[2]); // Y 间距 ×stride → 跨度不变 + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(d[0]) * ny2 * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j2 = 0; j2 < ny2; ++j2) { + const int j = std::min(j2 * stride, d[1] - 1); + for (int i = 0; i < d[0]; ++i) { + const vtkIdType idIn = + (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = + (static_cast(k) * ny2 + j2) * d[0] + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// 根因验证用:把一张体沿 X 裁掉后半 → 范围/包围盒改变(模拟引擎换"子区域"),用来确认 +// "改包围盒的换图"才是破坏 multi-volume 的真凶。 +vtkSmartPointer cropXhalf(vtkImageData* in) { + int d[3]; + in->GetDimensions(d); + double sp[3], org[3]; + in->GetSpacing(sp); + in->GetOrigin(org); + const int nx2 = std::max(1, d[0] / 2); + auto out = vtkSmartPointer::New(); + out->SetDimensions(nx2, d[1], d[2]); + out->SetOrigin(org); // 保 origin,但 X 范围缩到一半 → 包围盒变 + out->SetSpacing(sp); + vtkNew arr; + arr->SetName("v"); + arr->SetNumberOfTuples(static_cast(nx2) * d[1] * d[2]); + auto* inArr = vtkShortArray::SafeDownCast(in->GetPointData()->GetScalars()); + for (int k = 0; k < d[2]; ++k) + for (int j = 0; j < d[1]; ++j) + for (int i = 0; i < nx2; ++i) { + const vtkIdType idIn = (static_cast(k) * d[1] + j) * d[0] + i; + const vtkIdType idOut = (static_cast(k) * d[1] + j) * nx2 + i; + arr->SetValue(idOut, inArr ? inArr->GetValue(idIn) : 0); + } + out->GetPointData()->SetScalars(arr); + return out; +} + +// #6 色标图例:按【统一物理幅值范围】+ 默认配色建一个屏幕右侧色阶条(含刻度),让用户 +// 知道颜色代表多大的幅值。配色与体属性同源(pickColor),故图例颜色与体内颜色一致。 +vtkSmartPointer buildScalarBar(double vmin, double vmax) { + const GalleryVariant& v = kViewDefaultVariant; + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + auto lut = vtkSmartPointer::New(); + constexpr int N = 64; + for (int t = 0; t < N; ++t) { + const double phys = vmin + (vmax - vmin) * t / (N - 1); + const auto c = cs.colorAt(phys); + lut->AddRGBPoint(phys, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + auto bar = vtkSmartPointer::New(); + bar->SetLookupTable(lut); + bar->SetTitle("Amplitude"); // ASCII(默认字体无 CJK,避免乱码) + bar->SetNumberOfLabels(5); + bar->SetMaximumWidthInPixels(80); + bar->GetTitleTextProperty()->SetColor(1, 1, 1); + bar->GetLabelTextProperty()->SetColor(1, 1, 1); + bar->SetPosition(0.91, 0.28); + bar->SetWidth(0.08); + bar->SetHeight(0.46); + return bar; +} + +// 把世界相机参数逆变换到某线局部帧(T⁻¹):pos/focal 是点(含平移逆),up 是方向 +// (仅旋转逆,TransformVector 不含平移)。再调引擎 updateView 选层选区(视锥外→引擎 +// 内部 selectLod 判 empty → 不提交,保留上一就绪/无图)。 +// 诊断开关:置 1 时跳过引擎选层/换图,全程只渲各线 baseImage(粗整卷)。 +// 用于隔离"首帧连续→引擎换图后断开/消失"——若 baseOnly 始终连续,则根因是引擎换图。 +bool gViewAllBaseOnly = false; + +// 根因验证开关:就地换端口贴图后施加何种"失效/重算"策略,看能否让 multi-volume 算对。 +// 0=不做(基线,已知会断开) 1=multiVol.Modified() 2=重 SetVolume+Modified +// 3=2 + mapper.Modified() 4=3 + 强制 multiVol.GetBounds() 重算 +int gViewAllSwapFix = 0; + +// §3 通道维 LOD:按相机距离选 Y stride(远疏近密),保包围盒就地换 Y 平面子集。--chanLod 0 关。 +bool gChanLod = true; + +void viewAllSubmitOneLine(PlacedSource& ps, vtkCamera* worldCam, + double aspect, int viewportH) { + if (gViewAllBaseOnly || worldCam == nullptr) return; + double wp[3], wf[3], wu[3]; + worldCam->GetPosition(wp); + worldCam->GetFocalPoint(wf); + worldCam->GetViewUp(wu); + + geopro::render::CameraView c{}; + ps.worldInv->TransformPoint(wp, c.pos); + ps.worldInv->TransformPoint(wf, c.focal); + ps.worldInv->TransformVector(wu, c.up); + c.fovYDeg = worldCam->GetViewAngle(); + c.aspect = aspect; + c.viewportH = viewportH; + + // 引擎 exagg=1.0:局部帧几何无夸张(夸张在 T 里),故喂引擎 volumeView 默认即可。 + ps.source->setAspect(aspect); + ps.source->setViewportHeight(viewportH); + ps.source->updateView(c, geopro::render::VolumeView{ + ps.meta.nx, ps.meta.ny, ps.meta.nz, + ps.meta.brick, ps.source->levelCount(), + {ps.meta.origin[0], ps.meta.origin[1], + ps.meta.origin[2]}, + {ps.meta.spacing[0], ps.meta.spacing[1], + ps.meta.spacing[2]}, + 1.0}); +} + +// 非阻塞拉取该线后台已就绪的引擎单图(视野 LOD 选区),换上该线【自己的 mapper】 +// (无新结果→沿用上一帧)。返回 1=换上新图。引擎选 LOD(远→粗 whole,近→细局部小区)。 +// 各线独立 mapper → 换"改包围盒的子区域"安全(无 multi-volume 可破坏)。 +int viewAllPickOneLine(PlacedSource& ps) { + if (gViewAllBaseOnly) return 0; // 诊断:不换图,保持 baseImage + auto imgs = ps.source->currentImages(); // 内部 takeLatest(非阻塞) + if (imgs.empty() || imgs[0] == nullptr) return 0; + if (imgs[0] == ps.currentImg) return 0; + ps.currentImg = imgs[0]; + if (ps.mapper) { + ps.mapper->SetInputData(ps.currentImg); + ps.mapper->Update(); + } + return 1; +} + +// view-all 每帧驱动共享状态(挂 interactor 回调)。 +struct ViewAllState { + std::vector* lines = nullptr; + std::vector>* mappers = nullptr; + vtkRenderer* ren = nullptr; + vtkCamera* cam = nullptr; + vtkRenderWindow* rw = nullptr; + vtkTextActor* fpsText = nullptr; + double aspect = 1400.0 / 900.0; + int viewportH = 900; + double dragImgSample = 4.0; // 交互态屏幕降采样倍率(--maxImgSample) + double sampleDist = 0.3; // 静止态沿光线步长(世界米,--sampleDist;越大越快越糙) + double dragSampleMul = 3.0; // 交互态沿光线步长再放大倍数(--dragSampleMul) + bool lowQ = false; // 当前是否处于低质(交互)态 + int idleTicks = 0; // 连续无交互的定时器 tick 数(>=阈值 → 恢复全质量) + bool inCb = false; +}; + +// 交互态(拖动/滚轮):屏幕降采样(1/dragImgSample) + 沿光线步长加粗(sampleDist×dragSampleMul)。 +// 关键:AutoAdjust 只降屏幕、不降沿光线步长;20 个体每步采 20 次,沿光线步长才是大头, +// 故交互时必须手动把沿光线步长也加粗(否则拖动仍卡)。 +void viewAllSetWheelCoarse(ViewAllState* st) { + if (!st->mappers) return; + for (auto& mm : *st->mappers) { + mm->SetImageSampleDistance(st->dragImgSample); + mm->SetSampleDistance(static_cast(st->sampleDist * st->dragSampleMul)); + } + st->lowQ = true; +} + +// 静止态:全屏幕分辨率 + 沿光线步长回到 sampleDist(清晰)。AutoAdjust 全程关(手动控)。 +void viewAllRestoreAdaptive(ViewAllState* st) { + if (!st->mappers) return; + for (auto& mm : *st->mappers) { + mm->SetImageSampleDistance(1.0); + mm->SetSampleDistance(static_cast(st->sampleDist)); + } + st->lowQ = false; +} + +// §3 通道维 LOD:按相机距离/场景对角线选 Y stride(远→疏省、近→全密度)。 +int viewAllChanStride(vtkRenderer* ren) { + double b[6]; + ren->ComputeVisiblePropBounds(b); + const double diag = std::sqrt((b[1] - b[0]) * (b[1] - b[0]) + + (b[3] - b[2]) * (b[3] - b[2]) + + (b[5] - b[4]) * (b[5] - b[4])); + const double dist = ren->GetActiveCamera()->GetDistance(); + const double r = diag > 1e-9 ? dist / diag : 1.0; + if (r > 1.4) return 8; // 远 → 半通道密度(7 平面/56) + if (r > 0.8) return 4; // 中 → 原始通道(14) + if (r > 0.45) return 2; // 近 → 原始+1(28) + return 1; // 最近 → 全插值(56) +} + +// 按当前相机选 stride,对各线就地换 Y 平面子集(保包围盒 → multi-volume 不破坏)。 +// 返回换图线数(>0 需重渲)。 +int viewAllApplyChanLod(ViewAllState* st) { + if (!gChanLod) return 0; + const int stride = viewAllChanStride(st->ren); + int changed = 0; + for (PlacedSource& ps : *st->lines) { + if (ps.fullYImg == nullptr || ps.yStride == stride) continue; + ps.yStride = stride; + ps.currentImg = subsampleYStride(ps.fullYImg, stride); + if (ps.multiMapper) + ps.multiMapper->SetInputDataObject(ps.port, ps.currentImg); + ++changed; // 保包围盒换图,无需失效重算 + } + return changed; +} + +// 对所有线提交引擎目标(非阻塞,按当前相机各自选 LOD)。 +// 方案 A 起:20 条共享【单个】vtkGPUVolumeRayCastMapper 单遍合成,重叠也只穿一遍 → +// 不再需要视锥裁剪削 20× 开销(且各线 AABB 都覆盖全路、互相重叠,裁剪本就几乎无效)。 +void viewAllSubmitTargets(ViewAllState* st) { + for (PlacedSource& ps : *st->lines) + viewAllSubmitOneLine(ps, st->cam, st->aspect, st->viewportH); +} + +// 拖动进行中(InteractionEvent):质量交给 AutoAdjust 自适应(够快就清晰,不够才降)。 +// 拖动中:进入交互态低质(屏幕 + 沿光线步长都加粗)+ 重置裁剪范围(防旋转裁掉)。绝不重建纹理。 +void viewAllOnInteracting(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + if (!st->lowQ) viewAllSetWheelCoarse(st); // 进入交互态低质(拖动跟手) + st->idleTicks = 0; + if (st->ren) st->ren->ResetCameraClippingRange(); +} + +// 滚轮缩放(MouseWheelForward/BackwardEvent):立刻固定低分辨率(AutoAdjust 单帧来不及 +// 自适应,故手动降)。高优先级先于 style 的 Dolly+Render → 滚轮那一帧即低质快渲。 +void viewAllOnWheel(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + viewAllSetWheelCoarse(st); + st->idleTicks = 0; + if (st->ren) st->ren->ResetCameraClippingRange(); +} + +// 定时器:滚轮停手约 idle 后恢复自适应 + 全质量重渲。 +void viewAllOnTimer(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + if (st->inCb) return; + if (st->lowQ && ++st->idleTicks >= 3) { // ~3×定时器周期无交互 → 恢复 + viewAllRestoreAdaptive(st); + viewAllApplyChanLod(st); // §3:滚轮缩放停手后按新距离重选通道密度 + st->ren->ResetCameraClippingRange(); + st->rw->Render(); + } +} + +// 拖动结束(EndInteractionEvent):恢复自适应(静止 AutoAdjust→全质量)+ 刷新 fps。 +void viewAllOnInteract(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + if (st->inCb) return; + st->inCb = true; + viewAllRestoreAdaptive(st); + viewAllApplyChanLod(st); // §3:拖动结束后按当前距离重选通道密度 + st->idleTicks = 0; + st->ren->ResetCameraClippingRange(); + + Stopwatch swR; + st->rw->Render(); // 一帧全质量;其耗时即静止 fps + const double fps = swR.elapsedMs() > 0 ? 1000.0 / swR.elapsedMs() : 0.0; + char buf[256]; + std::snprintf(buf, sizeof(buf), + "fps(静止): %.1f | lines: %d | 单遍合成(multi-volume)", fps, + static_cast(st->lines->size())); + if (st->fpsText) st->fpsText->SetInput(buf); + st->rw->Render(); + st->inCb = false; +} + +int cmdViewAll(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.size() < 2) { + std::cerr << "用法: gpr_poc view-all [--preview] " + "[--exagg 8] [--level 1] [--spread M] [--shotDir ]\n" + "例: gpr_poc view-all tmp/lines_all D:/Downloads/明星路 " + "--preview --exagg 8\n"; + return 2; + } + const std::string storesDir = a.positional[0]; + const std::string gpsDir = a.positional[1]; + const double exagg = std::stod(a.get("exagg", "8")); + // --level:每条体取金字塔哪一层整渲。默认 1(L1 ~5664×7×398/体,20 体内存/纹理可控)。 + const int level = std::stoi(a.get("level", "1")); + // --spread M:每条线沿垂直自身航向方向额外横向偏移 index*M 米,把「同一条路重复多趟、 + // 真实位置高度重叠」的体在视觉上铺开成可分辨的并排测区(M=0=纯真实位置,默认 0)。 + const double spread = std::stod(a.get("spread", "0")); + const bool preview = a.kv.count("preview") > 0; + const std::string shotDir = a.get("shotDir", storesDir); + // --maxPerPass N:每个 vtkMultiVolume 单遍合成最多挂几条体(受 GPU 每着色器纹理单元 + // 上限约束,每体约吃 4 个单元)。0=自动按 GPU 纹理单元数推断(推荐)。超过的线分成 + // 多个 multi-volume 包,每包一遍 → 总遍数=ceil(N/K)(远少于"每线一遍"的 N 遍)。 + const int maxPerPassArg = std::stoi(a.get("maxPerPass", "0")); + // LOD 中心架构(passcost 确诊后):各线独立 mapper → 引擎换 LOD 子区【安全】(无 multi-volume + // 可破坏)。故视野 LOD【默认开】,单帧渲染量随视野走、与 20 条总量解耦。--baseOnly 仅诊断用 + // (锁定整卷底图、关引擎换图)。 + gViewAllBaseOnly = a.kv.count("baseOnly") != 0; + gViewAllSwapFix = std::stoi(a.get("swapFix", "0")); // 旧 multi-volume 换图诊断(已无意义,保留不碍) + // 引擎金字塔已逐级降 Y,无需单独抽 Y 平面 → 通道维 LOD【默认关】(--chanLod 1 可重开)。 + gChanLod = std::stoi(a.get("chanLod", "0")) != 0; + // --bgSuppress F(0..1):压背景、突出反射层(传函压低近零背景 + 中心透明死区)。 + // 0=原观感(背景可见);0.3~0.6 让总览干净;过大(>0.7)会连带抹掉弱反射(弱异常靠切片抓)。 + gBgSuppress = std::clamp(std::stod(a.get("bgSuppress", "0")), 0.0, 1.0); + // --maxImgSample F:拖动态屏幕采样距离上限(AutoAdjust 拖动时最多降到 1/F 分辨率, + // 越大越快越糊;松手自动恢复全质量)。默认 4。拉近卡顿可调大(如 8)。 + // LOD 已扛住性能(概览/拉近都够快)→ 拖动【默认不降质,保持清晰】。降质纯 opt-in 兜底: + // --maxImgSample F(拖动屏幕降到 1/F,默认 1=不降)、--dragSampleMul M(拖动步长×M,默认 1=不变)。 + // 仅当低端机拖动仍卡时才调大它们。 + const double maxImgSample = std::stod(a.get("maxImgSample", "1")); + // --sampleDist D:静止态沿光线步长(世界米)。通道插值后 Y 很密,自动步长会算得过细→巨卡, + // 故固定一个合理值。越大越快越糙;卡时调大(0.5/1.0),太糊/丢层调小(0.15)。 + const double sampleDist = std::stod(a.get("sampleDist", "0.3")); + const double dragSampleMul = std::stod(a.get("dragSampleMul", "1")); + + std::cout << "[view-all] storesDir=" << storesDir << " gpsDir=" << gpsDir + << " exagg=" << exagg << " level=" << level << " spread=" << spread + << (preview ? " [PREVIEW 离屏俯视+斜视出图]" : " [真窗口可交互]") + << "\n"; + + // 离屏闸门:不可渲机不产假结果(preview/真窗口都需 GL)。 + std::cout << "[view-all] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[view-all] 闸门失败,中止。\n"; + return 1; + } + + // 1) 发现 storesDir 下所有 明星路_NNN 体目录(含 meta.json),按名排序。 + std::vector storeNames; + for (const auto& e : fs::directory_iterator(storesDir)) { + if (!e.is_directory()) continue; + if (!fs::exists(e.path() / "meta.json")) continue; + storeNames.push_back(e.path().filename().string()); + } + std::sort(storeNames.begin(), storeNames.end()); + std::cout << "[view-all] 发现体目录数=" << storeNames.size() << "\n"; + if (storeNames.empty()) { + std::cerr << "[view-all] 错误: storesDir 下未发现任何含 meta.json 的体目录\n"; + return 1; + } + + // 2) 各线 .gps(按目录名末段 _NNN 匹配),先全解析定公共世界原点(最小经纬)。 + struct LineGps { + std::string name; + std::string num; // NNN + std::string gpsPath; + geopro::io::gpr::GpsTrack track; + }; + std::vector gpsList; + // P9:CGCS2000 公共参考。带号由首条可用线首点经度推断(3°带),全线共用同一带号 → + // 同一高斯-克吕格平面参考系;公共原点取全体投影后最小东/北(数值减到较小,避免大坐标精度损失)。 + constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; + int cgcsZone = 0; + bool zoneSet = false; + double minEast = std::numeric_limits::infinity(); + double minNorth = std::numeric_limits::infinity(); + for (const std::string& nm : storeNames) { + const std::size_t us = nm.find_last_of('_'); + if (us == std::string::npos) { + std::cerr << "[view-all] 跳过 " << nm << ":名无 _NNN 后缀,无法配 .gps\n"; + continue; + } + const std::string num = nm.substr(us + 1); + // .gps:匹配 "*_.gps"。 + std::string gpsPath; + for (const auto& e : fs::directory_iterator(gpsDir)) { + if (!e.is_regular_file()) continue; + if (e.path().extension().string() != ".gps") continue; + const std::string stem = e.path().stem().string(); + const std::size_t s2 = stem.find_last_of('_'); + if (s2 != std::string::npos && stem.substr(s2 + 1) == num) { + gpsPath = e.path().string(); + break; + } + } + if (gpsPath.empty()) { + std::cerr << "[view-all] 跳过 " << nm << ":缺 .gps(gpsDir 无 *_" << num + << ".gps)\n"; + continue; + } + geopro::io::gpr::GpsTrack tr = geopro::io::gpr::parseGps(gpsPath); + if (tr.pts.size() < 2) { + std::cerr << "[view-all] 跳过 " << nm << ":.gps 轨迹点 <2(无法定位/航向)\n"; + continue; + } + // 带号:首条可用线首点经度推断(与 P8 Gpr3dvSurveyVolumeBridge 同口径),全线共用。 + if (!zoneSet) { + cgcsZone = static_cast(std::lround(tr.pts.front().lon / 3.0)); + zoneSet = true; + } + for (const auto& p : tr.pts) { + double cx = 0.0, cy = 0.0; // cx=北(northing), cy=东(easting,含带号) + CoordinateTransform::wgs84ToCgcs2000(p.lat * kDeg2Rad, p.lon * kDeg2Rad, + cgcsZone, cx, cy); + minEast = std::min(minEast, cy); + minNorth = std::min(minNorth, cx); + } + gpsList.push_back({nm, num, gpsPath, std::move(tr)}); + } + if (gpsList.empty()) { + std::cerr << "[view-all] 错误: 无任何线同时具备体与可用 .gps\n"; + return 1; + } + std::cout << "[view-all] CGCS2000 带号=" << cgcsZone + << " 公共世界原点(最小东/北)=(" << std::fixed << minEast << ", " + << minNorth << ")" << std::defaultfloat << " (共 " << gpsList.size() + << " 线参与)\n"; + if (spread <= 0.0) { + std::cout << "[view-all] 提示: --spread=0 用纯真实 GPS 位置;本工区为同一条路重复多趟、" + "横向仅约数十米,真实位置下多趟高度重叠会叠成一条带。" + "如需把各趟铺开成可分辨的并排测区,加 --spread 60。\n"; + } + + // 3) 逐线:算起点局部米 + 航向 + 建 ViewAdaptiveVolumeSource 引擎 → PlacedSource。 + // P11:不再整卷固定层加载(撞 GL 16384 纹理墙),改为每线一个视野自适应引擎, + // 引擎恒产 ≤16384 单纹理(LOD+视野选区),exagg/航向/平移由世界变换 T 承担。 + const int winW = 1400, winH = 900; + const double aspect = static_cast(winW) / winH; + std::vector lines; + int lineIdx = 0; + for (const LineGps& lg : gpsList) { + // 轨迹 → CGCS2000 局部米(投影到公共带号后减公共原点)。XY 约定 x=东、y=北。 + std::vector trackM; + trackM.reserve(lg.track.pts.size()); + for (const auto& p : lg.track.pts) { + double cx = 0.0, cy = 0.0; // cx=北, cy=东 + CoordinateTransform::wgs84ToCgcs2000(p.lat * kDeg2Rad, p.lon * kDeg2Rad, + cgcsZone, cx, cy); + trackM.push_back(geopro::io::gpr::XY{cy - minEast, cx - minNorth}); + } + + const geopro::io::gpr::XY& start = trackM.front(); + const geopro::io::gpr::XY& end = trackM.back(); + const double hx = end.x - start.x, hy = end.y - start.y; + const double headingDeg = std::atan2(hy, hx) * 180.0 / 3.14159265358979323846; + const double hlen = std::hypot(hx, hy); + double spreadX = 0, spreadY = 0; + if (spread > 0.0 && hlen > 0.0) { + const double off = lineIdx * spread; + spreadX = (-hy / hlen) * off; + spreadY = (hx / hlen) * off; + } + ++lineIdx; + + const std::string storePath = (fs::path(storesDir) / lg.name).string(); + + PlacedSource ps; + ps.name = lg.name; + // 引擎 exagg=1.0:垂向夸张放进世界变换 T(与单条 view 把 exagg 放 actor 同理)。 + ps.source = std::make_unique( + storePath, /*exagg=*/1.0); + ps.source->setAspect(aspect); + ps.source->setViewportHeight(winH); + ps.meta = ps.source->meta(); + ps.startX = start.x; + ps.startY = start.y; + ps.headingDeg = headingDeg; + ps.spreadX = spreadX; + ps.spreadY = spreadY; + + // 世界变换 T + 逆变换(相机逆变换到局部帧)。 + ps.world = makeLineTransform(start.x, start.y, headingDeg, spreadX, spreadY, + exagg); + ps.worldInv = vtkSmartPointer::New(); + ps.worldInv->DeepCopy(ps.world); + ps.worldInv->Inverse(); + + // 逐线传函(从常驻底图标定)。P12:每线只建【一个】体(套世界变换 T), + // 起步喂粗底图(小且不空),引擎备好更合适 LOD 单图后整图换上。 + ps.prop = buildLineProperty(ps.meta, ps.source->baseImage()); + + // LOD 中心:每条线建【自己的】GPU mapper,起步喂 baseImage,引擎选区后换上。 + // 手动控质(与旧 multi-volume 同口径):AutoAdjust 关、静止步长 sampleDist。 + ps.mapper = vtkSmartPointer::New(); + ps.mapper->SetAutoAdjustSampleDistances(0); + ps.mapper->SetImageSampleDistance(1.0); + ps.mapper->SetSampleDistance(static_cast(sampleDist)); + ps.volume = vtkSmartPointer::New(); + if (ps.source->baseImage() != nullptr) { + ps.currentImg = ps.source->baseImage(); // 起步即有图(不空白) + ps.fullYImg = ps.source->baseImage(); + ps.mapper->SetInputData(ps.currentImg); + ps.mapper->Update(); + } + ps.volume->SetMapper(ps.mapper); + ps.volume->SetProperty(ps.prop); + ps.volume->SetUserTransform(ps.world); + + // 该线世界 AABB(底图模型盒经 T 变换的 8 角包络)→ 视锥裁剪用。 + if (ps.source->baseImage() != nullptr) { + double mb[6]; + ps.source->baseImage()->GetBounds(mb); + double lo[3] = {std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity()}; + double hi[3] = {-std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity()}; + for (int cx = 0; cx < 2; ++cx) + for (int cy = 0; cy < 2; ++cy) + for (int cz = 0; cz < 2; ++cz) { + double in[3] = {mb[cx], mb[2 + cy], mb[4 + cz]}, out[3]; + ps.world->TransformPoint(in, out); + for (int d = 0; d < 3; ++d) { + lo[d] = std::min(lo[d], out[d]); + hi[d] = std::max(hi[d], out[d]); + } + } + ps.worldBounds[0] = lo[0]; ps.worldBounds[1] = hi[0]; + ps.worldBounds[2] = lo[1]; ps.worldBounds[3] = hi[1]; + ps.worldBounds[4] = lo[2]; ps.worldBounds[5] = hi[2]; + } + + int bd[3] = {0, 0, 0}; + if (ps.source->baseImage()) ps.source->baseImage()->GetDimensions(bd); + std::cout << "[view-all] " << lg.name << " 引擎底图 level=" + << ps.source->baseLevel() << " 底图维度=" << bd[0] << "x" << bd[1] + << "x" << bd[2] << " 起点局部米=(" << start.x << ", " << start.y + << ") 航向=" << headingDeg << "°\n"; + lines.push_back(std::move(ps)); + } + std::cout << "[view-all] 加载并定位线数=" << lines.size() + << "(每线一个 ViewAdaptiveVolumeSource 引擎)\n"; + + // 诊断(--overlapStat):实测一条俯视光线真实穿几个体——把各线世界 AABB 投到 X-Y 平面, + // 在公共 footprint 上撒细网格,统计每格被几条线的 AABB 覆盖(=该处俯视光线穿的体数)。 + // 纯几何、不渲染。验证"20× 重叠"是真是假(§7.1)。 + if (a.kv.count("overlapStat") > 0) { + double mnx = 1e30, mny = 1e30, mxx = -1e30, mxy = -1e30; + for (const auto& ps : lines) { + mnx = std::min(mnx, ps.worldBounds[0]); mxx = std::max(mxx, ps.worldBounds[1]); + mny = std::min(mny, ps.worldBounds[2]); mxy = std::max(mxy, ps.worldBounds[3]); + } + const int G = 400; // 网格分辨率 + const double sx = (mxx - mnx) / G, sy = (mxy - mny) / G; + long covered = 0, sumCov = 0, maxCov = 0; + std::vector hist(static_cast(lines.size()) + 1, 0); + for (int j = 0; j < G; ++j) + for (int i = 0; i < G; ++i) { + const double cx = mnx + (i + 0.5) * sx, cy = mny + (j + 0.5) * sy; + int cov = 0; + for (const auto& ps : lines) + if (cx >= ps.worldBounds[0] && cx <= ps.worldBounds[1] && + cy >= ps.worldBounds[2] && cy <= ps.worldBounds[3]) + ++cov; + if (cov > 0) { ++covered; sumCov += cov; maxCov = std::max(maxCov, cov); } + ++hist[cov]; + } + std::cout << "\n=== overlapStat(俯视光线穿体数,纯几何)===\n"; + std::cout << "footprint X=[" << mnx << "," << mxx << "] Y=[" << mny << "," << mxy + << "] (米)\n"; + std::cout << "有体覆盖格的【平均重叠层数】= " + << (covered ? double(sumCov) / covered : 0.0) + << " 最大重叠 = " << maxCov << " / 共 " << lines.size() << " 条\n"; + std::cout << "重叠层数分布(覆盖格中占比):\n"; + for (std::size_t k = 1; k < hist.size(); ++k) + if (hist[k] > 0) + std::cout << " 穿 " << k << " 个体: " << (covered ? 100.0 * hist[k] / covered : 0.0) + << "%\n"; + std::cout << "结论:平均/最大若 ~2–3 而非 ~20,则'20×重叠'是误判,瓶颈在别处(密 Y 细采样)。\n"; + return 0; + } + + // #6 统一传函:汇总各线底图 2/98 分位 → 一套【公共物理幅值范围】,重建各线属性使 + // 同一幅值跨线映射同色(可比)。各线仍用自己的 quant(量化差异由各自 quant 吸收)。 + double sumLo = 0.0, sumHi = 0.0; + int nRange = 0; + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + const ScalarPercentiles pc = + sampleScalarPercentiles(b, ps.meta.quant, 0.02, 0.98); + if (pc.samples > 0) { + sumLo += pc.lo; + sumHi += pc.hi; + ++nRange; + } + } + double shVmin = 0.0, shVmax = 0.0; + if (nRange > 0 && sumLo / nRange < sumHi / nRange) { + shVmin = sumLo / nRange; + shVmax = sumHi / nRange; + for (PlacedSource& ps : lines) { + ps.prop = buildLineProperty(ps.meta, ps.source->baseImage(), shVmin, shVmax); + ps.volume->SetProperty(ps.prop); + } + std::cout << "[view-all] #6 统一幅值范围 [" << shVmin << ", " << shVmax + << "](跨线一致 + 色标图例)\n"; + } + + // 4) 单个共享 vtkGPUVolumeRayCastMapper + vtkMultiVolume:全部线作为端口单遍合成。 + auto rw = preview ? makeOffscreenWindow(winW, winH) + : vtkSmartPointer::New(); + if (!preview) rw->SetSize(winW, winH); + vtkNew ren; + ren->SetBackground(kViewDefaultVariant.bg[0], kViewDefaultVariant.bg[1], + kViewDefaultVariant.bg[2]); + rw->AddRenderer(ren); + + // #6 色标图例(右侧色阶条 + 幅值刻度)。仅在拿到公共幅值范围时加。 + if (shVmin < shVmax) ren->AddViewProp(buildScalarBar(shVmin, shVmax)); + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // 方案 A 核心:把多条【各自独立插值】的体作为同一个 vtkGPUVolumeRayCastMapper 的不同 + // 端口注册进一个 vtkMultiVolume → 单遍 ray-cast 一次性合成(含重叠),消除"N 体=N 遍 + // ray-cast"的物理 N× 开销;每条线仍各保留自己的世界变换 T 与逐线传函(满足"分开插值、 + // 合并渲染")。 + // + // 硬约束:单个 multi-volume 同时挂的体数受 GPU 每着色器纹理单元上限制约(每体约吃 4 个 + // 单元:体标量 + 颜色 TF + 不透明 TF + 梯度)。超限会报"Hardware does not support the + // number of textures"并悄悄丢体。故按 K 条/包分组:每包一个 multi-volume 单遍合成, + // 总遍数=ceil(N/K)(远少于每线一遍的 N 遍)。实测本机 K=7(32 单元 GPU)。 + // + // 先空渲一帧建好 GL 上下文,再查 GPU 纹理单元数推断【起始】K(每包体数)。 + rw->Render(); + int K = maxPerPassArg; + const bool autoK = (maxPerPassArg <= 0); + if (autoK) { + int units = 0; + if (auto* oglrw = vtkOpenGLRenderWindow::SafeDownCast(rw)) { + if (auto* tum = oglrw->GetTextureUnitManager()) + units = tum->GetNumberOfTextureUnits(); + } + K = units > 8 ? (units - 4) / 4 : 1; // 起始【估计】:留 4 单元、每体 ~4 单元 + if (K < 1) K = 1; + std::cout << "[view-all] GPU 纹理单元=" << units << " → 起始每包体数 K=" << K + << "(仅估计;下面按真实硬件告警自动退避。--maxPerPass 可锁定)\n"; + } else { + std::cout << "[view-all] 每包体数 K=" << K << "(--maxPerPass 锁定,不自动退避)\n"; + } + + // LOD 中心装配:各线独立 GPU mapper(已在上面建好、喂了 baseImage),直接把每条线的 volume + // 加进同一 renderer。无 multi-volume → 无纹理单元上限、无分包、无退避(passcost 证明 N 遍开销 + // 温和:20 条独立 mapper 铺开 78fps)。每帧实际渲染量由视野 LOD(引擎选区)压住、与 20 条总量解耦。 + (void)K; + (void)autoK; + std::vector> mappers; + mappers.reserve(lines.size()); + for (PlacedSource& ps : lines) { + if (ps.mapper) mappers.push_back(ps.mapper); + ren->AddVolume(ps.volume); + } + ren->ResetCamera(); + rw->Render(); + std::cout << "[view-all] 已加入场景线数=" << lines.size() + << "(各线独立 GPU mapper + 视野 LOD,弃 multi-volume 单遍;单帧渲染量随视野走)\n"; + + // --slice:全局世界切面——定义一个世界轴对齐薄板,每条线用 worldInv 作 ResliceTransform + // reslice 到这【同一个面】上采样(覆盖外透明),blend 合成一张 → 真正一整片切面,整体扫过去。 + GlobalSlice gs; + const bool sliceOn = a.kv.count("slice") > 0; + vtkSmartPointer sliceActor; + if (sliceOn) { + gs.rw = rw.Get(); + const std::string sax = a.get("slice", ""); + int inPlane[2]; + if (sax == "frontback") { // 顺路 radargram:沿 world X 扫,面=Y-Z + gs.sweepAxis = 0; inPlane[0] = 1; inPlane[1] = 2; + } else if (sax == "leftright") { // 横切断面:沿 world Y(沿路) 扫,面=X-Z(横穿所有线) + gs.sweepAxis = 1; inPlane[0] = 0; inPlane[1] = 2; + } else { // updown 深度 C-scan:沿 world Z 扫,面=X-Y + gs.sweepAxis = 2; inPlane[0] = 0; inPlane[1] = 1; + } + gs.inA = inPlane[0]; + gs.inB = inPlane[1]; + // footprint:全线世界 AABB 并集。 + double lo[3] = {1e30, 1e30, 1e30}, hi[3] = {-1e30, -1e30, -1e30}; + for (const PlacedSource& ps : lines) + for (int d = 0; d < 3; ++d) { + lo[d] = std::min(lo[d], ps.worldBounds[d * 2]); + hi[d] = std::max(hi[d], ps.worldBounds[d * 2 + 1]); + } + gs.sweepMin = lo[gs.sweepAxis]; + gs.sweepMax = hi[gs.sweepAxis]; + const double atFrac = std::clamp(std::stod(a.get("sliceAt", "0.5")), 0.0, 1.0); + gs.sweepWorld = lo[gs.sweepAxis] + atFrac * (hi[gs.sweepAxis] - lo[gs.sweepAxis]); + for (int d = 0; d < 3; ++d) { gs.footLo[d] = lo[d]; gs.footHi[d] = hi[d]; } + const double SENT = -32768.0; // 覆盖外 sentinel(int16 数据 ±7417 之外) + const geopro::core::ColorScale sliceCs = + pickColor(kViewDefaultVariant.color, shVmin, shVmax); + vtkSmartPointer lut = makeLut(sliceCs, shVmin, shVmax); + + if (gs.sweepAxis == 2) { + // ── 深度 C-scan:逐线整张水平切面(深度共面→拼成完整 C-scan,全覆盖、原生分辨率)── + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + int dd[3]; + double oo[3], ss[3]; + b->GetDimensions(dd); + b->GetOrigin(oo); + b->GetSpacing(ss); + auto col = vtkSmartPointer::New(); + col->SetLookupTable(lut); + col->SetInputData(b); + col->SetOutputFormatToRGBA(); + col->Update(); + auto act = vtkSmartPointer::New(); + act->GetMapper()->SetInputConnection(col->GetOutputPort()); + act->SetUserTransform(ps.world); // 整张水平片摆进世界(共面) + ren->AddActor(act); + gs.lineActors.push_back(act); + gs.winv.push_back(ps.worldInv); + gs.lorg.push_back({oo[0], oo[1], oo[2]}); + gs.lspc.push_back({ss[0], ss[1], ss[2]}); + gs.ldim.push_back({dd[0], dd[1], dd[2]}); + if (ps.volume) gs.volumes.push_back(ps.volume.Get()); + } + } else { + // ── 横切/顺路(竖直面):全局面 reslice 各线到同一世界面 + blend(共面、可见整片)── + // 输出薄板:面内铺满 footprint(目标 0.1m、每轴上限 2000),扫描轴 1 片@sweepWorld。 + const double targetSp = 0.1; + double osp[3], oorg[3]; + int oext[6]; + for (int d = 0; d < 3; ++d) { + if (d == gs.sweepAxis) { + osp[d] = 1.0; oorg[d] = gs.sweepWorld; oext[d * 2] = 0; oext[d * 2 + 1] = 0; + } else { + const double span = std::max(1e-3, hi[d] - lo[d]); + const int Nd = std::clamp(static_cast(span / targetSp), 64, 2000); + osp[d] = span / Nd; + oorg[d] = lo[d]; + oext[d * 2] = 0; oext[d * 2 + 1] = Nd - 1; + } + } + auto blend = vtkSmartPointer::New(); + blend->SetBlendModeToNormal(); + for (PlacedSource& ps : lines) { + vtkImageData* b = ps.source->baseImage(); + if (b == nullptr) continue; + auto rs = vtkSmartPointer::New(); + rs->SetInputData(b); + rs->SetResliceTransform(ps.worldInv); + rs->SetInterpolationModeToNearestNeighbor(); + rs->SetBackgroundLevel(SENT); + rs->SetOutputDimensionality(3); + rs->SetOutputSpacing(osp); + rs->SetOutputOrigin(oorg); + rs->SetOutputExtent(oext); + auto rgb = vtkSmartPointer::New(); + rgb->SetLookupTable(lut); + rgb->SetInputConnection(rs->GetOutputPort()); + rgb->SetOutputFormatToRGB(); + auto th = vtkSmartPointer::New(); + th->SetInputConnection(rs->GetOutputPort()); + th->ThresholdBetween(SENT - 0.5, SENT + 0.5); + th->SetInValue(0); + th->SetOutValue(255); + th->SetOutputScalarTypeToUnsignedChar(); + auto app = vtkSmartPointer::New(); + app->AddInputConnection(rgb->GetOutputPort()); + app->AddInputConnection(th->GetOutputPort()); + blend->AddInputConnection(app->GetOutputPort()); + gs.reslices.push_back(rs); + if (ps.volume) gs.volumes.push_back(ps.volume.Get()); + } + sliceActor = vtkSmartPointer::New(); + sliceActor->GetMapper()->SetInputConnection(blend->GetOutputPort()); + blend->Update(); + sliceActor->SetDisplayExtent(oext[0], oext[1], oext[2], oext[3], oext[4], oext[5]); + ren->AddActor(sliceActor); + } + gs.applySlice(); + gs.applyVolume(); + const char* axisName = gs.sweepAxis == 2 + ? "深度 C-scan(逐线整片,全覆盖原生分辨率,沿深度扫)" + : gs.sweepAxis == 1 + ? "横切断面(全局面,沿路扫,整片横穿所有线)" + : "顺路 radargram(全局面,沿横向扫)"; + const std::size_t nslice = + gs.lineActors.empty() ? gs.reslices.size() : gs.lineActors.size(); + std::cout << "[view-all] --slice:切片已加(" << nslice << " 线," << axisName + << ")。↑↓整片扫过 / [ ]调体透明度 / v 体显隐。\n"; + } + + ViewAllState st; + st.lines = &lines; + st.mappers = &mappers; + st.ren = ren.Get(); + st.rw = rw.Get(); + st.aspect = aspect; + st.viewportH = winH; + st.dragImgSample = maxImgSample; + st.sampleDist = sampleDist; + st.dragSampleMul = dragSampleMul; + + // 首帧:ResetCamera 框全测区 → 概览(各线选粗 LOD 底图)。提交引擎目标 + 阻塞拉首图。 + ren->ResetCamera(); + st.cam = ren->GetActiveCamera(); + viewAllSubmitTargets(&st); + // 概览阻塞拉一次(保证首帧高清就绪,离屏/真窗口都从有图起步)。 + for (PlacedSource& ps : lines) { + for (int tries = 0; tries < 200; ++tries) { + if (viewAllPickOneLine(ps)) break; + if (ps.currentImg != nullptr) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + } + viewAllApplyChanLod(&st); // §3:首帧按概览距离选通道密度(远→疏) + rw->Render(); + if (capWin->textureError()) { + std::cerr << "[view-all] 警告: 仍检测到 3D 纹理维度错误(不应发生,引擎契约 " + "≤16384)。\n"; + } + + // 根因验证(--swapTest):确定性复现"通道 LOD 换密度"。before=全 Y 渲一帧存图; + // 然后把每条线端口就地换成 Y 抽半的图(同范围、不同 Y 密度)+ 施加 --swapFix 策略; + // after 再渲一帧存图。对比 before/after:after 若仍连续/在位 → 就地换贴图能算对。 + if (a.kv.count("swapTest") > 0) { + const std::string sd = a.get("shotDir", "tmp/swaptest"); + fs::create_directories(sd); + ren->ResetCamera(); + ren->GetActiveCamera()->Elevation(30); + ren->GetActiveCamera()->Azimuth(25); + ren->ResetCameraClippingRange(); + rw->Render(); + savePng(rw.Get(), (fs::path(sd) / "before-fullY.png").string()); + for (PlacedSource& ps : lines) { + if (ps.currentImg == nullptr) continue; + int dIn[3]; + ps.currentImg->GetDimensions(dIn); + // --swapMode x = 改包围盒(X 裁半,模拟引擎子区域);否则 y = 保包围盒(改 Y 密度,通道 LOD)。 + auto dY = (a.get("swapMode", "y") == "x") ? cropXhalf(ps.currentImg) + : downsampleY(ps.currentImg); + int dOut[3]; + dY->GetDimensions(dOut); + ps.currentImg = dY; + ps.multiMapper->SetInputDataObject(ps.port, dY); + if (gViewAllSwapFix >= 2) ps.multiVol->SetVolume(ps.volume, ps.port); + ps.multiVol->Modified(); + if (gViewAllSwapFix >= 3) ps.multiMapper->Modified(); + if (gViewAllSwapFix >= 4) ps.multiVol->GetBounds(); + std::cout << "[swapTest] " << ps.name << " Y " << dIn[1] << "→" << dOut[1] + << "\n"; + } + ren->ResetCameraClippingRange(); + rw->Render(); + savePng(rw.Get(), (fs::path(sd) / "after-halfY.png").string()); + std::cout << "[swapTest] swapFix=" << gViewAllSwapFix + << " 出图: " << (fs::path(sd) / "before-fullY.png").string() + << " / after-halfY.png(after 连续在位=能算对)\n"; + return 0; + } + + if (preview) { + fs::create_directories(shotDir); + + // (A) 概览俯视(top):相机沿 -Z 俯看 XY 平面(看 20 条在测区平面铺开)。 + { + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + double fp[3]; + cam->GetFocalPoint(fp); + const double* b = ren->ComputeVisiblePropBounds(); + const double span = std::max({b[1] - b[0], b[3] - b[2], b[5] - b[4]}); + cam->SetPosition(fp[0], fp[1], fp[2] + span * 2.0); + cam->SetFocalPoint(fp[0], fp[1], fp[2]); + cam->SetViewUp(0, 1, 0); + ren->ResetCameraClippingRange(); + st.cam = cam; + viewAllSubmitTargets(&st); + for (PlacedSource& ps : lines) + for (int t = 0; t < 100 && ps.currentImg == nullptr; ++t) { + if (viewAllPickOneLine(ps)) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + viewAllApplyChanLod(&st); + if (!lines.empty()) { + int d[3] = {0, 0, 0}; + if (lines[0].currentImg) lines[0].currentImg->GetDimensions(d); + std::cout << "[chanLod] 俯视(远) stride=" << lines[0].yStride + << " 线0 Y=" << d[1] << "\n"; + } + rw->Render(); + savePng(rw.Get(), (fs::path(shotDir) / "view-all-top.png").string()); + std::cout << "[view-all] 俯视图存: " + << (fs::path(shotDir) / "view-all-top.png").string() << "\n"; + } + + // (B) 概览斜视(oblique):var4 取景 + 概览 fps(全部可见、各选粗 LOD)。 + int ovVisible = 0, ovCulled = 0; + double fpsOverview = 0.0; + { + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(35.0); + cam->Azimuth(30.0); + ren->ResetCameraClippingRange(); + st.cam = cam; + viewAllSubmitTargets(&st); + for (PlacedSource& ps : lines) { + for (int t = 0; t < 100 && ps.currentImg == nullptr; ++t) { + if (viewAllPickOneLine(ps)) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ++ovVisible; + } + rw->Render(); + savePng(rw.Get(), + (fs::path(shotDir) / "view-all-oblique.png").string()); + std::cout << "[view-all] 斜视图存: " + << (fs::path(shotDir) / "view-all-oblique.png").string() << "\n"; + + rw->SetDesiredUpdateRate(15.0); // 拖动态:降采样 + rw->Render(); + Stopwatch sw; + const int frames = 60; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + rw->Render(); + } + fpsOverview = sw.elapsedMs() > 0 ? frames * 1000.0 / sw.elapsedMs() : 0.0; + } + + // (C) 拉近一段:把焦点对准测区中段一条线的世界中心、近距正对该段 → 大部分线出 + // 视锥被裁掉,当前线只渲可见段的合适 LOD。报拉近 fps(与概览对比)。 + // fps 探针用小幅 azimuth 摆动(±6°,模拟拉近态轻微转动观察),而非整周 orbit + // ——整周 orbit 会把全测区转回视野使裁剪失效,不代表真实"拉近看一段"的交互。 + int nearVisible = 0, nearCulled = 0; + double fpsNear = 0.0; + { + // 选中段一条线(取参与线的中位)作拉近目标,焦点置其世界 AABB 中心。 + const PlacedSource& tgt = lines[lines.size() / 2]; + const double cx = 0.5 * (tgt.worldBounds[0] + tgt.worldBounds[1]); + const double cy = 0.5 * (tgt.worldBounds[2] + tgt.worldBounds[3]); + const double cz = 0.5 * (tgt.worldBounds[4] + tgt.worldBounds[5]); + // 该段(含 exagg 后)世界跨度 → 近距视距(贴该段,使其充满视野、邻线出视锥)。 + const double segLen = std::max({tgt.worldBounds[1] - tgt.worldBounds[0], + tgt.worldBounds[3] - tgt.worldBounds[2], + tgt.worldBounds[5] - tgt.worldBounds[4]}); + vtkCamera* cam = ren->GetActiveCamera(); + const double fovY = cam->GetViewAngle(); + const double tanH = + std::max(1e-3, std::tan(0.5 * fovY * 3.14159265358979 / 180.0)); + // 近距:只贴该段横截面尺度(取较短轴 ~该段 Y/Z 跨度的 1/4),使邻行线出视锥。 + const double shortSpan = std::max( + 1.0, std::min(tgt.worldBounds[3] - tgt.worldBounds[2], + tgt.worldBounds[5] - tgt.worldBounds[4])); + const double dist = (0.25 * shortSpan) / tanH * 1.4; + cam->SetFocalPoint(cx, cy, cz); + cam->SetPosition(cx, cy + dist, cz + 0.3 * dist); // 斜上方近观该段 + cam->SetViewUp(0, 0, 1); + ren->ResetCameraClippingRange(); + st.cam = cam; + (void)segLen; + viewAllSubmitTargets(&st); + for (PlacedSource& ps : lines) { + for (int t = 0; t < 120 && ps.currentImg == nullptr; ++t) { + if (viewAllPickOneLine(ps)) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ++nearVisible; + } + viewAllApplyChanLod(&st); + if (!lines.empty()) { + int d[3] = {0, 0, 0}; + if (lines[0].currentImg) lines[0].currentImg->GetDimensions(d); + std::cout << "[chanLod] 拉近(近) stride=" << lines[0].yStride + << " 线0 Y=" << d[1] << "\n"; + } + rw->Render(); + savePng(rw.Get(), (fs::path(shotDir) / "view-all-near.png").string()); + std::cout << "[view-all] 拉近图存: " + << (fs::path(shotDir) / "view-all-near.png").string() << "\n"; + + // --slice 验证图:相机沿扫描轴正对切面(看整片切面 face-on)。 + if (sliceOn) { + const int sa = gs.sweepAxis; + const int a = (sa == 0) ? 1 : 0; // 面内轴 1 + const int b = (sa == 2) ? 1 : 2; // 面内轴 2 + double ctr[3]; + for (int d = 0; d < 3; ++d) ctr[d] = 0.5 * (gs.footLo[d] + gs.footHi[d]); + ctr[sa] = gs.sweepWorld; + const double extA = std::max(1.0, gs.footHi[a] - gs.footLo[a]); + const double extB = std::max(1.0, gs.footHi[b] - gs.footLo[b]); + const double dist = 0.6 * std::max(extA, extB) / tanH * 1.4; + double pos[3] = {ctr[0], ctr[1], ctr[2]}; + pos[sa] += dist; // 沿扫描轴退后,正对切面 + cam->SetFocalPoint(ctr[0], ctr[1], ctr[2]); + cam->SetPosition(pos[0], pos[1], pos[2]); + // 俯视(深度,沿Z看)用 Y 朝上;竖直剖面用 Z 朝上(避免 up 与视向平行退化)。 + if (sa == 2) cam->SetViewUp(0, 1, 0); + else cam->SetViewUp(0, 0, 1); + ren->ResetCameraClippingRange(); + rw->SetDesiredUpdateRate(0.5); + gs.volOn = false; // 验证:隐去体,只看切面本身 + gs.applyVolume(); + rw->Render(); + savePng(rw.Get(), (fs::path(shotDir) / "view-all-slice.png").string()); + std::cout << "[view-all] 切面正视图存: " + << (fs::path(shotDir) / "view-all-slice.png").string() << "\n"; + } + + rw->SetDesiredUpdateRate(15.0); // 拖动态:降采样 + rw->Render(); + Stopwatch sw; + const int frames = 60; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(f % 2 == 0 ? 0.4 : -0.4); // 小幅摆动(不转回全测区) + rw->Render(); // 单遍合成,无需逐帧重提交(拖动只渲已有纹理) + } + fpsNear = sw.elapsedMs() > 0 ? frames * 1000.0 / sw.elapsedMs() : 0.0; + rw->SetDesiredUpdateRate(0.5); + } + + const vtkIdType nb = countNonBlackPixels(rw.Get(), winW, winH); + const bool texErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + + std::cout << "\n=== view-all --preview 测区全貌(多体共场景,引擎 LOD+视锥裁剪)===\n"; + std::cout << "参与线数 : " << lines.size() << "\n"; + std::cout << "exagg(Z) : " << exagg << "\n"; + std::cout << "spread(横向铺开) : " << spread << " m (0=纯真实位置)\n"; + std::cout << "纹理维度错误 : " << (texErr ? "是(!!)" : "否(引擎契约 ≤16384)") + << "\n"; + std::cout << "概览可见/裁剪线 : " << ovVisible << " / " << ovCulled << "\n"; + std::cout << "概览 fps(60帧旋) : " << fpsOverview << "\n"; + std::cout << "拉近可见/裁剪线 : " << nearVisible << " / " << nearCulled + << " (视锥裁剪生效:裁掉屏外线)\n"; + std::cout << "拉近 fps(60帧旋) : " << fpsNear << "\n"; + const double speedup = fpsOverview > 0 ? fpsNear / fpsOverview : 0.0; + std::cout << "拉近/概览 fps 比 : " << speedup << "x\n"; + std::cout << "末帧非黑像素 : " << nb << " / " << (winW * winH) << "\n"; + std::cout << "俯视图 : " + << (fs::path(shotDir) / "view-all-top.png").string() << "\n"; + std::cout << "斜视图 : " + << (fs::path(shotDir) / "view-all-oblique.png").string() << "\n"; + std::cout << "拉近图 : " + << (fs::path(shotDir) / "view-all-near.png").string() << "\n"; + + writeMetricLine( + "view-all,lines=" + std::to_string(lines.size()) + + ",exagg=" + std::to_string(exagg) + ",spread=" + std::to_string(spread) + + ",ovVisible=" + std::to_string(ovVisible) + + ",ovCulled=" + std::to_string(ovCulled) + + ",nearVisible=" + std::to_string(nearVisible) + + ",nearCulled=" + std::to_string(nearCulled) + + ",fpsOverview=" + std::to_string(fpsOverview) + + ",fpsNear=" + std::to_string(fpsNear) + + ",nonBlack=" + std::to_string(nb) + + ",texErr=" + std::to_string(texErr ? 1 : 0)); + return (nb > 0 && !texErr) ? 0 : 1; + } + + // 真窗口:可旋转/缩放(每线引擎 LOD + 视锥裁剪 + 拖动降采样)。 + vtkOutputWindow::SetInstance(nullptr); + rw->SetWindowName("gpr_poc view-all —— 多体单遍合成(multi-volume)/逐线LOD/拖动降采样"); + + // 屏幕左上角 fps + 可见/裁剪线数文本。 + vtkNew fpsText; + fpsText->SetInput("fps: -- | visible lines: -- | culled: --"); + fpsText->GetTextProperty()->SetFontSize(20); + fpsText->GetTextProperty()->SetColor(1.0, 1.0, 0.4); + fpsText->SetDisplayPosition(12, winH - 30); + ren->AddViewProp(fpsText); + st.fpsText = fpsText.Get(); + + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + iren->SetDesiredUpdateRate(15.0); // 拖动态:mapper 降采样 + iren->SetStillUpdateRate(0.5); // 静止态:全质量 + + vtkNew cbInteract; + cbInteract->SetCallback(viewAllOnInteracting); + cbInteract->SetClientData(&st); + iren->AddObserver(vtkCommand::InteractionEvent, cbInteract); + + vtkNew cbEnd; + cbEnd->SetCallback(viewAllOnInteract); + cbEnd->SetClientData(&st); + iren->AddObserver(vtkCommand::EndInteractionEvent, cbEnd); + + vtkNew cbTimer; + cbTimer->SetCallback(viewAllOnTimer); + cbTimer->SetClientData(&st); + iren->AddObserver(vtkCommand::TimerEvent, cbTimer); + + // 滚轮缩放:高优先级(1.0)先于 style 的 Dolly+Render → 该帧即按低质渲染。 + vtkNew cbWheelF; + cbWheelF->SetCallback(viewAllOnWheel); + cbWheelF->SetClientData(&st); + iren->AddObserver(vtkCommand::MouseWheelForwardEvent, cbWheelF, 1.0); + vtkNew cbWheelB; + cbWheelB->SetCallback(viewAllOnWheel); + cbWheelB->SetClientData(&st); + iren->AddObserver(vtkCommand::MouseWheelBackwardEvent, cbWheelB, 1.0); + + // --slice:键盘 Up/Down 改 C-scan 深度(高优先级,先于 style 处理)。 + vtkNew cbKey; + if (sliceOn) { + cbKey->SetCallback(cscanOnKey); + cbKey->SetClientData(&gs); + iren->AddObserver(vtkCommand::KeyPressEvent, cbKey, 1.0); + } + + std::cout << "[view-all] 打开真窗口。左键旋转 / 滚轮缩放 / q 退出" + << (sliceOn ? " / ↑↓改切片深度 / [ ]调体透明度 / v 体显隐" : "") + << "。\n"; + iren->Initialize(); + iren->CreateRepeatingTimer(33); // ~30Hz 非阻塞拉取后台就绪纹理 + rw->Render(); + iren->Start(); + std::cout << "[view-all] 窗口关闭,退出。\n"; + return 0; +} + +// ============================================================================ +// view-survey-all:测绘级世界对齐体直接按 CGCS2000 origin 摆放渲染(Task P8) +// ============================================================================ +// +// 与 view-all(线局部体 + .gps 起点+航向刚体近似摆放)不同:本命令的体已是测绘级世界对齐 +// 体(build-survey-all 产出,meta.origin=CGCS2000 世界米,轴 X=东/Y=北/Z=深)。故【无需 .gps、 +// 无需航向旋转】——直接按各体 meta.origin 平移摆进同一世界框即精确就位(跟路的弯)。 +// 取公共参考原点(全体 origin 最小东/北)平移到近零局部框,保 VTK 数值稳定且相对位置严格不变。 + +// 由测绘级世界对齐体 + 公共参考原点 + Z 夸张 → vtkVolume(平移到世界位,无旋转)。 +vtkSmartPointer makeSurveyPlacedVolume( + vtkImageData* img, const geopro::data::StoreMeta& m, double refX, + double refY, double exagg, double& vminOut, double& vmaxOut) { + const GalleryVariant& v = kViewDefaultVariant; + + double vmin = m.vminPhys, vmax = m.vmaxPhys; + const ScalarPercentiles pc = + sampleScalarPercentiles(img, m.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + } + vminOut = vmin; + vmaxOut = vmax; + const geopro::core::ColorScale cs = pickColor(v.color, vmin, vmax); + + GradStats gs; + if (v.useGradientOpacity) gs = sampleGradientMagnitude(img); + vtkSmartPointer prop = makeVariantProperty( + v, m.quant, cs, vmin, vmax, v.maxOpacity, + v.useGradientOpacity ? &gs : nullptr); + + // img 的 origin 是 meta.origin(CGCS 世界米,东向 ~4e7 量级)。直接把 image origin 减去公共 + // 参考原点落到近零局部框——避免 VTK 内部 float32 在 ~4e7 绝对坐标上丢精度(±1m);相对位置严格不变。 + double iorg[3]; + img->GetOrigin(iorg); + img->SetOrigin(iorg[0] - refX, iorg[1] - refY, iorg[2]); + + vtkNew mapper; + mapper->SetInputData(img); + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + mapper->SetAutoAdjustSampleDistances(0); + mapper->SetInteractiveAdjustSampleDistances(0); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + + // 体已落近零世界框;仅 Z 夸张(局部深度)。 + auto xf = vtkSmartPointer::New(); + xf->PostMultiply(); + xf->Scale(1.0, 1.0, exagg); + volume->SetUserTransform(xf); + return volume; +} + +int cmdViewSurveyAll(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc view-survey-all [--preview] " + "[--exagg 8] [--level 1] [--shotDir ]\n" + "例: gpr_poc view-survey-all tmp/survey_all --preview " + "--exagg 8\n"; + return 2; + } + const std::string storesDir = a.positional[0]; + const double exagg = std::stod(a.get("exagg", "8")); + const int level = std::stoi(a.get("level", "1")); + const bool preview = a.kv.count("preview") > 0; + const std::string shotDir = a.get("shotDir", storesDir); + + std::cout << "[view-survey-all] storesDir=" << storesDir << " exagg=" << exagg + << " level=" << level + << (preview ? " [PREVIEW 离屏俯视+斜视出图]" : " [真窗口可交互]") + << "\n"; + + std::cout << "[view-survey-all] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[view-survey-all] 闸门失败,中止。\n"; + return 1; + } + + // 1) 发现体目录(含 meta.json),按名排序。 + std::vector storeNames; + for (const auto& e : fs::directory_iterator(storesDir)) { + if (!e.is_directory()) continue; + if (!fs::exists(e.path() / "meta.json")) continue; + storeNames.push_back(e.path().filename().string()); + } + std::sort(storeNames.begin(), storeNames.end()); + std::cout << "[view-survey-all] 发现体目录数=" << storeNames.size() << "\n"; + if (storeNames.empty()) { + std::cerr << "[view-survey-all] 错误: 未发现任何含 meta.json 的体目录\n"; + return 1; + } + + // 2) 逐体读 meta,先定公共参考原点(全体 origin 最小东/北)。 + struct SurveyPlaced { + std::string name; + vtkSmartPointer img; + geopro::data::StoreMeta meta; + }; + std::vector placed; + double refX = std::numeric_limits::infinity(); + double refY = std::numeric_limits::infinity(); + for (const std::string& nm : storeNames) { + const std::string storePath = (fs::path(storesDir) / nm).string(); + geopro::data::ChunkedVolumeStore store(storePath); + const geopro::data::StoreMeta m = store.meta(); + refX = std::min(refX, m.origin[0]); + refY = std::min(refY, m.origin[1]); + const int lv = std::max(0, std::min(level, store.levels() - 1)); + vtkSmartPointer img = buildLevelImage(store, lv, m); + std::cout << "[view-survey-all] " << nm << " level=" << lv << " 维度=" + << img->GetDimensions()[0] << "x" << img->GetDimensions()[1] << "x" + << img->GetDimensions()[2] << " CGCS origin=(" << std::fixed + << m.origin[0] << ", " << m.origin[1] << ")\n"; + placed.push_back({nm, img, m}); + } + if (placed.empty()) { + std::cerr << "[view-survey-all] 错误: 无可用体\n"; + return 1; + } + std::cout << "[view-survey-all] 公共参考原点(最小东/北)=(" << std::fixed << refX + << ", " << refY << ") (共 " << placed.size() << " 体)\n"; + + // 3) 同一 renderer 加全部世界对齐体(按 origin-ref 摆放,无旋转)。 + const int winW = 1400, winH = 900; + auto rw = preview ? makeOffscreenWindow(winW, winH) + : vtkSmartPointer::New(); + if (!preview) rw->SetSize(winW, winH); + vtkNew ren; + ren->SetBackground(kViewDefaultVariant.bg[0], kViewDefaultVariant.bg[1], + kViewDefaultVariant.bg[2]); + rw->AddRenderer(ren); + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + int added = 0; + for (const SurveyPlaced& sp : placed) { + double vmin = 0, vmax = 0; + vtkSmartPointer vol = makeSurveyPlacedVolume( + sp.img.Get(), sp.meta, refX, refY, exagg, vmin, vmax); + ren->AddVolume(vol); + ++added; + } + std::cout << "[view-survey-all] 已加入场景体数=" << added << "\n"; + + ren->ResetCamera(); + rw->Render(); + if (capWin->textureError()) { + std::cerr << "[view-survey-all] 警告: 检测到 3D 纹理维度错误(某体超 GL 上限)," + "可增大 --level 取更粗层。\n"; + } + + if (preview) { + fs::create_directories(shotDir); + { // 俯视(top):看 20 条按真实 CGCS 位置铺成测区(含路的弯)。 + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + double fp[3]; + cam->GetFocalPoint(fp); + const double* b = ren->ComputeVisiblePropBounds(); + const double span = std::max({b[1] - b[0], b[3] - b[2], b[5] - b[4]}); + cam->SetPosition(fp[0], fp[1], fp[2] + span * 2.0); + cam->SetFocalPoint(fp[0], fp[1], fp[2]); + cam->SetViewUp(0, 1, 0); + ren->ResetCameraClippingRange(); + rw->Render(); + const std::string p = + (fs::path(shotDir) / "view-survey-all-top.png").string(); + savePng(rw.Get(), p); + std::cout << "[view-survey-all] 俯视图存: " << p << "\n"; + } + { // 斜视(oblique)。 + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(35.0); + cam->Azimuth(30.0); + ren->ResetCameraClippingRange(); + rw->Render(); + const std::string p = + (fs::path(shotDir) / "view-survey-all-oblique.png").string(); + savePng(rw.Get(), p); + std::cout << "[view-survey-all] 斜视图存: " << p << "\n"; + } + + rw->Render(); + Stopwatch sw; + const int frames = 60; + for (int f = 0; f < frames; ++f) rw->Render(); + const double ms = sw.elapsedMs(); + const double fps = ms > 0 ? frames * 1000.0 / ms : 0.0; + const vtkIdType nb = countNonBlackPixels(rw.Get(), winW, winH); + + std::cout << "\n=== view-survey-all --preview 测区全貌(测绘级世界对齐体)===\n"; + std::cout << "参与体数 : " << placed.size() << "\n"; + std::cout << "level : " << level << "\n"; + std::cout << "exagg(Z) : " << exagg << "\n"; + std::cout << "公共参考原点 : (" << std::fixed << refX << ", " << refY + << ") CGCS2000 米\n"; + std::cout << "非黑像素 : " << nb << " / " << (winW * winH) << "\n"; + std::cout << "fps(" << frames << "帧连渲) : " << fps << "\n"; + std::cout << "俯视图 : " + << (fs::path(shotDir) / "view-survey-all-top.png").string() + << "\n"; + std::cout << "斜视图 : " + << (fs::path(shotDir) / "view-survey-all-oblique.png").string() + << "\n"; + + writeMetricLine("view-survey-all,bodies=" + std::to_string(placed.size()) + + ",level=" + std::to_string(level) + + ",exagg=" + std::to_string(exagg) + + ",nonBlack=" + std::to_string(nb) + + ",fps=" + std::to_string(fps)); + return nb > 0 ? 0 : 1; + } + + rw->SetWindowName("gpr_poc view-survey-all —— 测绘级 CGCS2000 世界对齐体"); + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(35.0); + cam->Azimuth(30.0); + ren->ResetCameraClippingRange(); + std::cout << "[view-survey-all] 打开真窗口。左键旋转 / 滚轮缩放 / q 退出。\n"; + iren->Initialize(); + rw->Render(); + iren->Start(); + std::cout << "[view-survey-all] 窗口关闭,退出。\n"; + return 0; +} + +int cmdView(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc view [--exagg 8] [--opacity 0.5] " + "[--budget 64] [--smoke] [--preview] [--base] [--variant N] " + "[--gallery] [--frames 90]\n"; + return 2; + } + const std::string dir = a.positional[0]; + // 交互/preview/smoke 默认视觉参数 = kViewDefaultVariant(var4):配色/不透明度包络/ + // exagg/背景全部走 var4,故默认画面 == view-var4(DRY,与画廊同源)。命令行 --exagg / + // --opacity 若用户显式传则覆盖 var4 对应值,否则用 var4 的 exagg / maxOpacity。 + const GalleryVariant& dv = kViewDefaultVariant; + const double exagg = + a.kv.count("exagg") ? std::stod(a.get("exagg", "8")) : dv.exagg; + const double opacity = a.kv.count("opacity") + ? std::stod(a.get("opacity", "0.5")) + : dv.maxOpacity; + const std::size_t budget = + static_cast(std::stoul(a.get("budget", "64"))); + const int frames = std::stoi(a.get("frames", "90")); + auto hasFlag = [&](const char* name) { + return a.kv.count(name) > 0 || + std::find(a.positional.begin(), a.positional.end(), + std::string("--") + name) != a.positional.end(); + }; + const bool smoke = hasFlag("smoke"); + const bool preview = hasFlag("preview"); + // C3-8 验收用:--preview --shots 额外从【真 view 场景(base+hires+cropping)】多旋转角 + // 离屏出图,用于人工核对「路细长不胖 / 拼接无缝无白 / 旋转无移动白斑」。只加图,不改 + // 默认行为(无 --shots 时与原 preview 完全一致)。 + const bool shots = hasFlag("shots"); + // C3-6 底图预览:--preview --base(或 --base)模拟「交互态:只渲常驻粗底图」—— + // 隐去高清叠加层、只渲整卷最粗 ≤16384 层单纹理 → 整卷概览、盖全、绝不空白。 + const bool basePreview = hasFlag("base"); + // 拉近预览(Task 12d-singletex):--preview --variant near(或 --near)走与真窗口 + // 完全相同的单纹理拉近路径(viewRefreshSingle 选 level0 局部子区域),供控制方 Read + // 验证「拉近后」单图非空、完整、fps 高。 + const bool nearPreview = + hasFlag("near") || (a.kv.count("variant") && a.get("variant", "") == "near"); + + // 画廊出图目录:--out 显式指定;否则默认存到 store 目录(P4:gallery 落在 + // tmp/line001_proc,便于控制方就地对照)。 + const std::string galleryShotDir = a.get("out", dir); + + // 画廊模式(Task 12d):渲 4 组视觉调参图供挑选。优先于其余路径。 + if (hasFlag("gallery")) { + return cmdViewGallery(dir, frames, galleryShotDir); + } + // 单变体:view --preview --variant N(N=1..4),只渲第 N 组(near 不走此路)。 + if (preview && a.kv.count("variant") && !nearPreview) { + const int vi = std::stoi(a.get("variant", "1")); + const int n = static_cast(sizeof(kGalleryVariants) / + sizeof(kGalleryVariants[0])); + if (vi < 1 || vi > n) { + std::cerr << "[view] --variant 需在 1.." << n << " 之间\n"; + return 2; + } + std::cout << "[view] storeDir=" << dir << " 单变体 variant=" << vi << "\n"; + if (cmdOffscreenSmoke() != 0) return 1; + return runGalleryVariant(dir, kGalleryVariants[vi - 1], frames, + galleryShotDir); + } + + std::cout << "[view] storeDir=" << dir << " exagg=" << exagg + << " opacity=" << opacity << " budget=" << budget + << (preview ? " [PREVIEW 离屏存图+测fps]" + : (smoke ? " [SMOKE 离屏]" : " [真窗口交互]")) + << "\n"; + + // preview/smoke 走离屏。 + const bool offscreen = smoke || preview; + const int winW = 1280, winH = 800; + + // 单纹理统一路径(Task 12d-singletex):交互 view 只用 ChunkedVolumeStore 产 + // 单图 + 单 SmartVolumeMapper,不再用 OutOfCoreSource/BrickPager/MultiBlock 分块。 + (void)budget; // 交互 view 不再用 budget 分块(保留参数以兼容旧命令行)。 + // 渲染源 = ViewAdaptiveVolumeSource(C2)。exagg 走 actor 的 SetScale(不烘进 + // 几何,避免与 SetScale 重复夸张),故源构造 exagg=1.0。视口高/宽高比注入给 + // C1 选层(分辨率密度 + 视锥裁剪)。 + geopro::render::ViewAdaptiveVolumeSource source(dir, /*exagg=*/1.0); + source.setViewportHeight(winH); + source.setAspect(static_cast(winW) / winH); + const auto& m = source.meta(); + // P3 首帧高清段(沿线中段 level0):直读 store 建该段单图——既作传函分位标定的基准 + // (该段实际值,对比比整卷底图更punchy),又供 viewSetupDefaultFrame 直接喂高清 mapper + // (绕开异步 LOD 在「框一段」视距下仍选最粗层的问题)。同 gallery 的 buildLocalLevel0Image + // 直读路径,非 LOD 算法。读不到则回退底图/全域。 + vtkSmartPointer segImg; + { + geopro::data::ChunkedVolumeStore store(dir); + const int totBx = store.bricksX(0); + const int localBx = std::min(kViewDefaultLocalBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + segImg = buildLocalLevel0Image(store, m, bx0, localBx); + } + // P3 传函分位标定:色阶/不透明度端点按【实际值 2%/98% 分位】(物理域),裁离群。 + // 处理后体值多集中 ±窄带、少量离群 ±9000,按 meta 全域(±9249)映射 → 窄带近透明 → 全黑。 + // 基准用常驻底图(整卷代表):band 较窄 → 窄带信号映到更饱和色、可见度更高;且代表整线 + // 典型信号、不受单段局部高能离群影响。底图缺失再回退首帧段。退化(无样本)回退全域。 + double vmin = m.vminPhys, vmax = m.vmaxPhys; + vtkImageData* pcBasis = + source.baseImage() != nullptr ? source.baseImage() : segImg.Get(); + if (pcBasis != nullptr) { + const ScalarPercentiles pc = + sampleScalarPercentiles(pcBasis, m.quant, 0.02, 0.98); + if (pc.samples > 0) { + vmin = pc.lo; + vmax = pc.hi; + std::cout << "[view] 传函分位标定(" + << (source.baseImage() != nullptr ? "底图" : "局部段") + << ",样本 " << pc.samples << "): 2%=" << vmin << " 98%=" << vmax + << " (全域 [" << m.vminPhys << ", " << m.vmaxPhys + << "] → 裁离群)\n"; + } + } + // 配色/不透明度包络取自 var4:seismic + V 形实体包络(floor/mid + opacity 作峰值)。 + const geopro::core::ColorScale cs = pickColor(dv.color, vmin, vmax); + // C4:默认变体(var4)开了梯度不透明度 → 从常驻底图实测梯度分布标定阈值。底图恒非空。 + GradStats dvGs; + if (dv.useGradientOpacity && source.baseImage() != nullptr) { + dvGs = sampleGradientMagnitude(source.baseImage()); + std::cout << "[view] 梯度幅值分布(底图,量化域,样本 " << dvGs.samples + << "): median=" << dvGs.median << " p90=" << dvGs.p90 + << " p99=" << dvGs.p99 << "\n"; + } + vtkSmartPointer prop = makeVariantProperty( + dv, m.quant, cs, vmin, vmax, opacity, + dv.useGradientOpacity ? &dvGs : nullptr); + + // 渲染窗口:preview/smoke 走离屏,否则真窗口。 + vtkSmartPointer rw; + if (offscreen) { + rw = makeOffscreenWindow(winW, winH); + } else { + rw = vtkSmartPointer::New(); + rw->SetSize(winW, winH); + rw->SetWindowName("gpr_poc view —— 核外 LOD 体绘制 (滚轮缩放切 LOD, 左键旋转)"); + } + + vtkNew ren; + ren->SetBackground(dv.bg[0], dv.bg[1], dv.bg[2]); // var4 略亮冷灰背景 + rw->AddRenderer(ren); + + // C3-6 常驻粗底图层(底图永不空白的命脉):第一个 vtkVolume = 整卷最粗 + // 「各轴 ≤16384」层单纹理(source.baseImage(),构造时主线程一次建成、永远持有)。 + // 它【永远在场、永远渲染、绝不释放、绝不被异步路径触碰】→ 任何相机/任何运动中都 + // 盖住整个体 → 拖动/缩放绝不空白。高清层(下方 mapper)叠在其上、就绪后局部覆盖。 + vtkNew baseMapper; + baseMapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + baseMapper->SetAutoAdjustSampleDistances(1); + baseMapper->SetInteractiveAdjustSampleDistances(1); + auto baseVolume = vtkSmartPointer::New(); + if (source.baseImage() != nullptr) { // 退化 store 防护(理论恒非空) + baseMapper->SetInputData(source.baseImage()); // 常驻输入,永不改 + baseMapper->Update(); + baseVolume->SetMapper(baseMapper); + baseVolume->SetProperty(prop); // 与高清层共用传函(同配色/不透明度) + baseVolume->SetScale(1.0, 1.0, exagg); // 垂向夸张只放大深度(Z);横向路宽(Y)不动 → 与高清层空间对齐 + ren->AddVolume(baseVolume); // 先加底图 → 底层常渲 + } + + // 高清叠加层:单 vtkSmartVolumeMapper(GPU 光线投射,整张 3D 纹理),与 --preview / + // gallery 同一 mapper 类型。叠在底图之上:currentImages 就绪后摆到对应世界位置局部 + // 覆盖底图;没就绪则无输入(只显底图,不空)。运动中高清滞后由底图兜底,绝不空白。 + vtkNew mapper; + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + // C3-5:交互式采样距离自适应(修长板填屏 ray-march 慢的关键)。POC 当初为离屏 + // 基准把这两个关掉(0/0)以求恒定全质量,但交互场景必须【开启】:拖动时按渲染窗口的 + // DesiredUpdateRate 拉大 SampleDistance(降采样→快)、停下恢复小步长(全质量→可慢)。 + // 长板填屏慢的根因是每像素沿超长轴海量采样,LOD/异步都不缩短 ray-march 长度,只有 + // 拖动期降采样才把交互态降到可跟手帧率。 + mapper->SetAutoAdjustSampleDistances(1); + mapper->SetInteractiveAdjustSampleDistances(1); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, 1.0, exagg); // 垂向夸张只放大深度(Z);横向路宽(Y)保持真实比例(修 GPR 路被压胖) + ren->AddVolume(volume); // 后加高清 → 叠在底图上 + + // 屏幕左上角实时 fps 文本。 + vtkNew fpsText; + fpsText->SetInput("fps: -- | LOD level: --"); + fpsText->GetTextProperty()->SetFontSize(20); + fpsText->GetTextProperty()->SetColor(1.0, 1.0, 0.4); + fpsText->SetDisplayPosition(12, winH - 30); + ren->AddViewProp(fpsText); + + // 捕获式 OutputWindow(拦截块上传纹理错)。 + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + ViewState st; + st.source = &source; + st.mapper = mapper.Get(); + st.baseMapper = baseMapper.Get(); // 供 viewSyncBaseCropping 按高清块挖空底图 + st.ren = ren.Get(); + st.fpsText = fpsText.Get(); + st.rw = rw.Get(); + st.exagg = exagg; + st.dir = dir; // 供首帧直读 level0 局部段(绕开 LOD 选粗层) + st.seedSegImg = segImg; // cmdView 已为分位标定建好的首帧高清段,直接复用 + + // 相机初始定向(修复 1):默认框「局部段」而非整卷。整线横截面 1:34,框整卷 + // 即便 exagg=8 也是一条隐形细带(看着空白);改为对准沿线中段一个 ~768 道窗口 + // 的全分辨率局部体 → 开窗第一帧就看到一段有层状结构的体。三路径共用此取景。 + std::size_t warm = viewSetupDefaultFrame(&st, ren); + rw->Render(); + + // C3-6 底图预览(模拟「交互态:只渲常驻粗底图」):隐去高清叠加层、把相机框到整卷 + // 包围盒(exagg 后),只渲整卷最粗 ≤16384 层单纹理 → 整卷概览、盖全、非空。证明 + // 拖动/缩放时即使高清全部缺位,底图也独立盖住整个体(永不空白的命脉)。 + if (preview && basePreview) { + volume->SetVisibility(0); // 隐去高清层 → 只剩常驻底图 + baseMapper->SetCropping(0); // 高清隐了 → 底图取消挖空,整卷全渲(证明永不空白) + // 框整卷(无参 ResetCamera 按场景中可见 actor 包围盒;高清隐了 → 框底图全卷)。 + ren->ResetCamera(); + st.cam = ren->GetActiveCamera(); + st.cam->Elevation(kViewDefaultVariant.elevation); + st.cam->Azimuth(kViewDefaultVariant.azimuth); + ren->ResetCameraClippingRange(); + rw->Render(); + + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + const std::string pngPath = (shotDir / "view-base.png").string(); + savePng(rw.Get(), pngPath); + + auto countStructPx = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 50 || px->GetComponent(i, 1) > 50 || + px->GetComponent(i, 2) > 50) { + ++n; + } + } + return n; + }; + const vtkIdType baseStruct = countStructPx(); + int bd[3] = {0, 0, 0}; + if (source.baseImage()) source.baseImage()->GetDimensions(bd); + const bool texErrB = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool okB = !texErrB && source.baseImage() != nullptr && baseStruct > 0; + + std::cout << "\n=== view --preview --base 常驻粗底图验证(整卷概览)===\n"; + std::cout << "底图 level : " << source.baseLevel() + << "(整卷最粗 ≤16384 层)\n"; + std::cout << "底图维度 : " << bd[0] << " x " << bd[1] << " x " << bd[2] + << "\n"; + std::cout << "存图 : " << pngPath << "\n"; + std::cout << "结构像素(>50) : " << baseStruct << " / " << (winW * winH) + << " (" << (100.0 * baseStruct / (winW * winH)) << "%)\n"; + std::cout << "纹理维度错误 : " << (texErrB ? "是(!!)" : "否") << "\n"; + std::cout << "base 结果 : " + << (okB ? "OK ✔ 底图盖全非空" : "FAIL ✘") << "\n"; + + writeMetricLine( + "view-base,dir=" + dir + ",baseLevel=" + + std::to_string(source.baseLevel()) + ",bx=" + std::to_string(bd[0]) + + ",by=" + std::to_string(bd[1]) + ",bz=" + std::to_string(bd[2]) + + ",structPixels=" + std::to_string(baseStruct) + + ",ok=" + std::to_string(okB ? 1 : 0) + ",png=" + pngPath); + return okB ? 0 : 1; + } + + // 拉近预览:在默认取景基础上拉近相机,再走阻塞刷新(与真窗口缩放后完全相同的 + // 单纹理选区路径,level0 局部子区域),轮询到就绪验证「拉近后」单图非空、完整。 + if (nearPreview) { + st.cam->Dolly(2.5); // 拉近 + ren->ResetCameraClippingRange(); + warm = viewRefreshBlocking(&st); + rw->Render(); + } + + std::cout << "[view] 预热(" << (nearPreview ? "拉近局部段" : "默认局部段") + << "): level=" << st.lastLevel << " 渲染块=" << warm << "\n"; + + const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), winW, winH); + const bool textureErr = capWin->textureError(); + const bool renderedOk = !textureErr && nonBlack > 0; + + if (preview) { + // 修复 2:用与真窗口完全相同的默认相机/source/exagg/传函(viewSetupDefaultFrame + // 已建好),离屏渲一帧存图 → 控制方先 Read 确认开窗默认画面非空、有结构。 + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + const std::string pngPath = + (shotDir / (nearPreview ? "view-near.png" : "view-default.png")) + .string(); + savePng(rw.Get(), pngPath); + // C3-8:多旋转角出图(同一真 view 场景:base+hires 共用 SetScale(1,1,exagg) + 底图 + // 按高清块 cropping 挖空)。从默认相机起取若干 (azimuth,elevation) 离屏存图,供人工 + // 核对路细长比例 / 拼接无缝 / 旋转无移动白斑。无 --shots 不执行。 + if (shots) { + const struct { + const char* name; + double az; + double el; + } kAngles[] = { + {"view-shot-az0", 0.0, 0.0}, {"view-shot-az30", 30.0, 0.0}, + {"view-shot-az60", 60.0, 0.0}, {"view-shot-az-30", -30.0, 0.0}, + {"view-shot-el20", 0.0, 20.0}, {"view-shot-az45el15", 45.0, 15.0}, + }; + for (const auto& s : kAngles) { + st.cam->Azimuth(s.az); + st.cam->Elevation(s.el); + ren->ResetCameraClippingRange(); + rw->Render(); + const std::string sp = (shotDir / (std::string(s.name) + ".png")).string(); + savePng(rw.Get(), sp); + std::cout << "[view] 旋转角出图: " << sp << " (az=" << s.az + << " el=" << s.el << ")\n"; + st.cam->Elevation(-s.el); // 复位到默认朝向,下一角从默认起算 + st.cam->Azimuth(-s.az); + } + } + // 结构像素计数:背景为深蓝灰(R/G≈10,B≈20),countNonBlackPixels(>10) 会把整屏 + // 背景都算「非空」,对验证「画面有结构」无意义。改为只数明显亮于背景的像素 + // (任一通道 >50),作为「确有渲出的体结构」的诚实判据。 + auto countStructPixels = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 50 || px->GetComponent(i, 1) > 50 || + px->GetComponent(i, 2) > 50) { + ++n; + } + } + return n; + }; + const vtkIdType defStruct = countStructPixels(); + + // C3-5:两态 fps 对照。AutoAdjustSampleDistances 据渲染窗口的 DesiredUpdateRate + // 决定 SampleDistance——高 DesiredUpdateRate=拖动态(大步长/降采样/快),低=静止态 + // (小步长/全质量/可慢)。离屏无真交互,故显式设 DesiredUpdateRate 模拟两态,旋相机 + // 测真实 Render() 帧率,量化拖动降采样提速。 + // + // ① 全质量静态:StillUpdateRate(慢档),mapper 用最小 SampleDistance。 + rw->SetDesiredUpdateRate(0.5); // 静止目标帧率(慢)→全质量 + rw->Render(); // 预热一帧(管线热 + 采样距离按此档生效) + Stopwatch swStill; + for (int f = 0; f < frames; ++f) { + st.cam->Azimuth(360.0 / frames); + rw->Render(); + } + const double msStill = swStill.elapsedMs(); + const double fps = msStill > 0 ? frames * 1000.0 / msStill : 0.0; + + // ② 交互态(降采样):高 DesiredUpdateRate(拖动目标 15fps)→ mapper 拉大 SampleDistance。 + rw->SetDesiredUpdateRate(15.0); // 拖动目标帧率→降采样 + rw->Render(); // 预热一帧让降采样步长生效 + Stopwatch swInteract; + for (int f = 0; f < frames; ++f) { + st.cam->Azimuth(360.0 / frames); + rw->Render(); + } + const double msInteract = swInteract.elapsedMs(); + const double fpsInteract = + msInteract > 0 ? frames * 1000.0 / msInteract : 0.0; + rw->SetDesiredUpdateRate(0.5); // 复位 + + const bool texErr2 = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + const bool ok = !texErr2 && defStruct > 0; + + std::cout << "\n=== view --preview 离屏默认视角验证 ===\n"; + std::cout << "默认局部段维度 : " << kViewDefaultLocalBricks + << " brick 列(沿线中段) level0\n"; + std::cout << "存图 : " << pngPath << "\n"; + std::cout << "结构像素(>50) : " << defStruct << " / " << (winW * winH) + << " (" << (100.0 * defStruct / (winW * winH)) + << "%, 已排除深蓝灰背景)\n"; + std::cout << "纹理维度错误 : " << (texErr2 ? "是(!!)" : "否") << "\n"; + const double speedup = fps > 0 ? fpsInteract / fps : 0.0; + std::cout << "全质量静态 fps : " << (ok ? std::to_string(fps) : "INVALID") + << " (" << frames << " 帧旋相机, DesiredUpdateRate=0.5)\n"; + std::cout << "交互态降采样 fps: " + << (ok ? std::to_string(fpsInteract) : "INVALID") << " (" << frames + << " 帧旋相机, DesiredUpdateRate=15)\n"; + std::cout << "拖动态提速 : " << (ok ? std::to_string(speedup) : "INVALID") + << "x (交互态/全质量)\n"; + std::cout << "交互级(≥15fps) : " + << (ok ? (fpsInteract >= 15.0 ? "是 ✔" : "否(未达拖动目标)") + : "INVALID") + << "\n"; + std::cout << "preview 结果 : " + << (ok ? "OK ✔ 默认视角有结构" : "FAIL ✘") << "\n"; + + writeMetricLine( + "view-preview,dir=" + dir + ",exagg=" + std::to_string(exagg) + + ",opacity=" + std::to_string(opacity) + + ",localBricks=" + std::to_string(kViewDefaultLocalBricks) + + ",structPixels=" + std::to_string(defStruct) + + ",fpsStill=" + (ok ? std::to_string(fps) : "INVALID") + + ",fpsInteract=" + (ok ? std::to_string(fpsInteract) : "INVALID") + + ",speedup=" + (ok ? std::to_string(speedup) : "INVALID") + + ",textureErr=" + std::to_string(texErr2 ? 1 : 0) + + ",ok=" + std::to_string(ok ? 1 : 0) + + ",png=" + pngPath); + return ok ? 0 : 1; + } + + if (smoke) { + // 离屏 smoke:模拟一次缩放 → 验 LOD 切换 + 不崩(单纹理路径)。 + const int lvlNear = st.lastLevel; + st.cam->Dolly(0.02); // 大幅拉远 → 期望切到粗 LOD(整卷粗层单纹理) + ren->ResetCameraClippingRange(); + const std::size_t blocksFar = viewRefreshBlocking(&st); + const int lvlFar = st.lastLevel; + rw->Render(); + st.cam->Dolly(50.0); // 拉近回来 → 期望切回细 LOD(level0 局部子区域) + ren->ResetCameraClippingRange(); + viewRefreshBlocking(&st); + const int lvlNear2 = st.lastLevel; + rw->Render(); + const vtkIdType nb2 = countNonBlackPixels(rw.Get(), winW, winH); + + vtkOutputWindow::SetInstance(nullptr); + const bool lodSwitched = (lvlFar != lvlNear) || (lvlNear2 != lvlFar); + const bool ok = renderedOk && nb2 > 0 && !capWin->textureError(); + std::cout << "\n=== view --smoke 离屏冒烟 ===\n"; + std::cout << "近观 level=" << lvlNear << " → 拉远 level=" << lvlFar + << " → 再拉近 level=" << lvlNear2 << "\n"; + std::cout << "LOD 随缩放切换 : " << (lodSwitched ? "是 ✔" : "否(测点档位未跨界)") + << " (blocksFar=" << blocksFar << ")\n"; + std::cout << "纹理维度错误 : " << (textureErr ? "是(!!)" : "否") << "\n"; + std::cout << "渲出非空像素 : " << (renderedOk ? "是" : "否(!!)") + << " (近=" << nonBlack << " 远拉近=" << nb2 << ")\n"; + std::cout << "smoke 结果 : " << (ok ? "OK ✔ 不崩" : "FAIL ✘") << "\n"; + return ok ? 0 : 1; + } + + vtkOutputWindow::SetInstance(nullptr); + if (!renderedOk) { + std::cout << "[view] 警告: 首帧未渲出非空像素(纹理错=" << textureErr + << ");窗口仍开,供人工排查。\n"; + } + + // 真窗口交互:TrackballCamera + 每次交互结束重选 LOD + 刷 fps 文本。 + vtkNew iren; + iren->SetRenderWindow(rw); + vtkNew style; + iren->SetInteractorStyle(style); + + // C3-5:交互/静止目标帧率。interactor 在交互(拖动)中把渲染窗口 DesiredUpdateRate + // 拉到 DesiredUpdateRate(15)→mapper 自适应降采样(快、跟手);松手后落回 StillUpdateRate + // (0.5)→恢复小步长全质量。配合上面 mapper 的 AutoAdjust/InteractiveAdjust 才生效。 + iren->SetDesiredUpdateRate(15.0); + iren->SetStillUpdateRate(0.5); + + vtkNew cb; + cb->SetCallback(viewOnInteract); + cb->SetClientData(&st); + // EndInteraction:旋转/缩放松手后提交新目标 + 刷 fps(仅松手触发一次,不自激)。 + // 注意:绝不可在 rw 的 EndEvent 上注册——回调内部 Render() 会再触发 EndEvent + // 形成无限递归重渲(窗口卡死、fps≈0)。fps 文本在松手时刷新即可。 + iren->AddObserver(vtkCommand::EndInteractionEvent, cb); + + // C3-2:拖动进行中持续提交目标(非阻塞),主线程不被重组卡住 → 跟手。 + vtkNew cbInteract; + cbInteract->SetCallback(viewOnInteracting); + cbInteract->SetClientData(&st); + iren->AddObserver(vtkCommand::InteractionEvent, cbInteract); + + // C3-2:周期定时器非阻塞拉取后台已就绪纹理换上(新 LOD 备好即显示,拖动不卡)。 + vtkNew cbTimer; + cbTimer->SetCallback(viewOnTimer); + cbTimer->SetClientData(&st); + iren->AddObserver(vtkCommand::TimerEvent, cbTimer); + + std::cout << "[view] 打开真窗口。左键旋转 / 滚轮缩放(切 LOD) / q 退出。\n"; + iren->Initialize(); + iren->CreateRepeatingTimer(33); // ~30Hz 拉取后台就绪纹理(不阻塞主线程) + rw->Render(); + iren->Start(); + + std::cout << "[view] 窗口关闭,退出。\n"; + return 0; +} + +// ============================================================================ +// 12d-polish:梯度不透明度 + 光照 打磨探针(验证"体内部白雾"能否靠打磨解决) +// ============================================================================ +// +// 当前体绘制对道路 GPR 水平层数据,体中间是均匀白雾、只有端面有层次。本探针在同一 +// 全分辨率(level0)局部段 + 同一「看进体内部」视角(斜穿俯视,视线穿过体内部而非只看 +// 端面)下渲 3 张离屏对比图,验证:给体加【梯度不透明度】(均匀区透明、层界面显出) + +// 【光照/明暗】能否让内部层状结构"浮"出来: +// polish-a-value.png 基线:按数值的不透明度(V形包络),无梯度不透明度、无光照 +// polish-b-grad.png + 梯度不透明度(SetGradientOpacity) +// polish-c-grad-shade.png + 梯度不透明度 + 光照(ShadeOn, Ambient/Diffuse/Specular) +// +// 梯度不透明度的 piecewise 按【实际梯度幅值分布】标定阈值(不靠猜):先在量化域逐体素 +// 采样 6 邻居中心差分梯度幅值,取分位数(median / p90)作斜坡控制点。 + +// 量化域标量不透明度峰值。基线(a)用 0.15(与默认体绘制同档→均匀积分白雾);开了梯度 +// 不透明度(b/c)后均匀区被梯度门压成透明,可放心把标量峰值提到 0.6,让【层界面】这类 +// 高梯度处的净不透明度(标量×梯度)足够高、层面真正"浮"成实面,而非仍是淡影。 +constexpr double kPolishMaxOpacityFog = 0.15; // a:基线白雾 +constexpr double kPolishMaxOpacityGrad = 0.6; // b/c:梯度门控后可提高 + +// 在 VTK_SHORT 局部体上采样梯度幅值分布(量化域,中心差分),返回有序的若干分位数。 +// 跳过 kBlank 体素及其邻居(空值不参与梯度)。返回 {median, p75, p90, p99, max}。 +// (GradStats 已在文件上方前置声明,供 C4 画廊共用。) +GradStats sampleGradientMagnitude(vtkImageData* img) { + int dims[3]; + img->GetDimensions(dims); + const int nx = dims[0], ny = dims[1], nz = dims[2]; + auto* arr = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars()); + GradStats gs; + if (!arr || nx < 3 || ny < 3 || nz < 3) return gs; + const std::int16_t blank = geopro::core::ScalarVolumeI16::kBlank; + auto at = [&](int i, int j, int k) -> std::int16_t { + const vtkIdType id = (static_cast(k) * ny + j) * nx + i; + return arr->GetValue(id); + }; + std::vector mags; + mags.reserve(static_cast(nx) * ny * nz / 8 + 1); + // 步长抽样:大体不必逐体素,间隔取样即可代表分布(≤~50万样本)。 + const vtkIdType total = static_cast(nx - 2) * (ny - 2) * (nz - 2); + const int stride = static_cast(std::max(1, total / 500000)); + vtkIdType counter = 0; + for (int k = 1; k < nz - 1; ++k) { + for (int j = 1; j < ny - 1; ++j) { + for (int i = 1; i < nx - 1; ++i) { + if ((counter++ % stride) != 0) continue; + const std::int16_t c = at(i, j, k); + if (c == blank) continue; + const std::int16_t xm = at(i - 1, j, k), xp = at(i + 1, j, k); + const std::int16_t ym = at(i, j - 1, k), yp = at(i, j + 1, k); + const std::int16_t zm = at(i, j, k - 1), zp = at(i, j, k + 1); + if (xm == blank || xp == blank || ym == blank || yp == blank || + zm == blank || zp == blank) { + continue; + } + const double gx = 0.5 * (xp - xm); + const double gy = 0.5 * (yp - ym); + const double gz = 0.5 * (zp - zm); + mags.push_back(std::sqrt(gx * gx + gy * gy + gz * gz)); + } + } + } + if (mags.empty()) return gs; + std::sort(mags.begin(), mags.end()); + auto q = [&](double p) { + const std::size_t idx = static_cast( + std::min(mags.size() - 1, p * (mags.size() - 1))); + return mags[idx]; + }; + gs.median = q(0.50); + gs.p75 = q(0.75); + gs.p90 = q(0.90); + gs.p99 = q(0.99); + gs.mx = mags.back(); + gs.samples = mags.size(); + return gs; +} + +// 标量分位标定实现(P3):步长抽样该 VTK_SHORT 体的非空体素值,排序取 pLo/pHi 分位, +// 用 quant.toPhys 反算成物理端点。失败/退化(无样本或 lo>=hi)返回 samples=0,由调用方 +// 回退到 meta 全量化域。仿 sampleGradientMagnitude 的 stride/blank 处理,自适应该体值域。 +ScalarPercentiles sampleScalarPercentiles(vtkImageData* img, + const geopro::core::Quant& q, + double pLo, double pHi) { + ScalarPercentiles out; + if (!img) return out; + int dims[3]; + img->GetDimensions(dims); + const vtkIdType npts = + static_cast(dims[0]) * dims[1] * dims[2]; + auto* arr = vtkShortArray::SafeDownCast(img->GetPointData()->GetScalars()); + if (!arr || npts <= 0) return out; + const std::int16_t blank = geopro::core::ScalarVolumeI16::kBlank; + std::vector vals; + vals.reserve(static_cast(npts) / 4 + 1); + // 步长抽样:~50 万样本足以代表分布(与梯度采样同档)。 + const vtkIdType stride = std::max(1, npts / 500000); + for (vtkIdType id = 0; id < npts; id += stride) { + const std::int16_t v = arr->GetValue(id); + if (v == blank) continue; + vals.push_back(v); + } + if (vals.empty()) return out; + std::sort(vals.begin(), vals.end()); + auto pick = [&](double p) { + const std::size_t idx = static_cast( + std::min(vals.size() - 1, p * (vals.size() - 1))); + return static_cast(vals[idx]); + }; + const double qLo = pick(pLo); + const double qHi = pick(pHi); + if (!(qLo < qHi)) return out; // 退化(常数体)→ 调用方回退全域 + out.lo = q.toPhys(static_cast(std::lround(qLo))); + out.hi = q.toPhys(static_cast(std::lround(qHi))); + out.samples = vals.size(); + return out; +} + +// 标量不透明度:V 形包络(与 makeSolidVolumeProperty 同思路,floor/mid/max),三图共用, +// 保证唯一变量是梯度不透明度 / 光照。 +void setPolishScalarOpacity(vtkVolumeProperty* prop, const geopro::core::Quant& q, + double vminPhys, double vmaxPhys, double maxOpacity) { + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + const double qmid = 0.5 * (qminD + qmaxD); + const double half = 0.5 * (qmaxD - qminD); + const double floorOp = 0.4 * maxOpacity; // 中段背景按峰值比例压低(V 形) + vtkNew opacity; + opacity->AddPoint( + static_cast(geopro::core::ScalarVolumeI16::kBlank), 0.0); + opacity->AddPoint(qminD, maxOpacity); + opacity->AddPoint(qmid - 0.55 * half, floorOp); + opacity->AddPoint(qmid, 0.2 * maxOpacity); + opacity->AddPoint(qmid + 0.55 * half, floorOp); + opacity->AddPoint(qmaxD, maxOpacity); + prop->SetScalarOpacity(opacity); +} + +// 颜色传函(量化域,seismic 红白蓝,与其余探针同思路)。 +void setPolishColor(vtkVolumeProperty* prop, const geopro::core::Quant& q, + const geopro::core::ColorScale& cs, double vminPhys, + double vmaxPhys) { + constexpr int kSamples = 64; + if (vminPhys >= vmaxPhys) vmaxPhys = vminPhys + 1.0; + const double qminD = static_cast(q.toQ(vminPhys)); + const double qmaxD = static_cast(q.toQ(vmaxPhys)); + vtkNew color; + for (int t = 0; t < kSamples; ++t) { + const double qd = qminD + (qmaxD - qminD) * t / (kSamples - 1); + const auto qv = static_cast(std::lround(qd)); + const auto c = cs.colorAt(q.toPhys(qv)); + color->AddRGBPoint(qd, c.r / 255.0, c.g / 255.0, c.b / 255.0); + } + prop->SetColor(color); +} + +// 共用:把局部体喂单 SmartVolumeMapper,按一个「看进体内部」的相机取景渲一帧 + 存 PNG, +// 报告 结构像素 / 平均亮度 / fps。mode: 0=value 基线 1=+grad 2=+grad+shade。 +int renderPolishOne(vtkImageData* locImg, const geopro::core::Quant& quant, + const geopro::core::ColorScale& cs, double vmin, double vmax, + const GradStats& gs, int mode, double exagg, + const std::string& pngPath, int frames, double elevation, + double azimuth, double zoom) { + const int winW = 1280, winH = 800; + auto rw = makeOffscreenWindow(winW, winH); + vtkNew ren; + ren->SetBackground(0.05, 0.05, 0.09); + rw->AddRenderer(ren); + + vtkNew mapper; + mapper->SetInputData(locImg); + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + mapper->SetAutoAdjustSampleDistances(0); + mapper->SetInteractiveAdjustSampleDistances(0); + + auto prop = vtkSmartPointer::New(); + setPolishColor(prop, quant, cs, vmin, vmax); + const double scalarMax = + (mode == 0) ? kPolishMaxOpacityFog : kPolishMaxOpacityGrad; + setPolishScalarOpacity(prop, quant, vmin, vmax, scalarMax); + prop->SetInterpolationTypeToLinear(); + + // 梯度不透明度(mode>=1):梯度小(均匀区)→透明、梯度大(层界面)→不透明。 + // 阈值按实测分布:median 处仍接近 0(压住均匀积分雾),p90 升到 0.5,p99 到 0.9。 + if (mode >= 1) { + vtkNew grad; + grad->AddPoint(0.0, 0.0); + grad->AddPoint(std::max(1.0, gs.median), 0.0); + grad->AddPoint(std::max(2.0, gs.p90), 0.5); + grad->AddPoint(std::max(3.0, gs.p99), 0.9); + prop->SetGradientOpacity(grad); + } + + // 光照(mode>=2):ShadeOn + Ambient/Diffuse/Specular,让层界面带立体明暗。 + if (mode >= 2) { + prop->ShadeOn(); + prop->SetAmbient(0.3); + prop->SetDiffuse(0.7); + prop->SetSpecular(0.2); + prop->SetSpecularPower(10.0); + } else { + prop->ShadeOff(); + } + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, exagg, exagg); // 垂向夸张:薄轴放大,截面结构才看得出 + ren->AddVolume(volume); + + auto capWin = vtkSmartPointer::New(); + vtkOutputWindow::SetInstance(capWin); + + // 「看进体内部」取景:斜穿俯视——较大 Elevation 让视线从上方斜穿过体内部(而非只看 + // 端面),配合垂向夸张后体呈板状,俯视看穿层间。Zoom 填满画面。 + ren->ResetCamera(); + vtkCamera* cam = ren->GetActiveCamera(); + cam->Elevation(elevation); + cam->Azimuth(azimuth); + cam->Zoom(zoom); + ren->ResetCameraClippingRange(); + rw->Render(); + + // 结构像素:任一通道 >50(排除暗背景)。 + auto countStructPixels = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 50 || px->GetComponent(i, 1) > 50 || + px->GetComponent(i, 2) > 50) { + ++n; + } + } + return n; + }; + // 高于背景像素:背景为 (0.05,0.05,0.09)≈RGB(13,13,23),阈值 35 干净地把渲出的体 + // 结构与背景分开(区别于结构像素>50:>35 能纳入梯度门控后偏暗但确有的层面)。 + auto countAboveBg = [&]() -> vtkIdType { + auto px = vtkSmartPointer::New(); + rw->GetRGBACharPixelData(0, 0, winW - 1, winH - 1, /*front=*/1, px); + vtkIdType n = 0; + const vtkIdType np = px->GetNumberOfTuples(); + for (vtkIdType i = 0; i < np; ++i) { + if (px->GetComponent(i, 0) > 35 || px->GetComponent(i, 1) > 35 || + px->GetComponent(i, 2) > 35) { + ++n; + } + } + return n; + }; + const vtkIdType structPx = countStructPixels(); + const vtkIdType aboveBg = countAboveBg(); + const double bright = meanBrightness(rw.Get(), winW, winH); + + savePng(rw.Get(), pngPath); + + // fps:旋相机 frames 帧(保持大俯角,绕 Azimuth)。 + rw->Render(); + Stopwatch sw; + for (int f = 0; f < frames; ++f) { + cam->Azimuth(360.0 / frames); + rw->Render(); + } + const double ms = sw.elapsedMs(); + const double fps = ms > 0 ? frames * 1000.0 / ms : 0.0; + const bool texErr = capWin->textureError(); + vtkOutputWindow::SetInstance(nullptr); + // 有效判据:无纹理错 + 渲出高于背景的像素(>35)。结构像素(>50)仅作亮度强弱度量, + // 不作有效门——梯度门控后层面偏暗但确有渲出,不应误判为空。 + const bool ok = !texErr && aboveBg > 0; + + const char* label = + mode == 0 ? "a-value(基线)" + : (mode == 1 ? "b-grad(+梯度不透明度)" : "c-grad-shade(+梯度+光照)"); + std::cout << "\n--- polish " << label << " ---\n"; + std::cout << "存图 : " << pngPath << "\n"; + std::cout << "高于背景像素(>35): " << aboveBg << " / " << (winW * winH) << " (" + << (100.0 * aboveBg / (winW * winH)) << "%)\n"; + std::cout << "结构像素(>50) : " << structPx << " / " << (winW * winH) << " (" + << (100.0 * structPx / (winW * winH)) << "%)\n"; + std::cout << "平均亮度(0-255) : " << bright << "\n"; + std::cout << "真实 fps : " << (ok ? std::to_string(fps) : "INVALID") + << " (" << frames << " 帧旋相机)\n"; + std::cout << "结果 : " << (ok ? "OK" : "FAIL(纹理错或空渲染)") << "\n"; + + writeMetricLine( + "polish," + std::string(mode == 0 ? "a-value" : mode == 1 ? "b-grad" + : "c-grad-shade") + + ",aboveBg=" + std::to_string(aboveBg) + + ",structPx=" + std::to_string(structPx) + + ",bright=" + std::to_string(bright) + + ",fps=" + (ok ? std::to_string(fps) : "INVALID") + + ",texErr=" + std::to_string(texErr ? 1 : 0) + ",png=" + pngPath); + return ok ? 0 : 1; +} + +int cmdPolish(int argc, char** argv) { + const Args a = parseArgs(argc, argv, 2); + if (a.positional.empty()) { + std::cerr << "用法: gpr_poc polish [--exagg 8] [--frames 90] " + "[--localBricks 4]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double exagg = std::stod(a.get("exagg", "8")); + const int frames = std::stoi(a.get("frames", "90")); + const int localBricks = std::stoi(a.get("localBricks", "4")); + // 「看进体内部」取景:斜穿俯视。默认 El45/Az30/Zoom1.5(视线从上方斜穿层间, + // 既不是纯端面也不至于过陡退化成边缘线)。可命令行覆盖以微调。 + const double elevation = std::stod(a.get("elevation", "45")); + const double azimuth = std::stod(a.get("azimuth", "30")); + const double zoom = std::stod(a.get("zoom", "1.5")); + std::cout << "[polish] storeDir=" << dir << " exagg=" << exagg + << " frames=" << frames << " localBricks=" << localBricks << "\n"; + + std::cout << "[polish] 离屏闸门复检...\n"; + if (cmdOffscreenSmoke() != 0) { + std::cout << "[polish] 闸门失败,中止。\n"; + return 1; + } + + geopro::data::ChunkedVolumeStore store(dir); + const geopro::data::StoreMeta& m = store.meta(); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeSeismicColorScale(vmin, vmax); + + // 全分辨率 level0 局部段(沿线中段),三图共用同一体。 + const int totBx = store.bricksX(0); + const int localBx = std::min(localBricks, totBx); + const int bx0 = std::max(0, totBx / 2 - localBx / 2); + vtkSmartPointer locImg = + buildLocalLevel0Image(store, m, bx0, localBx); + int locDims[3]; + locImg->GetDimensions(locDims); + std::cout << "[polish] level0 局部段 " << locDims[0] << "x" << locDims[1] << "x" + << locDims[2] << " (brick列 [" << bx0 << "," << (bx0 + localBx) + << ") / " << totBx << ")\n"; + + // 标定梯度不透明度阈值:采样实际梯度幅值分布。 + const GradStats gs = sampleGradientMagnitude(locImg.Get()); + std::cout << "[polish] 梯度幅值分布(量化域,样本 " << gs.samples + << "): median=" << gs.median << " p75=" << gs.p75 + << " p90=" << gs.p90 << " p99=" << gs.p99 << " max=" << gs.mx + << "\n"; + std::cout << "[polish] 梯度不透明度 piecewise: grad<=" << std::max(1.0, gs.median) + << "→0.0 grad=" << std::max(2.0, gs.p90) << "→0.5 grad>=" + << std::max(3.0, gs.p99) << "→0.9\n"; + + const fs::path shotDir = + fs::path("docs") / "superpowers" / "plans" / "poc-lod-shots"; + fs::create_directories(shotDir); + + int rc = 0; + rc |= renderPolishOne(locImg.Get(), m.quant, cs, vmin, vmax, gs, /*mode=*/0, + exagg, (shotDir / "polish-a-value.png").string(), frames, + elevation, azimuth, zoom); + rc |= renderPolishOne(locImg.Get(), m.quant, cs, vmin, vmax, gs, /*mode=*/1, + exagg, (shotDir / "polish-b-grad.png").string(), frames, + elevation, azimuth, zoom); + rc |= renderPolishOne(locImg.Get(), m.quant, cs, vmin, vmax, gs, /*mode=*/2, + exagg, (shotDir / "polish-c-grad-shade.png").string(), + frames, elevation, azimuth, zoom); + + std::cout << "\n[polish] 完成,3 张对比图存于 " << shotDir.string() + << " (polish-a-value / polish-b-grad / polish-c-grad-shade)\n"; + return rc; +} + +void usage() { + std::cerr << "gpr_poc —— POC-B headless 度量 CLI\n" + " gpr_poc build [--line 001] [--cellXY 0.2] " + "[--cellZ 0.05] [--out ] [--levels 2]\n" + " gpr_poc build-stream [--cellXY 0.05] [--cellZ 0.05] " + "[--out ] [--levels 3] [--sliceXBricks 8] " + "[--maxLines N]\n" + " gpr_poc build-geo [--cellXY 0.5] [--cellZ 0.1] " + "[--out ] [--levels 4] [--maxLines N] [--curvilinear]\n" + " gpr_poc build-line --out " + "[--levels 3] [--coarse F]\n" + " gpr_poc build-all --outBase " + "[--levels 3] [--coarse F] [--minFreeGB 3]\n" + " gpr_poc build-survey-line --out " + " [--levels 3] [--coarse F] [--cell 0.05] " + "[--radius 0.5] [--gps ]\n" + " gpr_poc build-survey-all --outBase " + "[--levels 3] [--coarse F] [--cell 0.05] [--radius 0.5] " + "[--minFreeGB 3]\n" + " gpr_poc load \n" + " gpr_poc selftest\n" + " gpr_poc offscreen-smoke\n" + " gpr_poc renderB [--frames 120]\n" + " gpr_poc renderC [--budget 64] [--frames 120]\n" + " gpr_poc renderC-partitioned [--frames 120]\n" + " gpr_poc renderLOD [--frames 120]\n" + " gpr_poc tune [--opacity 0.5] [--exagg 8] " + "[--frames 120] [--localBricks 4]\n" + " gpr_poc fps-budget [--frames 90] " + "[--bricks 4,16,64,128,256]\n" + " gpr_poc view [--exagg 8] [--opacity 0.5] " + "[--smoke] [--preview] [--near] [--variant N] [--gallery] " + "[--frames 90]\n" + " gpr_poc view-all [--preview] " + "[--exagg 8] [--level 1] [--spread M] [--shotDir ]\n" + " gpr_poc view-survey-all [--preview] [--exagg 8] " + "[--level 1] [--shotDir ]\n" + " gpr_poc polish [--exagg 8] [--frames 90] " + "[--localBricks 4]\n"; +} + +} // namespace + +int main(int argc, char** argv) { +#ifdef _WIN32 + // Windows 控制台默认 GBK,会把 UTF-8 中文输出显示为乱码。设为 UTF-8 码页修复。 + SetConsoleOutputCP(CP_UTF8); +#endif + if (argc < 2) { + usage(); + return 2; + } + const std::string cmd = argv[1]; + try { + if (cmd == "build") return cmdBuild(argc, argv); + if (cmd == "build-stream") return cmdBuildStream(argc, argv); + if (cmd == "build-geo") return cmdBuildGeo(argc, argv); + if (cmd == "build-line") return cmdBuildLine(argc, argv); + if (cmd == "build-all") return cmdBuildAll(argc, argv); + if (cmd == "ess-stat") return cmdEssStat(argc, argv); + if (cmd == "passcost") return cmdPassCost(argc, argv); + if (cmd == "slice") return cmdSlice(argc, argv); + if (cmd == "build-survey-line") return cmdBuildSurveyLine(argc, argv); + if (cmd == "build-survey-all") return cmdBuildSurveyAll(argc, argv); + if (cmd == "load") return cmdLoad(argc, argv); + if (cmd == "selftest") return cmdSelftest(); + if (cmd == "offscreen-smoke") return cmdOffscreenSmoke(); + if (cmd == "renderB") return cmdRenderB(argc, argv); + if (cmd == "renderC") return cmdRenderC(argc, argv); + if (cmd == "renderC-partitioned") + return cmdRenderCPartitioned(argc, argv); + if (cmd == "renderLOD") return cmdRenderLOD(argc, argv); + if (cmd == "tune") return cmdTune(argc, argv); + if (cmd == "fps-budget") return cmdFpsBudget(argc, argv); + if (cmd == "view") return cmdView(argc, argv); + if (cmd == "view-all") return cmdViewAll(argc, argv); + if (cmd == "view-survey-all") return cmdViewSurveyAll(argc, argv); + if (cmd == "polish") return cmdPolish(argc, argv); + } catch (const std::exception& e) { + std::cerr << "错误: " << e.what() << "\n"; + return 1; + } + usage(); + return 2; +} diff --git a/vcpkg.json b/vcpkg.json index f515a80..1458de6 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,6 +2,7 @@ "name": "geopro-desktop", "version": "0.1.0", "description": "Geopro 3.0 desktop client (Qt6 + VTK9) - M1. 方案②-修订: Qt/VTK/ADS/QtKeychain 对接官方 MSVC Qt(不走 vcpkg); 仅非 Qt 依赖走 vcpkg, 按层递增。", + "builtin-baseline": "10ceb139a610ebf3c6aa49cdc4a4b7f3db5d3f2b", "dependencies": [ "eigen3", "gdal",