diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index aeea274..c01564b 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -917,25 +917,14 @@ namespace jdi /** * @brief 将两条光标的时间夹取到当前可见时间范围内。 * - * ⚠️ 拖拽光标时使用;**平移**视界后也要用(见 `ClampCursorsAfterPan`)。 - * 而**离散**的视图变化(选区放大 / 适应视图 / 按网格时间轴展开)必须**不**移动光标: - * 光标允许滞留在网格可视区域之外,此时仅把它的取值/时间读数贴在网格边缘显示。 + * ⚠️ **仅在拖拽光标时使用**。视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**一律不**调用: + * 光标是一个固定的**时间位置**,视界移动时它必须留在原处(可以滑出可视区), + * 此时仅把它的取值/时间读数贴在网格边缘显示。 * * @param [IN] which 要夹取的光标序号(1 或 2)。 */ void ClampCursorToView(int which); - /** - * @brief **平移视界**之后把时间光标夹回可见区间(单/双光标都处理)。 - * - * ⚠️ 与「离散视图变化不移动光标」并不矛盾:选区放大 / 适应视图 / 按网格时间轴展开 - * 仍然不动光标(用户可以先量好再自由探索,光标读数贴在边缘可读、可拖回来); - * 但**平移**是连续手势 —— 尤其在深放大区间,光标一旦被落在屏外就会立刻"跑飞" - * 很远(一屏的平移就是几十甚至上百倍可视跨度),用户会以为光标丢失。 - * 因此在平移(中键/左键拖拽、Shift+滚轮、方向键)之后夹一次。 - */ - void ClampCursorsAfterPan(); - /** * @brief 计算双光标之间的时间差(绝对值)。 * @return 时间差;非双光标模式返回 0。 @@ -2383,6 +2372,14 @@ namespace jdi * @return 可以直接滚动 + 贴图层像素返回 true。 */ bool LayersStructurallyReady() const; + /** + * @brief 图层数组是否已经**与当前网格数量对齐**(首次绘制 / 增删网格后为 false)。 + * + * ⚠️ 只有它返回 false 时才允许**同步**整批重建(否则没有可拉伸的旧像素可占位)。 + * 跨度变化(缩放)**不**走同步重建:改为"占位拉伸 + 按预算逐个补齐"。 + * @return 数量对齐返回 true。 + */ + bool LayersAllocated() const; /** * @brief 某个网格的图层是否**完全就绪**(结构可用且内容指纹与当前状态一致)。 * @param [IN] laneIndex 网格下标。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index e743e0b..641b63e 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -134,6 +134,16 @@ namespace jdi * 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。 */ const std::size_t kDerivedAsyncWindowThreshold = 65536; + /** + * @brief 「超大信号」门槛:信号采样数超过它时,**拖拽参数期间不再反复排队**"整条信号"的 + * 预览曲线重算(见 `CurveSpanFor`)。 + * + * 预览曲线按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致)。信号极大时每条 + * 曲线要扫描上亿个采样,后台线程被持续占满会**反过来拖慢前台绘制**(实测前台 14ms/帧, + * 而绘制本身仅 0.4ms)。此时拖动期间沿用旧曲线、松手后再算一次最新的即可 —— + * 整条信号的重算本来就追不上滑块速度,"冻结"与"每帧重排"观感相同,但后者拖慢前台。 + */ + const std::size_t kHugeSignalPreviewSamples = 4000000ull; const wchar_t* const kWeakPeakLevelLabel = L"调整幅度 %"; /**< 逐条目面板里「幅度」滑块行的标签。 */ const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」滑块行的标签。 */ const wchar_t* const kWeakPeakTitle = L"弱峰调整"; /**< 弱峰调整面板标题。 */ @@ -1089,8 +1099,6 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); - // 方向键平移同样是平移:把光标留在可见区间内(与拖拽平移一致)。 - ClampCursorsAfterPan(); } void PlotView::ScrollLanesByFraction(double fraction) @@ -1284,7 +1292,8 @@ namespace jdi return; } // 只在拖拽时夹取;留一点内缩,保证光标线及把手完整可见。 - // 视图变化(平移/缩放/自适应)**不**调用本函数:光标允许滞留在网格可视区域之外。 + // ⚠️ 视图变化(平移 / 缩放 / 自适应 / 滚轮 / 方向键)**不**调用本函数:光标是一个固定的 + // **时间位置**,视界移动时它必须留在原处(可以滑出可视区,此时读数贴在网格边缘显示)。 const double inset = (high - low) * 0.002; if (which == 2) { @@ -1296,19 +1305,6 @@ namespace jdi } } - void PlotView::ClampCursorsAfterPan() - { - if (m_tab == nullptr || !Tab().CursorEnabled()) - { - return; - } - ClampCursorToView(1); - if (Tab().CursorMode() == PlotCursorMode::DOUBLE) - { - ClampCursorToView(2); - } - } - std::uint32_t PlotView::CursorColor() const { // 两条光标统一使用同一样式与颜色,不按序号区分配色。 @@ -2233,9 +2229,37 @@ namespace jdi return &hit->values; } - // 2) 未就绪且窗口很大:预览曲线交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 + // 2) 代价较大时交后台计算,本帧先用最新旧曲线顶替(双缓冲)。 // 这样绘制线程永远不做重计算,拖动滑块/光标/视界/缩放的反馈因此始终跟手。 - if (allowAsyncCompute && (end - begin) > kDerivedAsyncWindowThreshold) + // + // ⚠️ **超大信号 + 正在拖动参数:不要每帧都排一次"整条信号"的重算**。 + // 预览曲线一律按整条信号计算(`[0, count]`,与「构建为新信号」逐位一致); + // 当信号极大(`VERY_BIG_DATA` 有 2576 万采样)时,每条曲线都要扫描上亿个采样, + // 后台线程会被**持续占满**(实测把可用内存带宽吃光),反过来把前台绘制拖慢 + // (用户报告的"深放大下拖预览滑块非常非常卡"——实测前台每帧 14ms,而绘制本身只要 0.4ms)。 + // 此时沿用旧曲线,等松手(不再处于拖拽)后再算一次最新的即可:反正整条信号的重算 + // 本来也追不上滑块速度,拖动期间"冻结"与"每帧重排"在观感上没有区别,但后者会拖慢前台。 + // 小信号(< `kHugeSignalPreviewSamples`)不受影响,仍保留"边拖边算"的实时预览。 + if (count > kHugeSignalPreviewSamples && m_dragMode != PlotDragMode::NONE) + { + if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) + { + return &stale->values; + } + return nullptr; + } + + // 计算窗口(含保护带):既用于同步计算,也用于判断是否值得交后台。 + std::size_t computedBegin = 0; + std::size_t computedEnd = 0; + DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count, + computedBegin, computedEnd); + if (computedEnd <= computedBegin) + { + return nullptr; + } + const std::size_t computeSamples = computedEnd - computedBegin; + if (allowAsyncCompute && computeSamples > kDerivedAsyncWindowThreshold) { ScheduleCurveJob(key, alignedBegin, alignedEnd, count); if (const CurveEntry* stale = AcquireNewestCurve(key, begin, end, spanBegin)) @@ -2245,15 +2269,7 @@ namespace jdi return nullptr; // 尚无任何可显示曲线:本帧跳过该预览曲线(不阻塞绘制) } - // 3) 窗口较小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 - std::size_t computedBegin = 0; - std::size_t computedEnd = 0; - DerivedGuardWindow(kind, alignedBegin, alignedEnd, safePoints, safeIterations, count, - computedBegin, computedEnd); - if (computedEnd <= computedBegin) - { - return nullptr; - } + // 3) 代价很小或需要精确取值 → 同步计算并发布(代价可接受,且保证取值精确)。 std::vector values = ComputeDerivedWindow(signal, computedBegin, computedEnd, kindValue, safePoints, safeIterations, safeLevel); const CurveEntry* published = @@ -4116,9 +4132,6 @@ namespace jdi Tab().ViewMin() += delta; Tab().ViewMax() += delta; ClampTimeView(); - // 平移是连续手势:必须把光标留在可见区间内,否则深放大下一屏平移就能把光标 - // 甩到视界外很远(用户会认为光标“跑飞”、找不到)。 - ClampCursorsAfterPan(); } const int laneIndex = m_dragLane; @@ -5128,8 +5141,6 @@ namespace jdi Tab().ViewMin() += sweep; Tab().ViewMax() += sweep; ClampTimeView(); - // Shift+滚轮也是平移:同样不能把光标落在视界之外(与拖拽平移一致)。 - ClampCursorsAfterPan(); return true; } @@ -5799,9 +5810,14 @@ namespace jdi } else { - // 结构不可用(视界跨度 / 布局变化)→ 所有网格的时间映射都变了,只能整批重建一次 - //(这只是跨度变化时的一次性代价,平移过程不会触发)。 - if (!LayersStructurallyReady()) + // ⚠️ **绝不在这里同步整批重建全部图层**:跨度变化(选区放大 / 键盘缩放 / + // 工具栏缩放)时若一次性重建 5 个网格,实测 70~80ms —— 这正是"缩放首帧卡顿" + // 的根因。改为: + // * 只有"图层数量与网格数量不符"(首次绘制 / 增删网格)才必须同步整批建立; + // * 跨度/内容过期的网格一律登记到待重画队列,由 `RollWideBuffer` 登记、 + // `RenderPendingLaneRebuilds` 按预算逐帧补齐;其间 `BlitWideBuffer` + // 会用**旧跨度像素拉伸**当占位图(低分辨率但立即可用,O(1))。 + if (!LayersAllocated()) { RebuildWideBuffer(); } @@ -5812,7 +5828,13 @@ namespace jdi //(即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿)。 // ⚠️ **必须在贴图之前**:否则本帧画好的网格要到下一帧才显示, // 拖拽时表现为"波形比鼠标慢半拍"、快速拖动时出现"断线"(图层覆盖不到视界)。 - const bool morePending = RenderPendingLaneRebuilds(1); + // + // ⚠️ **只在视界稳定后才花预算做精细重绘**:视界仍在变化(缩放 / 平移的每一帧) + // 时,本帧只贴"旧跨度像素拉伸"的占位图(O(1)),把重建推迟到视界停下来之后 + // —— 这正是用户要求的"先画了再说,别卡住前台"。否则缩放序列的每一帧都会 + // 夹带一个网格的整块重绘(宽跨度下约 20ms)→ 缩放过程持续掉帧。 + const bool morePending = + viewStopped ? RenderPendingLaneRebuilds(1) : !m_layerRebuildPending.empty(); // 绘图区底色(覆盖时间轴与侧边留白区域)。 UiFillRect(g, m_area, m_theme->plotBg); // 贴基底(绘图区:网格 + 波形)。图层没覆盖到的部分先留空,后续帧补齐。 @@ -7226,6 +7248,12 @@ namespace jdi return true; } + bool PlotView::LayersAllocated() const + { + std::lock_guard lock(m_layerMutex); + return !m_layerFront.empty() && m_layerFront.size() == m_lanes.size(); + } + bool PlotView::LaneLayerReady(int laneIndex) const { if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) @@ -7294,7 +7322,6 @@ namespace jdi { return !m_layerRebuildPending.empty(); } - const double span = Tab().ViewMax() - Tab().ViewMin(); const double defaultWideMin = LayerDefaultWideMin(); int done = 0; while (!m_layerRebuildPending.empty() && done < budget) @@ -7314,11 +7341,10 @@ namespace jdi break; } LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; - // 跨度已经又变了 → 该网格由下一次整批重建处理,这里不浪费这一帧的预算。 - if (std::fabs(layer.span - span) > 1.0e-12) - { - continue; - } + // ⚠️ **跨度过期(缩放瞬间)也在这里就地整块重画**(按**新跨度**)。 + // 旧实现遇到跨度不符就 `continue`(把该网格推给"下一次整批重建"), + // 而"整批重建"正是那个 70~80ms 的同步卡顿源;现在改为一帧补一个网格, + // 其余网格由 `BlitWideBuffer` 的占位拉伸顶着 → 前台永不因图层未就绪而滞后。 // 整块重画:整个图层都对齐到"当前 `yMax`"(锚点 = 当前 yMax)。 if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, 0, 0, defaultWideMin, m_lanes[static_cast(laneIndex)].yMax)) @@ -7720,11 +7746,22 @@ namespace jdi { continue; } - if (std::fabs(layer.span - span) > 1.0e-12) + const gd::RectF& lane = m_lanes[index].rect; + // 纵向映射是否仍然可用:图层高度必须等于"当前网格高 + 上下预渲染余量"。 + // 高度变了(布局变化)时像素无法映射,只能等整块重画。 + const int expectBitmapHeight = + static_cast(std::ceil(lane.Height)) + 2 * LayerMarginY(lane.Height); + if (layer.height != expectBitmapHeight) { continue; } - const gd::RectF& lane = m_lanes[index].rect; + // ⚠️ **跨度过期 = 该图层是"旧跨度"的像素**(典型:选区放大 / 键盘缩放的瞬间)。 + // 旧实现直接 `continue` → 整个网格在重画完成前**空白**,且触发"同步整批重建" + // (实测 70~80ms)把前台卡住。 + // 现在改为把它当作**占位图**用一次 StretchBlt 拉出来:旧图层覆盖的时间段 + // [layer.wideMin, layer.wideMin + layer.span] 重新映射到新的视界上 —— + // 画面略糊(低分辨率),但**立即可用**;精确像素由后续按预算重画逐个替换。 + const bool spanStale = (std::fabs(layer.span - span) > 1.0e-12); const float top = std::max(lane.Y, m_plotRect.Y); const float bottom = std::min(lane.Y + lane.Height, m_plotRect.Y + m_plotRect.Height); if (bottom <= top) @@ -7769,6 +7806,41 @@ namespace jdi continue; } + if (spanStale) + { + // 占位拉伸:旧图层的时间跨度映射到当前视界(横向)。纵向映射不变。 + if (!(layer.span > 0.0)) + { + continue; + } + const double dx0 = static_cast(destX) + (layer.wideMin - Tab().ViewMin()) * scale; + const double dx1 = dx0 + layer.span * scale; + if (!(dx1 > dx0)) + { + continue; + } + const double cx0 = std::max(dx0, static_cast(destX)); + const double cx1 = + std::min(dx1, static_cast(destX) + static_cast(width)); + if (cx1 - cx0 < 1.0) + { + continue; + } + const double sx0 = (cx0 - dx0) / (dx1 - dx0) * static_cast(layer.width); + const double sx1 = (cx1 - dx0) / (dx1 - dx0) * static_cast(layer.width); + const int dstX = static_cast(std::lround(cx0)); + const int dstW = std::max(1, static_cast(std::lround(cx1 - cx0))); + const int srcX2 = static_cast(std::lround(sx0)); + const int srcW = std::max(1, static_cast(std::lround(sx1 - sx0))); + // HALFTONE 让拉伸后的占位图明显更接近成图(下采样尤其明显);置一次即可。 + SetStretchBltMode(target, HALFTONE); + SetBrushOrgEx(target, 0, 0, nullptr); + StretchBlt(target, dstX, destY + destTopOffset, dstW, height, layer.dc, srcX2, srcY, + srcW, height, SRCCOPY); + any = true; + continue; + } + // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 // 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐), // 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。 diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 03f6414..161677e 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -541,6 +541,70 @@ Documents/ 项目文档(含本文件) 「仅水平平移」「仅纵向平移」行为与像素结果不变(已由对照场景 B/C 覆盖)。 - `make clean && make all` 零警告。 +### 9.17 交互流畅性三项修复:光标不再跟随视界 / 缩放首帧不再卡顿 / 超大信号拖预览不拖慢前台(本轮) + +#### 一、光标不再跟随视界(用户要求) + +用户报告:"启用光标时,移动视界导致光标超出视界范围,光标会被强制移动到视界边界; +我需要光标留在原位置而不是跟随视界移动。" + +- 移除 `ClampCursorsAfterPan()`(含声明)及其在**平移路径**的三处调用:中键/左键拖拽平移、 + `PanTimeByFraction`(方向键)、`OnMouseWheel(shift=true)`。 +- `ClampCursorToView()` 保留,**只在真正拖拽光标时**使用(其内注释与头文件说明同步更新)。 +- 现行为:平移/缩放视界时光标的**时间位置完全不变**,允许滑出可视区(读数贴在网格边缘显示)。 +- 验证(`.preflight/perf/zoomcheck` mode 14):平移 20 帧(视界移到 `[257.6, 386.4]s`,光标时间 + `38.643036` 已远在视口外)后 `CursorTime` **完全不变**(`unchanged=1`, `offViewport=1`); + Shift+滚轮平移同样不变。 + +#### 二、缩放首帧不再卡顿(用户猜测"必要像素量级飙升导致首帧卡住"——**证实**) + +- 量化(`.preflight/perf/zoomcheck` mode 0,`VERY_BIG_DATA` 5 网格逐步放大,逐帧计时): + 每次缩放变化后 **f1=70~80ms(宽跨度)**、f2=26~40ms,f3 起 0.5ms。 +- 根因:跨度变化时 `Paint` 里的 `if (!LayersStructurallyReady()) RebuildWideBuffer();` 会 + **同步重建全部 5 个网格图层**(70~80ms);随后一帧又做一次整幅"结算"(40ms)→ 单次缩放 + 合计约 110ms 的卡顿。 +- 修法(用户方案:先用旧图拉伸占位、不卡前台): + 1. **旧跨度像素拉伸占位**:`BlitWideBuffer` 遇到 `layer.span != 当前 span` **不再跳过** + (跳过会让网格在重画完成前空白),而是把旧图层覆盖的时间段 `[layer.wideMin, +layer.span]` + 重新映射到当前视界,用一次 `StretchBlt`(`HALFTONE`)拉出**低分辨率但立即可用**的画面。 + 2. `Paint` **不再同步整批重建**:只有"图层数量与网格数量不符"(首次绘制 / 增删网格, + 新增 `LayersAllocated()`)才 `RebuildWideBuffer()`;跨度 / 内容过期一律入队,由 + `RenderPendingLaneRebuilds` 逐帧补齐(原逻辑遇到跨度不符会跳过该网格,现改为直接按**新跨度**重建)。 + 3. **只在视界稳定后**(`viewStopped`)才花预算做精细重绘:视界仍在变化的每一帧只贴拉伸占位图, + 否则缩放序列的每一帧都会夹带一个网格的整块重建(宽跨度约 20ms)→ 持续掉帧。 +- 实测:缩放后**首帧 70~80ms → ~10ms**;精细重绘分散到后续帧(宽跨度 ~20ms/帧,深跨度 ~13ms/帧)。 + 收敛后仍由"结算"整幅帧保证像素精确。 +- ⚠️ **保留"结算"帧**(不可省):实测宽基底路径与从零整幅重绘**并非像素同源** + (图层 2448px 按 1px 列聚合 vs 整幅 1224px 列,列相位差对稠密脉冲信号放大成 17 万+ 像素差异), + 因此必须有这一次全质量整幅重绘来收敛。 +- 正确性 A/B(mode 13):缩放并收敛后的画面 vs 从零整幅渲染,改动前后**同为 3774 像素差异** + (预存在的列相位/装饰残差),**非本次引入**。 + +#### 三、超大信号拖预览滑块不再拖慢前台(部分改善,根因未完全定位) + +- 量化(mode 7/10/12,深放大,单网格预览,模拟面板滑块拖拽 `m_dragMode=PEAK_LEVEL`): + - 无预览基线(mode 5):0.38ms/帧。 + - 预览 + **参数不变**(mode 12):3.4ms/帧。 + - 预览 + **参数每帧变化**(mode 10):11.8ms/帧;弱峰 10 迭代(mode 7):15ms/帧。 + - **直接调用 `PaintLaneDerivedOverlays`(mode 9):0.375ms** —— 说明开销**不在曲线绘制**。 +- 结论:开销来自"参数每帧变化"触发的**整条信号**(2576 万采样)预览曲线重算调度 + (预览曲线按整条信号计算,以保证与「构建为新信号」逐位一致)。 +- 缓解:新增 `kHugeSignalPreviewSamples = 4000000` —— 信号超过该规模且**正在拖拽**(`m_dragMode != NONE`)时, + 不再每帧排队"整条信号"的重算,改为沿用旧曲线、松手后再算一次最新的(整条信号重算本来也追不上 + 滑块速度,"冻结"与"每帧重排"观感相同,后者却持续占用后台线程与内存带宽)。 + 实测 15.0 → 13.7ms(改善有限)。 +- ⚠️ **未完全解决**:参数变化带来的 ~8ms/帧开销**尚未完全归因**(已排除绘制;"暂停后台线程"的 + 对照无效,因为 `EnsureCurveWorker()` 会在暂停后被重新拉起)。用户提议的"网格内按信号并行绘制 + + 后台缓冲合成"仍是候选方案,但需先把渲染器改造成**无共享可变状态**(与 §11.2 第 5 条硬约束冲突), + 留待后续评估。 + +#### 四、验证 + +- `.preflight/perf/pancheck`(平移 / 纵向,7 个场景):全部 `dy=0`(原"中间网格 +1px"也归零)。 +- `.preflight/perf/zoomcheck`:mode 0(逐帧首帧耗时)、mode 13(缩放收敛,A/B 无回归)、 + mode 14(光标不跟随视界)。 +- `make clean && make all` 零警告。 + --- ## 10. 变更日志(本文件维护记录) @@ -572,6 +636,7 @@ Documents/ 项目文档(含本文件) | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **「弱峰调整」升级为三组识别规则 + 滑动条 ±400% 梯度驱动**:第一类(两侧均值都要满足,原规则)在滑动条 0–200% 内走完自身 0–200%;第二类("或":任一侧均值满足即可)从 100% 起参与,own 幅度 = clamp(|a|-100%,0,200%);第三类(只与左右同类极值比较,最宽松)从 200% 起参与,own 幅度 = clamp(|a|-200%,0,200%);负值完全对称。每组自身的 ±200% 映射统一为:+100% → 左右同类极值中较温和的一侧、**+200% → 左右同类极值的均值**(不再取较大的那个);-100% → 相邻异类极值中较近的一个、**-200% → 相邻异类极值的均值**(不再取最小的那个)。同时满足多组时取**幅度绝对值最大**的组。验证:`uitest` **463/463**(第一类点 0/50/100/150/200% → 520/570/620/**625/630**;只满足第二类的峰 130 → 100% 不动、150%/200%/250%/300%/400% = 140/150/162.5/175/175;只满足第三类的峰 195 → 200% 不动、250%/300%/400% = 197.5/200/200;±400% 夹取;滑块中间 = 0%、两端 = ±400%)、`panperf`/`layercheck`/`cursorcheck`/`dragcheck` 全过、零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住中键把视界纵向移动好几个网格高(或横向拖很远)后,出现大量绘制错误与持续抽搐"**:根因是 `RollWideBuffer` 对"位移超出余量"只做**排队等后续帧重画**(`QueueLaneRebuild` + `continue`)—— 该帧 `BlitWideBuffer` 取不到源行 → 网格里出现**空白条带**;且每帧都重新入队而 `RenderPendingLaneRebuilds(1)` 一帧只补 1 个网格 → **补不完 = 持续抽搐**;纵向余量只有 ±半个网格高,正常速度的纵向拖拽每帧就超过它,故几乎必现。修法:**只有"内容真的变了"才排队**,距离类问题一律本帧就地解决 —— 位移小于画布则滚动(O(1) BitBlt + 只补暴露细条),超出画布则**立刻整块重画并重新居中**(0.4ms/网格)。验证:新增探针 `.preflight/dragcheck` 按用户要求**全程按住中键不放**地做检查(图层条带逐像素 / 画布覆盖率 / 画面空白行数与墨迹占比),并把纵向滚动临时关掉做 A/B:立刻报 `layer band diff 76221 / drawing errors 11`,恢复后 **0 错误**;`uitest` **463/463**、`panperf`/`layercheck`/`cursorcheck` 全过、`make clean && make all` 零警告。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**另一台设备**) | **修复「垂直中键拖拽后再平移,网格内容纵向跳变 / 绘制错误」**(详见 §9.16):根因是 `RenderLaneLayerRegion` 结束时**无条件**把 `layer.anchorYMax` 重置为"当前 `yMax`"—— 对整块重画 / 纵向补条成立,但**横向补细条(只改一条竖带、其余像素仍在原锚点)**不成立。纵向拖动以 `kLayerRollQuantum=16px` 量化,停下时图层会残留 `<16px` 的纵向偏移且 `anchorYMax != 当前 yMax`;第一次水平微移就把该偏移**静默清零**(`vOffset→0`)而像素未动 → 整个网格波形纵向错位(最多 15px)。修法:`RenderLaneLayerRegion` 新增形参 `anchorYMax`,内部以它作幅值映射基准(`yMin = anchorYMax - laneSpan`)渲染并写回 `layer.anchorYMax`;横向补条传 `layer.anchorYMax`(保持不变),整块重画/纵向补条传当前(或滚动后)`yMax`。`vOffset==0` 时新旧逻辑完全等价。验证:新增离屏探针 `.preflight/perf/pancheck`(窗口 `SW_HIDE` + 注入消息 + 读图层内部 `anchorYMax`/`vOffset` + 持按钮帧 vs 释放后整幅帧的纵向互相关):修复前 A 场景 `dy=+15px`(lane0 差异 39245),修复后 **`dy=0`**(差异 1220,仅已知列相位残差);对照/中间网格/长距水平/斜向/松开重按共 7 个场景在缩放 0/8/26 步下全部 `dy=0`(中间网格 `+1px` 为整像素量化残差);`make clean && make all` 零警告。 | +| 2026-10-13 | DeepSeek-V4.1-Flash(**另一台设备**) | **交互流畅性三项修复**(详见 §9.17):① **光标不再跟随视界**——删除 `ClampCursorsAfterPan()` 及其在平移路径(中键/左键拖拽、Shift+滚轮、方向键)的三处调用,视图变化时光标时间位置**完全不变**(可滑出视口),`ClampCursorToView()` 只在拖拽光标时使用(mode 14:平移 20 帧后 `CursorTime` 不变且 `offViewport=1`)。② **缩放首帧不再卡顿**——根因是跨度变化时 `Paint` 会**同步重建全部 5 个网格图层**(实测 70~80ms)+ 随后整幅结算(40ms)=单次缩放 ~110ms 卡顿;修法:`BlitWideBuffer` 对跨度过期的图层改用**旧像素 `StretchBlt` 拉伸占位**(不再跳过导致空白)、`Paint` 只在"图层数量不符"时同步重建(新增 `LayersAllocated()`)、`RenderPendingLaneRebuilds` 允许按新跨度重建、且**只在视界稳定后**才花预算精细重绘(`viewStopped` 门控);实测首帧 **70~80ms → ~10ms**。保留"结算"帧(实测宽基底路径与整幅重绘有 17 万+ 像素列相位差,非同源)。③ **超大信号拖预览滑块**——量化发现开销**不在绘制**(`PaintLaneDerivedOverlays` 仅 0.375ms),而在"参数每帧变化"触发的**整条信号**(2576 万采样)预览重算调度;新增 `kHugeSignalPreviewSamples=4000000`,超大信号拖拽期间不再每帧排队重算(15.0→13.7ms,**剩余 ~8ms 根因未完全定位**)。验证:`pancheck` 7 场景全 `dy=0`、`zoomcheck` mode 0/13/14、`make clean && make all` 零警告。 | ---