「阶数 N」改称「迭代次数 N」;修复大文件放大后的平移卡顿与越界三角样式

一、命名(用户要求)
- 实验性功能里的「阶数 N」全部改为「迭代次数 N」(面板滑块标签、均值滤波/中心轴
  的数值框标签、构建提示语)。
- 相关标识符与函数参数统一改名:order → iterations(Lane::MeanFilterItem::iterations、
  Lane::weakBoostIterations、ComputeWeakWaveBoost/ComputeMovingAverageIterated 的形参、
  MeanFilterCurveFor/WeakBoostCurveFor/WeakBoostValueAt/EdgeCenterCountsFor、
  各曲线缓存项的字段、EdgePanelLayout::rowIterations/sliderIterations、
  PlotEditTarget::MEAN_FILTER_ITERATIONS/EDGE_CENTER_ITERATIONS、
  PlotDragMode::BOOST_ITERATIONS、kWeakBoostIterationsMin/Max/Label、
  DerivedSignalRequest::iterations 等)。网格拖拽重排里的局部 order 为避免歧义
  改名为 laneOrder;`std::memory_order_*`、`rowOrder`→`rowIterations` 一并核对无误。
- 导出信号尾缀统一为「N次迭代」:
  `<原信号> (N点均值 M次迭代)`、`<原信号> (弱波增强 X% M次迭代)`;
  弱波增强此前缺失该信息,现已补上(两种派生信号都恒定带迭代次数)。
  状态提示语同步为「… M 次迭代信号。」。

二、越界三角与光标读数的样式统一(用户要求)
- 新增 `kMarkerTriangleGap`:网格内数据点越界时,方向三角与数值标注之间**留出间隙**
  (此前三角与胶囊贴在一起,看起来像胶囊上多出的尖角);中心轴/均值滤波/弱波增强的
  派生取值标注同样处理。
- 光标时间读数(以及双光标 Δt 读数)改用与网格内数据点**完全一致**的越界样式:
  三角画在胶囊**外侧**、尖端朝外、与胶囊之间留出同样的间隙;为此把读数胶囊在越界一侧
  向内让出「三角深度 + 间隙」。旧的"三角压在胶囊上沿"的画法以及为它额外留出的叠放
  间距(kCursorTipStackGap)一并删除。

三、大文件放大后"平移 / 改配色非常卡"的根因与修复(用户要求,流畅性优先)
用离屏探针(`.preflight/perf3`)在 VERY_BIG_DATA(1.77GB、2580 万采样 × 5 信号、
1224×796 绘图区)上先量化,实测到的三个根因:
1. **包络带"梳状化"**:当信号的振荡周期与像素列宽度相当时(编码器方波在 2ms/大格下
   约 3 个采样点一列),逐列包络会在"全高"与"极窄"之间交替——实测 1224 列里有 988 处
   全高跳变。这种多边形让 GDI+ 的扫描线填充耗时暴涨:同一条 2448 顶点的路径
   **27.6ms → 1.1ms(±1 列极值扩展后快 25 倍)**。这正是"某些放大等级下平移特别卡"
   (实测 2ms/大格平移 87ms,其余等级 17~20ms)的原因。
   → `EmitEnvelopeBand` 在拼装包络带时对列窗口做 ±1 列的极值扩展(纵向最多各多 1 像素,
   肉眼不可辨),一次性消除该病理。
2. **派生预览曲线按折线逐段描边**:均值滤波/弱波增强曲线在可见采样数超过像素列数时
   仍按"每 2 像素一个顶点"的抗锯齿折线绘制,而这类曲线常叠在方波上(大量近垂直跳变),
   实测单条 12~42ms,5 个网格合计上百毫秒。
   → 稠密时改用**逐像素列的包络带**(复用 `EmitEnvelopeBand`)一次 FillPath,实测
   0.56ms / 1.53ms(分别快 22 倍 / 28 倍),视觉与细折线一致(列宽就是 1 像素)。
3. **悬停光晕用宽笔描整条折线**:实测悬停一帧多花 35ms。
   → 包络带模式下改为"把带整体外扩后填充一次"(`PaintBandHalo`),悬停帧恢复正常。

另外修掉两个相关的固定开销:
- `UiMeasureTextWidth` 增加 (字体, 文本) 记忆化缓存:它是布局代码的热点(浮动面板几何
  按「网格数 × 面板种类」重复计算,每帧重复测量同一批字符串成百上千次),实测单个面板
  的绘制因此从 3.2ms 降到 1ms 量级。字体重建(DPI 变化)时由 `UiClearMeasureCache` 作废。

实测(VERY_BIG_DATA 全选 5 信号,绘图区全重绘 = 平移一帧):
- 20ms/大格(用户报告的场景):平移 20.1ms → **11.3ms**;
- 2ms/大格(此前最糟):平移 86.7ms → **12.3ms**;
- 网格 1 开弱波增强:63.9ms → **17.6ms**;5 个网格全开:136.6ms → **31.9ms**;
- 悬停信号:整帧 49.6ms → **15.5ms**(与不悬停持平,光晕几乎免费)。
仍存的已知热点:0.5ms/大格附近(可见约 1000 个采样点、走**逐点描边**路径)平移约
28~39ms —— 该区间采样比像素还稀疏、无法用包络带替代,且用户明确要求保留抗锯齿,
本次未动。

四、验证
- 离屏 harness(窗口 SW_HIDE、不占用鼠标/键盘/屏幕)**355 项检查全部通过**,
  新增 `[AD]`:越界读数胶囊向内让位、三角在外侧、三角与胶囊间有真实间隙、
  胶囊上方不再有内容;取值标注与三角之间保留 `kMarkerTriangleGap` 的间隙(用
  散点模式排除波形干扰后逐像素断言)。`[X]/[Y]/[AA]/[AB]/[Z]` 等既有用例同步通过。
- 性能结论来自 `.preflight/perf3`(离屏探针 + 两套微基准),已随任务清理。
This commit is contained in:
DeepSeek V4.1 Flash committed 2026-10-10 20:13:21 +08:00
1 parent 8c8508e5f8
commit b960b03cb5
8 files changed
+666 -301

No files matched your search

+11 -11
View File
@@ -137,7 +137,7 @@ namespace jdi
bool weakBoost = false;
std::vector<Lane::WeakBoostItem> weakBoostItems;
double weakBoostLevel = 0.5;
int weakBoostOrder = 1;
int weakBoostIterations = 1;
bool weakBoostPanelOpen = true;
double weakBoostPanelX = -1.0;
double weakBoostPanelY = -1.0;
@@ -169,7 +169,7 @@ namespace jdi
carry.weakBoost = lane.weakBoost;
carry.weakBoostItems = lane.weakBoostItems;
carry.weakBoostLevel = lane.weakBoostLevel;
carry.weakBoostOrder = lane.weakBoostOrder;
carry.weakBoostIterations = lane.weakBoostIterations;
carry.weakBoostPanelOpen = lane.weakBoostPanelOpen;
carry.weakBoostPanelX = lane.weakBoostPanelX;
carry.weakBoostPanelY = lane.weakBoostPanelY;
@@ -212,7 +212,7 @@ namespace jdi
lane.weakBoost = carry.weakBoost;
lane.weakBoostItems = carry.weakBoostItems;
lane.weakBoostLevel = carry.weakBoostLevel;
lane.weakBoostOrder = carry.weakBoostOrder;
lane.weakBoostIterations = carry.weakBoostIterations;
lane.weakBoostPanelOpen = carry.weakBoostPanelOpen;
lane.weakBoostPanelX = carry.weakBoostPanelX;
lane.weakBoostPanelY = carry.weakBoostPanelY;
@@ -1020,10 +1020,10 @@ namespace jdi
}
} // namespace
void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int order,
void ComputeMovingAverageIterated(const std::vector<double>& values, int radius, int iterations,
std::vector<float>& out)
{
const int stages = std::max(1, order);
const int stages = std::max(1, iterations);
// 第 1 阶:从原始采样值出发。
ComputeMovingAverage(values, radius, out);
if (stages == 1)
@@ -1230,7 +1230,7 @@ namespace jdi
}
} // namespace
void ComputeWeakWaveBoost(const std::vector<double>& values, double level, int order,
void ComputeWeakWaveBoost(const std::vector<double>& values, double level, int iterations,
std::vector<float>& out)
{
const std::size_t count = values.size();
@@ -1251,17 +1251,17 @@ namespace jdi
}
}
// 「阶数 N」与「均值滤波」的阶数语义一致:把同一处理**重复应用 N 次**,
// 「迭代次数 N」与「均值滤波」的迭代次数语义一致:把同一处理**重复应用 N 次**,
// 每一阶以上一阶的结果为输入。一阶时改定量已严格等于「等级」对应的目标值;
// 多阶时相邻的弱波会互相抬高彼此的基准,从而把较宽的弱波区逐步恢复。
const int stages = std::max(1, std::min(order, 1000));
// 多次迭代时相邻的弱波会互相抬高彼此的基准,从而把较宽的弱波区逐步恢复。
const int stages = std::max(1, std::min(iterations, 1000));
const double strength = std::max(0.0, std::min(1.0, level));
std::vector<float> next;
for (int stage = 0; stage < stages; ++stage)
{
if (!ApplyWeakBoostStage(current, strength, next))
{
// 这一阶没有任何采样点需要调整:后续阶数也不会再有变化,提前结束。
// 这一阶没有任何采样点需要调整:后续迭代次数也不会再有变化,提前结束。
break;
}
current.swap(next);
@@ -1912,7 +1912,7 @@ namespace jdi
lane.edgeCenterPanelY = source.edgeCenterPanelY;
lane.weakBoost = source.weakBoost;
lane.weakBoostLevel = source.weakBoostLevel;
lane.weakBoostOrder = source.weakBoostOrder;
lane.weakBoostIterations = source.weakBoostIterations;
lane.weakBoostPanelOpen = source.weakBoostPanelOpen;
lane.weakBoostPanelX = source.weakBoostPanelX;
lane.weakBoostPanelY = source.weakBoostPanelY;
+72 -1
View File
@@ -2,6 +2,12 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <functional>
#include <mutex>
#include <unordered_map>
#include <utility>
#include <string>
namespace jdi
{
@@ -89,11 +95,52 @@ namespace jdi
}
return font;
}
} // namespace
namespace
{
/**
* @brief 文本宽度测量的缓存键(字体句柄 + 文本)。
*/
struct MeasureKey
{
HFONT font = nullptr;
std::wstring text;
bool operator==(const MeasureKey& other) const
{
return font == other.font && text == other.text;
}
};
struct MeasureKeyHash
{
std::size_t operator()(const MeasureKey& key) const
{
std::size_t hash = std::hash<const void*>()(key.font);
for (const wchar_t character : key.text)
{
hash = hash * 131u + static_cast<std::size_t>(character);
}
return hash;
}
};
/** 上限保护:超过时整体清空(缓存只是加速手段,清空后重新填充即可)。 */
constexpr std::size_t kMeasureCacheLimit = 4096;
std::unordered_map<MeasureKey, float, MeasureKeyHash> g_measureCache;
std::mutex g_measureMutex;
} // namespace
void UiClearMeasureCache()
{
std::lock_guard<std::mutex> lock(g_measureMutex);
g_measureCache.clear();
}
bool UiFonts::Create(UINT targetDpi)
{
Destroy();
// 字体句柄会被重新创建,旧句柄可能被系统复用:必须作废按句柄缓存的文本宽度,
// 否则复用同一句柄值的新字体会命中旧宽度(布局错位)。
UiClearMeasureCache();
dpi = (targetDpi == 0) ? 96u : targetDpi;
const double scale = static_cast<double>(dpi) / 96.0;
@@ -359,6 +406,21 @@ namespace jdi
return 0.0f;
}
// 记忆化:文本宽度只能走 GDI(GetDC + GetTextExtentPoint32W + ReleaseDC),
// 单次约 10µs;而布局代码每帧会把同一批字符串**重复测量成百上千次**——
// 例如浮动面板的几何是按「网格数 × 面板种类」反复重算的(5 网格 × 4 类 = 20 次,
// 每次十来个字符串),实测光这一项就占 3ms 以上。字体与文本都相同的测量结果
// 必然一致,因此按 (字体, 文本) 做一层缓存;字体被重建时由 `UiClearMeasureCache` 作废。
const MeasureKey key{font, text};
{
std::lock_guard<std::mutex> lock(g_measureMutex);
const auto found = g_measureCache.find(key);
if (found != g_measureCache.end())
{
return found->second;
}
}
HDC hdc = GetDC(nullptr);
if (hdc == nullptr)
{
@@ -371,7 +433,16 @@ namespace jdi
SelectObject(hdc, oldFont);
ReleaseDC(nullptr, hdc);
return static_cast<float>(size.cx);
const float width = static_cast<float>(size.cx);
{
std::lock_guard<std::mutex> lock(g_measureMutex);
if (g_measureCache.size() >= kMeasureCacheLimit)
{
g_measureCache.clear();
}
g_measureCache.emplace(key, width);
}
return width;
}
std::wstring UiEllipsize(const std::wstring& text, HFONT font, float maxWidth)