From 045ca7f6886b972e3e0138dc959cb80599952f62 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 30 Oct 2026 15:57:43 +0800 Subject: [PATCH] =?UTF-8?q?docs(=E9=85=8D=E5=9B=BE):=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E9=93=BE=E8=B7=AF=E9=B8=9F=E7=9E=B0=E8=BF=9B=E7=94=9F?= =?UTF-8?q?=E6=88=90=E5=99=A8=E4=BD=93=E7=B3=BB=EF=BC=8C=E8=A1=A5=E5=A4=A7?= =?UTF-8?q?=E5=9B=BE=E7=A2=B0=E6=92=9E=E5=AE=A1=E8=AE=A1=E5=88=A4=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 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 警。 --- .dsh/skills/draw-diagram/SKILL.md | 41 +- .../reference/svg-collision-audit.py | 154 +++++++ docs/images/架构总览_端到端链路.svg | 284 ++++++++++++ docs/架构设计/图形绘制流程.md | 2 + docs/架构设计/项目架构总览.md | 10 +- tools/arch_diagram/README.md | 1 + tools/arch_diagram/gen_arch_endtoend_svg.py | 429 ++++++++++++++++++ 7 files changed, 919 insertions(+), 2 deletions(-) create mode 100644 .dsh/skills/draw-diagram/reference/svg-collision-audit.py create mode 100644 docs/images/架构总览_端到端链路.svg create mode 100644 tools/arch_diagram/gen_arch_endtoend_svg.py diff --git a/.dsh/skills/draw-diagram/SKILL.md b/.dsh/skills/draw-diagram/SKILL.md index a6bdc570..158148d3 100644 --- a/.dsh/skills/draw-diagram/SKILL.md +++ b/.dsh/skills/draw-diagram/SKILL.md @@ -73,6 +73,41 @@ whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层 5. 跑 `& '.venv\Scripts\python.exe' tools/arch_diagram/gen_<主题>_svg.py`,**把 OVERFLOW 告警打到 0 再进目检**——预检能在渲染前就报出 `foot: 974 > 968` 这类溢出(本窗实测两张图首跑各报一条脚注超宽),比靠眼睛反复量便宜一个数量级。 6. 新脚本落库时**同批**把这一行加进 `tools/arch_diagram/README.md` 的「脚本一览」表。 +## 三b、大图(几十框几十边)加一道碰撞审计,别只靠眼睛 + +边数一多,像素回读就查不全(本窗实测:42 框 37 边的端到端链路图,缩略 PNG 看着干净, +审计器一眼报出 23 条:边标签压节点、走线穿框、标签出画布)。做法是**解析自己生成的 SVG** +而不是解析渲染结果——`ET.parse` 产物,按 `font-size`/`fill` 分诊文字,估宽口径直接复用生成器的 +`text_w()`(两边同一把尺子才不会假绿),然后判三件事: + +1. **边标签的「文字盒」vs 节点框**(不是白片盒):交集 > 6 px² 就算问题——节点在边之后画, + 压上去的**文字**会被节点白底吃掉半个字(本窗症状:「串行执行」渲染成「行执行」)。 + 白片本身压到框边角是**无害**的(白片先画、节点后画,只是盖住一点白底,不改字)。 + 除「有没有压上」,还要打印**文字与最近框的最小水平余量**并按 < 3px 报问题: + 本窗「TRGO 触发」余量 0.7px、「帧 B 下发」1.5px,都没压上却随时会被真实字宽吃笔画, + 缩短标签后升到 3.5px 以上才算安全。 +2. **走线段 vs 节点框**(内缩 2px 再判,排除贴边端点):线穿框是硬伤,改边的出入框方位或独占通道。 +3. **标签互压 / 出画布**:白片两两求交;`box[3] > H-2` 查脚注贴底。 +4. **零余量复扫(生成器侧,别只跑 SVG 侧那一遍)**:SVG 反推不出哪两个框是本边端点, + 走线只能内缩着查 → 1~3px 的切边照不到。用生成器自己的 `Box` / `route()` / `label_at()` + 真值再扫一遍:框不膨胀不内缩、只跳本边两端。跑 SVG 侧与真值侧两遍都归零才算收工。 + +脚本骨架与判据见 `reference/svg-collision-audit.py`(可直接拷进 `ci-artifacts/` 当一次性工具用, +**别放 `tools/arch_diagram/`**——那里只放生成器,非 `gen_*.py` 的脚本混进去会让人以为在册)。 + +配套的两条几何纪律(都是本窗踩出来的): + +- **同轴直连不要插退化折点**:同行两框若按「同轴 → 加一段 gx 走廊」的通用规则生成,会产出 + `[(195,176),(219,176),(219,176),(243,176)]` 这种点集——线还是直的,但**标签落点算成第一半的中点**, + 整格偏移半个通道 → 压框。正解:出框轴与入框轴同轴且已对齐时直接返回两点线。 +- **标签落点用弧长中点,不用「最长段中点」**:多段折线里最长段可能有多条(对称 Z 字), + 取第一条会把标签推到拐角上;沿总弧长取 50% 再定位,配合 `seg`/`t` 旋钮微调。 + +**先验审计器本身,再信它的「0 条」**:本窗把 `hi > y0` 复制粘贴成 `hi > x0`,零余量那一遍 +凭空报出 3 条「穿框」,差点为此重排三条边的走线。判据脚本的假阳性比漏报更贵——它会让你 +去改一张本来没问题的图。规矩:写完后挑一条**已知干净**的边(例如两框正对的直连边)单独跑一遍, +确认它不报,再跑全量。 + ## 三、目检(Windows:本机没有任何现成 SVG 渲染器) **先探测,别照抄 cairosvg 命令**:本仓 `.venv` 无 `cairosvg`(`ModuleNotFoundError: No module named 'cairosvg'`),`rsvg-convert` 不存在,只有 Edge / Chrome: @@ -89,7 +124,9 @@ Test-Path "C:\Program Files\Google\Chrome\Application\chrome.exe" Test-Path "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" ``` -截图(**`--screenshot=` 必须给绝对 Windows 路径 + 绝对 `file:///` 输入**): +截图(**`--screenshot=` 必须给绝对 Windows 路径 + 绝对 `file:///` 输入**,并且**固定用 +`--headless=new`**:本窗用旧 `--headless` 同一套命令时成时不成——一次写出了 168 KB, +下一次静默不写文件;也别把 stderr 丢进 `/dev/null`,成功标志就是它自己打印的那行字数): ```bash mkdir -p ci-artifacts # Chrome 没有建目录的参数,先自己建 @@ -152,6 +189,7 @@ sha1sum docs/images/<主题>.svg - **照着现文 / `docs/架构设计/图形绘制流程.md` §四 跑 `pip install cairosvg` 或 `rsvg-convert`**:这台机器上没有(前者 `ModuleNotFoundError`、后者根本没有这个命令)→ 先按 §三探测,改用 headless Chrome/Edge。**别在没渲染出 PNG 的情况下声称"已目检"。** - **`--screenshot=ci-artifacts/ladder.png`(相对路径)** → `EXIT 2` + `Failed to write file ci-artifacts/ladder.png: 系统找不到指定的路径。 (0x3)`,PNG 压根没落盘;换绝对 Windows 路径后写出 95549 bytes。这条报错**不区分**"相对路径"与"目录不存在"两种成因(实测 `mkdir -p ci-artifacts` 已先跑过、仍报同一句),排查时两条都排。 +- **只做预检 + 缩略目检就收工(大图)**:`check_fit` 归零、PNG 看着干净,仍可能有二十来条标签压框——节点白底后画会吃掉压上去的标签;大图必须走 §三b 的解析式审计。 - **只做预检就收工**:`check_fit` / `OVERFLOW` 归零也不代表干净——本窗实测"门标签压第 5 级盒、蓝底标签挤、多余虚线"三类碰撞只有 PNG 回读看得见;反过来只目检不预检,脚注溢出就得靠眼睛反复量。**两者都要 0**。 - **手改 SVG 成品**:等于制造「不是我改的」diff,且 `generated-fresh` 立刻 DIFF;也**不要退化成 ASCII 框图**(AGENTS.md 明令勿手绘)。一切修改都落在生成器常量上再重跑。 - **批量改生成器时 `old_string` 不匹配**:一批 `edit` 会在中途抛错,但**前面的编辑已经落盘** → 半成品生成器(本窗实测:`emit` helper 缺失却仍打印 OK,几轮之后才查出)。正解:改前先 `read` 出精确原文,逐条改,末尾统一复验(`grep -n "def emit"`、`grep -c print`、再重跑看 rc 与 OK 行)。 @@ -163,6 +201,7 @@ sha1sum docs/images/<主题>.svg - [ ] 生成器输出 `OK … bytes, canvas WxH`,**无** `OVERFLOW WARN` / `!! N overflow warnings`;`grep -n "def emit"` 命中、`grep -c print` = 0。 - [ ] 绝对路径 PNG 存在、尺寸 = 画布,`read_image` 回读确认:无文字压框 / 溢出、无标签互撞、无 `…` 截断收尾、配色在调色板内。 - [ ] 连跑两次产物 sha1 不变;`scripts/check_generated_fresh.py` rc=0(无 DIFF / NEW,`[BASELINE]` 未增加)。 +- [ ] 大图(≥20 边):跑一遍 §三b 的碰撞审计,报数归零(标签压框 / 走线穿框 / 标签出画布)。 - [ ] 引用它的 `.md` 相对路径正确(根目录 `images/`,子目录 `../images/`);`tools/arch_diagram/README.md` 已加脚本行。 - [ ] 脚本 + 产物 `.svg` + `.md` + README 行一并暂存;`pre-commit run --files …` 全过(无乱码 / 行尾空白 / 编码 OK)。 diff --git a/.dsh/skills/draw-diagram/reference/svg-collision-audit.py b/.dsh/skills/draw-diagram/reference/svg-collision-audit.py new file mode 100644 index 00000000..2212bfdc --- /dev/null +++ b/.dsh/skills/draw-diagram/reference/svg-collision-audit.py @@ -0,0 +1,154 @@ +#!/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`(本脚本靠这两条分诊);文字用 ``。 + +**这一遍照不到的东西**(别把它当唯一判据): +- 走线检查必须把框**内缩**几像素,否则合法的「线接在框边上」会全报成穿框——从 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): + """ → (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) diff --git a/docs/images/架构总览_端到端链路.svg b/docs/images/架构总览_端到端链路.svg new file mode 100644 index 00000000..6186bca9 --- /dev/null +++ b/docs/images/架构总览_端到端链路.svg @@ -0,0 +1,284 @@ + + + +STM32F407ZGTx 工业 DTU 平台 · 端到端链路总览 +一个电气量从引脚到 104 报文 / 显存 / 记录文件穿过的框,与「谁在什么时候被谁唤醒」;分层归属与层内模块清单见 项目架构总览.svg,本图只画链路与握手 + +片外硬件 +板级器件 · 不走软件分层 + +驱动层 Drivers/BSP +手写芯片驱动与总线 + +中间件与算法 +取数 → 相量 → 快照 + +任务编排与握手 +App/task · 就绪屏障 + +平台服务与数据落点 +记录 / 配置 / 网络门面 + +8 路帧 + +TRGO + +原子快照 + +DMA 双缓冲 + +语义通道 + +相量 + +1 kHz → 20 ms + +同一上游 + +定值 + +YK 请求 + +脏段 + +边沿投递 + +唯一写者 + +try 语义 + +SPI 事务 + +集中读清 + +串行执行 + +单连接 + +TCP 对端 + +页读写 + +逻辑块 + +挂载 + +I2C1 + +墙钟 + +收包对时 + +帧 B + +灌数 + +同一显存 + +700000 + +100 ms 喂心跳 + +等就绪 + +WaitAll + +同源像素 + +纯逻辑直跑 + +唤醒发送 + +唯一落盘者 + +回调宿主 + + +TPAFE5160 前端 +并行总线 · 8 路 + + +片内 ADC1 + DMA +TIM3 TRGO 乒乓 + + +CH395F 以太网 +SPI 串行化 + + +GD5F SPI NAND +2 KB 页 · ECC + + +SD2506 RTC +I2C1 硬件亚秒 + + +屏板 160×160 +USART1 700000 + + +RS-485 总线 +UART4 · 未接线(暂缓) + + +TPAFE 驱动 +原子快照 snapshot + + +ADC · TIM3 · DMA +手写上电自校准 + + +CH395F 驱动 +中断集中读清 + + +NAND 驱动 + FTL +ECC · BBT + + +RTC 驱动 + 墙钟 +sys_clock epoch + + +屏板链路驱动 +借道 RS485 + + +控制台 UART5 +4 KB 环形 + + +语义取数层 +有效性 · 标定 + + +全周傅氏相量 +ring → phasor + + +遥测快照双区 +测频 200 ms + + +NET 中间件 +socket · 挂起 + + +FTL + FatFs +dhara · 8.3 短名 + + +对时协议 +推送写 RTC + + +LVGL + ui_port +160×160 1bpp + + +IWDG + Fault +心跳掩码 + + +adcTask +1 kHz 消费者 + + +protectTask +20 ms 判据 + + +定值层 +prot_set 校验 + + +iec104Task +100 ms · :2404 + + +netTask +MsgQueue 深 8 + + +defaultTask +记录唯一写者 + + +timeSyncTask +等 NET_READY + + +uiTask +唯一写侧 + + +就绪屏障 +位组 · NoClear + + +SOE 事件 +soe.log 定长 + + +定值落盘 +/prot_set.bin + + +配置引擎 +net_cfg · ftp_cfg + + +TCP 服务面 +:21 / :2404 + + +文件系统落点 +/log · /wave + + +卷锁 + 账本 +三路 try 协定 + + +一份显存三出 +屏板 · :7003 + + +ftpTask +lftpd 根 :21 + + +主机侧第三端 +直打生产 .c + + +上位侧对端 +104 主站 + + +PC 模拟器 +SDL2 同源码 + + +数据 / 读写 + +命令 · 请求 · 配置 + +屏障位 · 心跳 · 同源镜像 + +片外硬件 + +驱动 + +中间件与算法 + +任务与握手 + +服务与落点 + +对外 / 同构端 +口径:AGENTS.md「数据主线」+ docs/架构设计/项目架构总览.md(行号级证据在那份文档与代码里)。RS-485 采集未接线:rs485_task 有实现但不建任务,接线后数据进语义取数层;其余行为条件(未标定闭锁 / 就绪位 NoClear / CH395F 单连接 / V0. +1 双转换器无共同采样触发)见正文,它们只改变判据与端口、不改变本图的框与连线。 + diff --git a/docs/架构设计/图形绘制流程.md b/docs/架构设计/图形绘制流程.md index e9879e45..eee2622d 100644 --- a/docs/架构设计/图形绘制流程.md +++ b/docs/架构设计/图形绘制流程.md @@ -13,6 +13,7 @@ |---|---|---| | 协议帧 / 字节布局(精确比例) | **SVG**(`gen_frame_svg.py`) | 字段宽须严格 ∝ 字节数 + 逐字段标注 + 偏移标尺 | | 闪存 / 内存分区布局(精确比例) | **SVG**(`gen_partition_svg.py`) | 帧图生成器的 MB 计量副本(分区宽 ∝ MB 数 + 偏移标尺) | +| 端到端链路鸟瞰(一条数据走到底) | **SVG**(`gen_arch_endtoend_svg.py`) | 42 框 37 边、长边各占一条独占通道;标签白片必须落在框间通道内,否则被后画的节点吃掉半字 → 判据见 **`draw-diagram` skill** §三b | | 分层架构 / 模块依赖 | **SVG**(`gen_arch_svg.py`) | 多节点跨层依赖,Mermaid 会画成"意大利面",手工排布更清晰;单层单职责的栈图用其副本 `gen_storage_stack_svg.py` | | 就绪屏障 / 生产者-消费者 | **SVG**(`gen_barrier_svg.py`) | 三栏对齐 + 正交连线,避免交叉 | | 流程图 / 时序 / 状态机 / ER | **Mermaid**(Markdown 内嵌) | 拓扑关系类图,节点少、强调连接;渲染器原生支持 | @@ -45,6 +46,7 @@ | `gen_storage_stack_svg.py` | `docs/images/存储栈分层.svg` | `L_*` / `ROWS` / `TITLE` / `SUB` / `FOOT` | | `gen_partition_svg.py` | `docs/images/物理分区布局.svg` | `FIELDS` / `TITLE` / `SUB` / `FOOT`(MB 计量) | | `gen_net_pending_svg.py` | `docs/images/Net层阻塞模型.svg` | `CALLER_STEPS` / `LOOP_STEPS` / `PHASES` | +| `gen_arch_endtoend_svg.py` | `docs/images/架构总览_端到端链路.svg` | `NODES`(id / 列 / 行 / 标题 / 副题)+ `EDGES`(出入框方位与 f / g / my / gx 旋钮)+ `BANDS` 纵带;三条独占通道 `CHAN_B` / `CHAN_78` / `CHAN_SLOTS` 与走廊 `GUT_C4C5` 落在几何区 | | `gen_cfg_arch_svg.py` | `docs/images/配置文件分层架构.svg` | 配置引擎分层 | | `gen_net_stack_svg.py` | `docs/images/BSD网络栈分层.svg` | BSD 网络栈分层 | | `gen_memory_svg.py` | `docs/images/内存布局_*.svg` | RAM / FLASH 布局 | diff --git a/docs/架构设计/项目架构总览.md b/docs/架构设计/项目架构总览.md index 243b45c7..40862054 100644 --- a/docs/架构设计/项目架构总览.md +++ b/docs/架构设计/项目架构总览.md @@ -1,6 +1,6 @@ # STM32F4-Base 项目架构总览 -> 版本:v1.2 日期:2026-10-30 +> 版本:v1.3 日期:2026-10-30 > 平台:STM32F407ZGTx @168MHz(HSE 8MHz → PLL M=4/N=168/P=2)+ FreeRTOS V10.3.1(CMSIS-RTOS2) > 用途:一页看清项目的分层、任务、数据流与目录组织。现状能力与功能缺口的权威口径在 AGENTS.md「项目概况」,细节以各模块专项文档为准(见 §9)。 @@ -48,6 +48,14 @@ | `time_sync` / `sys_clock` → `sd2506` | 墙钟基准与写回 RTC | | `ui_port` / `ui_page_*` → `LVGL` | UI 全部绘制走 LVGL(1bpp 离屏渲染到内存帧缓冲) | +### 1.3 端到端链路鸟瞰(另一视角:一个电气量走到底) + +![一个电气量从片外前端穿过驱动与算法,经任务交给 104 / 显存 / 记录文件;跨任务握手走就绪屏障](../images/架构总览_端到端链路.svg) + +> 上图由 `tools/arch_diagram/gen_arch_endtoend_svg.py` 生成(42 框 / 37 边 / 5 条纵带,线型三类:实线 = 数据与读写、短虚线 = 命令·请求·配置、紫点线 = 屏障位·心跳·同源镜像)。 +> **与 §1 分层图的分工**:§1 回答「谁属于哪一层、层间单向依赖」,本图回答「一条数据从引脚走到 104 报文 / 显存 / 记录文件要穿过哪些框、谁在什么时候被谁唤醒」。图里的五条纵带只是**列分组**(便于对齐),不是依赖声明——依赖方向仍以 §1.1 表与 `layer-deps` 门禁为准。 +> 影响链路走向的两条行为条件已画进图内(RS-485 未接线 → 暂缓接语义取数层;跨任务握手集中经 `就绪屏障`);其余行为条件(未标定闭锁、`osFlagsNoClear`、CH395F 单连接、V0.1 双转换器无共同采样触发)不在图上重复,见 AGENTS.md「项目概况 / 平台约束」与 §2.2、§5。 + > `*` `rs485_task.c` 任务体已实现,但**当前未在 `freertos.c` 创建**(B2 暂缓,见 `docs/任务规划/暂缓-RS485.md`);`adc_task.c` 已于 2026-10-10 接入创建(`adcTask`)。 > 串口三分(2026-10-29 屏板批 0 后的定态):**USART1 = 屏板链路 700 kbaud**、**UART4 = RS-485(115200,未接线)**、**UART5 = 控制台日志 115200**——日志口原在 USART1,屏板占用后迁至 UART5(RS232#1 台架线改接日志口)。 diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index 1673ec0f..40029571 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -18,6 +18,7 @@ | `gen_watchdog_svg.py` | 看门狗心跳模型图 | IWDG + 任务心跳位图(置位→判定→喂狗 + 时间轴) | | `gen_adc_frontend_svg.py` | ADC 前端电路图 | 端子 → 串阻 → 转换节点 → 转换器引脚;含差分负端与 STM32 侧对照 | | `gen_adc_code_scale_svg.py` | ADC 码值刻度图 | 「码」的直观定义:两片转换器的刻度尺对照(16 位双极性 vs 12 位单极性) | +| `gen_arch_endtoend_svg.py` | 架构总览端到端链路图 | 端到端链路鸟瞰:片外硬件 → 驱动 → 算法 → 任务 → 落点 → 对外/同构端,42 框 37 边 5 条纵带;线型分数据 / 命令 / 屏障心跳三类,长边各占一条独占通道(与 §1 分层图分工:那张答「谁在哪一层」,这张答「一条数据走到底要穿过谁」) | | `gen_data_trust_ladder_svg.py` | 数据可信台阶图 | 采集数据可信度的五级台阶:现在踩在哪一级、下一级挡什么 | | `gen_data_trust_dc_ac_svg.py` | ADC 直流标定与交流验证图 | 上排直流注入链、下排信号发生器正弦验证,中间列只有正弦能证的六件事 | | `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 | diff --git a/tools/arch_diagram/gen_arch_endtoend_svg.py b/tools/arch_diagram/gen_arch_endtoend_svg.py new file mode 100644 index 00000000..2019cd4c --- /dev/null +++ b/tools/arch_diagram/gen_arch_endtoend_svg.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/架构总览_端到端链路.svg —— 端到端链路鸟瞰(纯标准库)。 + +这张图回答什么问题:一个电气量从芯片引脚走到 104 报文 / 显存 / 记录文件, +中间要穿过哪些框;以及「谁在什么时候被谁唤醒、谁独占写、谁只是同一份代码的 +另一颗脑袋」——即**数据流与控制握手**,不是层间依赖清单。 + +与同目录另两张总览图的分工: + gen_arch_svg.py → 架构分层图:六层 + 每层有哪些模块(回答「谁属于哪一层」) + gen_barrier_svg.py → 就绪屏障图:Event Flags 模型细节 + 本脚本 → 端到端链路:片外硬件 → 驱动 → 算法 → 任务 → 落点 → 对外/同构端 +线型三类:实线 = 数据与读写;短虚线 = 命令 / 请求 / 配置;紫点线 = 屏障位、心跳与同源镜像。 + +为什么不用 Mermaid:本图 42 框 37 边,含跨行长边(遥测快照 → 主机侧第三端独占一条行通道)、 +同列相邻框之间的并行双线(定值下行 / YK 上行各占一个出框位置)、绕底槽的握手边 +(就绪屏障 → 控制台要跨过整行),还要把五条分层纵带画成等宽对齐的框。 +Mermaid 自动布局既不可控(多节点跨层依赖会画成"意大利面"),也表达不了 +「一条边独占一条通道」这类判据。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_arch_endtoend_svg.py +输出: + docs/images/架构总览_端到端链路.svg +改内容:编辑 NODES / EDGES / BANDS / 文案常量后重跑。 +调坐标:先改顶部几何常量(CW / CH / PX / PY 与三条独占通道 Y、一条走廊 X), +单边微调走 EDGES 尾部旋钮 f / g(出框、入框位置 0~1)与 my / gx(独占通道坐标)。 +产物内容零不确定:**不得**写入时间戳 / 提交号 / 绝对路径(generated-fresh 逐字节比对)。 +""" +import html +import os +import sys +import xml.etree.ElementTree as ET + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "架构总览_端到端链路.svg") + +# ----------------------------- 画布与几何 ----------------------------- +PAD_L = 34 +PAD_R = 30 +TITLE_H = 74 +LEGEND_H = 50 +FOOT_GAP = 14 + +CW = 148 +CH = 58 +COLS = 6 +ROWS = 9 +PX = 196 +PY = 92 +BAND_PAD = 13 +BAND_TITLE_H = 30 +BAND_BOTTOM_PAD = 24 # 带内底部专用走廊(第 8 行下方绕行的握手边走这里) + +GRID_W = (COLS - 1) * PX + CW +W = PAD_L + GRID_W + PAD_R +X0 = PAD_L + BAND_PAD +Y0 = TITLE_H + 30 + BAND_PAD + BAND_TITLE_H +H = Y0 + (ROWS - 1) * PY + CH + BAND_PAD + BAND_BOTTOM_PAD + FOOT_GAP + LEGEND_H + +# 独占通道 / 走廊(长边走这里;数值落在通道内,改动后回看有无重叠) +CHAN_B = Y0 + 2 * PY + CH + 11 # 第 2、3 行之间(遥测快照 → 主机侧第三端) +CHAN_B2 = Y0 + 2 * PY + CH + 24 # 同一条走廊的下半股(遥测快照 → iec104Task,两条平行不共线) +CHAN_78 = Y0 + 7 * PY + CH + 12 # 第 7、8 行之间(RS-485 → 屏板链路) +CHAN_SLOTS = Y0 + 8 * PY + CH + 13 # 第 8 行下方底槽(就绪屏障 → 控制台,带内独占) +GUT_C4C5 = X0 + 4 * PX + CW + (PX - CW) * 0.62 # 第 4、5 列之间垂直走廊(配置引擎 → 落点) + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" +TITLE_FONT = 20 +SUB_FONT = 12.5 +BAND_FONT = 13.5 +BAND_SUB_FONT = 10.5 +NODE_FONT = 11.5 +NODE_SUB_FONT = 9.5 +EDGE_FONT = 9.5 +FOOT_FONT = 9.5 + +# 调色板(沿用 docs/架构设计/图形绘制流程.md;紫色整族让给「屏障 / 事件」,纵带改用玫红) +C_HW = ("#475569", "#f1f5f9") +C_DRV = ("#d97706", "#fffbeb") +C_MID = ("#16a34a", "#f0fdf4") +C_TASK = ("#0d9488", "#f0fdfa") +C_SVC = ("#e11d48", "#fff1f2") +C_EXT = ("#2563eb", "#eff6ff") +E_DATA = "#334155" +E_CTRL = "#64748b" +E_EVT = "#7c3aed" +GRID_LINE = "#cbd5e1" + +TITLE = "STM32F407ZGTx 工业 DTU 平台 · 端到端链路总览" +SUBTITLE = ("一个电气量从引脚到 104 报文 / 显存 / 记录文件穿过的框,与「谁在什么时候被谁唤醒」;" + "分层归属与层内模块清单见 项目架构总览.svg,本图只画链路与握手") +FOOTNOTE = ("口径:AGENTS.md「数据主线」+ docs/架构设计/项目架构总览.md(行号级证据在那份文档与代码里)。" + "RS-485 采集未接线:rs485_task 有实现但不建任务,接线后数据进语义取数层;" + "其余行为条件(未标定闭锁 / 就绪位 NoClear / CH395F 单连接 / V0.1 双转换器无共同采样触发)见正文," + "它们只改变判据与端口、不改变本图的框与连线。") + +# ----------------------------- 纵带(一列 = 一条带) ----------------------------- +BANDS = [ + (0, "片外硬件", "板级器件 · 不走软件分层", C_HW), + (1, "驱动层 Drivers/BSP", "手写芯片驱动与总线", C_DRV), + (2, "中间件与算法", "取数 → 相量 → 快照", C_MID), + (3, "任务编排与握手", "App/task · 就绪屏障", C_TASK), + (4, "平台服务与数据落点", "记录 / 配置 / 网络门面", C_SVC), +] + +# ----------------------------- 节点:(id, 列, 行, 标题, 副题) ----------------------------- +NODES = [ + ("tpafe", 0, 0, "TPAFE5160 前端", "并行总线 · 8 路"), + ("adc1", 0, 1, "片内 ADC1 + DMA", "TIM3 TRGO 乒乓"), + ("eth", 0, 4, "CH395F 以太网", "SPI 串行化"), + ("nandchip", 0, 5, "GD5F SPI NAND", "2 KB 页 · ECC"), + ("rtc", 0, 6, "SD2506 RTC", "I2C1 硬件亚秒"), + ("panel", 0, 7, "屏板 160×160", "USART1 700000"), + ("rs485hw", 0, 8, "RS-485 总线", "UART4 · 未接线(暂缓)"), + + ("drv_tpafe", 1, 0, "TPAFE 驱动", "原子快照 snapshot"), + ("drv_adc", 1, 1, "ADC · TIM3 · DMA", "手写上电自校准"), + ("drv_ch395", 1, 4, "CH395F 驱动", "中断集中读清"), + ("drv_nand", 1, 5, "NAND 驱动 + FTL", "ECC · BBT"), + ("drv_rtc", 1, 6, "RTC 驱动 + 墙钟", "sys_clock epoch"), + ("sb_link", 1, 7, "屏板链路驱动", "借道 RS485"), + ("console", 1, 8, "控制台 UART5", "4 KB 环形"), + + ("adc_input", 2, 0, "语义取数层", "有效性 · 标定"), + ("phasor", 2, 1, "全周傅氏相量", "ring → phasor"), + ("meas", 2, 2, "遥测快照双区", "测频 200 ms"), + ("netmw", 2, 4, "NET 中间件", "socket · 挂起"), + ("fs", 2, 5, "FTL + FatFs", "dhara · 8.3 短名"), + ("timeproto", 2, 6, "对时协议", "推送写 RTC"), + ("lvgl", 2, 7, "LVGL + ui_port", "160×160 1bpp"), + ("selfheal", 2, 8, "IWDG + Fault", "心跳掩码"), + + ("adc_task", 3, 0, "adcTask", "1 kHz 消费者"), + ("protect_task", 3, 1, "protectTask", "20 ms 判据"), + ("protset", 3, 2, "定值层", "prot_set 校验"), + ("iec104_task", 3, 3, "iec104Task", "100 ms · :2404"), + ("net_task", 3, 4, "netTask", "MsgQueue 深 8"), + ("default_task", 3, 5, "defaultTask", "记录唯一写者"), + ("time_sync_task", 3, 6, "timeSyncTask", "等 NET_READY"), + ("ui_task", 3, 7, "uiTask", "唯一写侧"), + ("barrier", 3, 8, "就绪屏障", "位组 · NoClear"), + + ("soe", 4, 1, "SOE 事件", "soe.log 定长"), + ("setfile", 4, 2, "定值落盘", "/prot_set.bin"), + ("cfgplane", 4, 3, "配置引擎", "net_cfg · ftp_cfg"), + ("services", 4, 4, "TCP 服务面", ":21 / :2404"), + ("files", 4, 5, "文件系统落点", "/log · /wave"), + ("vollock", 4, 6, "卷锁 + 账本", "三路 try 协定"), + ("disp", 4, 7, "一份显存三出", "屏板 · :7003"), + ("ftp_task", 4, 8, "ftpTask", "lftpd 根 :21"), + + ("hosttest", 5, 2, "主机侧第三端", "直打生产 .c"), + ("peer", 5, 3, "上位侧对端", "104 主站"), + ("sim", 5, 7, "PC 模拟器", "SDL2 同源码"), +] + +# ----------------------------- 边 ----------------------------- +# (起点, 终点, 标签, 类别, 出框, 入框, 旋钮) +# 类别 d=数据/读写 c=命令/请求/配置 e=屏障·心跳·同源 +# 出/入框 t b l r;出框轴与入框轴同向时自动加一段(水平用 gx,垂直用 my) +# 旋钮 f/g = 该边上的位置 0~1(默认 0.5);my/gx = 独占通道坐标;seg/t = 标签落点 +EDGES = [ + ("tpafe", "drv_tpafe", "8 路帧", "d", "r", "l", {}), + ("drv_adc", "adc1", "TRGO", "c", "l", "r", {}), + ("drv_tpafe", "adc_input", "原子快照", "d", "r", "l", {}), + ("drv_adc", "adc_input", "DMA 双缓冲", "d", "t", "b", {"f": 0.72, "g": 0.28}), + ("adc_input", "phasor", "语义通道", "d", "b", "t", {}), + ("phasor", "meas", "相量", "d", "b", "t", {}), + ("adc_task", "protect_task", "1 kHz → 20 ms", "c", "b", "t", {}), + ("meas", "iec104_task", "同一上游", "d", "b", "t", {"f": 0.3, "g": 0.5, "my": CHAN_B2}), + ("protset", "protect_task", "定值", "d", "t", "b", {"f": 0.34, "g": 0.34}), + ("iec104_task", "protset", "YK 请求", "c", "t", "b", {"f": 0.68, "g": 0.68}), + ("iec104_task", "cfgplane", "脏段", "c", "r", "l", {}), + ("protect_task", "soe", "边沿投递", "d", "r", "l", {}), + ("default_task", "files", "唯一写者", "d", "r", "l", {}), + ("default_task", "vollock", "try 语义", "c", "b", "t", {"f": 0.86, "g": 0.26}), + ("eth", "drv_ch395", "SPI 事务", "d", "r", "l", {}), + ("drv_ch395", "netmw", "集中读清", "d", "r", "l", {}), + ("netmw", "net_task", "串行执行", "d", "r", "l", {}), + ("net_task", "services", "单连接", "d", "r", "l", {}), + ("services", "peer", "TCP 对端", "c", "r", "b", {"f": 0.26, "g": 0.6}), + ("nandchip", "drv_nand", "页读写", "d", "r", "l", {}), + ("drv_nand", "fs", "逻辑块", "d", "r", "l", {}), + ("fs", "default_task", "挂载", "d", "r", "l", {}), + ("rtc", "drv_rtc", "I2C1", "d", "r", "l", {}), + ("drv_rtc", "timeproto", "墙钟", "d", "r", "l", {}), + ("time_sync_task", "timeproto", "收包对时", "d", "l", "r", {}), + ("lvgl", "sb_link", "帧 B", "d", "l", "r", {}), + ("ui_task", "lvgl", "灌数", "d", "l", "r", {}), + ("ui_task", "disp", "同一显存", "d", "r", "l", {}), + ("sb_link", "panel", "700000", "d", "l", "r", {}), + ("ui_task", "selfheal", "100 ms 喂心跳", "c", "b", "t", {"f": 0.26, "g": 0.32, "my": CHAN_78}), + ("ui_task", "barrier", "等就绪", "c", "b", "t", {"f": 0.62}), + ("barrier", "ftp_task", "WaitAll", "d", "r", "l", {}), + ("disp", "sim", "同源像素", "e", "r", "l", {}), + ("meas", "hosttest", "纯逻辑直跑", "e", "b", "b", {"f": 0.82, "g": 0.62, "my": CHAN_B}), + ("barrier", "console", "唤醒发送", "e", "b", "b", {"my": CHAN_SLOTS}), + ("cfgplane", "files", "唯一落盘者", "d", "r", "r", {"f": 0.72, "g": 0.5, "gx": GUT_C4C5}), + ("rs485hw", "sb_link", "回调宿主", "d", "t", "b", {"f": 0.28, "g": 0.28, "my": CHAN_78}), +] + +OVERFLOW = [] + +def emit(s): + """控制台打印按目标编码替换,绝不因 GBK 终端遇上 → / × 之类字符而崩 + (generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n") + +def esc(s): + return html.escape(s, quote=True) + +def text_w(s, fs): + """估宽:CJK / 全角 / 箭头 ≈ 1em,其余 ≈ 0.58em(宁大勿小)。""" + w = 0.0 + for ch in s: + o = ord(ch) + if o > 0x2E80 or ch in "→×·…—": + w += 1.0 + else: + w += 0.58 + return w * fs + +def check_fit(s, fs, avail, where): + got = text_w(s, fs) + if got > avail: + OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, got, avail, s)) + +def wrap(s, fs, avail, max_lines, where): + """按估宽折行;超行数截断并加 … 记一条 OVERFLOW。""" + lines, cur = [], "" + for ch in s: + if cur and text_w(cur + ch, fs) > avail: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + if len(lines) > max_lines: + lines = lines[:max_lines] + lines[-1] = lines[-1].rstrip()[:-1] + "…" + OVERFLOW.append("%s: 折行超 %d 行 [%s]" % (where, max_lines, s)) + return lines + +def rect(x, y, w, h, fill, stroke, rx=7, sw=1.2, opacity=1.0): + o = ' fill-opacity="%.2f"' % opacity if opacity != 1.0 else "" + return ('' % (x, y, w, h, rx, fill, stroke, sw, o)) + +def txt(x, y, s, fs, fill, anchor="start", weight="normal", opacity=1.0): + o = ' fill-opacity="%.2f"' % opacity if opacity != 1.0 else "" + return ('%s' + % (x, y, FONT, fs, fill, anchor, weight, o, esc(s))) + +ECOL = {"d": E_DATA, "c": E_CTRL, "e": E_EVT} +EDASH = {"d": None, "c": "5 3", "e": "1.5 3"} + +def poly(points, cls): + d = " ".join(("M" if i == 0 else "L") + "%.1f %.1f" % p for i, p in enumerate(points)) + dash = EDASH[cls] + dd = ' stroke-dasharray="%s"' % dash if dash else "" + w = 1.35 if cls == "d" else 1.25 + return ('' % (d, ECOL[cls], w, dd, cls)) + +def elabel(x, y, s, cls): + """边标签:先铺白色底片,避免与线或其他标签叠字。""" + w = text_w(s, EDGE_FONT) + 7 + h = EDGE_FONT + 5 + return ('%s' + % (x - w / 2, y - h / 2, w, h, x, y + EDGE_FONT * 0.36, FONT, EDGE_FONT, + ECOL[cls], esc(s))) + +class Box(object): + def __init__(self, nid, col, row, label, sub): + self.id = nid + self.col = col + self.row = row + self.label = label + self.sub = sub + self.x = float(X0 + col * PX) + self.y = float(Y0 + row * PY) + self.w = float(CW) + self.h = float(CH) + + def side(self, sd, f): + """出框点:sd ∈ t b l r,f 沿该边位置(0 靠左/上,1 靠右/下)。""" + if sd == "t": + return (self.x + self.w * f, self.y) + if sd == "b": + return (self.x + self.w * f, self.y + self.h) + if sd == "l": + return (self.x, self.y + self.h * f) + return (self.x + self.w, self.y + self.h * f) + +def route(a, b, ex, en, k): + """折线点集:出框轴与入框轴垂直 → 3 点;同轴 → 4 点(加一段 gx 或 my)。""" + f = k.get("f", 0.5) + g = k.get("g", 0.5) + pa = a.side(ex, f) + pb = b.side(en, g) + ax = ex in "lr" + bx = en in "lr" + if ax != bx: + if ax: # 水平出、垂直入 + return [pa, (pb[0], pa[1]), pb] + return [pa, (pa[0], pb[1]), pb] + if ax: # 都水平 + if abs(pa[1] - pb[1]) < 0.6: + return [pa, pb] # 同一行:直连(别插退化折点,否则标签落点偏半格) + gx = k.get("gx", (pa[0] + pb[0]) / 2) + return [pa, (gx, pa[1]), (gx, pb[1]), pb] + if abs(pa[0] - pb[0]) < 0.6: # 同一列:直连 + return [pa, pb] + my = k.get("my", (pa[1] + pb[1]) / 2) # 都垂直:走 my 水平通道 + return [pa, (pa[0], my), (pb[0], my), pb] + +def label_at(points, k): + """标签落点:默认整条折线的弧长中点(不会退化成「第一半的中点」); + seg = 指定段序号、t = 该段(或整条线)上的比例。""" + if "seg" in k: + i = k["seg"] + t = k.get("t", 0.5) + x0, y0 = points[i] + x1, y1 = points[i + 1] + return (x0 + (x1 - x0) * t, y0 + (y1 - y0) * t) + lens = [abs(points[i + 1][0] - points[i][0]) + abs(points[i + 1][1] - points[i][1]) + for i in range(len(points) - 1)] + total = sum(lens) or 1.0 + want = total * k.get("t", 0.5) + acc = 0.0 + for i, L in enumerate(lens): + if acc + L >= want or i == len(lens) - 1: + u = 0.5 if L <= 0 else min(1.0, max(0.0, (want - acc) / L)) + x0, y0 = points[i] + x1, y1 = points[i + 1] + return (x0 + (x1 - x0) * u, y0 + (y1 - y0) * u) + acc += L + return points[0] + +def build(): + boxes = [Box(*n) for n in NODES] + nodes = dict((b.id, b) for b in boxes) + col_band = dict((c, cb) for c, _t, _s, cb in BANDS) + + check_fit(TITLE, TITLE_FONT, W - PAD_L - PAD_R, "title") + + p = ['' % (W, H, W, H, FONT)] + defs = [''] + for cls in ("d", "c", "e"): + defs.append('' + '' % (cls, ECOL[cls])) + defs.append("") + p.append("".join(defs)) + p.append('' % (W, H)) + p.append(txt(PAD_L, 34, TITLE, TITLE_FONT, "#0f172a", weight="bold")) + for i, ln in enumerate(wrap(SUBTITLE, SUB_FONT, W - PAD_L - PAD_R, 2, "subtitle")): + p.append(txt(PAD_L, 55 + i * 15, ln, SUB_FONT, "#475569")) + + band_y = Y0 - BAND_PAD - BAND_TITLE_H + band_h = (ROWS - 1) * PY + CH + 2 * BAND_PAD + BAND_BOTTOM_PAD + BAND_TITLE_H + for col, title, sub, (fg, bg) in BANDS: + x = X0 + col * PX - BAND_PAD + w = CW + 2 * BAND_PAD + p.append(rect(x, band_y, w, band_h, bg, fg, rx=10, sw=1.1, opacity=0.5)) + check_fit(title, BAND_FONT, w - 14, "band[%d]" % col) + p.append(txt(x + 10, band_y + 19, title, BAND_FONT, fg, weight="bold")) + for j, ln in enumerate(wrap(sub, BAND_SUB_FONT, w - 20, 1, "band-sub[%d]" % col)): + p.append(txt(x + 10, band_y + 33, ln, BAND_SUB_FONT, fg, opacity=0.85)) + + for src, dst, label, cls, ex, en, k in EDGES: + pts = route(nodes[src], nodes[dst], ex, en, k) + p.append(poly(pts, cls)) + lx, ly = label_at(pts, k) + p.append(elabel(lx, ly, label, cls)) + + for b in boxes: + fg, _bg = col_band.get(b.col, C_EXT) + p.append(rect(b.x, b.y, b.w, b.h, "#ffffff", fg, rx=7, sw=1.3)) + p.append(rect(b.x, b.y, 3.5, b.h, fg, fg, rx=1.75, sw=0)) + check_fit(b.label, NODE_FONT, b.w - 16, "node[%s]" % b.id) + p.append(txt(b.x + 11, b.y + 24, b.label, NODE_FONT, "#0f172a", weight="bold")) + for j, ln in enumerate(wrap(b.sub, NODE_SUB_FONT, b.w - 18, 2, "node-sub[%s]" % b.id)): + p.append(txt(b.x + 11, b.y + 40 + j * 12, ln, NODE_SUB_FONT, "#475569")) + + ly = H - LEGEND_H + 4 + p.append('' + % (PAD_L, band_y + band_h + 16, W - PAD_R, band_y + band_h + 16, GRID_LINE)) + lx = PAD_L + for label, cls, kind in [("数据 / 读写", "d", None), ("命令 · 请求 · 配置", "c", "5 3"), + ("屏障位 · 心跳 · 同源镜像", "e", "1.5 3")]: + dd = ' stroke-dasharray="%s"' % kind if kind else "" + p.append('' % (lx, ly + 1, lx + 26, ly + 1, ECOL[cls], dd)) + p.append(txt(lx + 31, ly + 5, label, FOOT_FONT, "#334155")) + lx += 31 + text_w(label, FOOT_FONT) + 24 + for label, (fg, _bg) in [("片外硬件", C_HW), ("驱动", C_DRV), ("中间件与算法", C_MID), + ("任务与握手", C_TASK), ("服务与落点", C_SVC), ("对外 / 同构端", C_EXT)]: + p.append(rect(lx, ly - 5, 11, 11, "#ffffff", fg, rx=2.5, sw=1.2)) + p.append(txt(lx + 15, ly + 5, label, FOOT_FONT, "#334155")) + lx += 15 + text_w(label, FOOT_FONT) + 16 + if lx > W - PAD_R: + OVERFLOW.append("legend: 图例排到 %.0f 超出右边界 %.0f" % (lx, W - PAD_R)) + for j, ln in enumerate(wrap(FOOTNOTE, FOOT_FONT, W - PAD_L - PAD_R, 2, "footnote")): + p.append(txt(PAD_L, H - 26 + j * 12, ln, FOOT_FONT, "#64748b")) + + p.append("") + svg = "\n".join(p) + "\n" + ET.fromstring(svg) + d = os.path.dirname(OUT) + if not os.path.isdir(d): + os.makedirs(d) + with open(OUT, "wb") as f: + f.write(svg.encode("utf-8")) + emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT)) + if OVERFLOW: + for w in OVERFLOW: + emit("OVERFLOW WARN " + w) + emit("!! %d overflow warnings" % len(OVERFLOW)) + +if __name__ == "__main__": + build()