From bec6a376d5efc3483b04827c9af6dd3d8d738a53 Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 23 Jun 2026 18:40:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E8=AF=A6=E6=83=85=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E9=A1=B5=E8=84=9A/=E8=A1=A8=E5=8D=95=E6=94=B9?= =?UTF-8?q?=E8=B5=B0=20FormKit=20=E7=AC=A6=E5=90=88=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E8=A7=84=E8=8C=83(=E5=8E=BBArco=E5=BC=8F=E5=A4=A7=E6=8C=89?= =?UTF-8?q?=E9=92=AE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前为"像原版"手搭 QFormLayout + Arco式页脚(45%等宽/两端对齐/多主按钮/顺序反), 违反 Geopro3.0 视觉规范 §6.7/§7.5/§7.0.10。改为规范实现(字段结构/API 不动): - 白化/另存/滤波/网格化:页脚改 formkit::addDialogButtons(右对齐 取消+确定,主按钮蓝); 异步确认改接 Ok 按钮 clicked(校验/whitenData/save 成功才 accept);表单改 makeEditForm +editLabel+capField+addSection;宽度按规范(白化560/另存420/滤波保留宽/网格分组) - 滤波"保存设置"作次按钮(ActionRole)不抢 primary;网格化 上一步(次)左+取消/确定右 - 快查:ScatterFilterDialog 可编辑输入改 makeEditForm;AutoAnnotation 主操作 setDefault - 异常各弹窗/反演表单 已合规未动 build all 绿,341/341。 --- .superpowers/sdd/task-12d-report.md | 173 +++++ .../plans/poc-lod-shots/lod-tuned-local.png | Bin 0 -> 25991 bytes .../poc-lod-shots/lod-tuned-overview.png | Bin 0 -> 4023 bytes docs/superpowers/plans/poc-results-C.md | 23 + src/app/panels/chart/AutoAnnotationDialog.cpp | 1 + src/app/panels/chart/FilterDialog.cpp | 44 +- src/app/panels/chart/GridWizardDialog.cpp | 31 +- src/app/panels/chart/SaveAsDialog.cpp | 71 +- src/app/panels/chart/ScatterFilterDialog.cpp | 9 +- src/app/panels/chart/WhiteningDialog.cpp | 82 +-- tools/gpr_poc/main.cpp | 659 +++++++++++++++++- 11 files changed, 946 insertions(+), 147 deletions(-) create mode 100644 .superpowers/sdd/task-12d-report.md create mode 100644 docs/superpowers/plans/poc-lod-shots/lod-tuned-local.png create mode 100644 docs/superpowers/plans/poc-lod-shots/lod-tuned-overview.png 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/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 0000000000000000000000000000000000000000..67a68a0f85959a130792c63f54f0138a3f89f8e0 GIT binary patch literal 25991 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%0AT>~lBb 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7d079c9cce326250e7cce69d5d9075d56ce20bf4 GIT binary patch literal 4023 zcmeAS@N?(olHy`uVBq!ia0y~yU;#3j8911LlIqU|5j27pVGyJR4Ap6Ohl)A^{{g41t6|5|B_h0wf$d7#SEE z1ek$L#ZiT$!84jDMzg|bNikYJj@AmJmF8$uVYGoX+AJJxHIMccMtex39Y|m`{bnHK;6 literal 0 HcmV?d00001 diff --git a/docs/superpowers/plans/poc-results-C.md b/docs/superpowers/plans/poc-results-C.md index 34554f5..46a5c00 100644 --- a/docs/superpowers/plans/poc-results-C.md +++ b/docs/superpowers/plans/poc-results-C.md @@ -47,3 +47,26 @@ 粗层概览 + 全分辨率局部【都达交互级】且切换【无不可接受卡顿】→ 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/src/app/panels/chart/AutoAnnotationDialog.cpp b/src/app/panels/chart/AutoAnnotationDialog.cpp index c47d940..f9e79fb 100644 --- a/src/app/panels/chart/AutoAnnotationDialog.cpp +++ b/src/app/panels/chart/AutoAnnotationDialog.cpp @@ -123,6 +123,7 @@ AutoAnnotationDialog::AutoAnnotationDialog(geopro::data::IDatasetCommandReposito 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); diff --git a/src/app/panels/chart/FilterDialog.cpp b/src/app/panels/chart/FilterDialog.cpp index 32a979e..c297f93 100644 --- a/src/app/panels/chart/FilterDialog.cpp +++ b/src/app/panels/chart/FilterDialog.cpp @@ -5,6 +5,7 @@ #include #include "EmptyAwareComboBox.hpp" +#include #include #include #include @@ -22,6 +23,7 @@ #include #include +#include "FormKit.hpp" // addDialogButtons / addSection / editLabel #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildFilterApplyBody / buildNewFilterBody #include "repo/IDatasetCommandRepository.hpp" @@ -32,7 +34,6 @@ namespace { constexpr int kDialogW = 900; // 原版弹窗宽 900px constexpr int kMatrixMin = 1, kMatrixMax = 21; // 矩阵行列范围(对照原版 1~21) constexpr int kDefaultDim = 3; -constexpr int kSettingLabelW = 80; // 原版 .setting-label width:80px const char kDefaultCustomKey[] = "default-custom-filter"; // 默认自定义滤波器(不可删) const char kCustomGroupName[] = "自定义滤波器"; @@ -44,17 +45,9 @@ double cellValue(const QTableWidgetItem* it) { return ok ? v : 0.0; } -// 原版分组小标题(14px 半粗 + 标题下 1px divider)。 +// 分组小标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 void addSpecTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { - auto* lbl = new QLabel(title, parent); - auto f = lbl->font(); - f.setBold(true); - lbl->setFont(f); - into->addWidget(lbl); - auto* line = new QFrame(parent); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - into->addWidget(line); + formkit::addSection(into, title, parent, /*topGap=*/false); } // 原版带边框卡片(1px 边框 + 圆角 + 内距)。 @@ -83,25 +76,18 @@ FilterDialog::FilterDialog(geopro::data::IDatasetCommandRepository* repo, QStrin buildLeft(body); buildRight(body); - // 底部按钮(原版三按钮各 ~30%:保存设置(主,左)/确认(主,中)/取消(右))。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - auto* saveSettingBtn = new QPushButton(QStringLiteral("保存设置"), this); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(saveSettingBtn, 30); - btnLay->addStretch(2); - btnLay->addWidget(okBtn_, 30); - btnLay->addStretch(2); - btnLay->addWidget(cancelBtn, 30); - root->addLayout(btnLay); + // 规范 §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(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); - connect(okBtn_, &QPushButton::clicked, this, &FilterDialog::onConfirm); connect(saveSettingBtn, &QPushButton::clicked, this, &FilterDialog::saveCustomFilter); connect(tree_, &QTreeWidget::itemSelectionChanged, this, &FilterDialog::onTreeSelectionChanged); @@ -213,14 +199,12 @@ void FilterDialog::buildRight(QHBoxLayout* body) { body->addWidget(card, 6); // 右 ~60% } -// 原版 .setting-row:label(80px) + 主控件(30%) [+ 右侧 label「值:」+ 值框(30%)]。 +// 设置行:定宽右标签列(§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); - auto* lbl = new QLabel(label, parent); - lbl->setMinimumWidth(kSettingLabelW); - row->addWidget(lbl); + row->addWidget(formkit::editLabel(label, parent)); row->addWidget(main, 3); if (valField) { row->addSpacing(geopro::app::space::kLg); diff --git a/src/app/panels/chart/GridWizardDialog.cpp b/src/app/panels/chart/GridWizardDialog.cpp index 6ec362e..22fa4e6 100644 --- a/src/app/panels/chart/GridWizardDialog.cpp +++ b/src/app/panels/chart/GridWizardDialog.cpp @@ -7,7 +7,6 @@ #include "EmptyAwareComboBox.hpp" #include -#include #include #include #include @@ -19,6 +18,7 @@ #include #include +#include "FormKit.hpp" // addSection / editLabel #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildGridToBody #include "repo/IDatasetCommandRepository.hpp" @@ -42,17 +42,9 @@ QDoubleSpinBox* makeCoordSpin(QWidget* parent) { return sp; } -// 分组卡片标题(原版 .section-title:14px 半粗 + 标题下 1px divider)。 +// 分组标题:走 §7.0.10 唯一实现 formkit::addSection(heading 半粗 + 标题下 1px divider)。 void addSectionTitle(QVBoxLayout* into, const QString& title, QWidget* parent) { - auto* lbl = new QLabel(title, parent); - auto f = lbl->font(); - f.setBold(true); - lbl->setFont(f); - into->addWidget(lbl); - auto* line = new QFrame(parent); - line->setFrameShape(QFrame::HLine); - line->setFrameShadow(QFrame::Plain); - into->addWidget(line); + formkit::addSection(into, title, parent, /*topGap=*/false); } // 原版 .param-group:定宽右标签 + 紧随输入框,多个并排成一行栅格。 @@ -86,19 +78,20 @@ GridWizardDialog::GridWizardDialog(geopro::data::IDatasetCommandRepository* repo buildStep1(); buildStep2(); - // ── 底部按钮(上一步 / 确认(主) / 取消,原版步骤 2 三按钮)──────────── + // ── 底部操作栏(规范 §7.5 右对齐):上一步(次按钮) 左;取消(次) + 下一步/确认(主) 右。── auto* btnLay = new QHBoxLayout(); btnLay->setSpacing(geopro::app::space::kMd); - btnLay->addStretch(); - prevBtn_ = new QPushButton(QStringLiteral("上一步"), this); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - nextBtn_ = new QPushButton(QStringLiteral("下一步"), this); + 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->addWidget(okBtn_); - btnLay->addWidget(nextBtn_); + btnLay->addStretch(); btnLay->addWidget(cancelBtn); + btnLay->addWidget(nextBtn_); + btnLay->addWidget(okBtn_); root->addLayout(btnLay); prevBtn_->setVisible(false); okBtn_->setVisible(false); diff --git a/src/app/panels/chart/SaveAsDialog.cpp b/src/app/panels/chart/SaveAsDialog.cpp index e00a987..cd6d4d4 100644 --- a/src/app/panels/chart/SaveAsDialog.cpp +++ b/src/app/panels/chart/SaveAsDialog.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -12,6 +14,7 @@ #include #include +#include "FormKit.hpp" // makeEditForm / editLabel / capField / addDialogButtons #include "Theme.hpp" #include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) #include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测) @@ -20,9 +23,8 @@ namespace geopro::app { namespace { -constexpr int kInversionW = 400; // 原版 inversion 另存为弹窗宽 400px -constexpr int kRawDataW = 280; // 原版 RawData「数据另存为」弹窗宽 280px -constexpr int kLabelW = 60; // 原版 .label width:60px +constexpr int kInversionW = 420; // 规范 §7.5 小号对话框宽 +constexpr int kRawDataW = 420; // 同上(窄内容仍取小号标准宽,避免局促) } // namespace SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* repo, QString dsId, @@ -30,34 +32,32 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r : QDialog(parent), mode_(mode), repo_(repo), dsId_(std::move(dsId)) { setModal(true); - 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); + // 规范 §7.5 对话框外壳 + §7.0.10 唯一表单实现(makeEditForm)。 + auto* root = formkit::dialogRoot(this); + auto* form = formkit::makeEditForm(); if (mode_ == Mode::Inversion) { - // ── inversion:原版「另存为新的网格数据」400px,仅名称行 ── + // ── inversion:原版「另存为新的网格数据」,仅名称行 ── setWindowTitle(QStringLiteral("另存为新的网格数据")); - setFixedWidth(kInversionW); + setFixedWidth(scaledPx(kInversionW)); - auto* nameRow = new QHBoxLayout(); - nameRow->setSpacing(geopro::app::space::kMd); - nameLabel_ = new QLabel(QStringLiteral("名称:"), this); // 原版 label「名称:」 - nameLabel_->setMinimumWidth(kLabelW); + nameLabel_ = formkit::editLabel(QStringLiteral("名称"), this); // 原版 label「名称」 nameEdit_ = new QLineEdit(this); nameEdit_->setPlaceholderText(QStringLiteral("请输入名称")); nameEdit_->setText(QStringLiteral("网格数据1")); // 原版默认值 - nameRow->addWidget(nameLabel_); - nameRow->addWidget(nameEdit_, 1); - root->addLayout(nameRow); + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); } else { - // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」280px)── + // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」)── setWindowTitle(QStringLiteral("数据另存为")); - setFixedWidth(kRawDataW); + setFixedWidth(scaledPx(kRawDataW)); - auto* opLay = new QHBoxLayout(); - auto* rbNew = new QRadioButton(QStringLiteral("新增"), this); - auto* rbOverwrite = new QRadioButton(QStringLiteral("覆盖"), this); + 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); @@ -65,16 +65,13 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r opLay->addWidget(rbNew); opLay->addWidget(rbOverwrite); opLay->addStretch(); - root->addLayout(opLay); + form->addRow(formkit::editLabel(QStringLiteral("操作"), this), opWrap); - auto* nameRow = new QHBoxLayout(); - nameRow->setSpacing(geopro::app::space::kMd); - nameLabel_ = new QLabel(QStringLiteral("数据名称"), this); - nameLabel_->setMinimumWidth(kLabelW); + nameLabel_ = formkit::editLabel(QStringLiteral("数据名称"), this); nameEdit_ = new QLineEdit(this); - nameRow->addWidget(nameLabel_); - nameRow->addWidget(nameEdit_, 1); - root->addLayout(nameRow); + formkit::capField(nameEdit_); + form->addRow(nameLabel_, nameEdit_); + root->addLayout(form); // 切到覆盖隐藏名称框,切回新增显示。 connect(opGroup_, QOverload::of(&QButtonGroup::idClicked), this, [this](int id) { @@ -84,18 +81,10 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r }); } - // 底部按钮:原版右对齐,确认(主,左)/取消(右)。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - btnLay->addStretch(); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(okBtn_); - btnLay->addWidget(cancelBtn); - root->addLayout(btnLay); - - connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + // 规范 §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); } diff --git a/src/app/panels/chart/ScatterFilterDialog.cpp b/src/app/panels/chart/ScatterFilterDialog.cpp index db33ce6..1c83635 100644 --- a/src/app/panels/chart/ScatterFilterDialog.cpp +++ b/src/app/panels/chart/ScatterFilterDialog.cpp @@ -15,6 +15,7 @@ #include #include +#include "FormKit.hpp" // makeEditForm / editLabel / capField #include "Theme.hpp" #include "ToastOverlay.hpp" // showToast:成功轻提示 #include "panels/chart/RangeSlider.hpp" @@ -97,16 +98,16 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository currentPtsLbl_->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 infoLay->addLayout(statForm); - // 最大值在上、最小值在下(对照原版输入框顺序)。 - auto* inputForm = new QFormLayout(); + // 最大值在上、最小值在下(对照原版输入框顺序)。可编辑表单走 §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(new QLabel(QStringLiteral("最大值:")), maxSpin_); - inputForm->addRow(new QLabel(QStringLiteral("最小值:")), minSpin_); + inputForm->addRow(formkit::editLabel(QStringLiteral("最大值"), this), maxSpin_); + inputForm->addRow(formkit::editLabel(QStringLiteral("最小值"), this), minSpin_); infoLay->addLayout(inputForm); // 计算分布 / 重置(信息区中部,对照原版 .filter-actions)。 diff --git a/src/app/panels/chart/WhiteningDialog.cpp b/src/app/panels/chart/WhiteningDialog.cpp index 53c9779..cbb1bf6 100644 --- a/src/app/panels/chart/WhiteningDialog.cpp +++ b/src/app/panels/chart/WhiteningDialog.cpp @@ -6,8 +6,9 @@ #include #include "EmptyAwareComboBox.hpp" +#include +#include #include -#include #include #include #include @@ -16,7 +17,7 @@ #include #include -#include "FormKit.hpp" // formkit::comboBox(空态感知下拉) +#include "FormKit.hpp" // formkit::comboBox / makeEditForm / editLabel / capField / addDialogButtons #include "Theme.hpp" #include "panels/chart/InversionProcessOps.hpp" // buildWhitenBody #include "repo/IDatasetCommandRepository.hpp" @@ -24,26 +25,12 @@ namespace geopro::app { namespace { -constexpr int kDialogW = 550; // 原版弹窗宽 550px -constexpr int kLabelMinW = 120; // 原版 .field-label min-width:120px 右对齐 -constexpr double kCtrlRatio = 0.6; // 原版控件宽 60% +constexpr int kDialogW = 560; // 规范 §7.5 中号对话框宽 -// 原版 .field-label:定宽右对齐标签。 -QLabel* fieldLabel(const QString& text, QWidget* parent) { - auto* lbl = new QLabel(text, parent); - lbl->setMinimumWidth(kLabelMinW); - lbl->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - return lbl; -} - -// 原版 .option-item:flex 行(标签右对齐 + 控件占 60%)。控件外裹一层以 60/40 拉伸。 -QHBoxLayout* optionRow(const QString& label, QWidget* ctrl, QWidget* parent) { - auto* row = new QHBoxLayout(); - row->setSpacing(geopro::app::space::kLg); - row->addWidget(fieldLabel(label, parent)); - row->addWidget(ctrl, 6); // 控件 60% - row->addStretch(4); // 余 40% 留白 - return row; +// 把「标签 + 控件」按 §7.0 度量加入表单(右对齐定宽标签列 + 字段宽上限)。 +void addFormRow(QFormLayout* form, const QString& label, QWidget* ctrl, QWidget* parent) { + formkit::capField(ctrl); + form->addRow(formkit::editLabel(label, parent), ctrl); } } // namespace @@ -56,30 +43,30 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, tmObjectId_(std::move(tmObjectId)) { setWindowTitle(QStringLiteral("白化配置")); // 原版 whiteningSetting setModal(true); - setFixedWidth(kDialogW); + setFixedWidth(scaledPx(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); + // 规范 §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); - root->addLayout(optionRow(QStringLiteral("白化方式"), methodCombo_, this)); + // §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 = new QVBoxLayout(page1); - p1->setContentsMargins(0, 0, 0, 0); - p1->setSpacing(geopro::app::space::kMd); + auto* p1 = formkit::makeEditForm(); + page1->setLayout(p1); extension_ = new QLineEdit(QStringLiteral("0"), page1); // 原版 AInput,默认 "0" - p1->addLayout(optionRow(QStringLiteral("白化边界扩展"), extension_, page1)); + addFormRow(p1, QStringLiteral("白化边界扩展"), extension_, page1); auto* typeWrap = new QWidget(page1); auto* typeRow = new QHBoxLayout(typeWrap); typeRow->setContentsMargins(0, 0, 0, 0); @@ -92,22 +79,22 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, typeRow->addWidget(rbOuter); typeRow->addWidget(rbInner); typeRow->addStretch(); - p1->addLayout(optionRow(QStringLiteral("白化"), typeWrap, page1)); + p1->addRow(formkit::editLabel(QStringLiteral("白化"), page1), typeWrap); stack_->addWidget(page1); // ── 方式 2:白化文件(选文件)────────────────────────────────────── auto* page2 = new QWidget(this); - auto* p2 = new QVBoxLayout(page2); - p2->setContentsMargins(0, 0, 0, 0); + auto* p2 = formkit::makeEditForm(); + page2->setLayout(p2); // 空态感知下拉:白化文件异步加载(listWhitenedData),未选显占位、无文件弹「暂无数据」。 fileCombo_ = formkit::comboBox(QStringLiteral("请选择白化文件"), page2); - p2->addLayout(optionRow(QStringLiteral("选择白化文件"), fileCombo_, page2)); + addFormRow(p2, QStringLiteral("选择白化文件"), fileCombo_, page2); stack_->addWidget(page2); // ── 方式 3:模型白化(梯形/矩形)─────────────────────────────────── auto* page3 = new QWidget(this); - auto* p3 = new QVBoxLayout(page3); - p3->setContentsMargins(0, 0, 0, 0); + auto* p3 = formkit::makeEditForm(); + page3->setLayout(p3); auto* subWrap = new QWidget(page3); auto* subRow = new QHBoxLayout(subWrap); subRow->setContentsMargins(0, 0, 0, 0); @@ -120,23 +107,14 @@ WhiteningDialog::WhiteningDialog(geopro::data::IDatasetCommandRepository* repo, subRow->addWidget(rbTrap); subRow->addWidget(rbRect); subRow->addStretch(); - p3->addLayout(optionRow(QStringLiteral("白化"), subWrap, page3)); + p3->addRow(formkit::editLabel(QStringLiteral("白化"), page3), subWrap); stack_->addWidget(page3); - Q_UNUSED(kCtrlRatio); - - // 底部按钮:原版 justify-content:space-between,确认(主,左)/取消(右) 各 45%。 - auto* btnLay = new QHBoxLayout(); - btnLay->setSpacing(geopro::app::space::kMd); - okBtn_ = new QPushButton(QStringLiteral("确认"), this); - okBtn_->setDefault(true); - auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); - btnLay->addWidget(okBtn_, 45); - btnLay->addStretch(10); - btnLay->addWidget(cancelBtn, 45); - root->addLayout(btnLay); - - connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); + // 规范 §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()); }); diff --git a/tools/gpr_poc/main.cpp b/tools/gpr_poc/main.cpp index e3afe16..3f21156 100644 --- a/tools/gpr_poc/main.cpp +++ b/tools/gpr_poc/main.cpp @@ -60,6 +60,11 @@ #include #include #include +#include +#include +#include +#include +#include #include #include #include @@ -653,6 +658,98 @@ geopro::core::ColorScale makeColorScale(double vmin, double vmax) { 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; +} + +// 参数化量化域传函:与 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()) { @@ -1761,6 +1858,557 @@ int cmdRenderLOD(int argc, char** argv) { 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; +} + +// ============================================================================ +// ③ view:真窗口可交互(给用户肉眼测 + 最低配机跑)(Task 12d) +// ============================================================================ +// +// 真 vtkRenderWindow + vtkRenderWindowInteractor(TrackballCamera),挂 +// OutOfCoreSource:相机变化时 source.update(camera) 重选 LOD/视野块再渲(确保 +// 拖动/缩放时 LOD 真切换);屏幕左上角 vtkTextActor 实时显示 fps + 当前 level。 +// 默认取景对准局部段 + 默认垂向夸张/不透明度(同 ①)。 +// +// 离屏 smoke:--smoke 时不开真窗口,只离屏建管线 + 渲一帧 + 验非空像素,确保不崩。 + +// view 的每帧回调共享状态(挂到 interactor 的 EndInteraction/Timer/Render 上)。 +struct ViewState { + geopro::render::OutOfCoreSource* src = nullptr; + vtkMultiBlockVolumeMapper* mapper = nullptr; + vtkCamera* cam = nullptr; + vtkTextActor* fpsText = nullptr; + vtkRenderWindow* rw = nullptr; + Stopwatch frameTimer; + double exagg = 8.0; + int lastLevel = -1; +}; + +// 用 source 当前工作集刷新 mapper 输入(每块成 MultiBlock)。返回块数。 +std::size_t viewRefreshBlocks(ViewState* st) { + st->src->update(st->cam); + auto imgs = st->src->currentImages(); + auto mb = makeMultiBlock(imgs); + st->mapper->SetInputDataObject(mb); + st->mapper->Update(); + return imgs.size(); +} + +// interactor 回调:每次交互(旋转/缩放)结束后重选 LOD + 刷新 fps 文本。 +void viewOnInteract(vtkObject*, unsigned long, void* clientData, void*) { + auto* st = static_cast(clientData); + const double frameMs = st->frameTimer.elapsedMs(); + const std::size_t blocks = viewRefreshBlocks(st); + const int lvl = st->src->lastLevel(); + const double fps = frameMs > 0 ? 1000.0 / frameMs : 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(); + st->frameTimer.reset(); +} + +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]\n"; + return 2; + } + const std::string dir = a.positional[0]; + const double exagg = std::stod(a.get("exagg", "8")); + const double opacity = std::stod(a.get("opacity", "0.5")); + const std::size_t budget = + static_cast(std::stoul(a.get("budget", "64"))); + const bool smoke = a.kv.count("smoke") > 0 || + std::find(a.positional.begin(), a.positional.end(), + "--smoke") != a.positional.end(); + std::cout << "[view] storeDir=" << dir << " exagg=" << exagg + << " opacity=" << opacity << " budget=" << budget + << (smoke ? " [SMOKE 离屏]" : " [真窗口交互]") << "\n"; + + const int winW = 1280, winH = 800; + + // 核外源(读 meta + 建 pager,不载整卷)。 + geopro::render::OutOfCoreSource src(dir, budget); + const auto& m = src.meta(); + src.setAspect(static_cast(winW) / winH); + const double vmin = m.vminPhys, vmax = m.vmaxPhys; + const geopro::core::ColorScale cs = makeStructuralColorScale(vmin, vmax); + vtkSmartPointer prop = + makeTunedVolumeProperty(m.quant, cs, vmin, vmax, opacity); + + // 渲染窗口:smoke 走离屏,否则真窗口。 + vtkSmartPointer rw; + if (smoke) { + rw = makeOffscreenWindow(winW, winH); + } else { + rw = vtkSmartPointer::New(); + rw->SetSize(winW, winH); + rw->SetWindowName("gpr_poc view —— 核外 LOD 体绘制 (滚轮缩放切 LOD, 左键旋转)"); + } + + vtkNew ren; + ren->SetBackground(0.04, 0.04, 0.08); + rw->AddRenderer(ren); + + vtkNew mapper; + mapper->SetRequestedRenderMode(vtkSmartVolumeMapper::GPURenderMode); + + auto volume = vtkSmartPointer::New(); + volume->SetMapper(mapper); + volume->SetProperty(prop); + volume->SetScale(1.0, exagg, exagg); // 垂向夸张(同 ①) + 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.src = &src; + st.mapper = mapper.Get(); + st.fpsText = fpsText.Get(); + st.rw = rw.Get(); + st.exagg = exagg; + + // 相机初始定向:先框整体选出工作集,再 ResetCamera 到工作集包围盒(同 renderC)。 + ren->ResetCamera(m.origin[0], m.origin[0] + m.nx * m.spacing[0], + m.origin[1], m.origin[1] + m.ny * m.spacing[1] * exagg, + m.origin[2], m.origin[2] + m.nz * m.spacing[2] * exagg); + st.cam = ren->GetActiveCamera(); + + const std::size_t warm = viewRefreshBlocks(&st); + { + double b[6]; + mapper->GetBounds(b); + if (b[0] <= b[1]) { + // 工作集包围盒需按 exagg 缩放后再框(actor 已 SetScale)。 + ren->ResetCamera(); + } + } + st.cam->Elevation(25.0); + st.cam->Azimuth(25.0); + ren->ResetCameraClippingRange(); + rw->Render(); + + std::cout << "[view] 预热: level=" << src.lastLevel() << " 视野块=" + << src.lastVisibleCount() << "/" << src.lastLevelBrickTotal() + << " 驻留=" << src.residentCount() << " 渲染块=" << warm << "\n"; + + const vtkIdType nonBlack = countNonBlackPixels(rw.Get(), winW, winH); + const bool textureErr = capWin->textureError(); + const bool renderedOk = !textureErr && nonBlack > 0; + + if (smoke) { + // 离屏 smoke:模拟一次缩放 → 验 LOD 切换 + 不崩。 + const int lvlNear = src.lastLevel(); + st.cam->Dolly(0.2); // 拉远 → 期望切粗 LOD + ren->ResetCameraClippingRange(); + const std::size_t blocksFar = viewRefreshBlocks(&st); + const int lvlFar = src.lastLevel(); + rw->Render(); + st.cam->Dolly(8.0); // 拉近 → 期望切细 LOD + ren->ResetCameraClippingRange(); + viewRefreshBlocks(&st); + const int lvlNear2 = src.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); + + vtkNew cb; + cb->SetCallback(viewOnInteract); + cb->SetClientData(&st); + // EndInteraction:旋转/缩放松手后重选 LOD(保证 LOD 真切换 + fps 刷新)。 + iren->AddObserver(vtkCommand::EndInteractionEvent, cb); + // 每帧 Render 后也更新一次 fps 文本(连续拖动时实时反馈)。 + rw->AddObserver(vtkCommand::EndEvent, cb); + + std::cout << "[view] 打开真窗口。左键旋转 / 滚轮缩放(切 LOD) / q 退出。\n"; + st.frameTimer.reset(); + iren->Initialize(); + rw->Render(); + iren->Start(); + + std::cout << "[view] 窗口关闭,退出。\n"; + return 0; +} + void usage() { std::cerr << "gpr_poc —— POC-B headless 度量 CLI\n" " gpr_poc build [--line 001] [--cellXY 0.2] " @@ -1771,7 +2419,13 @@ void usage() { " 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 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] " + "[--budget 64] [--smoke]\n"; } } // namespace @@ -1792,6 +2446,9 @@ int main(int argc, char** 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); } catch (const std::exception& e) { std::cerr << "错误: " << e.what() << "\n"; return 1;