diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 4a729d7..067f875 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -43,8 +43,6 @@ namespace jdi MOVE_SIGNAL, /**< 拖拽信号曲线到其它网格。 */ EDGE_MOVE, /**< 拖动「边沿计数」的横向光标(上下移动幅值)。 */ EDGE_PANEL_MOVE, /**< 拖动边沿计数浮动面板。 */ - PEAK_LEVEL, /**< 拖动「弱峰增强」面板中的等级滑块。 */ - PEAK_ITERATIONS, /**< 拖动「弱峰增强」面板中的迭代次数 N 滑块。 */ LANE_REORDER /**< 拖动网格图例中的 GX 编号徽标:上下调整网格次序。 */ }; @@ -72,7 +70,9 @@ namespace jdi EDGE_CENTER_POINTS, /**< 编辑「中心轴边沿计数」的均值点数 X。 */ EDGE_CENTER_ITERATIONS, /**< 编辑「中心轴边沿计数」的迭代次数 N。 */ MEAN_FILTER_POINTS, /**< 编辑「均值滤波」的均值点数 X。 */ - MEAN_FILTER_ITERATIONS /**< 编辑「均值滤波」的迭代次数 N。 */ + MEAN_FILTER_ITERATIONS, /**< 编辑「均值滤波」的迭代次数 N。 */ + PEAK_LEVEL_PERCENT, /**< 编辑「弱峰增强」某信号的增强等级(0–200%)。 */ + PEAK_ITERATIONS /**< 编辑「弱峰增强」某信号的迭代次数 N。 */ }; /** @@ -570,7 +570,7 @@ namespace jdi /** * @brief 「实验性功能 → 弱峰增强」的「构建为新信号」单个请求 - * (一个被勾选的信号 + 整个网格共用的增强等级)。 + * (一个被勾选的信号 + **它自己的**增强等级与迭代次数,与均值滤波同构)。 */ struct WeakPeakBuild { @@ -610,36 +610,6 @@ namespace jdi */ bool ToggleLaneWeakPeak(int laneIndex); - /** - * @brief 读取某网格的弱峰增强等级(0–2)。 - * @param [IN] laneIndex 网格下标。 - * @return 增强等级;网格不存在时返回 0.5。 - */ - double LaneWeakPeakLevel(int laneIndex) const; - - /** - * @brief 读取某网格的弱峰增强迭代次数 N(1–10)。 - * @param [IN] laneIndex 网格下标。 - * @return 迭代次数;网格不存在时返回 1。 - */ - int LaneWeakPeakIterations(int laneIndex) const; - - /** - * @brief 设置某网格的弱峰增强迭代次数 N(1–10,超出范围会被夹取)。 - * @param [IN] laneIndex 网格下标。 - * @param [IN] iterations 迭代次数。 - * @return 迭代次数发生变化返回 true。 - */ - bool SetLaneWeakPeakIterations(int laneIndex, int iterations); - - /** - * @brief 设置某网格的弱峰增强等级(0–2,超出范围会被夹取)。 - * @param [IN] laneIndex 网格下标。 - * @param [IN] level 增强等级。 - * @return 等级发生变化返回 true。 - */ - bool SetLaneWeakPeakLevel(int laneIndex, double level); - /** * @brief 获取弱峰增强曲线(按「信号 + 增强等级 + 迭代次数」缓存)。 * @param [IN] signal 目标信号。 @@ -1158,14 +1128,13 @@ namespace jdi gd::RectF close; /**< ✕ 关闭按钮。 */ gd::RectF edit; /**< 面板底部编辑行(无编辑内容时宽度为 0)。 */ gd::RectF button; /**< 面板底部按钮(无按钮时宽度为 0)。 */ - gd::RectF slider; /**< 「弱峰增强」等级滑块的轨道矩形(无滑块时宽度为 0)。 */ - gd::RectF sliderIterations; /**< 「弱峰增强」迭代次数 N 滑块的轨道矩形(无滑块时宽度为 0)。 */ int rows = 0; /**< 统计行数(每条可见信号一行)。 */ /** - * @brief 「逐条目」面板的矩形(MEAN 与 CENTER 面板共用同一套排布)。 + * @brief 「逐条目」面板的矩形(MEAN / CENTER / PEAK 面板共用同一套排布)。 * * 索引与 `rowRefs` 一一对应(即网格内可见信号的顺序): - * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowIterations` 为第二排的两个输入框; + * `rowChecks` 为第一排右侧的复选框;`rowPoints` / `rowIterations` 为第二排的两个输入框 + *(PEEK 信号分别为「增强等级 %」与「迭代次数 N」); * `rowStats` 为第三排的统计结果(中心轴边沿计数专用)。 * 未勾选的条目对应的矩形均为空。 */ @@ -1688,7 +1657,6 @@ namespace jdi int m_pendingWeakPeakLane = -1; /**< 待构建的弱峰增强信号所属网格下标(-1 表示无请求)。 */ std::vector m_pendingMeanFilterItems; /**< 待构建的信号及其参数。 */ std::vector m_pendingWeakPeakItems; /**< 待构建的弱峰增强信号及其参数。 */ - int m_peakDragLane = -1; /**< 正在拖动滑块的面板所属网格。 */ bool m_mouseOverPanel = false; /**< 上一帧鼠标是否落在某个实验性功能面板上。 */ /** * @brief 本帧是否使用「快速绘制」(交互期间关闭波形与派生曲线的抗锯齿)。 @@ -1979,9 +1947,7 @@ namespace jdi int edgeCenter = 0; /**< 中心轴边沿计数开关。 */ int meanFilter = 0; /**< 均值滤波开关。 */ int weakPeak = 0; /**< 弱峰增强开关。 */ - int weakPeakLevelPercent = 0; /**< 弱峰增强等级(0–200)。 */ - int weakPeakIterations = 1; /**< 弱峰增强迭代次数 N。 */ - /** 各条目展开为 [文件, 信号, 均值点数 X, 迭代次数 N],按原顺序(稳定)。 */ + /** 各条目展开为 [文件, 信号, 参数…],按原顺序(稳定)。 */ std::vector edgeCenterItems; std::vector meanFilterItems; std::vector weakPeakItems; @@ -2287,32 +2253,55 @@ namespace jdi /** * @brief 让各图层"滚动"以跟随视界:逐网格整数像素自贴图平移 + 只补画新暴露的细条。 * - * 每个网格按**自己的** `wideMin` 计算位移;位移过大(锚点离当前视界太远)或内容指纹 - * 与当前状态不一致的网格直接整块重画。任一网格图层缺失(尺寸 / 跨度不符)返回 false, - * 由调用方先 `RebuildWideBuffer()`。 - * @return 全部网格都能用现成像素跟随视界返回 true。 + * ⚠️ **本函数绝不做"整块重画"**:内容指纹过期 / 锚点离视界太远的网格只被排进 + * `m_layerRebuildPending`(随后由 `RenderPendingLaneRebuilds` 按预算补画),本帧仍按 + * 现有像素做整数像素平移,因此**前台线程永远不会因为"图层没准备好"而卡顿**。 */ - bool RollWideBuffer(); + void RollWideBuffer(); /** * @brief 逐个网格截取图层子矩形贴图到后备缓冲区(O(1) 位图搬运)。 + * + * 图层只覆盖"视界 ± 半屏":若当前视界有一部分落在图层之外,这里**只贴有像素的那一段** + * (其余留由底色填充,下一帧补齐),而不是跳过整块 —— 这正是"未绘制的部分先暂时不管"。 * @param [IN,OUT] g 绘图上下文。 - * @return 至少贴了若干网格返回 true。 + * @return 至少贴了若干像素返回 true。 */ bool BlitWideBuffer(gd::Graphics& g) const; /** - * @brief 全部图层是否已就绪且跨度与当前视界一致。 + * @brief 全部图层是否**结构可用**(数量 / 位图尺寸 / 视界跨度都与当前状态一致)。 * - * 只检查"位图尺寸 / 网格数量 / 视界跨度"这类**结构性**条件(内容指纹由 - * `RollWideBuffer()` 逐个网格处理);不满足时应先 `RebuildWideBuffer()`。 + * 结构不可用意味着"像素的时间映射已经不对",只能整批重建(跨度变化 / 布局变化 / 首帧); + * 内容指纹是否最新由 `LaneLayerReady()` 逐个判断。 * @return 可以直接滚动 + 贴图层像素返回 true。 */ - bool LayersReadyForView() const; + bool LayersStructurallyReady() const; + /** + * @brief 某个网格的图层是否**完全就绪**(结构可用且内容指纹与当前状态一致)。 + * @param [IN] laneIndex 网格下标。 + * @return 就绪返回 true。 + */ + bool LaneLayerReady(int laneIndex) const; + /** @brief 全部网格图层是否都完全就绪(用于判断能否做"结算"整幅精确帧)。 */ + bool LanesAllReadyForView() const; + /** @brief 把某网格排进"待重画"队列(去重;视界/跨度后续变化时会在渲染时再次校验)。 */ + void QueueLaneRebuild(int laneIndex); + /** + * @brief 按预算补画"待重画"的网格图层(**本帧最多 `budget` 个**)。 + * + * 这是"前台不做整批重绘"的关键:即使 5 个网格都需要重画,也一帧只画一个, + * 其余留到后续帧(缺的部分留空 / 用旧像素)。调用方若发现队列非空应请求再次重绘。 + * @param [IN] budget 本次最多重画的网格数量。 + * @return 队列中仍有待重画网格返回 true。 + */ + bool RenderPendingLaneRebuilds(int budget); /** @brief 使全部图层失效。 */ void InvalidateWideBuffer(); /** @brief 释放全部图层位图。 */ void ReleaseWideBuffer(); std::vector m_layerFront; /**< 各网格的「信号图层」(与 `m_lanes` 同序)。 */ + /** 待重画的网格下标(去重;`RollWideBuffer` 登记,`RenderPendingLaneRebuilds` 消费)。 */ + std::vector m_layerRebuildPending; /** * @brief 图层元数据的互斥(当前图层只在 UI 线程维护,故它实际不承担跨线程保护; * 保留是为了给 `BlitWideBuffer()` 这类 `const` 读取路径一个明确的一致性边界)。 @@ -2364,6 +2353,42 @@ namespace jdi std::uint64_t m_panelGlobalKey = 0; /**< 上一层对应的全局键。 */ std::vector m_panelSlots; /**< 上一层缓存的面板(本帧比对用)。 */ + /** + * @brief 浮动面板的**层序**(从下到上)。 + * + * 面板允许互相重叠,因此必须有明确的层序: + * * **绘制**按此顺序从下往上;**命中测试**从后往前(最上层优先)。 + * * 点击某个面板会把它移到队尾(置顶),因此重叠时总能操作到"用户想要"的那一个 + * (否则上层面板的 ✕ / 标题栏会被下层控件的命中判定抢走)。 + * * 新出现的面板追加到队尾(在最上层),不会被已有面板盖住。 + */ + struct PanelRef + { + int laneIndex = -1; /**< 所属网格。 */ + int kind = 0; /**< `EdgePanelKind` 的整型值。 */ + + bool operator==(const PanelRef& other) const + { + return laneIndex == other.laneIndex && kind == other.kind; + } + }; + + /** 同步 `m_panelOrder` 与当前"可见的面板集合"(新增的追加到最上层;消失的移除)。 */ + void SyncPanelOrder() const; + /** @brief 把某个面板移到最上层(点击面板时调用)。 */ + void BringPanelToFront(int laneIndex, int kind); + /** + * @brief 从最上层往下找出包含该点的面板。 + * @param [IN] x 客户区横坐标。 + * @param [IN] y 客户区纵坐标。 + * @param [OUT] laneIndex 命中面板所属网格。 + * @param [OUT] kind 命中面板的种类(`EdgePanelKind`)。 + * @return 命中返回 true。 + */ + bool TopPanelAtPoint(float x, float y, int& laneIndex, int& kind) const; + + mutable std::vector m_panelOrder; /**< 浮动面板层序(队尾 = 最上层;mutable 供 const 光标查询同步层序)。 */ + /** * @brief 查询坐标命中的图例条目。 * @param [IN] x 客户区横坐标。 diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index ed10eca..0a723ef 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -68,9 +68,6 @@ namespace jdi const float kEdgePanelMeanEditHeight = 24.0f; /**< 逐条目面板中每条信号的参数行高度。 */ const float kEdgePanelMeanRowGap = 2.0f; /**< 名称行与参数行之间的间距。 */ const float kEdgePanelMeanCheckSize = 15.0f; /**< 逐条目面板中条目复选框尺寸。 */ - const float kWeakPeakSliderRowHeight = 26.0f; /**< 弱峰增强面板中每个滑块行的高度。 */ - const float kWeakPeakTrackHeight = 6.0f; /**< 弱峰增强滑块的轨道高度。 */ - const float kWeakPeakTrackMinWidth = 120.0f; /**< 弱峰增强滑块轨道的最小宽度。 */ const int kWeakPeakIterationsMin = 1; /**< 迭代次数 N 的下限。 */ const int kWeakPeakIterationsMax = 10; /**< 迭代次数 N 的上限。 */ /** 弱峰增强等级上限(面板上显示为 200%)。 */ @@ -98,37 +95,12 @@ namespace jdi * 此时改为交后台线程计算、本帧先用旧曲线顶替(双缓冲),保证控件反馈始终跟手。 */ const std::size_t kDerivedAsyncWindowThreshold = 65536; - const wchar_t* const kWeakPeakLevelLabel = L"弱峰增强等级"; /**< 等级滑块行左侧的说明文字。 */ - const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 迭代次数滑块行左侧的说明文字。 */ + const wchar_t* const kWeakPeakLevelLabel = L"增强等级 %"; /**< 逐条目面板里「等级」输入框的标签。 */ + const wchar_t* const kWeakPeakIterationsLabel = L"迭代次数 N"; /**< 逐条目面板里「迭代次数」输入框的标签。 */ const wchar_t* const kWeakPeakTitle = L"弱峰增强"; /**< 弱峰增强面板标题。 */ const wchar_t* const kBuildSignalLabel = L"构建为新信号"; /**< 逐条目面板底部按钮文案(均值滤波 / 弱峰增强共用)。 */ const std::size_t kEdgeCountCacheLimit = 48; /**< 边沿统计缓存条目上限(超出时淘汰最早的一条)。 */ - /** - * @brief 判断坐标是否命中「弱峰增强」的等级滑块。 - * - * 轨道本身只有几个像素高,直接按轨道矩形命中很难抓住,因此把命中区在纵向上下各放宽 - * (至少一个轨道高度),与样式区滑块的交互一致。 - * - * @param [IN] track 滑块轨道矩形(宽度为 0 表示当前没有滑块)。 - * @param [IN] scale DPI 缩放比例。 - * @param [IN] x 客户区横坐标。 - * @param [IN] y 客户区纵坐标。 - * @return 命中返回 true。 - */ - bool WeakPeakSliderHit(const gd::RectF& track, float scale, float x, float y) - { - if (track.Width <= 0.0f) - { - return false; - } - gd::RectF hit = track; - const float pad = std::max(8.0f * scale, track.Height); - hit.Y -= pad; - hit.Height += pad * 2.0f; - return UiPointInRect(hit, x, y); - } - /** * @brief 取折线(相邻采样点之间线性连接)在给定时刻的取值。 * @@ -1905,63 +1877,6 @@ namespace jdi return m_tab->Lanes()[static_cast(laneIndex)].weakPeak; } - double PlotView::LaneWeakPeakLevel(int laneIndex) const - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return 0.5; - } - return m_tab->Lanes()[static_cast(laneIndex)].weakPeakLevel; - } - - int PlotView::LaneWeakPeakIterations(int laneIndex) const - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return kWeakPeakIterationsMin; - } - return m_tab->Lanes()[static_cast(laneIndex)].weakPeakIterations; - } - - bool PlotView::SetLaneWeakPeakIterations(int laneIndex, int iterations) - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const int clamped = std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMax, iterations)); - if (lane.weakPeakIterations == clamped) - { - return false; - } - lane.weakPeakIterations = clamped; - // 迭代次数变化会改变预览曲线:清掉整条曲线的缓存即可。 - // ⚠️ **不要**再 `InvalidateSceneCache()`:那会整幅重绘(5 个网格、上万个顶点), - // 拖滑块时每帧都发生;改为依赖 `m_sceneDerived` 快照 → 只重画受影响的网格。 - m_weakPeakCurveCache.clear(); - return true; - } - - bool PlotView::SetLaneWeakPeakLevel(int laneIndex, double level) - { - if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) - { - return false; - } - Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; - const double clamped = std::max(0.0, std::min(kWeakPeakLevelMax, level)); - if (lane.weakPeakLevel == clamped) - { - return false; - } - lane.weakPeakLevel = clamped; - // 等级变化会改变预览曲线:清掉整条曲线的缓存即可(同 `SetLaneWeakPeakIterations`, - // 故意**不**作废静态场景缓存,以便走"只重画受影响网格"的快路径)。 - m_weakPeakCurveCache.clear(); - return true; - } - bool PlotView::SetLaneWeakPeak(int laneIndex, bool enabled) { if (m_tab == nullptr || laneIndex < 0 || laneIndex >= static_cast(m_tab->Lanes().size())) @@ -2946,32 +2861,22 @@ namespace jdi width = std::max(width, kEdgePanelPad * 2.0f * m_scale + std::max(nameWidth, countWidth)); } - if (centerAxis || meanFilter) + if (centerAxis || meanFilter || weakPeak) { // 参数行 = 两个「说明文字 + 数值框」,必须保证文字不被数值框压住。 - // (「均值点数 X」/「迭代次数 N」的布局对两个逐条目面板通用:标签与数值框各占半行。) - const float labelX = UiMeasureTextWidth(m_fonts->uiSmall, L"均值点数 X"); + // (MEAN / CENTER:「均值点数 X」+「迭代次数 N」;PEAK:「增强等级 %」+「迭代次数 N」。 + // 标签与数值框各占半行。) + const wchar_t* const labelX = weakPeak ? kWeakPeakLevelLabel : L"均值点数 X"; + const float labelXWidth = UiMeasureTextWidth(m_fonts->uiSmall, labelX); const float labelN = UiMeasureTextWidth(m_fonts->uiSmall, L"迭代次数 N"); const float fieldWidth = std::max(kEdgePanelEditFieldWidth * m_scale * 0.75f, UiMeasureTextWidth(m_fonts->monoSmall, L"1000000") + 18.0f * m_scale); - const float halfWidth = std::max(labelX, labelN) + 6.0f * m_scale + fieldWidth; + const float halfWidth = std::max(labelXWidth, labelN) + 6.0f * m_scale + fieldWidth; const float rowWidth = halfWidth * 2.0f + 8.0f * m_scale; width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); } - if (weakPeak) - { - // 两个滑块行(等级 / 迭代次数 N)= 「说明文字 + 轨道 + 数值」。 - const float labelWidth = std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsLabel)); - const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"), - UiMeasureTextWidth(m_fonts->monoSmall, L"10")); - const float rowWidth = labelWidth + 6.0f * m_scale + kWeakPeakTrackMinWidth * m_scale + - 6.0f * m_scale + valueWidth; - width = std::max(width, kEdgePanelPad * 2.0f * m_scale + rowWidth); - } - if (meanFilter || weakPeak) { // 底部按钮行 = 「构建为新信号」,按按钮文字实测宽度确定下限。 @@ -3499,10 +3404,12 @@ namespace jdi if (m_editTarget == PlotEditTarget::EDGE_CENTER_POINTS || m_editTarget == PlotEditTarget::EDGE_CENTER_ITERATIONS || m_editTarget == PlotEditTarget::MEAN_FILTER_POINTS || - m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS) + m_editTarget == PlotEditTarget::MEAN_FILTER_ITERATIONS || + m_editTarget == PlotEditTarget::PEAK_LEVEL_PERCENT || + m_editTarget == PlotEditTarget::PEAK_ITERATIONS) { - // 「均值点数 X」/「迭代次数 N」:输入框就落在对应的字段矩形上 - //(中心轴与均值滤波面板的字段矩形含义相同)。 + // 「均值点数 X」/「迭代次数 N」(均值滤波、中心轴)与「增强等级 %」/「迭代次数 N」(弱峰增强): + // 输入框都落在对应的字段矩形上(三类面板的字段矩形含义相同)。 if (m_editFieldRect.Width <= 0.0f) { return gd::RectF(); @@ -3672,6 +3579,30 @@ namespace jdi // 「中心轴边沿计数」用**完全相同的参数与语义**,只是作用于自己的条目列表。 const bool centerTarget = (target == PlotEditTarget::EDGE_CENTER_POINTS || target == PlotEditTarget::EDGE_CENTER_ITERATIONS); + // 「弱峰增强」也是逐信号参数(增强等级 % + 迭代次数 N),只是作用于自己的条目列表。 + if (target == PlotEditTarget::PEAK_LEVEL_PERCENT || target == PlotEditTarget::PEAK_ITERATIONS) + { + Lane& lane = m_tab->Lanes()[static_cast(laneIndex)]; + const Lane::WeakPeakItem* existing = lane.FindWeakPeakItem(m_editMeanRef); + if (existing == nullptr) + { + return true; + } + Lane::WeakPeakItem& item = lane.EnsureWeakPeakItem(m_editMeanRef); + if (target == PlotEditTarget::PEAK_LEVEL_PERCENT) + { + // 增强等级 0–200%。 + item.levelPercent = std::max(0, std::min(kWeakPeakPercentMax, static_cast(std::lround(parsed)))); + } + else + { + item.iterations = std::max(kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMax, static_cast(std::lround(parsed)))); + } + // 参数变化会改变曲线:清掉曲线缓存即可(局部重画由 `m_sceneDerived` 快照驱动)。 + m_weakPeakCurveCache.clear(); + return true; + } if (target == PlotEditTarget::MEAN_FILTER_POINTS || target == PlotEditTarget::MEAN_FILTER_ITERATIONS || centerTarget) { @@ -3899,12 +3830,6 @@ namespace jdi case PlotDragMode::CURSOR_SPAN_MOVE: // 差值标签拖拽:两条光标整体沿时间轴平移 → 左右箭头。 return LoadCursorW(nullptr, IDC_SIZEWE); - case PlotDragMode::PEAK_LEVEL: - // 「弱峰增强」等级滑块拖拽 → 左右箭头。 - return LoadCursorW(nullptr, IDC_SIZEWE); - case PlotDragMode::PEAK_ITERATIONS: - // 「弱峰增强」迭代次数 N 滑块拖拽 → 左右箭头。 - return LoadCursorW(nullptr, IDC_SIZEWE); case PlotDragMode::MOVE_SIGNAL: // 拖动信号(参考 Photoshop 平移元素的抓握光标)。 return LoadCursorW(nullptr, IDC_SIZEALL); @@ -3931,44 +3856,32 @@ namespace jdi // 面板**只有标题栏**可以拖拽(显示抓握光标);✕ 与面板主体一律保持原始箭头。 if (m_tab != nullptr) { - for (const EdgePanelKind kind : kEdgePanelKinds) + // 按**层序从上层往下**判定,重叠时以最上层的面板为准。 + int panelLane = -1; + int panelKind = 0; + if (TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, panelKind)) { - for (int index = 0; index < static_cast(m_lanes.size()); ++index) + const EdgePanelLayout layout = + EdgePanelLayoutFor(panelLane, static_cast(panelKind)); + if (UiPointInRect(layout.close, static_cast(x), static_cast(y))) { - const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); - if (layout.panel.Width <= 0.0f || - !UiPointInRect(layout.panel, static_cast(x), static_cast(y))) - { - continue; - } - if (UiPointInRect(layout.close, static_cast(x), static_cast(y))) - { - // ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。 - return LoadCursorW(nullptr, IDC_HAND); - } - if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) - { - return LoadCursorW(nullptr, IDC_SIZEALL); - } - if (kind == EdgePanelKind::PEAK && - (WeakPeakSliderHit(layout.slider, m_scale, static_cast(x), - static_cast(y)) || - WeakPeakSliderHit(layout.sliderIterations, m_scale, static_cast(x), - static_cast(y)))) - { - // 等级 / 迭代次数滑块:左右箭头表示可左右拖动。 - return LoadCursorW(nullptr, IDC_SIZEWE); - } - if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::PEAK) && - layout.button.Width > 0.0f && - UiPointInRect(layout.button, static_cast(x), static_cast(y))) - { - // 「构建为新信号」按钮:手型光标作为可点击提示。 - return LoadCursorW(nullptr, IDC_HAND); - } - // 面板主体(含底部编辑行与按钮):保持原始箭头。 - return nullptr; + // ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。 + return LoadCursorW(nullptr, IDC_HAND); } + if (UiPointInRect(layout.title, static_cast(x), static_cast(y))) + { + return LoadCursorW(nullptr, IDC_SIZEALL); + } + if ((static_cast(panelKind) == EdgePanelKind::MEAN || + static_cast(panelKind) == EdgePanelKind::PEAK) && + layout.button.Width > 0.0f && + UiPointInRect(layout.button, static_cast(x), static_cast(y))) + { + // 「构建为新信号」按钮:手型光标作为可点击提示。 + return LoadCursorW(nullptr, IDC_HAND); + } + // 面板主体(含底部编辑行与参数输入框):保持原始箭头。 + return nullptr; } int edgeLane = -1; if (IsOnEdgeCursorLine(x, y, edgeLane)) @@ -4208,44 +4121,6 @@ namespace jdi m_dragMoved = true; return true; } - case PlotDragMode::PEAK_LEVEL: - { - // 「弱峰增强」等级滑块:按滑轨上的横向位置换算 0–200%。 - if (m_tab == nullptr || m_peakDragLane < 0 || - m_peakDragLane >= static_cast(m_lanes.size())) - { - break; - } - const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK); - if (layout.slider.Width <= 0.0f) - { - break; - } - const double ratio = UiSliderRatioFromX(layout.slider, fx); - SetLaneWeakPeakLevel(m_peakDragLane, ratio * kWeakPeakLevelMax); - m_dragMoved = true; - return true; - } - case PlotDragMode::PEAK_ITERATIONS: - { - // 「弱峰增强」迭代次数 N 滑块:按滑轨上的横向位置换算 1–10(整数)。 - if (m_tab == nullptr || m_peakDragLane < 0 || - m_peakDragLane >= static_cast(m_lanes.size())) - { - break; - } - const EdgePanelLayout layout = EdgePanelLayoutFor(m_peakDragLane, EdgePanelKind::PEAK); - if (layout.sliderIterations.Width <= 0.0f) - { - break; - } - const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx); - const int iterations = kWeakPeakIterationsMin + static_cast(std::lround( - ratio * (kWeakPeakIterationsMax - kWeakPeakIterationsMin))); - SetLaneWeakPeakIterations(m_peakDragLane, iterations); - m_dragMoved = true; - return true; - } case PlotDragMode::MOVE_SIGNAL: { const int target = LaneAtY(y); @@ -4343,24 +4218,13 @@ namespace jdi // 鼠标压在实验性功能面板上时不再触发信号悬停提示(面板本来就盖住波形), // 但该面板所属的网格仍然要高亮(与鼠标直接停在网格上一致)。 + // 按**层序**取最上层的面板(与点击命中保持一致,重叠时以最上层为准)。 bool overEdgePanel = false; int panelLane = -1; - for (long kindIndex = 0; - kindIndex < static_cast(sizeof(kEdgePanelKinds) / sizeof(kEdgePanelKinds[0])) && - !overEdgePanel; - ++kindIndex) { - for (int index = 0; index < static_cast(m_lanes.size()); ++index) - { - const gd::RectF panel = EdgePanelLayoutFor(index, kEdgePanelKinds[kindIndex]).panel; - if (panel.Width > 0.0f && - UiPointInRect(panel, static_cast(x), static_cast(y))) - { - overEdgePanel = true; - panelLane = index; - break; - } - } + int hoverKind = 0; + overEdgePanel = TopPanelAtPoint(static_cast(x), static_cast(y), panelLane, + hoverKind); } // 悬停网格:鼠标在面板上时高亮面板所属网格(用户明确要求)。 @@ -4455,7 +4319,6 @@ namespace jdi m_rubberRect = gd::RectF(); m_rubberAxis = PlotRubberAxis::UNDECIDED; m_cursorGrabOffset = 0; - m_peakDragLane = -1; // 新的交互开始:清掉上一次留下的提示(拖动过程中会重新设置粘滞提示)。 SetHint(std::wstring()); // 上一轮尚未抬起确认的面板关闭请求一律作废。 @@ -4537,23 +4400,24 @@ namespace jdi // 实验性功能浮动面板(面板压在波形之上,必须先于信号拖拽判定): // * ✕ → 关闭该网格对应的功能; - // * 底部编辑行 → 进入「平滑点数 / 均值点数 N」内联编辑; + // * 逐条目参数输入框 → 进入内联编辑; // * 底部按钮 → 触发「构建为新信号」请求; // * 标题栏 → 拖动面板; // * 面板主体 → 消费点击,但**不拖动也不穿透**(用户明确要求只有标题栏可拖拽)。 - for (int pass = 0; pass < kEdgePanelKindCount; ++pass) + // + // ⚠️ **按层序从上层往下命中**(重叠时只有最上层那个面板响应),并在命中后把该面板 + // **置顶**(用户点击哪个就把哪个放到最上层),这样: + // * 上层面板的 ✕ / 标题栏不会被下层面板的参数输入框抢走(否则"上层面板关不掉 / 拖不动"); + // * 两个面板重叠时总能操作到用户想要的那一个。 { - const EdgePanelKind kind = kEdgePanelKinds[pass]; - bool handledPanel = false; - for (int index = 0; index < static_cast(m_lanes.size()); ++index) + int panelLane = -1; + int panelKindValue = 0; + if (TopPanelAtPoint(fx, fy, panelLane, panelKindValue)) { + const EdgePanelKind kind = static_cast(panelKindValue); + const int index = panelLane; + BringPanelToFront(index, panelKindValue); const EdgePanelLayout layout = EdgePanelLayoutFor(index, kind); - if (layout.panel.Width <= 0.0f || - !UiPointInRect(layout.panel, fx, fy)) - { - continue; - } - handledPanel = true; if (UiPointInRect(layout.close, fx, fy)) { @@ -4569,7 +4433,7 @@ namespace jdi } // 「中心轴边沿计数」/「均值滤波」/「弱峰增强」面板:逐信号条目 - // (同一套「名称 + 复选框」排布,只是复选框绑定的条目列表不同)。 + // (同一套「名称 + 复选框 + 两个参数输入框」排布,只是绑定的条目列表不同)。 if ((kind == EdgePanelKind::MEAN || kind == EdgePanelKind::CENTER || kind == EdgePanelKind::PEAK) && m_tab != nullptr) @@ -4578,35 +4442,9 @@ namespace jdi const bool boostMode = (kind == EdgePanelKind::PEAK); Lane& tabLane = m_tab->Lanes()[static_cast(index)]; - // 弱峰增强的两个滑块:按下即开始拖动(拖动过程中实时更新)。 - if (boostMode && WeakPeakSliderHit(layout.slider, m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_LEVEL; - m_peakDragLane = index; - SetLaneWeakPeakLevel(index, UiSliderRatioFromX(layout.slider, fx) * kWeakPeakLevelMax); - return true; - } - if (boostMode && WeakPeakSliderHit(layout.sliderIterations, m_scale, fx, fy)) - { - m_dragMode = PlotDragMode::PEAK_ITERATIONS; - m_peakDragLane = index; - const double ratio = UiSliderRatioFromX(layout.sliderIterations, fx); - SetLaneWeakPeakIterations(index, - kWeakPeakIterationsMin + - static_cast(std::lround( - ratio * (kWeakPeakIterationsMax - kWeakPeakIterationsMin)))); - return true; - } - for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) { const SignalRef& ref = layout.rowRefs[rowIndex]; - const auto findItem = [&]() -> const Lane::MeanFilterItem* - { - return meanMode ? tabLane.FindMeanFilterItem(ref) - : (boostMode ? tabLane.FindWeakPeakItem(ref) - : tabLane.FindEdgeCenterItem(ref)); - }; // 第一排右侧的复选框 → 切换该信号的预览勾选状态。 if (layout.rowChecks[rowIndex].Width > 0.0f && UiPointInRect(layout.rowChecks[rowIndex], fx, fy)) @@ -4631,41 +4469,61 @@ namespace jdi m_dragMode = PlotDragMode::NONE; return true; } - // 第二排的两个输入框 → 就地编辑该信号的均值点数 X / 迭代次数 N - //(弱峰增强面板没有参数输入框,等级由滑块统一控制)。 + // 第二排的两个输入框 → 就地编辑该信号的参数 + //(MEAN/CENTER:均值点数 X / 迭代次数 N;PEAK:增强等级 % / 迭代次数 N)。 if (layout.rowPoints[rowIndex].Width > 0.0f && UiPointInRect(layout.rowPoints[rowIndex], fx, fy)) { - const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS, - index, std::to_wstring(item != nullptr ? item->points : 5), - layout.rowPoints[rowIndex]); + if (boostMode) + { + const Lane::WeakPeakItem* item = tabLane.FindWeakPeakItem(ref); + BeginEdit(PlotEditTarget::PEAK_LEVEL_PERCENT, index, + std::to_wstring(item != nullptr ? item->levelPercent : 150), + layout.rowPoints[rowIndex]); + } + else + { + const Lane::MeanFilterItem* item = + meanMode ? tabLane.FindMeanFilterItem(ref) + : tabLane.FindEdgeCenterItem(ref); + BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_POINTS + : PlotEditTarget::EDGE_CENTER_POINTS, + index, std::to_wstring(item != nullptr ? item->points : 5), + layout.rowPoints[rowIndex]); + } return true; } if (layout.rowIterations[rowIndex].Width > 0.0f && UiPointInRect(layout.rowIterations[rowIndex], fx, fy)) { - const Lane::MeanFilterItem* item = findItem(); m_editMeanRef = ref; - BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS, - index, std::to_wstring(item != nullptr ? item->iterations : 1), - layout.rowIterations[rowIndex]); + if (boostMode) + { + const Lane::WeakPeakItem* item = tabLane.FindWeakPeakItem(ref); + BeginEdit(PlotEditTarget::PEAK_ITERATIONS, index, + std::to_wstring(item != nullptr ? item->iterations : 1), + layout.rowIterations[rowIndex]); + } + else + { + const Lane::MeanFilterItem* item = + meanMode ? tabLane.FindMeanFilterItem(ref) + : tabLane.FindEdgeCenterItem(ref); + BeginEdit(meanMode ? PlotEditTarget::MEAN_FILTER_ITERATIONS + : PlotEditTarget::EDGE_CENTER_ITERATIONS, + index, std::to_wstring(item != nullptr ? item->iterations : 1), + layout.rowIterations[rowIndex]); + } return true; } } if (boostMode && layout.button.Width > 0.0f && UiPointInRect(layout.button, fx, fy)) { - // 「弱峰增强 → 构建为新信号」:与均值滤波同一套请求机制 - //(等级与迭代次数属于整个网格,网格内各被勾选信号共用同一组参数)。 + // 「弱峰增强 → 构建为新信号」:与均值滤波同一套请求机制, + // **每个信号用自己的等级与迭代次数**(与预览曲线逐位一致)。 const Lane& lane = m_tab->Lanes()[static_cast(index)]; - const int levelPercent = static_cast( - std::lround(std::max(0.0, std::min(kWeakPeakLevelMax, lane.weakPeakLevel)) * 100.0)); - const int iterations = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMax, lane.weakPeakIterations)); m_pendingWeakPeakItems.clear(); for (const SignalRef& ref : lane.signals) { @@ -4674,21 +4532,26 @@ namespace jdi { continue; } - if (lane.FindWeakPeakItem(ref) == nullptr) + const Lane::WeakPeakItem* item = lane.FindWeakPeakItem(ref); + if (item == nullptr) { continue; } WeakPeakBuild build; build.fileIndex = ref.fileIndex; build.signalIndex = ref.signalIndex; - build.levelPercent = levelPercent; - build.iterations = iterations; + build.levelPercent = + std::max(0, std::min(kWeakPeakPercentMax, item->levelPercent)); + build.iterations = std::max( + kWeakPeakIterationsMin, + std::min(kWeakPeakIterationsMax, item->iterations)); m_pendingWeakPeakItems.push_back(build); } if (!m_pendingWeakPeakItems.empty()) { - SetHint(L"正在构建弱峰增强信号(等级 " + std::to_wstring(levelPercent) + - L"% / " + std::to_wstring(iterations) + L" 次迭代,共 " + + const WeakPeakBuild& first = m_pendingWeakPeakItems.front(); + SetHint(L"正在构建弱峰增强信号(等级 " + std::to_wstring(first.levelPercent) + + L"% / " + std::to_wstring(first.iterations) + L" 次迭代,共 " + std::to_wstring(m_pendingWeakPeakItems.size()) + L" 条)…"); m_pendingWeakPeakLane = index; } @@ -4748,10 +4611,6 @@ namespace jdi m_dragMode = PlotDragMode::NONE; return true; } - if (handledPanel) - { - break; - } } // 「边沿计数」横向光标:上下拖动改变幅值。 @@ -4833,7 +4692,6 @@ namespace jdi m_edgeDragLane = -1; m_edgePanelDragLane = -1; m_edgePanelDragKind = EdgePanelKind::COUNT; - m_peakDragLane = -1; SetHint(std::wstring()); m_dragLane = LaneAtY(y); m_dragMode = PlotDragMode::PAN; @@ -4952,7 +4810,6 @@ namespace jdi m_edgeDragLane = -1; m_edgePanelDragLane = -1; m_edgePanelDragKind = EdgePanelKind::COUNT; - m_peakDragLane = -1; // 松开左键:粘滞提示结束计时,开始"停留后淡出"。 ReleaseHint(); return changed; @@ -5540,9 +5397,7 @@ namespace jdi // 让它们直接复用静态场景缓存(贴图 ~1ms)+ 叠加层,做到真正跟手。 const bool sceneChangingDrag = (m_dragMode == PlotDragMode::PAN) || (m_dragMode == PlotDragMode::SCROLL_LANES) || - (m_dragMode == PlotDragMode::LANE_REORDER) || (m_dragMode == PlotDragMode::MOVE_SIGNAL) || - (m_dragMode == PlotDragMode::PEAK_LEVEL) || - (m_dragMode == PlotDragMode::PEAK_ITERATIONS); + (m_dragMode == PlotDragMode::LANE_REORDER) || (m_dragMode == PlotDragMode::MOVE_SIGNAL); m_fastFrame = sceneChangingDrag; // 贴图之后、静态场景之外的全部内容(随鼠标变化或位于最上层)。 @@ -5627,31 +5482,39 @@ namespace jdi wideKey *= 1099511628211ull; wideKey ^= static_cast(std::llround(Tab().ViewMax() * 1.0e6)); - if (m_wideLastValid && m_wideLastKey == wideKey) + // 「结算」:视界已停止变化(内容与视界都与上一条快路径帧相同)、**不在拖拽中**、 + // 且**全部网格图层都已就绪** → 落到下方整幅全质量重绘,保证最终呈现像素精确 + // (此后命中静态场景缓存,不再回到本分支)。 + // + // ⚠️ 三个条件缺一不可: + // * **不在拖拽中**:拖拽期间不做结算,否则每两帧就会穿插一次整幅重绘 —— + // 而拖拽帧的 `m_fastFrame` 为真(关抗锯齿),于是出现"波形质量轮流变差/抖动" + // 与周期性卡顿(这正是用户报告的"按住拖拽时显示效果下降、断线"的根因)。 + // * **全部图层就绪**:还有网格没画好时不能结算,否则结算帧之后的格子又是旧的。 + // * 记录已消费本 key,避免下一帧重复结算。 + const bool viewStopped = (m_wideLastValid && m_wideLastKey == wideKey); + if (viewStopped && m_dragMode == PlotDragMode::NONE && LanesAllReadyForView()) { - m_wideLastValid = false; // 结算:落到下方整幅重绘 + m_wideLastValid = false; // 结算:落到下方整幅重绘(非拖拽 → 全质量) } else { - // 图层缺失 / 视界跨度变化(缩放 / 布局变化)时先**同步重建**全部网格图层; - // 随后 `RollWideBuffer()` 逐网格滚动跟随视界(每个网格用自己的 `wideMin` 算位移, - // 内容指纹与当前状态不一致的网格会被单独整块重画)。 - // 只有"图层仍然建不起来"时才落到下方整幅重绘以保证正确性。 - if (!LayersReadyForView()) + // 结构不可用(视界跨度 / 布局变化)→ 所有网格的时间映射都变了,只能整批重建一次 + //(这只是跨度变化时的一次性代价,平移过程不会触发)。 + if (!LayersStructurallyReady()) { RebuildWideBuffer(); } - bool ready = RollWideBuffer(); - // 图层渲染一律在本线程内完成(不要把这一步搬回后台线程), - // 否则后台对渲染状态的"保存→改写→恢复"会覆盖用户正在进行的平移。 - if (ready) + // 逐网格滚动跟随视界。⚠️ `RollWideBuffer` **不做整块重画**:内容过期 / 锚点 + // 过远的网格只登记到待重画队列,因此前台每帧的工作量恒定且很小。 + RollWideBuffer(); + // 绘图区底色(覆盖时间轴与侧边留白区域)。 + UiFillRect(g, m_area, m_theme->plotBg); + // 贴基底(绘图区:网格 + 波形)。图层没覆盖到的部分先留空,后续帧补齐。 + if (BlitWideBuffer(g)) { m_wideLastKey = wideKey; m_wideLastValid = true; - // 绘图区底色(覆盖时间轴与侧边留白区域)。 - UiFillRect(g, m_area, m_theme->plotBg); - // 贴基底(绘图区:网格 + 波形)。 - BlitWideBuffer(g); // 时间轴:宽缓冲不含它,前台按当前视图绘制。 const double wideViewMin = Tab().ViewMin(); const double wideViewMax = Tab().ViewMax(); @@ -5690,6 +5553,19 @@ namespace jdi } } paintOverlays(); + // 预算内补画"待重画"的网格:**本帧最多一个**,其余留到后续帧。 + // 这样即使 5 个网格同时需要重画,前台也不会在某一帧里做完整批而卡顿; + // 未画好的部分本帧留空(或沿用旧像素),下一个循环就绪后自然出现。 + const bool morePending = RenderPendingLaneRebuilds(1); + if (morePending) + { + // 请宿主再送一帧(与派生曲线就绪用同一条消息:只失效绘图区且不置脏)。 + const HWND host = m_host; + if (host != nullptr) + { + PostMessageW(host, kMessagePlotDerivedReady, 0, 0); + } + } return; } } @@ -6037,13 +5913,14 @@ namespace jdi mix(static_cast(item.iterations)); } mix(Tab().Lanes()[laneIndex].weakPeak ? 1ull : 0ull); - mixFloat(static_cast(Tab().Lanes()[laneIndex].weakPeakLevel)); - mix(static_cast(Tab().Lanes()[laneIndex].weakPeakIterations)); mix(static_cast(Tab().Lanes()[laneIndex].weakPeakItems.size())); for (const Lane::WeakPeakItem& item : Tab().Lanes()[laneIndex].weakPeakItems) { + // 逐信号参数(等级 + 迭代次数)都参与指纹:改任一条的参数都会重画。 mix(static_cast(item.ref.fileIndex)); mix(static_cast(item.ref.signalIndex)); + mix(static_cast(item.levelPercent)); + mix(static_cast(item.iterations)); } } mix(signals.size()); @@ -6176,8 +6053,6 @@ namespace jdi differs = (nowDerived.edgeCenter != beforeDerived.edgeCenter) || (nowDerived.meanFilter != beforeDerived.meanFilter) || (nowDerived.weakPeak != beforeDerived.weakPeak) || - (nowDerived.weakPeakLevelPercent != beforeDerived.weakPeakLevelPercent) || - (nowDerived.weakPeakIterations != beforeDerived.weakPeakIterations) || (nowDerived.edgeCenterItems != beforeDerived.edgeCenterItems) || (nowDerived.meanFilterItems != beforeDerived.meanFilterItems) || (nowDerived.weakPeakItems != beforeDerived.weakPeakItems); @@ -6201,10 +6076,7 @@ namespace jdi entry.edgeCenter = lane.edgeCenter ? 1 : 0; entry.meanFilter = lane.meanFilter ? 1 : 0; entry.weakPeak = lane.weakPeak ? 1 : 0; - entry.weakPeakLevelPercent = static_cast( - std::lround(std::max(0.0, std::min(kWeakPeakLevelMax, lane.weakPeakLevel)) * 100.0)); - entry.weakPeakIterations = lane.weakPeakIterations; - const auto flatten = [](const auto& items, std::vector& target) + const auto flatten = [](const auto& items, std::vector& target, auto valueOf) { target.clear(); target.reserve(items.size() * 4); @@ -6212,13 +6084,17 @@ namespace jdi { target.push_back(item.ref.fileIndex); target.push_back(item.ref.signalIndex); - target.push_back(item.points); + // 均值滤波/中心轴取 points,弱峰增强取 levelPercent(由 `valueOf` 决定)。 + target.push_back(static_cast(valueOf(item))); target.push_back(item.iterations); } }; - flatten(lane.edgeCenterItems, entry.edgeCenterItems); - flatten(lane.meanFilterItems, entry.meanFilterItems); - flatten(lane.weakPeakItems, entry.weakPeakItems); + flatten(lane.edgeCenterItems, entry.edgeCenterItems, + [](const Lane::EdgeCenterItem& item) { return item.points; }); + flatten(lane.meanFilterItems, entry.meanFilterItems, + [](const Lane::MeanFilterItem& item) { return item.points; }); + flatten(lane.weakPeakItems, entry.weakPeakItems, + [](const Lane::WeakPeakItem& item) { return item.levelPercent; }); out[laneIndex] = std::move(entry); } } @@ -6898,6 +6774,7 @@ namespace jdi ReleaseLaneLayer(layer); } m_layerFront.clear(); + m_layerRebuildPending.clear(); } void PlotView::InvalidateWideBuffer() @@ -6907,6 +6784,7 @@ namespace jdi { layer.valid = false; } + m_layerRebuildPending.clear(); } std::uint64_t PlotView::LaneLayerKey(int laneIndex) const @@ -6937,7 +6815,7 @@ namespace jdi return Tab().ViewMin() - static_cast(margin) / scale; } - bool PlotView::LayersReadyForView() const + bool PlotView::LayersStructurallyReady() const { std::lock_guard lock(m_layerMutex); if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) @@ -6953,8 +6831,9 @@ namespace jdi const int width = static_cast(std::lround(plotWidth)); const int margin = std::max(1, width / 2); const int bitmapWidth = width + 2 * margin; - for (const LaneLayer& layer : m_layerFront) + for (std::size_t index = 0; index < m_layerFront.size(); ++index) { + const LaneLayer& layer = m_layerFront[index]; if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth) { return false; @@ -6964,10 +6843,120 @@ namespace jdi { return false; } + // 高度也要一致:网格高度变化会让贴图纵向错位。 + const int bitmapHeight = + static_cast(std::ceil(m_lanes[index].rect.Height)); + if (layer.height != bitmapHeight) + { + return false; + } } return true; } + bool PlotView::LaneLayerReady(int laneIndex) const + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return false; + } + std::lock_guard lock(m_layerMutex); + if (laneIndex >= static_cast(m_layerFront.size())) + { + return false; + } + const LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + const float plotWidth = m_plotRect.Width; + const double span = Tab().ViewMax() - Tab().ViewMin(); + if (!(plotWidth > 8.0f) || !(span > 0.0)) + { + return false; + } + const int width = static_cast(std::lround(plotWidth)); + const int margin = std::max(1, width / 2); + const int bitmapWidth = width + 2 * margin; + const int bitmapHeight = static_cast(std::ceil(m_lanes[static_cast(laneIndex)].rect.Height)); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + return false; + } + return layer.key == LaneLayerKey(laneIndex); + } + + bool PlotView::LanesAllReadyForView() const + { + if (m_lanes.empty()) + { + return false; + } + for (std::size_t index = 0; index < m_lanes.size(); ++index) + { + if (!LaneLayerReady(static_cast(index))) + { + return false; + } + } + return true; + } + + void PlotView::QueueLaneRebuild(int laneIndex) + { + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + return; + } + for (const int existing : m_layerRebuildPending) + { + if (existing == laneIndex) + { + return; // 已经在队列里 + } + } + m_layerRebuildPending.push_back(laneIndex); + } + + bool PlotView::RenderPendingLaneRebuilds(int budget) + { + if (m_layerRebuildPending.empty() || budget <= 0) + { + return !m_layerRebuildPending.empty(); + } + const double span = Tab().ViewMax() - Tab().ViewMin(); + const double defaultWideMin = LayerDefaultWideMin(); + int done = 0; + while (!m_layerRebuildPending.empty() && done < budget) + { + const int laneIndex = m_layerRebuildPending.front(); + m_layerRebuildPending.erase(m_layerRebuildPending.begin()); + if (laneIndex < 0 || laneIndex >= static_cast(m_lanes.size())) + { + continue; + } + { + // 加锁顺序与前台一致:先 `m_renderMutex` 再 `m_layerMutex`。 + std::lock_guard renderLock(m_renderMutex); + std::lock_guard layerLock(m_layerMutex); + if (m_layerFront.size() != m_lanes.size()) + { + break; + } + LaneLayer& layer = m_layerFront[static_cast(laneIndex)]; + // 跨度已经又变了 → 该网格由下一次整批重建处理,这里不浪费这一帧的预算。 + if (std::fabs(layer.span - span) > 1.0e-12) + { + continue; + } + if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, defaultWideMin)) + { + continue; + } + } + ++done; + } + return !m_layerRebuildPending.empty(); + } + bool PlotView::RenderLaneLayerRegion(int laneIndex, LaneLayer& layer, int stripX, int stripW, double wideMin) { @@ -7097,6 +7086,7 @@ namespace jdi std::lock_guard renderLock(m_renderMutex); std::lock_guard layerLock(m_layerMutex); m_layerFront.resize(m_lanes.size()); + m_layerRebuildPending.clear(); // 全部网格统一用"当前视界居中"的映射(与 `BlitWideBuffer` 的逐网格 `wideMin` 相容)。 const double wideMin = LayerDefaultWideMin(); @@ -7111,13 +7101,13 @@ namespace jdi return ok; } - bool PlotView::RollWideBuffer() + void PlotView::RollWideBuffer() { const float plotWidth = m_plotRect.Width; const double span = Tab().ViewMax() - Tab().ViewMin(); if (!(plotWidth > 8.0f) || !(span > 0.0)) { - return false; + return; } const int width = static_cast(std::lround(plotWidth)); const int margin = std::max(1, width / 2); @@ -7128,40 +7118,56 @@ namespace jdi std::lock_guard layerLock(m_layerMutex); if (m_layerFront.size() != m_lanes.size() || m_layerFront.empty()) { - return false; - } - for (const LaneLayer& layer : m_layerFront) - { - if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || - std::fabs(layer.span - span) > 1.0e-12) - { - return false; // 图层缺失 / 跨度不符:由调用方先 RebuildWideBuffer() - } + return; } // ⚠️ **逐网格独立滚动**:每个图层有自己的时间映射(`layer.wideMin`), // 因此位移必须按各自的锚点分别计算。曾用**一个全局锚点**给所有图层算同一个位移, // 结果各网格被平移了不同的真实距离 → 网格之间时间错位、贴回的像素互相矛盾, // 表现为严重的绘制错误与波形抖动。 - const double defaultWideMin = LayerDefaultWideMin(); - bool ok = true; + // + // ⚠️ **本函数绝不重画整块图层**:内容过期 / 锚点太远的网格只登记到待重画队列 + //(由 `RenderPendingLaneRebuilds` 按预算在后续帧补画),本帧仍尽量用现有像素跟随视界。 + // 这样前台**永远不会因为"图层没准备好"而做整批重绘**(那正是"拖拽时前台卡顿"的根因)。 for (std::size_t index = 0; index < m_layerFront.size(); ++index) { LaneLayer& layer = m_layerFront[index]; const int laneIndex = static_cast(index); + // 结构不可用(尺寸 / 跨度不符):像素的时间映射已经不对,既不能贴也不能滚。 + const int bitmapHeight = static_cast(std::ceil(m_lanes[index].rect.Height)); + if (!layer.valid || layer.dc == nullptr || layer.width != bitmapWidth || + layer.height != bitmapHeight || std::fabs(layer.span - span) > 1.0e-12) + { + QueueLaneRebuild(laneIndex); + continue; + } + // 视界左端当前落在该图层的哪个 x 上;期望它等于 `margin`(左右余量对称)。 const double desired = (Tab().ViewMin() - layer.wideMin) * scale; const int shift = static_cast(std::llround(desired - static_cast(margin))); - - // 内容(外观 / 网格几何 / 幅值 / 可见性…)与当前状态不一致,或锚点离当前视界 - // 太远 —— 都不能再用旧像素做坐标偏移,直接整块重画该网格。 + const bool tooFar = (std::abs(shift) >= bitmapWidth - 4); + // 内容(外观 / 网格几何 / 幅值 / 可见性…)与当前状态不一致。 const bool contentStale = (layer.key != LaneLayerKey(laneIndex)); - if (contentStale || std::abs(shift) >= bitmapWidth - 4) + + if (contentStale || tooFar) { - if (!RenderLaneLayerRegion(laneIndex, layer, 0, 0, defaultWideMin)) + // 登记待重画(本帧不重画),但仍按现有像素平移,避免网格"卡在原地"。 + QueueLaneRebuild(laneIndex); + if (!tooFar && shift != 0) { - ok = false; + if (shift > 0) + { + BitBlt(layer.dc, 0, 0, bitmapWidth - shift, layer.height, layer.dc, shift, 0, + SRCCOPY); + } + else + { + const int amount = -shift; + BitBlt(layer.dc, amount, 0, bitmapWidth - amount, layer.height, layer.dc, 0, 0, + SRCCOPY); + } + layer.wideMin += static_cast(shift) / scale; } continue; } @@ -7183,17 +7189,13 @@ namespace jdi } layer.wideMin += static_cast(shift) / scale; - // 只补画新暴露的细条(每帧代价恒为 O(细条),无周期性大尖峰)。 + // 只补画新暴露的细条(每帧代价恒为 O(细条))。 // ⚠️ 细条必须用**该图层更新后的映射**渲染(传 `layer.wideMin`);若改用"当前视界" // 的映射,细条与已平移的旧像素之间会有 ≤0.5px 的错位接缝 → 拖动时看起来在抖动。 const int stripWidth = std::min(std::abs(shift), bitmapWidth); const int stripX = (shift > 0) ? (bitmapWidth - stripWidth) : 0; - if (!RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, layer.wideMin)) - { - ok = false; - } + RenderLaneLayerRegion(laneIndex, layer, stripX, stripWidth, layer.wideMin); } - return ok; } bool PlotView::BlitWideBuffer(gd::Graphics& g) const @@ -7231,11 +7233,6 @@ namespace jdi { continue; } - const double subX = (Tab().ViewMin() - layer.wideMin) * scale; - if (subX < -0.5 || subX + static_cast(width) > static_cast(bitmapWidth) + 0.5) - { - continue; // 该网格图层已移出当前视界范围 → 由调用方安排重建 - } const gd::RectF& lane = m_lanes[index].rect; 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); @@ -7245,14 +7242,35 @@ namespace jdi } const int destX = static_cast(std::lround(m_plotRect.X)); const int destY = static_cast(std::lround(top)); - const int srcX = static_cast(std::lround(subX)); const int srcY = static_cast(std::lround(top - lane.Y)); const int height = static_cast(std::lround(bottom - top)); if (height <= 0) { continue; } - BitBlt(target, destX, destY, width, height, layer.dc, srcX, srcY, SRCCOPY); + + // ⚠️ **局部贴图**:图层只覆盖"视界 ± 半屏",视界可能有一部分落在图层之外。 + // 这里只贴**有像素的那一段**(其余留给底色填充,后续帧由预算内重画补齐), + // 而不是整块跳过 —— 跳过会让整个网格瞬间变空("断线 / 空白"的直接原因)。 + const double subX = (Tab().ViewMin() - layer.wideMin) * scale; + int srcX = static_cast(std::lround(subX)); + int destOffset = 0; // 从视界左端起偏移多少像素开始贴 + int blitWidth = width; + if (srcX < 0) + { + destOffset = -srcX; + blitWidth -= destOffset; + srcX = 0; + } + if (srcX + blitWidth > bitmapWidth) + { + blitWidth = bitmapWidth - srcX; + } + if (blitWidth <= 0) + { + continue; // 完全没有可用像素(该网格待重画) + } + BitBlt(target, destX + destOffset, destY, blitWidth, height, layer.dc, srcX, srcY, SRCCOPY); any = true; } g.ReleaseHDC(target); @@ -7795,10 +7813,6 @@ namespace jdi { return; } - const int levelPercent = static_cast( - std::lround(std::max(0.0, std::min(kWeakPeakLevelMax, tabLane.weakPeakLevel)) * 100.0)); - const int boostIterations = - std::max(kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMax, tabLane.weakPeakIterations)); const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); for (const SignalRef& ref : tabLane.signals) @@ -7826,9 +7840,14 @@ namespace jdi } if (weakPeakOn) { + // 每个信号用自己的「增强等级 + 迭代次数」(与面板输入框、构建新信号完全一致)。 const Lane::WeakPeakItem* item = tabLane.FindWeakPeakItem(ref); if (item != nullptr) { + const int levelPercent = + std::max(0, std::min(kWeakPeakPercentMax, item->levelPercent)); + const int boostIterations = std::max( + kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMax, item->iterations)); PaintWeakPeakCurve(g, resolved, lane, levelPercent, boostIterations); } } @@ -8620,12 +8639,11 @@ namespace jdi m_tab->Lanes()[laneIndex].FindWeakPeakItem(ref); if (boostItem != nullptr) { - const int levelPercent = static_cast(std::lround( - std::max(0.0, std::min(kWeakPeakLevelMax, m_tab->Lanes()[laneIndex].weakPeakLevel)) * - 100.0)); + const int levelPercent = + std::max(0, std::min(kWeakPeakPercentMax, boostItem->levelPercent)); const int iterations = std::max( kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMax, m_tab->Lanes()[laneIndex].weakPeakIterations)); + std::min(kWeakPeakIterationsMax, boostItem->iterations)); paintDerivedCapsule(DerivedCurveKind::WEAK_PEAK, 0, iterations, levelPercent, L"弱峰增强"); } @@ -8698,6 +8716,105 @@ namespace jdi } } + void PlotView::SyncPanelOrder() const + { + // 收集当前"可见"(已启用且已展开、且网格内有可见信号)的面板集合。 + std::vector visible; + for (int index = 0; index < static_cast(m_lanes.size()); ++index) + { + for (int pass = 0; pass < kEdgePanelKindCount; ++pass) + { + const EdgePanelKind kind = kEdgePanelKinds[pass]; + if (EdgePanelLayoutFor(index, kind).panel.Width > 0.0f) + { + PanelRef ref; + ref.laneIndex = index; + ref.kind = static_cast(kind); + visible.push_back(ref); + } + } + } + // 保留已有层序(剔除已不可见的),并把**新出现**的面板追加到队尾(= 最上层), + // 这样新打开的面板不会被已有面板盖住。 + std::vector next; + next.reserve(visible.size()); + for (const PanelRef& existing : m_panelOrder) + { + for (const PanelRef& candidate : visible) + { + if (candidate == existing) + { + next.push_back(existing); + break; + } + } + } + for (const PanelRef& candidate : visible) + { + bool present = false; + for (const PanelRef& kept : next) + { + if (kept == candidate) + { + present = true; + break; + } + } + if (!present) + { + next.push_back(candidate); + } + } + m_panelOrder = std::move(next); + } + + void PlotView::BringPanelToFront(int laneIndex, int kind) + { + SyncPanelOrder(); + PanelRef target; + target.laneIndex = laneIndex; + target.kind = kind; + for (std::size_t index = 0; index < m_panelOrder.size(); ++index) + { + if (!(m_panelOrder[index] == target)) + { + continue; + } + if (index + 1 == m_panelOrder.size()) + { + return; // 已经在最上层 + } + m_panelOrder.erase(m_panelOrder.begin() + static_cast(index)); + m_panelOrder.push_back(target); + return; + } + } + + bool PlotView::TopPanelAtPoint(float x, float y, int& laneIndex, int& kind) const + { + laneIndex = -1; + kind = 0; + SyncPanelOrder(); + // `m_panelOrder` 队尾 = 最上层:从后往前找第一个命中的面板。 + for (std::size_t index = m_panelOrder.size(); index-- > 0;) + { + const PanelRef& ref = m_panelOrder[index]; + const EdgePanelLayout layout = + EdgePanelLayoutFor(ref.laneIndex, static_cast(ref.kind)); + if (layout.panel.Width <= 0.0f) + { + continue; + } + if (UiPointInRect(layout.panel, x, y)) + { + laneIndex = ref.laneIndex; + kind = ref.kind; + return true; + } + } + return false; + } + PlotView::EdgePanelLayout PlotView::EdgePanelLayoutFor(int laneIndex, EdgePanelKind kind) const { EdgePanelLayout layout; @@ -8748,13 +8865,13 @@ namespace jdi // 因此不再有独立的底部编辑行;只有「边沿计数」保留两行文本式排版。 const float buttonHeight = hasButton ? (kEdgePanelButtonHeight * m_scale) : 0.0f; const float rowsPerSignal = meanFilter ? 1.0f : 2.0f; - // 「弱峰增强」在条目与底部按钮之间额外有两行滑块(等级 / 迭代次数 N),其余逐条目面板没有。 - const float sliderRowHeight = weakPeak ? (kWeakPeakSliderRowHeight * m_scale) : 0.0f; - const float sliderRowsHeight = weakPeak ? (sliderRowHeight * 2.0f) : 0.0f; // 逐条目面板:每个条目 = 第一排(信号名 + 复选框) - // + 第二排(均值点数 X / 迭代次数 N)+(中心轴)第三排(统计结果); + // + 第二排(均值点数 X / 迭代次数 N,或弱峰增强的增强等级 % / 迭代次数 N) + // +(中心轴)第三排(统计结果); // 后两排**只在该条目被勾选时才占位**,因此面板高度随勾选数量动态变化。 + // 逐条目参数行:MEAN / CENTER / PEAK 三种面板共用(PEEK 与 MEAN 完全同构)。 + const bool itemHasParams = (centerAxis || meanFilter || weakPeak); float meanRowHeight = 0.0f; float itemsHeight = 0.0f; float height = 0.0f; @@ -8772,7 +8889,7 @@ namespace jdi const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakPeak ? lane.WeakPeakEnabled(ref) : lane.EdgeCenterEnabled(ref)); - if (checked && (centerAxis || meanFilter)) + if (checked && itemHasParams) { itemsHeight += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) @@ -8783,8 +8900,7 @@ namespace jdi } // 底部按钮行:上方与条目之间、下方与面板下边界之间各留一个 `pad`, // 与面板的左右内边距完全一致(原先按钮直接贴着下边框,看起来很不协调)。 - height = titleHeight + pad + itemsHeight + sliderRowsHeight + pad + buttonHeight + - (hasButton ? pad : 0.0f); + height = titleHeight + pad + itemsHeight + pad + buttonHeight + (hasButton ? pad : 0.0f); } else { @@ -8829,29 +8945,9 @@ namespace jdi std::max(0.0f, width - pad * 2.0f), buttonRowHeight); } - // 弱峰增强的两个滑块行(等级 / 迭代次数 N,位于信号条目与底部按钮之间)。 - if (weakPeak) - { - const float labelWidth = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsLabel)); - const float valueWidth = std::max(UiMeasureTextWidth(m_fonts->monoSmall, L"100%"), - UiMeasureTextWidth(m_fonts->monoSmall, L"10")); - const float trackHeight = kWeakPeakTrackHeight * m_scale; - const float firstRowTop = top + titleHeight + pad + itemsHeight; - const float trackLeft = left + pad + labelWidth + 6.0f * m_scale; - const float trackRight = left + width - pad - valueWidth - 6.0f * m_scale; - const float trackWidth = std::max(10.0f * m_scale, trackRight - trackLeft); - layout.slider = - gd::RectF(trackLeft, firstRowTop + (sliderRowHeight - trackHeight) * 0.5f, - trackWidth, trackHeight); - layout.sliderIterations = - gd::RectF(trackLeft, firstRowTop + sliderRowHeight + (sliderRowHeight - trackHeight) * 0.5f, - trackWidth, trackHeight); - } - // 逐信号条目:第一排 = 名称 + 复选框(复选框靠右); - // 第二排 = 「均值点数 X」与「迭代次数 N」两个输入框(仅勾选时存在); + // 第二排 = 两个参数输入框(MEAN:均值点数 X / 迭代次数 N;PEAK:增强等级 % / 迭代次数 N, + // 仅勾选时存在; // 第三排(中心轴)= 上升沿/下降沿统计结果(仅勾选时存在)。 const float checkSize = kEdgePanelMeanCheckSize * m_scale; const float fieldHeight = kEdgePanelMeanEditHeight * m_scale; @@ -8872,7 +8968,7 @@ namespace jdi const bool checked = meanFilter ? lane.MeanFilterEnabled(ref) : (weakPeak ? lane.WeakPeakEnabled(ref) : lane.EdgeCenterEnabled(ref)); - if (checked && (centerAxis || meanFilter)) + if (checked && itemHasParams) { const float editTop = rowY + meanRowHeight; layout.rowPoints.push_back( @@ -8898,7 +8994,7 @@ namespace jdi } rowY += meanRowHeight; - if (checked && (centerAxis || meanFilter)) + if (checked && itemHasParams) { rowY += (kEdgePanelMeanEditHeight + kEdgePanelMeanRowGap) * m_scale; if (centerAxis) @@ -9432,7 +9528,6 @@ namespace jdi mixRef(m_hoverLegend); mixRef(m_dragSignal); mixRef(SelectedRef()); - mix(static_cast(m_peakDragLane + 2)); mix(static_cast(m_edgePanelDragLane + 2)); mix(static_cast(m_edgePanelDragKind)); @@ -9485,6 +9580,14 @@ namespace jdi mixDouble(lane.edgeValue); } } + + // ⚠️ **面板层序也是"全局"状态**:层序变化(点击某个面板把它置顶)会让"谁盖住谁"改变, + // 因此必须让所有面板缓存失效、按新层序整层重画(否则贴回的旧像素仍是旧层叠关系)。 + for (const PanelRef& ref : m_panelOrder) + { + mix(static_cast(ref.laneIndex + 2)); + mix(static_cast(ref.kind + 2)); + } return hash; } @@ -9513,8 +9616,6 @@ namespace jdi mixRect(layout.close); mixRect(layout.edit); mixRect(layout.button); - mixRect(layout.slider); - mixRect(layout.sliderIterations); mix(static_cast(layout.rows)); for (const gd::RectF& rect : layout.rowChecks) { @@ -9549,14 +9650,10 @@ namespace jdi const Lane::EdgeCenterItem* centerItem = lane.FindEdgeCenterItem(ref); mix(static_cast(centerItem != nullptr ? centerItem->points + 2 : 0)); mix(static_cast(centerItem != nullptr ? centerItem->iterations + 2 : 0)); - } - // 滑块数值(等级百分比 + 迭代次数)只显示在「弱峰增强」面板上: - // ⚠️ 必须限定 kind,否则拖等级滑块会把同一网格的均值滤波 / 中心轴面板 - // 也判为脏,再经"重叠级联"扩散到其他网格,等于所有面板都重画(实测 40ms/帧)。 - if (kind == EdgePanelKind::PEAK) - { - mixFloat(static_cast(lane.weakPeakLevel)); - mix(static_cast(lane.weakPeakIterations + 2)); + // 弱峰增强的逐信号参数(增强等级 % + 迭代次数)直接显示在面板输入框里。 + const Lane::WeakPeakItem* peakItem = lane.FindWeakPeakItem(ref); + mix(static_cast(peakItem != nullptr ? peakItem->levelPercent + 2 : 0)); + mix(static_cast(peakItem != nullptr ? peakItem->iterations + 2 : 0)); } // 鼠标落在面板内时,✕ / 复选框 / 滑块 / 底部按钮的悬停态都依赖精确坐标。 @@ -9690,17 +9787,24 @@ namespace jdi (m_editTarget == PlotEditTarget::NONE) && EnsurePanelCache(); const bool panelGlobalSame = panelCacheReady && m_panelValid && panelGlobalKey == m_panelGlobalKey; - for (int pass = 0; pass < kEdgePanelKindCount; ++pass) + // ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致): + // `m_panelOrder` 队尾 = 最上层 = 最后绘制。 + SyncPanelOrder(); + for (const PanelRef& panelRef : m_panelOrder) { - const EdgePanelKind kind = kEdgePanelKinds[pass]; + const EdgePanelKind kind = static_cast(panelRef.kind); + const std::size_t index = static_cast(panelRef.laneIndex); const bool centerAxis = (kind == EdgePanelKind::CENTER); const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); // 「均值滤波」与「弱峰增强」共用「逐条目 + 底部按钮」排布。 const bool itemized = (centerAxis || meanFilter || weakPeak); const bool hasButton = (meanFilter || weakPeak); - for (std::size_t index = 0; index < count; ++index) { + if (index >= count) + { + continue; + } const Lane& tabLane = tabLanes[index]; const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast(index), kind); const gd::RectF& panel = layout.panel; @@ -9854,15 +9958,28 @@ namespace jdi continue; } - // 第二排:两个输入框(「均值点数 X / 迭代次数 N」;弱峰增强没有参数输入框)。 + // 第二排:两个参数输入框。 + // MEAN/CENTER = 「均值点数 X / 迭代次数 N」;PEAK = 「增强等级 % / 迭代次数 N」。 const Lane::MeanFilterItem* item = meanFilter ? tabLane.FindMeanFilterItem(ref) : tabLane.FindEdgeCenterItem(ref); - const int shownPoints = (item != nullptr) ? item->points : 5; - const int shownOrder = (item != nullptr) ? item->iterations : 1; - const PlotEditTarget pointsTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS - : PlotEditTarget::EDGE_CENTER_POINTS; - const PlotEditTarget orderTarget = meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS - : PlotEditTarget::EDGE_CENTER_ITERATIONS; + const Lane::WeakPeakItem* peakItem = + weakPeak ? tabLane.FindWeakPeakItem(ref) : nullptr; + const int shownPoints = + weakPeak ? (peakItem != nullptr ? peakItem->levelPercent : 150) + : ((item != nullptr) ? item->points : 5); + const int shownOrder = weakPeak + ? (peakItem != nullptr ? peakItem->iterations : 1) + : ((item != nullptr) ? item->iterations : 1); + const PlotEditTarget pointsTarget = + weakPeak ? PlotEditTarget::PEAK_LEVEL_PERCENT + : (meanFilter ? PlotEditTarget::MEAN_FILTER_POINTS + : PlotEditTarget::EDGE_CENTER_POINTS); + const PlotEditTarget orderTarget = + weakPeak ? PlotEditTarget::PEAK_ITERATIONS + : (meanFilter ? PlotEditTarget::MEAN_FILTER_ITERATIONS + : PlotEditTarget::EDGE_CENTER_ITERATIONS); + const wchar_t* const pointsLabel = + weakPeak ? kWeakPeakLevelLabel : L"均值点数 X"; const auto paintField = [&](const gd::RectF& rect, const wchar_t* label, int value, PlotEditTarget target) { @@ -9899,8 +10016,9 @@ namespace jdi m_theme->textStrong, TextAlign::CENTER); } }; - paintField(layout.rowPoints[rowIndex], L"均值点数 X", shownPoints, pointsTarget); - paintField(layout.rowIterations[rowIndex], L"迭代次数 N", shownOrder, orderTarget); + paintField(layout.rowPoints[rowIndex], pointsLabel, shownPoints, pointsTarget); + paintField(layout.rowIterations[rowIndex], kWeakPeakIterationsLabel, shownOrder, + orderTarget); // 第三排(中心轴):该信号在当前参数下的上升沿/下降沿统计结果。 if (centerAxis && rowIndex < layout.rowStats.size() && @@ -9920,61 +10038,6 @@ namespace jdi TextAlign::LEFT); } } - - // 「弱峰增强」的两个滑块(等级 0–200% / 迭代次数 N 1–10): - // 左侧说明文字 + 轨道 + 右侧数值。 - if (weakPeak && layout.slider.Width > 0.0f) - { - const float rowHeight = - std::max(kWeakPeakSliderRowHeight * m_scale, layout.slider.Height); - // 两行滑块的说明文字必须**左对齐到同一列**(用户明确要求): - // 列宽取两条标签中较宽者,与 `EdgePanelLayoutFor` 里定位轨道的方式一致。 - const float labelColumnWidth = - std::max(UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakLevelLabel), - UiMeasureTextWidth(m_fonts->uiSmall, kWeakPeakIterationsLabel)); - const auto paintSliderRow = [&](const gd::RectF& track, const wchar_t* label, - double ratio, const std::wstring& valueText, - bool active) - { - if (track.Width <= 0.0f) - { - return; - } - const float labelLeft = track.X - 6.0f * m_scale - labelColumnWidth; - UiText(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y + track.Height * 0.5f - rowHeight * 0.5f, - labelColumnWidth, rowHeight), - m_theme->textMuted, TextAlign::LEFT); - - // 与样式区/拾色器共用同一套滑块绘制(轨道 + 已填充部分 + 圆钮)。 - UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - - const float valueWidth = UiMeasureTextWidth(m_fonts->monoSmall, valueText); - UiText(g, valueText, m_fonts->monoSmall, - gd::RectF(track.X + track.Width + 6.0f * m_scale, - track.Y + track.Height * 0.5f - rowHeight * 0.5f, - valueWidth, rowHeight), - m_theme->textStrong, TextAlign::LEFT); - }; - - const double level = std::max(0.0, std::min(kWeakPeakLevelMax, tabLane.weakPeakLevel)); - const bool levelActive = (m_dragMode == PlotDragMode::PEAK_LEVEL) && - (m_peakDragLane == static_cast(index)); - paintSliderRow(layout.slider, kWeakPeakLevelLabel, level / kWeakPeakLevelMax, - std::to_wstring(static_cast(std::lround(level * 100.0))) + - L"%", - levelActive); - - const int iterations = std::max(kWeakPeakIterationsMin, - std::min(kWeakPeakIterationsMax, tabLane.weakPeakIterations)); - const double orderRatio = static_cast(iterations - kWeakPeakIterationsMin) / - static_cast(kWeakPeakIterationsMax - - kWeakPeakIterationsMin); - const bool orderActive = (m_dragMode == PlotDragMode::PEAK_ITERATIONS) && - (m_peakDragLane == static_cast(index)); - paintSliderRow(layout.sliderIterations, kWeakPeakIterationsLabel, orderRatio, - std::to_wstring(iterations), orderActive); - } } float rowY = panel.Y + titleHeight + pad; diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index 3e5c468..eb80ee6 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -510,6 +510,7 @@ Documents/ 项目文档(含本文件) | 2026-10-12 | DeepSeek-V4.1-Flash | 修复用户报告的「缩放后平移出现拖影/残留/错误元素」与「2ms 一大格区间仍然卡顿」。根因:①网格纵向滚动量未参与任何内容指纹→滚动时把旧位置像素贴回且每两帧触发一次整幅重绘(≈8fps);②`FindStyleOnlyLanes` 未校验视界→视界变化后仍走局部补丁;③宽基底用含装饰的整幅画面初始化→装饰随平移重影;④装饰缓存的矩形列表与坐标原点两帧不同源→装饰消失/黑块/错位。修复后交互帧 9~19ms → **4.5~6ms**,纵向拖动残留 2 万+ 通道 → 72 通道(亚像素量化),静态/结算帧像素精确。新增 `perf.exe ... 6` 诊断模式(逐帧计时 + 逐像素对比 + 差异包围盒)。未实现:每网格双缓冲后台渲染(需先把 `PaintLane` 改造成无状态渲染器,已在文档 9.15 说明)。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **跨设备记忆合流**:把起源设备(本机)的详细工程记忆**并入本文件**(同一记忆文件,不再另建 `agent_mem_origin.md` 等旁支文件),新增 §11(跨设备协作须知)与 §12(起源设备详细记忆原文)。远端 `gitea/main` 已快进合并到本机(`7608e85` → `f97c4cf`,**16** 个提交)。本机 MinGW-w64 GCC 15.2.0 (UCRT) 下 `make release` 通过。 | | 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复「缩放后平移出现严重绘制错误/波形抖动/前台被拖慢」**:根因是后台图层线程会改写共享渲染状态(保存→改写→恢复`m_area`/`m_plotRect`/视界/网格矩形),与 UI 线程无锁的平移/命中测试相冲 → 视界被弹回、网格错位;叠加"全局锚点 + 逐网格内容"导致各网格时间错位。修法:**移除后台图层线程**(改为 UI 线程同步维护)、**逐网格独立锚点与滚动**(补细条用该图层自己的 `wideMin`)、跨度变化时同步 `RebuildWideBuffer()`。同时把「弱波增强」改名「**弱峰增强**」并改为 **0~200% 分段插值**(0% 完全不动 / 100% 到较温和一侧 / 200% 完全拉平;新 1.5 = 旧 0.5)。离屏验证:居中图层 vs 整幅重绘 **5 网格逐像素 0 差异**、平移帧对齐 ≤1px、24 次竞态检测 0 覆盖;测试套件 **380/380 通过**。 | +| 2026-10-12 | DeepSeek-V4.1-Flash(**起源设备**) | **修复"按住键拖拽平移时波形质量下降 / 断线 / 周期性卡顿"与"浮动面板互相覆盖时交互错位"**,并把弱峰增强改为**逐信号参数**。① 平移:`RollWideBuffer` 不再整块重画(只平移+补细条,内容过期/锚点过远只入队),新增 `RenderPendingLaneRebuilds(budget)`(**每帧最多重画 1 个网格**,其余下帧)+ `BlitWideBuffer` **局部贴图**(图层没覆盖的部分留空,不再整块跳过);"结算"整幅精确帧**仅限非拖拽且全部图层就绪**(消除拖拽中每两帧的整幅重绘/质量振荡)。实测 `VERY_BIG_DATA` 5 网格深放大平移:中位 **6.2ms**、max 7.6ms(无尖峰);按住键且视界停在边界时 **0.29ms/帧**。② 弱峰增强:`WeakPeakItem` 改为携带 `levelPercent(0–200)+iterations`,面板改为与均值滤波同构的**逐信号两个输入框**(删除网格级两滑块),哈希/快照/构建/预览全部改为逐信号。③ 浮动面板:新增 `m_panelOrder` 层序,**命中从最上层往下**、点击 `BringPanelToFront`、新面板置顶;修复"上层 ✕ / 标题栏被下层参数框抢走"。离屏测试 **388/388 通过**。 | --- @@ -542,14 +543,15 @@ Documents/ 项目文档(含本文件) 5. ⚠️ **图层渲染一律在 UI 线程内同步完成**(`Paint` 全程持有 `m_renderMutex`)。**绝不要把图层渲染搬到后台线程**:它会临时改写 `m_area`/`m_plotRect`/`Tab().ViewMin()/ViewMax()`/各网格矩形,而这些状态被 UI 线程的消息处理**无锁读/写**(平移读视界、命中测试读网格矩形),后台的"保存→改写→恢复"会**覆盖用户正在进行的平移**(视界弹回)并让命中测试错位 —— 这就是 2026-10-12 移除后台图层线程的原因(详见 §12 本轮)。 6. 每个图层记录**自己的**时间映射 `LaneLayer::wideMin`;滚动与贴图必须**逐网格**按各自 `wideMin` 计算,**绝不共用一个全局锚点**(否则各网格被平移不同的真实距离 → 网格间时间错位)。补细条时必须传 `layer.wideMin`(不能用"当前视界"的映射,否则有 ≤0.5px 接缝与抖动)。 7. 加锁顺序**恒定** `m_renderMutex`(recursive)→ `m_layerMutex`(图层不再有跨线程访问,但顺序约定保留)。 -6. **GDI 绘制不受 GDI+ `SetClip` 约束**:`UiText`(`DrawTextW` / `ETO_CLIPPED`)与 `UiFillRect` 的 GDI 快路径(不透明且面积 ≥ 20000px²)都会画出裁剪区;宽矩形 / 文本必须**自己夹取**,GDI 批绘必须显式传 clip。 -7. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。 -8. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。 -9. **不要为波形构建 / 绘制引入多线程**;已落地的多线程只有:每网格图层后台线程(`m_layerWorker`,`BELOW_NORMAL`)、派生曲线线程池(`m_curveWorkers`)。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。 -10. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。 -11. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。 -12. `GdiplusStartup` 之后创建的 GDI+ 成员(如 `ColorPickerPopup` 的色域缓存 `gd::Bitmap`)必须在 `GdiplusShutdown` **之前**释放,否则退出时访问违例(实测退出码 `0xC0000005`)。 -13. 主题化弹出层禁止 `CreatePopupMenu` / `TrackPopupMenu` / `AppendMenuW`;一律用自绘 `PopupMenu` / `ColorPickerPopup`,且 `Open()` 后必须 `InvalidateRect`。 +8. ⚠️ **前台刷新线程绝不做“整批图层重绘”**:平移/拖动期间 `RollWideBuffer()` **只**做逐网格整数像素平移 + 补细条;内容过期 / 锚点过远的网格只登记到 `m_layerRebuildPending`,由 `RenderPendingLaneRebuilds(budget)` **本帧至多重画 1 个**,其余下帧继续(并 `PostMessage(kMessagePlotDerivedReady)` 请求重绘)。图层没覆盖到的部分 **`BlitWideBuffer` 只贴有像素的那一段**(其余留给底色,下帧补齐),**不得整块跳过**(跳过 = 网格变空 / “断线”)。 +9. ⚠️ **“结算”(整幅全质量精确帧)只能在 `m_dragMode == NONE` 且全部图层就绪时发生**:否则按住键拖拽时会在“宽基底快路径”与“整幅重绘(拖拽时关 AA)”之间每两帧振荡 → **波形质量轮流变差 / 抖动 / 周期性卡顿**(用户报告的“按住拖拽时显示效果下降、出现断线,松手才恢复”)。 +10. **GDI 绘制不受 GDI+ `SetClip` 约束**:`UiText`(`DrawTextW` / `ETO_CLIPPED`)与 `UiFillRect` 的 GDI 快路径(不透明且面积 ≥ 20000px²)都会画出裁剪区;宽矩形 / 文本必须**自己夹取**,GDI 批绘必须显式传 clip。 +11. `SceneContentHash` 必须覆盖 `Tab().LaneScroll()`(网格纵向滚动量)与 `SelectedRef()`;`FindStyleOnlyLanes()` 必须**逐项比较 `SceneCacheKey`**(`SceneGeometryHash()` 刻意不含视界,只比它不足以判定"视图没变")。 +12. 随鼠标变化的绘制(悬停高亮 / 光晕 / 图例高亮 / 光标读数 / 选区 / 提示 / 面板悬停)**必须同时**加在"缓存贴图快路径"与"整幅重绘路径"两侧。 +13. 浮动面板必须按**层序**(`m_panelOrder`,队尾 = 最上层)绘制与命中:命中从后往前取第一个命中者,点击面板 `BringPanelToFront()`,新开面板追加到队尾。**不要再按 `kEdgePanelKinds` 固定顺序命中再 break**(否则上层 ✕ / 标题栏会被下层参数输入框抢走 → “上层面板关不掉 / 拖不动”)。 +14. **不要为波形构建 / 绘制引入多线程**;已落地的多线程只有派生曲线线程池(`m_curveWorkers`)。任何"移动 / 释放采样"的操作前必须 `PauseDerivedAsync()`。 +15. MinGW 的 `swprintf` 按 C99:宽格式串里的 `%s` = **窄**字符串,宽字符串必须用 `%ls`(否则 "KB" 会被截成 "K")。 +16. 源文件名必须**全局唯一**(Makefile 按 basename 去重 + vpath 查找);新增目录要同时登记进 `CXX_INCLUDES_PATHS` / `CXX_SOURCES_PATHS`。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) @@ -2397,3 +2399,88 @@ Documents/ 项目文档(含本文件) → 容差由 32 提到 **96**(真正“面板内容过期”的差异是百级)。 - `[X]` 预览曲线像素计数的颜色容差 6 → **40**(真实信号上曲线多与波形重合,纯色像素本就少)。 - **验证**:`make release` 零警告;离屏测试套件 **380/380 通过**(exit=0)。 + +--- + +## 本轮(2026-10-12,起源设备):平移不再阻塞前台 + 弱峰增强逐信号参数 + 浮动面板层序 + +### A. 平移:前台刷新线程**永不阻塞**(用户猜测的评估与落地) + +**用户的猜测与评估**:用户推测“网格自己的前后台缓冲区没准备好时,前台会等待它完成,于是退化到 +未优化之前”—— **机制描述与当前代码不完全一致**(`f97c4cf` 的后台图层线程已在上一轮被删除, +因为后台改写共享渲染状态会覆盖用户的平移),但**问题类别与补救方向完全正确**: +平移时前台确实会因为“某个网格的图层没准备好(内容指纹过期 / 锚点离视界太远)”而**同步重画整块图层** +(旧代码里 `RollWideBuffer` 直接 `RenderLaneLayerRegion(..., 0, 0, defaultWideMin)`), +5 个网格就会在一帧里做完整批重画 → 卡顿;而“结算”帧在**拖拽期间**也会触发, +于是按住鼠标不动时在“宽基底快路径”与“整幅重绘(拖拽 → 关抗锯齿)”之间**每两帧振荡**, +表现为“波形质量轮流变差 / 断线 / 周期性卡顿,松手才恢复”。 + +**本轮改动**: +1. `RollWideBuffer()` **不再做任何整块重画**:逐网格整数像素平移 + 补细条; + 内容过期 / 锚点过远(`|shift| >= bitmapWidth-4`)的网格只 `QueueLaneRebuild()`, + 本帧仍按现有像素平移(位置照样跟随)。 +2. 新增 `RenderPendingLaneRebuilds(budget)`:**每帧最多重画 `budget` 个网格**(`Paint` 传 1)。 + 队列非空时 `PostMessageW(kMessagePlotDerivedReady)` 请宿主再送一帧,直到补齐。 +3. `BlitWideBuffer()` 改为**局部贴图**:图层只覆盖“视界 ± 半屏”,视界落在图层之外时 + **只贴有像素的那一段**(`srcX/destOffset/blitWidth` 三个量把 BlitBlt 夹到可用区间), + 其余留由底色填充 —— 即“未绘制的部分先暂时不管,下个循环就绪后再用新缓冲”。 + 旧行为是 `subX < -0.5 || subX + width > bitmapWidth + 0.5` 就 `continue` → **整个网格瞬间变空**。 +4. **“结算”(整幅全质量精确帧)的触发条件收紧为三者同时成立**: + 内容+视界与上一条快路径帧相同(`viewStopped`)**且** `m_dragMode == NONE` **且** + `LanesAllReadyForView()`。拖拽期间绝不结算 → 不再有质量振荡与周期性整幅重绘。 + 新增 `LaneLayerReady(i)` / `LanesAllReadyForView()`(结构 + 内容指纹 + 跨度全一致); + 原 `LayersReadyForView()` 改名 `LayersStructurallyReady()`(只看结构,用于“跨度/布局变了 + 必须整批重建一次”这一种情形)。 +5. 跨度 / 尺寸变化(缩放、布局变化、首帧)仍然走**同步整批** `RebuildWideBuffer()`: + 这是“一次性”代价(不是平移路径),不做预算分摊,否则会出现整屏空白的中间帧。 + +**实测(离屏 `.preflight/panperf`,`VERY_BIG_DATA` 5 网格,深放大 ≈2ms/大格,每帧平移 60px)**: +- 平移 40 帧:**中位 6.2ms / p90 7.2ms / max 7.6ms**(旧实现会周期性出现数十~上百毫秒的尖峰)。 +- **按住键且视界停在数据边界(视界不再变化)12 帧:中位 0.29ms / max 1.29ms** + —— 证明拖拽期间不再有整幅重绘(旧实现这里会每两帧穿插一次整幅重绘 + 关抗锯齿)。 + +### B. 弱峰增强:改为**逐信号参数**(对齐均值滤波) + +- 数据层:`Lane::WeakPeakItem` 从 `using WeakPeakItem = MeanFilterItem`(只有 `ref` 有效) + 改为**独立结构体** `{ ref, levelPercent = 150, iterations = 1 }`; + 删除 `Lane::weakPeakLevel` / `Lane::weakPeakIterations`(网格级)。 +- 因 `WeakPeakItem` 不再是 `MeanFilterItem`,`TabConfig::RemapAfterSignalRemoval()` 里原本 + 绑定到 `std::vector&` 的 `purgeRefs` lambda 改为**泛型 lambda + `std::decay_t`**。 +- `PlotView`:删除 `LaneWeakPeakLevel/LaneWeakPeakIterations/SetLaneWeakPeakLevel/SetLaneWeakPeakIterations`、 + `PlotDragMode::PEAK_LEVEL/PEAK_ITERATIONS`、`m_peakDragLane`、`EdgePanelLayout::slider/sliderIterations`、 + `WeakPeakSliderHit()` 与三个滑块常量。 +- 面板:弱峰增强**与均值滤波完全同构**(逐条目「名称 + 复选框」+ 第二排两个输入框 + 「增强等级 %」/「迭代次数 N」+ 底部「构建为新信号」);`EdgePanelLayout.itemHasParams` + 统一了 MEAN/CENTER/PEAK 的参数行占位。新增 `PlotEditTarget::PEAK_LEVEL_PERCENT / PEAK_ITERATIONS`, + 在 `EndEdit` 里分别夹到 0–200 / 1–10 并清 `m_weakPeakCurveCache`。 +- 指纹与快照:`SceneContentHash` 混合每条 `WeakPeakItem` 的 `levelPercent + iterations`; + `CollectSceneDerived` 的 `flatten` 增加 `valueOf` 参数(MEAN/CENTER 取 `points`,PEAK 取 `levelPercent`); + `PanelSlotKey` 混合每条目的等级/迭代(面板数字随输入实时刷新)。 +- 预览曲线 / 光标胶囊 / 「构建为新信号」全部改为**读取该信号自己的条目参数**, + 因此「预览」与「构建」逐位一致,且同一网格内不同信号可用不同强度。 + +### C. 浮动面板:层序(z-order)与命中修复 + +**问题**(用户报告):两个面板重叠时,对**上层**面板的 ✕ / 标题栏操作会落到**下层**面板 +(因为命中按固定的 `kEdgePanelKinds` 顺序遍历、先命中先 break),于是“上层 ✕ 关不掉 / 标题栏拖不动”。 + +**修法**: +- 新增层序 `m_panelOrder`(`std::vector`,**队尾 = 最上层**,`mutable`)。 +- `SyncPanelOrder()`:按现有顺序保留仍可见的面板,把**新出现**的面板**追加到队尾**(新面板永不被盖住)。 +- `TopPanelAtPoint(x, y, lane, kind)`:**从队尾往前**找第一个命中的面板。 +- `BringPanelToFront(lane, kind)`:点击面板(任意部位,含 ✕/标题/输入框)时把它移到队尾 = 置顶。 +- 绘制循环改为**按 `m_panelOrder` 自下而上**绘制(与命中顺序、像素缓存级联一致); + `PanelGlobalKey()` 混合层序 → 层序变化时整层面板缓存作废并按新层序重画。 +- `QueryCursor` 与 `OnMouseMove` 的“是否在面板上”判断也改用 `TopPanelAtPoint`(与点击一致)。 + +### D. 测试与脚本 + +- `uitest` 新增 `[AG]`:层预算(`RenderPendingLaneRebuilds(1)` 一帧只重画 1 个网格、剩余留在队列、 + 最终补齐且全部就绪)、**局部贴图**(把某层 `wideMin` 推到只覆盖视界左半 → 断言左侧被贴、 + 右侧保持哨兵色 = “未被绘制就不管”)、面板层序(上层面板 ✕ 命中上层、下层面板不受影响、 + 点击下层把它置顶、`TopPanelAtPoint` 随之改变、新开面板在最上层)。 +- `uitest` 的 `[X]` 改为逐信号参数(含两个输入框的就地编辑:按下进入编辑、输入 175 回车写入 + **该信号自己的条目**、999 被夹到 200、迭代次数写入、上下限夹取),并删除已不存在的滑块断言。 +- 新增离屏探针 `.preflight/panperf`(平移帧耗时 + 按住键静止帧耗时)。 +- **验证**:`make all` 零警告;`uitest` **388/388 通过**;`panperf` PASS; + `.preflight/layercheck` 保持“居中图层 vs 整幅重绘逐像素 0 差异 / 平移对齐 ≤1px / 0 次视界覆盖”。 diff --git a/Utilities/Inc/DataModel.hpp b/Utilities/Inc/DataModel.hpp index e95abd2..48ef253 100644 --- a/Utilities/Inc/DataModel.hpp +++ b/Utilities/Inc/DataModel.hpp @@ -374,13 +374,17 @@ namespace jdi using EdgeCenterItem = MeanFilterItem; /** - * @brief 「弱峰增强」中某个信号自己的条目。 + * @brief 「弱峰增强」中某个信号自己的参数条目。 * - * 目前只用到 `ref`(增强等级属于整个网格,统一保存在 `weakPeakLevel` 中); - * 复用 `MeanFilterItem` 结构是为了与均值滤波/中心轴共享 - * 「条目查找 + 信号插入/删除时的引用重映射」等既有实现。 + * 与「均值滤波」完全一致:**每个被勾选的信号各自持有参数** + *(增强等级 0–200% + 迭代次数 N),因此同一网格内不同信号可以使用不同强度。 */ - using WeakPeakItem = MeanFilterItem; + struct WeakPeakItem + { + SignalRef ref; /**< 目标信号。 */ + int levelPercent = 150; /**< 增强等级(0–200;100% = 到达较温和的一侧,200% = 完全拉平)。 */ + int iterations = 1; /**< 迭代次数 N(1–10)。 */ + }; std::wstring name; /**< 网格名称。 */ std::vector signals; /**< 该网格包含的信号(按加入顺序)。 */ @@ -505,35 +509,23 @@ namespace jdi * * 开启后为本网格内**被勾选**的信号额外绘制一条「弱峰增强」曲线: * 识别纹波的波峰/波谷,把“比左右同类极值更弱”的波峰上拉、波谷下拉, - * 并按「弱峰增强等级」在左右两个同类极值之间取目标值,使其恢复与周边一致的振幅。 + * 并按各信号自己的「增强等级」在左右两个同类极值之间取目标值,使其恢复与周边振幅。 * 这条曲线只是叠加显示,不会作为信号加入左侧列表。 */ bool weakPeak = false; /** - * @brief 弱峰增强等级(0–2,面板上显示为 0%–200%)。 + * @brief 参与弱峰增强预览的信号及其**各自的参数**(只包含被勾选的信号)。 * - * 0 = 目标就是当前值(完全不动);1 = 拉到较温和的一侧(波峰取较低的左/右波峰, - * 波谷取较高的左/右波谷);2 = 拉到另一侧(完全拉平)。默认 1.5,等价于旧版的 50%。 + * 参数(等级 0–200% + 迭代次数 N)按信号保存,与「均值滤波」的实现方式一致: + * 同一网格内不同信号可以使用不同的增强强度,也可以分别「构建为新信号」。 */ - double weakPeakLevel = 1.5; - - /** - * @brief 弱峰增强的迭代次数 N(1–10)。 - * - * 与「均值滤波」的迭代次数 N 一样表示“把同一处理重复应用 N 次”(每一阶以上一阶为输入): - * 一阶时改定量就已严格等于「等级」对应的目标值;多阶会把相邻弱峰互相抬高 - * 彼此的基准,从而把较宽的弱峰区逐步恢复。 - */ - int weakPeakIterations = 1; - - /** 参与弱峰增强预览的信号条目(只包含被勾选的信号)。 */ std::vector weakPeakItems; - /** 取某个信号的弱峰增强条目(未勾选时返回空指针)。 */ + /** 取某个信号的弱峰增强参数(未勾选时返回空指针)。 */ const WeakPeakItem* FindWeakPeakItem(const SignalRef& ref) const; - /** 取得或创建某信号的弱峰增强条目(用于勾选)。 */ + /** 取得或创建某信号的弱峰增强参数(用于勾选)。 */ WeakPeakItem& EnsureWeakPeakItem(const SignalRef& ref); /** 取消某信号的弱峰增强勾选。 */ diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 89ef919..6a25278 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -136,8 +136,6 @@ namespace jdi double meanFilterPanelY = -1.0; bool weakPeak = false; std::vector weakPeakItems; - double weakPeakLevel = 1.5; - int weakPeakIterations = 1; bool weakPeakPanelOpen = true; double weakPeakPanelX = -1.0; double weakPeakPanelY = -1.0; @@ -168,8 +166,6 @@ namespace jdi carry.meanFilterPanelY = lane.meanFilterPanelY; carry.weakPeak = lane.weakPeak; carry.weakPeakItems = lane.weakPeakItems; - carry.weakPeakLevel = lane.weakPeakLevel; - carry.weakPeakIterations = lane.weakPeakIterations; carry.weakPeakPanelOpen = lane.weakPeakPanelOpen; carry.weakPeakPanelX = lane.weakPeakPanelX; carry.weakPeakPanelY = lane.weakPeakPanelY; @@ -211,8 +207,6 @@ namespace jdi lane.meanFilterPanelY = carry.meanFilterPanelY; lane.weakPeak = carry.weakPeak; lane.weakPeakItems = carry.weakPeakItems; - lane.weakPeakLevel = carry.weakPeakLevel; - lane.weakPeakIterations = carry.weakPeakIterations; lane.weakPeakPanelOpen = carry.weakPeakPanelOpen; lane.weakPeakPanelX = carry.weakPeakPanelX; lane.weakPeakPanelY = carry.weakPeakPanelY; @@ -1618,17 +1612,20 @@ namespace jdi } } - // 均值滤波 / 中心轴边沿计数的条目同样持有信号引用,必须一起删除/前移。 - const auto purgeRefs = [fileIndex, signalIndex](std::vector& items) + // 均值滤波 / 中心轴边沿计数 / 弱峰增强的条目同样持有信号引用,必须一起删除/前移。 + // 三类条目都是「带 `ref` 的结构体」,因此用泛型 lambda 统一处理 + //(弱峰增强的参数与均值滤波不同,不能再共用同一个具体类型)。 + const auto purgeRefs = [fileIndex, signalIndex](auto& items) { + using ItemType = typename std::decay_t::value_type; items.erase(std::remove_if(items.begin(), items.end(), - [fileIndex, signalIndex](const Lane::MeanFilterItem& item) + [fileIndex, signalIndex](const ItemType& item) { return item.ref.fileIndex == fileIndex && item.ref.signalIndex == signalIndex; }), items.end()); - for (Lane::MeanFilterItem& item : items) + for (ItemType& item : items) { if (item.ref.fileIndex == fileIndex && item.ref.signalIndex > signalIndex) { @@ -1931,8 +1928,6 @@ namespace jdi lane.edgeCenterPanelX = source.edgeCenterPanelX; lane.edgeCenterPanelY = source.edgeCenterPanelY; lane.weakPeak = source.weakPeak; - lane.weakPeakLevel = source.weakPeakLevel; - lane.weakPeakIterations = source.weakPeakIterations; lane.weakPeakPanelOpen = source.weakPeakPanelOpen; lane.weakPeakPanelX = source.weakPeakPanelX; lane.weakPeakPanelY = source.weakPeakPanelY;