045ca7f688
把 archify 出的交互架构图落回仓内可复现形态,避免 docs/ 里出现「没有生成器的 SVG」: - 新生成器 tools/arch_diagram/gen_arch_endtoend_svg.py → docs/images/架构总览_ 端到端链路.svg(42 框 / 37 边 / 5 条纵带,线型分数据·命令·屏障心跳三类); 数据全部内嵌在 .py 里(generated-fresh 只把单个 gen_*.py 拷进临时目录重跑, 外部 JSON 输入会 rc≠0 假红),SVG 内不含时间戳/commit/绝对路径 - docs/架构设计/项目架构总览.md 新增 §1.3 挂图并写清与 §1 分层图的分工 (那张答「谁在哪一层」,这张答「一条数据走到底穿过谁」;纵带只是列分组, 依赖方向仍以 §1.1 表与 layer-deps 门禁为准),版本 v1.2 → v1.3 - 两处生成器清单登记:tools/arch_diagram/README.md 脚本一览、 docs/架构设计/图形绘制流程.md 的选型表与产物对照表 - draw-diagram skill 新增 §三b(大图碰撞审计判据 + 两条几何纪律)+ reference/svg-collision-audit.py(拷进 ci-artifacts 用,刻意不进 tools/) 本窗实测(都已写进 skill):42 框 37 边的图缩略目检看着干净,解析式审计一眼 报出 23 条——边标签文字盒压到节点框会被后画的节点白底吃掉半个字(「串行执行」 渲染成「行执行」);根因是同行两框走了「同轴加一段走廊」的通用规则,产出退化 四点折线,把标签落点推到第一半中点。修法:同轴且已对齐直接返回两点线,标签 落点改按总弧长取 50%。 零余量第二遍(用生成器自己的 Box/route/label_at 真值扫,框不膨胀不内缩)又抓到 两处近失:「TRGO 触发」「帧 B 下发」与相邻框只剩 0.7px / 1.5px,缩成「TRGO」 「帧 B」后最紧余量升到 3.5px;同一遍还纠正了审计器自己的错——`hi > y0` 误写成 `hi > x0` 凭空报 3 条穿框,差点重排三条边的走线,所以判据里加了「先验审计器 本身再信 0 条」。 自查:连跑两次产物 sha1 不变;scripts/check_generated_fresh.py rc=0(25 生成器 / 21 一致 / 4 在只减不增基线内);碰撞审计 rc=0(0 条);生成器无 OVERFLOW; doc-links --sections-strict 逐篇 rc=0;mermaid-syntax / nested-comment / skill-size / bom-drift 无新发;改动文件字节体检干净;/keil-build 0 错 0 警。
155 lines
7.8 KiB
Python
155 lines
7.8 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""一次性碰撞审计(拷进 `ci-artifacts/` 用,**不要放进 `tools/arch_diagram/`**):
|
||
解析自己生成的 SVG,报「边标签压节点 / 走线穿框 / 标签互压 / 文字出画布 / 节点文字出框 /
|
||
标签余量过窄」,并打印最紧的三条「标签文字 ↔ 最近框」水平余量。
|
||
|
||
为什么需要它:`check_fit` 预检只量**框内**文字;几十框几十边的大图,
|
||
标签压框与走线穿框在缩略 PNG 里根本看不全(实测 42 框 37 边报出 23 条),
|
||
而像素回读又慢又主观。解析产物是确定性的。
|
||
|
||
依赖:待审图对应的生成器模块必须能被 import(脚本靠它复用同一把估宽尺子 `text_w()`
|
||
与几何常量 `CW` / `CH` / `EDGE_FONT` / `ECOL`,两边同尺子才不会假绿)。
|
||
生成器侧要求:边标签白片用 `fill-opacity="0.94"`、节点框用 `fill="#ffffff"` 且
|
||
`width==CW`、`height==CH`(本脚本靠这两条分诊);文字用 `<text font-size fill>`。
|
||
|
||
**这一遍照不到的东西**(别把它当唯一判据):
|
||
- 走线检查必须把框**内缩**几像素,否则合法的「线接在框边上」会全报成穿框——从 SVG
|
||
反推不出哪两个框是本边的端点。代价:1~3px 的切边也照不到。要零余量就在**生成器侧**
|
||
再用 `Box` / `route()` / `label_at()` 的真值扫一遍(线段 vs 不膨胀的框,只跳本边两端)。
|
||
- 白片比通道宽一点、被节点白底盖住边角:无害(白片先画、节点后画),不用改;
|
||
要盯的是**文字**的余量,上面那三条数就是。
|
||
- 自写的判据脚本先拿一条**已知干净**的边验它不报,再信它的「0 条」:实测一条
|
||
`hi > x0` 写成 `hi > y0` 的复制粘贴错,凭空报出 3 条「穿框」,差点为此重排走线。
|
||
|
||
用法(仓库根):
|
||
python ci-artifacts/label_audit.py <生成器模块名> [svg 路径]
|
||
# 例:python ci-artifacts/label_audit.py gen_arch_endtoend_svg
|
||
# python ci-artifacts/label_audit.py gen_wave_record_flow_svg docs/images/故障录波数据流.svg
|
||
退出码:0 = 无问题,1 = 有问题(打印明细)。
|
||
别的生成器若没有同名常量(多数用 `CHIP_FONT` / 自己的线色变量),改脚本顶部 `EDGE_COLORS` 与 `edge_labels` 那两处筛选即可——估宽必须继续用该生成器自己的 `text_w()`,别换一把尺子。
|
||
"""
|
||
import sys
|
||
import xml.etree.ElementTree as ET
|
||
|
||
sys.path.insert(0, "tools/arch_diagram")
|
||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||
|
||
mod_name = sys.argv[1]
|
||
G = __import__(mod_name)
|
||
svg_path = sys.argv[2] if len(sys.argv) > 2 else G.OUT
|
||
|
||
MASK_OP = "0.94" # 边标签白片的 fill-opacity(生成器侧约定)
|
||
EDGE_COLORS = set(G.ECOL.values()) # 边标签颜色(生成器侧的线色表)
|
||
|
||
def box_of_text(el):
|
||
"""<text> → (x0, y0, x1, y1, 文本, 字号, 颜色)。baseline y 之上约 0.85em、之下 0.22em。"""
|
||
x, y = float(el.get("x")), float(el.get("y"))
|
||
fs = float(el.get("font-size"))
|
||
s = "".join(el.itertext())
|
||
w = G.text_w(s, fs)
|
||
a = el.get("text-anchor", "start")
|
||
x0 = x if a == "start" else (x - w / 2 if a == "middle" else x - w)
|
||
return (x0, y - fs * 0.85, x0 + w, y + fs * 0.22, s, fs, el.get("fill"))
|
||
|
||
def area(a, b):
|
||
ix = min(a[2], b[2]) - max(a[0], b[0])
|
||
iy = min(a[3], b[3]) - max(a[1], b[1])
|
||
return ix * iy if (ix > 0.5 and iy > 0.5) else 0.0
|
||
|
||
def segs_of(d):
|
||
n = [float(v) for v in d.replace("M", " ").replace("L", " ").replace(",", " ").split()]
|
||
pts = list(zip(n[0::2], n[1::2]))
|
||
return list(zip(pts, pts[1:]))
|
||
|
||
def seg_hits_rect(sg, r, m=2.0):
|
||
"""轴对齐线段 vs 矩形(内缩 m 排除贴边的合法端点)。斜段一律算可疑。"""
|
||
(x0, y0), (x1, y1) = sg
|
||
rx0, ry0, rx1, ry1 = r[0] + m, r[1] + m, r[2] - m, r[3] - m
|
||
if abs(y0 - y1) < 0.5:
|
||
return ry0 < y0 < ry1 and min(x0, x1) < rx1 - m and max(x0, x1) > rx0 + m
|
||
if abs(x0 - x1) < 0.5:
|
||
return rx0 < x0 < rx1 and min(y0, y1) < ry1 - m and max(y0, y1) > ry0 + m
|
||
return True
|
||
|
||
root = ET.parse(svg_path).getroot()
|
||
W, H = float(root.get("width")), float(root.get("height"))
|
||
texts, nodes, masks, paths = [], [], [], []
|
||
for el in root.iter():
|
||
tag = el.tag.split("}")[-1] # 去命名空间,否则一个都匹配不上
|
||
if tag == "text":
|
||
texts.append(box_of_text(el))
|
||
elif tag == "rect":
|
||
w, h = float(el.get("width")), float(el.get("height"))
|
||
x, y = float(el.get("x")), float(el.get("y"))
|
||
if el.get("fill") == "#ffffff" and abs(w - G.CW) < 0.6 and abs(h - G.CH) < 0.6:
|
||
nodes.append((x, y, x + w, y + h, "node"))
|
||
elif el.get("fill") == "#ffffff" and el.get("fill-opacity") == MASK_OP:
|
||
masks.append((x, y, x + w, y + h, "mask"))
|
||
elif tag == "path" and el.get("fill") == "none":
|
||
paths.append(segs_of(el.get("d")))
|
||
|
||
edge_labels = [t for t in texts if t[6] in EDGE_COLORS and t[5] == G.EDGE_FONT]
|
||
bad = []
|
||
for lb in edge_labels:
|
||
b = lb[:4]
|
||
for n in nodes:
|
||
a = area(b, n)
|
||
if a > 6: # 压到框边 6 px² 以上就会被节点吃掉半个字
|
||
bad.append("标签压节点 %-12s 压 node@(%.0f,%.0f) %.0f px²" % (lb[4][:12], n[0], n[1], a))
|
||
for ob in edge_labels:
|
||
if ob is not lb and area(b, ob[:4]) > 6:
|
||
bad.append("标签互压 %s / %s" % (lb[4][:10], ob[4][:10]))
|
||
if b[0] < 2 or b[2] > W - 2 or b[3] > H - 2 or b[1] < 2:
|
||
bad.append("标签出画布 %s box=(%.0f,%.0f,%.0f,%.0f) 画布 %.0fx%.0f"
|
||
% (lb[4][:12], b[0], b[1], b[2], b[3], W, H))
|
||
for segs in paths:
|
||
for sg in segs:
|
||
for n in nodes:
|
||
if seg_hits_rect(sg, n):
|
||
bad.append("走线穿框 (%d,%d)-(%d,%d) 压 node@(%.0f,%.0f)"
|
||
% (sg[0][0], sg[0][1], sg[1][0], sg[1][1], n[0], n[1]))
|
||
break
|
||
# 「节点文字出框」只能按几何判:文字中心落在某个节点框里,却没被该框完整包住。
|
||
# 别拿 font-size 当筛选——本仓惯例里副题字号 = 边标签字号 = 脚注字号(9.5),按字号筛会全灭。
|
||
for t in texts:
|
||
cx, cy = (t[0] + t[2]) / 2, (t[1] + t[3]) / 2
|
||
host = [n for n in nodes if n[0] <= cx <= n[2] and n[1] <= cy <= n[3]]
|
||
for n in host:
|
||
if not (t[0] >= n[0] - 1 and t[2] <= n[2] + 1 and t[1] >= n[1] - 1 and t[3] <= n[3] + 1):
|
||
bad.append("节点文字出框 %r 超 node@(%.0f,%.0f)" % (t[4][:16], n[0], n[1]))
|
||
break
|
||
|
||
# 余量比「有没有压上」更早暴露问题:本仓约定边标签整段落在框间通道里,
|
||
# 只要文字离最近框 < 3px,字宽估算稍偏(本脚本与生成器共用同一把尺子,
|
||
# 但真实字体度量和它有出入)就会被后画的节点吃掉笔画。
|
||
MIN_CLEAR = 3.0
|
||
clears = []
|
||
for lb in edge_labels:
|
||
b = lb[:4]
|
||
worst, wid = None, None
|
||
for n in nodes:
|
||
if min(b[3], n[3]) - max(b[1], n[1]) <= 0: # 竖直方向不相干
|
||
continue
|
||
c = max(n[0] - b[2], b[0] - n[2]) # 正=间距,负=压进去多深
|
||
if worst is None or c < worst:
|
||
worst, wid = c, (n[0], n[1])
|
||
if worst is not None:
|
||
clears.append((worst, lb[4][:12], wid))
|
||
if worst < MIN_CLEAR:
|
||
bad.append("标签余量过窄 %r 距 node@%s 仅 %.1fpx(< %.1f)"
|
||
% (lb[4][:12], wid, worst, MIN_CLEAR))
|
||
|
||
seen = []
|
||
for b in bad:
|
||
if b not in seen:
|
||
seen.append(b)
|
||
print("%s:边标签 %d / 节点 %d / 文字 %d / 路径 %d"
|
||
% (svg_path, len(edge_labels), len(nodes), len(texts), len(paths)))
|
||
for c in sorted(clears)[:3]:
|
||
print(" 最紧余量 %.1fpx 标签 %r 邻 node@%s" % (c[0], c[1], c[2]))
|
||
for b in seen:
|
||
print(" ! " + b)
|
||
print("合计问题 %d 条" % len(seen))
|
||
sys.exit(1 if seen else 0)
|