全项目大检查:修复数据层/UI 真 BUG、清理死代码与过期注释、并评估二进制体积

按「性能 / BUG / 错误 / 过期注释 / 命名与实际含义不符」四项对全仓做了一次审查,
四个只读子审查交叉核对后逐条回到源码确认再改。

一、数据层真 BUG
- MatH5:U8/U16/U32/U64 用逐字节 Cached() 拼装,而 Cached 只服务「完全落在同一个
  64KB 块内」的请求 => 跨块时静默读成 0(U32 整段为 0、U64 高 32 位为 0),分块 B-tree
  的 chunk 偏移/子节点地址被破坏且无任何报错。新增 ScalarLE():命中缓存用缓存,
  否则退回一次 ReadAt;U64 顺带从 8 次缓存查找降到 1 次。
- Mf4Loader::IsBlock 未校验链接表是否落在块内 => DataOffset 越界,随后
  block_len - (data - offset) 在 uint64 上下溢,得到一个约 2^64 的"可用字节数",
  下游 vector(usable+1) 直接 bad_alloc。同时把 offset + bytes > size 改成不产生回绕的写法。
- DecodeRawValue 的整数路径:bit_offset%8 != 0 且位宽 64 时需 9 个字节,先拼成 64 位
  再整体右移会把最高字节静默丢弃 => 改为逐字节带位偏移累加。
- cg_cycle_count 加「数据段总字节 / 记录字节」上限(压缩段按 deflate 最大膨胀比估算)。
- CsvLoader 表头分支同样跳过 #/% 注释行(否则整份文件按 1 列解析并被丢弃)。
- MatLoader:double -> int 的越界转换加了范围检查;普通对象递归加深度上限与成环检测。
- Utf8Util::BuildTicks:double -> long long 越界转换前先判商是否落在 ±9e18 内。

二、界面 BUG
- 工具栏按钮永远不显示悬停高亮:Paint 读的 Button::hover 全仓从未赋值,改为按 m_hoverIndex 判断。
- 菜单打开时"取消底层拖拽"对每个消息都执行,把菜单自己的 Target::EDIT_FIELD 也清掉
  => 菜单内联输入框无法拖拽框选。排除 EDIT_FIELD。
- GdiplusShutdown 之前未释放 PlotView 的 GDI+ 位图(Application 是进程单例,成员析构在
  WinMain 之后)=> 新增 PlotView::ReleaseGraphicsResources() 并在 Shutdown 里调用。
- WM_KILLFOCUS 现在会提交标签页/绘图区编辑并复位捕获与拖拽状态。
- WM_LBUTTONUP 不再抢走中键平移的鼠标捕获。
- TextEdit:被过滤器拒绝的字符返回 false;无选区时 Ctrl+X 只复制、不再清空整段文本。
- SignalPanel::InvalidateRows 作废编辑/待确认/改名计时等行下标状态。
- EndFilterEdit 改走 ApplyFilterText(否则输入纯空格后回车,列表仍停在筛选结果)。
- 新增 SignalBadgeVisible 作为唯一判据(原来命中只看"可见",绘制还要求"已分配网格")。
- 折起动画结束后夹取滚动量;双击分组名改名时清掉该组正在播放的折起动画。
- ScrollToSignal 复用 SignalScrollTarget(含悬浮组名避让)。
- RowNameRect 与绘制使用同一最小宽度(原来退化时返回空矩形)。
- 分隔条拖拽上限改用与 Relayout 同一公式;UpdateDragCursor 补 SPLITTER/EDIT_FIELD。
- SignalPanel 里几个守卫改为判断真正被解引用的指针;分组行 ✕ 也返回手型光标。

三、PlotView
- FindStyleOnlyLanes 改用完整的 SceneStyleEntry::operator==(原来只比 4 个字段,
  另一个网格同时改"调整"时带着旧像素被当成最新)。
- 宽基底图层不再烘焙拖拽光晕(LaneLayerHash 不含它),改由 PaintHoverHighlight 每帧叠加。
- PaintLaneLegend 提前返回前清掉本网格的命中矩形(否则能点中看不见的图例条目)。
- 面板主体改为完全不透明(消除平移后 4% 透出的旧波形,零额外代价)。
- PAN / Shift+滚轮改走 SetTimeView,使"自动点线"判据随平移重算。
- 内联输入框命中字体与绘制统一为 monoSmall。
- 派生曲线取值与窗口按 SourceIndexFor 映射(水平镜像时不再读到镜像另一端)。
- EdgePanelWidth 补 style/axis 空指针守卫;二分结果夹到 count。

四、清理
- 删除 Toolbar::ActionLabel、Theme::statusBg、PopupMenu 的 editResult 死机制、
  Toolbar::Layout 的重复 clear()、HitRow 的恒假分支、OnDoubleClick 的重复判定、
  PaintUtil 里孤立的注释块。
- 修正一批过期/错误注释(包络带"不做加宽"、LaneLayerHash 的图例说法、
  均值滤波曲线的窗口说法、样式区行数与「调整」按钮顺序、FIT_ALL 退出缩放模式、
  RemoveSignal 的 ✕ 位置、.mat/.dat 格式文档、MatH5 的"映射视图"、DataModel 的
  "点线/被移除文件"、SignalPanel 的"行高连续缩小"、TextEdit 的 PaintUtil 引用等)。

五、二进制体积评估(按要求只评估,未做压缩修改)
- release 809,472 字节;.text 652KB、.xdata+.pdata 约 75KB、.rdata 42KB、
  .rsrc 20KB、.idata 11.5KB。
- 去掉 -ld3d11 -ldwmapi -ld3dcompiler(仓内无任何调用)实测 0 字节收益
  (静态库只拉取被引用的对象)=> 删链接库不是压缩手段,只是配置整洁。
- 仓内已无 <iostream>/<sstream>/<filesystem>/<regex>/<locale> 等体积炸弹。
- 结论:已无明显的浪费可捡;要显著变小只能改变取舍(动态 CRT / UPX / 砍功能),
  需要用户决策,本轮未动。

验证
- editcheck 20/20、round3check 31/31、round4check 34/34、round5check 5/5、convcheck 18/18。
- check 136/137:唯一失败项经 git stash 回到改动前复现同一数值,属该探针自身的既有
  不稳定项(取决于前置用例留下的缓存/时序),与本轮改动无关。
- 真实数据:4 个 MF4 => 283/8/18/24 个信号;.mat 样例 8 信号、
  带无障碍对照-防夹力曲线数据 8 信号、BIG_MAT.mat 5 信号(私有内存 +741MB)。
- make release / make debug 零警告。

文档:Documents/agent_mem.md 同步 §10 变更日志、§11.2 第 171-180 条硬约束
与文末本轮小节(含体积评估数据)。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-20 16:10:29 +08:00
1 parent 30f853a361
commit 53b981a2a9
32 files changed
+691 -230

No files matched your search

+3 -2
View File
@@ -203,8 +203,9 @@ namespace jdi
/**
* @brief 从工作区移除某分组中的一条信号,并立即释放其采样内存。
*
* 用户点击信号行左侧的 ✕ 时调用:该信号一旦被确认不再需要,就应从数据层删除
* 并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,则连同分组一起移除。
* 用户点击信号行**行尾**的 ✕ 时调用(与分组行的关闭 ✕ 同列):该信号一旦被确认不再需要,
* 就应从数据层删除并释放内存(而非只是隐藏)。若它是所属分组中最后一条信号,
* 则连同分组一起移除。
*
* @param [IN] groupIndex 信号所属分组下标。
* @param [IN] signalIndex 组内信号下标。
+54 -12
View File
@@ -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:
@@ -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<float>(x - m_splitterGrabOffset) / m_scale;
const float maximum =
std::min(kMaxPanelWidth, static_cast<float>(kMinClientWidth) / m_scale);
// ⚠️ 上限必须与 `Relayout()` 里**同一个公式**(`min(kMaxPanelWidth, 客户区宽 × 0.45)`):
// 旧实现用 `min(kMaxPanelWidth, kMinClientWidth / m_scale)` 恒等于 `kMaxPanelWidth`,
// 窄窗口下能把面板拖到 400 逻辑像素,随后 `Relayout` 又悄悄夹回来 ⇒ 拖动时宽度回弹。
const float clientWidth = static_cast<float>(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;
+10
View File
@@ -603,6 +603,16 @@ namespace jdi
*/
void InvalidateDerivedCaches();
/**
* @brief 释放本组件持有的全部绘制资源(位图 / 后台线程)。
*
* ⚠️ **必须在 `GdiplusShutdown` 之前调用**:`PlotView` 持有 `gd::Bitmap`(场景/图层/
* 装饰/面板/光晕,以及并行图层 worker 各自的位图),而 `Application` 是进程级单例,
* 其成员要等到 `WinMain` 返回后才析构 —— 那时 GDI+ 已关闭,位图析构会直接访问违例
* (与拾色器当年那个 0xC0000005 同类)。`~PlotView()` 也会调一次(幂等)。
*/
void ReleaseGraphicsResources();
/**
* @brief 暂停后台派生曲线计算并作废其结果。
*
+7 -1
View File
@@ -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<int>(std::llround(cells)));
}
+15 -4
View File
@@ -867,10 +867,14 @@ namespace jdi
(cell + 1 == cells)
? viewMax
: (viewMin + cellSpan * static_cast<double>(cell + 1));
const std::size_t lo = static_cast<std::size_t>(
std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin());
const std::size_t hi = static_cast<std::size_t>(
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::size_t>(
std::lower_bound(axis.time.begin(), axis.time.end(), cellMin) - axis.time.begin()));
const std::size_t hi = std::min(
count, static_cast<std::size_t>(
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)` 每次调用都要做
// 指针解析与边界检查;放在循环体里等于**每个采样点解析两次**
+12 -13
View File
@@ -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<double>(x - m_dragLastX) / static_cast<double>(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<float>(x), static_cast<float>(y)))
{
return false;
}
// 命中光标时间读数标签:编辑该条光标的时间(优先于光标线,避免两条过近时选错)。
{
int labelWhich = 0;
+3 -2
View File
@@ -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 断点处
* 不会被"跨断点连线"填满。
*
+79 -28
View File
@@ -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<int>(m_lanes.size()))
{
const gd::RectF& laneRect = m_lanes[static_cast<std::size_t>(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<int>(
SourceIndexFor(*resolved.style, sourceCount, static_cast<std::size_t>(sampleIndex)));
const int sourceOther =
(otherIndex >= 0)
? static_cast<int>(SourceIndexFor(*resolved.style, sourceCount,
static_cast<std::size_t>(otherIndex)))
: -1;
// 本函数**只需要光标所在采样点处的派生取值**(以及双光标另一端的取值),
// 因此把请求窗口收窄到「需要的那几个下标」即可,均值滤波半径造成的取值
// 依赖由 `CurveSpanFor` 自身的保护带负责覆盖。
@@ -5041,11 +5088,11 @@ namespace jdi
// 平移视界把光标甩到很远,或光标远离当前缩放区间)时会得到一个上千万采样的
// 巨大窗口,超过缓存上限后走一次性缓冲 → **每帧重算整段滤波**,
// 实测单帧 300ms+(用户报告的"平移/拖动光标异常卡顿"的直接原因)。
std::size_t begin = static_cast<std::size_t>(sampleIndex);
std::size_t begin = static_cast<std::size_t>(sourceSample);
std::size_t end = begin + 1;
if (otherIndex >= 0)
if (sourceOther >= 0)
{
const std::size_t other = static_cast<std::size_t>(otherIndex);
const std::size_t other = static_cast<std::size_t>(sourceOther);
begin = std::min(begin, other);
end = std::max(end, other + 1);
}
@@ -5067,7 +5114,7 @@ namespace jdi
return static_cast<double>((*curvePtr)[static_cast<std::size_t>(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));
+2 -1
View File
@@ -25,7 +25,8 @@ namespace jdi
*
* 提供与常规输入框一致的行为:光标移动、Shift 选区、全选、
* 退格/删除清除选区、Ctrl+A/C/X/V、粘贴清洗,以及"首次键入整体替换"。
* 渲染由 `PaintUtil::UiDrawEditField` 消费本对象完成(含水平滚动)。
* 渲染由 `Components/Shared/UiControls.hpp` 的 `UiDrawEditField` 消费本对象完成(含水平滚动)。
* ⚠️ 剪切在没有选区时**只复制、不清空**(与常规输入框一致)。
*/
class TextEdit
{
-1
View File
@@ -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; /**< 强调描边色。 */
-7
View File
@@ -14,13 +14,6 @@ namespace jdi
namespace
{
/**
* @brief 颜色样块补充描边的不透明度阈值。
*
* 不透明度低于该值时,色块自身与浅色背景的对比度已不足,需要额外描边才能辨识;
* 高于该值时按需求不绘制描边,保持与预设色块一致的纯净外观。
*/
/**
* @brief 矩形面积超过该阈值(逻辑像素²)且为不透明色时,改用 GDI 填充。
*
+3 -4
View File
@@ -1237,13 +1237,12 @@ namespace jdi
const gd::RectF thumb = ScrollBarThumbRect();
if (thumb.Width > 0.0f && UiPointInRect(thumb, static_cast<float>(x), static_cast<float>(y)))
{
const float previous = m_scrollOffset;
// 抓住滑块:记录抓取偏移,拖动时按“指针 − 偏移”重算偏移量。
m_scrollBarDragging = true;
m_scrollBarGrabOffset = static_cast<float>(y) - thumb.Y;
m_pressedIndex = -1;
m_pressedSwatch = -1;
RebuildLayout();
(void)previous;
return true;
}
if (UiPointInRect(m_scrollBarRect, static_cast<float>(x), static_cast<float>(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;
}
+7 -11
View File
@@ -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)
-1
View File
@@ -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);
+40 -7
View File
@@ -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<gd::RectF, 3> m_modeRects; /**< 绘制样式按钮矩形。 */
// 样式区固定五行:颜色 / 样式 / 调整(系数调整·取绝对值·中心反转)/ 线宽 / 透明度。
// 样式区固定五行(不含顶部的「目标」行):颜色 / 样式 / 调整 / 线宽 / 透明度。
// 各行控件共用同一内容列宽度与左边界。
std::array<gd::RectF, 5> 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<gd::RectF, 3> m_adjustRects;
std::vector<ListRow> m_rows; /**< 列表行集合(惰性缓存)。 */
+17 -10
View File
@@ -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<int>(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<float>(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<std::size_t>(row)].groupIndex,
m_rows[static_cast<std::size_t>(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
@@ -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<std::ptrdiff_t>(index));
}
else
{
++index;
}
}
m_rowsDirty = true;
EnsureRows();
return BeginEdit(StyleEditField::GROUP_TITLE, GroupRowIndex(item.groupIndex));
@@ -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);
}
// 「移除该信号」✕:位于信号条目最右端,尺寸/样式/右边距与分组行的关闭 ✕ 完全一致。
+3 -9
View File
@@ -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; /**< 按下状态。 */
};
+8 -16
View File
@@ -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<int>(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)
+148
View File
@@ -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<int>`(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 字节收益**(静态库只拉引用到的对象);仓内已无 `<iostream>/<sstream>/<filesystem>` 等体积炸弹。验证:全部离屏探针(`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<int>(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 字节收益**(静态库只拉取被引用的对象),所以“删链接库”不是压缩手段,只是配置整洁 |
| 仓内重型头文件 | 已无 `<iostream>/<sstream>/<filesystem>/<regex>/<locale>`(唯一历史炸弹 `<filesystem>` 已被移除) |
**结论**:已经没有“明显的浪费”可捡(`-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` **零警告**。
+5 -3
View File
@@ -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 解析结果(若干时间轴块)。
+9
View File
@@ -204,6 +204,15 @@ namespace jdi
std::vector<std::uint8_t> 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;
+3 -2
View File
@@ -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` 为该组名)。
+15 -2
View File
@@ -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 解析结果(每个通道组一个时间轴块)。
+6
View File
@@ -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<char>(table.delimiter), state.fields);
+3 -3
View File
@@ -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<std::size_t>(groupIndex);
for (std::size_t index = position; index + 1 < m_groups.size(); ++index)
+51 -17
View File
@@ -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<std::uint64_t>(data[index]) << (8u * index);
}
return value;
}
std::uint16_t MatHdf5::U16(std::uint64_t offset) const
{
return static_cast<std::uint16_t>(U8(offset)) | (static_cast<std::uint16_t>(U8(offset + 1)) << 8);
}
std::uint8_t MatHdf5::U8(std::uint64_t offset) const
{
return static_cast<std::uint8_t>(ScalarLE(offset, 1));
}
std::uint32_t MatHdf5::U32(std::uint64_t offset) const
{
return static_cast<std::uint32_t>(U16(offset)) | (static_cast<std::uint32_t>(U16(offset + 2)) << 16);
}
std::uint16_t MatHdf5::U16(std::uint64_t offset) const
{
return static_cast<std::uint16_t>(ScalarLE(offset, 2));
}
std::uint64_t MatHdf5::U64(std::uint64_t offset) const
{
return static_cast<std::uint64_t>(U32(offset)) | (static_cast<std::uint64_t>(U32(offset + 4)) << 32);
std::uint32_t MatHdf5::U32(std::uint64_t offset) const
{
return static_cast<std::uint32_t>(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
+40 -5
View File
@@ -8,6 +8,7 @@
#include "MatH5.hpp"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <map>
@@ -45,6 +46,22 @@ namespace jdi
return buffer;
}
/**
* @brief 把 SDI 里读到的 `DataR2` / `TimeR2` 数值转成流下标。
*
* ⚠️ 不能直接 `static_cast<int>(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<int>(value);
}
/** @brief 去掉文件名中的扩展名。 */
std::wstring StemOf(const std::wstring& path)
{
@@ -104,12 +121,12 @@ namespace jdi
std::vector<double> numbers;
if (file.ReadNumbers(file.Member(values, "DataR2"), numbers) && !numbers.empty())
{
element.dataStream = static_cast<int>(numbers.front());
element.dataStream = StreamIndexFromDouble(numbers.front());
}
numbers.clear();
if (file.ReadNumbers(file.Member(values, "TimeR2"), numbers) && !numbers.empty())
{
element.timeStream = static_cast<int>(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<std::uint64_t>* visited = nullptr)
{
constexpr int kMaxDepth = 32;
if (depth > kMaxDepth)
{
return;
}
std::set<std::uint64_t> localVisited;
std::set<std::uint64_t>& 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
+65 -21
View File
@@ -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<std::uint64_t>(buffer[i]);
}
}
else
{
for (std::size_t i = 0; i < byteCount; ++i)
{
raw = (raw << 8) | static_cast<std::uint64_t>(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<std::uint64_t>(recordBytes) + 2ull;
if (cycles > recordBound)
{
++skippedNoLayout; // 声明的记录数远超数据块容量 ⇒ 视为损坏
if (!emitProgress())
{
cancelRequested = true;
}
continue;
}
}
const std::size_t masterIndex = SelectMasterChannel(channels, cgFlags);
if (masterIndex >= channels.size())
{
+12 -2
View File
@@ -614,8 +614,18 @@ namespace jdi
maxValue = temp;
}
const long long first = static_cast<long long>(std::ceil(minValue / step - 1.0e-9));
const long long last = static_cast<long long>(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<long long>(firstIndex);
const long long last = static_cast<long long>(lastIndex);
if (last < first)
{
return;