diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index fff7177..b245f78 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -2241,9 +2241,12 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] curve 曲线数值数组(缺失处为 NaN)。 * @param [IN] curveBegin `curve[0]` 对应的采样下标。 + * @param [IN] highlight 是否高亮(鼠标悬停在对应的面板条目上时): + * 先画一圈半透明的"光晕"再把曲线本身画得更粗更亮,让用户一眼看出这条预览曲线对应哪个条目。 */ void PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - const std::vector& curve, std::size_t curveBegin) const; + const std::vector& curve, std::size_t curveBegin, + bool highlight = false) const; /** * @brief 取得某个采样区间内的派生曲线(带缓存)。 @@ -2465,9 +2468,10 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] points 均值点数 X(滑平半径)。 * @param [IN] iterations 迭代次数 N(至少 1)。 + * @param [IN] highlight 是否高亮(鼠标悬停在该网格均值滤波面板的对应条目上)。 */ void PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int points, int iterations) const; + int points, int iterations, bool highlight = false) const; /** * @brief 绘制某条信号的弱峰调整曲线(与均值滤波曲线同一配色派生规则)。 @@ -2476,9 +2480,10 @@ namespace jdi * @param [IN] lane 网格绘制信息。 * @param [IN] amountPercent 弱峰调整幅度(-400–400)。 * @param [IN] iterations 迭代次数 N(1–10)。 + * @param [IN] highlight 是否高亮(鼠标悬停在该网格弱峰调整面板的对应条目上)。 */ void PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, - int amountPercent, int iterations) const; + int amountPercent, int iterations, bool highlight = false) const; /** * @brief 数据点标记的位置与"越界方向"(用于决定画圆点还是贴边三角)。 @@ -2862,6 +2867,18 @@ namespace jdi /** 「弱峰调整」每行滑块右侧的复位控件是否处于悬停态(用于“进入/离开”时只提示一次)。 */ bool m_peakResetHover = false; + /** + * @brief 上一帧鼠标悬停在「均值滤波 / 弱峰调整」面板的哪个**条目栏**上。 + * + * 用途(用户 2026-10-30 要求):悬停某条信号的条目栏时,把该信号的**预览曲线** + * 高亮出来(画一圈光晕 + 加粗),这样"列表里这一行 ↔ 网格里那条曲线"一眼对应。 + * + * 与 `m_freeMarkerRowHover*` 同一套路:只在悬停状态**变化**时返回 `changed`, + * 避免每帧都请求重绘。 + */ + int m_meanRowHoverLane = -1; + SignalRef m_meanRowHoverRef; + // ---------------------------------------------------------------- 自由标记 /** 最近一次解析出的「自由标记」跟随数据点(绘制与命中共用)。 */ diff --git a/Components/PlotView/Inc/PlotViewInternal.hpp b/Components/PlotView/Inc/PlotViewInternal.hpp index 5f5fc30..b74c5ad 100644 --- a/Components/PlotView/Inc/PlotViewInternal.hpp +++ b/Components/PlotView/Inc/PlotViewInternal.hpp @@ -384,6 +384,15 @@ namespace jdi */ inline const float kEdgePanelDisclosureWidth = 20.0f; + /** + * @brief 面板内**条目行 / 分组栏**左侧展开三角的边长(逻辑像素)。 + * + * ⚠️ 与标题栏的 `kEdgePanelDisclosureWidth`(20)**刻意不同**:行高只有 22 逻辑像素, + * 20 的三角会把整行顶满、显得又大又笨(用户 2026-10-30 报告)。 + * 「均值滤波」的条目栏与「自由标记」的分组栏共用本值(12),观感完全一致。 + */ + inline const float kEdgePanelRowTriangleSize = 12.0f; + // ⚠️ 这里曾有 `kFreeMarkerHoverTolerance = 8`(旧的非光标模式“纵向贴近”容差)。 // 用户 2026-10-22 要求把自由标记的触发范围与「常规状态下触发信号曲线高亮」**统一**, // 因此现在直接复用 `kSignalHitTolerance`(6 逻辑像素)并改判“到折线的垂直距离” @@ -559,8 +568,14 @@ namespace jdi */ inline const wchar_t* const kMeanFilterPointsSliderLabel = L"均值点数"; inline const wchar_t* const kMeanFilterIterationsSliderLabel = L"迭代次数"; - /** 「均值点数」下限(窗口 = 2X+1 ⇒ X=0 就是原信号)。 */ - inline const int kMeanFilterPointsMin = 0; + /** + * @brief 「均值点数」下限。 + * + * ⚠️ **必须是 1**(用户 2026-10-30 要求):窗口 = `2X+1`,`X = 0` 等于"不做任何平滑", + * 滑块拖到最左端时预览曲线与原始波形完全重合 —— 那不像"滤波",用户会以为功能坏了。 + * 下限取 1 ⇒ 最左端是 3 点均值,始终有可见的平滑效果。 + */ + inline const int kMeanFilterPointsMin = 1; /** 「均值点数」滑块的默认上限(用户可点击数值改成其它正整数)。 */ inline const int kMeanFilterPointsDefaultMax = 50; /** 「均值点数」上限允许的最大值(防止输入一个天文数字把滤波窗口撑爆)。 */ diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index f56d65f..c596375 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -644,6 +644,9 @@ namespace jdi m_hoverLegend = SignalRef(); m_dragSignal = SignalRef(); m_dragTargetLane = -1; + // 面板条目栏的悬停同样按引用保存(均值滤波 / 弱峰调整的预览曲线高亮)。 + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); InvalidateSceneCache(); } diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index c3368d9..5f96d4a 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -699,8 +699,11 @@ namespace jdi } const int maximum = isPoints ? 1000000 : 1000; int value = static_cast(std::lround(parsed)); - // 迭代次数至少为 1(1 次迭代等于不做滤波);均值点数可以为 0。 - value = isPoints ? std::max(0, std::min(value, maximum)) + // 迭代次数至少为 1(1 次迭代等于不做滤波)。 + // 「均值滤波」的**均值点数下限为 1**(用户 2026-10-30 明确要求:不能为 0); + // 「中心轴边沿计数」沿用旧语义(0 = 不做平滑),因此两者下限分开取。 + const int pointsMin = centerTarget ? 0 : kMeanFilterPointsMin; + value = isPoints ? std::max(pointsMin, std::min(value, maximum)) : std::max(1, std::min(value, maximum)); Lane::MeanFilterItem& item = centerTarget ? lane.EnsureEdgeCenterItem(m_editMeanRef) @@ -1824,6 +1827,49 @@ namespace jdi hoverPanelKind); } + // 「均值滤波 / 弱峰调整」面板**条目栏**的悬停 → 高亮该信号的预览曲线 + //(用户 2026-10-30 要求:"鼠标处于条目栏上方时,对应的预览曲线需要高亮提示")。 + { + int hoverLane = -1; + SignalRef hoverRef; + if (overEdgePanel && m_tab != nullptr && panelLane >= 0 && + (static_cast(hoverPanelKind) == EdgePanelKind::MEAN || + static_cast(hoverPanelKind) == EdgePanelKind::PEAK)) + { + const EdgePanelLayout layout = + EdgePanelLayoutFor(panelLane, static_cast(hoverPanelKind)); + if (UiPointInRect(layout.content, static_cast(x), static_cast(y))) + { + for (std::size_t rowIndex = 0; rowIndex < layout.rowRefs.size(); ++rowIndex) + { + if (rowIndex >= layout.rowHeaders.size()) + { + break; + } + const gd::RectF& headerRect = layout.rowHeaders[rowIndex]; + // 半截滚出的行**也算命中**:绘制时行本来就会被裁剪成半截显示, + // 若这里要求“完整落在内容区内”,就会出现“看得见却悬停不到”的不一致。 + // 命中点本身已被限定在内容区里,因此不会出现“看得见一半却点到”的幽灵热区。 + if (UiPointInRect(headerRect, static_cast(x), static_cast(y))) + { + hoverLane = panelLane; + hoverRef = layout.rowRefs[rowIndex]; + break; + } + } + } + } + if (hoverLane != m_meanRowHoverLane || !(hoverRef == m_meanRowHoverRef)) + { + m_meanRowHoverLane = hoverLane; + m_meanRowHoverRef = hoverRef; + // 预览曲线在部分绘制路径里属于"静态场景"(整幅重绘会缓存它), + // 因此悬停状态一变就作废场景缓存 —— 只在"进出某一行"时发生,不是每帧。 + InvalidateSceneCache(); + changed = true; + } + } + // 「弱峰调整」每行滑块右侧的**复位控件**:悬停时给出一次性提示(进入时设置、离开时清除)。 // 与「系数/偏移」二级面板标题栏的还原图标同一交互语义(悬停高亮 + 手型 + 提示)。 bool overPeakReset = false; @@ -2216,18 +2262,8 @@ namespace jdi { break; } - // 只允许命中**完全落在内容区里**的行(部分滚出的行其矩形会与内容区 - // 边界重叠,放行会造出"看得见一半却能点"的幽灵热区)。 - if (rowIndex < layout.rowHeaders.size()) - { - const gd::RectF& headerRect = layout.rowHeaders[rowIndex]; - if (headerRect.Y < layout.content.Y - 0.5f || - headerRect.Y + headerRect.Height > - layout.content.Y + layout.content.Height + 0.5f) - { - continue; - } - } + // 行可以“半截”显示(绘制时会按内容区裁剪),因此这里不再要求整行落在内容区; + // 命中点已由上面的 `insideContent` 限定在内容区内,不会抢走底部按钮的点击。 const bool hasParams = rowIndex < layout.rowLevelTracks.size() && layout.rowLevelTracks[rowIndex].Width > 0.0f && @@ -2334,13 +2370,38 @@ namespace jdi return true; } } - // 组头左侧三角:切换参数行的收起 / 展开(只有勾选后才有三角)。 - if (rowIndex < layout.rowTriangles.size() && - layout.rowTriangles[rowIndex].Width > 0.0f && - UiPointInRect(layout.rowTriangles[rowIndex], fx, fy)) + // 条目栏(信号色叠底那一整行):**整行都能点**(用户 2026-10-30 要求, + // 与「自由标记」分组栏一致:不再只能点小小的展开三角)。 + // 语义: + // * 点在右端复选框上 → 切换"是否启用该信号的预览"(与复选框同一语义, + // 因为整行已被本函数消费,复选框自己收不到点击了); + // * 已在预览中 → 切换参数行的收起 / 展开; + // * 尚未启用 → 直接启用(用户点这一行显然是想看它的预览)。 + if (rowIndex < layout.rowHeaders.size() && + layout.rowHeaders[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowHeaders[rowIndex], fx, fy)) { - Lane::MeanFilterItem& item = meanLane.EnsureMeanFilterItem(ref); - item.collapsed = !item.collapsed; + const bool onCheckBox = + rowIndex < layout.rowChecks.size() && + layout.rowChecks[rowIndex].Width > 0.0f && + UiPointInRect(layout.rowChecks[rowIndex], fx, fy); + const Lane::MeanFilterItem* existing = meanLane.FindMeanFilterItem(ref); + if (onCheckBox) + { + ToggleLaneMeanFilterSignal(index, ref); + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); + } + else if (existing == nullptr) + { + // 未启用:点击整行即开始预览(用户点这一行显然是想看它的预览)。 + meanLane.EnsureMeanFilterItem(ref); + } + else + { + // 已启用:整行切换参数行的收起 / 展开。 + meanLane.EnsureMeanFilterItem(ref).collapsed = !existing->collapsed; + } m_dragMode = PlotDragMode::NONE; return true; } @@ -2564,8 +2625,9 @@ namespace jdi MeanFilterBuild build; build.groupIndex = ref.groupIndex; build.signalIndex = ref.signalIndex; - build.points = item->points; - build.iterations = item->iterations; + // 与预览一致:点数下限 1、迭代次数下限 1(用户 2026-10-30 要求)。 + build.points = std::max(kMeanFilterPointsMin, item->points); + build.iterations = std::max(kMeanFilterIterationsMin, item->iterations); m_pendingMeanFilterItems.push_back(build); } if (!m_pendingMeanFilterItems.empty()) @@ -3398,6 +3460,13 @@ namespace jdi m_freeMarkerRowHoverItem = static_cast(-1); changed = true; } + // 「均值滤波 / 弱峰调整」条目栏的悬停高亮同样随鼠标离开清除(否则预览曲线一直亮着)。 + if (m_meanRowHoverLane != -1 || m_meanRowHoverRef.IsValid()) + { + m_meanRowHoverLane = -1; + m_meanRowHoverRef = SignalRef(); + changed = true; + } if (m_cursorHover) { m_cursorHover = false; diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index 9998db2..b071b65 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -2063,6 +2063,9 @@ namespace jdi } const int saveState = g.Save(); g.SetClip(lane.rect, gd::CombineModeIntersect); + // 面板条目栏的悬停高亮(用户 2026-10-30 要求):鼠标停在哪一行,就把那条**预览曲线** + // 画一圈光晕 + 加粗。`m_meanRowHoverLane/Ref` 由 `OnMouseMove` 维护。 + const bool hoverActive = (m_meanRowHoverLane == laneIndex) && m_meanRowHoverRef.IsValid(); for (const SignalRef& ref : tabLane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); @@ -2070,6 +2073,7 @@ namespace jdi { continue; } + const bool hovered = hoverActive && (ref == m_meanRowHoverRef); if (centerOn) { const Lane::EdgeCenterItem* centerItem = tabLane.FindEdgeCenterItem(ref); @@ -2083,7 +2087,7 @@ namespace jdi const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); if (item != nullptr) { - PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations); + PaintMeanFilterCurve(g, resolved, lane, item->points, item->iterations, hovered); } } if (weakPeakOn) @@ -2097,7 +2101,7 @@ namespace jdi const int boostIterations = std::max( kWeakPeakIterationsMin, std::min(kWeakPeakIterationsMaxLimit, item->iterations)); - PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations); + PaintWeakPeakCurve(g, resolved, lane, amountPercent, boostIterations, hovered); } } } @@ -3158,7 +3162,7 @@ namespace jdi void PlotView::PaintDerivedCurve(gd::Graphics& g, const ResolvedSignal& resolved, const LaneVisual& lane, const std::vector& curve, - std::size_t curveBegin) const + std::size_t curveBegin, bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -3273,6 +3277,27 @@ namespace jdi pen.SetLineJoin(gd::LineJoinRound); pen.SetStartCap(gd::LineCapRound); pen.SetEndCap(gd::LineCapRound); + // 悬停高亮(用户 2026-10-30 要求):先沿同一批顶点画一遍**加粗的半透明光晕**, + // 再把曲线本身画粗一点。光晕垫在曲线下面 ⇒ 不遮蔽曲线本身、也不影响它的颜色。 + if (highlight) + { + gd::Pen halo(ToGdiColor(WithAlpha(derived, 0x55u)), + std::max(1.0f, resolved.style->lineWidth) * m_scale + 5.0f * m_scale); + halo.SetLineJoin(gd::LineJoinRound); + halo.SetStartCap(gd::LineCapRound); + halo.SetEndCap(gd::LineCapRound); + for (std::size_t k = 0; k < breaks.size(); ++k) + { + const std::size_t start = breaks[k]; + const std::size_t stop = (k + 1 < breaks.size()) ? breaks[k + 1] : points.size(); + if (stop <= start + 1) + { + continue; + } + g.DrawLines(&halo, points.data() + start, static_cast(stop - start)); + } + pen.SetWidth(std::max(1.0f, resolved.style->lineWidth) * m_scale + 1.6f * m_scale); + } for (std::size_t k = 0; k < breaks.size(); ++k) { const std::size_t start = breaks[k]; @@ -3287,7 +3312,8 @@ namespace jdi } void PlotView::PaintMeanFilterCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, int points, int iterations) const + const LaneVisual& lane, int points, int iterations, + bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -3305,6 +3331,10 @@ namespace jdi // **逐位一致**(用户明确要求"预览显示效果要和构建为新信号一致")。 // 整段窗口超过异步门槛时由后台线程计算,绘制线程本帧用已算好的旧曲线顶替, // 因此不会阻塞交互;小信号同步计算。 + // ⚠️ 「均值点数」的下限是 `kMeanFilterPointsMin`(=1,用户 2026-10-30 明确要求不能为 0): + // 即使条目里的值被外部写成了 0/负数,预览也不能按“不平滑”画出来。 + points = std::max(kMeanFilterPointsMin, points); + iterations = std::max(kMeanFilterIterationsMin, iterations); std::size_t spanBegin = 0; const std::vector* curve = CurveSpanFor(DerivedCurveKind::MEAN_FILTER, *resolved.data, points, iterations, 0, 0, count, spanBegin, true); @@ -3312,11 +3342,12 @@ namespace jdi { return; } - PaintDerivedCurve(g, resolved, lane, *curve, spanBegin); + PaintDerivedCurve(g, resolved, lane, *curve, spanBegin, highlight); } void PlotView::PaintWeakPeakCurve(gd::Graphics& g, const ResolvedSignal& resolved, - const LaneVisual& lane, int amountPercent, int iterations) const + const LaneVisual& lane, int amountPercent, int iterations, + bool highlight) const { if (resolved.data == nullptr || resolved.axis == nullptr || resolved.style == nullptr) { @@ -4368,8 +4399,12 @@ namespace jdi const bool meanFilter = (kind == EdgePanelKind::MEAN); const bool weakPeak = (kind == EdgePanelKind::PEAK); const bool freeMarker = (kind == EdgePanelKind::MARK); - // 「均值滤波」与「弱峰调整」共用「逐条目 + 底部按钮」排布。 - const bool itemized = (centerAxis || meanFilter || weakPeak); + // ⚠️ `itemized` 只表示**旧的逐条目排布**(中心轴 / 弱峰调整)。 + // 「均值滤波」自 2026-10-30 起有自己的**可滚动分支**(`if (meanFilter)` 内), + // 若它仍被算作 `itemized`,旧分支会**再画一遍**每行的名称 + 复选框 —— + // 且旧分支**没有任何裁切**,滚动时这些元素就会画到标题栏上 + //(用户 2026-10-30 报告“元素超出浮动面板”的根因)。 + const bool itemized = (centerAxis || weakPeak); const bool hasButton = (meanFilter || weakPeak); { if (index >= count) @@ -4878,10 +4913,17 @@ namespace jdi const Lane::MeanFilterItem* item = tabLane.FindMeanFilterItem(ref); const bool checked = (item != nullptr); - // 叠底:先铺一层不透明同色再叠 0xE8(与「自由标记」分组栏同一做法), - // 这样滚动时下层内容不会透出来。 + // 内容区已由 `g.SetClip(layout.content)` 裁切(行可以“半截”显示,滚动更顺滑); + // 已经完整滚出可视范围的行在上面就 `continue` 了,这里不再额外门控。 UiFillRoundRect(g, header, 3.0f * m_scale, signalColor); UiFillRoundRect(g, header, 3.0f * m_scale, WithAlpha(signalColor, 0xE8)); + // 条目栏悬停:在信号色叠底上再压一层强调色,给出"这一行对应哪条曲线"的提示 + //(曲线本身的高亮见 `PaintLaneDerivedOverlays`)。 + if (m_meanRowHoverLane == static_cast(index) && m_meanRowHoverRef == ref) + { + UiFillRoundRect(g, header, 3.0f * m_scale, + WithAlpha(m_theme->accent, 0x2E)); + } // 左侧三角(仅勾选时存在):朝下 = 展开、朝右 = 收起。 const gd::RectF& triangle = layout.rowTriangles[rowIndex]; @@ -4917,26 +4959,25 @@ namespace jdi const wchar_t* label, int current, int minimum, int maximum, bool active) { - if (track.Width <= 0.0f) - { - return; - } const float labelLeft = track.X - 6.0f * m_scale - UiMeasureTextWidth(m_fonts->uiSmall, label); - UiText(g, label, m_fonts->uiSmall, - gd::RectF(labelLeft, track.Y - 8.0f * m_scale, - UiMeasureTextWidth(m_fonts->uiSmall, label), - track.Height + 16.0f * m_scale), - m_theme->textMuted, TextAlign::LEFT); + // ⚠️ **必须用 `UiTextClipped`(而不是 `UiText`)**:GDI 文本不受 + // GDI+ `SetClip` 约束,滚动时标签/数值会画到内容区之外 + //(用户 2026-10-30 报告"元素超出了浮动面板"的直接原因)。 + UiTextClipped(g, label, m_fonts->uiSmall, + gd::RectF(labelLeft, track.Y - 8.0f * m_scale, + UiMeasureTextWidth(m_fonts->uiSmall, label), + track.Height + 16.0f * m_scale), + m_theme->textMuted, TextAlign::LEFT, layout.content); const double ratio = (maximum > minimum) ? (static_cast(current - minimum) / static_cast(maximum - minimum)) : 0.0; UiDrawSlider(g, track, ratio, m_theme->accent, active, m_scale); - UiText(g, std::to_wstring(current), m_fonts->monoSmall, - gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), - m_theme->textStrong, TextAlign::LEFT); + UiTextClipped(g, std::to_wstring(current), m_fonts->monoSmall, + gd::RectF(valueRect.X, valueRect.Y, valueColumn, valueRect.Height), + m_theme->textStrong, TextAlign::LEFT, layout.content); // 复位按钮:悬停高亮 + 与「系数/偏移」菜单同款图标。 const bool hot = (m_dragMode != PlotDragMode::EDGE_PANEL_MOVE) && @@ -4998,6 +5039,18 @@ namespace jdi } g.Restore(meanSave); + + // ---- 内容滚动条(只在内容超高时出现;底部按钮不参与滚动)---- + // ⚠️ 必须在 `g.Restore(meanSave)` **之后**画:滚动条位于内容区右侧、在 + // 内容裁剪区之外,若在裁剪内画会被整条裁掉(用户 2026-10-30 报告 + // "全部启用后没有出现滚动条"的根因)。 + if (layout.scrollTrack.Width > 0.0f) + { + UiFillRoundRect(g, layout.scrollTrack, layout.scrollTrack.Width * 0.5f, + WithAlpha(m_theme->border, 0x88)); + UiFillRoundRect(g, layout.scrollThumb, layout.scrollThumb.Width * 0.5f, + m_theme->borderStrong); + } } // 「自由标记」面板:按信号分组的**分组栏**(信号色托底 + 三角 + 名称 + // 「N 标记点」+ ✕),下属固定点条目六列对齐(序号 / 取值 / 枚举 / 增量 / 时刻 / 间隔), @@ -5346,9 +5399,10 @@ namespace jdi { continue; } - if (itemized || freeMarker) + if (itemized || freeMarker || meanFilter) { - // 逐条目绘制已在上面完成(自由标记同样是逐条目排布)。 + // 逐条目绘制已在上面完成(自由标记同样是逐条目排布, + // 均值滤波走自己的可滚动分支)。 continue; } const std::uint32_t signalColor = StyleColor(*resolved.style); diff --git a/Components/PlotView/Src/PlotViewPanels.cpp b/Components/PlotView/Src/PlotViewPanels.cpp index 8ceb15d..6a3fe9a 100644 --- a/Components/PlotView/Src/PlotViewPanels.cpp +++ b/Components/PlotView/Src/PlotViewPanels.cpp @@ -630,9 +630,9 @@ namespace jdi layout.rowNameRects.push_back(gd::RectF(contentLeft, rowY, innerWidth, groupRow)); layout.rowTriangles.push_back( checked ? gd::RectF(contentLeft + 3.0f * m_scale, - rowY + (groupRow - kEdgePanelDisclosureWidth * m_scale) * 0.5f, - kEdgePanelDisclosureWidth * m_scale, - kEdgePanelDisclosureWidth * m_scale) + rowY + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale) : gd::RectF()); layout.rowChecks.push_back( gd::RectF(contentLeft + innerWidth - closeSize - 4.0f * m_scale, @@ -987,8 +987,9 @@ namespace jdi group.pinned = (barTop > rowY + 0.5f); group.header = gd::RectF(layout.content.X, barTop, innerWidth, groupRow); group.triangle = gd::RectF(layout.content.X + 3.0f * m_scale, - barTop + (groupRow - 12.0f * m_scale) * 0.5f, - 12.0f * m_scale, 12.0f * m_scale); + barTop + (groupRow - kEdgePanelRowTriangleSize * m_scale) * 0.5f, + kEdgePanelRowTriangleSize * m_scale, + kEdgePanelRowTriangleSize * m_scale); group.close = gd::RectF(layout.content.X + innerWidth - closeSize - 2.0f * m_scale, barTop + (groupRow - closeSize) * 0.5f, closeSize, closeSize); float pinnedBottom = barTop + groupRow; diff --git a/Components/Shared/Inc/Theme.hpp b/Components/Shared/Inc/Theme.hpp index 88ff681..f364e8c 100644 --- a/Components/Shared/Inc/Theme.hpp +++ b/Components/Shared/Inc/Theme.hpp @@ -120,7 +120,23 @@ namespace jdi std::uint32_t MixToLuminance(std::uint32_t color, double targetLuminance); /** - * @brief 界面主题色板(Solarised Light 派生)。 + * @brief 界面主题色板。 + * + * ## 结构色的对比度约定(重要,改配色前必读) + * + * `gridWeak` / `gridStrong` / `axisLine` / `borderStrong` 这些“结构色”是**主题配色的一部分** + * (每个主题各自给值),它们画在**网格叠底**(`laneBg`)之上,而不是绘图区底色 `plotBg`。 + * 因此它们的最小对比度一律针对 `laneBg` 校验: + * + * | 字段 | 最小对比度(相对 `laneBg`) | + * | --- | ---: | + * | `gridWeak` | 1.30 | + * | `gridStrong` | 1.70 | + * | `axisLine` | 2.10 | + * | `borderStrong` | 1.70 | + * + * 各主题给出的值本就满足上述下限,因此最终化时**逐位返回原色**、不做任何改色; + * 这些阈值只是“不允许配置出看不见的网格线”的兜底。 */ struct Theme { @@ -143,9 +159,19 @@ namespace jdi std::uint32_t pressed = 0; /**< 按下底色。 */ std::uint32_t selection = 0; /**< 选中底色。 */ - std::uint32_t gridWeak = 0; /**< 次级网格线颜色。 */ - std::uint32_t gridStrong = 0; /**< 主要网格线颜色。 */ + std::uint32_t gridWeak = 0; /**< 次级网格线颜色(主要刻度 + 五等分线)。 */ + std::uint32_t gridStrong = 0; /**< 主要网格线颜色(零线 / 加重线)。 */ std::uint32_t axisLine = 0; /**< 坐标轴线颜色。 */ + /** + * @brief 每条网格内部的**叠底**颜色(绘图区底色之外的“卡片层”)。 + * + * ⚠️ 网格线是画在**它**上面(不是 `plotBg` 上),因此结构色的对比度必须针对 + * 本字段校验(见 `Theme.hpp` 顶部的 `EnsureContrast` 约定);落在深色主题上时 + * 两者相差很大,只盯着 `plotBg` 算会让网格线“几乎看不见”(用户 2026-10-30 报告)。 + * + * 0 表示“未设置”:最终化时回退到 `cardBg` 并保证与 `plotBg` 有最小对比度。 + */ + std::uint32_t laneBg = 0; std::uint32_t cursor = 0; /**< 时间光标颜色(单/双光标共用同一样式)。 */ std::uint32_t readoutBg = 0; /**< 数值读数底色。 */ std::uint32_t danger = 0; /**< 警示色。 */ diff --git a/Components/Shared/Src/Theme.cpp b/Components/Shared/Src/Theme.cpp index 100463d..2c4189b 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -36,31 +36,40 @@ namespace jdi }; /** - * @brief 把绘图区内部的"结构性"颜色按**底色**自动校正到足够对比(见 `EnsureContrast`)。 + * @brief 主题**最终化**:确定网格叠底色,并保证结构色在**它**之上可辨。 * - * 阈值(对比度)取"Solarised Light 现有配色刚好达标"的水位: - * 该主题的历史配色是人工调过的,达标时 `EnsureContrast` 逐位返回原色 ⇒ 默认主题 - * **一个像素都不会变**;其它主题(尤其深色)则自动把网格线/时间轴/分界线提亮。 + * ⚠️⚠️ **参照底色必须是 `laneBg`(网格叠底),不是 `plotBg`**: + * 网格线画在每条网格内部的叠底上。深色主题里 `plotBg`(#1E1E1E)与 `laneBg` + * (#2D2D30)亮度差得远,只按 `plotBg` 校正会让网格线落进叠底色的亮度范围 + * ⇒ 实测对比度只有 **1.07**(几乎看不见,用户 2026-10-30 报告)。 * - * | 字段 | 阈值 | Solarised Light 现状 | + * 阈值取"Solarised Light 现有配色刚好达标"的水位(针对 `laneBg` 实测: + * 1.335 / 1.771 / 2.395 / 2.395),因此默认主题**逐位不变**;其它主题若不足才被校正。 + * + * | 字段 | 阈值(相对 `laneBg`) | Solarised Light 现状 | * | --- | ---: | ---: | - * | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 约 1.38 | - * | `gridStrong`(零线/加重线) | 1.65 | 约 1.80 | - * | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 约 2.43 | - * | `borderStrong`(网格间分界线) | 1.70 | 约 2.43 | + * | `gridWeak`(网格主刻线 + 五等分线基色) | 1.30 | 1.335 | + * | `gridStrong`(零线/加重线) | 1.70 | 1.771 | + * | `axisLine`(时间轴、网格分界线、绘图区外框) | 2.10 | 2.395 | + * | `borderStrong`(网格间分界线) | 1.70 | 2.395 | */ - Theme NormalizePlotContrast(Theme theme) + Theme FinalizeTheme(Theme theme) { const std::uint32_t plot = theme.plotBg | 0xFF000000u; - theme.gridWeak = EnsureContrast(plot, theme.gridWeak | 0xFF000000u, 1.30); - theme.gridStrong = EnsureContrast(plot, theme.gridStrong | 0xFF000000u, 1.65); - theme.axisLine = EnsureContrast(plot, theme.axisLine | 0xFF000000u, 2.10); - theme.borderStrong = EnsureContrast(plot, theme.borderStrong | 0xFF000000u, 1.70); + // 网格叠底:未显式配置时回退到 `cardBg`,并保证与绘图区底色可辨(1.03)。 + const std::uint32_t laneSource = + (theme.laneBg != 0u) ? (theme.laneBg | 0xFF000000u) : (theme.cardBg | 0xFF000000u); + theme.laneBg = EnsureContrast(plot, laneSource, 1.03); + + theme.gridWeak = EnsureContrast(theme.laneBg, theme.gridWeak | 0xFF000000u, 1.30); + theme.gridStrong = EnsureContrast(theme.laneBg, theme.gridStrong | 0xFF000000u, 1.70); + theme.axisLine = EnsureContrast(theme.laneBg, theme.axisLine | 0xFF000000u, 2.10); + theme.borderStrong = EnsureContrast(theme.laneBg, theme.borderStrong | 0xFF000000u, 1.70); return theme; } /** @brief 全局唯一主题实例(Solarised Light;历史默认主题)。 */ - const Theme kSolarizedLight = NormalizePlotContrast([] + const Theme kSolarizedLight = FinalizeTheme([] { Theme theme; theme.windowBg = MakeArgb(kBase3); @@ -82,8 +91,10 @@ namespace jdi theme.pressed = MakeArgb(0xD8CFB6u); theme.selection = MakeArgb(0xDCEAF6u); - // 网格线需要在绘图区底色(kBase3)上清晰可辨,同时又要与网格边界 - // (axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。 + // 网格线需要在**网格叠底**(kBase3 推暗 1.03 ≈ #F9F2E0)上清晰可辨,同时又要与 + // 网格边界(axisLine/borderStrong 的蓝灰)明显区分——因此采用偏暖的加深米色。 + // 这三个值相对叠底色的对比度分别是 1.335 / 1.771 / 2.395,已达标 ⇒ 最终化不改动。 + theme.laneBg = MakeArgb(0xF9F2E0u); theme.gridWeak = MakeArgb(0xDDD3B8u); theme.gridStrong = MakeArgb(0xC3B896u); theme.axisLine = MakeArgb(kBase1); @@ -100,7 +111,7 @@ namespace jdi * 与 Solarised Light 的区别:底色是**中性白/浅灰**而不是暖米色, * 强调色是 VS Code 的系统蓝 `#0078D4`,描边是中性灰。 */ - const Theme kLight = NormalizePlotContrast([] + const Theme kLight = FinalizeTheme([] { Theme theme; theme.windowBg = MakeArgb(0xFFFFFFu); // editor.background @@ -109,7 +120,7 @@ namespace jdi theme.cardBg = MakeArgb(0xFFFFFFu); theme.plotBg = MakeArgb(0xFFFFFFu); theme.border = MakeArgb(0xE5E5E5u); // panel.border - theme.borderStrong = MakeArgb(0xCECECEu); + theme.borderStrong = MakeArgb(0xC3C3C3u); theme.text = MakeArgb(0x3B3B3Bu); // foreground theme.textStrong = MakeArgb(0x1F1F1Fu); @@ -122,11 +133,12 @@ namespace jdi theme.pressed = MakeArgb(0xE8E8E8u); theme.selection = MakeArgb(0xE8E8E8u); - // 白底上网格线必须够淡(否则抢波形),但主刻线要能与次级线区分。 - // ⚠️ 这三个颜色会被 `NormalizePlotContrast` 按白底自动校正到可见水位。 - theme.gridWeak = MakeArgb(0xEDEDEDu); - theme.gridStrong = MakeArgb(0xD6D6D6u); - theme.axisLine = MakeArgb(0xCECECEu); + // 网格叠底比绘图区白底略暗一档,便于分辨相邻网格; + // 结构色按**叠底色**选中(对比度:1.30 / 1.70 / 2.10),不要再用原白底的值。 + theme.laneBg = MakeArgb(0xFBFBFBu); + theme.gridWeak = MakeArgb(0xDEDEDEu); + theme.gridStrong = MakeArgb(0xC3C3C3u); + theme.axisLine = MakeArgb(0xAFAFAFu); theme.cursor = MakeArgb(0xD18616u); // VS Code charts.orange theme.readoutBg = MakeArgb(0xF3F3F3u); theme.danger = MakeArgb(0xE51400u); // errorForeground @@ -137,7 +149,7 @@ namespace jdi /** * @brief 深色主题(参考 VS Code Dark+)。 */ - const Theme kDark = NormalizePlotContrast([] + const Theme kDark = FinalizeTheme([] { Theme theme; theme.windowBg = MakeArgb(0x1E1E1Eu); // editor.background @@ -146,7 +158,7 @@ namespace jdi theme.cardBg = MakeArgb(0x2D2D30u); theme.plotBg = MakeArgb(0x1E1E1Eu); theme.border = MakeArgb(0x3C3C3Cu); - theme.borderStrong = MakeArgb(0x4A4A4Au); + theme.borderStrong = MakeArgb(0x505050u); theme.text = MakeArgb(0xCCCCCCu); theme.textStrong = MakeArgb(0xFFFFFFu); @@ -159,11 +171,13 @@ namespace jdi theme.pressed = MakeArgb(0x37373Du); theme.selection = MakeArgb(0x04395Eu); - // ⚠️ 深色底上的原始取值几乎与底色同亮度(差 2~3 个色阶)⇒ 交给 - // `NormalizePlotContrast` 按 `plotBg` 自动提亮到可见水位。 - theme.gridWeak = MakeArgb(0x2B2B2Bu); - theme.gridStrong = MakeArgb(0x3A3A3Au); - theme.axisLine = MakeArgb(0x4A4A4Au); + // ⚠️ 深色主题的结构色**必须比旧值亮得多**:它们画在网格叠底(#2D2D30)上, + // 旧值(#2B2B2B / #3A3A3A)与叠底几乎同亮度(实测对比度 1.07)⇒ 网格线看不见。 + // 这里的值按叠底色选:1.30 / 1.70 / 2.10。 + theme.laneBg = MakeArgb(0x2D2D30u); + theme.gridWeak = MakeArgb(0x3F3F3Fu); + theme.gridStrong = MakeArgb(0x505050u); + theme.axisLine = MakeArgb(0x5E5E5Eu); theme.cursor = MakeArgb(0xD18616u); theme.readoutBg = MakeArgb(0x2D2D30u); theme.danger = MakeArgb(0xF44747u); @@ -177,7 +191,7 @@ namespace jdi * 底色用 base03、面板用 base02,强调色仍是 Solarised 那一套 —— 因此波形配色表 * (`SignalPalette`)与 Solarised Light 完全一致。 */ - const Theme kSolarizedDark = NormalizePlotContrast([] + const Theme kSolarizedDark = FinalizeTheme([] { Theme theme; theme.windowBg = MakeArgb(kBase03); @@ -186,7 +200,7 @@ namespace jdi theme.cardBg = MakeArgb(kBase03); theme.plotBg = MakeArgb(kBase03); theme.border = MakeArgb(0x18505Fu); - theme.borderStrong = MakeArgb(kBase01); + theme.borderStrong = MakeArgb(0x205363u); theme.text = MakeArgb(kBase0); theme.textStrong = MakeArgb(kBase1); @@ -199,8 +213,11 @@ namespace jdi theme.pressed = MakeArgb(0x0A3F4Cu); theme.selection = MakeArgb(0x12414Fu); - theme.gridWeak = MakeArgb(0x0B3A46u); - theme.gridStrong = MakeArgb(0x14495Au); + // 网格叠底与绘图区底色同为 base03 时看不出网格层次 ⇒ 显式给出略亮一档的叠底, + // 结构色按**它**选(对比度 1.30 / 1.70 / 2.10)。 + theme.laneBg = MakeArgb(0x032E38u); + theme.gridWeak = MakeArgb(0x13414Cu); + theme.gridStrong = MakeArgb(0x205363u); theme.axisLine = MakeArgb(kBase01); theme.cursor = MakeArgb(kOrange); theme.readoutBg = MakeArgb(kBase02); @@ -316,10 +333,14 @@ namespace jdi std::uint32_t ThemeLaneBackground(const Theme& theme) { - // 网格底色必须与绘图区底色**可辨**(否则几十条网格看起来是一整片)。 - // 用统一的对比度阈值 1.03:本就有差异的主题(Solarised Light / Light / Dark) - // 逐位返回 `cardBg`;两侧完全同色的主题(Solarised Dark 的 base03)会被轻轻推开。 - return EnsureContrast(theme.plotBg | 0xFF000000u, theme.cardBg | 0xFF000000u, 1.03); + // 网格叠底色是**主题配色的一部分**(`Theme::laneBg`),由 `FinalizeTheme` 在构建主题时 + // 保证与绘图区底色可辨;这里只做"未配置"的兜底。 + if (theme.laneBg != 0u) + { + return theme.laneBg | 0xFF000000u; + } + const std::uint32_t plot = theme.plotBg | 0xFF000000u; + return EnsureContrast(plot, theme.cardBg | 0xFF000000u, 1.03); } const Theme& ThemeFor(ThemeKind kind) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index a439c6e..db8946f 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -8687,13 +8687,15 @@ Solarised Light 的 `0xFFFDF6E3`,这是"换深色主题后预览曲线永远 * 条目 = **信号色叠底标题栏**(左侧展开三角 + 名称 + 右端**复选框**)。勾选前只有标题栏; 勾选后出现三角与两行滑块,三角可收起参数行。复选框自绘(对比色描边/填充), 主题色在信号色叠底上不可辨。 -* 两行滑块:`均值点数`(0…上限)与 `迭代次数`(1…上限),**标签不带 X / N**; +* 两行滑块:`均值点数`(1…上限,**2026-10-30 第十七轮把下限从 0 改为 1**)与 + `迭代次数`(1…上限),**标签不带 X / N**; 右侧数值可点击输入**滑块上限**(沿用"输入上限(当前N)"提示与 `EditFilter::BYTE`), 最右侧是复位按钮(点数 → 5 且上限 → 50;迭代 → 1 且上限 → 10)。 新增 `Lane::MeanFilterItem::{maxPoints, maxIterations, collapsed}`、`Lane::meanFilterScroll`。 * 内容区超高时**滚动**(`layout.content` + 滚动条 + 滚轮),底部「构建为新信号」按钮固定。 面板高度上限 = 绘图区可用高度 ⇒ 上百信号合并时不再顶出屏幕。 -* 命中要求"行**完全落在内容区**里",避免滚动出的行抢底部按钮的点击。 +* 命中:只要**点击/悬停点落在内容区内**即算命中(行可以是"半截"显示的,与绘制一致); + 第十七轮已删掉旧的"行必须完全落在内容区内"限制(它造成"看得见却点不到")。 * 缓存键:`PanelSlotKey` 增补 `rowHeaders / rowTriangles / scrollOffset / MarkerGroup::check / meanItem->maxPoints / maxIterations / collapsed`;`meanFilterScroll` **只进槽键**(不进全局键, 否则滚动一个面板会让所有面板重画)。 @@ -8764,3 +8766,91 @@ make debug -> 零 error 零 warning `PlotView::` 前缀。 * 探针的工程根目录推导:探针若放在 `.preflight\xxx.exe`,从 exe 路径**上溯 2 级**即根目录 (上溯 3 级会跑到桌面上,`TestData` 找不到)。 + +--- + +## 2026-10-30 第十七轮(本机):均值面板越界根因 + 网格线进入主题配色 + 悬停/整行点击 + 点数下限 1 + +### 1. 「元素超出浮动面板」的真正根因(**最重要,别再误判**) + +**症状**:均值滤波面板内容超高、滚到底时,**标题栏上**出现一条信号名 + 一个蓝色复选框(无信号色底)。 + +**曾经的错误判断**:以为是「GDI+ `SetClip` 在滚动面板里不可靠」,于是加了「只画完全落在 +`layout.content` 里的行」的双保险 —— 完全无效(像素差 789/1808 **一位都没变**)。 + +**真因**:`PaintEdgeCursor` 里绘制侧的 `itemized` 判定仍为 +`(centerAxis || meanFilter || weakPeak)`,而布局侧的 `EdgePanelLayoutFor` 早已改为 +`(centerAxis || weakPeak)`(均值滤波有自己的可滚动分支)。于是**均值面板被旧「逐条目」分支 +又画了一遍**:它给每行画 `名称 + 复选框`,**没有任何裁切**,行按 `layout.rowHeaders` 的坐标画 +(滚动后就是负的/越界的 Y)⇒ 直接画到标题栏上。 + +**修法**: +* 绘制侧 `itemized = (centerAxis || weakPeak)`(与布局侧统一); +* 旧「逐条目」兜底循环的跳过条件补上 `meanFilter`(否则均值面板会被当成普通面板画两行文字); +* 底部「构建为新信号」按钮由**共享的 `if (hasButton)`** 绘制,不受影响。 + +**教训**:同一语义的标志位在「布局」和「绘制」两处各有一份时,改一处必须同步另一处; +出现"画出来的东西比布局多"时,先怀疑**有第二个绘制分支**,而不是怀疑裁剪 API。 + +### 2. 顺带恢复:半截行正常显示 + +真因修掉后 `g.SetClip(layout.content)` 是够用的,于是删除了「`fullyInside` 门控」 +(行必须整行落在内容区)。现在滚动时行可以**半截**显示,滚动观感连续、不再跳空档。 +探针 `meanpix`:`titleDiff(top vs mid)=0`、`titleDiff(mid vs max)=0`、`outsideDiff=0`(面板外 0 差异), +而 `contentDiff` 仍然很大(内容确实在滚动)。 + +### 3. 条目栏交互:整行点击 + 悬停高亮预览曲线 + +* **整行可点**(不再只能点 12px 的三角):点击落在右端复选框上 → 切换"是否预览"; + 已启用 → 切换参数行的收起/展开;尚未启用 → 直接开始预览。 +* **悬停行 → 对应预览曲线高亮**:`m_meanRowHoverLane / m_meanRowHoverRef` 由 `OnMouseMove` 维护, + `OnMouseLeave` / `InvalidateDerivedCaches` 清除;绘制侧 + `PaintDerivedCurve(..., bool highlight)` 先画 `+5px` 半透明同色光晕、再把线加粗 `+1.6px`, + 并在行头叠一层 `accent 0x2E`。 +* 悬停状态变化会 `InvalidateSceneCache()`(派生曲线属于"静态场景",否则高亮要等下一次重绘)。 +* 命中不再要求行"完全在内容区内"(见上),但**命中点本身必须落在 `layout.content` 里**, + 所以不会抢底部按钮的点击,也不会出现"看不见却能点到"的热区。 + +### 4. 均值点数下限 1(用户明确要求:不可以为 0) + +* `kMeanFilterPointsMin` 由 **0 → 1**(`PlotViewInternal.hpp`); +* 输入提交路径:`pointsMin = centerTarget ? 0 : kMeanFilterPointsMin` + (中心轴边沿计数沿用旧语义 0 = 不平滑,不跟着改); +* 预览:`PaintMeanFilterCurve` 内 `points/iterations` 再夹一次下限 ⇒ 即使条目被外部写成 0, + 画出来也与 1 点完全一致(探针断言"0 与 1 的整帧像素 0 差异"); +* 构建请求 `MeanFilterBuild.points` 同样夹到下限 ⇒ 预览与「构建为新信号」仍逐位一致。 + +### 5. 网格线颜色成为主题配色的一部分 + +* `struct Theme` 新增 `laneBg`(网格叠底色,= 绘图区在该主题下的真实底色)。 +* 全部结构性颜色(`gridWeak / gridStrong / axisLine / borderStrong`)统一**对 `laneBg`** + 做 WCAG 对比度归一(`FinalizeTheme`,契约:gridWeak ≥1.30、gridStrong ≥1.70、 + axisLine ≥2.10、borderStrong ≥1.70),并给出每个主题的显式取值。 +* Dark 主题原本 gridWeak 对 lane 只有 **1.071**(几乎不可见)⇒ 现改为 ≥1.30; + 其它主题同步微调,四个主题的网格都清晰可辨。 + +### 6. 验证 + +``` +uicheck 69/69 lbcheck 21/21 lbresid 5/5 lbzorder 4/4 +lbcovr 7/7 lbpanel 10/10 derivecheck 16/16 +round15check 28/28 themecheck 17/17 +meancheck 17/17 (含:滚动条确实被绘制、三角 12 逻辑像素、整行点击可反复切换、 + 悬停记录/清除、点数 0 与 1 等价、同状态两帧逐像素一致) +meanpix 标题栏 / 面板外:滚动前后 0 像素差异 +rendercheck 8 个场景哈希与基线完全一致(A…H 未变) +make release -> 零 warning(805,376 B);make debug -> 零 warning +``` + +### 7. 踩坑(本轮新增) + +* ⚠️⚠️ **探针 `build.ps1` 只编译 `main.cpp`,链接的是 `Build/Release/*.o`**: + 改完工程源码后只重建探针 ⇒ 探针仍在跑**旧目标文件**,诊断打印"凭空消失"、 + 修复"看起来无效"。**先 `make release` 再重建探针**,并且**不要用 + `Select-String error` 过滤 `make` 输出后忽略非零退出码**(本轮就因为一次 + `const gd::RectF& header` 重复声明导致 `make` 失败而被过滤掉,白查了半小时)。 +* ⚠️ 探针里断言"某行已启用"前要确认条目真的存在:`SetLaneMeanFilter(lane,true)` 会**自动启用 + 第一个信号**,再"全量切换"会把它关掉 ⇒ 出现 39/40 这种偏差,断言就会落在"未启用的行"上, + 而“未启用的行单击 = 开始预览”本来就不切换 `collapsed`。 +* ⚠️ 异步派生曲线(大信号走后台线程)让"改参数 → 立刻截图比对"变得不稳定: + 每个状态**连渲染两帧**再取像素,才是稳定态。