diff --git a/docs/images/菜单树总览.svg b/docs/images/菜单树总览.svg
new file mode 100644
index 00000000..1ae2e7ec
--- /dev/null
+++ b/docs/images/菜单树总览.svg
@@ -0,0 +1,235 @@
+
+
diff --git a/docs/产品需求/产品需求设计文档.md b/docs/产品需求/产品需求设计文档.md
index 8ff0f2b3..3f3fa74b 100644
--- a/docs/产品需求/产品需求设计文档.md
+++ b/docs/产品需求/产品需求设计文档.md
@@ -1804,10 +1804,9 @@ pre-commit run --all-files
### 8.2 菜单树(43 节点)
-
+
-> 该图仍是 42 节点时代所画(2026-10-29 新增的「录波回放」叶未进图):`tools/arch_diagram/` 里没有这张图的生成器,
-> 重画要另开一笔并走 `generated-fresh`;**本节下面的文字与代码生成物是真值**。
+> 图里的节点数、绑页与密码门读数由 `tools/arch_diagram/gen_menu_tree_svg.py` 现算自真值 `ui_menu_tree.txt`(改菜单树 → 同步脚本内 `NODE_SRC` 重跑);级联框在屏上怎么摆(几何)→ `docs/UI设计/菜单页面设计.md` §4.1。
**规模**:菜单节点 **9** 个(含根)+ 叶子节点 **34** 个 = **43** 节点;最大深度 **4 级(主界面 / 一级 / 二级 / 三级菜单)**。
**绑定现状**(2026-10-29 复扫):**已绑内容页 30 叶 / 未绑 4 叶**(页面注册表 `UI_PAGE_MAP_COUNT` = 30,
diff --git a/docs/架构设计/图形绘制流程.md b/docs/架构设计/图形绘制流程.md
index 644c8fb0..e9879e45 100644
--- a/docs/架构设计/图形绘制流程.md
+++ b/docs/架构设计/图形绘制流程.md
@@ -54,6 +54,22 @@
| `gen_data_trust_ladder_svg.py` | `docs/images/数据可信台阶.svg` | `STEPS` 五级台阶(状态 / 问题 / 依据)+ `BAND_LABEL_1` / `GATE_LABEL` / `FOOTNOTE`(台阶状态变了才重画) |
| `gen_data_trust_dc_ac_svg.py` | `docs/images/ADC直流标定与交流验证.svg` | `BOXES_A` / `BOXES_B` 两条泳道 + `CHIPS` 六件事 + `NOTES` 红线与"证不到"清单 |
| `gen_wave_record_flow_svg.py` | `docs/images/故障录波数据流.svg` | `CHAIN` 主链四段 + `EDGE` 跨边界标签 + `TRAPS` 三条陷阱 + `FORBID` 四条红线(所有权、段形态或红线变了才重画) |
+| `gen_menu_tree_svg.py` | `docs/images/菜单树总览.svg` | **内嵌 `NODE_SRC`**(= `ui_menu_tree.txt` 的 43 行节点)+ 版面常量;仓库里跑会与真值逐行互验,不一致 exit 1(临时目录里读不到真值 → 用内嵌表,输出逐字节一致) |
+
+### 2.1 钩子扫不到的图:refgraph 产物与历史手画稿
+
+`generated-fresh` 的 `docs-svg` 条目只把 `tools/arch_diagram/gen_*.py` 拷进临时目录重跑逐字节比对。
+上表没列到的 `docs/images/*.svg` 就是下面这几张,**它们全都不在这条钩子的扫描面里**——改了不会有任何
+人报警,这就是「42 个节点的旧图挂在 43 个节点的标题下」能一路活到今天的原因。
+
+| 图 | 谁画的 | 用在哪 | 维护口径 |
+|---|---|---|---|
+| `docs/images/引用关系图.svg`、`docs/images/引用关系图_模块.svg` | `tools/refgraph/gen_refgraph.py`(graphviz,不在 `gen_*.py` 命名面里) | `docs/架构设计/引用关系图说明.md` 的产品 | 改了代码引用就跑 `tools\refgraph\gen_refgraph.bat` 重生成,**svg 与改动同一笔提交**,别只更新 `.dot`;口径 → `docs/架构设计/引用关系图说明.md` |
+| `docs/images/级联框总览.svg` | 2026-10-26 手画 | `docs/UI设计/菜单页面设计.md` §4.1(级联框显示几何:根框 + 整定子框) | 只作**显示几何示意**,不承载菜单树事实;树的结构 / 层级 / 绑页 → `docs/images/菜单树总览.svg`(有生成器) |
+| `docs/images/级联框几何计算.svg` | 同上 | `docs/UI设计/菜单页面设计.md` §5.3(逐框坐标推导) | 同上;坐标公式变了要重算重画,别在 SVG 上描字 |
+
+新增精确布局图一律走 `tools/arch_diagram/gen_*.py`(`docs-svg` 条目自动发现,**不必登记基线**);上面这几张
+是这套纪律的**例外面**,再多一张先说清为什么不能用生成器。
---
diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md
index 6dfed728..cb428933 100644
--- a/tools/arch_diagram/README.md
+++ b/tools/arch_diagram/README.md
@@ -21,6 +21,7 @@
| `gen_data_trust_ladder_svg.py` | 数据可信台阶图 | 采集数据可信度的五级台阶:现在踩在哪一级、下一级挡什么 |
| `gen_data_trust_dc_ac_svg.py` | ADC 直流标定与交流验证图 | 上排直流注入链、下排信号发生器正弦验证,中间列只有正弦能证的六件事 |
| `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 |
+| `gen_menu_tree_svg.py` | 菜单树总览图 | 43 节点的层级 / 绑页 / 密码门鸟瞰;内嵌 `ui_menu_tree.txt` 的节点行并与真值互验,防图与树漂移 |
| `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 |
| `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 |
| `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 |
diff --git a/tools/arch_diagram/gen_menu_tree_svg.py b/tools/arch_diagram/gen_menu_tree_svg.py
new file mode 100644
index 00000000..a163796b
--- /dev/null
+++ b/tools/arch_diagram/gen_menu_tree_svg.py
@@ -0,0 +1,458 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 docs/images/菜单树总览.svg —— 菜单树的层级、绑页与密码门鸟瞰图。
+
+数据源 = ui_menu_tree.txt(真值,tools/menu_gen/gen_menu.py 的输入)。本脚本内嵌同一张
+节点表 NODE_SRC,两种运行环境都要有确定行为:
+ · 在仓库里跑:先与 ui_menu_tree.txt 的节点行逐行互验,不一致直接 exit 1 打印差异
+ —— 改了菜单树必须同步内嵌表,图才会跟着变(漂移摆在台面上,而不是让 SVG 悄悄失真);
+ · 在临时目录跑(pre-commit 的 generated-fresh 钩子把脚本拷出去重跑):读不到输入
+ 文件 → 退回内嵌表 → 输出逐字节一致。
+"""
+import html
+import pathlib
+import sys
+import xml.etree.ElementTree as ET
+
+ROOT = pathlib.Path(__file__).resolve().parents[2]
+TREE_FILE = ROOT / "ui_menu_tree.txt"
+OUT = ROOT / "docs" / "images" / "菜单树总览.svg"
+
+# ui_menu_tree.txt 的节点行(缩进 2 空格 = 一层;#P = 需密码;#page=x = 已绑页面模块)。
+# 改菜单树 → 同步这张表 → 重跑本脚本 → 脚本与产物同一笔提交。
+NODE_SRC = [
+ "主界面",
+ " 装置信息 #page=device_info",
+ " 实时数据",
+ " 交流量 #page=ac",
+ " 直流量 #page=dc",
+ " 遥信量 #page=yx",
+ " 参数定值",
+ " 整定",
+ " 参数 #P #page=view_param",
+ " 定值 #P #page=view_set",
+ " 控制字 #P #page=view_word",
+ " 软压板 #P #page=view_plate",
+ " 查看",
+ " 参数 #page=view_param",
+ " 定值 #page=view_set",
+ " 控制字 #page=view_word",
+ " 软压板 #page=view_plate",
+ " 三遥设置",
+ " 遥测死区 #P #page=deadband",
+ " 遥测系数 #P #page=calib",
+ " 遥信类型 #P",
+ " 遥信防抖 #P",
+ " 双点遥信 #P",
+ " 装置维护",
+ " 时钟设置 #P #page=clock",
+ " 强制复归 #P #page=reset",
+ " 手动录波 #P #page=wave_manual",
+ " 清除记录 #P #page=clear",
+ " 通讯参数",
+ " 通讯设置 #P #page=comm",
+ " 网口设置 #P #page=net_cfg",
+ " SNTP设置 #P",
+ " 记录查询",
+ " SOE记录 #page=soe",
+ " 事故记录 #page=accident",
+ " 操作记录 #page=oper",
+ " 保护告警 #page=alarm",
+ " 保护启动 #page=start",
+ " 遥控记录 #page=yk",
+ " 自检记录 #page=selftest",
+ " 运行记录 #page=runlog",
+ " 运行报告 #page=runreport",
+ " 录波回放 #page=wave_play",
+]
+
+# ---- 版面 ----
+FS_LEAF = 12
+FS_MENU = 12
+FS_ROOT = 13
+FS_PAGE = 9
+PITCH = 18
+ROW_TOP = 96
+X_COL = (28, 150, 306, 474)
+TRUNK_OFF = 22
+LEAF_TEXT = 14
+PAD_X = 9
+BAND_X = X_COL[1] - 12
+BAND_R = 624
+PANEL_X = 640
+W = 990
+TREE_LIMIT = 630
+
+INK = "#0f172a"
+MUTED = "#475569"
+FAINT = "#94a3b8"
+LINE = "#334155"
+RULE = "#cbd5e1"
+C_BLUE = "#1d4ed8"
+BG_BLUE = "#eff6ff"
+C_VIOLET = "#6d28d9"
+BG_VIOLET = "#f5f3ff"
+C_AMBER = "#b45309"
+C_ROSE = "#be123c"
+C_GREEN = "#15803d"
+BG_GREEN = "#f0fdf4"
+BG_GRAY = "#f8fafc"
+FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
+
+_over = []
+
+
+def emit(msg):
+ """控制台可能是 GBK:中文/符号打印不能把生成器打崩(假红 = 钩子重跑失败)。"""
+ enc = getattr(sys.stdout, "encoding", None) or "utf-8"
+ safe = str(msg).encode(enc, errors="replace").decode(enc, errors="replace")
+ sys.stdout.write(safe + "\n")
+
+
+def tw(text, fs):
+ """近似字宽:CJK 约 1.0 em,其余 0.58 em。"""
+ return sum(fs * (1.0 if ord(c) > 0x2E80 else 0.58) for c in str(text))
+
+
+def esc(text):
+ return html.escape(str(text), quote=True)
+
+
+def fit(x_right, limit, what):
+ if x_right > limit:
+ _over.append("%s: %.0f > %.0f" % (what, x_right, limit))
+
+
+def node_lines(raw):
+ """取节点行:丢空行与 # 指令行(指令改了不影响图的形状)。"""
+ out = []
+ for ln in raw.splitlines():
+ ln = ln.rstrip("\r")
+ if not ln.strip() or ln.strip().startswith("#"):
+ continue
+ out.append(ln)
+ return out
+
+
+def verify_src():
+ """仓库里跑 = 与真值互验;临时目录里跑 = 静默用内嵌表。"""
+ if not TREE_FILE.exists():
+ emit("note: 未找到 ui_menu_tree.txt(临时目录重跑场景)→ 用内嵌 NODE_SRC")
+ return 0
+ live = node_lines(TREE_FILE.read_text(encoding="utf-8-sig"))
+ if live == list(NODE_SRC):
+ emit("互验 OK:%d 行节点与 ui_menu_tree.txt 一致" % len(live))
+ return 0
+ emit("[FAIL] ui_menu_tree.txt 与本脚本内嵌 NODE_SRC 不一致 —— 图会失真,请同步内嵌表后重跑")
+ for i in range(max(len(live), len(NODE_SRC))):
+ a = live[i] if i < len(live) else "<缺>"
+ b = NODE_SRC[i] if i < len(NODE_SRC) else "<缺>"
+ if a != b:
+ emit(" 第 %d 行:真值 %r / 内嵌 %r" % (i + 1, a, b))
+ return 1
+
+
+def parse(lines):
+ """节点行 → (根, 文档序节点表)。"""
+ root = None
+ docs = []
+ stack = []
+ for ln in lines:
+ depth = (len(ln) - len(ln.lstrip(" "))) // 2
+ page = None
+ pwd = False
+ keep = []
+ for tok in ln.strip().split():
+ if tok == "#P":
+ pwd = True
+ elif tok.startswith("#page="):
+ page = tok[len("#page="):]
+ else:
+ keep.append(tok)
+ node = {"depth": depth, "label": " ".join(keep), "page": page,
+ "pwd": pwd, "kids": [], "y": 0.0, "w": 0.0}
+ docs.append(node)
+ while stack and stack[-1]["depth"] >= depth:
+ stack.pop()
+ if stack:
+ stack[-1]["kids"].append(node)
+ else:
+ root = node
+ stack.append(node)
+ return root, docs
+
+
+def layout(root):
+ """叶按文档序占行;菜单 y = 首末子节点 y 的中点。返回叶行数。"""
+ state = [0]
+
+ def walk(n):
+ if not n["kids"]:
+ n["y"] = ROW_TOP + state[0] * PITCH
+ state[0] += 1
+ return
+ for k in n["kids"]:
+ walk(k)
+ n["y"] = (n["kids"][0]["y"] + n["kids"][-1]["y"]) / 2.0
+
+ walk(root)
+ return state[0]
+
+
+def yspan(n):
+ """子树占用的 y 区间。"""
+ if not n["kids"]:
+ return n["y"], n["y"]
+ lo, _ = yspan(n["kids"][0])
+ _, hi = yspan(n["kids"][-1])
+ return lo, hi
+
+
+def tally(docs):
+ menus = [n for n in docs if n["kids"]]
+ leaves = [n for n in docs if not n["kids"]]
+ unbound = [n for n in leaves if not n["page"]]
+ return {"total": len(docs), "menus": len(menus), "leaves": len(leaves),
+ "bound": len(leaves) - len(unbound), "unbound": unbound,
+ "pwd": len([n for n in leaves if n["pwd"]]),
+ "depth": max(n["depth"] for n in docs) + 1}
+
+
+QA = chr(34) # 双引号:拼 SVG 属性值用,免得在字面量里嵌引号
+
+
+def r1(v):
+ """一位小数(决定输出逐字节稳定)。"""
+ return format(float(v), ".1f")
+
+
+def attrs(pairs):
+ out = []
+ for key, val in pairs:
+ out.append(key + "=" + QA + str(val) + QA)
+ return " ".join(out)
+
+
+def txt(x, y, body, fs, fill, weight="normal", anchor="start"):
+ head = attrs([("x", r1(x)), ("y", r1(y)), ("font-size", fs), ("fill", fill),
+ ("font-weight", weight), ("text-anchor", anchor)])
+ return "" + esc(body) + ""
+
+
+def rrect(x, y, w, h, fill, stroke, rx=5, sw=1.2):
+ return ""
+
+
+def line(x1, y1, x2, y2, stroke, sw=1.2, dash=None):
+ pairs = [("x1", r1(x1)), ("y1", r1(y1)), ("x2", r1(x2)), ("y2", r1(y2)),
+ ("stroke", stroke), ("stroke-width", sw)]
+ if dash:
+ pairs.append(("stroke-dasharray", dash))
+ return ""
+
+
+def svg_header(h):
+ return ["",
+ "")
+ body = "\n".join(s) + "\n"
+ OUT.parent.mkdir(parents=True, exist_ok=True)
+ OUT.write_bytes(body.encode("utf-8"))
+ try:
+ ET.fromstring(body)
+ except ET.ParseError as exc:
+ emit("XML FAIL: %s" % exc)
+ return 1
+ emit("SVG %dx%d 节点 %d 菜单 %d 叶 %d 绑页 %d 未绑 %d 密码门 %d 最深 %d 级"
+ % (W, h, c["total"], c["menus"], c["leaves"], c["bound"],
+ len(c["unbound"]), c["pwd"], c["depth"]))
+ if _over:
+ for item in _over:
+ emit("OVERFLOW " + item)
+ emit("[FAIL] 版面溢出 %d 处 —— 调版面常量后重跑" % len(_over))
+ return 1
+ emit("OVERFLOW 0 → " + str(OUT.relative_to(ROOT)))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())