修复拆分网格误继承实验性功能 / 阶跃线宽 / 面板拖拽残影 / 自由标记卡顿与交互

- 拆分网格:实验性功能只由原网格的首个信号继承(LaneCarry::owner);
  SplitLane 只让第一份继承;新增 PruneLaneSignalItems 按 lane.signals 过滤四类
  逐信号条目(修复"新网格出现别人的固定点、面板里却查不到")
- 阶跃信号:包络带横向膨胀半径 ceil -> floor(1px 线宽不再把竖直沿撑成 3px)
- 浮动面板:面板位置移出 PanelGlobalKey(拖动单个面板不再重画全部面板),
  新增 vacatedRects 让"上一帧被移动/缩小/关闭的面板腾出的区域"也强制重画
- 自由标记:新增 FreeMarkerContents 内容缓存(键不含视界),大量固定点时拖光标/平移不再逐帧采样;
  透明度滑块与光标模式解耦(始终显示);悬停胶囊恢复不透明(瞄着被遮住的数据点时除外);
  胶囊与面板五列按实测文本撑开,不再省略
- 面板交互:折起指示器与信号列表组名同款三角;GX 徽标/标题栏悬停不改光标、移除徽标气泡;
  标题栏双击滚动到该网格(单击不触发,双击不再折起)
- 验证:新增离屏 .preflight/fixcheck 37/37(三处 A/B 均能如实失败);
  回归 round3check 31/31、round4check 34/34、round5check 5/5、editcheck 20/20、convcheck 18/18;
  make release/debug 零警告
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-21 17:21:09 +08:00
1 parent 3400ac7c29
commit 8f735d51be
8 files changed
+654 -198

No files matched your search

+43 -11
View File
@@ -1836,6 +1836,46 @@ namespace jdi
gd::RectF close;
};
/**
* @brief 「自由标记」某个固定点的**内容**(取值与各种文本)。
*
* 这些值与**视界无关**(只取决于采样数据与信号样式),因此单独缓存:
* 平移视界、拖动时间光标时不需要重新采样/重新格式化。
* 面板的五列与网格内的取值胶囊共用这一份内容。
*/
struct FreeMarkerContentEntry
{
std::size_t itemIndex = 0; /**< `Lane::freeMarkerItems` 下标。 */
bool valid = false; /**< 取值是否有效(无效时全部文本为 `N/A`)。 */
double value = 0.0; /**< 绘制用取值(用于算胶囊/圆点的纵向位置)。 */
std::wstring capsuleText; /**< 网格内取值胶囊的文本(`值(±增量) = 枚举`)。 */
std::wstring valueText; /**< 面板:取值列。 */
std::wstring enumText; /**< 面板:枚举列(可能是空串)。 */
std::wstring deltaText; /**< 面板:增量列。 */
std::wstring timeText; /**< 面板:时刻列。 */
std::wstring deltaTimeText; /**< 面板:间隔(Δt)列。 */
std::uint32_t color = 0; /**< 信号色(信号样式里的颜色,已乘不透明度)。 */
};
/** 一个网格的「自由标记」内容缓存(`key` 只含与视界无关的输入)。 */
struct FreeMarkerContentCache
{
std::uint64_t key = 0;
std::vector<FreeMarkerContentEntry> entries;
};
/**
* @brief 取某个网格的「自由标记」内容列表(必要时重建,**与视界无关**)。
*
* ⚠️ 性能硬约束:大量固定点时,逐帧重新 `SampleDisplayedSignalAt` + 拼字符串
* 是“拖动全局光标/移动视界变卡”的主因。内容只依赖数据与样式,因此缓存起来;
* 命中缓存时每帧只做几何与绘制。
*/
const std::vector<FreeMarkerContentEntry>& FreeMarkerContents(int laneIndex) const;
/** 按网格下标的「自由标记」内容缓存(`mutable`:只在绘制时按需重建)。 */
mutable std::vector<FreeMarkerContentCache> m_freeMarkerContent;
/**
* @brief 计算某个网格里「第 index 个固定点」的标注几何(绘制与命中共源)。
*
@@ -2407,15 +2447,6 @@ namespace jdi
*/
void PaintSignalInteraction(gd::Graphics& g);
/**
* @brief 绘制浮动面板「GX」徽标的悬停提示气泡(用户 2026-10-19 要求)。
*
* 气泡锚定在徽标下方,内容为“G 编号”与当前可执行的操
*(目标网格已在视界内 / 点击滚动到该网格)。
* ⚠️ 必须在**面板像素图层之后**绘制(见 `PaintEdgeCursor` 末尾),否则会被缓存进面板位图。
*/
void PaintPanelBadgeTooltip(gd::Graphics& g);
/**
* @brief 在绘图区底部绘制临时提示条(如“已放弃当前选区”)。
* @param [IN,OUT] g 绘图上下文。
@@ -2620,8 +2651,9 @@ namespace jdi
/**
* @brief 上一帧鼠标是否悬停在某个浮动面板标题栏的「GX」徽标上(是则记录网格下标 / 面板种类)。
*
* 徽标绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标),
* 而提示气泡是贴图之后的叠加层(见 `PaintPanelBadgeTooltip`),因此需要单独记录状态。
* 徽标本体绘制在面板**像素图层**里(缓存键只在鼠标位于面板内时才混入坐标),
* 因此需要单独记录状态才能让高亮跟着鼠标进出而刷新。
* ⚠️ 用户 2026-10-21 要求:徽标悬停**不改变鼠标形状**、也**不弹提示气泡**。
*/
int m_hoverPanelBadgeLane = -1;
int m_hoverPanelBadgeKind = -1;
@@ -934,12 +934,13 @@ namespace jdi
// ✕ 按钮:与面板/标签栏的其它 ✕ 一致,用手型光标作为可点击提示。
return LoadCursorW(nullptr, IDC_HAND);
}
// 「GX」编号徽标:悬停给出提示 + 手型光标;点击把该网格滚动到视界内。
// ⚠️ 必须排在标题栏之前(徽标位于标题栏矩形内部,否则会被判成“可拖拽”)。
// 「GX」编号徽标:用户 2026-10-21 明确要求**悬停不改变鼠标形状**,
// 也不弹出“是否在视界内”的浮动提示(点击行为不变:滚动到该网格)。
// ⚠️ 必须提前 return(而不是落到下面的标题栏判定),否则会被当成“可拖拽”。
if (layout.badge.Width > 0.0f &&
UiPointInRect(layout.badge, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_HAND);
return nullptr;
}
// 「展开 / 折起」三角指示器:可点击控件 ⇒ 手型光标。
if (layout.disclosure.Width > 0.0f &&
@@ -949,7 +950,9 @@ namespace jdi
}
if (UiPointInRect(layout.title, static_cast<float>(x), static_cast<float>(y)))
{
return LoadCursorW(nullptr, IDC_SIZEALL);
// 标题栏:悬停**保持箭头**,只有按住左键拖动时(`EDGE_PANEL_MOVE`,
// 见本函数开头的拖拽分支)才变成十字箭头(用户 2026-10-21 要求)。
return nullptr;
}
if ((static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::MEAN ||
static_cast<EdgePanelKind>(panelKind) == EdgePanelKind::PEAK) &&
@@ -2720,8 +2723,9 @@ namespace jdi
int panelKind = 0;
if (TopPanelAtPoint(static_cast<float>(x), static_cast<float>(y), panelLane, panelKind))
{
// 标题栏双击 = 展开 / 折起该面板(折起的面板只显示标题栏)。
// ⚠️ 折起状态下标题栏是唯一可见区域,因此这里也是“双击重新展开”的入口。
// 标题栏双击 = 把该面板所属网格**滚动到视界内**(与单击 GX 徽标完全同效)。
// 用户 2026-10-21 明确要求:**只有双击标题栏**才触发这个联动,单击标题栏不触发
//(单击标题栏只是准备拖拽面板);面板的折起/展开只由左侧三角指示器负责。
{
const EdgePanelLayout titleLayout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
@@ -2737,7 +2741,11 @@ namespace jdi
UiPointInRect(titleLayout.disclosure, static_cast<float>(x),
static_cast<float>(y))))
{
ToggleLanePanelCollapsed(panelLane, static_cast<EdgePanelKind>(panelKind));
m_dragMode = PlotDragMode::NONE;
if (ScrollLaneIntoView(panelLane))
{
SetHint(L"正在滚动到 G" + std::to_wstring(panelLane + 1) + L" 网格…");
}
return true;
}
}
@@ -2884,7 +2892,18 @@ namespace jdi
int panelKind = 0;
if (TopPanelAtPoint(fx, fy, panelLane, panelKind))
{
return true;
// ⚠️ 例外:**标题栏**(排除徽标 / ✕ / 折起三角)双击有明确语义 ——
// 把该面板所属网格滚动到视界内(用户 2026-10-21 要求,与单击 GX 徽标一致)。
// 其余落点仍然只有“按下”语义。
const EdgePanelLayout layout =
EdgePanelLayoutFor(panelLane, static_cast<EdgePanelKind>(panelKind));
const bool onTitle = layout.title.Width > 0.0f &&
UiPointInRect(layout.title, fx, fy) &&
!(layout.badge.Width > 0.0f && UiPointInRect(layout.badge, fx, fy)) &&
!(layout.close.Width > 0.0f && UiPointInRect(layout.close, fx, fy)) &&
!(layout.disclosure.Width > 0.0f &&
UiPointInRect(layout.disclosure, fx, fy));
return !onTitle;
}
}
// 右下角「回到最上方」浮动按钮:按下只登记、抬起才生效 ⇒ 只有单击语义。
+7 -1
View File
@@ -1074,7 +1074,13 @@ namespace jdi
// 列间距由前两列推断(同一段内等宽等距);退化时按 1px 处理。
const float spacing = columns[1].x - columns[0].x;
const float step = (spacing > 0.01f) ? spacing : 1.0f;
std::size_t radius = static_cast<std::size_t>(std::ceil(halfWidth / step));
// ⚠️ **必须向下取整**(用户 2026-10-21 报告的阶跃信号线宽不一致):
// `ceil` 会让 1px 线宽(`halfWidth` ≈ 0.5–0.7 设备像素)也膨胀 1 列,
// 阶跃的竖直沿因此从 1 列变成 3 列(≈3px),而水平段只有 ~1.25px ⇒
// 视觉上“竖直跳变的线宽明显大于水平持续段”(放大到走逐点描边路径后又正常了)。
// `floor` 下 1px 线宽不膨胀(竖直沿 = 1 列),粗线(≥ 2 列宽)仍按整数列膨胀。
const std::size_t radius =
static_cast<std::size_t>(std::floor(halfWidth / step + 1e-4f));
if (radius > 0 && radius * 2 + 1 < count)
{
widened.resize(count);
+320 -136
View File
@@ -144,12 +144,42 @@ namespace jdi
if (kind == EdgePanelKind::MARK)
{
// 「自由标记」面板:**宽度用固定值**,不做逐行文本测量。
// ⚠️ `EdgePanelWidth` 被 `EdgePanelLayoutFor` 调用,而后者又在 `TopPanelAtPoint` /
// `SyncPanelOrder` 里被高频调用(每次鼠标移动数次)——若在这里逐行取采样值算文本宽度,
// 就变成"鼠标一动就做几十次采样查询",会明显拖慢交互。
// 取值的文本超长时由绘制侧的 `UiEllipsize` 截断即可。
width = std::max(width, kFreeMarkerPanelWidth * m_scale);
// 「自由标记」面板:宽度 = 五列(取「缺省列宽」与**实测内容宽度**的较大者)+ 行 ✕
// + 左右内边距 + **滚动条预留位**(内容超高时右侧要放滚动条)。
// ⚠️ 用户 2026-10-21 要求面板里的数据列**不得出现省略号**,因此不能再按固定列宽。
// 逐行取采样值算宽度会拖慢交互(本函数被 `TopPanelAtPoint` / `SyncPanelOrder`
// 高频调用),所以走 `Lane::freeMarkerColumnWidths`:绘制该面板时顺便量好真实
// 文本宽度存起来,这里只读。还没量过(全 0)时退回缺省列宽。
const bool hasEnum = [this, &lane]() {
for (const SignalRef& ref : lane.signals)
{
const ResolvedSignal resolved = ResolveSignal(ref);
if (resolved.data != nullptr && resolved.visible &&
!resolved.data->enumText.Empty() && lane.HasFreeMarkers(ref))
{
return true;
}
}
return false;
}();
float columnsWidth = 0.0f;
for (int column = 0; column < 5; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
if (omitted)
{
continue;
}
columnsWidth += std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(column)]);
columnsWidth += kFreeMarkerColumnGap * m_scale;
}
// 行尾 ✕(按胶囊同高的正方形取宽)+ 滚动条预留 + 少量余量。
const float rowCloseWidth = kFreeMarkerItemRowHeight * m_scale + 2.0f * m_scale;
const float markerWidth = kEdgePanelPad * 2.0f * m_scale +
(kFreeMarkerScrollWidth + 4.0f) * m_scale + columnsWidth +
rowCloseWidth;
width = std::max(width, std::max(kFreeMarkerPanelWidth * m_scale, markerWidth));
}
// 夹在绘图区宽度之内(过窄的网格也不至于把面板挤到外面)。
@@ -5441,8 +5471,8 @@ namespace jdi
const float groupRow = kFreeMarkerNameRowHeight * m_scale;
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow =
lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
// 「标记透明度」滑块行**始终显示**(用户 2026-10-21 要求:不再依附于光标模式按钮)。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
float content = 0.0f;
std::vector<std::size_t> order;
for (const SignalRef& ref : lane.signals)
@@ -5735,16 +5765,17 @@ namespace jdi
const float itemRow = kFreeMarkerItemRowHeight * m_scale;
const float closeSize = kFreeMarkerCloseSize * m_scale;
const float markerButtonHeight = kEdgePanelButtonHeight * m_scale;
const float sliderRow = lane.freeMarkerCursor ? (kFreeMarkerSliderRowHeight * m_scale) : 0.0f;
// 「标记透明度」独立于光标模式按钮:**总是**显示在按钮上方。
const float sliderRow = kFreeMarkerSliderRowHeight * m_scale;
const float scrollW = kFreeMarkerScrollWidth * m_scale;
// 底部固定区:切换按钮(整行宽度、与面板下边界留一个 pad)。
layout.button = gd::RectF(left + pad, top + height - pad - markerButtonHeight,
std::max(0.0f, width - pad * 2.0f), markerButtonHeight);
if (sliderRow > 0.0f)
{
// 「标记透明度」行:与信号样式区的「透明度」行同一套排版
//(左侧标签 + 中间轨道 + 右侧百分比),只是位置固定在按钮上方。
//(左侧标签 + 中间轨道 + 右侧百分比),位置固定在按钮上方。
// ⚠️ 无论是否启用「光标选取模式」都显示(用户 2026-10-21 要求)。
const float rowTop = layout.button.Y - sliderRow;
const float valueWidth =
std::max(36.0f * m_scale,
@@ -5829,8 +5860,11 @@ namespace jdi
for (int column = 0; column < 5; ++column)
{
const bool omitted = (column == EdgePanelLayout::MARKER_COL_ENUM) && !hasEnum;
// ⚠️ 列宽取「缺省列宽」与**实测内容宽度**的较大者 ⇒ 面板里不再出现省略号。
const float columnWidth =
omitted ? 0.0f : (kFreeMarkerColumnWidths[column] * m_scale);
omitted ? 0.0f
: std::max(kFreeMarkerColumnWidths[column] * m_scale,
lane.freeMarkerColumnWidths[static_cast<std::size_t>(column)]);
layout.markerColumns[column].left = columnX;
layout.markerColumns[column].width = columnWidth;
columnX += columnWidth + (omitted ? 0.0f : kFreeMarkerColumnGap * m_scale);
@@ -6303,18 +6337,23 @@ namespace jdi
const std::vector<Lane>& lanes = m_tab->Lanes();
for (const Lane& lane : lanes)
{
mixFloat(static_cast<float>(lane.edgePanel.x));
mixFloat(static_cast<float>(lane.edgePanel.y));
mixFloat(static_cast<float>(lane.edgeCenterPanel.x));
mixFloat(static_cast<float>(lane.edgeCenterPanel.y));
mixFloat(static_cast<float>(lane.meanFilterPanel.x));
mixFloat(static_cast<float>(lane.meanFilterPanel.y));
mixFloat(static_cast<float>(lane.weakPeakPanel.x));
mixFloat(static_cast<float>(lane.weakPeakPanel.y));
// ⚠️⚠️ **面板的位置 / 尺寸绝不能进全局键**(用户 2026-10-21 报告“拖动面板时
// 下方另一个面板出现拖拽残影”):位置只影响该面板自己,已在 `PanelSlotKey`
// 里(它混合 `layout.panel/title/...`)。以前把 4 类面板的 x/y 混进全局键 ⇒
// 拖动任意面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的
// 位置偏偏漏了没混 ⇒ 拖动它时其它面板照旧贴旧像素 → 正好在下方留残影。
// 现在:拖动面板只重画它自己;它**腾出的区域**由 `PaintEdgeCursor` 的
// `vacatedRects` 机制兜底(覆盖到该区域的面板必须重画)。
mix(lane.edgePanel.open ? 1ull : 0ull);
mix(lane.edgeCenterPanel.open ? 1ull : 0ull);
mix(lane.meanFilterPanel.open ? 1ull : 0ull);
mix(lane.weakPeakPanel.open ? 1ull : 0ull);
mix(lane.freeMarkerPanel.open ? 1ull : 0ull);
mix(lane.edgePanel.collapsed ? 1ull : 0ull);
mix(lane.edgeCenterPanel.collapsed ? 1ull : 0ull);
mix(lane.meanFilterPanel.collapsed ? 1ull : 0ull);
mix(lane.weakPeakPanel.collapsed ? 1ull : 0ull);
mix(lane.freeMarkerPanel.collapsed ? 1ull : 0ull);
mix(lane.edgeValueValid ? 1ull : 0ull);
mixDouble(lane.edgeValue);
}
@@ -6614,6 +6653,103 @@ namespace jdi
return true;
}
const std::vector<PlotView::FreeMarkerContentEntry>& PlotView::FreeMarkerContents(int laneIndex) const
{
static const std::vector<FreeMarkerContentEntry> kNoEntries;
if (m_tab == nullptr || laneIndex < 0 ||
laneIndex >= static_cast<int>(m_tab->Lanes().size()))
{
return kNoEntries;
}
const Lane& lane = m_tab->Lanes()[static_cast<std::size_t>(laneIndex)];
if (static_cast<std::size_t>(laneIndex) >= m_freeMarkerContent.size())
{
m_freeMarkerContent.resize(static_cast<std::size_t>(laneIndex) + 1);
}
FreeMarkerContentCache& cache = m_freeMarkerContent[static_cast<std::size_t>(laneIndex)];
// 缓存键:只混入**与视界无关**的输入(条目、信号样式、数据地址与长度)。
// ⚠️ 绝不能混入 `ViewMin/ViewMax`、鼠标位置或网格几何 —— 否则平移视界、
// 拖动时间光标时缓存永远命中不了,又回到"每帧对每个固定点采样一次"。
std::uint64_t key = 1469598103934665603ull;
const auto mix = [&key](std::uint64_t value) {
key ^= value;
key *= 1099511628211ull;
};
const auto mixFloat = [&mix](float value) {
std::uint32_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(static_cast<std::uint64_t>(bits));
};
const auto mixDouble = [&mix](double value) {
std::uint64_t bits = 0;
std::memcpy(&bits, &value, sizeof(bits));
mix(bits);
};
mix(static_cast<std::uint64_t>(lane.freeMarkerItems.size()));
for (const Lane::FreeMarkerItem& item : lane.freeMarkerItems)
{
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.groupIndex)));
mix(static_cast<std::uint64_t>(static_cast<std::uint32_t>(item.ref.signalIndex)));
mixDouble(item.time);
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
{
mix(0ull);
continue;
}
mix(reinterpret_cast<std::uint64_t>(resolved.data));
mix(static_cast<std::uint64_t>(resolved.data->values.size()));
mix(resolved.visible ? 1ull : 2ull);
mix(static_cast<std::uint64_t>(resolved.style->color));
mixFloat(resolved.style->opacity);
mixFloat(resolved.style->gain);
mixFloat(resolved.style->offsetY);
mixDouble(resolved.style->offsetTime);
mix(static_cast<std::uint64_t>(static_cast<int>(resolved.style->mode)));
mix((resolved.style->absolute ? 1ull : 0ull) | (resolved.style->mirrorVertical ? 2ull : 0ull) |
(resolved.style->mirrorHorizontal ? 4ull : 0ull));
}
if (cache.key != key)
{
cache.key = key;
cache.entries.clear();
cache.entries.reserve(lane.freeMarkerItems.size());
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
FreeMarkerContentEntry entry;
entry.itemIndex = itemIndex;
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
if (valueOk)
{
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
}
const ResolvedSignal resolved = ResolveSignal(item.ref);
const std::wstring* enumPtr =
(resolved.data != nullptr) ? resolved.data->enumText.Find(enumValue) : nullptr;
entry.valid = valueOk;
entry.value = value;
entry.color = (resolved.style != nullptr) ? StyleColor(*resolved.style) : 0u;
entry.capsuleText = FreeMarkerValueText(value, hasPrev, delta, enumPtr);
entry.valueText = valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
entry.enumText = (enumPtr != nullptr) ? *enumPtr : std::wstring();
entry.deltaText = (valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
entry.timeText = FreeMarkerTimeText(item.time);
entry.deltaTimeText = FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
cache.entries.push_back(std::move(entry));
}
}
return cache.entries;
}
bool PlotView::FreeMarkerLabelRects(int laneIndex, std::size_t itemIndex, gd::RectF& label,
gd::RectF& close, std::wstring& text) const
{
@@ -6632,16 +6768,14 @@ namespace jdi
return false;
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
double value = 0.0;
double enumValue = 0.0;
if (!FreeMarkerValue(lane, item, value, enumValue))
// 取值与文本来自**与视界无关**的内容缓存(大量固定点 + 拖光标/平移时不重复采样)。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(laneIndex);
if (itemIndex >= contents.size() || !contents[itemIndex].valid)
{
return false;
}
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
FreeMarkerDelta(lane, itemIndex, value, hasPrev, delta, timeDelta);
const FreeMarkerContentEntry& entry = contents[itemIndex];
const double value = entry.value;
// 超出视界**直接不显示**(用户明确要求:不按常规数据点那样贴边定格 + 三角指示)。
const float rawX = TimeToX(item.time);
@@ -6660,14 +6794,12 @@ namespace jdi
}
// 枚举文本按**采样原值**查找(与光标读数完全一致)。
const ResolvedSignal labelResolved = ResolveSignal(item.ref);
const std::wstring* enumText = (labelResolved.data != nullptr)
? labelResolved.data->enumText.Find(enumValue)
: nullptr;
text = FreeMarkerValueText(value, hasPrev, delta, enumText);
text = entry.capsuleText;
// ✕ 按钮 = **与取值胶囊同高的正方形**(用户明确要求:长宽都等于胶囊的上下高度)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 10.0f * m_scale;
// ⚠️ 宽度多加 2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 有时会因为亚像素/字形
// 伸部而把“刚好装满”的文本截成省略号(用户报告枚举文本被 … 缺省)。
const float textWidth = UiMeasureTextWidth(m_fonts->monoSmall, text) + 12.0f * m_scale;
const float textHeight = 17.0f * m_scale;
const float closeSize = textHeight;
const float gap = kFreeMarkerCloseGap * m_scale;
@@ -6803,6 +6935,8 @@ namespace jdi
continue;
}
const float markerAlpha = std::max(0.0f, std::min(1.0f, lane.freeMarkerOpacity));
// 内容(取值 / 文本 / 信号色)来自与视界无关的缓存,不逐帧重新采样。
const std::vector<FreeMarkerContentEntry>& contents = FreeMarkerContents(static_cast<int>(laneIndex));
std::vector<gd::RectF> placed;
for (std::size_t itemIndex = 0; itemIndex < lane.freeMarkerItems.size(); ++itemIndex)
{
@@ -6814,20 +6948,18 @@ namespace jdi
continue; // 超出视界直接不显示(不贴边、不画三角)
}
const Lane::FreeMarkerItem& item = lane.freeMarkerItems[itemIndex];
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.style == nullptr)
if (itemIndex >= contents.size())
{
continue;
}
const std::uint32_t signalColor = StyleColor(*resolved.style);
const FreeMarkerContentEntry& entry = contents[itemIndex];
const std::uint32_t signalColor = entry.color;
const LaneVisual& visual = lanes[laneIndex];
const float clipTop = std::max(visual.rect.Y, m_plotRect.Y);
const float clipBottom =
std::min(visual.rect.Y + visual.rect.Height, m_plotRect.Y + m_plotRect.Height);
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(lane, item, value, enumValue);
const bool valueOk = entry.valid;
// ---- 纵向避让:与本次已放置的胶囊重叠时,向下 / 向上交替错开 ----
const auto occupied = [&placed](const gd::RectF& rect) {
@@ -6877,13 +7009,32 @@ namespace jdi
const float radius = 3.4f * m_scale;
const int dotAlpha = static_cast<int>(std::lround(255.0f * markerAlpha));
gd::SolidBrush dot(ToGdiColor(WithAlpha(signalColor, dotAlpha)));
const float dotY = valueOk ? ValueToY(value, visual) : (clipTop + clipBottom) * 0.5f;
const float dotY =
valueOk ? ValueToY(entry.value, visual) : (clipTop + clipBottom) * 0.5f;
g.FillEllipse(&dot, x - radius, dotY - radius, radius * 2.0f, radius * 2.0f);
// ✕ 按钮的悬停态决定它的配色,因此先算再画胶囊。
const bool hot = (m_freeMarkerHoverLane == static_cast<int>(laneIndex) &&
m_freeMarkerHoverItem == itemIndex);
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, markerAlpha);
// 「标记透明度」:鼠标停在某个取值胶囊上时把**这一个**胶囊恢复为全不透明
//(用户 2026-10-21 要求:既能看淡全局标记,又能读清当前关注的数值)。
// ⚠️ 例外:如果此刻跟随探针正瞄着一个**被这个胶囊遮住的数据点**
//(用户意图标记被遮住的那条曲线),则不恢复 —— 否则胶囊会重新遮住目标点、
// 无法看出自己到底瞄到了哪个采样点。
float capsuleAlpha = markerAlpha;
const float fx = static_cast<float>(m_mouseX);
const float fy = static_cast<float>(m_mouseY);
if (markerAlpha < 0.999f && UiPointInRect(label, fx, fy))
{
const bool probeHiddenByLabel =
m_freeMarker.valid && m_freeMarker.laneIndex == static_cast<int>(laneIndex) &&
UiPointInRect(label, m_freeMarker.x, m_freeMarker.y);
if (!probeHiddenByLabel)
{
capsuleAlpha = 1.0f;
}
}
PaintFreeMarkerCapsule(g, label, text, signalColor, close, hot, capsuleAlpha);
FreeMarkerHit hit;
hit.laneIndex = static_cast<int>(laneIndex);
@@ -7261,8 +7412,62 @@ namespace jdi
// ⚠️ **按层序从下往上绘制**(与命中测试、像素缓存的层叠顺序完全一致):
// `m_panelOrder` 队尾 = 最上层 = 最后绘制。
SyncPanelOrder();
for (const PanelRef& panelRef : m_panelOrder)
// 预扫描一:本帧各面板的布局(循环里直接复用,避免每帧重复计算)。
std::vector<EdgePanelLayout> panelLayouts(m_panelOrder.size());
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
{
panelLayouts[ordinal] = EdgePanelLayoutFor(
m_panelOrder[ordinal].laneIndex, static_cast<EdgePanelKind>(m_panelOrder[ordinal].kind));
}
// 预扫描二:上一帧已缓存、但本帧**位置/尺寸变了(或被移除)**的面板——它们腾出的
// 区域可能仍然留在**其它面板**的缓存像素里(那块像素是上一帧连它一起拷下来的),
// 于是鼠标拖动面板时,下方面板上会出现拖拽残影(用户 2026-10-21 报告)。
// 把腾空区域也当“脏区”,强制覆盖到它的面板重画,就能彻底消除残影。
std::vector<gd::RectF> vacatedRects;
if (panelCacheReady && !m_panelSlots.empty())
{
const float margin = 4.0f * m_scale;
for (const PanelSlot& slot : m_panelSlots)
{
int cx0 = slot.x0;
int cy0 = slot.y0;
int cx1 = slot.x1;
int cy1 = slot.y1;
bool alive = false;
for (std::size_t ordinal = 0; ordinal < m_panelOrder.size(); ++ordinal)
{
if (m_panelOrder[ordinal].laneIndex != slot.laneIndex ||
m_panelOrder[ordinal].kind != slot.kind)
{
continue;
}
const gd::RectF& panel = panelLayouts[ordinal].panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
break;
}
cx0 = std::max(0, static_cast<int>(std::floor(panel.X - margin)));
cy0 = std::max(0, static_cast<int>(std::floor(panel.Y - margin)));
cx1 = std::max(cx0, static_cast<int>(std::ceil(panel.X + panel.Width + margin)));
cy1 = std::max(cy0, static_cast<int>(std::ceil(panel.Y + panel.Height + margin)));
alive = true;
break;
}
if (!alive || cx0 != slot.x0 || cy0 != slot.y0 || cx1 != slot.x1 || cy1 != slot.y1)
{
vacatedRects.push_back(gd::RectF(static_cast<float>(slot.x0),
static_cast<float>(slot.y0),
static_cast<float>(slot.x1 - slot.x0),
static_cast<float>(slot.y1 - slot.y0)));
}
}
}
for (std::size_t panelOrdinal = 0; panelOrdinal < m_panelOrder.size(); ++panelOrdinal)
{
const PanelRef& panelRef = m_panelOrder[panelOrdinal];
const EdgePanelKind kind = static_cast<EdgePanelKind>(panelRef.kind);
const std::size_t index = static_cast<std::size_t>(panelRef.laneIndex);
const bool centerAxis = (kind == EdgePanelKind::CENTER);
@@ -7278,7 +7483,8 @@ namespace jdi
continue;
}
const Lane& tabLane = tabLanes[index];
const EdgePanelLayout layout = EdgePanelLayoutFor(static_cast<int>(index), kind);
// 布局已在预扫描里算好(同一帧内复用,避免重复计算)。
const EdgePanelLayout& layout = panelLayouts[panelOrdinal];
const gd::RectF& panel = layout.panel;
if (panel.Width <= 0.0f || panel.Height <= 0.0f)
{
@@ -7302,6 +7508,19 @@ namespace jdi
break;
}
}
if (!forcedDirty)
{
// 上一帧有面板在这里腾出了位置(移动 / 缩小 / 关闭)⇒ 本面板的缓存像素可能
// 仍然包含那块旧内容,必须重画(否则拖动上面那个面板时这里会留残影)。
for (const gd::RectF& vacated : vacatedRects)
{
if (vacated.IntersectsWith(cachedRect))
{
forcedDirty = true;
break;
}
}
}
std::uint64_t slotKey = 0;
const std::size_t slotIndex = nextSlots.size();
@@ -7360,8 +7579,9 @@ namespace jdi
UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale,
WithAlpha(m_theme->accent, 0x2A));
// 标题栏左侧的「展开 / 折起」指示器:三角(朝下 = 已展开、朝右 = 已折起)
// + 三角后方的一条短横线(经典展开/折起标识),位于 GX 徽标左边。
// 标题栏左侧的「展开 / 折起」指示器:**与信号列表组名最左边的折叠三角完全同款**
//(同一尺寸 12 逻辑像素、同一个 `UiDrawTriangleIcon`、同样只在悬停时变强调色、
// 不再带三角后方那条横线 —— 用户 2026-10-21 要求“统一使用相同标记”)。
// 单击它切换折起状态;折起后面板只剩标题栏,这里是唯一的“重新展开”按钮。
{
const gd::RectF& disclosureRect = layout.disclosure;
@@ -7373,17 +7593,14 @@ namespace jdi
static_cast<float>(m_mouseY));
const std::uint32_t disclosureColor =
disclosureHot ? m_theme->accent : m_theme->text;
const float centerX = disclosureRect.X + 5.5f * m_scale;
const float centerX = disclosureRect.X + disclosureRect.Width * 0.5f;
const float centerY = disclosureRect.Y + disclosureRect.Height * 0.5f;
UiDrawTriangleIcon(
g,
gd::RectF(centerX - 4.5f * m_scale, centerY - 4.5f * m_scale,
9.0f * m_scale, 9.0f * m_scale),
gd::RectF(centerX - 6.0f * m_scale, centerY - 6.0f * m_scale,
12.0f * m_scale, 12.0f * m_scale),
disclosureColor,
layout.collapsed ? IconDirection::RIGHT : IconDirection::DOWN);
UiDrawLine(g, centerX + 6.0f * m_scale, centerY,
disclosureRect.X + disclosureRect.Width, centerY, disclosureColor,
1.4f * m_scale);
}
}
@@ -7751,60 +7968,79 @@ namespace jdi
}
// ---- 固定点条目(五列,列起点由布局给定 ⇒ 纵向严格对齐)----
// ⚠️ 取值与各列文本来自**与视界无关的内容缓存**(`FreeMarkerContents`):
// 大量固定点时逐帧重新采样会让"拖光标 / 平移视界"明显变卡。
const std::vector<FreeMarkerContentEntry>& contents =
FreeMarkerContents(static_cast<int>(index));
// 五列的**实测宽度**:含滚出视界的条目,保证列宽稳定、文本不被省略。
// 存回 `Lane` 供下一步的布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)使用。
{
float widest[5] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const auto measure = [this, &widest](std::size_t column, const std::wstring& text) {
if (text.empty() || column >= 5)
{
return;
}
widest[column] = std::max(widest[column],
UiMeasureTextWidth(m_fonts->monoSmall, text));
};
for (const FreeMarkerContentEntry& entry : contents)
{
measure(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText);
measure(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText);
measure(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText);
measure(EdgePanelLayout::MARKER_COL_TIME, entry.timeText);
measure(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText);
}
std::array<float, 5>& stored =
tabLanes[index].freeMarkerColumnWidths;
for (std::size_t column = 0; column < 5; ++column)
{
// +2px 余量:`DrawTextW` 的 `DT_END_ELLIPSIS` 对"刚好装满"的文本
// 有时仍会截成省略号(亚像素/字形伸部),留一点余量可彻底避免。
const float want =
(widest[column] > 0.0f) ? (widest[column] + 2.0f * m_scale) : 0.0f;
if (std::fabs(stored[column] - want) > 0.5f)
{
stored[column] = want;
}
}
}
for (const EdgePanelLayout::MarkerRow& markerRow : layout.markerRows)
{
const Lane::FreeMarkerItem& item = tabLane.freeMarkerItems[markerRow.itemIndex];
const ResolvedSignal resolved = ResolveSignal(item.ref);
if (resolved.data == nullptr || resolved.style == nullptr)
if (markerRow.itemIndex >= contents.size())
{
continue;
}
double value = 0.0;
double enumValue = 0.0;
const bool valueOk = FreeMarkerValue(tabLane, item, value, enumValue);
bool hasPrev = false;
double delta = 0.0;
double timeDelta = 0.0;
if (valueOk)
{
FreeMarkerDelta(tabLane, markerRow.itemIndex, value, hasPrev, delta,
timeDelta);
}
const std::wstring* enumText = resolved.data->enumText.Find(enumValue);
const std::wstring valueText =
valueOk ? FormatValue(value) : std::wstring(kFreeMarkerNotAvailable);
const std::wstring deltaText =
(valueOk && hasPrev) ? FormatSignedValue(delta)
: std::wstring(kFreeMarkerNotAvailable);
const std::wstring timeText = FreeMarkerTimeText(item.time);
const std::wstring timeDeltaText =
FreeMarkerDeltaText(valueOk && hasPrev, timeDelta);
const FreeMarkerContentEntry& entry = contents[markerRow.itemIndex];
const auto columnRect = [&](EdgePanelLayout::MarkerColumnIndex column) {
return gd::RectF(layout.markerColumns[column].left, markerRow.rect.Y,
layout.markerColumns[column].width,
markerRow.rect.Height);
};
// ⚠️ 面板里的数据列**不得省略**(用户 2026-10-21 明确要求):
// 列宽已按实测文本宽度撑开,因此这里直接整段输出,不再 `UiEllipsize`。
const auto paintCell = [&](EdgePanelLayout::MarkerColumnIndex column,
const std::wstring& text, std::uint32_t color) {
if (layout.markerColumns[column].width <= 0.0f || text.empty())
{
return;
}
const gd::RectF rect = columnRect(column);
UiText(g, UiEllipsize(text, m_fonts->monoSmall, rect.Width),
m_fonts->monoSmall, rect, color, TextAlign::LEFT);
UiText(g, text, m_fonts->monoSmall, columnRect(column), color,
TextAlign::LEFT);
};
paintCell(EdgePanelLayout::MARKER_COL_VALUE, valueText, m_theme->textStrong);
paintCell(EdgePanelLayout::MARKER_COL_VALUE, entry.valueText,
m_theme->textStrong);
if (layout.markerHasEnumColumn)
{
paintCell(EdgePanelLayout::MARKER_COL_ENUM,
(enumText != nullptr) ? *enumText : std::wstring(),
m_theme->text);
paintCell(EdgePanelLayout::MARKER_COL_ENUM, entry.enumText, m_theme->text);
}
paintCell(EdgePanelLayout::MARKER_COL_DELTA, deltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, timeDeltaText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DELTA, entry.deltaText,
m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_TIME, entry.timeText, m_theme->textMuted);
paintCell(EdgePanelLayout::MARKER_COL_DT, entry.deltaTimeText,
m_theme->textMuted);
// 行尾 ✕:移除该固定点(与之前一致)。
const gd::RectF& closeRect = markerRow.close;
@@ -7960,58 +8196,6 @@ namespace jdi
m_panelSlots = std::move(nextSlots);
m_panelGlobalKey = panelGlobalKey;
m_panelValid = panelCacheReady;
// 「GX」徽标的悬停提示气泡:必须在**全部面板像素都存进图层之后**绘制,
// 否则气泡会被烘进面板缓存(下一帧即使鼠标移开也会重现)。
PaintPanelBadgeTooltip(g);
}
void PlotView::PaintPanelBadgeTooltip(gd::Graphics& g)
{
if (m_hoverPanelBadgeLane < 0 || m_fonts == nullptr || m_fonts->uiSmall == nullptr ||
m_tab == nullptr || m_hoverPanelBadgeKind < 0)
{
return;
}
const EdgePanelKind kind = static_cast<EdgePanelKind>(m_hoverPanelBadgeKind);
const EdgePanelLayout layout = EdgePanelLayoutFor(m_hoverPanelBadgeLane, kind);
if (layout.badge.Width <= 0.0f)
{
return;
}
// 目标网格是否已完整可见:决定提示文案(不可见时提示“点击会滚动过去”)。
bool laneVisible = false;
const std::size_t laneIndex = static_cast<std::size_t>(m_hoverPanelBadgeLane);
if (laneIndex < m_lanes.size())
{
const gd::RectF& rect = m_lanes[laneIndex].rect;
laneVisible = (rect.Y >= m_plotRect.Y - 0.5f) &&
(rect.Y + rect.Height <= m_plotRect.Y + m_plotRect.Height + 0.5f);
}
const std::wstring label = L"G" + std::to_wstring(m_hoverPanelBadgeLane + 1);
const std::wstring text =
label + (laneVisible ? L" · 已在视界内" : L" · 点击滚动到该网格");
const float padX = 8.0f * m_scale;
const float height = 22.0f * m_scale;
const float width = std::min(UiMeasureTextWidth(m_fonts->uiSmall, text) + padX * 2.0f,
std::max(10.0f, m_plotRect.Width - 4.0f * m_scale));
float left = layout.badge.X - 2.0f * m_scale;
left = std::max(m_plotRect.X + 2.0f * m_scale,
std::min(left, m_plotRect.X + m_plotRect.Width - width - 2.0f * m_scale));
// 默认贴在徽标下方;下方放不下就翻到上方(与弹出菜单的翻转规则一致)。
float top = layout.badge.Y + layout.badge.Height + 4.0f * m_scale;
if (top + height > m_plotRect.Y + m_plotRect.Height - 2.0f * m_scale)
{
top = layout.badge.Y - height - 4.0f * m_scale;
}
top = std::max(m_plotRect.Y + 2.0f * m_scale, top);
const gd::RectF bubble(left, top, width, height);
UiFillRoundRect(g, bubble, 4.0f * m_scale, m_theme->readoutBg);
UiDrawRoundRect(g, bubble, 4.0f * m_scale, m_theme->borderStrong, 1.0f);
UiText(g, text, m_fonts->uiSmall, bubble, m_theme->textStrong, TextAlign::CENTER);
}
void PlotView::PaintEditField(gd::Graphics& g)
+138
View File
@@ -3015,6 +3015,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **「自由标记」体验重做 + 全部实验性面板可折起(详见 §12 末尾「2026-10-21 第二轮」与 §11.2 第 194–201 条)**。① **网格内固定点**:取值胶囊互相重叠时做**纵向避让**;✕ 改为**与胶囊同高的正方形**;**删除纵向半透明竖线**;新增「标记透明度」(0–100%,作用于本网格全部固定点:圆点 / 胶囊 / 文字 / ✕);⚠️ **GDI `DrawTextW` 忽略 alpha**,因此半透明时文字改走 **GDI+ `DrawString`**(不透明度 100% 时仍走 `UiText`,像素零变化),实测 0% 时胶囊区域与“无标记”帧逐像素相同(1433→**0**)。② **面板布局重做**:每个有固定点的信号一个**信号色托底分组栏**(三角 + 名称 + `N 标记点` + ✕,文字/三角/✕ 一律用 `UiContrastInk` 自适应对比色,点击分组栏收起该组);条目改成**五列**(取值 / 枚举 / 增量 / 时刻 / 间隔),首项增列与Δt列显示 `N/A`,**整个面板没有枚举文本时枚举列整列缺省**。③ **内容滚动**:内容超高时右侧出现滚动条(分组栏与条目一起滚动),滚轮在内容区翻页;底部按钮与透明度滑块**不参与滚动**。④ **面板折起**:`LanePanelState` 新增 `collapsed`,标题栏 GX 徽标左侧新增「三角 + 横线」指示器(`kEdgePanelDisclosureWidth`),单击折起/展开、双击标题栏切换;折起后只剩标题栏(五类面板全部支持)。验证:新增离屏 `.preflight/perf/mark2check.exe`(**69/69**,含 A/B:恢复竖线 ⇒ 1 项失败;禁用避让 ⇒ 2 项失败);回归 `datacheck` 69/69、`verify` 49/49、`panelanim` 158/158、`errcheck` 19/19;`make release`/`make debug` 零警告;`TargetCheck.exe` 866,816 字节。 |
| 2026-10-21 | DeepSeek-V4.1-Flash(**本机**) | **四处 BUG 修复 + 两处交互改进(详见 §12 末尾「2026-10-21 第三轮」与 §11.2 第 203–212 条)**。① **合并 → 拆分会把一个网格的实验性功能复制到每个拆分结果**(用户:第二个网格也自动启用了功能;「自由标记」还会在新网格里画出**别的信号的固定点**、而面板里查不到):`LaneCarry` 新增 `owner`(只有原网格**首个信号**继承功能状态),`SplitLane` 只让第一份继承;新增 `dmdetail::PruneLaneSignalItems` 在每次重建/拆分后按 `lane.signals` 过滤四类逐信号条目。② **阶跃信号的竖直沿比水平段粗**:`EmitEnvelopeBand` 的横向膨胀半径由 `ceil` 改 `floor`(1px 线宽不再膨胀 1 列)。③ **拖动浮动面板时下方另一个面板出现拖拽残影**:面板位置从 `PanelGlobalKey` 移除(曾让拖动任一面板都重画全部面板,而 MARK 面板的位置漏混 ⇒ 残影),并新增 `vacatedRects`(上一帧被移动/缩小/关闭面板腾出的矩形 → 与之相交的面板强制重画)。④ **大量固定点时拖全局光标/平移卡顿**:新增 `FreeMarkerContents` 内容缓存(取值 + 五列文本 + 信号色,键**不含视界**)。⑤ **面板外观/交互统一**:折起指示器改成与信号列表组名同款三角(去掉横线);GX 徽标与标题栏悬停**不改光标**、徽标气泡移除;标题栏**双击**滚动到该网格(单击不触发,折起只走三角)。⑥ **自由标记**:「标记透明度」滑块与光标模式解耦(始终显示);悬停胶囊恢复不透明(探针瞄着被遮住的数据点时除外);胶囊与面板五列**不再省略**(列宽按实测内容撑开)。验证:新增离屏 `.preflight/fixcheck` **37/37**(三处 A/B:恢复 `ceil` ⇒ 线宽断言失败;恢复 `owner`/`PruneLaneSignalItems` ⇒ 归属断言失败;禁用 `vacatedRects` ⇒ 残影 111px);回归 `round3check` 31/31(更新一条被用户推翻的旧断言)、`round4check` 34/34、`round5check` 5/5、`editcheck` 20/20、`convcheck` 18/18;`make release`/`make debug` 零警告;`TargetCheck.exe` **852,480 字节(832.5 KiB)**。 |
## 11. 跨设备协作须知(起源设备视角)
> **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。
@@ -3948,6 +3950,47 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ }
202. ⚠️ **固定点取值胶囊的重叠避让只在绘制侧做**(`PaintFreeMarker` 内按已放置矩形纵向错开),
命中矩形(`m_freeMarkerHits`)由绘制时登记 ⇒ 自动跟随避让后的位置,**不要**在命中侧重算。
203. ⚠️⚠️ **实验性功能是「网格级」开关,但只能由该网格的*首个信号*继承**
(`LaneCarry::owner` + `RebuildLanes` + `SplitLane`):`applyCarry` 对
`!carry.matched || !carry.owner` 直接 **return**(保持 `Lane` 默认值 = 功能全关)。
否则「合并网格 → 拆分网格」会把一个网格的功能(以及它的逐信号条目)复制到**每一个**
拆出来的网格上(用户 2026-10-21 报告的“第二个网格也自动启用了实验性功能”)。
204. ⚠️⚠️ **任何“按信号保存的条目”在网格重建/拆分后必须按 `lane.signals` 过滤**
(`dmdetail::PruneLaneSignalItems`:`meanFilterItems` / `edgeCenterItems` / `weakPeakItems` /
`freeMarkerItems` / `freeMarkerCollapsedGroups`)。不过滤的症状:拆分后的网格里**画着别的信号的
固定点**(“野生的镜像标记点”),而浮动面板里又查不到它们(面板只列本网格的信号)。
205. ⚠️⚠️ **包络带的横向(线宽)膨胀半径必须 `floor`、不能 `ceil`**
(`EmitEnvelopeBand` 的 `radius = floor(halfWidth / step)`):`ceil` 会让 1px 线宽
(`halfWidth ≈ 0.5–0.7` 设备像素)也膨胀 1 列 ⇒ 阶跃/方波的**竖直沿 3 列宽(≈3px)**
而水平段只有 ~1.25px,用户看到“竖直跳变的线宽明显大于水平持续段”;
放大到走逐点描边路径后又正常(那条路径线宽天然一致)。修复后实测 1px 线宽:水平 1px / 竖直 1px。
206. ⚠️⚠️ **面板像素缓存:面板位置绝不要混进 `PanelGlobalKey`**。它只影响该面板自己
(`PanelSlotKey` 已混 `layout.panel/title/...`)。曾把 4 类面板的 x/y 混进全局键 ⇒
拖动任一面板都会让**所有**面板重画(15 个 ≈33ms/帧),而「自由标记」面板的位置偏偏漏混
⇒ 拖它时其它面板贴旧像素,正好在下方留**拖拽残影**。
207. ⚠️⚠️ **面板“腾出的区域”必须算脏区**(`PaintEdgeCursor` 的 `vacatedRects`):
上一帧缓存过的面板若本帧移动 / 缩小 / 关闭,它腾出的矩形仍留在**其它面板**的缓存像素里,
只靠“新矩形与谁相交”的级联是漏的(新矩形可能已不与任何人相交)。做法:绘制前预扫描
`m_panelSlots`,与本次 `EdgePanelLayoutFor` 的缓存矩形比对,不相等的旧矩形一律进
`vacatedRects`,与它相交的面板全部强制重画。
208. ⚠️ **`DoubleClickIsPlainPress` 对浮动面板默认返回 true(当作普通按下);例外是标题栏**
(排除 ✕ / GX 徽标 / 折起三角):标题栏双击有“把所属网格滚动到视界内”的语义(与单击 GX 徽标一致),
单击标题栏**不**触发联动(只是准备拖面板),折起/展开只走左侧三角。
209. ⚠️ **面板标题栏悬停保持箭头光标**(只有 `EDGE_PANEL_MOVE` 拖拽中才是 `IDC_SIZEALL`);
GX 徽标悬停同样**不改光标、不弹提示气泡**(高亮保留)。
210. ⚠️⚠️ **「自由标记」的内容(取值 / 五列文本 / 信号色)必须缓存且键**不含视界**:
`PlotView::FreeMarkerContents`(`mutable m_freeMarkerContent`)只混入条目、信号样式、
数据地址与长度。大量固定点时逐帧 `SampleDisplayedSignalAt` + 拼字符串是“拖全局光标 / 平移变卡”
的主因;平移视界、拖光标时缓存必须命中(键里加 ViewMin/ViewMax 就等于没缓存)。
211. ⚠️ **「标记透明度」滑块与光标模式按钮解耦**:滑块行**始终**显示在按钮上方
(`kFreeMarkerSliderRowHeight`,面板高度算式同步);鼠标悬停在某个取值胶囊上时该胶囊恢复
全不透明,但若探针正瞄着**被该胶囊遮住的数据点**(`m_freeMarker.probe` 落在胶囊内)则不恢复
—— 否则无法看出自己瞄到了哪个采样点。
212. ⚠️⚠️ **面板里的数据列不得出现省略号**:五列宽度取「缺省列宽」与
`Lane::freeMarkerColumnWidths`(绘制该面板时把真实文本量一遍存回 `Lane`)的**较大者**,
面板宽度随之撑开;`DT_END_ELLIPSIS` 对“刚好装满”的文本仍可能截断,因此每列额外留 2px 余量。
网格内取值胶囊同理(宽度额外留 12px 余量)。
### 11.3 离屏验证方法学(本机偏好,强烈建议沿用)
- **不要占用用户的鼠标 / 键盘 / 屏幕**:优先 `.preflight/uitest`(离屏 harness:`SW_HIDE` 窗口 + 直接注入消息 + `BitBlt` 从 `m_backDc` 截图)或 `.preflight/perf`(离屏计时 / 逐像素对比)。
@@ -7429,3 +7472,98 @@ DG3 data=0 cycle=0 (空组,一个字符串通道)
---
## 本轮(2026-10-21 第三轮,本机):拆分网格功能归属 + 阶跃线宽 + 面板残影 + 自由标记性能/交互
> 用户需求(逐条):
> ① **BUG**:两个信号独立网格 → 给其中一个开实验性功能 → 「合并网格」→「拆分网格」后**第二个网格也
> 开启了实验性功能**;「自由标记」更严重:合并再拆分后第二个网格上出现**野生的固定点**
> (第一个网格标记点的镜像),但新网格的浮动面板里又没有这些点的信息。
> ② **BUG**:`TestData/SCATTER_TEST.csv` 的 step 信号在默认 1px 线宽下,**垂直阶跃段的线宽明显
> 大于水平持续段**;把跳变时刻放大后线宽又正常。
> ③ **BUG**:拖动一个浮动面板经过另一个浮动面板时,下方/其它面板上留下**拖拽残影**。
> ④ **BUG**:自由标记打上大量固定点后,左右拖动全局时间光标变卡(移动视界/滚动网格偶发卡顿)。
> ⑤ **改进**:所有浮动面板的折起指示器与信号列表组名的折叠三角**同款**;GX 徽标悬停**不改光标**、
> 不显示"是否在视界内"的浮动提示;标题栏**双击**把目标网格滚动到视界内(单击不触发);
> 标题栏悬停不改光标,只有按下左键拖动时才是十字箭头。
> ⑥ **改进**:标记透明度独立于光标模式按钮(始终显示);悬停某个固定点的数值显示时该显示恢复全不透明
> (但若鼠标正瞄准被它遮住的数据点则不恢复);固定点数值与面板数据列**不出现省略号**。
### A. 拆分网格:实验性功能只属于"首个信号"(§11.2 第 203–204 条)
- 根因两处:`RebuildLanes` 把"开着"的面板状态**按信号逐个**继承(`inheritedCarry(ref)`),而合并后的
网格里每条信号都指向同一份状态 ⇒ 拆回每信号一格时**每一格**都带着功能;`SplitLane` 则直接把
源网格的 `*.open` 复制给每一份。
- 修法:`LaneCarry` 新增 `owner`,只有 `lane.signals.front()` 为 true;`applyCarry` 对
`!carry.matched || !carry.owner` **直接 return**(保持默认 = 功能全关)。`SplitLane` 只让
`signalSlot == 0` 的那一份继承(并把 `freeMarker / freeMarkerCursor / freeMarkerPanel /
freeMarkerOpacity` 一并补上——旧实现漏了自由标记)。
- "野生标记点"的真正来源:条目是**按网格整体**携带的,拆分后每份都拿到了全部 `freeMarkerItems`。
新增 `dmdetail::PruneLaneSignalItems`(按 `lane.signals` 过滤四类逐信号条目 + 收起分组),
在 `RebuildLanes` 末尾与 `SplitLane` 的每一份上都调用。
### B. 阶跃信号的竖直沿比水平段粗(§11.2 第 205 条)
- 仅发生在**包络带路径**(可见采样数 > 绘图区宽度)。根因:横向膨胀半径用
`ceil(halfWidth / step)`,而 1px 线宽的 `strokeHalf = 1 × m_scale × 0.5 ≈ 0.625` 设备像素
⇒ `ceil` 得到 1 列 ⇒ 竖直沿从"1 个像素列"变成 3 列(≈3px),水平段却只有 ~1.25px。
放大到走逐点描边路径(线宽天然一致)后就"正常"了——与用户描述完全吻合。
- 修法:`radius = floor(halfWidth / step)`(1px 不膨胀;≥2 列宽仍按整数列膨胀,10px 仍被撑粗)。
实测(125% 缩放、1px 线宽):**水平 1px / 竖直 1px**(改前 1 / 2)。
### C. 面板拖拽残影(§11.2 第 206–207 条)
- 根因链:`PanelGlobalKey` 里混了 4 类面板的 `x/y`(拖动任一面板 ⇒ 所有面板重画,15 个 ≈33ms/帧),
但**漏了 `freeMarkerPanel`**;于是拖「自由标记」面板时其它面板照旧贴旧像素,而它们上一帧的缓存里
含被拖面板的图像 ⇒ 残影。另外只靠"新矩形与谁相交"的级联重画是漏的:面板可能已移到**不与任何
面板相交**的位置,而别处的缓存像素里还留着它的旧图像。
- 修法两条:① 面板位置**从 `PanelGlobalKey` 移除**(它只影响自己,`PanelSlotKey` 已含
`layout.panel`),只保留 `open / collapsed` 与 `edgeValue`;② 绘制前预扫描 `m_panelSlots`,
与本帧布局的缓存矩形比较,不相等(或被移除)的旧矩形进 `vacatedRects`,任何与之相交的面板强制重画。
- 附带收益:拖动单个面板不再重画全部面板(原来 15 个 ≈33ms/帧)。
### D. 大量固定点时的卡顿(§11.2 第 210 条)
- 每个固定点每帧都要 `ResolveSignal` ×2 + `SampleDisplayedSignalAt` ×2 + 拼 5 段文本 + 量宽度,
而拖光标/平移时这些值**根本没变**。新增 `PlotView::FreeMarkerContents(laneIndex)`(`mutable` 缓存),
条目 `FreeMarkerContentEntry{capsuleText, valueText, enumText, deltaText, timeText,
deltaTimeText, color, value, valid}`;键只混"条目 + 样式 + 数据地址/长度",**不含视界与鼠标**。
网格内胶囊与面板五列共用它(`FreeMarkerLabelRects` 也改读缓存)。
### E. 面板外观 / 交互(§11.2 第 208–209 条)
- 折起指示器:改成与 `SignalPanel` 组名**完全相同**的 `UiDrawTriangleIcon`(12 逻辑像素、悬停变 accent),
去掉三角后方那条短横线。
- `QueryCursor`:GX 徽标与标题栏悬停都返回 `nullptr`(箭头);`EDGE_PANEL_MOVE` 拖拽中仍是 `IDC_SIZEALL`。
- 删除 `PaintPanelBadgeTooltip`(含声明)——徽标只保留高亮,不再弹"已在视界内 / 点击滚动到该网格"。
- `OnDoubleClick`:标题栏(排除 ✕ / 徽标 / 三角)改成 `ScrollLaneIntoView` + 提示;
`DoubleClickIsPlainPress` 对面板默认 `true`,**只有标题栏**返回 false(否则双击永远到不了 `OnDoubleClick`)。
### F. 自由标记:透明度 / 悬停恢复 / 不省略(§11.2 第 211–212 条)
- 「标记透明度」滑块行**始终**显示(面板高度算式同步去掉 `freeMarkerCursor` 条件)。
- 悬停取值胶囊 ⇒ 该胶囊 `alpha = 1`;例外:`m_freeMarker.valid` 且其 `(x, y)` 落在胶囊矩形内
(正瞄着被遮住的数据点)时保持原透明度。
- 不省略:`Lane::freeMarkerColumnWidths[5]`(绘制该面板时按真实文本量宽度存回 `Lane`),列宽取
「缺省列宽」与它的大者、面板宽度随之撑开、每列多留 2px;胶囊宽度余量 10 → 12px。
### G. 验证(全离屏,未占用鼠标/键盘/屏幕)
- 新增 `.preflight/fixcheck`(`build.ps1` + `main.cpp`)**37/37**:
[A] 合并→拆分的功能归属 + "没有别人的固定点";[B] 阶跃线宽(水平 vs 竖直,跳过图例色块);
[D/E] 内容缓存命中(平移视界键不变 / 改样式键失效)、滑块始终存在且在按钮上方、
列宽 ≥ 实测文本、悬停恢复不透明、探针遮挡时保持半透明;
[C] 面板残影(拖动中的一帧 vs 作废面板图层后的重画,逐像素);
[F] 徽标/标题光标、单击不滚动、双击滚动、双击不折起。
- **A/B(判别力已证)**:`ceil` 还原 ⇒ 竖直 2px vs 水平 1px ⇒ 1 项 FAIL;`carry.owner` 还原 ⇒
功能归属断言 FAIL;禁用 `vacatedRects` ⇒ 残影 111 像素 ⇒ FAIL。恢复后 37/37。
- 回归:`round3check` **31/31**(其中「徽标显示手型光标」是用户本轮明确推翻的旧规则,已改为箭头)、
`round4check` 34/34、`round5check` 5/5(1px→2px、10px→26px 仍成立)、`editcheck` 20/20、
`convcheck` 18/18。
- `make release` / `make debug` **零警告**;`TargetCheck.exe` **852,480 字节(832.5 KiB)**。
- ⚠️ 探针坑:`Grab` 用 `GetDIBits(app.m_backDc, app.m_backBitmap, …)`;残影只存在于"另一块面板的
**缓存矩形**"里,比较区域要取两个缓存矩形的交集(面板主体不透明会盖住大半残影);直接改
`lane.*Panel.x/y` 就等价于"拖动中的某一帧"(走鼠标注入会先 `BringPanelToFront` 并改全局键,
那一帧所有面板都重画,反而看不到残影)。
+12
View File
@@ -5,6 +5,7 @@
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <array>
#include <limits>
#include <string>
#include <atomic>
@@ -1013,6 +1014,17 @@ namespace jdi
*/
std::vector<SignalRef> freeMarkerCollapsedGroups;
/**
* @brief 「自由标记」面板五列的**实测内容宽度**(设备像素,0 = 还没量过)。
*
* 用户 2026-10-21 明确要求:面板里的取值 / 枚举文本 / 增量 / 时刻 / 间隔
* **不得出现省略号(…)**。因此列宽不能再用固定值:绘制该面板时顺便把每格
* 真实文本量一遍,取最大值存到这里;布局(`EdgePanelWidth` / `EdgePanelLayoutFor`)
* 用它与缺省列宽 `kFreeMarkerColumnWidths` 的较大者,于是列宽自然贴合内容。
* ⚠️ 存的是**设备像素**(已经乘过 DPI 缩放),与字体直接对应。
*/
std::array<float, 5> freeMarkerColumnWidths{};
/** 某信号分组在「自由标记」面板里是否被收起。 */
bool FreeMarkerGroupCollapsed(const SignalRef& ref) const
{
+55 -2
View File
@@ -61,6 +61,38 @@ namespace jdi
PadValueRange(yMin, yMax);
}
/**
* @brief 清掉网格里**不属于本网格信号**的逐信号条目(均值滤波 / 中心轴 / 弱峰调整 / 自由标记)。
*
* 这些条目按信号保存,但只有“该信号就在本网格里”时才有意义。
* 网格重建(合并 → 拆分)时上一帧的条目是按网格整体携带的,若不过滤就会出现:
* 拆分出来的新网格里仍然画着**其它信号**的固定点(用户 2026-10-21 报告:
* 第二个网格上出现“野生的”自由标记点,而浮动面板里又没有任何条目)。
*
* @param [IN,OUT] lane 目标网格(`signals` 必须已经就位)。
*/
inline void PruneLaneSignalItems(Lane& lane)
{
const auto inLane = [&lane](const SignalRef& ref)
{
return std::find(lane.signals.begin(), lane.signals.end(), ref) != lane.signals.end();
};
const auto prune = [&inLane](auto& items)
{
items.erase(std::remove_if(items.begin(), items.end(),
[&inLane](const auto& item) { return !inLane(item.ref); }),
items.end());
};
prune(lane.meanFilterItems);
prune(lane.edgeCenterItems);
prune(lane.weakPeakItems);
prune(lane.freeMarkerItems);
lane.freeMarkerCollapsedGroups.erase(
std::remove_if(lane.freeMarkerCollapsedGroups.begin(), lane.freeMarkerCollapsedGroups.end(),
[&inLane](const SignalRef& ref) { return !inLane(ref); }),
lane.freeMarkerCollapsedGroups.end());
}
/**
* @brief 依据全部可见信号重新生成网格划分。
* @param [IN] visible 各信号的可见性(按 [分组][信号] 索引)。
@@ -74,6 +106,16 @@ namespace jdi
struct LaneCarry
{
bool matched = false; /**< 是否找到该信号原本所在的网格(false = 新出现的信号)。 */
/**
* @brief 该信号是否是原网格的**首个信号**(= 这个网格的“实验性功能”持有者)。
*
* ⚠️ 实验性功能(边沿计数 / 中心轴 / 均值滤波 / 弱峰调整 / 自由标记)是**网格级**开关,
* 但一个网格可能与其它信号共用(合并网格)。若把“开着”的状态按信号逐个继承,
* 那么「合并网格 → 拆分网格」之后**每个**拆出来的网格都会带着这些功能
* (用户 2026-10-21 报告:第二个网格也自动启用了实验性功能、并出现了
* 其它信号的固定点镜像)。因此只有**首个信号**继承功能状态,其余信号视为默认(关闭)。
*/
bool owner = false;
bool fitYToSelection = false;
bool edgeCount = false;
double edgeValue = 0.0;
@@ -139,9 +181,12 @@ namespace jdi
carry.freeMarkerOpacity = lane.freeMarkerOpacity;
carry.freeMarkerScroll = lane.freeMarkerScroll;
carry.freeMarkerCollapsedGroups = lane.freeMarkerCollapsedGroups;
for (const SignalRef& ref : lane.signals)
for (std::size_t signalIndex = 0; signalIndex < lane.signals.size(); ++signalIndex)
{
previousCarry.emplace_back(ref, carry);
LaneCarry entry = carry;
// 只有首个信号是这些网格级开关的持有者(见 `LaneCarry::owner` 说明)。
entry.owner = (signalIndex == 0);
previousCarry.emplace_back(lane.signals[signalIndex], entry);
}
}
const auto inheritedCarry = [&previousCarry](const SignalRef& ref)
@@ -160,6 +205,12 @@ namespace jdi
const LaneCarry carry = inheritedCarry(ref);
// 新出现的信号(之前没有网格):继承「全局 Y 轴自适应」是否开启。
lane.fitYToSelection = carry.matched ? carry.fitYToSelection : previousAllAutoFit;
// ⚠️ 实验性功能的状态**只由原网格的首个信号继承**(见 `LaneCarry::owner`):
// 否则「合并网格 → 拆分网格」会把一个网格的功能复制到全部拆分结果上。
if (!carry.matched || !carry.owner)
{
return; // 其它信号:保持 `Lane` 的默认值(全部功能关闭、条目为空)。
}
lane.edgeCount = carry.edgeCount;
lane.edgeValue = carry.edgeValue;
lane.edgeValueValid = carry.edgeValueValid;
@@ -348,9 +399,11 @@ namespace jdi
}
}
// 清掉“不属于本网格信号”的逐信号条目(合并网格时它们会被整块携带过来)。
// 统一重命名,保证编号连续。
for (std::size_t index = 0; index < lanes.size(); ++index)
{
PruneLaneSignalItems(lanes[index]);
lanes[index].name = L"网格 " + std::to_wstring(index + 1);
}
}
+52 -40
View File
@@ -419,61 +419,73 @@ namespace jdi
std::vector<Lane> pieces;
pieces.reserve(signals.size());
for (const SignalRef& ref : signals)
for (std::size_t signalSlot = 0; signalSlot < signals.size(); ++signalSlot)
{
const SignalRef& ref = signals[signalSlot];
// ⚠️ 只有**首个信号**所在的网格继承原网格的实验性功能(用户 2026-10-21 要求:
// 拆分后只有“拆分前的网格内的第一个信号”保留已应用的功能)。
// 否则拆分会把一个网格的功能(以及它的逐信号条目)复制到每一份上。
const bool primary = (signalSlot == 0);
Lane lane;
lane.autoRange = source.autoRange;
lane.yMin = source.yMin;
lane.yMax = source.yMax;
lane.fitYToSelection = source.fitYToSelection;
lane.edgeCount = source.edgeCount;
lane.edgeValue = source.edgeValue;
lane.edgeValueValid = source.edgeValueValid;
lane.edgePanel.open = source.edgePanel.open;
lane.edgePanel.collapsed = source.edgePanel.collapsed;
lane.edgePanel.x = source.edgePanel.x;
lane.edgePanel.y = source.edgePanel.y;
lane.edgeCenter = source.edgeCenter;
lane.edgeCenterPanel.open = source.edgeCenterPanel.open;
lane.edgeCenterPanel.collapsed = source.edgeCenterPanel.collapsed;
lane.edgeCenterPanel.x = source.edgeCenterPanel.x;
lane.edgeCenterPanel.y = source.edgeCenterPanel.y;
lane.weakPeak = source.weakPeak;
lane.weakPeakPanel.open = source.weakPeakPanel.open;
lane.weakPeakPanel.collapsed = source.weakPeakPanel.collapsed;
lane.weakPeakPanel.x = source.weakPeakPanel.x;
lane.weakPeakPanel.y = source.weakPeakPanel.y;
lane.meanFilter = source.meanFilter;
lane.meanFilterPanel.open = source.meanFilterPanel.open;
lane.meanFilterPanel.collapsed = source.meanFilterPanel.collapsed;
lane.meanFilterPanel.x = source.meanFilterPanel.x;
lane.meanFilterPanel.y = source.meanFilterPanel.y;
// 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 弱峰调整条目。
lane.meanFilterItems.clear();
lane.edgeCenterItems.clear();
lane.weakPeakItems.clear();
for (const Lane::MeanFilterItem& item : source.meanFilterItems)
if (primary)
{
if (item.ref == ref)
lane.edgeCount = source.edgeCount;
lane.edgeValue = source.edgeValue;
lane.edgeValueValid = source.edgeValueValid;
lane.edgePanel = source.edgePanel;
lane.edgeCenter = source.edgeCenter;
lane.edgeCenterPanel = source.edgeCenterPanel;
lane.weakPeak = source.weakPeak;
lane.weakPeakPanel = source.weakPeakPanel;
lane.meanFilter = source.meanFilter;
lane.meanFilterPanel = source.meanFilterPanel;
lane.freeMarker = source.freeMarker;
lane.freeMarkerCursor = source.freeMarkerCursor;
lane.freeMarkerPanel = source.freeMarkerPanel;
lane.freeMarkerOpacity = source.freeMarkerOpacity;
// 拆分后每个子网格只保留自己那个信号的均值滤波 / 中心轴 / 弱峰调整 / 自由标记条目。
for (const Lane::MeanFilterItem& item : source.meanFilterItems)
{
lane.meanFilterItems.push_back(item);
if (item.ref == ref)
{
lane.meanFilterItems.push_back(item);
}
}
}
for (const Lane::EdgeCenterItem& item : source.edgeCenterItems)
{
if (item.ref == ref)
for (const Lane::EdgeCenterItem& item : source.edgeCenterItems)
{
lane.edgeCenterItems.push_back(item);
if (item.ref == ref)
{
lane.edgeCenterItems.push_back(item);
}
}
}
for (const Lane::WeakPeakItem& item : source.weakPeakItems)
{
if (item.ref == ref)
for (const Lane::WeakPeakItem& item : source.weakPeakItems)
{
lane.weakPeakItems.push_back(item);
if (item.ref == ref)
{
lane.weakPeakItems.push_back(item);
}
}
for (const Lane::FreeMarkerItem& item : source.freeMarkerItems)
{
if (item.ref == ref)
{
lane.freeMarkerItems.push_back(item);
}
}
for (const SignalRef& collapsed : source.freeMarkerCollapsedGroups)
{
if (collapsed == ref)
{
lane.freeMarkerCollapsedGroups.push_back(collapsed);
}
}
}
lane.signals.push_back(ref);
PruneLaneSignalItems(lane);
pieces.push_back(std::move(lane));
}
// 用 pieces 替换原网格(插入到原位置,因此其后方的网格整体顺次后移)。