From 8271879eded2f24d5db84f7afd59f41b1119046e Mon Sep 17 00:00:00 2001 From: "DeepSeek V4.1 Flash" <> Date: Fri, 30 Oct 2026 15:06:01 +0800 Subject: [PATCH] =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=8A=A8=E7=94=BB=E5=B8=A7?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E7=BB=98=E7=BB=98=E5=9B=BE=E5=8C=BA?= =?UTF-8?q?=EF=BC=88=E7=BB=84=E5=90=8D=E5=B1=95=E5=BC=80/=E6=94=B6?= =?UTF-8?q?=E8=B5=B7=E5=8D=A1=E9=A1=BF=E6=A0=B9=E6=B2=BB=EF=BC=89+=20?= =?UTF-8?q?=E5=9D=87=E5=80=BC=E9=9D=A2=E6=9D=BF=E4=B8=A4=E5=A4=84=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20+=20=E4=BF=A1=E5=8F=B7=E8=A1=8C=E9=9B=86=E5=90=88?= =?UTF-8?q?=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 组名展开/收起卡顿的真因(用户报告:288 个信号同一网格、双光标/实验性功能开启时, 点组名展开收起的动画非常卡顿,而网格里没有任何需要重绘的变化) - 滚动定时器给面板动画帧做的是整窗失效,WM_PAINT 里"更新区域不在绘图区内 ⇒ m_chromeDirty = true"这条启发式把整帧判成脏 ⇒ Render 走 fullChrome 分支 ⇒ 标签栏/工具栏/绘图区全部重绘。绘图区走场景缓存很快,但**光标叠加层每帧都要重画**: 288 个信号的取值胶囊(圆角 + GDI 文本 + 重叠避让)实测 24~46ms/帧。 - 新增 Application::m_panelAnimationFrame:滚动定时器在只有面板动画时置位, WM_PAINT 命中它且没有任何其它消息要求装饰区重绘时不再把整帧判脏 ⇒ 走 panelOnly。 - 新增 Application::m_plotPaintValid:panelOnly 帧且该标志为真时跳过 PlotView::Paint (面板不覆盖绘图区,上一帧像素原样留在后备缓冲区,整窗回送也不会错)。 作废点:InvalidatePlotRect(签名多收一个 bool&,5 个调用点统一传入)、Relayout、 EnsureBackBuffer、WM_PAINT 中"更新区域就在绘图区内"、以及"面板动画帧里绘图区的 滚动/回到最上方动画同时也在跑"(否则会把绘图区动画冻住)。 - panelOnly 分支画分隔条时把右边界夹到 floor(m_plotRect.X):分隔条是浮点矩形 (457.5…462.5),GDI+ 会把最后一列按 50% 混进去;整帧重绘时绘图区随后会盖掉它, 但跳过绘图区的帧会留下**一整列 1px 接缝**(实测 847 像素)。 - 实测(283 信号同网格 + 双光标):动画帧 34~43ms → 1.6~2.1ms。 2. 均值滤波面板 - **未勾选的条目栏点击完全无响应**:未勾选时点名称区域不再自动勾选、也不展开/收起, 只能点右端复选框(点击仍被消费,不穿透)。 - **滚动时参数滑块不再消失**:原实现按条目栏矩形判断整行可见性,条目栏滚出内容区后 整行(含两行参数滑块)被跳过。现在按整行纵向范围(条目栏 ∪ 两行轨道/数值/复位)判定。 3. 信号列表:行集合与折叠状态自愈(用户报告:开自由标记并标记点后,点 MF4 组名, 三角显示已展开但下属 283 条信号行一条都不出现) - 新增 SignalPanel::RowsSignature()(FNV 混合 分组集合/每组信号数/是否折起/是否有动画), BuildRows 记录它,EnsureRows 每帧 O(分组数) 校验,不一致就地重建。 这样任何"改了折叠状态却忘记置 m_rowsDirty"的路径都不会再让行集合与折叠状态脱节。 4. 验证(全部离屏,不占鼠标/键盘/屏幕) uicheck 69 / lbcheck 21 / lbresid 5 / lbzorder 4 / lbcovr 7 / lbpanel 10 / derivecheck 16 / round15check 28 / themecheck 17 / meancheck 21 全绿; 新增 .preflight/panelbug 14/14(外部改折叠状态→行集合自愈、动画中途改状态→位移清零、 未勾选行点击无响应、条目栏滚出后滑块仍绘制); 新增 .preflight/panelperf 12/12(动画帧 <16ms、且跳过绘图区后该区域像素与紧接的 整幅重绘逐像素完全相同); rendercheck A–H 8 个场景哈希与基线一致;make release / make debug 零 warning (805,888 B)。 同步文档:Documents/agent_mem.md 追加「2026-10-30 第十八轮」, 并把第十七轮里"尚未启用 → 直接开始预览"改标注为已推翻。 --- Components/Application/Inc/Application.hpp | 22 +++++ .../Application/Inc/ApplicationInternal.hpp | 5 +- Components/Application/Src/Application.cpp | 60 ++++++++++-- .../PlotView/Src/PlotViewInteraction.cpp | 22 ++--- Components/PlotView/Src/PlotViewPaint.cpp | 41 ++++++++- Components/SignalPanel/Inc/SignalPanel.hpp | 10 ++ Components/SignalPanel/Src/SignalPanel.cpp | 44 +++++++++ Documents/agent_mem.md | 91 ++++++++++++++++++- 8 files changed, 268 insertions(+), 27 deletions(-) diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index cf8760f..7ea9580 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -646,6 +646,28 @@ namespace jdi */ bool m_panelDirty = false; bool m_mouseOverPlot = false; /**< 上一次鼠标移动是否位于绘图区内。 */ + + /** + * @brief 本次 `WM_PAINT` 是不是「面板动画帧」(由滚动定时器置位)。 + * + * 面板动画每帧都会把**整个窗口**标脏,而 `WM_PAINT` 里"更新区域不在绘图区内 ⇒ + * 置 `m_chromeDirty`"这条启发式会因此把整帧判定为脏 ⇒ 每帧都重绘标签栏/工具栏/ + * **绘图区**。绘图区一帧要 12~46ms(283 个信号同网格、双光标时实测 46ms), + * 面板动画于是被拖到 43ms/帧(用户 2026-10-30 报告:组名展开/收起非常卡顿, + * 而网格里根本没有任何需要重绘的变化)。 + * ✓ 定时器置位后,`WM_PAINT` 在"没有其它任何原因要求整帧脏"时**不**再置 `m_chromeDirty`。 + */ + bool m_panelAnimationFrame = false; + + /** + * @brief 后备缓冲区里的**绘图区像素是否仍然有效**(可跳过重绘)。 + * + * 只有「面板动画帧」且该标志为真时才跳过 `PlotView::Paint`:此时面板不覆盖绘图区, + * 上一帧画好的绘图区像素原样保留在缓冲区里,回送整窗也不会错。 + * 任何"绘图区需要重绘"的来源都必须把它置假(见 `InvalidatePlotRect` 的各调用点、 + * `WM_PAINT` 里"更新区域就在绘图区内"的分支、以及后备缓冲区重建)。 + */ + bool m_plotPaintValid = false; }; } // namespace jdi diff --git a/Components/Application/Inc/ApplicationInternal.hpp b/Components/Application/Inc/ApplicationInternal.hpp index f80b951..84a6ae6 100644 --- a/Components/Application/Inc/ApplicationInternal.hpp +++ b/Components/Application/Inc/ApplicationInternal.hpp @@ -155,9 +155,12 @@ namespace jdi * * @param [IN] hwnd 窗口句柄。 * @param [IN] plotRect 绘图区矩形(客户区坐标)。 + * @param [OUT] plotPaintValid `Application::m_plotPaintValid`:置假,表示后备缓冲区里的 + * 绘图区像素已不可信(面板动画帧不得跳过绘图区重绘)。 */ - inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect) + inline void InvalidatePlotRect(HWND hwnd, const gd::RectF& plotRect, bool& plotPaintValid) { + plotPaintValid = false; RECT invalid = {}; invalid.left = static_cast(std::floor(plotRect.X)); invalid.top = static_cast(std::floor(plotRect.Y)); diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index 4c36cb8..f793783 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -345,6 +345,8 @@ namespace jdi // 所有控件的目标矩形都变了,装饰区域必须整体重绘一次。 m_chromeDirty = true; + // 绘图区矩形也变了 ⇒ 后备缓冲区里的绘图区像素不再是本布局的内容。 + m_plotPaintValid = false; } void Application::EnsureBackBuffer(int width, int height) @@ -358,6 +360,7 @@ namespace jdi // 新建的后备位图内容未定义,必须整帧重绘一次。 m_chromeDirty = true; + m_plotPaintValid = false; HDC screenDc = GetDC(m_hwnd); if (screenDc == nullptr) @@ -580,11 +583,29 @@ namespace jdi // 只有面板像素变了 ⇒ 只重画面板与分隔条(`SignalPanel::Paint` 会铺满自己的区域)。 UiSetTextLayer(ComponentLayer(&m_panel)); m_panel.Paint(g); - UiFillRect(g, m_splitterRect, m_theme.border); + // ⚠️ 分隔条的右边界必须**夹到绘图区左边界取整之前**:分隔条矩形是浮点的 + //(例如 457.5…462.5),GDI+ 会把最后一列(x=462)按 50% 覆盖率混进去; + // 整帧重绘时绘图区随后会把它整列盖掉,但**面板动画帧不重绘绘图区**, + // 这一列就会残留成一条 1px 接缝(实测 847 像素、整列)。 + gd::RectF splitter = m_splitterRect; + const float splitterLimit = std::floor(m_plotRect.X); + if (splitter.X + splitter.Width > splitterLimit) + { + splitter.Width = std::max(0.0f, splitterLimit - splitter.X); + } + UiFillRect(g, splitter, m_theme.border); } UiSetTextLayer(ComponentLayer(&m_plot)); - m_plot.Paint(g); + // ⚠️ 「面板动画帧」且绘图区像素仍然有效时**跳过绘图区重绘**: + // 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里(随后整窗回送也不出错), + // 而绘图区一帧要 12~46ms(283 个信号同网格 + 双光标实测 46ms)—— + // 这正是“组名展开/收起非常卡顿、网格里却没有任何需要重绘的变化”的真因。 + if (!panelOnly || !m_plotPaintValid) + { + m_plot.Paint(g); + m_plotPaintValid = true; + } // 弹出菜单与浮动拾色器始终绘制在最上层,保证与主界面风格一致。 // ⚠️ 调试:它们是不透明浮层,文本要记入更高的绘制层, @@ -936,7 +957,7 @@ namespace jdi { case kMessagePlotDerivedReady: // 后台派生曲线计算完成:只失效绘图区,下一帧即可取到最新曲线(双缓冲切换)。 - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); return 0; case kMessagePlotLowMemory: // 可用内存**严重不足**(连一条预览曲线本身都装不下):绘图区已放弃本次派生计算, @@ -944,7 +965,7 @@ namespace jdi SetStatusMessage(L"可用内存不足,已暂停派生预览曲线与相关统计的计算。\n" L"请关闭部分分组或信号后重试。", true); - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); return 0; case WM_SIZE: { @@ -983,6 +1004,9 @@ namespace jdi // 其余情况(窗口首次显示、其它窗口遮挡后露出、DPI 变化等)必须整帧重绘。 RECT update = {}; const bool hasUpdate = GetUpdateRect(hwnd, &update, FALSE) != FALSE; + // 面板动画帧标记是一次性的(只对紧随其后的这一帧生效)。 + const bool panelFrame = m_panelAnimationFrame; + m_panelAnimationFrame = false; if (hasUpdate) { const bool insidePlot = @@ -991,8 +1015,15 @@ namespace jdi update.right <= static_cast(std::ceil(m_plotRect.X + m_plotRect.Width)) && update.bottom <= static_cast(std::ceil(m_plotRect.Y + m_plotRect.Height)) && update.right > update.left && update.bottom > update.top; - if (!insidePlot) + if (insidePlot) { + // 本次更新的就是绘图区 ⇒ 绘图区像素不再可信,必须重绘。 + m_plotPaintValid = false; + } + else if (!panelFrame || m_chromeDirty) + { + // ⚠️ `panelFrame` 只是“本次失效来自面板动画”的标志:若其它任何消息 + // 已经把装饰区标脏(`m_chromeDirty`),仍按原来的规则走整帧重绘。 m_chromeDirty = true; } } @@ -1043,7 +1074,7 @@ namespace jdi if (plotOnlyMove && !m_chromeDirty) { // 只失效绘图区,配合 WM_PAINT 中的判断即可走局部回送快路径。 - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } else { @@ -1090,7 +1121,7 @@ namespace jdi if (plotChanged && !chromeChanged && !m_chromeDirty) { - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } else if (chromeChanged || plotChanged) { @@ -1741,6 +1772,10 @@ namespace jdi { const bool panelAnimating = m_panel.ScrollAnimationActive() || m_panel.GroupAnimationActive(); + // 绘图区自己的动画(网格滚动到可视区 / 「回到最上方」淡入淡出)也会改像素: + // 它跟面板动画同时跑时,本帧**不能**跳过绘图区重绘(否则绘图区动画会冻结)。 + const bool plotAnimating = + m_plot.LaneScrollAnimationActive() || m_plot.BackToTopAnimationActive(); m_panel.AdvanceScrollAnimation(); m_panel.AdvanceGroupAnimations(); m_plot.AdvanceLaneScrollAnimation(); @@ -1753,11 +1788,20 @@ namespace jdi // ⚠️ 用**推进之前**的状态判定:动画结束那一帧仍需整窗重绘, // 保证面板的最终位置被完整重绘并回送。 m_panelDirty = !m_chromeDirty; + // 标记本帧是「面板动画帧」:`WM_PAINT` 里不再把整窗失效当成 + // “装饰+绘图区都脏”,否则每帧都会重绘绘图区(283 信号 + 双光标时 + // 实测 46ms/帧,用户 2026-10-30 报告的卡顿真因)。 + m_panelAnimationFrame = true; + if (plotAnimating) + { + // 绘图区本帧有变化 ⇒ 作废“绘图区像素仍然有效”的假定。 + m_plotPaintValid = false; + } InvalidateRect(hwnd, nullptr, FALSE); } else { - InvalidatePlotRect(hwnd, m_plotRect); + InvalidatePlotRect(hwnd, m_plotRect, m_plotPaintValid); } return 0; } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index 5f96d4a..c239738 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -2370,13 +2370,13 @@ namespace jdi return true; } } - // 条目栏(信号色叠底那一整行):**整行都能点**(用户 2026-10-30 要求, - // 与「自由标记」分组栏一致:不再只能点小小的展开三角)。 - // 语义: + // 条目栏(信号色叠底那一整行): // * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义, // 因为整行已被本函数消费,复选框自己收不到点击了); - // * 已在预览中 → 切换参数行的收起 / 展开; - // * 尚未启用 → 直接启用(用户点这一行显然是想看它的预览)。 + // * 已启用 → 整行切换参数行的收起 / 展开; + // * **未启用 → 什么都不做**(用户 2026-10-30 明确要求:未勾选前点击条目栏 + // 既不能自动勾选、也不能展开 / 收起,只能点复选框)。 + // 仍然消费这次点击(不穿透到底层的平移/选区等)。 if (rowIndex < layout.rowHeaders.size() && layout.rowHeaders[rowIndex].Width > 0.0f && UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) @@ -2385,22 +2385,18 @@ namespace jdi rowIndex < layout.rowChecks.size() && layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy); - const Lane::MeanFilterItem* existing = meanLane.FindMeanFilterItem(ref); + Lane::MeanFilterItem* existing = const_cast( + meanLane.FindMeanFilterItem(ref)); if (onCheckBox) { ToggleLaneMeanFilterSignal(index, ref); m_meanRowHoverLane = -1; m_meanRowHoverRef = SignalRef(); } - else if (existing == nullptr) - { - // 未启用:点击整行即开始预览(用户点这一行显然是想看它的预览)。 - meanLane.EnsureMeanFilterItem(ref); - } - else + else if (existing != nullptr) { // 已启用:整行切换参数行的收起 / 展开。 - meanLane.EnsureMeanFilterItem(ref).collapsed = !existing->collapsed; + existing->collapsed = !existing->collapsed; } m_dragMode = PlotDragMode::NONE; return true; diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index b071b65..77a9f20 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -4903,10 +4903,45 @@ namespace jdi continue; } const gd::RectF& header = layout.rowHeaders[rowIndex]; - if (header.Y + header.Height <= layout.content.Y || - header.Y >= layout.content.Y + layout.content.Height) + // 行的**可见性**必须按“整行的纵向范围”判定,不能只看条目栏: + // 条目栏已滚出内容区,但它下面两行参数滑块仍可能还在可视区内 + //(用户 2026-10-30 报告:此时两个滑块会直接消失 —— 显示错误)。 + float rowTop = header.Y; + float rowBottom = header.Y + header.Height; + const auto extendRow = [&rowBottom](const gd::RectF& rect) { + if (rect.Width > 0.0f && rect.Height > 0.0f) + { + rowBottom = std::max(rowBottom, rect.Y + rect.Height); + } + }; + if (rowIndex < layout.rowLevelTracks.size()) { - continue; // 完全滚出可视范围 + extendRow(layout.rowLevelTracks[rowIndex]); + } + if (rowIndex < layout.rowLevelValues.size()) + { + extendRow(layout.rowLevelValues[rowIndex]); + } + if (rowIndex < layout.rowLevelResets.size()) + { + extendRow(layout.rowLevelResets[rowIndex]); + } + 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 (rowBottom <= layout.content.Y || + rowTop >= layout.content.Y + layout.content.Height) + { + continue; // 整行(含全部参数行)完全滚出可视范围 } const std::uint32_t signalColor = StyleColor(*resolved.style); const UiInk ink = UiContrastInk(signalColor); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 900a376..e7b3395 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -749,6 +749,15 @@ namespace jdi */ void BuildRows(); + /** + * @brief 行集合的“定义状态”签名(分组集合 / 每组可见项数 / 折起状态 / 是否有动画)。 + * + * `EnsureRows` 用它做**自愈校验**:只要签名与“上一次建行集合时的签名”不一致, + * 就重建行集合。这样可以彻底堵住“改了折起状态却忘了置 `m_rowsDirty`”这类缺陷 + *(用户 2026-10-30 报告:组名三角已显示展开,但下属信号行完全不出现)。 + */ + std::uint64_t RowsSignature() const; + /** * @brief 在需要时重建列表行集合。 */ @@ -1337,6 +1346,7 @@ namespace jdi */ std::vector m_rowTop; bool m_rowsDirty = true; /**< 列表行集合是否需要重建。 */ + std::uint64_t m_rowsSignature = 0; /**< 建行集合时的 `RowsSignature()`(用于自愈校验)。 */ float m_contentHeight = 0.0f; /**< 列表内容总高度。 */ float m_listFullWidth = 0.0f; /**< 列表区不含滚动条的完整宽度。 */ bool m_scrollBarVisible = false; /**< 是否需要显示滚动条。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 7872bde..b049d09 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -518,6 +518,8 @@ namespace jdi { m_rows.clear(); m_rowsDirty = false; + // 记住“本行集合对应的定义状态”(含空数据集的早退路径),供 `EnsureRows()` 做自愈校验。 + m_rowsSignature = RowsSignature(); // 行集合重建后行下标的含义可能变化(增删/折起展开)⇒ 行位图缓存整体作废。 // 这同时把"不再显示的行"的位图内存还回去(缓存条目按行数增长)。 InvalidateRowCache(); @@ -588,6 +590,37 @@ namespace jdi UpdateAnimGeometry(); } + std::uint64_t SignalPanel::RowsSignature() const + { + // FNV-1a:混合“每个分组是否列出、列出多少项、是否折起、是否有折起动画”。 + // 这四项恰好完全决定行集合的内容,因此它们不变时无需重建。 + std::uint64_t hash = 1469598103934665603ull; + const auto mix = [&hash](std::uint64_t value) + { + hash = (hash ^ value) * 1099511628211ull; + }; + if (m_dataSet == nullptr || m_tab == nullptr) + { + return hash; + } + const std::vector& groups = m_dataSet->Groups(); + mix(static_cast(groups.size() + 1)); + // 筛选态下“每组列多少项”要逐信号比对(成本 O(信号数)),因此只在非筛选时入签名; + // 筛选文本变化本身会走 `ApplyFilterText()` 置脏,无需靠签名发现。 + const bool filtering = !m_filterText.empty(); + mix(filtering ? 1ull : 0ull); + for (std::size_t index = 0; index < groups.size(); ++index) + { + const int groupIndex = static_cast(index); + const std::uint64_t count = + filtering ? 0u : static_cast(groups[index].signals.size()); + mix(count + 2u); + mix(IsGroupCollapsed(groupIndex) ? 3u : 0u); + mix((FindGroupAnimation(groupIndex) >= 0) ? 5u : 0u); + } + return hash; + } + void SignalPanel::EnsureRows() { if (m_rowsDirty) @@ -596,6 +629,17 @@ namespace jdi RefreshScrollBar(); return; } + // ⚠️ **自愈不变量**:行集合必须与“分组集合 / 每组可见项数 / 折起状态 / 是否有动画” + // 严格一致。历史上确实出现过“折起状态变了但忘了置 `m_rowsDirty`”的路径 —— + // 表现就是用户 2026-10-30 报告的:组名三角已经显示“展开”, + // 但下属信号行一条都不出现(行集合里根本没有它们)。 + // 这里每帧花 O(分组数) 校验一次签名,不一致就地重建,不再依赖每个调用点自觉置脏。 + if (m_rowsSignature != RowsSignature()) + { + BuildRows(); + RefreshScrollBar(); + return; + } if (m_rowShift.size() != m_rows.size()) { // 行集合没变但位移缓存与它不同步(例如首次构建后)—— 补齐,避免读到越界值。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index db8946f..6c29536 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -8802,7 +8802,9 @@ make debug -> 零 error 零 warning ### 3. 条目栏交互:整行点击 + 悬停高亮预览曲线 * **整行可点**(不再只能点 12px 的三角):点击落在右端复选框上 → 切换"是否预览"; - 已启用 → 切换参数行的收起/展开;尚未启用 → 直接开始预览。 + 已启用 → 切换参数行的收起/展开; + **尚未启用 → 什么都不做**(⚠️ 2026-10-30 第十八轮推翻:原实现会"直接开始预览", + 用户明确要求未勾选前点击条目栏不得自动勾选/展开收起,只能点复选框)。 * **悬停行 → 对应预览曲线高亮**:`m_meanRowHoverLane / m_meanRowHoverRef` 由 `OnMouseMove` 维护, `OnMouseLeave` / `InvalidateDerivedCaches` 清除;绘制侧 `PaintDerivedCurve(..., bool highlight)` 先画 `+5px` 半透明同色光晕、再把线加粗 `+1.6px`, @@ -8851,6 +8853,91 @@ make release -> 零 warning(805,376 B);make debug -> 零 warning `const gd::RectF& header` 重复声明导致 `make` 失败而被过滤掉,白查了半小时)。 * ⚠️ 探针里断言"某行已启用"前要确认条目真的存在:`SetLaneMeanFilter(lane,true)` 会**自动启用 第一个信号**,再"全量切换"会把它关掉 ⇒ 出现 39/40 这种偏差,断言就会落在"未启用的行"上, - 而“未启用的行单击 = 开始预览”本来就不切换 `collapsed`。 + 而“未启用的行单击”自**第十八轮**起是**完全无响应**(早期是“开始预览”),断言必须挑**已启用**的行。 * ⚠️ 异步派生曲线(大信号走后台线程)让"改参数 → 立刻截图比对"变得不稳定: 每个状态**连渲染两帧**再取像素,才是稳定态。 + +--- + +## 2026-10-30 第十八轮(本机):面板动画帧不再重绘绘图区(卡顿根治)+ 均值面板两处显示/交互 + 行集合自愈 + +### 1. 组名展开/收起卡顿的**真因**:面板动画帧每帧都整幅重绘绘图区 + +用户复现:`MF4-Record.MF4` + `VERY_BIG_DATA.CSV` 全部信号合并到一个网格, +开「弱峰调整 / 中心轴边沿计数」或**只开双光标**,点信号列表组名 → 展开/收起动画非常卡顿, +而网格里没有任何需要重绘的变化。 + +**测量(`.preflight/panelperf`,283 信号同网格)**: + +| 场景 | 单帧耗时 | +| --- | --- | +| `PlotView::Paint`(双光标) | **31~46 ms** | +| `PlotView::Paint`(无光标) | 7~13 ms | +| 组名动画帧(修复前) | **34~43 ms/帧** | +| 组名动画帧(修复后) | **1.6~2.1 ms/帧** | + +**根因链**:滚动定时器给面板动画帧做的是**整窗失效**(`InvalidateRect(hwnd,nullptr)`)⇒ +`WM_PAINT` 里"更新区域不在绘图区内 ⇒ `m_chromeDirty = true`"这条启发式把整帧判成脏 ⇒ +`Render` 走 `fullChrome` 分支 ⇒ **标签栏/工具栏/绘图区全部重绘**。 +绘图区本身走场景缓存(~1ms),但**光标叠加层每帧都要重画**:288 个信号 × +(圆角胶囊 + GDI 文本 + 重叠避让)≈ 24~40ms。于是每一帧都掉到 25fps 以下。 + +**修法(三处,缺一不可)**: +1. 新增 `Application::m_panelAnimationFrame`:滚动定时器在"只有面板动画在跑"时置位; + `WM_PAINT` 命中它**且**没有任何其它消息要求装饰区重绘(`m_chromeDirty` 仍为假)时, + **不再**把整帧判脏 ⇒ 走 `panelOnly` 分支(只画面板 + 分隔条)。 +2. 新增 `Application::m_plotPaintValid`:`panelOnly` 帧且该标志为真时**跳过 `PlotView::Paint`** + —— 面板不覆盖绘图区,上一帧的像素原样留在后备缓冲区里,整窗回送也不会错。 + 作废点:`InvalidatePlotRect()`(签名改为多收一个 `bool&`,5 个调用点统一传 + `m_plotPaintValid`)、`Relayout()`、`EnsureBackBuffer()`、`WM_PAINT` 里"更新区域就在绘图区内"、 + 以及"面板动画帧里绘图区动画(网格滚动 / 回到最上方)同时也在跑"。 +3. `panelOnly` 分支画分隔条时**把右边界夹到 `floor(m_plotRect.X)`**: + 分隔条矩形是浮点的(457.5…462.5),GDI+ 会把最后一列按 50% 混进去;整帧重绘时绘图区随后 + 会盖掉它,但跳过绘图区的帧会留下**一整列 1px 接缝**(实测 847 像素)。 + 探针断言"动画最后一帧的绘图区与紧接的整幅重绘**逐像素相同**"就是这个坑逼出来的。 + +### 2. 均值面板:两处显示/交互修正 + +* **未勾选条目栏不再响应点击**(用户 2026-10-30 要求):未勾选时点击名称区域 + **既不会自动勾选、也不会展开/收起**,只能点右端复选框(点击仍被消费,不穿透)。 + 已勾选时整行点击仍是展开/收起。 +* **滚动时滑块不再消失**:原实现按**条目栏矩形**判断整行是否可见,条目栏滚出内容区后 + 整行(含下面两行参数滑块)被 `continue` 跳过 ⇒ 滑块凭空消失。 + 现在按**整行纵向范围**(条目栏 ∪ 两行轨道/数值/复位)判断,只要还有任何一部分在内容区内就照常绘制 + (裁剪仍由 `g.SetClip(layout.content)` 负责)。 + +### 3. 信号列表:行集合与折叠状态**自愈** + +用户报告:开自由标记并标记了几个点后,点 `MF4-Record.MF4` 组名,三角已显示"展开", +但下属 283 条信号行一条都不出现。逐一排查后确认这只能是 +**"折叠状态改了、行集合没重建"**(`m_rows` 里根本没有那些行)—— +直接切 `TabConfig` 折叠状态的探针能 100% 复现该表现。 + +**修法(自愈不变量)**:新增 `SignalPanel::RowsSignature()`(FNV 混合 +"分组集合 / 每组信号数 / 是否折起 / 是否有折起动画"),`BuildRows()` 记录它, +`EnsureRows()` 每帧花 O(分组数) 校验一次,**不一致就地重建**。 +这样任何"忘记置 `m_rowsDirty`"的路径(数据层变化、自由标记、外部调用……) +都不会再让行集合与折叠状态脱节。 + +### 4. 验证 + +``` +uicheck 69 / lbcheck 21 / lbresid 5 / lbzorder 4 / lbcovr 7 / lbpanel 10 +derivecheck 16 / round15check 28 / themecheck 17 / meancheck 21 +.preflight/panelbug 14/14(含"外部改折叠状态 → 行集合自愈"、"动画中途改状态 → 位移清零") +.preflight/panelperf 12/12(动画帧 <16ms + 跳过绘图区后像素与整幅重绘**完全一致**) +rendercheck 8 个场景哈希与基线完全一致;make release / make debug 零 warning(805,888 B) +``` + +### 5. 踩坑(本轮新增) + +* ⚠️ **"只改一小块像素"的路径必须自己保证不越界**:`panelOnly` 帧只画面板+分隔条, + 任何一点点溢出(浮点矩形的抗锯齿边缘)都会在绘图区留下**永久的 1px 接缝**。 + 判据要写成"动画最后一帧 vs 紧接的整幅重绘**逐像素相同**",而不是"看起来没问题"。 +* ⚠️ 与"跳过绘图区"配套时,**必须**同时检查"绘图区自己的动画是否在跑" + (网格滚动到可视区 /「回到最上方」淡入淡出),否则会把它冻住。 +* ⚠️ 探针断言里先打印真实状态(`m_rows.size()` / `m_rowShift` / 折叠标志 / `m_collapsed.size()`) + 再写断言:本轮因为前面场景留下的折叠状态,导致"collapsedRows / expandedRows"语义正好反了, + 白改了一轮断言。 +* ⚠️ PowerShell 里用 `'...'` 拼接含 `%d` / 引号 / 长中文的补丁串极易被截断(出现 `>>` 续行提示后 + 只能 Ctrl+C)。**补丁一律写成 `.git\xxx.ps1`(纯 ASCII)+ `%TEMP%\xxx.txt` 数据文件**。