diff --git a/Documents/Code_Review_Plan.md b/Documents/Code_Review_Plan.md index 7c99584..88e4902 100644 --- a/Documents/Code_Review_Plan.md +++ b/Documents/Code_Review_Plan.md @@ -236,3 +236,38 @@ `__USE_MINGW_ANSI_STDIO=1`,命令行关不掉 —— 见 Makefile 注释)。 4. **改面板几何/绘制前后必须跑** `OffscreenChecks -run panels -mode record/compare`; 本轮它当场抓到了"截取面板少了 37px 高度"的回归。 + +--- + +## 八、第 36 轮追加(2026-11-04):§12.7 两项建议的落地 + +> 触发:用户要求「继续按照上轮报告的后续建议推进」。§12.7 列了两项: +> ① `EdgePanelLayout` 平行数组合并;② `PaintEdgeCursor` 拆分。 + +| 节点 | 内容 | 结论 | +| --- | --- | --- | +| **P26** | 拆分 `PaintEdgeCursor` 三段内容绘制 | ✅ 完成(新建 `PlotViewPanelPaint.cpp`) | +| **P27** | 平行数组错位隐患 | ✅ 用 `AppendRow()` 唯一入口消除(**未做**完整结构合并,理由见下) | +| **P28** | 收紧不变式 | ✅ 18 个数组**全部**必须等于行数(不再允许"重采样专属列可为空") | + +### 8.1 本轮新增硬约束 + +1. **新增一种面板行,只能走 `EdgePanelLayout::AppendRow(...)`**。 + 18 个平行数组由它一次补齐;参数行 / 重采样行一律 `rowXxx.back() = …` **赋值**。 + **禁止**在任何布局分支里手写 `rowXxx.push_back(...)`。 +2. **面板内容绘制按排版类别分文件**:骨架(边框/标题栏/✕/按钮)在 `PlotViewPaint.cpp`, + 内容在 `PlotViewPanelPaint.cpp`。新增一种面板 = 加一个 `PanelStyleOf` 分支 + + 在对应内容函数里加一支。 +3. **`OffscreenChecks -run panels` 的长度不变式现在是"全等"判定**: + 任何一列短于行数即失败(这条检查是"绕过 `AppendRow`"的唯一探测器)。 + +### 8.2 为什么没有做「完整合并成 `std::vector` + 列视图」 + +技术上可行(`PanelColumn` 持有 `std::vector*` + 成员指针, +读点只需在 `layout.rowXxx` 后加一对括号),但 **`EdgePanelLayout` 需要自定义拷贝构造/赋值** +来重新绑定视图;而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector`, +拷贝是热路径 —— 拷贝语义一旦写错,会产生**比原缺陷更隐蔽的悬垂视图**(原缺陷至少会被 +长度不变式抓到,悬垂指针不会)。 + +本轮先用 `AppendRow()` 把**真实隐患**("某分支漏推一列")消除, +完整结构合并留待"逐行几何基线"检查就绪后再做。 diff --git a/Documents/Code_Review_Progress.md b/Documents/Code_Review_Progress.md index dfe1e5f..2d1432e 100644 --- a/Documents/Code_Review_Progress.md +++ b/Documents/Code_Review_Progress.md @@ -347,3 +347,66 @@ Makefile 按目录通配收集源文件,**无需登记新文件**。 **本轮未做**:与 P22 抢时间,且属纯可读性收益。 3. **给平行数组补检查**:在 `panels` 里增加"每个 row 的 17 项长度一致"断言 (成本极低,可作为第 1 项的前置护栏)—— 已记入待办。 + +--- + +## 第 36 轮(2026-11-04):§12.7 两项建议落地 + +### P26 拆分 `PaintEdgeCursor` —— ✅ 完成(提交 `38fbd4b`) + +* 新增 `Components/PlotView/Src/PlotViewPanelPaint.cpp`(1016 行,**机械搬移、零逻辑改动**): + * `PaintPanelCountTableRows` —— 边沿计数表格(固定表头 + 可滚动行); + * `PaintPanelListRows` —— 列表式(均值/中心轴/弱峰/重采样/截取)条目栏 + 参数行; + * `PaintPanelMarkerGroups` —— 自由标记(分组栏 + 表头 + 条目 + 吸顶)。 + 每个函数只接收它真正需要的参数(`layout` / `tabLane` / `kind` / `laneIndex`), + 不再"整个函数都看得见"。 +* `PlotViewPaint.cpp`:6554 → 5575 行(`PaintEdgeCursor` 由约 1900 行降到约 900 行); + 三处块替换为一行调用,骨架(边框/标题栏/折起三角/GX 徽标/✕)与底部按钮仍在一处。 +* 滚动条绘制抽为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`。 +* **顺带补上第 35 轮承诺未落地的一处**:`PaintEdgeCursor` 的标题仍是 7 层三元式 + (与 `EdgePanelWidth` 里另一条链并存,后者漏了 MARK/CLIP)⇒ 统一为 `PanelTitle(kind)`。 + +**过程中"机械搬移"踩到的三个真实问题**(都已修) +1. 提取块里引用的 `paintCounts` / `paintPanelScrollBar` 是**局部 lambda** ⇒ + 前者改为新函数内的局部 lambda(只服务于该段)、后者提升为 pvdetail 内联函数。 +2. 「标记透明度」滑块用到的 `panel` / `pad` 是外层局部变量 ⇒ 改为从 `layout.panel` 派生。 +3. `tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ 参数必须非 const。 + +### P27 平行数组错位隐患 —— ✅ 用 `AppendRow()` 消除(提交 `85aaed5`) + +审计发现 `EdgePanelLayout` 每行对应 **18 个平行 vector**,而**四个**布局分支各自手写 push: +* 边沿计数表格:13 次 push; +* 列表式基行:13 次 push; +* 「未勾选/收起」分支:又补 6(+重采样 4)次空 push; +* 「重采样展开」/「普通展开」分支:各自再 push 6(+4)次。 +任何一处漏推 ⇒ 该列之后所有行读到**别人的**矩形。 + +改法: +* 新增 `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)` —— **唯一入口**, + 18 个数组一次补齐(其余列为空矩形)。 +* 三个 push 块 → 各 **1 次 `AppendRow`**;参数行/重采样行改为 `rowXxx.back() = …` **赋值**; + 「未勾选/收起」分支的 6+4 次补空 push **整段删除**(AppendRow 已补过)。 +* ⇒ "新增一列"只需改 `AppendRow` 一处;"新增一行"必须走它(结构上不会漏列)。 + +⚠️ **没做完整结构合并**(`std::vector` + 列视图),理由: +列视图需要 `EdgePanelLayout` 自定义拷贝构造/赋值来重绑视图,而该类型是 +`EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— 拷贝是热路径, +拷贝语义写错会产生**比原缺陷更隐蔽的悬垂视图**(原缺陷至少会被长度不变式抓到)。 +先消除真实隐患,结构合并留待"逐行几何基线"检查就绪。 + +### P28 收紧不变式 + +`panels` / `panelscroll` 里的长度检查从"13 个必须等长 + 4 个可空"改为 +**18 个全部必须等于行数** —— `AppendRow()` 成为唯一入口后,"某列短了" +就是"有人绕过它手写 push"的直接证据。 + +### 本轮验证 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;产物 786,432 → **785,408 B** | +| `make -C Tools` | 零 error / 零 warning | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 6/6 PASS,`checks=101 failed=0`(断言合并后略减) | +| `panels -mode compare` | 7 个面板像素哈希 + 几何**逐项不变**(两次重构全程守住) | +| `panelscroll`(MF4,566 行) | 18 个行数组长度全部 = 566,行为不变 | diff --git a/Documents/Code_Review_Report.md b/Documents/Code_Review_Report.md index dbd90c8..007982d 100644 --- a/Documents/Code_Review_Report.md +++ b/Documents/Code_Review_Report.md @@ -480,3 +480,78 @@ Tools/README.md 目录结构、作用、测试项、验收标准、A/B 机械提取为 3 个成员函数,判据 = `panels -mode compare` 全绿。 3. **`EdgeScanFor` 状态查找**:维持线性扫描(第 34 轮实测 198 状态 0.097 µs/次, 哈希化需 +5,632 B,不划算)。 + +--- + +## 13. 第 36 轮(2026-11-04):§12.7 两项建议的落地结果 + +> 计划 `Code_Review_Plan.md` §八,逐节点 `Code_Review_Progress.md` 第 36 轮。 + +### 13.1 一句话结论 + +§12.7 的两项都做完了,并且**都用像素基线证明是纯结构改动**: +`PaintEdgeCursor` 从约 1900 行降到约 900 行(内容绘制拆到 `PlotViewPanelPaint.cpp` 1016 行), +平行数组的"漏推一列"隐患用 `AppendRow()` 唯一入口消除。 +产物 786,432 → **785,408 B**(−1,024),7 个面板的像素哈希与几何**逐项不变**。 + +| §12.7 建议 | 结果 | +| --- | --- | +| 1. `EdgePanelLayout` 平行数组隐患 | ✅ 用 `AppendRow()` 消除真实隐患;**完整结构合并**按其风险(悬垂视图)暂不做,理由见 §13.3 | +| 2. 拆分 `PaintEdgeCursor` | ✅ 完成,新建 `PlotViewPanelPaint.cpp` | + +### 13.2 拆分后的文件职责 + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 5575 | 面板**骨架**(边框 / 标题栏 / 折起三角 / GX 徽标 / ✕)+ 底部按钮 + 其余全部绘制 | +| `PlotViewPanelPaint.cpp` | 1016 | 面板**内容**:`PaintPanelCountTableRows` / `PaintPanelListRows` / `PaintPanelMarkerGroups` | + +⚠️ 骨架与内容的划分依据:**对所有面板都是同一份**的留在骨架; +**按排版类别不同**的进内容函数。新增一种面板 = `PanelStyleOf` 加一支 + +在对应内容函数里加一支(不会碰到骨架)。 + +### 13.3 为什么没做"完整合并成 `std::vector`" + +技术上可行:`PanelColumn` 持有 `std::vector*` + 成员指针, +读点只需 `layout.rowXxx` → `layout.rowXxx()`(纯正则,314 处可信)。 + +**但**:`EdgePanelLayout` 必须自定义拷贝构造 / 拷贝赋值来重绑视图, +而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— +拷贝在热路径上。视图一旦在拷贝后忘记重绑,行为是**悬垂指针**: +可能读出垃圾矩形、也可能"看起来正常"直到某次重排才崩。 + +对比两个方案的**失败可见性**: +* 现在的平行数组 + `AppendRow()`:漏一列 ⇒ **长度不变式当场报错**; +* 列视图:忘了重绑 ⇒ **没有检查能抓到**。 + +因此本轮只做"消除真实隐患"(`AppendRow`),完整合并推迟到 +"逐行几何基线"(把每行 18 个矩形都写进基线文件、逐项比对)就绪之后。 + +### 13.4 本轮"机械搬移"踩到的三个真问题(值得记住) + +1. **局部 lambda 无法跨函数复用**:`paintCounts` 只服务列表段 ⇒ 下沉为该函数的局部 lambda; + `paintPanelScrollBar` 服务三段 ⇒ 提升为 `pvdetail::PaintPanelScrollBar` 内联函数 + (参数显式化,不再隐式捕获 `m_theme`)。 +2. **提取块依赖外层局部变量**:「标记透明度」滑块用 `panel` / `pad` ⇒ 改为从 `layout.panel` 派生。 + 这类隐式依赖正是"整个函数都看得见"的害处,提取时必须逐个查。 +3. **const 正确性**:`tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ + 参数不能是 `const std::vector&`。提取时"看起来很只读"的参数要核对写入点。 + +### 13.5 验证与现状 + +| 项 | 结果 | +| --- | --- | +| `make release`(全量) | 零 error / 零 warning;**785,408 B**(距 1 MB 预警线 ≈260 KB) | +| `UnitTests` | `checks=260 failed=0 cases=19` | +| `OffscreenChecks -all` | 6/6 PASS,`checks=101 failed=0` | +| `panels -mode compare` | 7 个面板像素哈希 + 几何逐项不变 | +| `panelscroll`(566 行) | 18 个行数组全部等于 566 | + +### 13.6 后续建议(更新) + +1. **逐行几何基线**:把每行 18 个矩形写进 `Temp/tools/rows-baseline.txt`,逐项比对。 + 这是"完整结构合并"与"重写布局算式"的前置护栏,成本低(导出 + 字符串比对)。 +2. **`rowPoints` / `rowIterations` 已无绘制方使用**(旧式输入框排版已删除): + 可在 `AppendRow` 里保留但标注"仅为兼容保留",或在下一轮与文档一起清掉。 +3. **`PaintPanelListRows` 仍约 470 行**:可再按"条目栏 / 参数行 / 重采样行"拆三段, + 但当前已不在"改一次就出错"的路径上,优先级低于第 1 项。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index d43deba..91122c6 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -10499,3 +10499,68 @@ release/debug **零警告**;Release `TargetCheck.exe` **890,880 B**(< 1 MB 是"改布局就出错"的最后一块机制隐患;已先用长度检查兜住。 * `PaintEdgeCursor`(约 1600 行)把三段内容绘制机械提取为 3 个成员函数, 判据 = `panels -mode compare` 全绿。 + +--- + +## 2026-11-04 · 第 36 轮(`Code_Review` 分支):§12.7 两项建议落地 + +> 提交:`38fbd4b`(拆分 PaintEdgeCursor)、`85aaed5`(AppendRow 唯一入口)、本提交(不变式收紧+文档)。 +> 规则原文:`Documents/agent_mem.md` 第 36 轮、`Code_Review_Report.md` §13。 + +### 1. ✅ `PaintEdgeCursor` 已拆分(1900 → 约 900 行) + +| 文件 | 行数 | 职责 | +| --- | --- | --- | +| `PlotViewPaint.cpp` | 5575 | 面板**骨架**(边框/标题栏/折起三角/GX 徽标/✕)+ 底部按钮 + 其余绘制 | +| `PlotViewPanelPaint.cpp` | 1016 | 面板**内容**:`PaintPanelCountTableRows` / `PaintPanelListRows` / `PaintPanelMarkerGroups` | + +划分依据:**对所有面板都是同一份**的留骨架;**按排版类别不同**的进内容函数。 +新增一种面板 = `PanelStyleOf` 加一支 + 对应内容函数加一支(不碰骨架)。 +滚动条外观统一为 `pvdetail::PaintPanelScrollBar(g, layout, theme)`。 + +### 2. ✅ 平行数组错位隐患用 `AppendRow()` 消除 + +**新硬规则**:新增面板行**只能**走 `EdgePanelLayout::AppendRow(ref, header, nameRect, triangle, check)` +(18 个平行数组一次补齐);参数行 / 重采样行一律 `rowXxx.back() = …` **赋值**。 +**禁止**在任何布局分支里手写 `rowXxx.push_back(...)`。 +⇒ "新增一列"只需改 `AppendRow` 一处;"新增一行"必须走它(结构上不会漏列)。 + +改前有 **4 个**地方各自手写 push(表格 13 次 / 列表基行 13 次 / 未勾选分支 6+4 次空 push / +展开分支 6+4 次),任何一处漏推都会让该列之后的所有行读到别人的矩形。 + +### 3. ⚠️ 为什么**不**把平行数组合并成 `std::vector` + 列视图 + +技术上可行(读点只需正则加括号),但 `EdgePanelLayout` 必须自定义拷贝构造/赋值来重绑视图, +而它是 `EdgePanelLayoutFor()` 的返回值、又被存进 `std::vector` —— 拷贝是热路径。 +**失败可见性对比**: +* 现在(平行数组 + AppendRow):漏一列 ⇒ **长度不变式当场报错**; +* 列视图:忘了重绑 ⇒ **悬垂指针,没有检查能抓到**。 +⇒ 先消除真实隐患,完整合并推迟到"逐行几何基线"就绪后。 + +### 4. ✅ 不变式收紧为"全等" + +`panels` / `panelscroll` 的长度检查从"13 个等长 + 4 个可空"改为 +**18 个全部必须等于行数** —— `AppendRow()` 成为唯一入口后, +"某列短了"就是"有人绕过它"的直接证据。 + +### 5. ⚠️ 机械搬移踩到的三个真问题(提取代码时必查) + +1. **局部 lambda 不能跨函数复用**:只服务一段的下沉为该函数局部;多段共用的提升为 + pvdetail 内联函数(参数显式化)。 +2. **提取块会隐式依赖外层局部变量**(如「标记透明度」滑块的 `panel` / `pad`)⇒ + 改为从 `layout` 派生。这是"整个函数都看得见"的害处。 +3. **const 正确性**:`tabLanes` 在 marker 段里是**被写**的(回写各列实测宽度)⇒ + "看起来只读"的参数要核对写入点。 + +### 6. 本轮数字 + +* Release **785,408 B**(786,432 → −1,024);零 warning。 +* `UnitTests` 260 断言 / 19 用例;`OffscreenChecks -all` 6 检查 / 101 断言;全部 PASS。 +* `panels -mode compare` 7 个面板像素哈希 + 几何**逐项不变**(两次重构全程守住)。 +* `panelscroll`(MF4 566 行)18 个行数组全部 = 566。 + +### 7. 遗留(下一步候选) + +* **逐行几何基线**(导出每行 18 个矩形做逐项比对)—— 完整结构合并与布局算式重写的前置护栏。 +* `rowPoints` / `rowIterations` 已无绘制方使用(旧式输入框排版早已删除),可标注/清理。 +* `PaintPanelListRows` 仍约 470 行,可再拆三段,但已不在"改一次就出错"的路径上。 diff --git a/Tools/OffscreenChecks/Src/Checks.cpp b/Tools/OffscreenChecks/Src/Checks.cpp index 860c684..f7ded39 100644 --- a/Tools/OffscreenChecks/Src/Checks.cpp +++ b/Tools/OffscreenChecks/Src/Checks.cpp @@ -418,22 +418,23 @@ namespace jdi Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); // 不变式:`EdgePanelLayout` 的**平行数组长度必须全部等于行数**。 - // 这 13 个按行平行的 vector 每个分支都要按同一顺序 push 恰好 13 项, - // 漏一项就会让某一列读到别的行的矩形("改布局就出错"的典型机制)。 - // ⚠️ 「重采样」专属的 4 个数组(最大/最小间隔、间隔/频率输入框)在**其它** - // 面板里允许为空(没人读),但只要非空就必须同样与行数对齐。 + // 这 18 个按行平行的 vector 现在由 `AppendRow()` 一次补齐 + //(新增行的唯一入口),因此**不允许**任何一列短于行数 —— + // 短了就说明有分支绕过 `AppendRow` 手写 push("某列读到别人矩形"的根因)。 // 成本极低(O(1) 比长度),但能挡住整类错位缺陷。 const std::size_t rowCount = layout.rowRefs.size(); - const std::size_t coreSizes[] = { - layout.rowRefs.size(), layout.rowHeaders.size(), - layout.rowNameRects.size(), layout.rowTriangles.size(), - layout.rowChecks.size(), layout.rowPoints.size(), - layout.rowIterations.size(), layout.rowStats.size(), - layout.rowLevelTracks.size(), layout.rowLevelValues.size(), - layout.rowLevelResets.size(), layout.rowIterTracks.size(), - layout.rowIterValues.size(), layout.rowIterResets.size()}; + const std::size_t parallelSizes[] = { + layout.rowRefs.size(), layout.rowHeaders.size(), + layout.rowNameRects.size(), layout.rowTriangles.size(), + layout.rowChecks.size(), layout.rowPoints.size(), + layout.rowIterations.size(), layout.rowStats.size(), + layout.rowLevelTracks.size(), layout.rowLevelValues.size(), + layout.rowLevelResets.size(), layout.rowIterTracks.size(), + layout.rowIterValues.size(), layout.rowIterResets.size(), + layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), + layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; bool parallel = true; - for (const std::size_t size : coreSizes) + for (const std::size_t size : parallelSizes) { if (size != rowCount) { @@ -441,19 +442,7 @@ namespace jdi break; } } - const std::size_t resampleSizes[] = { - layout.rowInfoMaxInterval.size(), layout.rowInfoMinInterval.size(), - layout.rowIntervalField.size(), layout.rowFrequencyField.size()}; - bool resampleParallel = true; - for (const std::size_t size : resampleSizes) - { - if (size != 0 && size != rowCount) - { - resampleParallel = false; - break; - } - } - if (!parallel || !resampleParallel) + if (!parallel) { EmitLine("[panels] %s row arrays: refs=%d hdr=%d name=%d tri=%d chk=%d pts=%d it=%d " "stat=%d lvl=%d/%d/%d iter=%d/%d/%d info=%d/%d fld=%d/%d\n", @@ -476,9 +465,7 @@ namespace jdi static_cast(layout.rowIntervalField.size()), static_cast(layout.rowFrequencyField.size())); } - Expect(parallel, std::string("row arrays have identical lengths: ") + PanelKindName(kind)); - Expect(resampleParallel, - std::string("resample row arrays empty-or-aligned: ") + PanelKindName(kind)); + Expect(parallel, std::string("all 18 row arrays match row count: ") + PanelKindName(kind)); char buffer[160] = {}; std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), @@ -605,7 +592,8 @@ namespace jdi initial.scrollTrack.Width > 0.0f ? 1 : 0, static_cast(initial.rowRefs.size())); - // 多信号场景下额外守住"平行数组长度一致"(本轮新增,成本 O(1))。 + // 多信号场景下额外守住"18 个平行数组长度全部等于行数" + //(`AppendRow()` 是新增行的唯一入口 ⇒ 任何一列短了就是绕过它的证据)。 { const std::size_t rows = initial.rowRefs.size(); const std::size_t sizes[] = {initial.rowHeaders.size(), initial.rowNameRects.size(), @@ -621,14 +609,13 @@ namespace jdi bool aligned = true; for (const std::size_t size : sizes) { - // 「重采样」专属数组在其它面板允许为空(见 `panels` 检查的说明)。 - if (size != rows && size != 0) + if (size != rows) { aligned = false; break; } } - Expect(aligned, std::string("row arrays aligned with rows: ") + name); + Expect(aligned, std::string("all 18 row arrays match row count: ") + name); } if (maxScroll <= 1.0f) diff --git a/Tools/README.md b/Tools/README.md index 27ca1ac..1f0ca9e 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -171,8 +171,8 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list | --- | --- | --- | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | -| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**平行数组长度与行数一致**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | -| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住平行数组一致性 | +| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**18 个行数组长度全部等于行数**;`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | +| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住 18 个行数组与行数完全一致 | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 |