Files
Just-DataInspector/Components/Shared/Src/UiDebugBounds.cpp
T
DeepSeek V4.1 Flash aa4925a2b7 fix(UI): 边界框不再内缩 + 文本获得几何身份 + 补全漏项 + 描边提速 32%
## 1. 取消内缩(这是"边框不清晰"的主因)

第九轮为"父子矩形共边不叠粗"给 depth > 0 的 box 加了 1px 内缩。结果 GX 徽标
(30x17 逻辑像素的胶囊,圆角 = 高的一半,外接矩形正好等于 LaneBadgeRect)内缩 1px 后
框比形状小一圈 ⇒ 左右尖端与上下圆弧全部落在框外。

现行规则:**框 = 可见形状的外接矩形,绝不内缩**。父子共边时让"后画的覆盖"即可,
这本来就是正确的图层语义。层级 > 0 只影响配色,不再影响几何。

## 2. 文本第一次有了几何身份(唯一出口回报,不逐处登记)

文本是本项目唯一"没有几何身份"的元素:65 处绘制点各自现算 gd::RectF、画完即弃。
逐处补登记必漏,因此在唯一出口回报一次:

  PaintUtil 新增
    class UiTextRectSink { virtual void OnTextRect(const gd::RectF&) = 0; };
    void UiSetTextRectSink(UiTextRectSink*);       // 开关关闭时为 nullptr
    void UiSetTextRectOffset(float dx, float dy);  // 离屏位图渲染的坐标换算
    UiTextRectSink* UiGetTextRectSink();

DrawUiText() / UiTextAtClipped() 画完后按对齐方式 + 实测宽高把布局矩形**收窄成墨迹矩形**
再回报。UiDebugBoxList 直接继承该接口,宿主只需 UiSetTextRectSink(&m_debugBoxes) 一句。
开关关闭时只是一次空指针判断,正常绘制零影响。

## 3. 缓存命中的行也要有文本框

面板的行是"先画进位图再贴回",命中缓存的帧**根本不调用 UiText** ⇒ 文本框第二帧起消失。
做法:行渲染时用本地 RowTextCollector 收集(位图坐标)→ 连同位图存进
RowBitmapCache::textRects → 贴图时按 (originX, originY) 回放。
⚠️ **必须无条件收集**:只在"外层有收集器"时收集会导致"关调试时滚过列表 → 打开调试 →
命中缓存 → 永远没有文本框"。

## 4. 补齐 PlotView 漏项(全部读现成成员)

m_timeAxisRect / m_scrollBarRect / BackToTopRect() / m_cursorSpanRect /
m_cursorLabelRects / EditFieldRect()

## 5. 描边提速:图元 12 → 8

微基准结论:图元数量是唯一瓶颈(每次 FillRectangle ≈1µs)。
旧实现把每条边拆成"中段 1px + 两端各一段加粗"= 12 次;新实现 = 4 条 1px 边 + 4 个自适应角块。
实测 paintBoxes 5.892ms → 4.028ms(-32%),观感几乎一致(角仍是两条粗边的交点)。
⚠️ 不要改写成 GraphicsPath/FillPath(单次约 20µs,比 12 段还慢);
GDI PolyPolygon 一次画 5688 个矩形要 114ms,更不能用。

## 6. 关闭开关时清空容器(释放内存,避免误读)

## 7. 验证

- make release / make debug 零 error 零 warning;TargetCheck.exe 909,824 B(888.5 KiB < 900 KiB)。
- 新增探针 .preflight/lbcovr 7/7:box-time-axis / box-plot-scrollbar / box-back-to-top /
  box-gx-badge-matches-pill(容差 0.5px)/ text-boxes-collected(89 个)/
  row-text-boxes-from-cached-rows / boxes-cleared-when-off。
- 回归:lbcheck 21/21、lbresid 5/5、lbzorder 4/4、uicheck 69/69。
- rendercheck:8 个场景的整帧与区域哈希与上一轮**逐一相同** + FUNC 7/7
  (证明调试功能对正常绘制零影响)。
- 帧耗时分解(MF4 / 283 网格):base 9.06ms、仅强制整帧 6.66ms、完整调试 11.35ms;
  collect 0.082ms、paintBoxes 4.028ms(上一轮 5.892ms)。

## 8. 文档

Documents/UI_Refactor_Plan.md / UI_Refactor_Progress.md / UI_Refactor_Report.md 追加第九阶段;
Documents/agent_mem.md 同步变更日志与「2026-10-28 第十一轮」硬约束与踩坑。
2026-10-28 17:38:55 +08:00

309 lines
11 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "UiDebugBounds.hpp"
#include <algorithm>
#include <cmath>
namespace jdi
{
namespace
{
/** @brief 四条边的描边宽度(设备像素)。 */
constexpr int kEdgeStroke = 1;
/** @brief 层级 0 的包角基础粗细(设备像素)。 */
constexpr int kAreaCornerThick = 4;
/** @brief 层级 ≥1 的包角基础粗细(设备像素)。 */
constexpr int kNodeCornerThick = 3;
/** @brief 包角沿边延伸的长度上限(设备像素)。 */
constexpr int kCornerLenMax = 14;
/** @brief 短边不超过该值时**只画四个角块、不画边**(小方框画满一圈会像粗边框)。 */
constexpr int kEdgelessThreshold = 12;
/** @brief 短边不超过该值时完全不画(再画就盖住元素本身了)。 */
constexpr int kSkipThreshold = 5;
/** @brief 一个已对齐到整像素、可直接填充的矩形。 */
struct PixelRect
{
int left = 0;
int top = 0;
int right = -1;
int bottom = -1;
int Width() const { return right - left + 1; }
int Height() const { return bottom - top + 1; }
bool Valid() const { return right >= left && bottom >= top; }
};
/**
* @brief 用整数像素矩形填充(要求调用方已把像素偏移模式设为 `None`)。
*
* 走 GDI+ 而不是 `UiFillRect`:这里需要**精确的 1px 覆盖**,而 `UiFillRect` 的快
* 路径按 `floor/ceil` 取整并可能转交 GDI,混进调试层会带来额外状态切换开销。
*
* ⚠️ **每次图元调用约 1µs**(实测 474 个 box × 12 段 = 5.9ms/帧),因此这里刻意
* 把图元数量从 12 压到 8;并且**不要**改写成 `GraphicsPath` —— `FillPath` 单次约
* 20µs,比 12 段 `FillRectangle` 还慢(本机实测 22.8ms vs 13.0ms)。
*/
void FillPixels(gd::Graphics& g, int x, int y, int width, int height, std::uint32_t argb)
{
if (width <= 0 || height <= 0)
{
return;
}
gd::SolidBrush brush{gd::Color(argb)};
g.FillRectangle(&brush, x, y, width, height);
}
/** @brief 把逻辑矩形对齐到整像素(**不内缩**,见头文件说明)。 */
PixelRect AlignToPixels(const gd::RectF& rect)
{
if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f))
{
return PixelRect();
}
PixelRect out;
out.left = static_cast<int>(std::lround(rect.X));
out.top = static_cast<int>(std::lround(rect.Y));
out.right = static_cast<int>(std::lround(rect.X + rect.Width)) - 1;
out.bottom = static_cast<int>(std::lround(rect.Y + rect.Height)) - 1;
if (out.right < out.left)
{
out.right = out.left;
}
if (out.bottom < out.top)
{
out.bottom = out.top;
}
return out;
}
} // namespace
void UiDebugBoxList::Clear()
{
m_boxes.clear();
m_occluders.clear();
m_layer = 0;
m_clip = gd::RectF();
}
void UiDebugBoxList::OnTextRect(const gd::RectF& rect)
{
// 文本用固定层级 4:它总是"最里层"的元素(复选框里的字、行里的信号名…)。
Add(rect, 4);
}
void UiDebugBoxList::Add(const gd::RectF& rect, int depth)
{
if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f))
{
return; // 空矩形(未布局 / 该元素当前不显示)不登记。
}
if (m_clip.Width > 0.0f && m_clip.Height > 0.0f)
{
// 与所属组件的区域无交集 ⇒ 已经完全滚出地盘,不必登记(也避免越界画到邻居上)。
if (rect.X + rect.Width <= m_clip.X || rect.X >= m_clip.X + m_clip.Width ||
rect.Y + rect.Height <= m_clip.Y || rect.Y >= m_clip.Y + m_clip.Height)
{
return;
}
}
UiDebugBox box;
box.rect = rect;
box.clip = m_clip;
box.depth = (depth < 0) ? 0 : depth;
box.layer = m_layer;
m_boxes.push_back(box);
}
void UiDebugBoxList::AddAll(const std::vector<gd::RectF>& rects, int depth)
{
for (const gd::RectF& rect : rects)
{
Add(rect, depth);
}
}
void UiDebugBoxList::AddOccluder(const gd::RectF& rect)
{
if (!(rect.Width > 0.0f) || !(rect.Height > 0.0f))
{
return;
}
Occluder occluder;
occluder.rect = rect;
occluder.layer = m_layer;
m_occluders.push_back(occluder);
}
std::uint32_t UiDebugBoundsColor(int depth)
{
// 与主题无关的固定配色:调试层必须"永远显眼",不能跟随用户主题变成接近底色。
switch (depth)
{
case 0:
return 0xFF29B6F6u; // 青蓝:顶层组件区域
case 1:
return 0xFFE040FBu; // 品红:组件内部的功能分区
case 2:
return 0xFFFFB300u; // 琥珀:具体控件 / 行
case 3:
return 0xFF66BB6Au; // 绿:更深的子块(复选框、色块、✕ …)
default:
return 0xFFFF7043u; // 橙:文本墨迹矩形
}
}
void UiDebugPaintBounds(gd::Graphics& g, const gd::RectF& rect, int depth)
{
UiDebugPaintBoundsClipped(g, rect, gd::RectF(), depth);
}
void UiDebugPaintBoundsClipped(gd::Graphics& g, const gd::RectF& rect, const gd::RectF& clip,
int depth)
{
const int level = (depth < 0) ? 0 : depth;
// 先判断"值不值得画":元素太小时画上去就是糊住它本身。
if (std::min(rect.Width, rect.Height) <= static_cast<float>(kSkipThreshold))
{
return;
}
PixelRect pixels = AlignToPixels(rect);
if (!pixels.Valid())
{
return;
}
// ⚠️ 与裁剪矩形求交(见 `UiDebugBoxList::SetClip`):组件内部的元素可能落在地盘之外。
// 用 floor/ceil 而不是取整:裁剪是"能看见多少就画多少",宁可多一像素也不要把
// 紧贴边界的包角整个裁掉。
if (clip.Width > 0.0f && clip.Height > 0.0f)
{
PixelRect clipped;
clipped.left = std::max(pixels.left, static_cast<int>(std::floor(clip.X)));
clipped.top = std::max(pixels.top, static_cast<int>(std::floor(clip.Y)));
clipped.right =
std::min(pixels.right, static_cast<int>(std::ceil(clip.X + clip.Width)) - 1);
clipped.bottom =
std::min(pixels.bottom, static_cast<int>(std::ceil(clip.Y + clip.Height)) - 1);
if (!clipped.Valid())
{
return;
}
pixels = clipped;
}
const int width = pixels.Width();
const int height = pixels.Height();
const int shortSide = std::min(width, height);
const std::uint32_t color = UiDebugBoundsColor(level);
// 包角粗细随尺寸自适应("小元素变成粗描边"的修复点):
// 基准 4/3px 只在足够大的矩形上使用;短边越小越细,且不超过短边的 1/4。
const int baseThick = (level == 0) ? kAreaCornerThick : kNodeCornerThick;
int cornerThick = std::max(1, std::min(baseThick, shortSide / 10));
cornerThick = std::max(1, std::min(cornerThick, std::max(1, shortSide / 4)));
// 包角长度随短边变化:短边的 1/3,不超过上限,也不小于角本身。
int cornerLen = std::min(kCornerLenMax, std::max(1, shortSide / 3));
cornerLen = std::max(cornerLen, cornerThick);
const gd::GraphicsState state = g.Save();
// 描边必须锐利:关抗锯齿 + 关半像素偏移,矩形已对齐整像素 ⇒ 1px 就是 1px。
g.SetSmoothingMode(gd::SmoothingModeNone);
g.SetPixelOffsetMode(gd::PixelOffsetModeNone);
// ⚠️ 图元数量直接决定耗时(每个约 1µs),因此这里固定为**最多 8 次**:
// 4 条 1px 边 + 4 个加粗角块。
// 旧实现把每条边拆成"中段 1px + 两端各一段加粗"共 12 次,观感几乎相同
// (角本来就是"两条粗边的交点"),却多花 50% 时间。
// 短边很小的矩形只画 4 个角块(画满一圈会让复选框/色块看起来"被框住")。
const bool drawEdges = (shortSide > kEdgelessThreshold);
if (drawEdges)
{
FillPixels(g, pixels.left, pixels.top, width, kEdgeStroke, color); // 上
FillPixels(g, pixels.left, pixels.bottom, width, kEdgeStroke, color); // 下
FillPixels(g, pixels.left, pixels.top, kEdgeStroke, height, color); // 左
FillPixels(g, pixels.right, pixels.top, kEdgeStroke, height, color); // 右
}
// 四个加粗角块(粗细随尺寸自适应,见上)。
// * 大矩形:角块是 `cornerThick` 见方的方块,与四条 1px 边接成 `⌜ ⌝ ⌞ ⌟`;
// * 小方框(不画边):角块沿水平方向拉长到 `cornerLen`,让"四个角"更明显。
const int blockW = std::min(drawEdges ? cornerThick : cornerLen, width);
const int blockH = std::min(cornerThick, height);
FillPixels(g, pixels.left, pixels.top, blockW, blockH, color);
FillPixels(g, pixels.right - blockW + 1, pixels.top, blockW, blockH, color);
FillPixels(g, pixels.left, pixels.bottom - blockH + 1, blockW, blockH, color);
FillPixels(g, pixels.right - blockW + 1, pixels.bottom - blockH + 1, blockW, blockH, color);
g.Restore(state);
}
void UiDebugPaintBoxList(gd::Graphics& g, const UiDebugBoxList& boxes)
{
if (boxes.Empty())
{
return;
}
// ⚠️ 排序键是 `(layer, depth)`:
// * `layer` 保证"后收集的(更靠上的浮层)压住先收集的" —— 没有它,信号面板的行边界
// 会盖在右键弹出的二级菜单上面;
// * `depth` 保证同一层内先画父再画子,子元素的彩色包角不会被父的边压住。
std::vector<const UiDebugBox*> ordered;
ordered.reserve(boxes.Count());
for (const UiDebugBox& box : boxes.Items())
{
ordered.push_back(&box);
}
std::stable_sort(ordered.begin(), ordered.end(),
[](const UiDebugBox* lhs, const UiDebugBox* rhs)
{
if (lhs->layer != rhs->layer)
{
return lhs->layer < rhs->layer;
}
return lhs->depth < rhs->depth;
});
for (const UiDebugBox* box : ordered)
{
// ⚠️ 遮挡:浮层(菜单 / 拾色器)是不透明的,它上面的内容不该出现下层组件的边框线。
// 仅靠 layer 排序只能决定"两个 box 重叠时谁压谁",不能阻止"先画的线仍然可见",
// 因此这里把**更高层**的遮挡矩形从裁剪区里排除。
gd::Region region;
bool occluded = false;
for (const UiDebugBoxList::Occluder& occluder : boxes.Occluders())
{
if (occluder.layer <= box->layer)
{
continue;
}
if (!occluded)
{
region.MakeInfinite();
occluded = true;
}
region.Exclude(occluder.rect);
}
if (!occluded)
{
UiDebugPaintBoundsClipped(g, box->rect, box->clip, box->depth);
continue;
}
// 已有裁剪矩形时先与它求交,再设进 Graphics(region 天然支持这种"挖洞"裁剪)。
if (box->clip.Width > 0.0f && box->clip.Height > 0.0f)
{
region.Intersect(box->clip);
}
const gd::GraphicsState state = g.Save();
g.SetClip(&region, gd::CombineModeIntersect);
UiDebugPaintBoundsClipped(g, box->rect, gd::RectF(), box->depth);
g.Restore(state);
}
}
} // namespace jdi