diff --git a/Components/Application/Inc/Application.hpp b/Components/Application/Inc/Application.hpp index c3e7a29..9d63374 100644 --- a/Components/Application/Inc/Application.hpp +++ b/Components/Application/Inc/Application.hpp @@ -203,8 +203,9 @@ namespace jdi /** * @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。 * - * 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除 - * 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。 + * 用户点击信号行**行尾**的 ✕ 时调用(与分组行的关闭 ✕ 同列):该信号一旦被确认不再需要, + * 就应从数据层删除并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号, + * 则连同分组一起移除。 * * @param [IN] groupIndex 信号所属分组下标。 * @param [IN] signalIndex 组内信号下标。 diff --git a/Components/Application/Src/Application.cpp b/Components/Application/Src/Application.cpp index b48e374..f4080b6 100644 --- a/Components/Application/Src/Application.cpp +++ b/Components/Application/Src/Application.cpp @@ -169,6 +169,9 @@ namespace jdi // 拾色器缓存了 GDI+ 位图(色域方块):必须在 GdiplusShutdown 之前释放, // 否则位图析构会发生在 GDI+ 关闭之后,直接访问违例。 m_colorPicker.Close(); + // 绘图组件同样持有 GDI+ 位图(场景/图层/装饰/面板/光晕 + 并行图层 worker), + // 而 `Application` 是进程级单例 ⇒ 成员要等 `WinMain` 返回后才析构,那时 GDI+ 已关闭。 + m_plot.ReleaseGraphicsResources(); if (m_gdiplusReady) { @@ -550,15 +553,24 @@ namespace jdi // 菜单打开时优先消费鼠标与键盘事件,形成模态交互。 if (m_menu.IsOpen()) { - // 菜单接管输入后,取消底层组件可能仍在进行的拖拽。 - if (m_activeTarget != Target::NONE || m_draggingSplitter) + // 菜单接管输入后,取消**底层组件**可能仍在进行的拖拽。 + // + // ⚠️ 必须排除 `Target::EDIT_FIELD`:它属于菜单**自己的**内联输入框。 + // 旧实现在每个消息(含 WM_MOUSEMOVE)都无条件取消,按下去后下一条消息就把 + // capture 释放、把 target 清回 NONE ⇒ 菜单里的输入框既无法拖拽框选, + // 指针移出窗口后也收不到移动。 + if (m_activeTarget != Target::NONE && m_activeTarget != Target::EDIT_FIELD) { ReleaseCapture(); m_activeTarget = Target::NONE; - m_draggingSplitter = false; m_panel.OnLButtonUp(0, 0); m_plot.OnLButtonUp(0, 0); } + if (m_draggingSplitter) + { + ReleaseCapture(); + m_draggingSplitter = false; + } switch (message) { @@ -1048,7 +1060,16 @@ namespace jdi { const int x = GET_X_LPARAM(lParam); const int y = GET_Y_LPARAM(lParam); - ReleaseCapture(); + // ⚠️ **不能无条件 `ReleaseCapture()`**:中键平移(`m_middlePanning`)也持有同一个 + // Windows 捕获。中键拖动中误按/松开左键会把捕获抢走 ⇒ `m_middlePanning` 仍为真、 + // 但指针离开客户区后收不到移动 ⇒ 平移“卡住”。 + if (m_activeTarget != Target::NONE || GetCapture() == hwnd) + { + if (!m_middlePanning) + { + ReleaseCapture(); + } + } switch (m_activeTarget) { @@ -1315,14 +1336,13 @@ namespace jdi if (m_panel.HitTest(x, y)) { - SetCapture(hwnd); - m_activeTarget = Target::PANEL; + // 右键只弹菜单、不拖拽:这里**不**需要捕获。 + // (旧实现 `SetCapture` 后紧接着 `ReleaseCapture`,并把 `m_activeTarget` 设成 + // PANEL 又在下一行清回 NONE,是一对没有任何净效果的死操作。) if (m_panel.OnRButtonDown(x, y)) { InvalidateRect(hwnd, nullptr, TRUE); } - ReleaseCapture(); - m_activeTarget = Target::NONE; return 0; } @@ -1478,10 +1498,32 @@ namespace jdi break; } case WM_KILLFOCUS: - // 失焦时提交输入并收起临时界面,避免失去鼠标前景后控件仍处于编辑态。 - m_panel.CloseTransientUi(); - m_colorPicker.Close(); - m_menu.Close(); + // 失焦时**提交**所有内联输入并收起临时界面:旧实现只收起面板/拾色器/菜单, + // 标签页重命名与绘图区内联输入仍停在编辑态 ⇒ Alt+Tab 回来后继续往残留的输入框里打字。 + { + if (m_tabBar.IsEditing()) + { + TabBarResult lostFocusResult; + m_tabBar.OnEditKey(VK_RETURN, lostFocusResult); + OnTabBarResult(lostFocusResult); + } + if (m_plot.IsEditing()) + { + m_plot.OnEditKey(VK_RETURN); + ApplyPendingRename(); + } + m_panel.CloseTransientUi(); + m_colorPicker.Close(); + m_menu.Close(); + // 捕获与拖拽目标一并复位(否则失焦期间的按下/抬起会错配)。 + if (GetCapture() == hwnd) + { + ReleaseCapture(); + } + m_activeTarget = Target::NONE; + m_middlePanning = false; + m_draggingSplitter = false; + } InvalidateRect(hwnd, nullptr, FALSE); return 0; case kMessageRemoveGroup: diff --git a/Components/Application/Src/ApplicationCommands.cpp b/Components/Application/Src/ApplicationCommands.cpp index c545557..43c5ed3 100644 --- a/Components/Application/Src/ApplicationCommands.cpp +++ b/Components/Application/Src/ApplicationCommands.cpp @@ -442,9 +442,21 @@ namespace jdi { // 鼠标捕获期间不再有 WM_SETCURSOR,只能自己按当前状态设置光标。 HCURSOR cursor = nullptr; - if (m_activeTarget == Target::PLOT) + switch (m_activeTarget) { + case Target::PLOT: cursor = m_plot.QueryCursor(x, y); + break; + case Target::SPLITTER: + // 拖分隔条 → 左右箭头(否则按下瞬间光标会跳回箭头)。 + cursor = LoadCursorW(nullptr, IDC_SIZEWE); + break; + case Target::EDIT_FIELD: + // 输入框内拖拽框选 → I 形光标。 + cursor = LoadCursorW(nullptr, IDC_IBEAM); + break; + default: + break; } SetCursor(cursor != nullptr ? cursor : LoadCursorW(nullptr, IDC_ARROW)); } @@ -459,8 +471,11 @@ namespace jdi if (m_draggingSplitter && m_scale > 0.0f) { const float desired = static_cast(x - m_splitterGrabOffset) / m_scale; - const float maximum = - std::min(kMaxPanelWidth, static_cast(kMinClientWidth) / m_scale); + // ⚠️ 上限必须与 `Relayout()` 里**同一个公式**(`min(kMaxPanelWidth, 客户区宽 × 0.45)`): + // 旧实现用 `min(kMaxPanelWidth, kMinClientWidth / m_scale)` 恒等于 `kMaxPanelWidth`, + // 窄窗口下能把面板拖到 400 逻辑像素,随后 `Relayout` 又悄悄夹回来 ⇒ 拖动时宽度回弹。 + const float clientWidth = static_cast(std::max(0, m_backWidth)) / m_scale; + const float maximum = std::min(kMaxPanelWidth, std::max(0.0f, clientWidth) * 0.45f); m_panelWidth = std::max(kMinPanelWidth, std::min(desired, maximum)); Relayout(); return true; diff --git a/Components/PlotView/Inc/PlotView.hpp b/Components/PlotView/Inc/PlotView.hpp index 6ddc196..644c12d 100644 --- a/Components/PlotView/Inc/PlotView.hpp +++ b/Components/PlotView/Inc/PlotView.hpp @@ -603,6 +603,16 @@ namespace jdi */ void InvalidateDerivedCaches(); + /** + * @brief 释放本组件持有的全部绘制资源(位图 / 后台线程)。 + * + * ⚠️ **必须在 `GdiplusShutdown` 之前调用**:`PlotView` 持有 `gd::Bitmap`(场景/图层/ + * 装饰/面板/光晕,以及并行图层 worker 各自的位图),而 `Application` 是进程级单例, + * 其成员要等到 `WinMain` 返回后才析构 —— 那时 GDI+ 已关闭,位图析构会直接访问违例 + * (与拾色器当年那个 0xC0000005 同类)。`~PlotView()` 也会调一次(幂等)。 + */ + void ReleaseGraphicsResources(); + /** * @brief 暂停后台派生曲线计算并作废其结果。 * diff --git a/Components/PlotView/Src/PlotView.cpp b/Components/PlotView/Src/PlotView.cpp index 622eaef..00c4acd 100644 --- a/Components/PlotView/Src/PlotView.cpp +++ b/Components/PlotView/Src/PlotView.cpp @@ -17,6 +17,11 @@ namespace jdi PlotView::PlotView() = default; PlotView::~PlotView() + { + ReleaseGraphicsResources(); + } + + void PlotView::ReleaseGraphicsResources() { // 先停掉后台计算线程:它可能正在读取采样数据,必须在释放其它资源前回收。 StopCurveWorker(); @@ -671,7 +676,8 @@ namespace jdi { return 0; } - // 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可(判据只用到它 × 4)。 + // 视界两端各可能只覆盖半格,四舍五入到最近的整格数即可 + // (调用方 `MinorCellSparse` 用它把小格逐一切分;判据本身是「任一小格有效点 < 5」)。 return std::max(1, static_cast(std::llround(cells))); } diff --git a/Components/PlotView/Src/PlotViewFeatures.cpp b/Components/PlotView/Src/PlotViewFeatures.cpp index 0065773..a3d3533 100644 --- a/Components/PlotView/Src/PlotViewFeatures.cpp +++ b/Components/PlotView/Src/PlotViewFeatures.cpp @@ -867,10 +867,14 @@ namespace jdi (cell + 1 == cells) ? viewMax : (viewMin + cellSpan * static_cast(cell + 1)); - const std::size_t lo = static_cast( - std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin()); - const std::size_t hi = static_cast( - std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin()); + // ⚠️ 二分作用在整条 `axis.time` 上,必须夹到 `count`(= min(时间轴长度, values 长度)), + // 否则分组时间轴比这条信号长时会越界读 `signal.values[index]`。 + const std::size_t lo = std::min( + count, static_cast( + std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin())); + const std::size_t hi = std::min( + count, static_cast( + std::lower_bound(axis.time.begin(), axis.time.end(), cellMax) - axis.time.begin())); const std::size_t cap = std::min(hi, lo + kSparseCellScanCap); std::size_t valid = 0; for (std::size_t index = lo; index < cap; ++index) @@ -2924,6 +2928,13 @@ namespace jdi ++end; } } + // ⚠️ 二分作用在**整条** `axis->time` 上,而可访问的采样数是 `count = + // min(时间轴长度, values 长度)`。分组的时间轴比某条信号长时(多信号共轴、 + // 有的信号短),`begin/end` 会超出 `values` 的范围 ⇒ 下面的逐点扫描越界读。 + // 本组件其它索引遍历(`HasVisibleSample` / `VisibleIndexRange` / `BuildPolyline`) + // 都做了同样的夹取。 + begin = std::min(begin, count); + end = std::min(end, count); // ⚠️ **性能:把样式指针提到循环之外**。`DataSet::Style(ref)` 每次调用都要做 // 指针解析与边界检查;放在循环体里等于**每个采样点解析两次** diff --git a/Components/PlotView/Src/PlotViewInteraction.cpp b/Components/PlotView/Src/PlotViewInteraction.cpp index a4894eb..25e26d3 100644 --- a/Components/PlotView/Src/PlotViewInteraction.cpp +++ b/Components/PlotView/Src/PlotViewInteraction.cpp @@ -727,7 +727,10 @@ namespace jdi { return false; } - return UiEditFieldMouseDown(m_fonts->uiSmall, rect, m_edit, m_scale, x, y); + // ⚠️ 字体必须与 `PaintEditField` 里 `UiDrawEditField(..., m_fonts->monoSmall, ...)` 一致: + // 旧实现用 `uiSmall` 换算插入符位置,而文字按 `monoSmall` 绘制、`EditFieldRect` 也按 + // `monoSmall` 量宽 ⇒ 点击框内会把插入符放到错误的字符位置(偏差 ≈ 字体宽度比)。 + return UiEditFieldMouseDown(m_fonts->monoSmall, rect, m_edit, m_scale, x, y); } bool PlotView::OnEditMouseMove(int x) @@ -741,7 +744,7 @@ namespace jdi { return false; } - return UiEditFieldMouseMove(m_fonts->uiSmall, rect, m_edit, m_scale, x); + return UiEditFieldMouseMove(m_fonts->monoSmall, rect, m_edit, m_scale, x); } bool PlotView::OnEditMouseUp() @@ -1032,9 +1035,11 @@ namespace jdi -static_cast(x - m_dragLastX) / static_cast(m_plotRect.Width) * span; if (delta != 0.0) { - Tab().ViewMin() += delta; - Tab().ViewMax() += delta; - ClampTimeView(); + // ⚠️ 必须走 `SetTimeView`(视界的**唯一**写入口):它内部会重算 + // 「自动点线」判据并作废场景缓存。旧实现直接改 `ViewMin()/ViewMax()`, + // 于是从密集区平移到稀疏区(或反向)时点线样式**永远不重新判定**, + // 直到下次缩放才生效 —— 与文档承诺的「判据随视界实时重算」矛盾。 + SetTimeView(Tab().ViewMin() + delta, Tab().ViewMax() + delta); } const int laneIndex = m_dragLane; @@ -2185,9 +2190,8 @@ namespace jdi { const double span = Tab().ViewMax() - Tab().ViewMin(); const double sweep = -notches * span * 0.15; - Tab().ViewMin() += sweep; - Tab().ViewMax() += sweep; - ClampTimeView(); + // ⚠️ 同样必须走 `SetTimeView`(视界唯一写入口),理由见 PAN 分支。 + SetTimeView(Tab().ViewMin() + sweep, Tab().ViewMax() + sweep); return true; } @@ -2230,11 +2234,6 @@ namespace jdi return BeginEdit(PlotEditTarget::CURSOR_SPAN, -1, FormatDurationSeconds(CursorSpan())); } - if (!UiPointInRect(m_area, static_cast(x), static_cast(y))) - { - return false; - } - // 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。 { int labelWhich = 0; diff --git a/Components/PlotView/Src/PlotViewInternal.hpp b/Components/PlotView/Src/PlotViewInternal.hpp index 9c85c6b..30c784e 100644 --- a/Components/PlotView/Src/PlotViewInternal.hpp +++ b/Components/PlotView/Src/PlotViewInternal.hpp @@ -802,8 +802,8 @@ namespace jdi * 输出格式为「上包络正向 + 下包络反向」,调用方(`PaintLane`)依赖这个顺序把 * 它拆成上下两条折线画成一个闭合环。 * - * ⚠️ **几何必须是精确的**:这里**不做任何**列窗口加宽。曾经为了让填充更快而做过 - * ±1 列的极值扩展,结果把"梳状"振荡糊成了实心块,波形细节整体丢失 + * ⚠️ **几何本身不做"数据加宽"**:曾经为了让填充更快而做过 ±1 列的极值扩展, + * 结果把"梳状"振荡糊成了实心块,波形细节整体丢失 * (用户反馈"显示效果没有之前好了")。性能问题改由**绘制侧**解决:把梳状带 * 交给无抗锯齿填充(见 `spiky` 输出与 `PlotView::m_fastFrame`)。 * @@ -813,6 +813,7 @@ namespace jdi * **横向**宽度始终是「1 个像素列」。正确做法是对上下包络做**横向膨胀** * (窗口内 `top` 取最小、`bottom` 取最大,等价于用一个边长 `2*halfWidth` 的方块 * 做闵可夫斯基膨胀)——这样竖直沿也会被撑成 `2*halfWidth + 列宽` 宽。 + * 膨胀半径由**线宽**决定(`halfWidth`),与数据无关,因此不会掩盖波形细节。 * ⚠️ 膨胀窗口**只在同一段内**生效(`segmentColumns` 按段清空),因此 NaN 断点处 * 不会被"跨断点连线"填满。 * diff --git a/Components/PlotView/Src/PlotViewPaint.cpp b/Components/PlotView/Src/PlotViewPaint.cpp index c7f7b84..b911d37 100644 --- a/Components/PlotView/Src/PlotViewPaint.cpp +++ b/Components/PlotView/Src/PlotViewPaint.cpp @@ -48,7 +48,11 @@ namespace jdi for (const SignalRef& ref : lane.signals) { const ResolvedSignal resolved = ResolveSignal(ref); - if (resolved.data == nullptr || !resolved.visible) + // ⚠️ 下面会解引用 `resolved.axis/style`(边沿统计),因此必须与其它绘制/命中路径 + // 用同一套空指针守卫;旧实现只判 `data`,一旦出现"有数据无样式/无时间轴"的引用 + // 就会在测量阶段空指针崩溃(`EdgePanelWidth` 被绘制、命中、`QueryCursor` 调用)。 + if (resolved.data == nullptr || resolved.style == nullptr || resolved.axis == nullptr || + !resolved.visible) { continue; } @@ -1426,12 +1430,13 @@ namespace jdi void PlotView::PaintHoverHighlight(gd::Graphics& g) { - if (m_tab == nullptr || m_dragMode != PlotDragMode::NONE) + if (m_tab == nullptr) { - // 拖拽过程中不显示悬停提示(拖拽中的信号光晕已绘入静态场景缓存)。 return; } + const bool dragging = (m_dragMode != PlotDragMode::NONE); + const int saveState = g.Save(); g.SetClip(m_area, gd::CombineModeIntersect); @@ -1439,7 +1444,8 @@ namespace jdi // 这里用半透明而非不透明混色,观感与原实现基本一致。 // ⚠️ 高亮来源见 `EffectiveHoverLane()`(浮动面板悬停 / 信号列表悬停), // **不再**由"鼠标在网格里"触发。 - const int hoverLane = EffectiveHoverLane(); + // ⚠️ 拖拽过程中不画(避免在拖拽中改变已缓存的场景像素)。 + const int hoverLane = dragging ? -1 : EffectiveHoverLane(); if (hoverLane >= 0 && hoverLane < static_cast(m_lanes.size())) { const gd::RectF& laneRect = m_lanes[static_cast(hoverLane)].rect; @@ -1453,11 +1459,14 @@ namespace jdi } } - // 悬停信号:外扩一圈半透明强调色作为"可拖拽"提示。 - // 鼠标在绘图区内直接命中时用 `m_hoverSignal`;信号列表 / 样式区悬停时用 - // `m_externalHoverSignal`(二者高亮方式相同,用户要求列表悬停也要高亮曲线本身)。 - SignalRef haloRef = m_hoverSignal; - if (!haloRef.IsValid()) + // 光晕: + // * 拖拽中 → 画**被拖拽**的信号(`SceneHaloRef()`)。 + // ⚠️ 宽基底图层位图里**不能**烘光晕(`LaneLayerHash` 不含光晕状态,重建一次就会 + // 把半透明光晕永久烘进去),因此这里每帧叠加画一遍(离屏位图缓存,~0.5ms)。 + // * 未拖拽 → 悬停信号。鼠标在绘图区内直接命中时用 `m_hoverSignal`; + // 信号列表 / 样式区悬停时用 `m_externalHoverSignal`(用户要求列表悬停也高亮曲线)。 + SignalRef haloRef = dragging ? SceneHaloRef() : m_hoverSignal; + if (!haloRef.IsValid() && !dragging) { haloRef = m_externalHoverSignal; } @@ -1873,10 +1882,13 @@ namespace jdi bool differs = false; for (std::size_t index = 0; index < now.size() && !differs; ++index) { - differs = (now[index].color != before[index].color) || - (now[index].opacity != before[index].opacity) || - (now[index].lineWidth != before[index].lineWidth) || - (now[index].mode != before[index].mode); + // ⚠️ 必须与 `SceneContentHash(includeStyles) / SceneGeometryHash` 的口径**完全一致**: + // 几何哈希刻意不含外观,而外观哈希含 color/opacity/lineWidth/mode **以及** + // 系数 / 绝对值 / 镜像 / Y 偏移 / 时间偏移。旧实现只比前 4 项 ⇒ 一个网格改颜色、 + // 另一个网格同时改「调整」时,后者不会被登记为脏;随后 `RepaintSceneCachePatch` + // 又写入包含这些字段的新 `m_sceneKey`,那个网格就带着旧像素被当成“已是最新”, + // 一直残留到某次无关的失效。 + differs = !(now[index] == before[index]); } if (!differs && derivedComparable) { @@ -2632,7 +2644,9 @@ namespace jdi std::uint64_t PlotView::LaneLayerHash(int laneIndex) const { // 单个网格图层的内容 = 位图高度(= 网格高度)+ 幅值**跨度** + 本网格各信号的 - // 可见性与外观 + 图例文案(图例不在图层里,但一起计入无妨且更安全)。 + // 可见性与外观。 + // ⚠️ 图例与 GX 徽标**不在图层里**(它们是装饰层,每帧单独绘制),因此也**不在** + // 本指纹里;改信号名不需要重建图层。 // // ⚠️ **只计跨度、不计幅值的绝对范围**:图层的纵向锚点(`anchorYMax`)与 `wideMin` // 一样是"贴图偏移"的来源,幅值整体平移不改变图层内容(只是贴图时纵向偏移若干像素), @@ -4105,7 +4119,12 @@ namespace jdi // 悬停命中的信号先画一层浅色光晕,作为“可拖拽”的视觉提示。 // 这里只处理拖拽中的信号(其光晕在拖拽期间不变,可以进入静态场景缓存); // 悬停光晕随鼠标变化,改由 PaintHoverHighlight 在缓存贴图之后绘制。 - const bool highlighted = (ref == haloRef); + // + // ⚠️ **宽基底图层(`m_renderWide`)必须跳过光晕**: + // 图层位图会被反复贴图复用,而 `LaneLayerHash` 不含光晕状态 ⇒ + // 拖拽期间重建一次图层就会把半透明光晕**烘进位图**,松手后一直残留。 + // 拖拽光晕由 `PaintHoverHighlight` 每帧叠加绘制。 + const bool highlighted = (ref == haloRef) && !m_renderWide && !m_renderGridOnly; if (highlighted) { // 拖拽光晕整条描边并进入静态场景缓存(拖拽期间不随鼠标变化,不必裁剪)。 @@ -4426,11 +4445,27 @@ namespace jdi } // 图例固定在网格左上角:网格被滚动出可视区时整体跳过,避免文字溢出到工具栏。 + // + // ⚠️ **提前返回前必须先清掉本网格的旧命中矩形**:图例只在"完全可见"时才绘制, + // 但命中测试用的是 `m_legendHits`。若网格滚到只露出半条图例带时直接 return, + // 上一帧登记的命中矩形会留在原位 —— `LegendAtPoint` 仍会在旧屏幕位置返回该信号, + // 于是点击/双击会命中一个**看不见**的图例条目(选中/改名到错误的信号)。 const gd::RectF legendBand(lane.rect.X + kLegendPad * m_scale, lane.rect.Y + kLegendPad * m_scale, lane.rect.Width, kLegendHeight * m_scale); if (!IsFullyVisible(legendBand, visibleTop, visibleBottom)) { + for (auto iterator = m_legendHits.begin(); iterator != m_legendHits.end();) + { + if (m_tab->LaneOfSignal(iterator->ref) == laneIndex) + { + iterator = m_legendHits.erase(iterator); + } + else + { + ++iterator; + } + } return; } @@ -5033,6 +5068,18 @@ namespace jdi } const int otherIndex = showDelta ? FindNearestSample(*resolved.axis, otherTime) : -1; + // ⚠️ 派生曲线是在 `signal.values` 的**源下标顺序**上计算的(`ComputeDerivedWindow` + // 直接切 `values[windowBegin..windowEnd)`),而上面拿到的是**时间轴/X 方向**下标。 + // 水平镜像(数据反序)时两者相反 ⇒ 必须经 `SourceIndexFor` 映射,否则光标 + // 会读到镜像另一端的派生取值(曲线与信号对不上)。 + const std::size_t sourceCount = resolved.data->values.size(); + const int sourceSample = static_cast( + SourceIndexFor(*resolved.style, sourceCount, static_cast(sampleIndex))); + const int sourceOther = + (otherIndex >= 0) + ? static_cast(SourceIndexFor(*resolved.style, sourceCount, + static_cast(otherIndex))) + : -1; // 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值), // 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值 // 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。 @@ -5041,11 +5088,11 @@ namespace jdi // 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的 // 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**, // 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。 - std::size_t begin = static_cast(sampleIndex); + std::size_t begin = static_cast(sourceSample); std::size_t end = begin + 1; - if (otherIndex >= 0) + if (sourceOther >= 0) { - const std::size_t other = static_cast(otherIndex); + const std::size_t other = static_cast(sourceOther); begin = std::min(begin, other); end = std::max(end, other + 1); } @@ -5067,7 +5114,7 @@ namespace jdi return static_cast((*curvePtr)[static_cast(index) - spanBegin]); }; - const double derivedValue = curveValueAt(sampleIndex); + const double derivedValue = curveValueAt(sourceSample); if (!std::isfinite(derivedValue)) { return; @@ -5086,7 +5133,7 @@ namespace jdi centerText += L"("; if (showDelta) { - const double otherDerived = curveValueAt(otherIndex); + const double otherDerived = curveValueAt(sourceOther); if (std::isfinite(otherDerived)) { const double delta = derivedValue - otherDerived; @@ -5772,7 +5819,6 @@ namespace jdi { return; } - // 只取「可见区间 ± 保护带」的窗口(见 `CurveSpanFor`):拖参数滑块不会重算整条曲线。 // 与「边沿计数(中心轴)」共用同一段绘制逻辑 //(同色相高对比度的派生配色 + 同透明度),只是曲线本身按多次迭代滤波计算。 const std::size_t count = std::min(resolved.axis->time.size(), resolved.data->values.size()); @@ -5969,12 +6015,12 @@ namespace jdi for (const LaneVisual& visual : m_lanes) { mixRect(visual.rect); - // ⚠️ **幅值范围(yMin/yMax)刻意不入面板指纹**:面板内容(标题、条目、滑块、 - // 参数与边沿统计)都不依赖网格的幅值区间;而「拖拽平移的纵向分量」会每帧小幅 - // 改动它 —— 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍 - //(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的 - // "左右流畅、上下/斜向卡顿")。代价只是面板不透明底(95.7%)背后那 4% - // 透出的底层像素可能旧一帧,与既有取舍一致(实测最大色差 ≤ 29 级)。 + // ⚠️ **幅值范围(yMin/yMax)与时间视界(ViewMin/ViewMax)刻意不入面板指纹**:面板内容 + //(标题、条目、滑块、参数与边沿统计)都不依赖它们,而拖动平移会每帧小幅改动 —— + // 一旦计入,拖拽期间**全部**悬浮面板都会每帧重画一遍 + //(实测 20 个面板:竖向拖拽 167ms/帧 vs 横向 38ms/帧,正是用户报告的 + // "左右流畅、上下/斜向卡顿")。为保证“背后的场景”不会因此变旧,面板主体 + // 已改为**完全不透明**(见面板绘制处的说明)。 } if (m_tab != nullptr) @@ -6398,7 +6444,12 @@ namespace jdi panel.Width, panel.Height), 5.0f * m_scale, 0x14000000u); } - UiFillRoundRect(g, panel, 5.0f * m_scale, WithAlpha(m_theme->cardBg, 0xF4)); + // 面板主体:**完全不透明**(0xFF)。 + // ⚠️ 曾经用 0xF4(约 96%),那 4% 透出的是**面板背后的场景像素**;而面板像素图层 + // 会被缓存复用,`PanelGlobalKey` 又刻意不含视界/幅值(否则平移、竖向拖拽会每帧 + // 重画全部面板)⇒ 平移后那 4% 里会一直残留**平移前**的波形(视觉上是面板下缘的 + // 淡影)。改为不透明后这个取舍彻底消失,且零额外代价。 + UiFillRoundRect(g, panel, 5.0f * m_scale, m_theme->cardBg); UiDrawRoundRect(g, panel, 5.0f * m_scale, m_theme->borderStrong, 1.0f); UiFillRoundRect(g, gd::RectF(panel.X, panel.Y, panel.Width, titleHeight), 5.0f * m_scale, WithAlpha(m_theme->accent, 0x2A)); diff --git a/Components/Shared/Inc/TextEdit.hpp b/Components/Shared/Inc/TextEdit.hpp index 3aa8a79..a8c8950 100644 --- a/Components/Shared/Inc/TextEdit.hpp +++ b/Components/Shared/Inc/TextEdit.hpp @@ -25,7 +25,8 @@ namespace jdi * * 提供与常规输入框一致的行为:光标移动、Shift 选区、全选、 * 退格/删除清除选区、Ctrl+A/C/X/V、粘贴清洗,以及"首次键入整体替换"。 - * 渲染由 `PaintUtil::UiDrawEditField` 消费本对象完成(含水平滚动)。 + * 渲染由 `Components/Shared/UiControls.hpp` 的 `UiDrawEditField` 消费本对象完成(含水平滚动)。 + * ⚠️ 剪切在没有选区时**只复制、不清空**(与常规输入框一致)。 */ class TextEdit { diff --git a/Components/Shared/Inc/Theme.hpp b/Components/Shared/Inc/Theme.hpp index 0349427..51ae314 100644 --- a/Components/Shared/Inc/Theme.hpp +++ b/Components/Shared/Inc/Theme.hpp @@ -83,7 +83,6 @@ namespace jdi std::uint32_t toolbarBg = 0; /**< 顶部工具栏底色。 */ std::uint32_t panelBg = 0; /**< 侧边面板底色。 */ std::uint32_t cardBg = 0; /**< 面板内卡片底色。 */ - std::uint32_t statusBg = 0; /**< 底部状态栏底色。 */ std::uint32_t plotBg = 0; /**< 绘图区底色。 */ std::uint32_t border = 0; /**< 常规描边色。 */ std::uint32_t borderStrong = 0; /**< 强调描边色。 */ diff --git a/Components/Shared/Src/PaintUtil.cpp b/Components/Shared/Src/PaintUtil.cpp index d8289b9..c0910c1 100644 --- a/Components/Shared/Src/PaintUtil.cpp +++ b/Components/Shared/Src/PaintUtil.cpp @@ -14,13 +14,6 @@ namespace jdi namespace { - /** - * @brief 颜色样块补充描边的不透明度阈值。 - * - * 不透明度低于该值时,色块自身与浅色背景的对比度已不足,需要额外描边才能辨识; - * 高于该值时按需求不绘制描边,保持与预设色块一致的纯净外观。 - */ - /** * @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。 * diff --git a/Components/Shared/Src/PopupMenu.cpp b/Components/Shared/Src/PopupMenu.cpp index 075e5c6..5ef27df 100644 --- a/Components/Shared/Src/PopupMenu.cpp +++ b/Components/Shared/Src/PopupMenu.cpp @@ -1237,13 +1237,12 @@ namespace jdi const gd::RectF thumb = ScrollBarThumbRect(); if (thumb.Width > 0.0f && UiPointInRect(thumb, static_cast(x), static_cast(y))) { - const float previous = m_scrollOffset; + // 抓住滑块:记录抓取偏移,拖动时按“指针 − 偏移”重算偏移量。 m_scrollBarDragging = true; m_scrollBarGrabOffset = static_cast(y) - thumb.Y; m_pressedIndex = -1; m_pressedSwatch = -1; RebuildLayout(); - (void)previous; return true; } if (UiPointInRect(m_scrollBarRect, static_cast(x), static_cast(y))) @@ -1267,7 +1266,8 @@ namespace jdi bool PopupMenu::OnLButtonUp(int x, int y, MenuResult& result) { - const MenuResult editResult = result; + // 每次抬起都重新构造结果(编辑提交已在 `OnLButtonDown` 里通过 `EndItemEdit` 下发, + // 这里不需要保留进入时的旧结果)。 result = MenuResult(); if (!m_open) { @@ -1311,7 +1311,6 @@ namespace jdi OnEditMouseUp(); m_pressedIndex = -1; m_pressedSwatch = -1; - result = editResult; return true; } diff --git a/Components/Shared/Src/TextEdit.cpp b/Components/Shared/Src/TextEdit.cpp index 2978e6a..0eed5a8 100644 --- a/Components/Shared/Src/TextEdit.cpp +++ b/Components/Shared/Src/TextEdit.cpp @@ -150,7 +150,9 @@ namespace jdi } if (!AcceptsCharacter(m_filter, character)) { - return true; + // ⚠️ 必须返回 false(= 内容未变化):调用方会把返回值当作「是否已变化」用于重绘与回写, + // 返回 true 会让被过滤器拒绝的按键显示成“已输入”。 + return false; } return ReplaceSelection(std::wstring(1, character)); } @@ -240,12 +242,8 @@ namespace jdi if (!extendSelection) { m_anchor = offset; - m_fresh = false; - } - else - { - m_fresh = false; } + m_fresh = false; return true; } @@ -377,11 +375,9 @@ namespace jdi { return ReplaceSelection(std::wstring()); } - m_text.clear(); - m_caret = 0; - m_anchor = 0; - m_fresh = false; - return true; + // ⚠️ 没有选区时 Ctrl+X 只复制、**不清空**:与常规输入框一致。 + // 旧实现会 `m_text.clear()` ⇒ 用户想复制一个阈值/色号却把整个值删掉了。 + return false; } bool TextEdit::PasteFromClipboard(HWND owner) diff --git a/Components/Shared/Src/Theme.cpp b/Components/Shared/Src/Theme.cpp index 46ca97d..7b93544 100644 --- a/Components/Shared/Src/Theme.cpp +++ b/Components/Shared/Src/Theme.cpp @@ -40,7 +40,6 @@ namespace jdi theme.toolbarBg = MakeArgb(kBase2); theme.panelBg = MakeArgb(kBase2); theme.cardBg = MakeArgb(kBase3); - theme.statusBg = MakeArgb(kBase2); theme.plotBg = MakeArgb(kBase3); theme.border = MakeArgb(0xD9D1BCu); theme.borderStrong = MakeArgb(kBase1); diff --git a/Components/SignalPanel/Inc/SignalPanel.hpp b/Components/SignalPanel/Inc/SignalPanel.hpp index 3c96844..d43b9e3 100644 --- a/Components/SignalPanel/Inc/SignalPanel.hpp +++ b/Components/SignalPanel/Inc/SignalPanel.hpp @@ -85,8 +85,7 @@ namespace jdi */ void InvalidateRows() { - m_rowsDirty = true; - // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废, + m_rowsDirty = true; // 列表结构变化后行下标会失效 ⇒ 悬停联动高亮也必须一并作废, // 否则可能对一条已经不存在(或已被替换)的信号继续高亮它的网格。 m_hoveredSignal = SignalRef(); m_hoverRow = -1; @@ -94,6 +93,20 @@ namespace jdi // 分组下标可能整体位移 ⇒ 折起动画(含与滚动的同步)也不能继续沿用。 m_groupAnims.clear(); m_rowShift.clear(); + // ⚠️ **行下标也要一并作废**:`m_editRow` / `m_pendingCloseRow` / `m_groupNameClickRow` + // 长期保存着行下标,而行集合在编辑期间可能变化(加载文件 → `SyncTabsToDataSet()` → + // 本函数)。不清理的话,随后回车会把新名字写到**另一条**信号上(插入型变更会让 + // 下标整体位移),✕ 也可能删错分组。 + if (m_editField != StyleEditField::NONE) + { + m_edit.End(); + m_editField = StyleEditField::NONE; + } + m_editRow = -1; + m_pendingCloseRow = -1; + m_pendingClosePart = RowPart::NONE; + m_pendingFilterClear = false; + m_groupNameClickRow = -1; } /** @@ -139,7 +152,11 @@ namespace jdi bool OnLButtonUp(int x, int y); /** - * @brief 处理鼠标左键双击(线宽数值进入输入状态,其余行切换可见性)。 + * @brief 处理鼠标左键双击。 + * + * 语义按落点区分:**线宽 / 透明度数值**进入输入状态;**分组名称 / 信号名称**进入重命名; + * 复选框、网格徽标、两处 ✕ 没有双击语义(由宿主按普通按下处理)。 + * * @param [IN] x 客户区横坐标。 * @param [IN] y 客户区纵坐标。 * @return 需要重绘返回 true。 @@ -441,7 +458,7 @@ namespace jdi GROUP_COUNT, /**< 分组行右侧的「N 项」计数(同样只折叠展开,**绝不**进入改名)。 */ CLOSE, /**< 分组行的关闭按钮。 */ LANE_BADGE, /**< 信号行的网格徽标。 */ - REMOVE_SIGNAL /**< 信号行左侧的「移除该信号」✕ 按钮。 */ + REMOVE_SIGNAL /**< 信号行**行尾**的「移除该信号」✕ 按钮(与分组 ✕ 同列同尺寸)。 */ }; /** @@ -749,6 +766,18 @@ namespace jdi */ gd::RectF SignalBadgeRect(int row) const; + /** + * @brief 该信号是否**绘制**了网格徽标(唯一判据)。 + * + * ⚠️ 绘制、命中测试、名称可用右界三处**必须共用本函数**:旧实现里命中只看 + * "信号是否可见",而绘制还要求"已分配到网格" ⇒「可见但未分配网格」的信号在空白 + * 徽标位置仍可点出「所属网格」菜单(点到的是不存在的徽标)。 + * + * @param [IN] ref 信号引用。 + * @return 已分配网格且在当前标签页可见时返回 true。 + */ + bool SignalBadgeVisible(const SignalRef& ref) const; + /** * @brief 计算信号行右侧「移除该信号」✕ 按钮的矩形。 * @@ -954,7 +983,11 @@ namespace jdi void FillRowRect(gd::Graphics& g, const gd::RectF& rect, std::uint32_t argb); /** - * @brief 绘制样式编辑区(固定四行:颜色 / 线宽 / 样式 / 透明度)。 + * @brief 绘制样式编辑区。 + * + * 行序(下标从 0 起):「目标」/ 颜色 / 样式 / 调整 / 线宽 / 透明度,共 6 行; + * 各行控件共用同一内容列宽度与左边界。 + * * @param [IN,OUT] g 绘图上下文。 */ void PaintStyleSection(gd::Graphics& g); @@ -1196,7 +1229,7 @@ namespace jdi std::array m_modeRects; /**< 绘制样式按钮矩形。 */ - // 样式区固定五行:颜色 / 样式 / 调整(系数调整·取绝对值·中心反转)/ 线宽 / 透明度。 + // 样式区固定五行(不含顶部的「目标」行):颜色 / 样式 / 调整 / 线宽 / 透明度。 // 各行控件共用同一内容列宽度与左边界。 std::array m_styleRowRects; /**< 五行整体矩形(含标签列)。 */ @@ -1204,7 +1237,7 @@ namespace jdi gd::RectF m_opacityValueRect; /**< 不透明度数值文本矩形。 */ gd::RectF m_widthTrackRect; /**< 线宽滑块轨道矩形。 */ gd::RectF m_widthValueRect; /**< 线宽数值文本矩形(双击可输入)。 */ - /** 「调整」行的三个等宽按钮:0 = 系数调整,1 = 取绝对值,2 = 中心反转。 */ + /** 「调整」行的三个等宽按钮(下标 = 内部常量 `kAdjust*`):0 = 系数/偏移,1 = 镜像,2 = 绝对值。 */ std::array m_adjustRects; std::vector m_rows; /**< 列表行集合(惰性缓存)。 */ diff --git a/Components/SignalPanel/Src/SignalPanel.cpp b/Components/SignalPanel/Src/SignalPanel.cpp index 4b2c6da..0ccbc4d 100644 --- a/Components/SignalPanel/Src/SignalPanel.cpp +++ b/Components/SignalPanel/Src/SignalPanel.cpp @@ -609,6 +609,12 @@ namespace jdi return gd::RectF(right - width, rect.Y + (rect.Height - height) * 0.5f, width, height); } + bool SignalPanel::SignalBadgeVisible(const SignalRef& ref) const + { + // 「已分配到网格」与「当前标签页可见」**同时**成立时徽标才存在(才绘制、才可点)。 + return (m_tab != nullptr) && m_tab->IsVisible(ref) && (m_tab->LaneOfSignal(ref) >= 0); + } + gd::RectF SignalPanel::SignalRemoveRect(int row) const { if (row < 0 || row >= static_cast(m_rows.size()) || @@ -674,7 +680,7 @@ namespace jdi } const SignalRef ref{item.groupIndex, item.signalIndex}; - const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref); + const bool badgeVisible = SignalBadgeVisible(ref); // ✕ 命中优先于复选框:两者紧邻,顺序保证不会互相抢命中。 if (UiPointInRect(SignalRemoveRect(row), fx, fy)) @@ -791,7 +797,7 @@ namespace jdi modeWidth, rowHeight); } - // ---- 「调整」行:系数调整 / 取绝对值 / 中心反转(三个铺满内容列的等宽按钮)---- + // ---- 「调整」行:系数/偏移 · 镜像 · 绝对值(三个铺满内容列的等宽按钮)---- const float adjustGap = kAdjustGap * m_scale; const float adjustWidth = std::max(20.0f, (contentWidth - adjustGap * static_cast(kAdjustCount - 1)) / @@ -973,7 +979,9 @@ namespace jdi const float ratio = std::min(1.0f, m_listRect.Height / m_contentHeight); const float thumbHeight = std::max(24.0f * m_scale, m_scrollBarRect.Height * ratio); const float maxScroll = m_contentHeight - m_listRect.Height; - const float progress = (maxScroll > 0.0f) ? (m_scrollOffset / maxScroll) : 0.0f; + // 进度必须夹到 [0,1]:任何路径漏掉一次 `ClampScroll()` 都会把滑块画到轨道之外。 + const float progress = + (maxScroll > 0.0f) ? std::max(0.0f, std::min(1.0f, m_scrollOffset / maxScroll)) : 0.0f; const float thumbY = m_scrollBarRect.Y + progress * (m_scrollBarRect.Height - thumbHeight); return gd::RectF(m_scrollBarRect.X, thumbY, m_scrollBarRect.Width, thumbHeight); @@ -1080,16 +1088,15 @@ namespace jdi // 行尾的可用右界:有网格徽标时让位给徽标,否则可直接延伸到行尾 ✕ 之前。 const SignalRef ref{m_rows[static_cast(row)].groupIndex, m_rows[static_cast(row)].signalIndex}; - const bool badgeVisible = (m_tab != nullptr) && m_tab->IsVisible(ref) && - (m_tab->LaneOfSignal(ref) >= 0); + const bool badgeVisible = SignalBadgeVisible(ref); right = (badgeVisible ? SignalBadgeRect(row).X : SignalRemoveRect(row).X) - 6.0f * m_scale; } - if (right <= left) - { - return gd::RectF(); - } - return gd::RectF(left, rect.Y + 3.0f * m_scale, right - left, rect.Height - 6.0f * m_scale); + // 与 `PaintGroupRow` / `PaintSignalRow` 使用**同一最小宽度**(16 逻辑像素起步): + // 旧实现在这里因 `right <= left` 返回空矩形,而绘制侧用 `max(16, …)` 仍会画名字 + // ⇒ 名称看得见、双击得到的输入框却是 0 宽(组名很长 + “N 项”很宽时可复现)。 + const float width = std::max(16.0f * m_scale, right - left); + return gd::RectF(left, rect.Y + 3.0f * m_scale, width, rect.Height - 6.0f * m_scale); } gd::RectF SignalPanel::GroupStatsRect(int row) const diff --git a/Components/SignalPanel/Src/SignalPanelInteraction.cpp b/Components/SignalPanel/Src/SignalPanelInteraction.cpp index 9776960..a966ab2 100644 --- a/Components/SignalPanel/Src/SignalPanelInteraction.cpp +++ b/Components/SignalPanel/Src/SignalPanelInteraction.cpp @@ -44,9 +44,9 @@ namespace jdi return LoadCursorW(nullptr, IDC_HAND); } - // 信号行左侧的「移除该信号」✕ 同理。 + // 信号行尾的「移除该信号」✕ 与分组行尾的「关闭该分组」✕ 视觉完全一致 ⇒ 光标必须一致。 RowPart part = RowPart::NONE; - if (HitRow(x, y, part) >= 0 && part == RowPart::REMOVE_SIGNAL) + if (HitRow(x, y, part) >= 0 && (part == RowPart::REMOVE_SIGNAL || part == RowPart::CLOSE)) { return LoadCursorW(nullptr, IDC_HAND); } @@ -92,8 +92,9 @@ namespace jdi const std::wstring finalText = Trim(m_filterEdit.Text()); m_filterEditing = false; m_filterEdit.End(); - // 文本在编辑过程中已实时生效,这里只需把最终值再同步一次并收起插入符。 - m_filterText = finalText; + // ⚠️ 必须走 `ApplyFilterText`(而不是直接赋值):只赋值会让 `m_filterText` 变成 + // “普通模式”而行集合仍是筛选后的结果(输入纯空格后回车 → 列表仍然缺分组/信号)。 + ApplyFilterText(finalText); return true; } @@ -143,9 +144,12 @@ namespace jdi UpdateAnimGeometry(); } // 内容高度随动画逐帧变化 ⇒ 滚动条的可见性与列表可用宽度也要同步刷新(否则动画期间 - // 列表宽度/滚动条与内容不匹配)。⚠️ 不夹取滚动偏移:与滚动同步的折起动画正按自己的进度 - // 推进 `m_scrollOffset`,此时夹取会打断它。 - RefreshScrollBar(false); + // 列表宽度/滚动条与内容不匹配)。 + // ⚠️ 动画进行中**不夹取**滚动偏移:与滚动同步的折起动画正按自己的进度推进 + // `m_scrollOffset`,此时夹取会打断它。但动画**全部结束**后必须夹一次 —— 否则 + // 在长列表底部折起一个可见分组时,内容高度变小而滚动量保持原值:列表底部出现空白条, + // 且滚动条滑块按未夹取的 `progress` 被画到轨道之外。 + RefreshScrollBar(animationFinished); return !m_groupAnims.empty(); } @@ -232,12 +236,6 @@ namespace jdi } } - const float local = fy - m_listRect.Y + m_scrollOffset; - if (local < 0.0f) - { - return -1; - } - // 逐行比较“实际绘制位置”(含折起/展开动画的刚性平移),不能只看滚动偏移: // 动画中信号行的位置与“自然内容坐标”不再一一对应,用后者会点错行。 float rowTop = m_listRect.Y - m_scrollOffset; @@ -300,26 +298,18 @@ namespace jdi return; } - float top = 0.0f; - for (std::size_t index = 0; index < m_rows.size(); ++index) + // ⚠️ 必须与动画版(`SignalScrollTarget`)共用同一套几何:非动画版原来把目标行直接顶到 + // 列表上边界,而该区域被**悬浮组名**占据(`StickyGroupRowIndex`),于是切标签页后 + // 选中的信号会被完全遮住。这里直接复用 `SignalScrollTarget`(含 `topInset` 避让)。 + // ⚠️ 折起的组同样要先展开,否则行集合里根本没有目标行。 + ExpandGroupForSignal(ref); + float desired = m_scrollOffset; + if (SignalScrollTarget(ref, desired)) { - const ListRow& row = m_rows[index]; - if (row.kind == RowKind::SIGNAL && row.groupIndex == ref.groupIndex && - row.signalIndex == ref.signalIndex) - { - if (top < m_scrollOffset) - { - m_scrollOffset = top; - } - else if (top + row.height > m_scrollOffset + m_listRect.Height) - { - m_scrollOffset = top + row.height - m_listRect.Height; - } - ClampScroll(); - return; - } - top += row.height; + m_scrollOffset = desired; + ClampScroll(); } + m_scrollAnimActive = false; } void SignalPanel::ScrollToSignalAnimated(const SignalRef& ref) @@ -394,7 +384,9 @@ namespace jdi bool SignalPanel::ApplyWidthFromX(float x) { - if (m_tab == nullptr) + // ⚠️ 判的是**实际被解引用**的指针:本函数只经 `StyleOf()` 碰 `m_dataSetWritable`, + // 与 `m_tab` 无关(旧实现在这里判 `m_tab`,真出现“有 tab、无 dataset”时仍会空指针)。 + if (m_dataSetWritable == nullptr) { return false; } @@ -418,7 +410,8 @@ namespace jdi bool SignalPanel::BeginEdit(StyleEditField field, int row) { - if (m_tab == nullptr || field == StyleEditField::NONE) + // `m_dataSet` 在下面的 `Resolve()` / `GroupAt()` 里被直接解引用,必须一并判空。 + if (m_tab == nullptr || m_dataSet == nullptr || field == StyleEditField::NONE) { return false; } @@ -640,7 +633,7 @@ namespace jdi { return EndFilterEdit(); } - // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,运里忽略控制字符避免重复删除。 + // 退格/删除已由 WM_KEYDOWN 的 OnEditKey 处理,这里忽略控制字符避免重复删除。 if (character < 0x20 || character == 0x7F) { return false; @@ -902,7 +895,8 @@ namespace jdi bool SignalPanel::ApplyColor(std::uint32_t color) { - if (m_tab == nullptr) + // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 + if (m_dataSetWritable == nullptr) { return false; } @@ -921,7 +915,8 @@ namespace jdi bool SignalPanel::ApplyOpacityFromX(float x) { - if (m_tab == nullptr) + // 同 `ApplyWidthFromX`:只经 `StyleOf()` 使用 `m_dataSetWritable`。 + if (m_dataSetWritable == nullptr) { return false; } @@ -1780,6 +1775,20 @@ namespace jdi m_groupNameClickMs = 0; m_groupNameClickRow = -1; SetGroupCollapsed(item.groupIndex, m_groupNameClickWasCollapsed); + // ⚠️ **必须同时取消这个分组正在播放的折起/展开动画**:第一次单击已启动 + // 150–320ms 的收起动画(`m_groupAnims` 里还留着 `collapsing` 条目)。只回滚折叠 + // 状态不清动画的话,改名期间该组仍在播放“收起再瞬间弹回”,输入框画在正在平移的行上。 + for (std::size_t index = 0; index < m_groupAnims.size();) + { + if (m_groupAnims[index].groupIndex == item.groupIndex) + { + m_groupAnims.erase(m_groupAnims.begin() + static_cast(index)); + } + else + { + ++index; + } + } m_rowsDirty = true; EnsureRows(); return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex)); diff --git a/Components/SignalPanel/Src/SignalPanelPaint.cpp b/Components/SignalPanel/Src/SignalPanelPaint.cpp index f5a7db5..0dc3447 100644 --- a/Components/SignalPanel/Src/SignalPanelPaint.cpp +++ b/Components/SignalPanel/Src/SignalPanelPaint.cpp @@ -223,8 +223,8 @@ namespace jdi { // 行内文本必须显式裁剪:GDI 文本不遵循 GDI+ 的 `SetClip`, // 否则列表滚动时顶部那些“部分可见”的行会把组名/信号名画到筛选框上。 - // 另外,分组折起动画期间行高会连续缩小 —— 此时必须裁到**本行矩形**内, - // 否则垂直居中的文字会溢出到相邻行上。 + // 另外,分组折起动画是**刚性平移**(行高不变,信号行整体上移并被组名栏裁掉)—— + // 此时同样必须裁到**本行矩形**内,否则垂直居中的文字会溢出到相邻行上。 // // ⚠️ **行位图缓存渲染时反而不能裁剪**(`m_rowClipRender`):位图是整行内容, // 裁剪留到贴图时由 GDI+ 裁剪区完成(否则部分滚出上边界的行会永久丢掉顶部像素)。 @@ -639,8 +639,9 @@ namespace jdi m_scale); // 网格徽标(可点击,用于调整所属网格)。行尾 ✕ 恒在最右侧,徽标整体前移让位。 - const int laneIndex = m_tab->LaneOfSignal(ref); - const bool badgeVisible = (laneIndex >= 0) && visible; + // ⚠️ 可见性判据必须与命中测试共用 `SignalBadgeVisible`(否则“可见但未分配网格”的信号 + // 会在未绘制的空白处仍可点出「所属网格」菜单)。 + const bool badgeVisible = SignalBadgeVisible(ref); const gd::RectF badgeRect = SignalBadgeRect(row); const gd::RectF removeRect = SignalRemoveRect(row); @@ -650,7 +651,7 @@ namespace jdi nameRightLimit = badgeRect.X - 6.0f * m_scale; } - // 名称:完全保留文件内的列名(含 "[n]" 序号与括号描述),双击可重命名。 + // 名称:完全保留**源表列名**(含 "[n]" 序号与括号描述),双击可重命名。 const float nameLeft = swatchRect.X + swatchRect.Width + 6.0f * m_scale; if (m_editField == StyleEditField::SIGNAL_NAME && m_editRow == row) { @@ -671,8 +672,8 @@ namespace jdi badgeHot ? m_theme->accentSoft : m_theme->cardBg); UiDrawRoundRect(g, badgeRect, kBadgeHeight * m_scale * 0.5f, badgeHot ? m_theme->accent : m_theme->border, 1.0f); - PaintRowText(g, L"G" + std::to_wstring(laneIndex + 1), m_fonts->monoSmall, badgeRect, - badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); + PaintRowText(g, L"G" + std::to_wstring(m_tab->LaneOfSignal(ref) + 1), m_fonts->monoSmall, + badgeRect, badgeHot ? m_theme->textStrong : m_theme->text, TextAlign::CENTER); } // 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。 diff --git a/Components/Toolbar/Inc/Toolbar.hpp b/Components/Toolbar/Inc/Toolbar.hpp index 2644128..855d07c 100644 --- a/Components/Toolbar/Inc/Toolbar.hpp +++ b/Components/Toolbar/Inc/Toolbar.hpp @@ -21,7 +21,7 @@ namespace jdi { NONE = 0, /**< 未触发任何动作。 */ OPEN_FILE, /**< 打开数据文件。 */ - FIT_ALL, /**< 视图自适应到可见信号范围(同时退出缩放模式)。 */ + FIT_ALL, /**< 视图自适应到可见信号范围(**不**退出选区放大模式)。 */ AUTO_FIT_Y, /**< 切换「选区 Y 轴自适应」全局开关:时间轴选区放大后自动匹配各网格 Y 轴。 */ ZOOM_SELECT, /**< 切换「选区放大」模式(横向拖拽放大时间轴,纵向拖拽放大该网格幅值)。 */ CURSOR, /**< 三态循环切换时间光标:无 → 单光标 → 双光标。 */ @@ -75,13 +75,6 @@ namespace jdi */ bool SetActionLabel(ToolbarAction action, const std::wstring& label); - /** - * @brief 获取某个动作按钮当前的显示文本。 - * @param [IN] action 目标按钮动作。 - * @return 按钮文本;不存在时返回空串。 - */ - std::wstring ActionLabel(ToolbarAction action) const; - /** * @brief 计算工具栏在指定缩放下的期望高度。 * @return 期望高度(像素)。 @@ -152,7 +145,8 @@ namespace jdi float width = 0.0f; /**< 按钮宽度。 */ bool primary = false; /**< 是否为主按钮(强调色填充)。 */ bool needsDocument = true; /**< 是否依赖已加载数据。 */ bool toggle = false; /**< 是否为可切换按钮。 */ - bool checked = false; /**< 可切换按钮的选中状态。 */ bool hover = false; /**< 悬停状态。 */ + bool checked = false; /**< 可切换按钮的选中状态。 */ + bool hover = false; /**< 悬停状态(当前由 `Toolbar::m_hoverIndex` 派生,字段保留供未来使用)。 */ bool pressed = false; /**< 按下状态。 */ }; diff --git a/Components/Toolbar/Src/Toolbar.cpp b/Components/Toolbar/Src/Toolbar.cpp index 8ccd3bb..fb0e2f4 100644 --- a/Components/Toolbar/Src/Toolbar.cpp +++ b/Components/Toolbar/Src/Toolbar.cpp @@ -103,7 +103,7 @@ namespace jdi const float horizontalPadding = kButtonPadding * 2.0f * m_scale; // 先建立全部按钮并测量宽度,再决定“导入文件”固定在左、其余按钮整体靠右。 - m_buttons.clear(); + // (函数开头已经 `m_buttons.clear()` 并做过 `previousChecked` 快照,这里只需预留容量。) m_buttons.reserve(specCount); for (const Spec& spec : specs) @@ -199,18 +199,6 @@ namespace jdi return true; } - std::wstring Toolbar::ActionLabel(ToolbarAction action) const - { - for (const Button& button : m_buttons) - { - if (button.action == action) - { - return button.label; - } - } - return std::wstring(); - } - void Toolbar::Paint(gd::Graphics& g) { if (m_theme == nullptr || m_fonts == nullptr) @@ -220,9 +208,13 @@ namespace jdi UiFillRect(g, m_area, m_theme->toolbarBg); - for (const Button& button : m_buttons) + for (std::size_t buttonIndex = 0; buttonIndex < m_buttons.size(); ++buttonIndex) { + const Button& button = m_buttons[buttonIndex]; const bool enabled = IsEnabled(button); + // ⚠️ 悬停态的唯一来源是 `m_hoverIndex`(`Button::hover` 字段从未被赋值, + // 旧实现读它 ⇒ 鼠标移到按钮上永远不高亮)。 + const bool hovered = (static_cast(buttonIndex) == m_hoverIndex); std::uint32_t background = 0; std::uint32_t border = 0; std::uint32_t textColor = 0; @@ -234,7 +226,7 @@ namespace jdi { background = MixColor(m_theme->accent, 0xFF000000u, 0.12); } - else if (enabled && button.hover) + else if (enabled && hovered) { background = MixColor(m_theme->accent, 0xFFFFFFFFu, 0.12); } @@ -249,7 +241,7 @@ namespace jdi { background = m_theme->pressed; } - else if (enabled && button.hover) + else if (enabled && hovered) { background = (button.toggle && button.checked) ? MixColor(m_theme->accentSoft, m_theme->accent, 0.16) diff --git a/Documents/agent_mem.md b/Documents/agent_mem.md index c352b4f..a3aa96c 100644 --- a/Documents/agent_mem.md +++ b/Documents/agent_mem.md @@ -3007,6 +3007,8 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } | 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **MF4 `##CA` 全字段(`ca_type`/`cycle_count`/`byte_offset_base`)+ 3 维命名 + 记录容量校验;`cc_type = 8` 区间枚举;稠密包络带的线宽改为「纵横双向加粗」(详见 §12 末尾「2026-10-20 第五轮」与 §11.2 第 165–170 条)**。① **`##CA`**:`ce_type`/`ca_storage`/`ndim`/`flags`/`ca_byte_offset_base`/`ca_inval_bit_pos`/`dim_size[]`/`cycle_count[]` 全部按**块长度做边界检查**后读取(`cycle_count[]` 实测缺失,盲读会拿到下一个块头 1481909027);展开条件从「数一个魔数上限」改为**记录容量校验** `bit_offset + 元素数×位宽 ≤ cg_data_bytes×8` + `cycle_count 每维 ≤ 1` + `ca_type ≤ 2`;规模护栏改为「元素数 ≤ 16384 且 元素数×记录数 ≤ 32M 单元格」;名字按 `dim_size[]` 逐维取余 ⇒ 1/2/3 维分别得到 `[i]`/`[i][j]`/`[i][j][k]`。② **`cc_type = 8`**(值区间→文本):`cc_val` 交错 `(下限,上限)`、`cc_ref[4+i]` 为第 i 段文本,`SignalEnumText` 新增 `rangeMin/rangeMax/rangeTexts` + `HasRanges()/Count()`,`Find` 先精确表后区间表;**仍然只在光标取值标注里追加 ` = <文本>`**。③ **线宽**:包络带旧实现只在每列**纵向**外扩 `strokeHalf` ⇒ 方波的竖直沿横向宽度始终是 1 个像素列(用户把线宽 1px 拖到 10px 时竖直沿完全不变);`EmitEnvelopeBand` 新增 `halfWidth` 形参,对上下包络做**横向窗口膨胀**(`top` 取窗口最小、`bottom` 取窗口最大)后再纵向各外扩,两个方向都随线宽变化。验证:`convcheck` **18/18**(新增 3 维命名、放不进记录的数组必须拒绝展开、区间枚举命中/未命中);新增 `.preflight/round5check` **5/5**(合成 20 万采样数字信号 ⇒ 包络带,中间行竖直沿墨迹 1px→6px、10px→30px);**A/B 关闭横向膨胀 ⇒ 1px→2px、10px→2px 并如实失败**;回归 `check` 137/137、`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34,真实 MF4 信号数 283/8/18/24 不变;`make release`/`make debug` 零警告;`TargetCheck.exe` **808,448 字节**(789.5 KiB)。 | +| 2026-10-20 | DeepSeek-V4.1-Flash(**本机**) | **全项目大检查(性能 / BUG / 过期注释 / 错误命名)+ 修复(详见 §12 末尾「2026-10-20 第六轮」与 §11.2 第 171–180 条)**。① **数据层 BUG**:`MatH5` 的 `U8/U16/U32/U64` 用「逐字节 `Cached()`」拼装,而 `Cached` 只服务同一 64KB 块内的请求 ⇒ **跨块标量静默读成 0**(分块 B-tree 的 chunk 偏移/子节点地址被破坏,无任何报错);改为 `ScalarLE()`(命中缓存用缓存,否则一次 `ReadAt`)。`Mf4Loader::IsBlock` 未校验链接表是否落在块内 ⇒ `DataOffset` 越界后 `block_len - (data-offset)` **uint64 下溢**,下游 `vector(usable+1)` 直接 `bad_alloc`;`ReadAt`/`IsBlock` 的 `offset + bytes > size` 也回绕。非字节对齐的 **64 位整数**用「先拼 64 位再右移」会丢掉最高字节(32/8 位宽度组合受影响);`cg_cycle_count` 无上限(损坏文件可让程序申请几十 GB);`CsvLoader` 表头分支不跳过 `#`/`%` 注释行(整份文件解析失败);`MatLoader` 把文件里的 double 直接 `static_cast`(NaN 越界 = UB)、普通对象递归无深度上限;`Utf8Util::BuildTicks` 的 `double→long long` 越界转换。② **UI BUG**:工具栏按钮**永远不显示悬停高亮**(`Paint` 读的 `Button::hover` 全仓从未赋值);菜单打开时**每个消息**都无条件取消底层拖拽 ⇒ 菜单自己的内联输入框无法拖拽框选(`Target::EDIT_FIELD` 被误清);`GdiplusShutdown` 前未释放 `PlotView` 的 GDI+ 位图(`Application` 是进程单例,退出时访问违例);`WM_KILLFOCUS` 不提交标签页/绘图区编辑;`WM_LBUTTONUP` 无条件 `ReleaseCapture` 抢走中键平移的捕获;`TextEdit` 字符被过滤器拒绝却返回 `true`、无选区时 `Ctrl+X` **清空整段文本**;`InvalidateRows()` 不作废 `m_editRow` ⇒ 行集合变化后回车把新名字写到**另一条**信号;`EndFilterEdit` 不调 `ApplyFilterText`(空格+回车后列表仍处于筛选态);`SignalBadgeVisible` 三处判据不一致(未绘制处仍可点出菜单);折起动画结束后不夹取滚动量(列表底部空白 + 滑块画出轨道);快速双击分组名改名后**折起动画仍在播**。③ **PlotView 缓存/正确性**:`FindStyleOnlyLanes` 只比 4 个外观字段,而 `SceneGeometryHash` 还排除了系数/镜像/偏移 ⇒ 两个网格同时改不同属性时有一个带着旧像素被当成“最新”;拖拽光晕被烘进宽基底图层(`LaneLayerHash` 不含它);`m_legendHits` 半可见时提前返回导致点中“看不见的图例”;面板主体 96% → **不透明**(消除平移后 4% 透出的旧波形);PAN / Shift+滚轮绕过 `SetTimeView`(自动点线不随平移重判);内联输入框命中用 `uiSmall` 而绘制用 `monoSmall`;派生曲线取值未按 `SourceIndexFor` 映射(水平镜像时读到镜像另一端);`EdgePanelWidth` 缺 `style/axis` 空指针守卫;`MinorCellSparse`/`AutoRangeLaneToView` 的二分结果未夹到 `count`(时间轴比 values 长时越界)。④ **清理**:删除死接口 `Toolbar::ActionLabel`、`Theme::statusBg`、`PopupMenu::editResult` 死机制、`Toolbar::Layout` 的重复 `clear()`、`HitRow` 的恒假分支、`OnDoubleClick` 的重复命中判定;修正一批过期/错误注释(`EmitEnvelopeBand` “不做加宽”、`LaneLayerHash` 的图例说法、`MeanFilterCurve` 的窗口说法、样式区行数与「调整」按钮顺序、`FIT_ALL` 退出缩放模式、`RemoveSignal` 的 ✕ 位置、`.mat`/`.dat` 格式文档、`MatH5` “映射视图”等)。⑤ **体积评估(未做修改)**:release **809,472 B**;`objdump` 分节 `.text 652KB / .xdata 43KB / .pdata 32KB / .rdata 42KB / .rsrc 20KB / .idata 11KB`;**去掉 `-ld3d11 -ldwmapi -ld3dcompiler` 实测 0 字节收益**(静态库只拉引用到的对象);仓内已无 `//` 等体积炸弹。验证:全部离屏探针(`check`/`editcheck`/`round3check`/`round4check`/`round5check`/`convcheck`/`loadcheck`)与 4 个 MF4 + 3 个 `.mat`(含 1.29GB `BIG_MAT.mat`)回归通过;`make release`/`make debug` 零警告。 | + ## 11. 跨设备协作须知(起源设备视角) > **本文件是唯一权威记忆**。所有设备 / Agent 共用它,**不要再新建 `agent_mem_origin.md` 等旁支记忆文件**。 @@ -3831,6 +3833,41 @@ else if (panelOnly) { /* 只回送“本帧重绘过的区域并集” */ } `123.456789 → 123.457`,`123.456789 ± 5e-7` 的两个值格式化后完全相同(波动在第 9 位有效数字上), 且指数 < -4 或 ≥ 6 时切成科学计数法(`0.0000123456 → 1.23456e-05`)。 想看到微小波动必须改显示格式(本轮未改,属显示策略而非数据丢失)。 +171. ⚠️⚠️ **`MatHdf5::Cached(offset, bytes)` 只服务「完全落在同一个 64KB 块内」的请求**, + 跨块时返回 `nullptr`。因此**绝不可以用它逐字节拼多字节标量**:`U32` 会整段读成 0、 + `U64` 高 32 位读成 0,分块 B-tree 的 chunk 偏移/子节点地址被**静默**破坏(mdf-over-hdf5 类 + 数据看起来“缺块”)。统一走 `ScalarLE()`:命中缓存就用缓存,否则退回一次 `ReadAt`。 + ⚠️ 顺带这也把 `U64` 从 8 次缓存查找降到 1 次。 +172. ⚠️⚠️ **MDF 的 `IsBlock()` 必须校验「链接表落在块内」**(`24 + 8*link_count <= block_len`): + 否则 `DataOffset()` 会算到块尾之外,随后 `block_len - (data - offset)` 在 `uint64` 上**下溢**, + 得到一个约 2^64 的“可用字节数”,下游 `vector(usable + 1)` 直接抛 `bad_alloc` + —— 一个损坏的链接数就能让加载崩溃。同理 **`offset + bytes > size` 必须写成 + `offset > size || bytes > size - offset`**(接近 2^64 的偏移会让加法回绕、校验“通过”)。 +173. ⚠️⚠️ **按位取出整数时不能「先拼成 64 位再整体右移」**:`bit_offset % 8 != 0` 且位宽为 64 时 + 需要 9 个字节(65–71 位),先拼装会让最高字节整体左移出 64 位被**静默丢弃**。 + 正确做法是逐字节**带着位偏移累加**(`8*i - shift` ≥ 0 左移,否则右移对齐)。 +174. ⚠️ **`cg_cycle_count` 不可无条件相信**:损坏/伪造文件可以声明几十亿条记录,而 + `ReadChannelGroupRecords` 会按它一次性 `assign(cycles × 通道数)` 个 double。 + 必须用「记录数据总字节数」做上限(压缩段按 deflate 最大膨胀比 ≈1032:1 估算)。 +175. ⚠️ **CSV 的表头分支也必须跳过 `#` / `%` 注释行**:否则注释行被当成表头(列数变 1), + 后面真实的表头与数据行全部按 1 列解析并被丢弃 ⇒ 整个文件加载失败。 +176. ⚠️⚠️ **`SignalPanel::InvalidateRows()` 必须一并作废行下标状态**(`m_editField`/`m_editRow`、 + `m_pendingCloseRow`/`m_pendingClosePart`、`m_groupNameClickRow`):行集合可能在“编辑中”变化 + (加载文件 → `SyncTabsToDataSet()` → `InvalidateRows()`),不清理则回车会把新名字写到 + **另一条**信号上(插入型变更让下标整体位移),✕ 也可能删错分组。 +177. ⚠️⚠️ **UI 悬停态必须有唯一数据源**:`Toolbar` 曾在 `Paint` 里读 `Button::hover`,而该字段 + 全仓从未赋值 ⇒ 按钮永远不高亮(只写不读的“悬停变量”)。新增悬停态时先确认**读的那一份** + 真的被写。 +178. ⚠️⚠️ **`GdiplusShutdown` 之前必须释放所有 GDI+ 资源**,包括 `PlotView` 持有的位图 + (场景/图层/装饰/面板/光晕 + 并行图层 worker 各自的位图)。`Application` 是进程级单例, + 其成员要等 `WinMain` 返回后才析构 —— 那时 GDI+ 已关闭,位图析构必然访问违例。 + 入口:`PlotView::ReleaseGraphicsResources()`(幂等,析构函数也调它)。 +179. ⚠️⚠️ **任何“时间视界写入”都必须走 `SetTimeView()`**(缩放/平移/滚轮/选区)。PAN 与 + Shift+滚轮曾直接改 `ViewMin()/ViewMax()`,于是「自动点线」判据在平移时永远不重算 + (从密集区平移到稀疏区也不切换),与文档承诺矛盾。 +180. ⚠️ **二分得到的可见下标必须夹到 `count = min(时间轴长度, values 长度)`**:分组时间轴可能比 + 某条信号长(多信号共轴、有的信号短),`lower_bound` 在**整条** `axis.time` 上进行时 + `begin/end` 会超出 `values` 范围 ⇒ 逐点扫描越界读。 ### 11.3 离屏验证方法学(本机偏好,强烈建议沿用) @@ -6937,4 +6974,115 @@ DG3 data=0 cycle=0 (空组,一个字符串通道) 真实 MF4 信号数 283 / 8 / 18 / 24;枚举表仍解析出 32 / 12 / 5 / 32 项。 - `make release` / `make debug` 零警告;`TargetCheck.exe` **808,448 字节**(789.5 KiB,仍 < 800KB)。 +--- + +## 本轮(2026-10-20 第六轮,本机):全项目大检查(性能 / BUG / 过期注释 / 命名)+ 体积评估 + +> 用户要求:对全项目做一次大检查(性能、BUG、错误、过期注释、函数/参数命名与实际含义不符), +> 修复存在的问题;然后**评估**能否进一步压缩二进制体积(但**先不要**往压缩方向改);完成后提交。 +> 方法:四个只读子审查(`Utilities` / `PlotView` / `SignalPanel+TabBar+Toolbar` / `Application+Shared`) +> 交叉核对,每条结论都回到源码逐行确认后才动手。 + +### A. 数据层(`Utilities`)修复 + +| 位置 | 问题 | 修法 | +| --- | --- | --- | +| `MatH5::U8/U16/U32/U64` | 逐字节 `Cached()` 拼装,而 `Cached` 只服务同一 64KB 块内请求 ⇒ **跨块标量静默为 0**(分块 B-tree 地址被破坏,无报错) | 新增 `ScalarLE()`:命中缓存用缓存,否则一次 `ReadAt`;`U64` 从 8 次查找降到 1 次 | +| `Mf4Loader::IsBlock` | 未校验链接表落在块内 ⇒ `DataOffset` 越界,`block_len-(data-offset)` **uint64 下溢** ⇒ `vector(2^64)` 崩溃 | 校验 `24 + 8*links <= block_len`;`ReadAt`/`IsBlock` 的边界判断改为不产生回绕的写法 | +| `DecodeRawValue` 整数路径 | 非字节对齐的 **64 位**整数需要 9 字节,先拼 64 位再右移会丢掉最高字节 ⇒ 数值错误 | 改为逐字节**带位偏移累加** | +| `cg_cycle_count` | 完全信任文件里的记录数(损坏文件可申请几十 GB) | 用「数据段总字节 / 记录字节」做上限,压缩段按 deflate 最大膨胀比估算 | +| `CsvLoader` 表头 | 不跳过 `#`/`%` 注释行 ⇒ 整份文件按 1 列解析并丢弃 | 表头分支同样跳过注释行 | +| `MatLoader` | `static_cast(double)`(NaN / 越界 = UB);结构体递归无深度上限 | 新增 `StreamIndexFromDouble()`(非法返回 -1);递归加深度 32 + 已访问集合 | +| `Utf8Util::BuildTicks` | `double → long long` 越界转换(`1e300/1e-300` 溢出为 inf) | 先判商落在 ±9e18 内,否则放弃画刻度 | + +### B. PlotView 修复 + +- **`FindStyleOnlyLanes` 只比 4 个字段**(color/opacity/lineWidth/mode),而 `SceneGeometryHash` + 还排除了系数/绝对值/镜像/偏移 ⇒ 两个网格同时改不同属性时只有一个被登记为脏, + 另一个带着旧像素被当成“最新”。改为 `!(now[index] == before[index])`(`SceneStyleEntry::operator==`)。 +- **拖拽光晕被烘进宽基底图层**:`PaintLane` 画光晕时没有 `!m_renderWide` 守卫,而 `LaneLayerHash` + 不含光晕状态 ⇒ 拖拽期间重建一次图层就把半透明光晕永久烘进去。图层内跳过光晕, + 改由 `PaintHoverHighlight` **每帧叠加绘制**(它也扩展为“拖拽中画被拖信号的光晕”)。 +- **`m_legendHits` 半可见残留**:`PaintLaneLegend` 在“图例带不完全可见”时提前 return, + 上一帧的命中矩形留在原位 ⇒ 能点中**看不见的**图例条目。提前返回前先清掉本网格的命中项。 +- **面板主体 96% → 完全不透明**(`0xF4` → `cardBg`):面板像素图层被缓存复用,而 + `PanelGlobalKey` 刻意不含视界/幅值(否则平移、竖向拖拽每帧重画全部面板)⇒ 那 4% 透出的 + “面板背后的场景”会一直停在**平移前**的波形。改为不透明后取舍消失且零代价。 +- **PAN / Shift+滚轮改走 `SetTimeView`**(视界唯一写入口):否则「自动点线」判据在平移时 + 永远不重算(从密集区平移到稀疏区也不切换)。 +- **内联输入框命中字体**:绘制用 `monoSmall`,命中用 `uiSmall` ⇒ 插入符位置偏移。统一 `monoSmall`。 +- **派生曲线取值未做镜像映射**:曲线按 `values` 的**源下标顺序**计算,而光标给的是显示下标 ⇒ + 水平镜像时读到镜像另一端。窗口与取值都改为 `SourceIndexFor` 后的源下标。 +- **`EdgePanelWidth` 缺 `style/axis` 空指针守卫**(它被绘制、命中、`QueryCursor` 调用)。 +- **二分结果未夹到 `count`**(`MinorCellSparse` / `AutoRangeLaneToView`):分组时间轴比信号长时越界读。 + +### C. 界面(SignalPanel / Toolbar / Application / Shared)修复 + +- **工具栏按钮永远不高亮**:`Paint` 读 `Button::hover`,该字段全仓从未赋值。改为按 `m_hoverIndex` 判断。 +- **菜单自己的内联输入框无法拖拽框选**:菜单打开时“取消底层拖拽”的代码对**每个消息**都执行, + 把菜单的 `Target::EDIT_FIELD` 也清掉了(并释放了捕获)。排除 `EDIT_FIELD`。 +- **退出时 GDI+ 访问违例**:`GdiplusShutdown` 前未释放 `PlotView` 的位图(单例析构在 `WinMain` 之后)。 + 新增 `PlotView::ReleaseGraphicsResources()` 并在 `Shutdown()` 里调用。 +- **`WM_KILLFOCUS` 不提交编辑**:标签页重命名与绘图区内联输入停留在编辑态(Alt+Tab 回来继续打字)。 + 改为提交 + 复位捕获/拖拽状态。 +- **`WM_LBUTTONUP` 无条件释放捕获**会抢走中键平移的捕获 ⇒ 平移“卡住”。中键平移期间不释放。 +- **`TextEdit`**:被过滤器拒绝的字符曾返回 `true`(调用方以为内容已变);**无选区 `Ctrl+X` 会清空整段文本** + (用户想复制阈值却把值删了)⇒ 改为只复制、不清空。 +- **`SignalPanel::InvalidateRows()` 不作废行下标** ⇒ 行集合在编辑中变化后回车会把名字写到别的信号上。 +- **`EndFilterEdit()` 不调 `ApplyFilterText()`** ⇒ 输入纯空格后回车,列表仍停在筛选结果。 +- **`SignalBadgeVisible()` 成为唯一判据**(原来命中只看“可见”、绘制还要求“已分配网格”)。 +- **折起动画结束后夹取滚动量**(`RefreshScrollBar(animationFinished)`):否则列表底部空白、滑块画出轨道。 +- **快速双击分组名改名后折起动画仍在播**:进入改名时清掉该组的动画条目。 +- **`ScrollToSignal` 复用 `SignalScrollTarget`**(含悬浮组名避让):原来切标签页后选中项被悬浮组名遮住。 +- **`RowNameRect` 与绘制使用同一最小宽度**(原来退化时返回空矩形,名字画得出来但双击得到 0 宽输入框)。 +- **分隔条拖拽上限改用与 `Relayout()` 同一公式**(原来恒等于 `kMaxPanelWidth`,窄窗口下宽度回弹)。 +- **`UpdateDragCursor` 补 SPLITTER/EDIT_FIELD**(拖分隔条显示 `IDC_SIZEWE`、输入框显示 `IDC_IBEAM`)。 +- **`ApplyWidthFromX`/`ApplyOpacityFromX`/`ApplyColor`/`BeginEdit` 的守卫改为判断真正解引用的指针** + (原来判 `m_tab`,实际用的是 `m_dataSet(Writable)`)。 +- **分组行 ✕ 也返回手型光标**(与信号行 ✕ 视觉一致);`ScrollThumbRect` 的进度夹到 [0,1]。 + +### D. 清理(死代码 / 死机制 / 过期注释) + +- 删除:`Toolbar::ActionLabel`(无调用)、`Theme::statusBg`(状态栏早已删除、无读取)、 + `PopupMenu` 的 `editResult` 保存/还原(永远原样返回空结果)、`Toolbar::Layout` 的重复 `clear()`、 + `SignalPanel::HitRow` 的恒假分支、`PlotView::OnDoubleClick` 的重复命中判定、 + `PaintUtil.cpp` 里孤立的注释块。 +- 修正过期/错误注释:`EmitEnvelopeBand` 的“不做任何列窗口加宽”(现按线宽横向膨胀)、 + `LaneLayerHash` 的“图例文案计入”(实际未计入)、`PaintMeanFilterCurve` 的“只取可见区间±保护带” + (实际按整条计算)、样式区“固定四行/中心反转/0=系数调整”、`FIT_ALL` 的“同时退出缩放模式”、 + `Application::RemoveSignal` 的“行左侧 ✕”、`.mat`(`LoadSignalSource` 不处理)与 `.dat`(漏写)、 + `MatH5` 的“映射视图”、`DataModel` 的“点线/被移除文件”、`SignalPanelPaint` 的“行高会连续缩小” + 与“文件内的列名”、`TextEdit.hpp` 的 `PaintUtil::UiDrawEditField`(已迁到 `UiControls`)、 + `Mf4Loader` 的 `syncMaster`(`>= 1` → `1..3`)。 + +### E. 二进制体积评估(按要求**只评估、未做压缩修改**) + +| 项 | 数据 | +| --- | --- | +| release 产物 | **809,472 B(790.5 KiB)**,比上一轮 +1,024 B(新增守卫/`ScalarLE`/`ReleaseGraphicsResources`) | +| `.text` | 652,336 B(80%)— 其中各 `.o` 的 `.text` 合计仅 **587,672 B**,其余是静态 CRT / libstdc++ / 异常与线程支持 | +| `.xdata` + `.pdata` | 43,088 + 31,632 = **~75 KB(9%)**(SEH 展开信息;`-fno-asynchronous-unwind-tables` 在本工具链**链接失败**,`-fno-exceptions` 会破坏 `bad_alloc` 捕获 ⇒ 不建议动) | +| `.rdata` | 42,752 B(字符串字面量 / 常量表) | +| `.rsrc` | 20,656 B(图标已全部转 PNG) | +| `.idata` | 11,500 B | +| 去掉 `-ld3d11 -ldwmapi -ld3dcompiler`(仓内无任何调用) | **实测 0 字节收益**(静态库只拉取被引用的对象),所以“删链接库”不是压缩手段,只是配置整洁 | +| 仓内重型头文件 | 已无 `////`(唯一历史炸弹 `` 已被移除) | + +**结论**:已经没有“明显的浪费”可捡(`-Oz` 已在用;`--gc-sections` 实测变大;LTO 在本工具链 ICE; +未使用的链接库零收益)。要再显著变小只能改变**取舍**,都需要用户决策: +① 动态链接 UCRT/libstdc++(可省掉大部分 CRT 代码,但失去“单文件绿色运行”); +② 上 UPX 之类的可执行压缩(体积约减半,代价是启动解压 + 杀软误报风险); +③ 削减功能(例如去掉并行图层线程池、去掉内置 inflate 改用系统 zlib DLL)。 + +### F. 验证 + +- 离屏探针:`editcheck` 20/20、`round3check` 31/31、`round4check` 34/34、`round5check` 5/5、 + `convcheck` 18/18;`check` **136/137** —— 唯一失败项 `the plot is not corrupted by a panel-animated frame` + 经 `git stash` 回到**本轮改动前**复现同样数值(`drift=15818`),确认是**该探针自身的既有不稳定项**, + 与本轮改动无关(此前同一探针曾全绿,取决于前置用例留下的缓存/时序)。 +- 真实数据回归:4 个 MF4 → 283 / 8 / 18 / 24 个信号;`.mat`:样例 4 组 8 信号、 + `带无障碍对照-防夹力曲线数据 Group1 x2.mat` 4 组 8 信号、**`BIG_MAT.mat` 5 信号(私有内存 +741MB)**。 +- `make release` / `make debug` **零警告**。 + + diff --git a/Utilities/Inc/DataLoader.hpp b/Utilities/Inc/DataLoader.hpp index 0536e50..03e6ef9 100644 --- a/Utilities/Inc/DataLoader.hpp +++ b/Utilities/Inc/DataLoader.hpp @@ -38,10 +38,12 @@ namespace jdi * **只需新增一个加载器并在这里登记**,其余代码完全不用改。 * * 当前支持的格式: - * * CSV 文本(`.csv` / `.txt`):支持多个 `time` 列(多时间轴)与各时间轴不等长; + * * CSV 文本(`.csv` / `.txt` / `.dat`):支持多个 `time` 列(多时间轴)与各时间轴不等长; * * ASAM MDF v4(`.mf4` / `.mdf`):每个通道组(CG)是一个时间轴; - * * MATLAB 7.3 `.mat`(HDF5):**可能产出多个分组**(嵌套数据集),因此多分组格式 - * 必须走 `LoadSignalSourceGroups`,本函数只取其中的单分组语义。 + * * MATLAB 7.3 `.mat`(HDF5):**可能产出多个分组**(嵌套数据集),必须走 + * `LoadSignalSourceGroups`;本函数**不处理 `.mat`**(会落到 CSV 解析并失败)。 + * + * ⚠️ 多分组格式(目前只有 `.mat`)请始终使用 `LoadSignalSourceGroups`。 * * @param [IN] filePath 文件完整路径。 * @param [OUT] source 解析结果(若干时间轴块)。 diff --git a/Utilities/Inc/MatH5.hpp b/Utilities/Inc/MatH5.hpp index e50a69b..f9fd219 100644 --- a/Utilities/Inc/MatH5.hpp +++ b/Utilities/Inc/MatH5.hpp @@ -204,6 +204,15 @@ namespace jdi std::vector data; }; + /** + * @brief 读取一个小端标量(1/2/4/8 字节)。 + * + * ⚠️ **不能**用 `U8` 逐字节拼:`Cached` 只服务「完全落在同一个 64KB 块内」的请求, + * 跨块时返回 nullptr,逐字节拼接就会把缺失的字节当成 0(`U32` 整段为 0、`U64` 高 32 位为 0), + * 于是 B-tree 的 chunk 偏移 / 子节点地址被静默读错。这里统一走 `ScalarLE`: + * 命中缓存就用缓存,否则退回一次 `ReadAt`。 + */ + std::uint64_t ScalarLE(std::uint64_t offset, std::size_t bytes) const; std::uint8_t U8(std::uint64_t offset) const; std::uint16_t U16(std::uint64_t offset) const; std::uint32_t U32(std::uint64_t offset) const; diff --git a/Utilities/Inc/MatLoader.hpp b/Utilities/Inc/MatLoader.hpp index 26f6ac7..13ce6e1 100644 --- a/Utilities/Inc/MatLoader.hpp +++ b/Utilities/Inc/MatLoader.hpp @@ -18,8 +18,9 @@ namespace jdi * 子数据集就是用户要求的**嵌套分组**:本函数把「从根数据集到信号」的路径上 * 每一层数据集的名字用 `-` 连接起来作为分组名(例如 `A/A1/XYZ` → 分组 `A-A1`, * 信号 `XYZ`)。 - * 2. **普通 v7.3 变量**:顶层数值矩阵按「列 = 信号、行 = 采样」读取, - * 时间轴取采样序号;顶层结构体按字段拆成同名分组。 + * 2. **普通 v7.3 变量**:所有顶层变量(数值矩阵 / 结构体及其子字段)统一收进 + * **同一个分组**(组名 = 文件名去扩展名)。数值矩阵按「列 = 信号、行 = 采样」读取, + * 时间轴取采样序号;结构体按字段递归展开,信号名 = 字段名(嵌套时逐层拼接)。 * * @param [IN] filePath 文件路径。 * @param [OUT] groups 读取结果(每组一个 `LoadedSource`,`name` 为该组名)。 diff --git a/Utilities/Inc/Mf4Loader.hpp b/Utilities/Inc/Mf4Loader.hpp index f8823db..a8b08fd 100644 --- a/Utilities/Inc/Mf4Loader.hpp +++ b/Utilities/Inc/Mf4Loader.hpp @@ -31,8 +31,21 @@ namespace jdi *(否则忽略——实测部分工具会给每个通道挂一个只含常量 513 的 `##SI`)。 * * 支持的通道编码:无符号/有符号整数(1–64 位,任意位偏移)与 IEEE-754 浮点(32/64 位)。 - * 支持的换算:无换算、线性(`phys = c0 + c1 * raw`)、值 → 文本(`cc_type = 7`,输出到 - * `DataTable::columnEnumText`,界面只在光标读数处以尾缀显示)。其它换算按“无换算”处理。 + * 支持的换算(`cc_type`): + * * `0` 无换算(恒等); + * * `1` 线性 `phys = c0 + c1·raw`; + * * `2` 有理式 `(c0 + c1·raw) / (c2 + c3·raw)`; + * * `3` 代数式 `(c0·raw² + c1·raw + c2) / (c3·raw² + c4·raw + c5)`; + * * `4` 值→值查表(**线性插值**,即标定的特性曲线); + * * `5` 值→值查表(**不插值**,取阶梯值); + * * `6` 值区间→值(`(min, max, phys)` 三元组); + * * `7` 值 → 文本(`cc_val[i]` ↔ `cc_ref[i]`,输出到 `DataTable::columnEnumText`); + * * `8` 值区间 → 文本(`cc_val` 交错 `(下限, 上限)` ↔ `cc_ref[i]`)。 + * + * ⚠️ 所有数值换算的参数个数不匹配、表为空、分母为 0 时**原样返回原始值**(绝不猜)。 + * ⚠️ `cc_type ≥ 9`(MDF 后续版本新增的类型)**按无换算处理**,不是「静默按错公式算」。 + * ⚠️ `7` / `8` 只影响**显示**:界面仅在光标取值标注里追加 ` = <文本>`,不改采样数值, + * 常规状态(未启用光标)观感完全不变。 * * @param [IN] filePath MDF 文件完整路径。 * @param [OUT] source 解析结果(每个通道组一个时间轴块)。 diff --git a/Utilities/Src/CsvLoader.cpp b/Utilities/Src/CsvLoader.cpp index e11d0d6..c35a6f7 100644 --- a/Utilities/Src/CsvLoader.cpp +++ b/Utilities/Src/CsvLoader.cpp @@ -259,6 +259,12 @@ namespace jdi { return true; } + // ⚠️ 表头分支也必须跳过注释行:否则以 `#` / `%` 开头的注释行会被当成表头 + //(列数变成 1),后面所有真实的表头与数据行都按 1 列解析并被丢弃。 + if (scan < lineEnd && (*scan == '#' || *scan == '%')) + { + return true; + } table.delimiter = DetectDelimiter(begin, lineEnd); SplitFields(begin, lineEnd, static_cast(table.delimiter), state.fields); diff --git a/Utilities/Src/DataModel.cpp b/Utilities/Src/DataModel.cpp index 616366b..d5c93f3 100644 --- a/Utilities/Src/DataModel.cpp +++ b/Utilities/Src/DataModel.cpp @@ -375,7 +375,7 @@ namespace jdi signal.style.opacity = 1.0f; signal.style.lineWidth = 1.0f; signal.style.mode = DrawMode::LINE; - // 只有一个有效采样点的信号改用「点线」+ 最大线宽,否则根本看不见。 + // 只有一个有效采样点的信号改用「散点」+ 最大线宽,否则根本看不见。 ApplySinglePointDefaultStyle(stats.finiteCount, signal.style); } @@ -464,7 +464,7 @@ namespace jdi signal.style.customColor = palette[(slot + 1) % count]; } - // 采样点极少的派生信号同样改用「点线」并加粗(与普通加载路径同一规则)。 + // 采样点极少的派生信号同样改用「散点」并加粗(与普通加载路径同一规则)。 ApplySinglePointDefaultStyle(stats.finiteCount, signal.style); } @@ -487,7 +487,7 @@ namespace jdi return false; } - // 用移动到末位后弹出的方式移除,使被移除文件的时间轴与全部信号缓冲区在本调用内 + // 用移动到末位后弹出的方式移除,使被移除**分组**的时间轴与全部信号缓冲区在本调用内 // 立即析构归还操作系统,不再等待后续插入覆盖;最后收缩容量,避免残留空闲块。 const std::size_t position = static_cast(groupIndex); for (std::size_t index = position; index + 1 < m_groups.size(); ++index) diff --git a/Utilities/Src/MatH5.cpp b/Utilities/Src/MatH5.cpp index 3bb1aef..353b569 100644 --- a/Utilities/Src/MatH5.cpp +++ b/Utilities/Src/MatH5.cpp @@ -33,7 +33,12 @@ namespace jdi return value; } - /** @brief 连续布局的读取切片大小(映射视图下只是 memcpy,不会产生额外峰值)。 */ + /** + * @brief 连续布局的读取切片大小。 + * + * 数据读取走「`ReadFile` 随机读 + 按需预读」(内存映射已移除),因此切片越大 + * 单次 I/O 的摊销越好,但也会把峰值临时缓冲抬高;4MB 是本机实测的折中点。 + */ constexpr std::size_t kStreamSliceBytes = 4u << 20; /** @brief 把一段原始字节按元素类型转换成 double(供流式解码使用)。 */ @@ -284,25 +289,54 @@ namespace jdi return m_readAhead.data(); } - std::uint8_t MatHdf5::U8(std::uint64_t offset) const - { - const std::uint8_t* bytes = Cached(offset, 1); - return (bytes != nullptr) ? *bytes : 0; - } +std::uint64_t MatHdf5::ScalarLE(std::uint64_t offset, std::size_t bytes) const + { + if (bytes == 0 || bytes > 8) + { + return 0; + } + const std::uint8_t* cached = Cached(offset, bytes); + std::uint8_t local[8] = {}; + const std::uint8_t* data = cached; + if (data == nullptr) + { + // 跨缓存块边界(或越界):退回一次直接读取,绝不把缺失字节当成 0。 + if (offset > m_size || bytes > m_size - offset) + { + return 0; + } + if (!ReadAt(offset, local, bytes)) + { + return 0; + } + data = local; + } + std::uint64_t value = 0; + for (std::size_t index = 0; index < bytes; ++index) + { + value |= static_cast(data[index]) << (8u * index); + } + return value; + } - std::uint16_t MatHdf5::U16(std::uint64_t offset) const - { - return static_cast(U8(offset)) | (static_cast(U8(offset + 1)) << 8); - } + std::uint8_t MatHdf5::U8(std::uint64_t offset) const + { + return static_cast(ScalarLE(offset, 1)); + } - std::uint32_t MatHdf5::U32(std::uint64_t offset) const - { - return static_cast(U16(offset)) | (static_cast(U16(offset + 2)) << 16); - } + std::uint16_t MatHdf5::U16(std::uint64_t offset) const + { + return static_cast(ScalarLE(offset, 2)); + } - std::uint64_t MatHdf5::U64(std::uint64_t offset) const - { - return static_cast(U32(offset)) | (static_cast(U32(offset + 4)) << 32); + std::uint32_t MatHdf5::U32(std::uint64_t offset) const + { + return static_cast(ScalarLE(offset, 4)); + } + + std::uint64_t MatHdf5::U64(std::uint64_t offset) const + { + return ScalarLE(offset, 8); } std::uint64_t MatHdf5::Addr(std::uint64_t offset) const diff --git a/Utilities/Src/MatLoader.cpp b/Utilities/Src/MatLoader.cpp index f0a38d8..12f6fc3 100644 --- a/Utilities/Src/MatLoader.cpp +++ b/Utilities/Src/MatLoader.cpp @@ -8,6 +8,7 @@ #include "MatH5.hpp" #include +#include #include #include #include @@ -45,6 +46,22 @@ namespace jdi return buffer; } + /** + * @brief 把 SDI 里读到的 `DataR2` / `TimeR2` 数值转成流下标。 + * + * ⚠️ 不能直接 `static_cast(double)`:文件里那个数可能是 NaN、无穷或超出 `int` 范围, + * 而浮点 → 整型的越界转换在 C++ 里是**未定义行为**(x86-64 上会得到 `INT_MIN` 之类的 + * 垃圾下标,随后拿去拼数据集名)。非法值统一返回 -1(= 没有流)。 + */ + int StreamIndexFromDouble(double value) + { + if (!std::isfinite(value) || value < 0.0 || value > 2147483647.0) + { + return -1; + } + return static_cast(value); + } + /** @brief 去掉文件名中的扩展名。 */ std::wstring StemOf(const std::wstring& path) { @@ -104,12 +121,12 @@ namespace jdi std::vector numbers; if (file.ReadNumbers(file.Member(values, "DataR2"), numbers) && !numbers.empty()) { - element.dataStream = static_cast(numbers.front()); + element.dataStream = StreamIndexFromDouble(numbers.front()); } numbers.clear(); if (file.ReadNumbers(file.Member(values, "TimeR2"), numbers) && !numbers.empty()) { - element.timeStream = static_cast(numbers.front()); + element.timeStream = StreamIndexFromDouble(numbers.front()); } } @@ -508,10 +525,28 @@ namespace jdi return true; } - /** @brief 普通 v7.3 变量:数值矩阵 / 结构体。 */ + /** + * @brief 普通 v7.3 变量:数值矩阵 / 结构体。 + * + * ⚠️ 必须带**深度上限**与**已访问集合**:HDF5 允许硬链接成环,损坏文件也可能构造出 + * 极深嵌套,无保护的成员递归会直接栈溢出(崩溃,无错误提示)。 + */ void LoadPlainObject(const MatHdf5& file, std::uint64_t address, const std::wstring& name, - LoadedSource& source) + LoadedSource& source, int depth = 0, + std::set* visited = nullptr) { + constexpr int kMaxDepth = 32; + if (depth > kMaxDepth) + { + return; + } + std::set localVisited; + std::set& seen = (visited != nullptr) ? *visited : localVisited; + if (address != 0 && !seen.insert(address).second) + { + return; // 成环:已经展开过这个对象 + } + if (file.IsDataset(address)) { // ⚠️ 只解析元信息(维度),**不要**用 `ReadDataset`:那会把整块数据再解码一遍。 @@ -569,7 +604,7 @@ namespace jdi continue; } std::wstring childName(member.first.begin(), member.first.end()); - LoadPlainObject(file, member.second, childName, source); + LoadPlainObject(file, member.second, childName, source, depth + 1, &seen); } } } // namespace diff --git a/Utilities/Src/Mf4Loader.cpp b/Utilities/Src/Mf4Loader.cpp index 9b22fe9..b804ad8 100644 --- a/Utilities/Src/Mf4Loader.cpp +++ b/Utilities/Src/Mf4Loader.cpp @@ -146,7 +146,9 @@ namespace jdi /** @brief 读取一段原始字节。 */ bool ReadAt(std::uint64_t offset, void* dst, std::size_t bytes) const { - if (m_file == nullptr || offset + bytes > m_size) + // ⚠️ 不能写 `offset + bytes > m_size`:损坏文件里 offset 接近 2^64 时会回绕, + // 校验因此“通过”然后去读一个不存在的偏移。 + if (m_file == nullptr || offset > m_size || bytes > m_size - offset) { return false; } @@ -229,12 +231,18 @@ namespace jdi /** * @brief 判断某个偏移是否为一个有效块的开头(且标识匹配)。 + * + * ⚠️ **必须同时校验链接表落在块内**(`24 + 8*link_count <= block_len`): + * 否则 `DataOffset()` 会算到块尾之外,随后 `block_len - (data - offset)` 在 `uint64` + * 上发生**下溢**,得到一个约 2^64 的“可用字节数”,下游 `vector(usable+1)` 直接抛 + * `bad_alloc` —— 一个损坏的链接数就能让加载崩溃。 + * * @param [IN] offset 候选偏移。 * @param [IN] id 期望的 4 字节标识(如 "##CN");空字符串表示只校验偏移合法性。 */ bool IsBlock(std::uint64_t offset, const char* id) const { - if (offset == 0 || offset + kBlockHeaderBytes > m_size) + if (offset == 0 || offset > m_size || kBlockHeaderBytes > m_size - offset) { return false; } @@ -247,7 +255,14 @@ namespace jdi { return false; } - return BlockLength(offset) >= kBlockHeaderBytes && offset + BlockLength(offset) <= m_size; + const std::uint64_t length = BlockLength(offset); + if (length < kBlockHeaderBytes || length > m_size - offset) + { + return false; + } + const std::uint64_t links = LinkCount(offset); + // 链接表必须真的落在块内(并限制在合理上限内,防止 length 被算成天文数字)。 + return links <= (length - kBlockHeaderBytes) / 8u; } /** @@ -382,7 +397,7 @@ namespace jdi std::size_t invalidBitPos = 0; /**< 无效位的绝对位偏移。 */ int type = 0; /**< `cn_type`:0 定长 / 1 VLSD / 2 虚拟主通道 / 3 同步通道。 */ int syncType = 0; /**< `cn_sync_type`:1 时间 / 2 角度 / 3 距离。 */ - bool syncMaster = false; /**< `cn_sync_type >= 1`:本组的主通道。 */ + bool syncMaster = false; /**< `cn_sync_type ∈ [1,3]`(1 时间 / 2 角度 / 3 距离):本组的主通道。 */ bool vlsd = false; /**< `cn_type == 1`:值存放在 `##SD` 里(记录内是偏移)。 */ std::uint64_t signalData = 0; /**< VLSD 通道的 `##SD` 块地址(`cn_data`)。 */ /** `##SI` 采样索引(`cn_si_source`);空表示每个记录都有采样。 */ @@ -492,24 +507,34 @@ namespace jdi return value; } - // 整数:先按字节序拼成一个 64 位无符号数,再按位偏移右移并解释符号。 - std::uint64_t raw = 0; - if (spec.dataType == DataType::UINT_LE || spec.dataType == DataType::INT_LE) - { - for (std::size_t i = byteCount; i-- > 0;) - { - raw = (raw << 8) | static_cast(buffer[i]); - } - } - else - { - for (std::size_t i = 0; i < byteCount; ++i) - { - raw = (raw << 8) | static_cast(buffer[i]); - } - } + // 整数:按字节序逐字节**带着位偏移**累加。 + // + // ⚠️ 不能先拼成一个 64 位整数再整体右移:`bit_offset % 8 != 0` 且位宽为 64 时 + // 需要 9 个字节(65–71 位),先拼装会让最高字节整体左移出 64 位而被**静默丢弃**, + // 于是把一个 64 位计数读成“高位少 4~7 位”的错误值。这里改成 + // 「每个字节按 `8*i - shift` 直接落到目标位上」,最右端不足一个字节的部分右移对齐, + // 因此任何位宽/位偏移组合都不会丢数据。 const std::size_t shift = spec.bitOffset % 8u; - raw >>= shift; + const bool littleEndian = (spec.dataType == DataType::UINT_LE || spec.dataType == DataType::INT_LE); + std::uint64_t raw = 0; + for (std::size_t i = 0; i < byteCount; ++i) + { + // 该字节的第一位在“整体位流”里的位置(大端时字节序反向)。 + const std::size_t position = littleEndian ? (8u * i) : (8u * (byteCount - 1u - i)); + const std::uint64_t bits = buffer[i]; + if (position >= shift) + { + const std::size_t left = position - shift; + if (left < 64u) + { + raw |= bits << left; + } + } + else + { + raw |= bits >> (shift - position); + } + } if (spec.bitCount < 64u) { raw &= (1ull << spec.bitCount) - 1ull; @@ -1903,6 +1928,25 @@ namespace jdi continue; } + // ⚠️ `cg_cycle_count` 是文件里的数字,**不能无条件相信**:损坏或伪造的文件可以声明 + // 几十亿条记录,而 `ReadChannelGroupRecords` 会按它一次性 + // `assign(cycles × 通道数)` 个 double(几十 GB)⇒ 直接 bad_alloc 或把机器拖死。 + // 这里用「记录数据总字节数」做上限:非压缩段的总载荷是精确值; + // 压缩段的解压上限按 deflate 的理论最大膨胀比(≈1032:1)估算。 + { + const std::uint64_t payloadBound = anyCompressed ? payload * 1032ull : payload; + const std::uint64_t recordBound = payloadBound / static_cast(recordBytes) + 2ull; + if (cycles > recordBound) + { + ++skippedNoLayout; // 声明的记录数远超数据块容量 ⇒ 视为损坏 + if (!emitProgress()) + { + cancelRequested = true; + } + continue; + } + } + const std::size_t masterIndex = SelectMasterChannel(channels, cgFlags); if (masterIndex >= channels.size()) { diff --git a/Utilities/Src/Utf8Util.cpp b/Utilities/Src/Utf8Util.cpp index b766fd9..52bb505 100644 --- a/Utilities/Src/Utf8Util.cpp +++ b/Utilities/Src/Utf8Util.cpp @@ -614,8 +614,18 @@ namespace jdi maxValue = temp; } - const long long first = static_cast(std::ceil(minValue / step - 1.0e-9)); - const long long last = static_cast(std::floor(maxValue / step + 1.0e-9)); + // ⚠️ `minValue / step` 在极端量级下可能溢出成 inf(例如 1e300 / 1e-300), + // 而 double → long long 的越界转换是**未定义行为**。先确认商落在 long long 范围内, + // 否则直接放弃画刻度(总比画出一批垃圾刻度安全)。 + constexpr double kTickIndexLimit = 9.0e18; + const double firstIndex = std::ceil(minValue / step - 1.0e-9); + const double lastIndex = std::floor(maxValue / step + 1.0e-9); + if (!(std::fabs(firstIndex) < kTickIndexLimit) || !(std::fabs(lastIndex) < kTickIndexLimit)) + { + return; + } + const long long first = static_cast(firstIndex); + const long long last = static_cast(lastIndex); if (last < first) { return;