Code Review :代码审查 + 产物体积 −12.2%(890,880 → 782,336 B)+ 面板结构收敛 + 离屏验证工具入库 #3

Merged
LuChiChick merged 22 commits from Code_Review into dev 2026-11-04 09:54:20 +00:00
11 changed files with 672 additions and 591 deletions
Showing only changes of commit 1463f9d3f8 - Show all commits

No files matched your search

+101 -86
View File
@@ -1999,7 +1999,14 @@ namespace jdi
gd::RectF badge; gd::RectF badge;
gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */
gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */
int rows = 0; /**< 统计行数(每条可见信号一行)。 */ /**
* @brief 该面板统计的**可见信号数**(不是行数!行容器见 `rows`)。
*
* 它来自“网格里的可见信号数”(`EdgePanelLayoutFor` 开头统计),
* 与 `rows.size()` 在**「自由标记」面板里不相等**(那里只有至少有一个固定点的
* 信号才有一行)。历史名字叫 `rows`,与新的行容器同名,故改名 `signalCount`。
*/
int signalCount = 0;
/** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */ /** @brief 表格的一列(左边界 + 宽度,客户区坐标)。 */
struct TableColumn struct TableColumn
@@ -2018,60 +2025,66 @@ namespace jdi
/** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */ /** @brief 表格三列的几何(`0` 信号名 / `1` 上升沿计数 / `2` 下降沿计数)。 */
std::array<TableColumn, 3> tableColumns{}; std::array<TableColumn, 3> tableColumns{};
/** /**
* @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 * @brief 面板里的一**行**:该行对应的信号 + 它的全部几何。
* *
* 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): * ⚠️⚠️ 2026-11-04(第 39 轮)把原来的 **16 个平行 `std::vector`** 合并成
* `rowChecks` 为第一排右侧的复选框;`rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 * `std::vector<PanelRow> rows`。合并的动机不是"省字节",而是消除三类**结构性**缺陷:
* 未勾选的条目对应的矩形均为空。
* *
* ⚠️ 2026-11-04 删除了 `rowPoints` / `rowIterations`:它们是**旧式输入框**(点数 / 迭代次数) * 1. **漏推一列 ⇒ 该列之后所有行读到别人的矩形**。平行数组靠"每个分支按同一顺序
* 的矩形,自滑块版排版(`rowLevelTracks` / `rowIterTracks`)上线后就没有绘制方读它们了 —— * push 恰好 N 项"来维持,任何一处漏推都是静默错位(历史上"改一次布局就出错"的机制)。
* 所有写入都是空矩形,读取方只剩调试框与缓存键。保留它们只会让每次 `AppendRow` * 现在一个 `PanelRow` 是一个整体,`rows.push_back(row)` 一次成型 —— **不可能错位**。
* 多两次 `push_back`、并且误导后续读者以为“这里还有一套输入框”。 * 2. **`rowRefs` 字段重载**:旧结构里「自由标记」面板借用 `rowRefs` 表示"信号分组",
* 其余 12 列必须保持为空,长度不变式只能按面板类别分流判定。现在每行自带
* `ref`,语义由**填哪些字段**表达,不再有"同一字段两种含义"。
* 3. 旧结构每实例 16 个 vector 头部(16 × 24 B)且各自独立分配;现在一次分配。
*
* ⚠️ **不要**改回平行数组,也不要引入"列视图"(`PanelColumn<T>` 持有 `rows` 指针 +
* 成员指针):那样拷贝 `EdgePanelLayout` 后忘记重绑就是**静默悬垂**,
* 没有任何检查能抓到;具名成员访问(`rows[i].check`)在结构上不可能悬垂。
*
* 字段布局(每一行都拥有全部字段,未使用的**恒为空矩形**,不需要"数组可能为空"的判空):
*
* | 字段 | 含义 |
* | --- | --- |
* | `ref` | 该行的信号(「自由标记」里是**分组**的代表信号) |
* | `header` | 信号色叠底条目栏 / 分组栏(整行宽度) |
* | `nameRect` | 名称区(列表式 = 条目栏;自由标记 = 分组栏;表格 = 名称列) |
* | `nameClose` | 名称右侧 ✕(仅「自由标记」:清除该信号全部固定点) |
* | `triangle` | 左侧展开 / 收起三角(仅勾选后出现) |
* | `check` | 右端复选框 |
* | `stats` | 「中心轴边沿计数」的上升 / 下降沿统计行 |
* | `levelTrack` / `levelValue` / `levelReset` | 参数滑块 1(均值点值 / 弱峰增强等级) |
* | `iterTrack` / `iterValue` / `iterReset` | 参数滑块 2(均值迭代次数 / 弱峰迭代次数) |
* | `infoMaxInterval` / `infoMinInterval` | 「重采样」两行只读统计 |
* | `intervalField` / `frequencyField` | 「重采样」两行联动输入框(间隔秒 / 频率 Hz) |
*/ */
std::vector<gd::RectF> rowChecks; struct PanelRow
std::vector<gd::RectF> rowStats; {
SignalRef ref; /**< 该行的信号(MARK 面板 = 分组的代表信号)。 */
gd::RectF header; /**< 信号色叠底条目栏 / 分组栏(整行宽度)。 */
gd::RectF nameRect; /**< 名称区(列表式条目栏 / 分组栏 / 表格名称列)。 */
gd::RectF nameClose; /**< 名称右侧 ✕(仅「自由标记」)。 */
gd::RectF triangle; /**< 左侧展开 / 收起三角。 */
gd::RectF check; /**< 右端复选框。 */
gd::RectF stats; /**< 「中心轴」上升 / 下降沿统计行。 */
gd::RectF levelTrack; /**< 参数滑块 1 轨道。 */
gd::RectF levelValue; /**< 参数滑块 1 数值文本。 */
gd::RectF levelReset; /**< 参数滑块 1 复位按钮。 */
gd::RectF iterTrack; /**< 参数滑块 2 轨道。 */
gd::RectF iterValue; /**< 参数滑块 2 数值文本。 */
gd::RectF iterReset; /**< 参数滑块 2 复位按钮。 */
gd::RectF infoMaxInterval; /**< 「重采样」最大数据间隔(只读)。 */
gd::RectF infoMinInterval; /**< 「重采样」最小数据间隔(只读)。 */
gd::RectF intervalField; /**< 「重采样」间隔输入框。 */
gd::RectF frequencyField; /**< 「重采样」频率输入框。 */
};
/** /**
* @brief 「弱峰调整」每个被勾选信号自己的两行**滑块**(与 `rowRefs` 同序)。 * @brief 面板的全部行(**唯一的**行容器:顺序 = 从上到下)。
* *
* `rowLevelTracks/rowLevelValues` = 增强等级滑块与它右侧的数值文本; * ⚠️ 只有 `AppendRow` / `AppendMarkerGroupRow` 可以追加,不要 `rows.push_back`
* `rowIterTracks/rowIterValues` = 迭代次数滑块与它右侧的数值文本 * (那会绕过"字段一次成型"的保证)。
*(**点值数值文本可以把滑块上限改为别的正整数**)。
* 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。
*/ */
std::vector<gd::RectF> rowLevelTracks; std::vector<PanelRow> rows;
std::vector<gd::RectF> rowLevelValues;
std::vector<gd::RectF> rowIterTracks;
std::vector<gd::RectF> rowIterValues;
/**
* @brief 「弱峰调整」每行滑块右侧的**复位按钮**矩形(与 `rowLevelTracks` 同序)。
*
* 点击把该行参数恢复为默认值(幅度 0 / 迭代次数 1 且上限回到初始上限)。
* 未勾选或非 PEAK 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowLevelResets;
std::vector<gd::RectF> rowIterResets;
/**
* @brief 「重采样」面板每个被勾选信号的四行内容(与 `rowRefs` 同序)。
*
* `rowInfoMaxInterval` / `rowInfoMinInterval` = 只读统计(最大 / 最小数据间隔);
* `rowIntervalField` / `rowFrequencyField` = 两行**联动输入框**(间隔秒 / 频率 Hz)。
* 未勾选或非 RESAMPLE 面板时对应元素为空矩形(数组也可能为空)。
*/
std::vector<gd::RectF> rowInfoMaxInterval;
std::vector<gd::RectF> rowInfoMinInterval;
std::vector<gd::RectF> rowIntervalField;
std::vector<gd::RectF> rowFrequencyField;
/**
* @brief 「均值滤波」面板每个信号的**信号色叠底标题栏**矩形(与 `rowRefs` 同序)。
*
* 排版与「自由标记」面板的信号分组栏一致(左侧展开三角 + 信号名),
* 但右端是**复选框**(`rowChecks`)而不是 ✕。未勾选时标题栏仍显示(用于勾选)。
*/
std::vector<gd::RectF> rowHeaders;
/** @brief 「均值滤波」面板每个信号标题栏左侧的**展开 / 收起三角**(仅勾选后出现)。 */
std::vector<gd::RectF> rowTriangles;
std::vector<SignalRef> rowRefs;
/** /**
* @brief 「自由标记」面板的一行(一个固定点)。 * @brief 「自由标记」面板的一行(一个固定点)。
@@ -2174,18 +2187,6 @@ namespace jdi
gd::RectF clipDestructiveButton; gd::RectF clipDestructiveButton;
/** @brief 「选区截取」需要双光标时的提示文案矩形(未启用双光标时使用)。 */ /** @brief 「选区截取」需要双光标时的提示文案矩形(未启用双光标时使用)。 */
gd::RectF clipHint; gd::RectF clipHint;
/**
* @brief 「自由标记」:每个信号名右侧的「清除该信号全部固定点」✕。
*
* 与 `rowRefs` 一一对应;该信号没有任何固定点时为空矩形。
*/
std::vector<gd::RectF> rowNameCloses;
/**
* @brief 「自由标记」:每个信号**名称行**的矩形(与 `rowRefs` 一一对应)。
*
* 绘制只读这个矩形(与 `rowNameCloses` 同一套横坐标),人为主观反推容易与布局漂移。
*/
std::vector<gd::RectF> rowNameRects;
/** /**
* @brief 「自由标记」面板的**六列**文本区 * @brief 「自由标记」面板的**六列**文本区
* (① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)。 * (① 序号 ② 标记值 ③ 枚举文本 ④ 增量 ⑤ 时间点 ⑥ 时间间隔)。
@@ -2215,40 +2216,54 @@ namespace jdi
}; };
/** /**
* @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余列一律补空矩形。 * @brief 追加一**行**(条目栏 / 名称 / 三角 / 复选框),其余字段一律留空。
* *
* ⚠️⚠️ 这是**唯一**允许新增行的入口。面板每行对应 16 个平行数组 * ⚠️⚠️ 这是**唯一**允许新增列表式行的入口(「自由标记」的分组行走
* (`rowRefs` / `rowHeaders` / `rowChecks` / `rowLevelTracks` / …); * `AppendMarkerGroupRow`)。一个 `PanelRow` 是一个整体,字段一次成型,
* 如果每个排版分支各写一遍 `push_back`,只要某分支漏推一项, * 因此**不可能**出现"某列漏推 ⇒ 之后所有行错位"(旧平行数组结构的老毛病)。
* 那一列之后的所有行都会**读到别人的矩形**("改一次布局就出错"的典型机制)。 * 参数行 / 重采样行一律用 `rows.back().xxx = …` **赋值**。
* 现在:新增行的分支只调本函数,**新增一列只需改这里一处**;
* 参数行 / 重采样行则用 `rowXxx.back() = …` 赋值(长度天然对齐)。
* *
* @param [IN] ref 该行对应的信号。 * @param [IN] ref 该行对应的信号。
* @param [IN] header 信号色叠底条目栏(整行宽度)。 * @param [IN] header 信号色叠底条目栏(整行宽度)。
* @param [IN] nameRect 名称(表格里的名称列)。 * @param [IN] nameRect 名称(表格里的名称列)。
* @param [IN] triangle 左侧展开 / 收起三角(不适用时传空矩形)。 * @param [IN] triangle 左侧展开 / 收起三角(不适用时传空矩形)。
* @param [IN] check 右端复选框。 * @param [IN] check 右端复选框。
* @return 新行的引用(调用方可直接赋值参数行)。
*/ */
void AppendRow(const SignalRef& ref, const gd::RectF& header, const gd::RectF& nameRect, PanelRow& AppendRow(const SignalRef& ref, const gd::RectF& header,
const gd::RectF& triangle, const gd::RectF& check) const gd::RectF& nameRect, const gd::RectF& triangle,
const gd::RectF& check)
{ {
rowRefs.push_back(ref); PanelRow row;
rowHeaders.push_back(header); row.ref = ref;
rowNameRects.push_back(nameRect); row.header = header;
rowTriangles.push_back(triangle); row.nameRect = nameRect;
rowChecks.push_back(check); row.triangle = triangle;
rowStats.push_back(gd::RectF()); row.check = check;
rowLevelTracks.push_back(gd::RectF()); rows.push_back(row);
rowLevelValues.push_back(gd::RectF()); return rows.back();
rowLevelResets.push_back(gd::RectF()); }
rowIterTracks.push_back(gd::RectF());
rowIterValues.push_back(gd::RectF()); /**
rowIterResets.push_back(gd::RectF()); * @brief 追加一个**「自由标记」信号分组行**(只有名称与 ✕ 有几何)。
rowInfoMaxInterval.push_back(gd::RectF()); *
rowInfoMinInterval.push_back(gd::RectF()); * 单独成一个入口(而不是复用 `AppendRow`)是因为语义不同:这里的 `ref` 是
rowIntervalField.push_back(gd::RectF()); * **分组**的代表信号,`nameRect` / `nameClose` 是分组栏与它右端的 ✕;
rowFrequencyField.push_back(gd::RectF()); * `header` / `triangle` / `check` 都不使用(分组栏自己的几何在
* `markerGroups` 里,那里还需要 `tableHeader` / `pinned` 等行外状态)。
*
* @param [IN] ref 该分组的代表信号。
* @param [IN] nameRect 分组栏矩形。
* @param [IN] nameClose 分组栏右端 ✕ 的命中矩形。
*/
void AppendMarkerGroupRow(const SignalRef& ref, const gd::RectF& nameRect,
const gd::RectF& nameClose)
{
PanelRow row;
row.ref = ref;
row.nameRect = nameRect;
row.nameClose = nameClose;
rows.push_back(row);
} }
}; };
+102 -135
View File
@@ -1127,36 +1127,36 @@ namespace jdi
Tab().Lanes()[static_cast<std::size_t>(panelLane)]; Tab().Lanes()[static_cast<std::size_t>(panelLane)];
if (listCursor) if (listCursor)
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowHeaders.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (layout.rowHeaders[rowIndex].Width > 0.0f && if (layout.rows[rowIndex].header.Width > 0.0f &&
UiPointInRect(layout.rowHeaders[rowIndex], static_cast<float>(x), UiPointInRect(layout.rows[rowIndex].header, static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
bool checked = false; bool checked = false;
if (cursorKind == EdgePanelKind::PEAK) if (cursorKind == EdgePanelKind::PEAK)
{ {
checked = cursorLane.FindWeakPeakItem( checked = cursorLane.FindWeakPeakItem(
layout.rowRefs[rowIndex]) != nullptr; layout.rows[rowIndex].ref) != nullptr;
} }
else if (cursorKind == EdgePanelKind::CENTER) else if (cursorKind == EdgePanelKind::CENTER)
{ {
checked = cursorLane.FindEdgeCenterItem( checked = cursorLane.FindEdgeCenterItem(
layout.rowRefs[rowIndex]) != nullptr; layout.rows[rowIndex].ref) != nullptr;
} }
else if (cursorKind == EdgePanelKind::RESAMPLE) else if (cursorKind == EdgePanelKind::RESAMPLE)
{ {
checked = cursorLane.FindResampleItem( checked = cursorLane.FindResampleItem(
layout.rowRefs[rowIndex]) != nullptr; layout.rows[rowIndex].ref) != nullptr;
} }
else if (cursorKind == EdgePanelKind::CLIP) else if (cursorKind == EdgePanelKind::CLIP)
{ {
checked = cursorLane.ClipEnabled(layout.rowRefs[rowIndex]); checked = cursorLane.ClipEnabled(layout.rows[rowIndex].ref);
} }
else else
{ {
checked = cursorLane.FindMeanFilterItem( checked = cursorLane.FindMeanFilterItem(
layout.rowRefs[rowIndex]) != nullptr; layout.rows[rowIndex].ref) != nullptr;
} }
// ⚠️⚠️ **「选区截取」的条目栏不可展开**(只有右侧复选框有效): // ⚠️⚠️ **「选区截取」的条目栏不可展开**(只有右侧复选框有效):
// 因此条目栏本体**绝不能**给手型 —— 否则会误导用户去点它 // 因此条目栏本体**绝不能**给手型 —— 否则会误导用户去点它
@@ -1174,14 +1174,12 @@ namespace jdi
if (cursorKind == EdgePanelKind::RESAMPLE) if (cursorKind == EdgePanelKind::RESAMPLE)
{ {
// 两行输入框是可点击控件(点击进入就地编辑)。 // 两行输入框是可点击控件(点击进入就地编辑)。
if (rowIndex < layout.rowIntervalField.size() && if (UiPointInRect(layout.rows[rowIndex].intervalField, static_cast<float>(x),
UiPointInRect(layout.rowIntervalField[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
if (rowIndex < layout.rowFrequencyField.size() && if (UiPointInRect(layout.rows[rowIndex].frequencyField, static_cast<float>(x),
UiPointInRect(layout.rowFrequencyField[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
@@ -1189,50 +1187,43 @@ namespace jdi
continue; continue;
} }
const bool hasParams = const bool hasParams =
rowIndex < layout.rowLevelTracks.size() && layout.rows[rowIndex].levelTrack.Width > 0.0f;
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams) if (!hasParams)
{ {
continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件 continue; // 参数行未展开(未勾选 / 已收起)⇒ 该行没有可交互控件
} }
// 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。 // 「均值点数 / 调整幅度」数值:均值滤波与中心轴可点击改上限(弱峰不能)。
if (cursorKind != EdgePanelKind::PEAK && if (cursorKind != EdgePanelKind::PEAK &&
rowIndex < layout.rowLevelValues.size() && layout.rows[rowIndex].levelValue.Width > 0.0f &&
layout.rowLevelValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].levelValue, static_cast<float>(x),
UiPointInRect(layout.rowLevelValues[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
if (rowIndex < layout.rowIterValues.size() && if (layout.rows[rowIndex].iterValue.Width > 0.0f &&
layout.rowIterValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterValue, static_cast<float>(x),
UiPointInRect(layout.rowIterValues[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
if (rowIndex < layout.rowLevelResets.size() && if (layout.rows[rowIndex].levelReset.Width > 0.0f &&
layout.rowLevelResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].levelReset, static_cast<float>(x),
UiPointInRect(layout.rowLevelResets[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
if (rowIndex < layout.rowIterResets.size() && if (layout.rows[rowIndex].iterReset.Width > 0.0f &&
layout.rowIterResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterReset, static_cast<float>(x),
UiPointInRect(layout.rowIterResets[rowIndex], static_cast<float>(x),
static_cast<float>(y))) static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_HAND); return LoadCursorW(nullptr, IDC_HAND);
} }
if (rowIndex < layout.rowLevelTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale,
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y))) static_cast<float>(x), static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_SIZEWE); return LoadCursorW(nullptr, IDC_SIZEWE);
} }
if (rowIndex < layout.rowIterTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale,
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale,
static_cast<float>(x), static_cast<float>(y))) static_cast<float>(x), static_cast<float>(y)))
{ {
return LoadCursorW(nullptr, IDC_SIZEWE); return LoadCursorW(nullptr, IDC_SIZEWE);
@@ -1542,13 +1533,13 @@ namespace jdi
} }
Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef); Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef);
const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef); const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef);
if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rowLevelTracks.size()) || if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rows.size()) ||
layout.rowLevelTracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f) layout.rows[static_cast<std::size_t>(rowIndex)].levelTrack.Width <= 0.0f)
{ {
break; break;
} }
const double ratio = const double ratio =
UiSliderRatioFromX(layout.rowLevelTracks[static_cast<std::size_t>(rowIndex)], fx); UiSliderRatioFromX(layout.rows[static_cast<std::size_t>(rowIndex)].levelTrack, fx);
// 滑块量程 -200% – +200%:0 在正中间。 // 滑块量程 -200% – +200%:0 在正中间。
// 滑块量程 -400% – +400%(三组规则的梯度区间):0 在正中间。 // 滑块量程 -400% – +400%(三组规则的梯度区间):0 在正中间。
item.amountPercent = std::max( item.amountPercent = std::max(
@@ -1575,13 +1566,13 @@ namespace jdi
} }
Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef); Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_peakDragRef);
const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef); const int rowIndex = PeakRowIndexOf(layout, m_peakDragRef);
if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rowIterTracks.size()) || if (rowIndex < 0 || rowIndex >= static_cast<int>(layout.rows.size()) ||
layout.rowIterTracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f) layout.rows[static_cast<std::size_t>(rowIndex)].iterTrack.Width <= 0.0f)
{ {
break; break;
} }
const double ratio = const double ratio =
UiSliderRatioFromX(layout.rowIterTracks[static_cast<std::size_t>(rowIndex)], fx); UiSliderRatioFromX(layout.rows[static_cast<std::size_t>(rowIndex)].iterTrack, fx);
const int peakMax = std::max(kWeakPeakIterationsMin, const int peakMax = std::max(kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); std::min(kWeakPeakIterationsMaxLimit, item.maxIterations));
item.iterations = std::max( item.iterations = std::max(
@@ -1629,9 +1620,9 @@ namespace jdi
const EdgePanelLayout layout = EdgePanelLayoutFor( const EdgePanelLayout layout = EdgePanelLayoutFor(
m_meanDragLane, m_meanDragKind); m_meanDragLane, m_meanDragKind);
int rowIndex = -1; int rowIndex = -1;
for (std::size_t candidate = 0; candidate < layout.rowRefs.size(); ++candidate) for (std::size_t candidate = 0; candidate < layout.rows.size(); ++candidate)
{ {
if (layout.rowRefs[candidate] == m_meanDragRef) if (layout.rows[candidate].ref == m_meanDragRef)
{ {
rowIndex = static_cast<int>(candidate); rowIndex = static_cast<int>(candidate);
break; break;
@@ -1641,15 +1632,15 @@ namespace jdi
{ {
break; break;
} }
const std::vector<gd::RectF>& tracks = // 两行滑块的轨道由 `pointsMode` 决定:这里取**引用**而不是"整个数组",
pointsMode ? layout.rowLevelTracks : layout.rowIterTracks; // 因为行字段现在同属一个 `PanelRow`(不再有可切换的平行数组)。
if (static_cast<std::size_t>(rowIndex) >= tracks.size() || const gd::RectF& track = pointsMode ? layout.rows[static_cast<std::size_t>(rowIndex)].levelTrack
tracks[static_cast<std::size_t>(rowIndex)].Width <= 0.0f) : layout.rows[static_cast<std::size_t>(rowIndex)].iterTrack;
if (track.Width <= 0.0f)
{ {
break; break;
} }
const double ratio = const double ratio = UiSliderRatioFromX(track, fx);
UiSliderRatioFromX(tracks[static_cast<std::size_t>(rowIndex)], fx);
if (pointsMode) if (pointsMode)
{ {
// ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。 // ⚠️ 下限:均值滤波 = 1(用户 2026-10-30 要求不能为 0);中心轴 = 0(0 = 不平滑)。
@@ -2006,20 +1997,16 @@ namespace jdi
const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind); const EdgePanelLayout layout = EdgePanelLayoutFor(panelLane, hoverKind);
if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y))) if (UiPointInRect(layout.content, static_cast<float>(x), static_cast<float>(y)))
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (rowIndex >= layout.rowHeaders.size()) const gd::RectF& headerRect = layout.rows[rowIndex].header;
{
break;
}
const gd::RectF& headerRect = layout.rowHeaders[rowIndex];
// 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示, // 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示,
// 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。 // 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。
// 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。 // 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。
if (UiPointInRect(headerRect, static_cast<float>(x), static_cast<float>(y))) if (UiPointInRect(headerRect, static_cast<float>(x), static_cast<float>(y)))
{ {
hoverLane = panelLane; hoverLane = panelLane;
hoverRef = layout.rowRefs[rowIndex]; hoverRef = layout.rows[rowIndex].ref;
break; break;
} }
} }
@@ -2042,17 +2029,17 @@ namespace jdi
static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK) static_cast<EdgePanelKind>(hoverPanelKind) == EdgePanelKind::PEAK)
{ {
const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK); const EdgePanelLayout resetLayout = EdgePanelLayoutFor(panelLane, EdgePanelKind::PEAK);
for (std::size_t rowIndex = 0; rowIndex < resetLayout.rowLevelResets.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < resetLayout.rows.size(); ++rowIndex)
{ {
const gd::RectF& level = resetLayout.rowLevelResets[rowIndex]; const gd::RectF& level = resetLayout.rows[rowIndex].levelReset;
if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast<float>(y))) if (level.Width > 0.0f && UiPointInRect(level, fx, static_cast<float>(y)))
{ {
overPeakReset = true; overPeakReset = true;
break; break;
} }
if (rowIndex < resetLayout.rowIterResets.size()) if (rowIndex < resetLayout.rows.size())
{ {
const gd::RectF& iter = resetLayout.rowIterResets[rowIndex]; const gd::RectF& iter = resetLayout.rows[rowIndex].iterReset;
if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast<float>(y))) if (iter.Width > 0.0f && UiPointInRect(iter, fx, static_cast<float>(y)))
{ {
overPeakReset = true; overPeakReset = true;
@@ -2456,39 +2443,37 @@ namespace jdi
// 条目栏 = 复选框切换 + 已勾选时整行收起/展开(与其它列表式面板一致)。 // 条目栏 = 复选框切换 + 已勾选时整行收起/展开(与其它列表式面板一致)。
if (resampleMode) if (resampleMode)
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (!insideContent) if (!insideContent)
{ {
break; break;
} }
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
const Lane::ResampleItem* item = meanLane.FindResampleItem(ref); const Lane::ResampleItem* item = meanLane.FindResampleItem(ref);
if (item == nullptr) if (item == nullptr)
{ {
continue; continue;
} }
// 点击「重采样间隔」输入框 → 编辑**秒**数(不带单位、不用科学计数法)。 // 点击「重采样间隔」输入框 → 编辑**秒**数(不带单位、不用科学计数法)。
if (rowIndex < layout.rowIntervalField.size() && if (layout.rows[rowIndex].intervalField.Width > 0.0f &&
layout.rowIntervalField[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].intervalField, fx, fy))
UiPointInRect(layout.rowIntervalField[rowIndex], fx, fy))
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_editMeanKind = kind; m_editMeanKind = kind;
const gd::RectF& field = layout.rowIntervalField[rowIndex]; const gd::RectF& field = layout.rows[rowIndex].intervalField;
BeginEdit(PlotEditTarget::RESAMPLE_INTERVAL, index, BeginEdit(PlotEditTarget::RESAMPLE_INTERVAL, index,
FormatDurationSeconds(item->interval), field); FormatDurationSeconds(item->interval), field);
m_dragMode = PlotDragMode::NONE; m_dragMode = PlotDragMode::NONE;
return true; return true;
} }
// 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动;不带单位)。 // 点击「重采样频率」输入框 → 编辑频率(Hz,与间隔联动;不带单位)。
if (rowIndex < layout.rowFrequencyField.size() && if (layout.rows[rowIndex].frequencyField.Width > 0.0f &&
layout.rowFrequencyField[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].frequencyField, fx, fy))
UiPointInRect(layout.rowFrequencyField[rowIndex], fx, fy))
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_editMeanKind = kind; m_editMeanKind = kind;
const gd::RectF& field = layout.rowFrequencyField[rowIndex]; const gd::RectF& field = layout.rows[rowIndex].frequencyField;
const double hz = (item->interval > 0.0) ? (1.0 / item->interval) : 0.0; const double hz = (item->interval > 0.0) ? (1.0 / item->interval) : 0.0;
BeginEdit(PlotEditTarget::RESAMPLE_FREQUENCY, index, BeginEdit(PlotEditTarget::RESAMPLE_FREQUENCY, index,
FormatDecimal(hz, 12), field); FormatDecimal(hz, 12), field);
@@ -2497,19 +2482,17 @@ namespace jdi
} }
} }
// 条目栏(信号色叠底):复选框切换;已勾选时整行切换参数行收起/展开。 // 条目栏(信号色叠底):复选框切换;已勾选时整行切换参数行收起/展开。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
if (!insideContent || rowIndex >= layout.rowHeaders.size() || if (!insideContent || layout.rows[rowIndex].header.Width <= 0.0f ||
layout.rowHeaders[rowIndex].Width <= 0.0f || !UiPointInRect(layout.rows[rowIndex].header, fx, fy))
!UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{ {
continue; continue;
} }
const bool onCheckBox = const bool onCheckBox =
rowIndex < layout.rowChecks.size() && layout.rows[rowIndex].check.Width > 0.0f &&
layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].check, fx, fy);
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
const Lane::ResampleItem* existing = meanLane.FindResampleItem(ref); const Lane::ResampleItem* existing = meanLane.FindResampleItem(ref);
if (onCheckBox) if (onCheckBox)
{ {
@@ -2577,15 +2560,14 @@ namespace jdi
const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE); const bool doubleCursor = (Tab().CursorMode() == PlotCursorMode::DOUBLE);
Lane& clipLane = m_tab->Lanes()[static_cast<std::size_t>(index)]; Lane& clipLane = m_tab->Lanes()[static_cast<std::size_t>(index)];
// 条目复选框:切换该信号是否为截取目标。 // 条目复选框:切换该信号是否为截取目标。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (!insideContent || rowIndex >= layout.rowChecks.size() || if (!insideContent || layout.rows[rowIndex].check.Width <= 0.0f ||
layout.rowChecks[rowIndex].Width <= 0.0f || !UiPointInRect(layout.rows[rowIndex].check, fx, fy))
!UiPointInRect(layout.rowChecks[rowIndex], fx, fy))
{ {
continue; continue;
} }
ToggleLaneClipSignal(index, layout.rowRefs[rowIndex]); ToggleLaneClipSignal(index, layout.rows[rowIndex].ref);
m_meanRowHoverLane = -1; m_meanRowHoverLane = -1;
m_meanRowHoverRef = SignalRef(); m_meanRowHoverRef = SignalRef();
m_dragMode = PlotDragMode::NONE; m_dragMode = PlotDragMode::NONE;
@@ -2651,26 +2633,24 @@ namespace jdi
// 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。 // 语义与该面板旧式排版完全一致,只是外观改成与均值滤波同款的可滚动列表。
if (boostMode) if (boostMode)
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
if (!insideContent) if (!insideContent)
{ {
break; break;
} }
const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref); const Lane::WeakPeakItem* peakItem = meanLane.FindWeakPeakItem(ref);
const bool hasParams = const bool hasParams =
peakItem != nullptr && rowIndex < layout.rowLevelTracks.size() && peakItem != nullptr && layout.rows[rowIndex].levelTrack.Width > 0.0f;
layout.rowLevelTracks[rowIndex].Width > 0.0f;
if (!hasParams) if (!hasParams)
{ {
continue; continue;
} }
Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref); Lane::WeakPeakItem& item = meanLane.EnsureWeakPeakItem(ref);
// 复位:调整幅度 → 0% // 复位:调整幅度 → 0%
if (rowIndex < layout.rowLevelResets.size() && if (layout.rows[rowIndex].levelReset.Width > 0.0f &&
layout.rowLevelResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].levelReset, fx, fy))
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{ {
item.amountPercent = 0; item.amountPercent = 0;
m_weakPeakCurveCache.clear(); m_weakPeakCurveCache.clear();
@@ -2678,9 +2658,8 @@ namespace jdi
return true; return true;
} }
// 复位:迭代次数 → 1(上限恢复为默认上限)。 // 复位:迭代次数 → 1(上限恢复为默认上限)。
if (rowIndex < layout.rowIterResets.size() && if (layout.rows[rowIndex].iterReset.Width > 0.0f &&
layout.rowIterResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterReset, fx, fy))
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
{ {
item.iterations = kWeakPeakIterationsMin; item.iterations = kWeakPeakIterationsMin;
item.maxIterations = kWeakPeakIterationsDefaultMax; item.maxIterations = kWeakPeakIterationsDefaultMax;
@@ -2690,14 +2669,13 @@ namespace jdi
} }
// 迭代次数行的**数值文本**:点击后整行变成输入框以修改滑块上限。 // 迭代次数行的**数值文本**:点击后整行变成输入框以修改滑块上限。
// ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。 // ⚠️ 必须放在轨道命中之前(数值区在轨道右侧,命中区不重叠,但顺序更安全)。
if (rowIndex < layout.rowIterValues.size() && if (layout.rows[rowIndex].iterValue.Width > 0.0f &&
layout.rowIterValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterValue, fx, fy))
UiPointInRect(layout.rowIterValues[rowIndex], fx, fy))
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_peakEditCurrentMax = item.maxIterations; m_peakEditCurrentMax = item.maxIterations;
const gd::RectF& track = layout.rowIterTracks[rowIndex]; const gd::RectF& track = layout.rows[rowIndex].iterTrack;
const gd::RectF& value = layout.rowIterValues[rowIndex]; const gd::RectF& value = layout.rows[rowIndex].iterValue;
const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X, const gd::RectF rowRect(track.X, value.Y, value.X + value.Width - track.X,
value.Height); value.Height);
BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect); BeginEdit(PlotEditTarget::PEAK_ITERATIONS_MAX, index, L"", rowRect);
@@ -2706,14 +2684,13 @@ namespace jdi
return true; return true;
} }
// 拖动「迭代次数」滑块。 // 拖动「迭代次数」滑块。
if (rowIndex < layout.rowIterTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale, fx, fy))
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
{ {
m_dragMode = PlotDragMode::PEAK_ITERATIONS; m_dragMode = PlotDragMode::PEAK_ITERATIONS;
m_peakDragLane = index; m_peakDragLane = index;
m_peakDragRef = ref; m_peakDragRef = ref;
const double ratio = const double ratio =
UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); UiSliderRatioFromX(layout.rows[rowIndex].iterTrack, fx);
const int peakMax = std::max( const int peakMax = std::max(
kWeakPeakIterationsMin, kWeakPeakIterationsMin,
std::min(kWeakPeakIterationsMaxLimit, item.maxIterations)); std::min(kWeakPeakIterationsMaxLimit, item.maxIterations));
@@ -2727,14 +2704,13 @@ namespace jdi
return true; return true;
} }
// 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。 // 拖动「调整幅度」滑块(0 在正中间 ⇒ ±400%)。
if (rowIndex < layout.rowLevelTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale, fx, fy))
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{ {
m_dragMode = PlotDragMode::PEAK_LEVEL; m_dragMode = PlotDragMode::PEAK_LEVEL;
m_peakDragLane = index; m_peakDragLane = index;
m_peakDragRef = ref; m_peakDragRef = ref;
item.amountPercent = static_cast<int>(std::lround( item.amountPercent = static_cast<int>(std::lround(
(UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx) * 2.0 - 1.0) * (UiSliderRatioFromX(layout.rows[rowIndex].levelTrack, fx) * 2.0 - 1.0) *
100.0 * kWeakPeakSliderScale)); 100.0 * kWeakPeakSliderScale));
item.amountPercent = std::max( item.amountPercent = std::max(
kWeakPeakAmountMin, kWeakPeakAmountMin,
@@ -2746,9 +2722,9 @@ namespace jdi
} }
else else
{ {
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
if (!insideContent) if (!insideContent)
{ {
break; break;
@@ -2756,16 +2732,14 @@ namespace jdi
// 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区; // 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区;
// 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。 // 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。
const bool hasParams = const bool hasParams =
rowIndex < layout.rowLevelTracks.size() && layout.rows[rowIndex].levelTrack.Width > 0.0f &&
layout.rowLevelTracks[rowIndex].Width > 0.0f &&
findItem(ref) != nullptr; findItem(ref) != nullptr;
if (hasParams) if (hasParams)
{ {
Lane::MeanFilterItem& item = ensureItem(ref); Lane::MeanFilterItem& item = ensureItem(ref);
// 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。 // 复位:均值点数 → 默认值(同时把滑块上限恢复为默认上限)。
if (rowIndex < layout.rowLevelResets.size() && if (layout.rows[rowIndex].levelReset.Width > 0.0f &&
layout.rowLevelResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].levelReset, fx, fy))
UiPointInRect(layout.rowLevelResets[rowIndex], fx, fy))
{ {
item.points = kMeanFilterPointsDefault; item.points = kMeanFilterPointsDefault;
item.maxPoints = kMeanFilterPointsDefaultMax; item.maxPoints = kMeanFilterPointsDefaultMax;
@@ -2778,9 +2752,8 @@ namespace jdi
return true; return true;
} }
// 复位:迭代次数 → 1(上限恢复为默认上限)。 // 复位:迭代次数 → 1(上限恢复为默认上限)。
if (rowIndex < layout.rowIterResets.size() && if (layout.rows[rowIndex].iterReset.Width > 0.0f &&
layout.rowIterResets[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterReset, fx, fy))
UiPointInRect(layout.rowIterResets[rowIndex], fx, fy))
{ {
item.iterations = kMeanFilterIterationsMin; item.iterations = kMeanFilterIterationsMin;
item.maxIterations = kMeanFilterIterationsDefaultMax; item.maxIterations = kMeanFilterIterationsDefaultMax;
@@ -2793,15 +2766,14 @@ namespace jdi
return true; return true;
} }
// 点击「均值点数」右侧数值 → 输入滑块上限。 // 点击「均值点数」右侧数值 → 输入滑块上限。
if (rowIndex < layout.rowLevelValues.size() && if (layout.rows[rowIndex].levelValue.Width > 0.0f &&
layout.rowLevelValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].levelValue, fx, fy))
UiPointInRect(layout.rowLevelValues[rowIndex], fx, fy))
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxPoints; m_meanEditCurrentMax = item.maxPoints;
m_editMeanKind = kind; m_editMeanKind = kind;
const gd::RectF& track = layout.rowLevelTracks[rowIndex]; const gd::RectF& track = layout.rows[rowIndex].levelTrack;
const gd::RectF& value = layout.rowLevelValues[rowIndex]; const gd::RectF& value = layout.rows[rowIndex].levelValue;
const gd::RectF rowRect(track.X, value.Y, const gd::RectF rowRect(track.X, value.Y,
value.X + value.Width - track.X, value.X + value.Width - track.X,
value.Height); value.Height);
@@ -2811,15 +2783,14 @@ namespace jdi
return true; return true;
} }
// 点击「迭代次数」右侧数值 → 输入滑块上限。 // 点击「迭代次数」右侧数值 → 输入滑块上限。
if (rowIndex < layout.rowIterValues.size() && if (layout.rows[rowIndex].iterValue.Width > 0.0f &&
layout.rowIterValues[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].iterValue, fx, fy))
UiPointInRect(layout.rowIterValues[rowIndex], fx, fy))
{ {
m_editMeanRef = ref; m_editMeanRef = ref;
m_meanEditCurrentMax = item.maxIterations; m_meanEditCurrentMax = item.maxIterations;
m_editMeanKind = kind; m_editMeanKind = kind;
const gd::RectF& track = layout.rowIterTracks[rowIndex]; const gd::RectF& track = layout.rows[rowIndex].iterTrack;
const gd::RectF& value = layout.rowIterValues[rowIndex]; const gd::RectF& value = layout.rows[rowIndex].iterValue;
const gd::RectF rowRect(track.X, value.Y, const gd::RectF rowRect(track.X, value.Y,
value.X + value.Width - track.X, value.X + value.Width - track.X,
value.Height); value.Height);
@@ -2829,8 +2800,7 @@ namespace jdi
return true; return true;
} }
// 拖动「均值点数」滑块。 // 拖动「均值点数」滑块。
if (rowIndex < layout.rowLevelTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].levelTrack, m_scale, fx, fy))
WeakPeakSliderHit(layout.rowLevelTracks[rowIndex], m_scale, fx, fy))
{ {
const int floorValue = centerMode ? 0 : kMeanFilterPointsMin; const int floorValue = centerMode ? 0 : kMeanFilterPointsMin;
const int maximum = const int maximum =
@@ -2839,7 +2809,7 @@ namespace jdi
m_meanDragLane = index; m_meanDragLane = index;
m_meanDragRef = ref; m_meanDragRef = ref;
m_meanDragKind = kind; m_meanDragKind = kind;
const double ratio = UiSliderRatioFromX(layout.rowLevelTracks[rowIndex], fx); const double ratio = UiSliderRatioFromX(layout.rows[rowIndex].levelTrack, fx);
item.points = std::max( item.points = std::max(
floorValue, floorValue,
std::min(maximum, std::min(maximum,
@@ -2852,8 +2822,7 @@ namespace jdi
return true; return true;
} }
// 拖动「迭代次数」滑块。 // 拖动「迭代次数」滑块。
if (rowIndex < layout.rowIterTracks.size() && if (WeakPeakSliderHit(layout.rows[rowIndex].iterTrack, m_scale, fx, fy))
WeakPeakSliderHit(layout.rowIterTracks[rowIndex], m_scale, fx, fy))
{ {
const int maximum = std::max( const int maximum = std::max(
kMeanFilterIterationsMin, kMeanFilterIterationsMin,
@@ -2862,7 +2831,7 @@ namespace jdi
m_meanDragLane = index; m_meanDragLane = index;
m_meanDragRef = ref; m_meanDragRef = ref;
m_meanDragKind = kind; m_meanDragKind = kind;
const double ratio = UiSliderRatioFromX(layout.rowIterTracks[rowIndex], fx); const double ratio = UiSliderRatioFromX(layout.rows[rowIndex].iterTrack, fx);
item.iterations = std::max( item.iterations = std::max(
kMeanFilterIterationsMin, kMeanFilterIterationsMin,
std::min(maximum, std::min(maximum,
@@ -2886,23 +2855,21 @@ namespace jdi
// * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏
// 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。 // 既不能自动勾选、也不能展开 / 收起,只能点复选框;此时鼠标指针也保持箭头)。
// 仍然消费这次点击(不穿透到底层的平移 / 选区等)。 // 仍然消费这次点击(不穿透到底层的平移 / 选区等)。
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (!insideContent) if (!insideContent)
{ {
break; break;
} }
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
if (rowIndex >= layout.rowHeaders.size() || if (layout.rows[rowIndex].header.Width <= 0.0f ||
layout.rowHeaders[rowIndex].Width <= 0.0f || !UiPointInRect(layout.rows[rowIndex].header, fx, fy))
!UiPointInRect(layout.rowHeaders[rowIndex], fx, fy))
{ {
continue; continue;
} }
const bool onCheckBox = const bool onCheckBox =
rowIndex < layout.rowChecks.size() && layout.rows[rowIndex].check.Width > 0.0f &&
layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rows[rowIndex].check, fx, fy);
UiPointInRect(layout.rowChecks[rowIndex], fx, fy);
bool checked = false; bool checked = false;
bool collapsed = false; bool collapsed = false;
if (boostMode) if (boostMode)
@@ -4225,9 +4192,9 @@ namespace jdi
int PlotView::PeakRowIndexOf(const EdgePanelLayout& layout, const SignalRef& ref) const int PlotView::PeakRowIndexOf(const EdgePanelLayout& layout, const SignalRef& ref) const
{ {
for (std::size_t index = 0; index < layout.rowRefs.size(); ++index) for (std::size_t index = 0; index < layout.rows.size(); ++index)
{ {
if (layout.rowRefs[index] == ref) if (layout.rows[index].ref == ref)
{ {
return static_cast<int>(index); return static_cast<int>(index);
} }
+45 -76
View File
@@ -814,18 +814,28 @@ namespace jdi
boxes.Add(layout.clipBuildButton, 3); boxes.Add(layout.clipBuildButton, 3);
boxes.Add(layout.clipDestructiveButton, 3); boxes.Add(layout.clipDestructiveButton, 3);
boxes.AddAll(layout.rowChecks, 4); // ⚠️ 行内元素**逐行**登记(不再有"整个数组一起 AddAll"):
boxes.AddAll(layout.rowStats, 4); // 一行的字段是一个整体,逐字段登记才不会漏(旧的 12 次 AddAll 里
boxes.AddAll(layout.rowLevelTracks, 4); // 少写一行就是整整一列元素不显示调试框,且没有任何检查会发现)。
boxes.AddAll(layout.rowLevelValues, 4); for (const EdgePanelLayout::PanelRow& row : layout.rows)
boxes.AddAll(layout.rowIterTracks, 4); {
boxes.AddAll(layout.rowIterValues, 4); boxes.Add(row.header, 3);
boxes.AddAll(layout.rowLevelResets, 4); boxes.Add(row.nameRect, 4);
boxes.AddAll(layout.rowIterResets, 4); boxes.Add(row.nameClose, 4);
boxes.AddAll(layout.rowNameRects, 4); boxes.Add(row.triangle, 4);
boxes.AddAll(layout.rowNameCloses, 4); boxes.Add(row.check, 4);
boxes.AddAll(layout.rowTriangles, 4); boxes.Add(row.stats, 4);
boxes.AddAll(layout.rowHeaders, 4); boxes.Add(row.levelTrack, 4);
boxes.Add(row.levelValue, 4);
boxes.Add(row.levelReset, 4);
boxes.Add(row.iterTrack, 4);
boxes.Add(row.iterValue, 4);
boxes.Add(row.iterReset, 4);
boxes.Add(row.infoMaxInterval, 4);
boxes.Add(row.infoMinInterval, 4);
boxes.Add(row.intervalField, 4);
boxes.Add(row.frequencyField, 4);
}
for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups) for (const EdgePanelLayout::MarkerGroup& group : layout.markerGroups)
{ {
@@ -4081,14 +4091,28 @@ namespace jdi
mixRect(layout.opacityValue); mixRect(layout.opacityValue);
mix(layout.collapsed ? 1ull : 0ull); mix(layout.collapsed ? 1ull : 0ull);
mix(layout.markerHasEnumColumn ? 1ull : 0ull); mix(layout.markerHasEnumColumn ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(layout.rows)); mix(static_cast<std::uint64_t>(layout.signalCount));
for (const gd::RectF& rect : layout.rowChecks) // 行的几何**逐行混入**(旧实现按列分组混 12~16 次,漏一列就少一层敏感度,
// 而且“哪些列入键”分散在四个地方)。现在一个 `PanelRow` 是一个整体,
// 新增字段只需在 `PanelRow` 与这一个循环里各加一行。
for (const EdgePanelLayout::PanelRow& row : layout.rows)
{ {
mixRect(rect); mixRect(row.header);
} mixRect(row.nameRect);
for (const gd::RectF& rect : layout.rowStats) mixRect(row.nameClose);
{ mixRect(row.triangle);
mixRect(rect); mixRect(row.check);
mixRect(row.stats);
mixRect(row.levelTrack);
mixRect(row.levelValue);
mixRect(row.levelReset);
mixRect(row.iterTrack);
mixRect(row.iterValue);
mixRect(row.iterReset);
mixRect(row.infoMaxInterval);
mixRect(row.infoMinInterval);
mixRect(row.intervalField);
mixRect(row.frequencyField);
} }
// 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。 // 「自由标记」:固定点行/名称 ✕ 的矩形、五列边界、表头/吸顶状态与每个固定点的身份 + 时刻都要入键。
for (const EdgePanelLayout::MarkerRow& row : layout.markerRows) for (const EdgePanelLayout::MarkerRow& row : layout.markerRows)
@@ -4097,14 +4121,6 @@ namespace jdi
mixRect(row.close); mixRect(row.close);
mix(static_cast<std::uint64_t>(row.itemIndex + 2)); mix(static_cast<std::uint64_t>(row.itemIndex + 2));
} }
for (const gd::RectF& rect : layout.rowNameCloses)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowNameRects)
{
mixRect(rect);
}
for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns) for (const EdgePanelLayout::MarkerColumn& column : layout.markerColumns)
{ {
mixFloat(column.left); mixFloat(column.left);
@@ -4150,8 +4166,9 @@ namespace jdi
mix(bits); mix(bits);
} }
} }
for (const SignalRef& ref : layout.rowRefs) for (const EdgePanelLayout::PanelRow& row : layout.rows)
{ {
const SignalRef& ref = row.ref;
mix(ref.IsValid() ? 1ull : 0ull); mix(ref.IsValid() ? 1ull : 0ull);
mix(static_cast<std::uint64_t>(ref.groupIndex)); mix(static_cast<std::uint64_t>(ref.groupIndex));
mix(static_cast<std::uint64_t>(ref.signalIndex)); mix(static_cast<std::uint64_t>(ref.signalIndex));
@@ -4220,54 +4237,6 @@ namespace jdi
} }
// 弱峰调整的滑块行矩形(每信号两行)。
for (const gd::RectF& rect : layout.rowLevelTracks)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterTracks)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterValues)
{
mixRect(rect);
}
// 弱峰调整每行滑块右侧的复位按钮(悬停高亮依赖它的精确矩形)。
for (const gd::RectF& rect : layout.rowLevelResets)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIterResets)
{
mixRect(rect);
}
// 「均值滤波」:信号色叠底标题栏 / 展开三角 / 内容区滚动偏移都要入键
//(滚动改变的是面板内部像素,位置与尺寸不变)。
for (const gd::RectF& rect : layout.rowHeaders)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowTriangles)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowInfoMaxInterval)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowInfoMinInterval)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowIntervalField)
{
mixRect(rect);
}
for (const gd::RectF& rect : layout.rowFrequencyField)
{
mixRect(rect);
}
mixFloat(layout.scrollOffset); mixFloat(layout.scrollOffset);
// 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。
+57 -142
View File
@@ -51,20 +51,15 @@ namespace jdi
// 行内容必须裁到内容区:滚动时行会越出可视范围。 // 行内容必须裁到内容区:滚动时行会越出可视范围。
g.SetClip(layout.content, gd::CombineModeIntersect); g.SetClip(layout.content, gd::CombineModeIntersect);
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
if (rowIndex >= layout.rowHeaders.size() || const SignalRef& ref = layout.rows[rowIndex].ref;
rowIndex >= layout.rowNameRects.size())
{
break;
}
const SignalRef& ref = layout.rowRefs[rowIndex];
const ResolvedSignal resolved = ResolveSignal(ref); const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr) if (resolved.data == nullptr || resolved.style == nullptr)
{ {
continue; continue;
} }
const gd::RectF& row = layout.rowHeaders[rowIndex]; const gd::RectF& row = layout.rows[rowIndex].header;
if (row.Y + row.Height <= layout.content.Y || if (row.Y + row.Height <= layout.content.Y ||
row.Y >= layout.content.Y + layout.content.Height) row.Y >= layout.content.Y + layout.content.Height)
{ {
@@ -77,7 +72,7 @@ namespace jdi
UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); UiFillRoundRect(g, row, 3.0f * m_scale, WithAlpha(signalColor, 0xE8));
// 信号名(第一列):超出列宽时省略。 // 信号名(第一列):超出列宽时省略。
const gd::RectF& nameRect = layout.rowNameRects[rowIndex]; const gd::RectF& nameRect = layout.rows[rowIndex].nameRect;
UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall, UiTextClipped(g, UiEllipsize(resolved.data->name, m_fonts->uiSmall,
std::max(8.0f, nameRect.Width)), std::max(8.0f, nameRect.Width)),
m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT, m_fonts->uiSmall, nameRect, ink.text, TextAlign::LEFT,
@@ -188,75 +183,40 @@ namespace jdi
UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale); UiDrawLine(g, middleX, lowY, right, highY, background, 1.6f * m_scale);
}; };
for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) for (std::size_t rowIndex = 0; rowIndex < layout.rows.size(); ++rowIndex)
{ {
const SignalRef& ref = layout.rowRefs[rowIndex]; const SignalRef& ref = layout.rows[rowIndex].ref;
if (rowIndex >= layout.rowHeaders.size() ||
rowIndex >= layout.rowChecks.size())
{
break;
}
const ResolvedSignal resolved = ResolveSignal(ref); const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data == nullptr || resolved.style == nullptr) if (resolved.data == nullptr || resolved.style == nullptr)
{ {
continue; continue;
} }
const gd::RectF& header = layout.rowHeaders[rowIndex]; const gd::RectF& header = layout.rows[rowIndex].header;
// 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏:
// 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内
//(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。
float rowTop = header.Y; // 整行的纵向范围 = 条目栏 ∪ 全部参数行(未使用的字段恒为空矩形,直接 extent 即可;
float rowBottom = header.Y + header.Height; // 旧结构里每一项都要先判断"数组可能更短",现在一行就是一个整体)。
const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex];
float rowTop = row.header.Y;
float rowBottom = row.header.Y + row.header.Height;
const auto extendRow = [&rowBottom](const gd::RectF& rect) { const auto extendRow = [&rowBottom](const gd::RectF& rect) {
if (rect.Width > 0.0f && rect.Height > 0.0f) if (rect.Width > 0.0f && rect.Height > 0.0f)
{ {
rowBottom = std::max(rowBottom, rect.Y + rect.Height); rowBottom = std::max(rowBottom, rect.Y + rect.Height);
} }
}; };
if (rowIndex < layout.rowLevelTracks.size()) extendRow(row.levelTrack);
{ extendRow(row.levelValue);
extendRow(layout.rowLevelTracks[rowIndex]); extendRow(row.levelReset);
} extendRow(row.iterTrack);
if (rowIndex < layout.rowLevelValues.size()) extendRow(row.iterValue);
{ extendRow(row.iterReset);
extendRow(layout.rowLevelValues[rowIndex]); extendRow(row.stats);
} extendRow(row.infoMaxInterval);
if (rowIndex < layout.rowLevelResets.size()) extendRow(row.infoMinInterval);
{ extendRow(row.intervalField);
extendRow(layout.rowLevelResets[rowIndex]); extendRow(row.frequencyField);
}
if (rowIndex < layout.rowIterTracks.size())
{
extendRow(layout.rowIterTracks[rowIndex]);
}
if (rowIndex < layout.rowIterValues.size())
{
extendRow(layout.rowIterValues[rowIndex]);
}
if (rowIndex < layout.rowIterResets.size())
{
extendRow(layout.rowIterResets[rowIndex]);
}
if (rowIndex < layout.rowStats.size())
{
extendRow(layout.rowStats[rowIndex]);
}
if (rowIndex < layout.rowInfoMaxInterval.size())
{
extendRow(layout.rowInfoMaxInterval[rowIndex]);
}
if (rowIndex < layout.rowInfoMinInterval.size())
{
extendRow(layout.rowInfoMinInterval[rowIndex]);
}
if (rowIndex < layout.rowIntervalField.size())
{
extendRow(layout.rowIntervalField[rowIndex]);
}
if (rowIndex < layout.rowFrequencyField.size())
{
extendRow(layout.rowFrequencyField[rowIndex]);
}
if (rowBottom <= layout.content.Y || if (rowBottom <= layout.content.Y ||
rowTop >= layout.content.Y + layout.content.Height) rowTop >= layout.content.Y + layout.content.Height)
{ {
@@ -300,7 +260,7 @@ namespace jdi
} }
// 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。
const gd::RectF& triangle = layout.rowTriangles[rowIndex]; const gd::RectF& triangle = layout.rows[rowIndex].triangle;
if (triangle.Width > 0.0f && checked) if (triangle.Width > 0.0f && checked)
{ {
UiDrawTriangleIcon(g, triangle, ink.text, UiDrawTriangleIcon(g, triangle, ink.text,
@@ -309,7 +269,7 @@ namespace jdi
} }
// 名称:从三角右侧到复选框左侧。 // 名称:从三角右侧到复选框左侧。
const gd::RectF& check = layout.rowChecks[rowIndex]; const gd::RectF& check = layout.rows[rowIndex].check;
const float nameLeft = (triangle.Width > 0.0f) const float nameLeft = (triangle.Width > 0.0f)
? (triangle.X + triangle.Width + 6.0f * m_scale) ? (triangle.X + triangle.Width + 6.0f * m_scale)
: (header.X + 8.0f * m_scale); : (header.X + 8.0f * m_scale);
@@ -336,8 +296,7 @@ namespace jdi
} }
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && rowIndex < layout.rowStats.size() && if (centerAxis && layout.rows[rowIndex].stats.Width > 0.0f)
layout.rowStats[rowIndex].Width > 0.0f)
{ {
std::size_t rising = 0; std::size_t rising = 0;
std::size_t falling = 0; std::size_t falling = 0;
@@ -348,7 +307,7 @@ namespace jdi
item->points, item->iterations, EdgeRangeActive(), item->points, item->iterations, EdgeRangeActive(),
EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete); EdgeRangeMin(), EdgeRangeMax(), rising, falling, complete);
} }
paintCounts(layout.rowStats[rowIndex], rising, falling, complete, paintCounts(layout.rows[rowIndex].stats, rising, falling, complete,
layout.content); layout.content);
} }
@@ -466,34 +425,18 @@ namespace jdi
? (static_cast<double>(peakIter - kWeakPeakIterationsMin) / ? (static_cast<double>(peakIter - kWeakPeakIterationsMin) /
static_cast<double>(peakMax - kWeakPeakIterationsMin)) static_cast<double>(peakMax - kWeakPeakIterationsMin))
: 0.0; : 0.0;
if (rowIndex < layout.rowLevelTracks.size() && if (row.levelTrack.Width > 0.0f)
layout.rowLevelTracks[rowIndex].Width > 0.0f)
{ {
paintPeakSlider( paintPeakSlider(row.levelTrack, row.levelValue, row.levelReset,
layout.rowLevelTracks[rowIndex], kWeakPeakLevelLabel, levelRatio,
rowIndex < layout.rowLevelValues.size() (peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%",
? layout.rowLevelValues[rowIndex] levelActive, false);
: gd::RectF(),
rowIndex < layout.rowLevelResets.size()
? layout.rowLevelResets[rowIndex]
: gd::RectF(),
kWeakPeakLevelLabel, levelRatio,
(peakAmount > 0 ? L"+" : L"") + FormatInteger(peakAmount) + L"%",
levelActive, false);
} }
if (rowIndex < layout.rowIterTracks.size() && if (row.iterTrack.Width > 0.0f)
layout.rowIterTracks[rowIndex].Width > 0.0f)
{ {
paintPeakSlider( paintPeakSlider(row.iterTrack, row.iterValue, row.iterReset,
layout.rowIterTracks[rowIndex], kWeakPeakIterationsSliderLabel, iterRatio,
rowIndex < layout.rowIterValues.size() FormatInteger(peakIter), iterActive, editingMax);
? layout.rowIterValues[rowIndex]
: gd::RectF(),
rowIndex < layout.rowIterResets.size()
? layout.rowIterResets[rowIndex]
: gd::RectF(),
kWeakPeakIterationsSliderLabel, iterRatio,
FormatInteger(peakIter), iterActive, editingMax);
} }
} }
else else
@@ -518,30 +461,12 @@ namespace jdi
(m_dragMode == PlotDragMode::MEAN_ITERATIONS) && (m_dragMode == PlotDragMode::MEAN_ITERATIONS) &&
(m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref) && (m_meanDragLane == static_cast<int>(index)) && (m_meanDragRef == ref) &&
(m_meanDragKind == kind); (m_meanDragKind == kind);
if (rowIndex < layout.rowLevelTracks.size()) paintMeanSlider(row.levelTrack, row.levelValue, row.levelReset,
{ kMeanFilterPointsSliderLabel, points, pointsFloor, pointsMax,
paintMeanSlider(layout.rowLevelTracks[rowIndex], pointsActive);
rowIndex < layout.rowLevelValues.size() paintMeanSlider(row.iterTrack, row.iterValue, row.iterReset,
? layout.rowLevelValues[rowIndex] kMeanFilterIterationsSliderLabel, iterations,
: gd::RectF(), kMeanFilterIterationsMin, iterationsMax, iterationsActive);
rowIndex < layout.rowLevelResets.size()
? layout.rowLevelResets[rowIndex]
: gd::RectF(),
kMeanFilterPointsSliderLabel, points,
pointsFloor, pointsMax, pointsActive);
}
if (rowIndex < layout.rowIterTracks.size())
{
paintMeanSlider(layout.rowIterTracks[rowIndex],
rowIndex < layout.rowIterValues.size()
? layout.rowIterValues[rowIndex]
: gd::RectF(),
rowIndex < layout.rowIterResets.size()
? layout.rowIterResets[rowIndex]
: gd::RectF(),
kMeanFilterIterationsSliderLabel, iterations,
kMeanFilterIterationsMin, iterationsMax, iterationsActive);
}
} }
} }
@@ -640,36 +565,26 @@ namespace jdi
} }
return text; return text;
}; };
if (rowIndex < layout.rowInfoMaxInterval.size()) // ⚠️ 四行均在本行的 `PanelRow` 里,不再需要 “数组可能更短” 的判空(旧平行数组结构的遗留)。
{ const EdgePanelLayout::PanelRow& row = layout.rows[rowIndex];
paintInfoRow(layout.rowInfoMaxInterval[rowIndex], kResampleMaxIntervalLabel, paintInfoRow(row.infoMaxInterval, kResampleMaxIntervalLabel,
hasInterval ? intervalWithFrequency(item.maxInterval) : L"—"); hasInterval ? intervalWithFrequency(item.maxInterval) : L"—");
} paintInfoRow(row.infoMinInterval, kResampleMinIntervalLabel,
if (rowIndex < layout.rowInfoMinInterval.size()) hasInterval ? intervalWithFrequency(item.minInterval) : L"—");
{
paintInfoRow(layout.rowInfoMinInterval[rowIndex], kResampleMinIntervalLabel,
hasInterval ? intervalWithFrequency(item.minInterval) : L"—");
}
const double interval = (item.interval > 0.0) ? item.interval : item.minInterval; const double interval = (item.interval > 0.0) ? item.interval : item.minInterval;
const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) && const bool editingInterval = (m_editTarget == PlotEditTarget::RESAMPLE_INTERVAL) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref); (m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) && const bool editingFrequency = (m_editTarget == PlotEditTarget::RESAMPLE_FREQUENCY) &&
(m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref); (m_editLane == static_cast<int>(index)) && (m_editMeanRef == ref);
if (rowIndex < layout.rowIntervalField.size()) // 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。
{ // 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。
// 非编辑态:自适应单位 + 小数(与「最大/最小数据间隔」同一套单位形式)。 paintInputRow(row.intervalField, kResampleIntervalLabel, FormatIntervalText(interval),
// 编辑态:只显示**秒**数值(不带单位),由 `PaintEditField` 绘制。 editingInterval);
paintInputRow(layout.rowIntervalField[rowIndex], kResampleIntervalLabel, // ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。
FormatIntervalText(interval), editingInterval); const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0;
} // 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。
if (rowIndex < layout.rowFrequencyField.size()) paintInputRow(row.frequencyField, kResampleFrequencyLabel,
{ (hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency);
// ⚠️ 频率同样用**真实间隔**换算(与时间间隔行同源)⇒ `270.517 Hz`。
const double hertz = (interval > 0.0) ? (1.0 / interval) : 0.0;
// 非编辑态:频率单位自适应(Hz/kHz/MHz/GHz);编辑态:只显示 Hz 数值。
paintInputRow(layout.rowFrequencyField[rowIndex], kResampleFrequencyLabel,
(hertz > 0.0) ? FormatFrequencyText(hertz) : L"—", editingFrequency);
}
} }
+17 -19
View File
@@ -380,7 +380,7 @@ namespace jdi
{ {
return layout; return layout;
} }
layout.rows = rowCount; layout.signalCount = rowCount;
const float width = EdgePanelWidth(laneIndex, kind); const float width = EdgePanelWidth(laneIndex, kind);
const float titleHeight = kEdgePanelTitleHeight * m_scale; const float titleHeight = kEdgePanelTitleHeight * m_scale;
@@ -735,7 +735,7 @@ namespace jdi
{ {
continue; continue;
} }
// ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型,
// 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。
layout.AppendRow(ref, gd::RectF(contentLeft, rowY, innerWidth, rowH), layout.AppendRow(ref, gd::RectF(contentLeft, rowY, innerWidth, rowH),
gd::RectF(contentLeft + 4.0f * m_scale, rowY, gd::RectF(contentLeft + 4.0f * m_scale, rowY,
@@ -877,9 +877,9 @@ namespace jdi
itemCollapsed = (item != nullptr && item->collapsed); itemCollapsed = (item != nullptr && item->collapsed);
} }
// 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。 // 组头(信号色叠底):整行宽度,左侧三角(仅勾选时)+ 名称,右端复选框。
// ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):18 个平行数组一次补齐, // ⚠️ 行的追加走 `AppendRow()`(**唯一入口**):一行的全部字段一次成型,
// 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。 // 不会出现"某个分支漏推一列 ⇒ 该列之后所有行读到别人的矩形"。
// 下面的参数行 / 重采样行一律用 `rowXxx.back() = …` **赋值**(长度天然对齐)。 // 下面的参数行 / 重采样行一律用 `layout.rows.back().xxx = …` **赋值**(同一行,天然对齐)。
layout.AppendRow( layout.AppendRow(
ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow), ref, gd::RectF(contentLeft, rowY, innerWidth, groupRow),
gd::RectF(contentLeft, rowY, innerWidth, groupRow), gd::RectF(contentLeft, rowY, innerWidth, groupRow),
@@ -903,43 +903,43 @@ namespace jdi
// 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读) // 「重采样」四行:① 最大间隔(只读)② 最小间隔(只读)
// ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。 // ③ 重采样间隔(输入)④ 重采样频率(输入,与③联动)。
// 每行 = 左侧标签 + 右侧数值/输入框区域。 // 每行 = 左侧标签 + 右侧数值/输入框区域。
layout.rowInfoMaxInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); layout.rows.back().infoMaxInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow);
rowY += sliderRow; rowY += sliderRow;
layout.rowInfoMinInterval.back() = gd::RectF(contentLeft, rowY, innerWidth, sliderRow); layout.rows.back().infoMinInterval = gd::RectF(contentLeft, rowY, innerWidth, sliderRow);
rowY += sliderRow; rowY += sliderRow;
// 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。 // 输入行的**数值矩形**靠右对齐,宽度取剩余可用宽度(绘制框与编辑框同源)。
const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale; const float fieldLeft = contentLeft + rowLabelColumn + 8.0f * m_scale;
const float fieldWidth = const float fieldWidth =
std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale); std::max(24.0f * m_scale, innerWidth - rowLabelColumn - 8.0f * m_scale);
layout.rowIntervalField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); layout.rows.back().intervalField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow);
rowY += sliderRow; rowY += sliderRow;
layout.rowFrequencyField.back() = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow); layout.rows.back().frequencyField = gd::RectF(fieldLeft, rowY, fieldWidth, sliderRow);
rowY += sliderRow; rowY += sliderRow;
continue; continue;
} }
// 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。 // 「中心轴边沿计数」:参数行最上面是**同一行**的「上升沿 / 下降沿」统计。
if (centerAxis && !layout.rowStats.empty()) if (centerAxis && !layout.rows.empty())
{ {
layout.rowStats.back() = gd::RectF(contentLeft, rowY, innerWidth, groupRow); layout.rows.back().stats = gd::RectF(contentLeft, rowY, innerWidth, groupRow);
rowY += groupRow; rowY += groupRow;
} }
const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, const gd::RectF pointsTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
layout.rowLevelTracks.back() = pointsTrack; layout.rows.back().levelTrack = pointsTrack;
layout.rowLevelValues.back() = layout.rows.back().levelValue =
gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn, gd::RectF(pointsTrack.X + pointsTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow); sliderRow);
layout.rowLevelResets.back() = layout.rows.back().levelReset =
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize);
rowY += sliderRow; rowY += sliderRow;
const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f, const gd::RectF iterTrack(trackLeft, rowY + (sliderRow - trackHeight) * 0.5f,
trackWidth, trackHeight); trackWidth, trackHeight);
layout.rowIterTracks.back() = iterTrack; layout.rows.back().iterTrack = iterTrack;
layout.rowIterValues.back() = layout.rows.back().iterValue =
gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn, gd::RectF(iterTrack.X + iterTrack.Width + 6.0f * m_scale, rowY, valueColumn,
sliderRow); sliderRow);
layout.rowIterResets.back() = layout.rows.back().iterReset =
gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize); gd::RectF(resetLeft, rowY + (sliderRow - resetSize) * 0.5f, resetSize, resetSize);
rowY += sliderRow; rowY += sliderRow;
} }
@@ -1136,9 +1136,7 @@ namespace jdi
// 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走 // 只登记"在可视内容区里"的分组栏与条目(滚出视界的条目不得抢走
// 底部按钮 / 滑块的点击)。 // 底部按钮 / 滑块的点击)。
layout.markerGroups.push_back(group); layout.markerGroups.push_back(group);
layout.rowRefs.push_back(ref); layout.AppendMarkerGroupRow(ref, group.header, group.close);
layout.rowNameRects.push_back(group.header);
layout.rowNameCloses.push_back(group.close);
} }
rowY += groupRow; rowY += groupRow;
if (group.collapsed) if (group.collapsed)
+37
View File
@@ -343,3 +343,40 @@
| 通用参数行(中心轴统计 + 两行滑块) | ⏸ 未提取:依赖 3 个 kind 标志 + 2 套拖拽/编辑状态 + 2 个滑块 lambda,提取需传 9 个参数,可读性收益低于参数表本身 | | 通用参数行(中心轴统计 + 两行滑块) | ⏸ 未提取:依赖 3 个 kind 标志 + 2 套拖拽/编辑状态 + 2 个滑块 lambda,提取需传 9 个参数,可读性收益低于参数表本身 |
| 条目栏(三角 + 名称 + 复选框 + 悬停) | 保留在 `PaintPanelListRows` 里(它是三种排版的公共前缀) | | 条目栏(三角 + 名称 + 复选框 + 悬停) | 保留在 `PaintPanelListRows` 里(它是三种排版的公共前缀) |
---
## 十一、第 39 轮追加(2026-11-04):平行数组合并为 `std::vector<PanelRow>`(§15.6 第 1 项)
> 触发:用户要求「继续按照上轮的建议继续推进」。上一轮结论是"若要做就做**方案 B**:
> `std::vector<PanelRow>` + 具名成员访问(**无视图**),并且必须单独一轮"。
| 节点 | 内容 | 验收标准 |
| --- | --- | --- |
| **P33** | `EdgePanelLayout` 的 16 个平行 `std::vector` 合并为 `std::vector<PanelRow> rows` | `rowgeom -mode compare`:**0** 条键消失、**0** 条已有键的值变化;新增键**全部**是空矩形 |
| **P34** | 唯一入口收敛为 `AppendRow` / `AppendMarkerGroupRow`;`int rows` 改名 `signalCount` | 编译零 warning;7 个面板像素哈希**逐项不变** |
| **P35** | 删掉因合并而变成恒真的 50+ 处 `rowIndex < layout.rows.size()` 判空 | 全量检查 133 断言全绿 |
### 11.1 本轮新增硬约束
1. **行的几何**只能通过 `rows[i].<字段>` 访问;**禁止**再加回平行数组,也**禁止**引入
`PanelColumn<T>` 列视图(拷贝 `EdgePanelLayout` 后忘重绑 = 静默悬垂,没有检查可以抓到)。
2. **新增一个行字段** = `PanelRow` 加一个成员 + `AppendRow` 里赋默认值(现在连这一步都不用,
成员自带默认构造)+ 需要绘制的地方读 `rows[i].字段`。**不需要**再同步任何"列清单"。
3. **`signalCount` ≠ `rows.size()`**:前者是"网格里的可见信号数"(`EdgePanelLayoutFor` 开头统计),
后者是"本面板实际产生的行数"。两者在「自由标记」面板里**不相等**(只有有固定点的信号才有行)。
4. **`rowgeom` 的键名保持 `<panel>.<旧列名>[NNN]`**:这样重构前后的基线可以逐字节比对,
等价性不靠人工核对。
### 11.2 等价性是怎么证明的(可复用的流程)
```text
old keys = 919, new keys = 965
仅存在于旧 = 0 ← 没有任何键消失
已有键的值变化 = 20 ← 全部是 ".count" 键:0 -> N(结构变化,不是几何变化)
仅存在于新 = 46 ← 全部是 <column>[NNN],且值全为 0.00,0.00,0.00,0.00
```
含义:旧结构里"某列根本没被 push"的那些行,现在每行都拥有该字段(值为空矩形)。
**逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希也逐项未变。
+79
View File
@@ -550,3 +550,82 @@ only-in-OLD lines NOT belonging to rowPoints/rowIterations = 0
| `rowgeom -mode compare` | 919 + 7 键,0 差异 | | `rowgeom -mode compare` | 919 + 7 键,0 差异 |
| 行数 | `PlotViewPaint.cpp` 5575 → 5565;`PlotViewPanelPaint.cpp` 1016 → 1020(净增 4 行:函数头 + 文档) | | 行数 | `PlotViewPaint.cpp` 5575 → 5565;`PlotViewPanelPaint.cpp` 1016 → 1020(净增 4 行:函数头 + 文档) |
---
## 第 39 轮(2026-11-04):平行数组合并为 `std::vector<PanelRow>`(§15.6 第 1 项)
> 这是"面板改一次就出错"的**最后一个机制性隐患**:旧的 16 个平行数组靠"每个布局分支
> 按同一顺序 push 恰好 N 项"维持对齐,任何一处漏推都是静默错位。
### P33 合并成 `std::vector<PanelRow>`
```cpp
struct PanelRow
{
SignalRef ref; // 该行的信号(MARK = 分组的代表信号)
gd::RectF header; // 信号色叠底条目栏 / 分组栏
gd::RectF nameRect; // 名称区
gd::RectF nameClose; // 名称右侧 ✕(仅 MARK)
gd::RectF triangle; // 展开/收起三角
gd::RectF check; // 右端复选框
gd::RectF stats; // 「中心轴」上升/下降沿统计行
gd::RectF levelTrack / levelValue / levelReset; // 滑块 1
gd::RectF iterTrack / iterValue / iterReset; // 滑块 2
gd::RectF infoMaxInterval / infoMinInterval; // 「重采样」两行只读统计
gd::RectF intervalField / frequencyField; // 「重采样」两行输入框
};
std::vector<PanelRow> rows; // 唯一的行容器
```
* 唯一入口:`AppendRow(ref, header, nameRect, triangle, check)`(返回新行引用)
与 `AppendMarkerGroupRow(ref, nameRect, nameClose)`(「自由标记」分组行)。
两者都**没有**"补齐其余列"这一步 —— 因为字段天生就在同一行里。
* 参数行 / 重采样行从 `layout.rowXxx.back() = …` 变成 `layout.rows.back().xxx = …`。
* 原来的 `int rows`(可见信号数)与新容器同名 ⇒ 改名 **`signalCount`**,
并注明它与 `rows.size()` 在 MARK 面板里**不相等**(只有有固定点的信号才有行)。
### P34 顺带消失的两类问题
| 旧问题 | 现在 |
| --- | --- |
| 某分支漏推一列 ⇒ 该列之后所有行读到别人的矩形 | `rows.push_back(row)` 一次成型,**不可能**错位 |
| `rowRefs` 字段重载(MARK 借它表示"分组",其余 12 列必须为空,长度不变式要按类别分流判定) | 每行自带 `ref`,语义由"填哪些字段"表达;不变式简化为"每行都要有名称区 + MARK 行不得带参数行字段" |
### P35 清理恒真判空
合并后 `rowIndex < layout.rowXxx.size()` 一律变成 `rowIndex < layout.rows.size()`,
而它们全都在 `for (… rowIndex < layout.rows.size(); …)` 循环体内 ⇒ **恒真**。
用正则批量删除 50+ 处(含 `a.size() != rows` 的 15 元组长度数组、`? x : gd::RectF()` 三元判空、
`if (rowIndex < …) { extendRow(...); }` 包裹),并逐文件编译验证。
⚠️ 中途有一次误操作:把"均值滤波双滑块"那段替换成了"弱峰双滑块",
编译器报 brace 不平衡 + 大面积类型错误 ⇒ 用备份 `Temp/PanelPaint.pre-cleanup.cpp` 回滚后重做。
**教训**:这类"整段替换"必须先用 `read_file` 精确读出 5 行上下文,不要凭记忆拼 oldString。
### 等价性证明(本轮最关键的一步)
```
old keys = 919, new keys = 965
仅存在于旧 = 0 ← 没有任何键消失
已有键的值变化 = 20 ← 全部是 ".count":0 -> N
仅存在于新 = 46 ← 全部是 <column>[NNN],值全为 0.00,0.00,0.00,0.00
```
⇒ 旧结构里"某列没被 push"的那些行,现在每行都拥有该字段但值为空矩形;
**逐行逐字段的真实几何一位都没变**,7 个面板的像素哈希(含 `resample=2BFC74B3D9B3239C`)
也逐项未变。之后按新结构重录正式基线(965 条键)。
### 本轮验证
| 项 | 结果 |
| --- | --- |
| `make release`(全量) | 零 error / 零 warning;产物 784,896 → **782,336 B**(−2,560) |
| `make debug` / `make -C Tools` | 零 error / 零 warning |
| `UnitTests` | `checks=260 failed=0 cases=19` |
| `OffscreenChecks -all` | 7/7 PASS,`checks=133 failed=0`(上轮 127:新增行结构断言) |
| `panels -mode compare` | 7 个面板像素哈希**逐项不变** |
| `rowgeom -mode compare` | 965 + 7 键,0 差异(默认数据);1268 + 7 键(MF4 566 行),0 差异 |
| 读点数 | 旧的 243 处 `layout.rowXxx[…]` 全部消失;现在 95 处 `layout.rows[…]`(84 在命中判定、11 在绘制) |
| 行数 | `PlotView.hpp` 4795;`PlotViewInteraction.cpp` 4399;`PlotViewPanelPaint.cpp` 935;`PlotViewPaint.cpp` 5534;`PlotViewPanels.cpp` 1729 |
+70
View File
@@ -683,3 +683,73 @@ B 的**剩余收益**主要是"单一分配 + 少 15 个 vector 头"(`EdgePane
让"MARK 面板 12 列为空"的注释消失。 让"MARK 面板 12 列为空"的注释消失。
3. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1 或 2。 3. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1 或 2。
---
## 十六、第 39 轮:平行数组合并为 `std::vector<PanelRow>`(§15.6 第 1 项,方案 B)
### 16.1 结论
| §15.6 建议 | 结果 |
| --- | --- |
| 1. 方案 B(`vector<PanelRow>` 无视图) | ✅ 完成:16 个平行数组 → `std::vector<PanelRow> rows`(17 个具名成员) |
| 2. 在 B 之上拆分 `rowRefs` 双语义 | ✅ **顺带完成**:`rows[i].ref` 取代重载的 `rowRefs`,MARK 分组行改走 `AppendMarkerGroupRow` |
| 3. 面板几何算式重写 | ⏸ 未做(本轮只做结构合并,算式未动) |
### 16.2 结构对比
| | 旧(16 个平行数组) | 新(`std::vector<PanelRow>`) |
| --- | --- | --- |
| 新增一列 | 改 `AppendRow` + 14 处布局分支 + 绘制 + 缓存键 + 调试框 + 检查的 15 元组 | 只在 `PanelRow` 加一个成员 |
| 漏推一列 | 该列之后**所有行**读到别人的矩形(静默) | 不可能(一行一次成型) |
| `rowRefs` 语义 | 列表式 = 行信号;MARK = 分组信号(同一字段两种含义) | `rows[i].ref` 一种含义;MARK 的分组行只填 `nameRect`/`nameClose` |
| 长度不变式 | 15 个 `size()` 组成的判据,且 MARK 要单独分流 | 恒真 ⇒ 改为语义断言(每行都要有名称区;MARK 行不得带参数行字段) |
| `EdgePanelLayout` 每实例 | 16 个 vector 头(16×24 B)+ 各自独立分配 | 1 个 vector 头 |
### 16.3 为什么这次敢做(前一轮不敢)
上一轮的顾虑是"列视图拷贝后忘重绑 = 静默悬垂"。方案 B **没有视图**:
所有访问都是 `rows[i].check`,`PanelRow` 是值类型、随 `rows` 一起拷贝,**结构上不可能悬垂**。
代价是 243 处读点必须机械改写(不省),收益是"漏推一列"与"字段重载"两类问题**从根上消失**。
### 16.4 等价性证明(本轮方法论的核心)
`rowgeom` 的键名刻意保持 `<panel>.<旧列名>[NNN]`,于是重构前后可以直接逐键比对:
```
old keys = 919, new keys = 965
仅存在于旧 = 0 ← 没有任何键消失
已有键的值变化 = 20 ← 全部是 ".count" 键:0 -> N(结构变化)
仅存在于新 = 46 ← 全部是新增列的 [NNN],值 0.00,0.00,0.00,0.00
```
再叠加 7 个面板的像素哈希逐项不变 ⇒ **渲染与几何都没有任何位移**。
这比"编译通过 + 肉眼看一眼"强得多:它把"等价重构"变成了**可证伪**的断言。
### 16.5 过程中唯一的一次事故
清理恒真判空时用"整段替换"把「均值滤波」双滑块段落误替换成「弱峰」双滑块,
编译器立刻报 brace 不平衡;用备份回滚后重做。
**结论:整段替换必须先精确读出上下文,oldString 不能凭记忆拼。**
### 16.6 验证
| 项 | 结果 |
| --- | --- |
| `make release` | 零 warning;784,896 → **782,336 B**(−2,560) |
| `make debug` / `make -C Tools` | 零 warning |
| `UnitTests` | 260 / 19 PASS |
| `OffscreenChecks -all` | 7 / **133** PASS(上轮 127) |
| `panels -mode compare` | 7 面板像素哈希逐项不变 |
| `rowgeom -mode compare` | 965 + 7 键 / 1268 + 7 键(MF4),0 差异 |
### 16.7 下一步(按价值排序)
1. **面板几何算式重写**(§13.6 遗留):结构合并与逐行基线都已就绪,现在"改算式"是**安全的**;
建议先做 `EdgePanelWidth`(逐信号量宽)与 `EdgePanelLayoutFor` 的行高累加,
判据 = `rowgeom -mode compare` + `panels -mode compare` 双基线归零。
2. **`PaintPanelListRows` 的通用参数行**提取(上轮因需 9 个参数而搁置):
现在 `row` 是一个整体、`extendRow` 已成组,可把"参数行"收成一个函数,
参数表从 9 个降到 `(g, layout, row, kind, state)` 5 个。
3. 若还要压体积:`EdgePanelLayout` 已无冗余列,剩余空间主要在字符串/浮点格式化(见 §11 的 gdtoa 结论)。
+63
View File
@@ -10695,3 +10695,66 @@ B 属**整洁性收益**(漏推一列已被 `AppendRow` + 长度不变式挡
`rows[].ref` + `markerGroups[]`,让"MARK 面板 12 列为空"的注释消失。 `rows[].ref` + `markerGroups[]`,让"MARK 面板 12 列为空"的注释消失。
2. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1。 2. 面板几何算式重写(§13.6 遗留):基线已就绪,但需先完成 1。
---
## 2026-11-04 · 第 39 轮(`Code_Review` 分支):平行数组合并为 `std::vector<PanelRow>`(§16.6 第 1 项,方案 B)
> 规则原文:`Documents/Code_Review_Report.md` §16、`Code_Review_Plan.md` §十一、
> `Code_Review_Progress.md` 第 39 轮。
### 1. ✅ `EdgePanelLayout` 的 16 个平行 `std::vector` → `std::vector<PanelRow> rows`
```cpp
struct PanelRow {
SignalRef ref; header; nameRect; nameClose; triangle; check; stats;
levelTrack; levelValue; levelReset; iterTrack; iterValue; iterReset;
infoMaxInterval; infoMinInterval; intervalField; frequencyField;
};
std::vector<PanelRow> rows;
```
唯一入口:`AppendRow(ref, header, nameRect, triangle, check)`、
`AppendMarkerGroupRow(ref, nameRect, nameClose)`(MARK 分组行)。
**参数行一律 `layout.rows.back().xxx = …` 赋值。**
### 2. 🔴 硬规则(本轮新增)
1. 行的几何**只能**通过 `rows[i].<字段>` 访问。**禁止**加回平行数组;
**禁止**引入 `PanelColumn<T>` 列视图(拷贝后忘重绑 = 静默悬垂,没有检查能抓到)。
2. **新增字段**只需在 `PanelRow` 加一个成员 —— 不再需要同步"列清单"
(`AppendRow` / 调试框 / 缓存键 / 检查里的 15 元组都已不存在)。
3. **`signalCount`(可见信号数)≠ `rows.size()`(本面板行数)**:MARK 面板里只有
"有固定点的信号"才有行。原 `int rows` 已改名 `signalCount`。
4. 长度不变式在结构上与恒真 ⇒ 已改为**语义断言**:每行必须有名称区;
MARK 行不得带 `header`/`check`/`stats`/滑块/重采样字段(防止把列表式布局误用到它上面)。
### 3. ⭐ `rowgeom` 键名刻意保持不变 ⇒ 等价重构**可证伪**
```
old keys = 919, new keys = 965
仅存在于旧 = 0 ; 已有键值变化 = 20(全是 ".count": 0 -> N)
仅存在于新 = 46(全是新增列 [NNN],值 0.00,0.00,0.00,0.00)
```
再叠加 7 面板像素哈希逐项不变 ⇒ 渲染/几何零位移。
**以后做"等价重构"都照这个流程:先保键名,再比对。**
### 4. ⚠️ 事故与教训
清理恒真判空时用"整段替换"把「均值滤波」双滑块段误换成「弱峰」双滑块,编译器立刻报
brace 不平衡。用备份回滚后重做。
**整段替换必须先 `read_file` 精确读出上下文,oldString 不能凭记忆拼。**
### 5. 本轮数字
* Release 784,896 → **782,336 B**(−2,560);release/debug/tools 全零 warning。
* `UnitTests` 260/19;`OffscreenChecks -all` 7 检查 / **133** 断言全 PASS(上轮 127)。
* `panels -mode compare` 7 面板像素哈希逐项不变;`rowgeom compare` 965+7 / 1268+7 键 0 差异。
* 读点:旧 243 处 `layout.rowXxx[…]` → 现 95 处 `layout.rows[…]`(84 命中判定 / 11 绘制)。
### 6. 下一步
1. **面板几何算式重写**(§13.6 遗留)—— 结构与双基线都已就绪,现在改算式是安全的;
建议先动 `EdgePanelWidth`(逐信号量宽)与 `EdgePanelLayoutFor` 的行高累加。
2. `PaintPanelListRows` 的"通用参数行"提取:现在参数表可从 9 个降到 5 个
(`g, layout, row, kind, state`)。
3. 体积剩余空间主要在字符串/浮点格式化(gdtoa 30+ KB 不可去掉,见 §11)。
+86 -122
View File
@@ -328,7 +328,7 @@ namespace jdi
AppendRectLine(text, key("clipBuildButton"), layout.clipBuildButton); AppendRectLine(text, key("clipBuildButton"), layout.clipBuildButton);
AppendRectLine(text, key("clipDestructiveButton"), layout.clipDestructiveButton); AppendRectLine(text, key("clipDestructiveButton"), layout.clipDestructiveButton);
text += key("collapsed") + "=" + (layout.collapsed ? "1" : "0") + "\n"; text += key("collapsed") + "=" + (layout.collapsed ? "1" : "0") + "\n";
text += key("rows") + "=" + std::to_string(layout.rows) + "\n"; text += key("rows") + "=" + std::to_string(layout.signalCount) + "\n";
text += key("markerContentHeight") + "=" + std::to_string(layout.markerContentHeight) + "\n"; text += key("markerContentHeight") + "=" + std::to_string(layout.markerContentHeight) + "\n";
text += key("scrollOffset") + "=" + std::to_string(layout.scrollOffset) + "\n"; text += key("scrollOffset") + "=" + std::to_string(layout.scrollOffset) + "\n";
@@ -342,46 +342,53 @@ namespace jdi
std::to_string(cell.width) + "\n"; std::to_string(cell.width) + "\n";
} }
// ---- 每一行 / 每一列 ---- // ---- 每一行 / 每一个字段 ----
// ⚠️ **每一列只按自己的长度导出**,不要假设所有列等长: // 2026-11-04(第 39 轮)`EdgePanelLayout` 的 16 个平行数组已合并为
// 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 // `std::vector<PanelRow> rows`,所以这里**逐行**导出全部字段即可,
// `rowNameRects` / `rowNameCloses`,其余 12 列**全为空** // 不再需要"每一列可能长度不同"的分列遍历(那种结构问题已从根上消失)。
// (这一点正是 `rowgeom` 第一次运行就抓出来的结构事实)。 //
// 按 `rowRefs.size()` 统一索引会越界崩溃。 // ⚠️ 键名保持与合并前**完全一致**(`<panel>.rowLevelTracks[001]=…`):
const auto dumpRects = [&text, &key](const char* name, const std::vector<gd::RectF>& rects) { // 这样重构前后的基线可以直接逐字节比对,等价性不靠人工核对。
text += key(name) + ".count=" + std::to_string(rects.size()) + "\n"; text += key("rowRefs") + ".count=" + std::to_string(layout.rows.size()) + "\n";
for (std::size_t index = 0; index < rects.size(); ++index) for (std::size_t index = 0; index < layout.rows.size(); ++index)
{
char cell[64] = {};
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
AppendRectLine(text, key(name) + cell, rects[index]);
}
};
text += key("rowRefs") + ".count=" + std::to_string(layout.rowRefs.size()) + "\n";
for (std::size_t index = 0; index < layout.rowRefs.size(); ++index)
{ {
char cell[64] = {}; char cell[64] = {};
std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index)); std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
text += key("rowRefs") + cell + "=" + text += key("rowRefs") + cell + "=" +
std::to_string(layout.rowRefs[index].groupIndex) + "/" + std::to_string(layout.rows[index].ref.groupIndex) + "/" +
std::to_string(layout.rowRefs[index].signalIndex) + "\n"; std::to_string(layout.rows[index].ref.signalIndex) + "\n";
} }
dumpRects("rowHeaders", layout.rowHeaders); const auto dumpField = [&text, &key](const char* name,
dumpRects("rowNameRects", layout.rowNameRects); const std::vector<PlotView::EdgePanelLayout::PanelRow>& rows,
dumpRects("rowNameCloses", layout.rowNameCloses); gd::RectF PlotView::EdgePanelLayout::PanelRow::*field) {
dumpRects("rowTriangles", layout.rowTriangles); text += key(name) + ".count=" + std::to_string(rows.size()) + "\n";
dumpRects("rowChecks", layout.rowChecks); for (std::size_t index = 0; index < rows.size(); ++index)
dumpRects("rowStats", layout.rowStats); {
dumpRects("rowLevelTracks", layout.rowLevelTracks); char cell[64] = {};
dumpRects("rowLevelValues", layout.rowLevelValues); std::snprintf(cell, sizeof(cell), "[%03d]", static_cast<int>(index));
dumpRects("rowLevelResets", layout.rowLevelResets); AppendRectLine(text, key(name) + cell, rows[index].*field);
dumpRects("rowIterTracks", layout.rowIterTracks); }
dumpRects("rowIterValues", layout.rowIterValues); };
dumpRects("rowIterResets", layout.rowIterResets); dumpField("rowHeaders", layout.rows, &PlotView::EdgePanelLayout::PanelRow::header);
dumpRects("rowInfoMaxInterval", layout.rowInfoMaxInterval); dumpField("rowNameRects", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameRect);
dumpRects("rowInfoMinInterval", layout.rowInfoMinInterval); dumpField("rowNameCloses", layout.rows, &PlotView::EdgePanelLayout::PanelRow::nameClose);
dumpRects("rowIntervalField", layout.rowIntervalField); dumpField("rowTriangles", layout.rows, &PlotView::EdgePanelLayout::PanelRow::triangle);
dumpRects("rowFrequencyField", layout.rowFrequencyField); dumpField("rowChecks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::check);
dumpField("rowStats", layout.rows, &PlotView::EdgePanelLayout::PanelRow::stats);
dumpField("rowLevelTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelTrack);
dumpField("rowLevelValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelValue);
dumpField("rowLevelResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::levelReset);
dumpField("rowIterTracks", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterTrack);
dumpField("rowIterValues", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterValue);
dumpField("rowIterResets", layout.rows, &PlotView::EdgePanelLayout::PanelRow::iterReset);
dumpField("rowInfoMaxInterval", layout.rows,
&PlotView::EdgePanelLayout::PanelRow::infoMaxInterval);
dumpField("rowInfoMinInterval", layout.rows,
&PlotView::EdgePanelLayout::PanelRow::infoMinInterval);
dumpField("rowIntervalField", layout.rows,
&PlotView::EdgePanelLayout::PanelRow::intervalField);
dumpField("rowFrequencyField", layout.rows,
&PlotView::EdgePanelLayout::PanelRow::frequencyField);
// ---- 自由标记:分组栏 / 表头 / 条目 ---- // ---- 自由标记:分组栏 / 表头 / 条目 ----
text += key("markerGroupCount") + "=" + std::to_string(layout.markerGroups.size()) + "\n"; text += key("markerGroupCount") + "=" + std::to_string(layout.markerGroups.size()) + "\n";
@@ -663,64 +670,47 @@ namespace jdi
rect.y + rect.height <= plotRect.y + plotRect.height + 1; rect.y + rect.height <= plotRect.y + plotRect.height + 1;
Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind)); Expect(inside, std::string("panel inside plot area: ") + PanelKindName(kind));
// 不变式:行的几何数组必须与行数**形状一致**。 // 不变式:**行结构自检**。
// //
// ⚠️⚠️ 判据必须**按排版类别**区分 —— 旧版"所有数组全部全等"是**错的**: // 2026-11-04(第 39 轮)起 `EdgePanelLayout` 的 16 个平行数组已合并成
// 「自由标记」面板复用 `rowRefs` 表示"信号分组",只填 // `std::vector<PanelRow> rows` ⇒ "某列比别的列短"这类错位**在结构上不可能**
// `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空** // (一行的字段是一个整体,由 `AppendRow` / `AppendMarkerGroupRow` 一次成型)。
// (它真正的行在 `markerRows` / `markerGroups` 里)。 // 旧的"15 列长度全部等于行数"因此退化为恒真,这里改为守住**语义**:
// 这是 `rowgeom` 第一次带真实固定点运行时越界崩溃所暴露的结构事实。 // * 每一行都必须有名称区(布局漏算就会变成空矩形,绘制时该行不可见/不可点);
// * 「自由标记」的行是**分组行**:参数滑块 / 重采样 / 统计字段必须保持为空
// (真正的条目行在 `markerRows`),防止有人把列表式布局误用到它上面。
const PanelStyle rowStyle = PanelStyleOf(kind); const PanelStyle rowStyle = PanelStyleOf(kind);
const std::size_t rowCount = layout.rowRefs.size(); bool rowsOk = true;
bool parallel = true; for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows)
if (rowStyle.markerGroups)
{ {
parallel = layout.rowNameRects.size() == rowCount && if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f)
layout.rowNameCloses.size() == rowCount && layout.rowHeaders.empty() &&
layout.rowChecks.empty() && layout.rowLevelTracks.empty() &&
layout.rowIterTracks.empty();
}
else
{
const std::size_t parallelSizes[] = {
layout.rowHeaders.size(), layout.rowNameRects.size(),
layout.rowTriangles.size(), layout.rowChecks.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()};
for (const std::size_t size : parallelSizes)
{ {
if (size != rowCount) rowsOk = false;
{ break;
parallel = false; }
break; if (rowStyle.markerGroups &&
} (row.header.Width > 0.0f || row.check.Width > 0.0f ||
row.stats.Width > 0.0f || row.levelTrack.Width > 0.0f ||
row.iterTrack.Width > 0.0f || row.intervalField.Width > 0.0f ||
row.frequencyField.Width > 0.0f))
{
rowsOk = false;
break;
} }
} }
if (!parallel) if (!rowsOk)
{ {
EmitLine("[panels] %s row arrays: refs=%d name=%d nameClose=%d hdr=%d chk=%d lvl=%d/%d/%d " EmitLine("[panels] %s row structure broken (rows=%d markerGroups=%d)\n",
"iter=%d/%d/%d info=%d/%d fld=%d/%d\n", PanelKindName(kind), static_cast<int>(layout.rows.size()),
PanelKindName(kind), static_cast<int>(layout.rowRefs.size()), layout.markerGroups.size());
static_cast<int>(layout.rowNameRects.size()), }
static_cast<int>(layout.rowNameCloses.size()), Expect(rowsOk, std::string("row structure is sound: ") + PanelKindName(kind));
static_cast<int>(layout.rowHeaders.size()), // 每一行的信号引用必须有效(布局用无效引用填行 ⇒ 绘制时会跳过该行)。
static_cast<int>(layout.rowChecks.size()), for (const PlotView::EdgePanelLayout::PanelRow& row : layout.rows)
static_cast<int>(layout.rowLevelTracks.size()), {
static_cast<int>(layout.rowLevelValues.size()), Expect(row.ref.IsValid() || rowStyle.markerGroups,
static_cast<int>(layout.rowLevelResets.size()), std::string("row has a valid signal ref: ") + PanelKindName(kind));
static_cast<int>(layout.rowIterTracks.size()),
static_cast<int>(layout.rowIterValues.size()),
static_cast<int>(layout.rowIterResets.size()),
static_cast<int>(layout.rowInfoMaxInterval.size()),
static_cast<int>(layout.rowInfoMinInterval.size()),
static_cast<int>(layout.rowIntervalField.size()),
static_cast<int>(layout.rowFrequencyField.size()));
} }
Expect(parallel, std::string("row arrays match row count: ") + PanelKindName(kind));
char buffer[160] = {}; char buffer[160] = {};
std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind), std::snprintf(buffer, sizeof(buffer), "%s=%016llX,%d,%d,%d,%d\n", PanelKindName(kind),
@@ -845,47 +835,21 @@ namespace jdi
EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d rows=%d\n", EmitLine("[panelscroll] %-8s content=%.1f view=%.1f maxScroll=%.1f scrollable=%d rows=%d\n",
name, initial.markerContentHeight, initial.content.Height, maxScroll, name, initial.markerContentHeight, initial.content.Height, maxScroll,
initial.scrollTrack.Width > 0.0f ? 1 : 0, initial.scrollTrack.Width > 0.0f ? 1 : 0,
static_cast<int>(initial.rowRefs.size())); static_cast<int>(initial.rows.size()));
// 多信号场景下同样守住"行数组形状一致"(判据按排版类别区分: // 行结构自检(合并成 `std::vector<PanelRow>` 后长度错位已不可能,
// MARK 只用 rowRefs / rowNameRects / rowNameCloses,详见 `panels` 检查里的说明)。 // 这里守住"每行都有名称区",详见 `panels` 检查里的说明)。
{ {
const PanelStyle rowStyle = PanelStyleOf(kind); bool rowsOk = true;
const std::size_t rows = initial.rowRefs.size(); for (const PlotView::EdgePanelLayout::PanelRow& row : initial.rows)
bool aligned = true;
if (rowStyle.markerGroups)
{ {
aligned = initial.rowNameRects.size() == rows && if (row.nameRect.Width <= 0.0f || row.nameRect.Height <= 0.0f)
initial.rowNameCloses.size() == rows && initial.rowHeaders.empty() &&
initial.rowChecks.empty();
}
else
{
const std::size_t sizes[] = {initial.rowHeaders.size(),
initial.rowNameRects.size(),
initial.rowTriangles.size(),
initial.rowChecks.size(),
initial.rowStats.size(),
initial.rowLevelTracks.size(),
initial.rowLevelValues.size(),
initial.rowLevelResets.size(),
initial.rowIterTracks.size(),
initial.rowIterValues.size(),
initial.rowIterResets.size(),
initial.rowInfoMaxInterval.size(),
initial.rowInfoMinInterval.size(),
initial.rowIntervalField.size(),
initial.rowFrequencyField.size()};
for (const std::size_t size : sizes)
{ {
if (size != rows) rowsOk = false;
{ break;
aligned = false;
break;
}
} }
} }
Expect(aligned, std::string("row arrays match row count: ") + name); Expect(rowsOk, std::string("every row has a name rect: ") + name);
} }
if (maxScroll <= 1.0f) if (maxScroll <= 1.0f)
@@ -1132,7 +1096,7 @@ namespace jdi
char line[160] = {}; char line[160] = {};
std::snprintf(line, sizeof(line), "%s.hash=%016llX rows=%d markerRows=%d\n", std::snprintf(line, sizeof(line), "%s.hash=%016llX rows=%d markerRows=%d\n",
PanelKindName(kind), static_cast<unsigned long long>(hash), PanelKindName(kind), static_cast<unsigned long long>(hash),
static_cast<int>(layout.rowRefs.size()), static_cast<int>(layout.rows.size()),
static_cast<int>(layout.markerRows.size())); static_cast<int>(layout.markerRows.size()));
scale += line; scale += line;
} }
+15 -11
View File
@@ -174,9 +174,9 @@ Tools\OffscreenChecks\Build\OffscreenChecks.exe -list
| --- | --- | --- | | --- | --- | --- |
| `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) | | `smoke` | 建隐藏窗口 → 加载 → 显示 3 条信号 → 渲染 → 抓帧 → 落盘 `smoke.png` | 客户区/绘图区尺寸合理、绘图区墨迹 > 1000 像素、**同一状态连续两帧逐像素相同**(渲染确定性) |
| `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 | | `frames` | 沿信号列表逐行移动"鼠标",每帧抓图 + 区域哈希 + 耗时 | 平均帧 < 500ms、帧序列**至少出现一次可见变化**;输出 `frame-XXX.png` 供事后分析 |
| `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**行数组与行数形状一致**(按排版类别判定,见 §4.4);`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** | | `panels` | 打开全部 7 个实验性面板(含双光标下的「选区截取」) | 每个面板:功能已启用、有尺寸、**必须被绘图区夹住**、**行结构自检**(每行都要有名称区;「自由标记」行不得带参数行字段,见 §4.4);`-mode record` 写 `panels-baseline.txt`,`-mode compare` 逐面板比对**像素哈希 + 几何** |
| `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住行数组与行数形状一致 | | `panelscroll` | 全部信号合并到同一网格后,逐一滚动 7 种面板的内容区 | 可滚动时:滚到底/滚回顶、`scrollOffset` 夹到 `maxScroll`、滑块到达/返回轨道端点、内容区几何不动;不可滚动时不得出现滚动条。另守住“每行都有名称区” |
| `rowgeom` | **逐行几何基线**:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出**每个矩形的每个字段**共 983 条键;规模层(全部信号 1 网格,MF4 = 566 行)导出每面板 FNV-1a 摘要 + 行数。`-mode record` / `-mode compare` 双向比对(MISSING / ADDED / CHANGED) | | `rowgeom` | **逐行几何基线**:7 个面板按「细节 / 规模」两层导出全部矩形并比对 | 细节层(6 信号 1 网格、参数行展开、3 个固定点、折起+展开)导出**每行全部字段**共 965 条键(`-data MF4-Record.MF4` 时 1268 条);规模层(全部信号 1 网格)导出每面板 FNV-1a 摘要 + 行数。`-mode record` / `-mode compare` 双向比对(MISSING / ADDED / CHANGED) |
| `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 | | `edgestats` | 3 个阈值下的边沿计数(普通计数)+ 中心轴计数 | 与 `Checks.cpp` 内的**独立参考实现**(朴素逐点)**逐位相等**;同参数重复查询结果稳定 |
| `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 | | `perf` | 整帧中位数、`EdgePanelLayoutFor` 单次耗时、198 个统计状态的整轮查询耗时 | 整帧 < 500ms、面板几何 < 200µs/次、统计查询 < 20µs/次;数字用于前后对比 |
@@ -208,16 +208,20 @@ OffscreenChecks.exe -run panels -mode compare -out ..\Temp\tools\panel-before
**基线是检测器,不是黄金图**:预期内的几何变化要重新 `-mode record`,并在提交信息里写明原因。 **基线是检测器,不是黄金图**:预期内的几何变化要重新 `-mode record`,并在提交信息里写明原因。
### 4.4 长度不变式为什幺要“按类别”判定 ### 4.4 长度不变式为什幺要“按类别”判定(2026-11-04 已作废,改为结构+语义断言)
`EdgePanelLayout::rowRefs` 是**重载字段**: > ⚠️ 本节描述的历史问题**已经不存在**:`EdgePanelLayout` 的 16 个平行数组
> 已在第 39 轮合并为 `std::vector<PanelRow> rows`,“某列比别的列短”在结构上不可能。
> 现在 `panels` / `panelscroll` 守的是**语义**:每行必须有名称区;「自由标记」行不得带
> `header`/`check`/`stats`/滑块/重采样字段。`rowgeom` 的键名(`<panel>.<旧列名>[NNN]`)
> 刻意保持不变,因此重构前后可以直接逐键比对(919 → 965 键时,消失 0 条、
> 已有值变化仅 20 条 `.count`、新增 46 条全为空矩形 ⇒ 几何零位移)。
* 列表式面板(均值/中心轴/弱峰/重采样/截取):`rowRefs[i]` = 第 i 行的信号; 历史背景(第 37–38 轮):当时 `EdgePanelLayout::rowRefs` 是**重载字段** ——
* 「自由标记」面板:`rowRefs[i]` = 第 i 个**信号分组**,真正的条目行在 `markerRows`, 列表式面板里是"第 i 行的信号",而「自由标记」面板里是"第 i 个信号分组",
且只填 `rowNameRects` / `rowNameCloses`,其余 12 列**必须为空**。 且只填 `rowNameRects` / `rowNameCloses`,其余 12 列必须为空。
因此那时的长度不变式只能按 `PanelStyleOf(kind).markerGroups` 分流判定。
所以判据按 `PanelStyleOf(kind).markerGroups` 分流(见 `Checks.cpp` 里 `panels` 的注释)。 第 39 轮合并成 `PanelRow` 后,这类"同一字段两种含义"的判据全部消失。
早先“18 列全等”的写法对 MARK 面板是**错的**,只因既有场景恰好未触发才没暴露。
### 4.5 输出(全部在 `Temp/tools/`,已 gitignore) ### 4.5 输出(全部在 `Temp/tools/`,已 gitignore)