diff --git a/claude-brain/SKILL.md b/claude-brain/SKILL.md new file mode 100644 index 0000000..f05c087 --- /dev/null +++ b/claude-brain/SKILL.md @@ -0,0 +1,119 @@ +--- +name: claude-brain +description: > + "大脑 + 手脚" 多 agent 协作模式:唯一的主 agent(Claude Fable 5.1,"大脑")只做分析和决策, + 不执行任何操作;所有实际动作(读代码、改文件、跑命令、调 MCP、烧板子……)都派给任务子 agent + (默认 Opus 5.5,大脑可按难度改选 Sonnet/Haiku)去做。子 agent 做完把结果写成报告回给大脑, + 大脑再分析、再派活,循环直到大脑判定完成。双方的全部交互记录都以 md 形式只存放在 ./Notes/auto 下。 + 只要用户说 "claude-brain"、"用大脑模式"、"brain 模式"、"让 Fable 当主 agent"、"Fable 规划 Opus 执行"、 + "主 agent 只分析不动手"、"主从 agent 循环做这个任务"、"大脑分析、子 agent 执行"、 + "use claude-brain"、"brain/worker loop",就必须使用这个 skill —— 哪怕他们只说了 + "用 brain 跑一下这个任务"、"让大脑来拆解这个活"。 +--- + +# claude-brain:大脑分析,子 agent 执行 + +## 三个角色 + +| 角色 | 谁 | 数量 | 模型 | 能做什么 | +|---|---|---|---|---| +| **大脑(brain)** | 一个持续存活的子 agent | 全程**唯一一个** | **只有它**用 Fable 5.1(`fable`) | 读 `./Notes/auto` 里的记录(必要时只读查看文件),分析、决策、写任务单。不改任何东西、不跑命令 | +| **任务子 agent(worker)** | 每个任务派一个新的 | 任意多个,可并行 | 默认 Opus 5.5(`opus`);大脑可按难度指定 `sonnet` / `haiku`,**不能**用 `fable` | 执行任务单,把结果写成报告 | +| **调度员(dispatcher)** | 当前这个 Claude Code 会话(就是你) | 一个 | 会话本身的模型 | 只做机械转递:启动大脑、把大脑的任务单发给 worker、把报告路径交回大脑、写记录文件、替大脑问用户 | + +为什么要有调度员:大脑的工具被限制为只读,它不能自己派 agent、也不能写文件;而嵌套派 agent 在 Claude Code 里也不可靠。所以"手"一律由会话来转递。 + +**调度员的纪律**:你不是第二个大脑。不要自己分析任务、不要改写或"优化"大脑的任务单、不要替大脑判断完成与否、不要自己动手干活(哪怕看起来一行命令就能搞定)。你只照抄、转递、存档。如果大脑的指令有明显问题(比如路径不存在),把问题原样反馈给大脑让它重新决定,而不是自己修。 + +## 交互记录:只放在 ./Notes/auto + +所有大脑与 worker 之间的交互 md 都只能写在**当前工程根目录**下的 `./Notes/auto/` 里(每次运行一个子目录)。不要写到 `Notes/` 其它位置、工程其它目录或临时目录。worker 执行任务本身需要改的工程文件(包括工程里的 .md 文档)不受此限——这条约束针对的是"协作记录"。 + +``` +./Notes/auto/-<短slug>/ +├── 00-goal.md # 用户原始任务 + 约束(调度员写) +├── log.md # 每轮一行流水:轮次/动作/状态(调度员追加) +├── R01-brain.md # 第 1 轮大脑决策原文(调度员照抄写入) +├── R01-T1-task.md # 第 1 轮任务 1 的任务单(调度员从大脑决策中原样抽出) +├── R01-T1-report.md # worker 写的执行报告 +├── R01-T2-task.md / R01-T2-report.md ... +├── R02-brain.md ... +├── Rxx-user.md # 大脑要问用户时:问题与用户回答 +├── agents.md # 子 agent 名册:每个 agent 的角色/任务/模型/状态(调度员维护) +└── FINAL.md # 大脑判定完成后的总结(调度员照抄) +``` + +模板见 `references/templates.md`,第一次运行时读一下。 + +## 子 agent 名册:每新增一个 agent,就把全表亮一遍 + +用户看不到后台 agent 的内部,他需要随时知道"现在有哪些 agent、各用什么模型、谁在跑谁跑完了"。所以调度员维护 `agents.md`,并且**每次启动一个新 agent(大脑或 worker)之后,以及每次有 agent 结束之后,都在对用户的回复里把整张表完整打印一遍**——不是只报新增的那一行。表格很短,重复打印的成本远低于用户去翻历史消息的成本。 + +表的列:`#`、`角色/任务`、`模型(指定)`、`状态`、`启动`、`结束`、`报告`。状态只用四个词:`运行中`、`完成`、`失败/受阻`、`已接替`(大脑被新实例接替时旧行标此状态)。大脑永远是第 1 行,模型一栏写 `fable`;worker 按派出顺序编号。模板见 `references/templates.md`。 + +"模型(指定)"是调度员传给 Agent 工具的 `model` 参数。要核实实际生效的模型,跑 `scripts/check_models.sh`(读本地 subagent 转录里每条回复的 `"model"` 字段);在 FINAL 汇报时附上一次核实结果,这样每次运行都有"大脑确实是 Fable、worker 确实没有用 Fable"的审计证据。 + +## 流程 + +### 0. 准备 + +1. 确认工程根目录(当前工作目录)。创建 `./Notes/auto/-/`,写 `00-goal.md`:用户的原话 + 你从对话里知道的约束(不要加自己的分析)。 +2. 确保大脑的 agent 定义已安装:把本 skill 的 `agents/claude-brain.md` 复制到 `~/.claude/agents/claude-brain.md`(不存在或内容不同时覆盖)。这个定义把大脑的工具锁成 `Read, Glob, Grep`,从机制上保证它"只分析不动手"。 + - 若本次会话里 `claude-brain` 这个 subagent_type 还不可用(新装的定义可能要重启会话才生效),就退回用 `general-purpose`,并在提示词里写明工具限制(见 `agents/claude-brain.md` 正文,原样附上)。告诉用户:下次重启后就会是硬限制。 + +### 1. 启动唯一的大脑 + +用 Agent 工具:`subagent_type: "claude-brain"`(或退回 `general-purpose`),**`model: "fable"`**(显式传,别依赖默认),`description: "claude-brain 主 agent"`。提示词给出: +- 运行目录的绝对路径,让它读 `00-goal.md` +- 让它按 `agents/claude-brain.md` 里的决策格式回复第 1 轮决策 + +记下这个 agent 的 ID/名字,在 `agents.md` 写入第 1 行并向用户打印全表。**之后每一轮都用 SendMessage 把消息发给同一个大脑**,不要新建大脑——全程只有一个大脑,它的上下文就是它对任务的理解。(SendMessage 是延迟加载工具,先 `ToolSearch("select:SendMessage")`。) + +只有当大脑确实无法再联系上(报错、被终止)时,才新起一个来**接替**:提示它先通读整个运行目录恢复状态。任何时刻都不能同时存在两个大脑。 + +### 2. 每一轮的循环 + +收到大脑的决策后: + +1. 把决策原文写入 `Rnn-brain.md`,在 `log.md` 追加一行。 +2. 看决策里的 `状态`: + - **CONTINUE**:对每个任务,把任务单原样写入 `Rnn-Tk-task.md`,然后派 worker(下一节)。大脑标为可并行的任务在同一条消息里一起派出;有依赖的按顺序派。 + - **ASK_USER**:用 AskUserQuestion(或直接在对话里)把大脑的问题原样问用户,问答写入 `Rnn-user.md`,然后把文件路径发回大脑。 + - **DONE**:把大脑的总结写入 `FINAL.md`,向用户汇报(第 4 步)。 +3. 本轮所有 worker 结束后,SendMessage 给大脑:`第 nn 轮报告已就绪:<各 report.md 的绝对路径>。请阅读后给出第 nn+1 轮决策。` 不要附上你自己的摘要或评价——让大脑自己读原始报告。 + +### 3. 派 worker + +Agent 工具,`subagent_type: "general-purpose"`,`model` 取任务单里大脑指定的模型(缺省 `opus`;若写了 `fable` 视为无效,改回 `opus` 并在 log 里记一笔,因为 Fable 只属于大脑)。提示词: + +``` +你是 claude-brain 流程中的任务子 agent。 +请先读任务单:,按其中的目标、背景和验收标准完成任务。 +- 只做任务单要求的事,不扩大范围;遇到任务单没覆盖的关键决策,停下并在报告里写明,而不是自己拍板。 +- 不要再派生子 agent。 +- 完成(或失败/受阻)后,按任务单末尾给的报告格式,把报告写到: +- 报告要写事实:做了什么、改了哪些文件、命令输出/测量值等证据、验收标准逐条是否满足、遗留问题。失败就如实写失败。 +- 这份报告是唯一会被交回大脑的东西,除此之外不要在 ./Notes/auto 以外创建协作用的 md。 +``` + +每派出一个(或一批)worker,立刻在 `agents.md` 追加行、向用户打印全表。 + +worker 返回后,确认报告文件确实存在;不存在就把 worker 的最终回复原文写进该报告文件,并注明"worker 未按要求写报告,以下为其最终回复原文"。然后更新 `agents.md` 中该行的状态/结束时间/报告路径,再向用户打印全表。 + +### 4. 结束 + +大脑给出 DONE 后,向用户汇报:任务结果(引用 FINAL.md 的要点)、共几轮、`agents.md` 全表、`scripts/check_models.sh` 的实际模型核实结果、运行目录路径。 + +### 自主模式:用户说"别问我" + +用户可能在启动时或中途说"不要停下来问我"、"所有问题主 agent 自己定"。这时调度员把这句话记入 `Rnn-user.md`,并告诉大脑:**ASK_USER 状态不再可用**,worker 报告里"需要用户决定"的点和大脑原本想问的事项全部由大脑裁定。为了用户事后能复核,要求大脑在每轮"分析"里把替用户做的裁定单列为"大脑裁定(原拟问用户)",并在 DONE 的总结里汇总这些裁定及依据。调度员自己也不再用 AskUserQuestion,但仍照常向用户打印名册和进展。 + +### 防止空转 + +- 默认上限 **15 轮**。到了上限先暂停,把现状和大脑的最新判断告诉用户,问是否继续。 +- 如果同一任务连续 3 轮失败、而大脑的新决策基本是重复派同样的活,把这一情况告诉大脑(事实陈述,不给建议),让它换思路或转 ASK_USER。 +- 用户随时插话时:把用户的话原样写入 `Rnn-user.md` 并转给大脑,由大脑决定如何调整。 + +## 恢复上次中断的运行 + +用户说"继续上次的 brain 任务"时:在 `./Notes/auto/` 里找最近的运行目录(看 `log.md` 最后一行,没有 FINAL.md 的就是未完成的),新起一个大脑(这是接替,不是第二个),让它通读该目录后给出下一轮决策,轮次编号接着往下排。 diff --git a/claude-brain/agents/claude-brain.md b/claude-brain/agents/claude-brain.md new file mode 100644 index 0000000..0e4ccb2 --- /dev/null +++ b/claude-brain/agents/claude-brain.md @@ -0,0 +1,62 @@ +--- +name: claude-brain +description: claude-brain 流程中唯一的主 agent(大脑)。只读分析、拆解任务、给出下一轮决策;不执行任何操作。由 claude-brain skill 启动,不要在其它场合使用。 +tools: Read, Glob, Grep +model: fable +--- + +你是 claude-brain 流程里的**大脑**——整个任务中唯一的主 agent。 + +## 你的职责 + +分析、决策、派活。你**不执行任何操作**:不改文件、不跑命令、不调 MCP、不派 agent。所有动作由调度员转给任务子 agent(worker)去做,它们做完会把报告写进运行目录,调度员再把路径交给你。 + +你可以用 Read/Glob/Grep 阅读运行目录(`./Notes/auto//`)里的记录。必要时也可以只读地查看工程文件来核实 worker 的说法,但大范围的调研、搜索、阅读也应当作为任务派出去——你的注意力应该花在判断上,而不是体力活上。 + +把 worker 的报告当作证据来审视,而不是当作结论来接受:验收标准有没有拿出真凭实据?报告里有没有"应该可以"、"理论上"之类没有验证的说法?必要时派一个独立的 worker 去复核。 + +## 选择 worker 模型 + +每个任务你都要指定 worker 模型,按难度灵活选: +- `opus`(Opus 5.5,**默认**):需要推理、设计、调试、改关键代码、多步操作、结果难以验证的任务。拿不准就选它。 +- `sonnet`:边界清楚的常规任务,如按明确规格改代码、跑测试并汇总、整理文档。 +- `haiku`:纯机械的小活,如列目录、grep 汇总、抄录数据、跑一条已知命令收集输出。 +- `fable` 只属于你,不能分配给 worker。 + +## 每轮回复格式 + +每次回复都严格用下面的格式(调度员会照抄存档,并机械地据此派活): + +``` +# 第 nn 轮决策 + +## 状态 +CONTINUE | ASK_USER | DONE + +## 分析 +<你对当前进展的判断:已确认什么、证据是否充分、还缺什么、为什么下一步这样走> + +## 任务 +(状态为 CONTINUE 时填写;可多个) + +### T1 +- 模型: opus | sonnet | haiku +- 并行: 可与 T2 并行 / 需在 T1 完成后 +- 目标: <一句话> +- 背景: +- 步骤建议: <可选> +- 禁止事项: <可选,如"不要上电"、"不要改 xxx 文件"> +- 验收标准: + 1. <可客观验证的条件,以及需要在报告里给出的证据> +- 报告格式: 按 templates 的 worker 报告格式 + +## 向用户提问 +(状态为 ASK_USER 时填写:问题、可选项、你的倾向及理由) + +## 总结 +(状态为 DONE 时填写:最终结果、关键证据、改动清单、遗留问题与建议) +``` + +**自主模式**:若调度员转告"用户不允许提问",ASK_USER 就不再可用——所有原本要问用户的事由你裁定。在"分析"里把这些裁定单列为"大脑裁定(原拟问用户)"并给依据;DONE 的总结里汇总全部此类裁定,供用户事后复核。 + +判断 DONE 的标准:用户在 `00-goal.md` 里的目标已达成,并且有 worker 报告中的证据支撑。没有证据的"完成"不算完成。如果目标本身有歧义或需要用户拍板(如有风险的硬件操作、改变需求),用 ASK_USER,不要猜。 diff --git a/claude-brain/references/templates.md b/claude-brain/references/templates.md new file mode 100644 index 0000000..32f6753 --- /dev/null +++ b/claude-brain/references/templates.md @@ -0,0 +1,98 @@ +# claude-brain 记录模板 + +全部文件只放在 `./Notes/auto/-/` 下。 + +## 00-goal.md(调度员写) + +``` +# 任务目标 +- 启动时间: 2026-10-14 21:05 +- 工程目录: E:\...\SMOD_ISDBT + +## 用户原话 +<逐字粘贴> + +## 已知约束 +<对话里用户明确说过的限制、偏好;不写调度员自己的分析> +``` + +## log.md(调度员逐行追加) + +``` +| 轮次 | 时间 | 事件 | 详情 | +|---|---|---|---| +| R01 | 21:06 | brain | CONTINUE,派 T1(opus) T2(haiku) | +| R01 | 21:15 | worker | T1 完成 / T2 失败 | +| R02 | 21:16 | brain | ASK_USER | +``` + +## Rnn-brain.md + +大脑回复的原文,不做任何修改。 + +## Rnn-Tk-task.md + +从大脑决策中原样抽出的 `### Tk` 那一节,前面加一行: + +``` +# R01-T1 任务单(模型: opus) +<大脑写的该任务全文> + +--- +## 报告格式 +写到同目录下的 R01-T1-report.md,结构如下: +(附下面的 worker 报告格式) +``` + +## Rnn-Tk-report.md(worker 写) + +``` +# R01-T1 报告 +- 结论: 完成 | 部分完成 | 失败 | 受阻 +- 模型: opus + +## 做了什么 +<按步骤列出> + +## 改动的文件 +<路径 + 一句话说明;没有就写"无"> + +## 证据 +<关键命令及输出摘录、测量值、测试结果、日志片段> + +## 验收标准逐条 +1. <标准> —— 满足/不满足,证据: ... + +## 问题与建议 +<遇到的意外、任务单没覆盖到需要大脑决定的点> +``` + +## Rnn-user.md + +``` +# R02 向用户提问 +## 大脑的问题 +<原文> +## 用户回答 +<原文> +``` + +## agents.md(调度员维护;每新增/结束一个 agent 就更新并向用户打印全表) + +``` +# 子 agent 名册 +运行目录: E:\...\Notes\auto\20261014-2101-xxx + +| # | 角色/任务 | 模型(指定) | 状态 | 启动 | 结束 | 报告 | +|---|---|---|---|---|---|---| +| 1 | 大脑(唯一主 agent) | fable | 运行中 | 21:02 | — | Rnn-brain.md | +| 2 | R01-T1 xxx 调研 | opus | 完成 | 21:08 | 21:23 | 01-T1-xxx.md | +| 3 | R01-T2 xxx 调研 | opus | 运行中 | 21:08 | — | — | +| 4 | R01-T3 工具链盘点 | sonnet | 失败/受阻 | 21:08 | 21:15 | 01-T3-xxx.md | +``` + +状态只用:`运行中` / `完成` / `失败/受阻` / `已接替`。大脑永远第 1 行。 + +## FINAL.md + +大脑 DONE 决策中"总结"一节的原文,加上统计:总轮数、worker 数量及各自模型。 diff --git a/claude-brain/scripts/check_models.sh b/claude-brain/scripts/check_models.sh new file mode 100644 index 0000000..4d349e0 --- /dev/null +++ b/claude-brain/scripts/check_models.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# 核实 claude-brain 运行中每个子 agent 实际使用的模型。 +# 原理:Claude Code 把每个 subagent 的转录写在 +# ~/.claude/projects/<项目slug>/<会话ID>/subagents/agent-.jsonl +# 每条模型回复都带 "model":"claude-xxx" 字段;逐文件统计即可知道实际生效的模型, +# 以及是否中途切换过(一个文件里出现多种模型即为切换)。 +# +# 用法: +# check_models.sh # 扫描当前项目目录下最近 24h 内活跃的会话 +# check_models.sh <会话ID> # 只看指定会话 +# check_models.sh --hours 6 # 改时间窗 +# 项目 slug 由当前工作目录推导(E:\a\b -> E--a-b),与 Claude Code 规则一致。 + +hours=24 +session="" +while [ $# -gt 0 ]; do + case "$1" in + --hours) hours="$2"; shift 2 ;; + *) session="$1"; shift ;; + esac +done + +cwd="$(pwd -W 2>/dev/null || pwd)" +# Claude Code 的 slug 规则:盘符冒号、路径分隔符、下划线都变成 '-'(E:\a_b\c -> E--a-b-c) +slug="$(printf '%s' "$cwd" | sed -e 's#[:/\\_]#-#g')" +proj="$HOME/.claude/projects/$slug" +if [ ! -d "$proj" ]; then + echo "找不到项目转录目录: $proj" >&2 + echo "请在 claude-brain 运行时所在的工程根目录执行本脚本。" >&2 + exit 1 +fi + +mins=$((hours * 60)) +if [ -n "$session" ]; then + files=$(find "$proj/$session/subagents" -name 'agent-*.jsonl' 2>/dev/null) +else + files=$(find "$proj" -path '*/subagents/agent-*.jsonl' -mmin "-$mins" 2>/dev/null) +fi + +if [ -z "$files" ]; then + echo "没有找到子 agent 转录(窗口 ${hours}h,目录 $proj)。" + exit 0 +fi + +printf '%-28s %-10s %s\n' "agent" "回复数" "实际模型(出现次数)" +for f in $files; do + id=$(basename "$f" .jsonl | sed 's/^agent-//') + stats=$(grep -o '"model":"claude-[a-z0-9-]*"' "$f" | sed 's/"model":"//;s/"$//' | sort | uniq -c | awk '{printf "%s x%s ", $2, $1}') + n=$(grep -c '"model":"claude-' "$f") + flag="" + kinds=$(printf '%s' "$stats" | grep -o 'claude-[a-z0-9-]*' | sort -u | wc -l) + [ "$kinds" -gt 1 ] && flag=" <-- 中途切换过模型" + printf '%-28s %-10s %s%s\n' "$id" "$n" "$stats" "$flag" +done diff --git a/claude-brain/scripts/split_tasks.py b/claude-brain/scripts/split_tasks.py new file mode 100644 index 0000000..8b75cb3 --- /dev/null +++ b/claude-brain/scripts/split_tasks.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""把大脑决策文件 Rnn-brain.md 里的 `### Tk` 各节原样抽成 Rnn-Tk-task.md。 + +用法: + python split_tasks.py <运行目录>/R04-brain.md + +规则(与 SKILL.md / templates.md 一致): +- 只抽取 "## 任务" 与 "## 向用户提问" 之间的 `### Tk` 节,正文一字不改。 +- 模型取该节 "- 模型:" 行;写了 fable 则改回 opus(Fable 只属于大脑),并在 stdout 提示。 +- 报告路径优先取该节 "- 报告格式:" 行里反引号中的路径(大脑常自己指定文件名); + 大脑用 `...\\` 省略了目录前缀时,用运行目录补全;没写则用默认 Rnn-Tk-report.md。 +- 末尾追加 worker 报告格式模板。 +输出: 每个任务一行 "Tk ",供调度员直接派 worker。 +""" +import re +import sys +from pathlib import Path + +REPORT_TEMPLATE = ''' +--- +## 报告格式 +写到 `{report}`,结构如下: + +``` +# {rnd}-{tk} 报告 +- 结论: 完成 | 部分完成 | 失败 | 受阻 +- 模型: {model} + +## 做了什么 +<按步骤列出> + +## 改动的文件 +<路径 + 一句话说明;没有就写"无"> + +## 证据 +<关键命令及输出摘录、测量值、测试结果、日志片段> + +## 验收标准逐条 +1. <标准> —— 满足/不满足,证据: ... + +## 问题与建议 +<遇到的意外、任务单没覆盖到需要大脑决定的点> +``` +验收标准要求的各张表、设计说明、数值结果放在"证据"一节或其后的独立小节中。 +''' + + +def main(path: str) -> None: + brain = Path(path).resolve() + rundir = brain.parent + rnd = brain.stem.split('-')[0] # R04 / R02b + text = brain.read_text(encoding='utf-8') + if '## 任务' not in text: + sys.exit('决策文件里没有 "## 任务" 节') + body = text.split('## 任务', 1)[1] + for stop in ('## 向用户提问', '## 总结'): + if stop in body: + body = body.split(stop, 1)[0] + parts = re.split(r'(?m)^### (T\w+)\s*$', body) + if len(parts) < 3: + sys.exit('没有找到任何 "### Tk" 节') + for i in range(1, len(parts), 2): + tk, sec = parts[i], parts[i + 1].strip('\n') + m = re.search(r'-\s*模型:\s*(\w+)', sec) + model = m.group(1).lower() if m else 'opus' + if model == 'fable': + print(f'[warn] {tk} 指定了 fable,改回 opus(Fable 只属于大脑)', file=sys.stderr) + model = 'opus' + rep = None + m = re.search(r'-\s*报告格式:.*?`([^`]+\.md)`', sec) + if m: + rep = m.group(1) + if rep.startswith('...'): + rep = str(rundir / Path(rep.replace('...', '').lstrip('\\/')).name) + elif not Path(rep).is_absolute(): + rep = str(rundir / rep) + if not rep: + rep = str(rundir / f'{rnd}-{tk}-report.md') + out = (f'# {rnd}-{tk} 任务单(模型: {model})\n\n### {tk}\n{sec}\n' + + REPORT_TEMPLATE.format(report=rep, rnd=rnd, tk=tk, model=model)) + task_file = rundir / f'{rnd}-{tk}-task.md' + task_file.write_text(out, encoding='utf-8') + print(f'{tk} {model} {task_file} {rep}') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/kicad-flow/SKILL.md b/kicad-flow/SKILL.md index b809e55..4093013 100644 --- a/kicad-flow/SKILL.md +++ b/kicad-flow/SKILL.md @@ -4,14 +4,15 @@ description: > 从一份需求 md 出发,驱动 KiCad 原理图的完整设计流程:画 drawio 电路框图、跟用户 确认功能划分、整理参考资料、必要时做原理仿真、画 KiCad 原理图、校验 FPGA/MCU 的 管脚与接口、加封装、回写 drawio、三道独立 agent 复核(一致性/排版/需求符合性), - 最后导入 PCB。驱动 E:\LangGraph_Design\kicad-flow 里的 LangGraph 工作流(27 节点 / - 8 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每个要动 MCP、 + 导入 PCB,再按用户布局要求布局、必要时为布线调整原理图连线(过 FPGA/MCU 管脚校验), + 最后完成阻抗线/差分线/等长线布线。驱动 E:\LangGraph_Design\kicad-flow 里的 LangGraph + 工作流(41 节点 / 10 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每个要动 MCP、 要派 agent、要人拍板的地方 interrupt 回到 Claude 会话,由本 skill 执行真实动作再 回填推进。数据库落在被设计的那个 KiCad 工程下,不在图工程目录下。 只要用户提到 画原理图、设计一块板、KiCad 原理图设计、从需求做电路、电路框图、 kicad-flow 工作流、加封装/封装补齐、原理图排版检查、原理图与框图对不上、 - 原理图导入 PCB、或者说 "启动 kicad-flow"、"跑原理图流程"、"接着上次那块板"、 + 原理图导入 PCB、PCB 布局、差分线/等长线/阻抗线布线、为布线换管脚、或者说 "启动 kicad-flow"、"跑原理图流程"、"接着上次那块板"、 "design a schematic in KiCad"、"start the KiCad schematic flow",就必须用这个 skill —— 哪怕他们没说 skill、没提 LangGraph,只说了 "帮我把这块板的原理图画出来"、 "这板子画到一半,接着弄"。 @@ -23,8 +24,8 @@ description: > ## 这个 skill 是什么 -一块板从需求到原理图 = 4 个阶段:**框图与资料 → 画原理图并校验平台 → 加封装 → -回写与三道复核 → 导入 PCB**。这些步骤有严格先后、有失败要退回重做的回环、 +一块板从需求到关键线布完 = 5 个阶段:**框图与资料 → 画原理图并校验平台 → 加封装 → +回写与三道复核 → 导入 PCB、布局、为布线调整原理图、关键线布线**。这些步骤有严格先后、有失败要退回重做的回环、 单次会话跑不完(用户要在 drawio、KiCad、Vivado 里手动看很多次)。所以状态由 LangGraph 图持久化在 SQLite 里,你负责把图停下来的每一步翻译成真实动作, 做完回填结论让它往下走。 @@ -94,10 +95,12 @@ $PY = (Select-String -Path "$env:USERPROFILE\skills_env.md" ` 一次要填多个键就多写几个 `--set`。确实需要嵌套结构(数组/对象)时用 `--json`, 两者可混用。 -`start` 可以调 9 个回环的重试上限,超限走 safety_end: +`start` 可以调 10 个回环的重试上限,超限走 safety_end: `--max-partition-retry` `--max-material-retry` `--max-layout-retry` `--max-vivado-retry` `--max-mcu-retry` `--max-footprint-retry` `--max-drawio-sync-retry` -`--max-recheck-retry` `--max-requirement-retry`。 +`--max-recheck-retry` `--max-requirement-retry` `--max-pcb-rules-retry`。 +另有 `--max-adjust-retry`(默认 3):PCB 段为布线调整原理图的尝试次数,**超了是放弃调整、 +按原原理图继续布线,不走 safety_end**(PR00.drawio 的「尝试超过三次?」)。 > **回填不能是空的**。langgraph 把 falsy 的 resume 当作「没提供」,会在同一个节点再 > 中断一次,看起来像卡住。脚本已经帮你拦了,但别指望它替你想该填什么。 @@ -127,10 +130,11 @@ G-0001:那个目录只放人写的 md,自动产物一律进 `Notes\auto\`) | `d07_confirm_partition` | 功能怎么划分是设计意图,只有用户知道他要什么 | | `n13_prompt_more_material` | 要用户去找资料,他不给你等不到 | | `n25_prompt_footprint` | 同上,封装信息得他补 | +| `n109_prompt_pcb_rules` | PCB 布局布线要求(叠层/阻抗/差分/等长/线宽过孔)是他的约束,不能替他编 | 其余节点(含三道「派单独 agent」的复核)由你执行并判定。 -**如果你自己就是被派来跑这个流程的 subagent**:上面三个节点不要替用户回填, +**如果你自己就是被派来跑这个流程的 subagent**:上面四个节点不要替用户回填, 把 interrupt 原样交回给派你来的那一方。agent 之间的指令不构成用户同意。 ## drawio 文件有两种格式,别踩 @@ -155,7 +159,7 @@ $DR = "$env:USERPROFILE\.claude\skills\kicad-flow\scripts\drawio_read.py" 两种格式它都吃,输出统一。**别自己写正则去抠**。 -## Quick Reference — 27 个节点 +## Quick Reference — 41 个节点 | action | 你要做的 | 回填 | |---|---|---| @@ -185,7 +189,21 @@ $DR = "$env:USERPROFILE\.claude\skills\kicad-flow\scripts\drawio_read.py" | `d22_meets_requirement` | **派 agent** 比原始需求 | `meets_requirement` `gaps` | | `n98_collect_fixes` | 把差距整理成可执行修改方案 | `fix_plan` | | `n93_schematic_done` | 无 interrupt,图自己出报告 | — | -| `n101_import_to_pcb` | `sync_schematic_to_board` 导入 PCB | `pcb_path` `imported` | +| `n101_import_to_pcb` | `schgen.pcbimport` 导入 PCB(**不用** MCP 的 sync,G-0039) | `pcb_path` `imported` | +| `n109_prompt_pcb_rules` | **要用户**给布局要求 md(叠层/阻抗差分等长/线宽过孔) | `pcb_rules_md` | +| `d176_pcb_rules_complete` | 要求每项都有可落成规则的数值? | `pcb_rules_complete` `missing` | +| `n110_place_components` | 评估难度,按最利于布线的方式布局(`schgen.pcbplace`) | `placement_report` `placed` | +| `d140_need_sch_adjust` | 要不要为布线调整原理图连线?**只提方案,先不动图** | `need_sch_adjust` `adjust_plan` | +| `d144_adjust_over_limit` | 无 interrupt,尝试次数计数,超 3 次放弃调整 | — | +| `d116_need_fpga_pin_change` | 方案动了 FPGA 管脚? | `need_fpga_pin_change` | +| `n118_rebuild_vivado_probe` | 按新管脚改验证 Vivado 工程 | `vivado_prj_dir` | +| `d123_swap_bitstream_ok` | 新管脚出得来 bitstream?(false=方案被拒,不重试) | `bitstream_ok` | +| `d128_need_mcu_pin_change` | 方案动了 MCU 管脚?(false=方案通过 → n183) | `need_mcu_pin_change` | +| `d132_swap_mcu_io_ok` | 调整后 MCU IO 对吗?(false=方案被拒,**不退回 n10**) | `mcu_io_ok` | +| `d148_have_new_adjust` | 被拒了,还有新方案吗? | `have_new_adjust` `adjust_plan` | +| `n183_apply_sch_adjust` | 方案通过后改原理图并更新到 PCB(**别重跑 pcbimport**),逐焊盘核对 | `applied` | +| `n159_collect_critical_nets` | 统计阻抗/差分/等长需求,建网络类/差分对规则 | `critical_nets` | +| `n167_route_critical_nets` | 按用户原则布完全部关键线 + DRC,流程终点 | `routed` `route_report` | 排版判定 `d11_layout_issue` 与 `d20_layout_issue` **同极性**: **true = 有排版问题 → 回去重修**,false = 没问题 → 往前走。回填键都叫 `layout_issue`。 @@ -198,11 +216,33 @@ $DR = "$env:USERPROFILE\.claude\skills\kicad-flow\scripts\drawio_read.py" | `Invalid \escape` | 你在用 `run.py ''` 拼路径。改用 `kf_flow.py resume --set` | | 中文 hint 是乱码 | 你绕过了 kf_flow.py。它自己转 UTF-8,直接跑 run.py 才需要 `PYTHONIOENCODING=utf-8` | | 同一个节点反复中断 | 回填是 falsy(空 dict/`false`/`""`)。至少给一个非空键 | +| `回填缺少本节点的主键 xxx` | interrupt 声明的第一个键(判定/主结论)没给。**缺判定键时节点会把它当 false、悄悄走另一条边**(d20 曾因此被跳到 d22),所以脚本默认拦下。确实只填部分键才加 `--allow-partial` | | `Read` drawio 只看到 base64 | SVG 外壳+压缩格式。用 `drawio_read.py` | | `拒绝启动:...会落在本工作流设计目录内` | `--prj-dir` 指到 `E:\LangGraph_Design\kicad-flow` 了。要指目标板子的工程目录 | | `KF_PRJ_DIR 指向的目录不存在` | 先把工程目录建出来 | | `[SAFETY-END]` | 某回环超上限。先解决报告里指出的卡点,再 `start --restart`,或 `--max-*-retry` 调高 | -| 改了图工程的代码想验证 | 图工程根下 `python smoke_test.py`,6 项用例覆盖落点/闸门/回环/护栏 | +| 改了图工程的代码想验证 | 图工程根下 `python smoke_test.py`,9 项用例覆盖落点/闸门/回环/护栏/PCB 段/drawio 逐边对账 | +| n183 之后布局全没了 | 落地调整方案时又跑了 `schgen.pcbimport`,它会重建空板。布局后只改受影响焊盘的网络 | + +## 画原理图 / 加封装用 schgen 脚本库(n10 / n23 / d11) + +`scripts\schgen\` 是从两块真实板子(ipots_tunerX10 载板、DVBS2_dmod tuner 子板)通用化来的 +原理图生成库:**原理图由工程里的 python 脚本生成,不用 MCP 一个个点**。坐标由符号真实外框推出, +自带重叠/越界检查、子电路画法检查(G-0035/37)、封装↔引脚一一对应检查、drawio→PNG 封面插图。 +用法、原语、踩过的坑见 [scripts/schgen/README.md](scripts/schgen/README.md); +最小完整示例是 `E:\Kicad_design\DVBS2_dmod\carrier\gen\`(gen_sch.py + mkfp.py,4 页 78 器件)。 + +```powershell +python <工程>\gen\gen_sch.py # 出图(有重叠不写文件) +python -m schgen.check <工程>\.kicad_sch # 在 scripts 目录下跑:ERC/器件数/封装/子电路/接点 +& <工程>\gen\mkfp.py # n23:从参考 PCB 提取封装(要 pcbnew) +& -m schgen.pcbimport <根图> <板.kicad_pcb> # n101:从网表建板 + 逐焊盘验收 +``` + +检查器不管"挤"只管"压":出完图一定 `kicad-cli sch export pdf` 渲染成 PNG 逐页看一遍。 + +> **n101 不要用 MCP 的 `sync_schematic_to_board`**(G-0039):它自报 success,DVBS2_dmod 上实际只写了 +> 26/60 个网(`Net-(...)` 无名网全丢、还冒出一个 PWR_FLAG 网)。用 `schgen.pcbimport`,以它的逐焊盘验收为准。 ## 相关 diff --git a/kicad-flow/references/nodes.md b/kicad-flow/references/nodes.md index 8094887..5d8434c 100644 --- a/kicad-flow/references/nodes.md +++ b/kicad-flow/references/nodes.md @@ -132,7 +132,9 @@ payload 的 `retry` 和 `sim_results` 告诉你上一轮发生了什么,**把 > 典型应用电路或参考设计保持一致,便于逐项比对、便于照抄取值,也便于后面 `n101` > 导入 PCB 后直接沿用这层就近关系。 -`batch_add_and_connect` / `batch_add_components` / `batch_connect` 比逐个加快得多。 +首选 `scripts\schgen` 脚本库生成(见 schgen/README.md,示例 `E:\Kicad_design\DVBS2_dmod\carrier\gen\gen_sch.py`): +自带重叠/越界、子电路、ERC、网表器件数检查,封面框图由 `schgen.diagram` 渲染、`schgen.root` 嵌入。 +零星修改才用 MCP 的 `batch_add_and_connect` / `batch_add_components` / `batch_connect`。 这是三条回环的汇合点,payload 会告诉你这轮为什么回来: - `fix_plan` 非空 → `d22` 判了不符合需求,**逐条按方案返工** @@ -207,6 +209,10 @@ STM32 可用 cubemx MCP 的 `get_pin_functions` / `get_pinout` 交叉验证。 2. 按型号或 datasheet 从网上下载并安装 3. 按型号或 datasheet 自己画(`create_footprint`) +脚本化做法:`schgen.fplib.FpLib`(KiCad 自带 python 跑)按位号从参考 `.kicad_pcb` 提取封装, +自画件直接写 `.kicad_mod`;原理图生成脚本里用 `fp_resolver` 按数值查表。 +验收:`python -m schgen.check <根图>` 的"封装"一行必须是"全部 N 个器件有封装且引脚/焊盘一一对应"。 + `search_footprints` / `list_library_footprints` 找库内现成的, `search_jlcpcb_parts` 可顺带确认可采购性。 @@ -304,9 +310,139 @@ STM32 可用 cubemx MCP 的 `get_pin_functions` / `get_pinout` 交叉验证。 ### `n101_import_to_pcb`(101)— 导入 PCB -`sync_schematic_to_board` 把原理图导入 PCB(器件带封装落板)。导入后确认器件数与 -网表数与原理图一致(`get_component_list`、`get_nets_list`)。 +**用 `schgen.pcbimport`,不要用 MCP 的 `sync_schematic_to_board`**(G-0039):后者自报 success, +实测 ipots 载板 338/327 封装、191/426 网;DVBS2_dmod 26/60 网(`Net-(...)` 无名网全丢、 +多出一个 PWR_FLAG 网、U4 一串焊盘没网)。 -PCB 的布局/布线/DRC **不属于本流程**,导入成功即收工。 +```powershell +cd $env:USERPROFILE\.claude\skills\kicad-flow\scripts +& -m schgen.pcbimport <根图.kicad_sch> <工程>\.kicad_pcb [--layers 4] +``` + +它从 kicad-cli 网表建一块新板(封装按页排在停车场、挂网),再重新载入**逐焊盘**比对网表, +末行"验收:… 问题 0"才算成功。每次都是重建整块板,布局开始后别再跑。 + +导入成功后直接进入阶段五(PCB)。 回填:`--set pcb_path="<.kicad_pcb 路径>" --set imported=true` + +--- + +## 阶段五:PCB —— 布局要求 → 布局 → 为布线调整原理图 → 关键线布线 + +PCB 段在 PR00.drawio 右侧,cell id 形如 `T50IymjQljGLdgGnkx4X-109`,下面只写末尾数字。 + +**原理图调整是「先试后落」的**:`d140` 只提方案、不动原理图;方案依次过 FPGA +(`d116`→`n118`→`d123`)和 MCU(`d128`→`d132`)两道校验,全过才算通过 +(`adjust_status=accepted`),**由 `n183` 真正改原理图并更新到 PCB**。任一道不过即 +「调整被拒绝」→ `d148` 想新方案;总尝试次数由 `d144` 卡在 `max_adjust_retry`(默认 3), +超了或想不出新方案都是「放弃调整」(`adjust_status=abandoned`),**按原原理图继续布线, +不是 safety_end**。 + +### `n109_prompt_pcb_rules`(109)— **要用户**给布局要求 md + +**必须拿到用户本人的回答**,不能替他编要求。md 放 `<工程>\Notes\` 根目录(用户手写), +至少覆盖:① 板层设置(层数/叠层/各层用途);② 阻抗线、差分线、等长线的要求; +③ 线宽、过孔的要求。回到这里时 payload 的 `missing` 是上轮查出的缺项,照着问。 + +回填:`--set pcb_rules_md=""` + +### `d176_pcb_rules_complete`(176)— 布局要求完整吗 + +逐项核对,而且每项都要是**能直接落成设计规则的数值**(目标阻抗 + 按叠层算出的线宽/间距、 +差分对与等长组成员及容差、最小线宽/过孔孔径与焊环)。「按常规」「尽量短」不算。 + +> false 回 `n109` 再问用户,上限 `max_pcb_rules_retry`(默认 3),超限 safety_end。 + +回填:`--set pcb_rules_complete=true` 或 `--set pcb_rules_complete=false --json '{"missing":["等长容差"]}'` + +### `n110_place_components`(110)— 评估难度并布局 + +先把板框、叠层、网络类设进 PCB,再评估布局布线难度(BGA/细间距扇出与层数、关键信号走向 +与长度、大电流路径、接口器件的板边约束),然后在满足用户要求的前提下**以最有利于布线** +的方式摆件。用 `schgen.pcbplace`(KiCad 自带 python),`check()` 到 0 重叠/出板, +`render()` 出正反面 PNG 目视。难度评估与布局理由写成一份 md。 + +回填:`--set placement_report="" --set placed=true` + +### `d140_need_sch_adjust`(140)— 要不要为布线调整原理图连线 + +看飞线:FPGA 同 bank 内换脚、MCU 功能换到另一组可复用引脚、连接器/排阻换序、差分对 P/N 对调 +这类**只改连接、不改功能**就能明显省层/省过孔/解交叉的地方。**此时先别动原理图**。 + +回填:`--set need_sch_adjust=false`,或 +`--set need_sch_adjust=true --json '{"adjust_plan":[{"ref":"U1","change":"LED0: A5→B6","why":"省两个过孔"}]}'` + +### `d144_adjust_over_limit`(144)— 无 interrupt + +图自己计数:放行一次 `adjust_retry` +1;已达 `max_adjust_retry` 就判「已超过三次,放弃修改」 +直接去 `n159`。 + +### `d116_need_fpga_pin_change`(116)/ `d128_need_mcu_pin_change`(128)— 方案动没动管脚 + +只看当前 `adjust_plan`。板上没有 FPGA / MCU 直接回 false。 +**`d128` 回 false 就等于本轮方案通过**(FPGA 那道要么没涉及、要么已经过了),下一步 `n183` 落地。 + +回填:`--set need_fpga_pin_change=true` / `--set need_mcu_pin_change=false` + +### `n118_rebuild_vivado_probe`(118)— 按新管脚重建验证工程 + +优先在 `n14` 建的工程上改 XDC。检查项与 `n14` 相同:管脚分配、供电 bank、时钟与 PLL +(时钟仍在 CC 脚)、差分对;Zynq 另查 MIO/EMIO、QSPI/eMMC/JTAG。 + +回填:`--set vivado_prj_dir="<工程目录>"` + +### `d123_swap_bitstream_ok`(123)— 新管脚下出得来 bitstream 吗 + +> **false 不回 `n118` 重试**,而是本轮方案被拒 → `d148`。别在这里反复改 Vivado。 + +回填:`--set bitstream_ok=true` 或 `--set bitstream_ok=false --json '{"errors":["时钟不在 CC 脚"]}'` + +### `d132_swap_mcu_io_ok`(132)— 调整后的 MCU IO 对吗 + +判据同 `d17`(专用脚复用、上拉/开漏、电平域、启动引脚),STM32 可用 cubemx 交叉验证。 +**与 `d17` 不同:false 不退回 `n10`**,只是本轮方案被拒 → `d148`。 + +回填:`--set mcu_io_ok=true` 或 `--set mcu_io_ok=false --json '{"violations":["PB3:JTDO 被占"]}'` + +### `d148_have_new_adjust`(148)— 被拒了,还有新方案吗 + +payload 的 `rejections` 最后一条是被拒原因。避开它能给新方案就回 true 并带新 `adjust_plan`; +想不出就 false —— 放弃调整。 + +回填:`--set have_new_adjust=false`,或 `--set have_new_adjust=true --json '{"adjust_plan":[...]}'` + +### `n183_apply_sch_adjust`(183)— 修改原理图并更新到 PCB 中 + +只有方案通过(`d128` 否 / `d132` 正确)才会到这里。 + +1. 按 `adjust_plan` 改原理图连线。schgen 生成的原理图就**改生成脚本重出图**,别手改 + `.kicad_sch` 之后又被脚本覆盖。 +2. 更新到 PCB:PCB 已经布局,**绝对不要重跑 `schgen.pcbimport`**(它重建空板,布局全丢), + 只改受影响焊盘的网络并保持器件位置。 +3. FPGA 的 XDC 以 `n118` 通过的那版为准。 +4. kicad-cli 导出网表与 PCB 逐焊盘核一遍,改过的网络全部一致才回 true。 + +回填:`--set applied=true --set note="<改了哪些网络>"`(路径变了再加 `schematic_path` / `pcb_path`) + +### `n159_collect_critical_nets`(159)— 统计阻抗/差分/等长需求 + +payload 的 `adjust_status`: + +- `accepted` → 方案已由 `n183` 落地,以更新后的网络为准。 +- `abandoned` → 原理图不动;若为验证改过 Vivado 工程的 XDC,退回 `n14` 那版。 +- `none` → 没调整,直接统计。 + +然后对照原理图、datasheet 与布局要求 md 统计**所有**阻抗线、差分线、等长线,并在 PCB +里建好对应网络类/差分对规则。 + +回填:`--json '{"critical_nets":[{"group":"USB","kind":"diff","nets":["USB_DP","USB_DN"],"rule":"90R 5/5mil"}]}'` + +### `n167_route_critical_nets`(167)— 关键线布线(流程终点) + +按用户布线原则把 `critical_nets` 全部布完(kicad MCP 的 `route_differential_pair` / +`route_trace` 等):差分对全程耦合、换层配回流过孔、等长组绕到容差内。布完跑 DRC, +关键线相关违规清零,逐组记录实测长度/偏差写进布线报告。普通信号不在本步范围。 +图随后写最终报告并结束。 + +回填:`--set routed=true --set route_report=""` diff --git a/kicad-flow/scripts/kf_flow.py b/kicad-flow/scripts/kf_flow.py index dfa0302..7b0d467 100644 --- a/kicad-flow/scripts/kf_flow.py +++ b/kicad-flow/scripts/kf_flow.py @@ -58,6 +58,9 @@ DEFAULT_GRAPH_ROOT = _env_lookup("KICAD_FLOW_ROOT", r"E:\LangGraph_Design\kicad- RETRY_LIMITS = { "partition": 3, "material": 5, "layout": 5, "vivado": 3, "mcu": 3, "footprint": 5, "drawio-sync": 3, "recheck": 3, "requirement": 3, + "pcb-rules": 3, + # adjust 超限不是 safety_end,而是 d144「放弃调整原理图」,按原原理图继续布线 + "adjust": 3, } @@ -165,7 +168,7 @@ def report(graph, thread: dict, before_len: int) -> int: _out("先解决这个卡点,再用 `start --restart` 重跑,或用 --max-*-retry 调高上限。") return 0 - _out("[DONE] 图已跑完:原理图设计完成并已导入 PCB。") + _out("[DONE] 图已跑完:原理图完成、PCB 已布局、关键线(阻抗/差分/等长)已布完。") _out(f"[REPORT] {values.get('report_path', '(未生成)')}") _out(f"[PCB] {values.get('pcb_path', '(未回填)')}") return 0 @@ -250,6 +253,17 @@ def cmd_resume(args): snap = graph.get_state(thread) if not snap.next: _fatal("当前没有挂起的 interrupt,无需回填。先跑 status 看状态。") + # 判定键缺失时节点会把它当 False 处理、悄悄走另一条边(2026-09-23 DVBS2_dmod 的 d20 + # 就这样被跳到了 d22)。所以 interrupt 声明的第一个键(判定/主结论键)必须给。 + want = None + for task in snap.tasks: + if task.interrupts and isinstance(task.interrupts[0].value, dict): + want = task.interrupts[0].value.get("resume") + if isinstance(want, dict) and want and not args.allow_partial: + key0 = next(iter(want)) + if key0 not in payload: + _fatal(f"回填缺少本节点的主键 {key0}({want[key0]})。" + f"本节点声明的键:{list(want)}。确实只想填部分键就加 --allow-partial") before = len((snap.values or {}).get("history", [])) _out(f"[RESUME] {json.dumps(payload, ensure_ascii=False)}") graph.invoke(Command(resume=payload), thread) @@ -342,6 +356,8 @@ def main() -> int: p.add_argument("--set", action="append", metavar="KEY=VALUE", help="免转义回填;true/false→bool,纯数字→int,[ 或 { 开头→JSON") p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)") + p.add_argument("--allow-partial", action="store_true", + help="允许不填 interrupt 声明的主键(默认缺主键直接报错)") p.set_defaults(func=cmd_resume) p = sub.add_parser("list", help="列出该工程库里的所有线程") diff --git a/kicad-flow/scripts/schgen/README.md b/kicad-flow/scripts/schgen/README.md new file mode 100644 index 0000000..0996913 --- /dev/null +++ b/kicad-flow/scripts/schgen/README.md @@ -0,0 +1,100 @@ +# schgen —— kicad-flow 的原理图 / 封装生成库 + +`n10_draw_schematic`、`n23_assign_footprints`、`d11/d20` 排版检查用。由两块真实板子的生成脚本通用化而来: +ipots_tunerX10 载板(`E:\Kicad_design\ipots_tunerX10\carrier\gen`,9 页 / 327 器件)与 +DVBS2_dmod tuner 子板(`E:\Kicad_design\DVBS2_dmod\carrier\gen`,4 页 / 78 器件,**最小完整示例,先看这个**)。 + +思路:**原理图由工程里的 python 脚本生成,不手画**。所有坐标由符号真实外框推出, +画什么就同步登记一个占位矩形,最后逐对查重叠/越界,有问题不写文件。改电路改脚本重跑。 + +## 模块 + +| 模块 | 干什么 | 跑它用的 python | +|---|---|---| +| `env` | 读 `~/skills_env.md`:`KICAD_ROOT`(必需)、`CHROME_EXE`(可选) | 任意 | +| `kicad_lib` | `.kicad_sch` 底层:符号提取(含 extends 展开)/ 外框 / 旋转 / `Sheet` 写文件;`register_lib()` 登记工程符号库 | 任意 | +| `layout` | `Canvas`:占位登记 + 重叠/越界检查(中文按 1.3 字宽估,G-0034) | 任意 | +| `page` | `Project` / `Page`:画图原语,见下 | 任意 | +| `symgen` | `box_symbol()` 按引脚表生成方框符号(每边可不同间距);`grab_from_sch()` 从参考 `.kicad_sch` 抽符号 | 任意 | +| `diagram` | drawio → PNG(SVG 外壳直接截;纯 XML 用 diagrams.net viewer 在 headless Chrome 渲染,需联网) | 任意 | +| `root` | 根图:层次页符号 + 内嵌框图 PNG(300DPI 反算 scale,G-0022);`.kicad_pro`;`sym/fp-lib-table` | 任意 | +| `check` | `python -m schgen.check <根图>`:ERC、网表器件数、**封装↔引脚一一对应**、子电路画法(G-0035/37) | 任意 | +| `fplib` | `FpLib`:按位号从参考 `.kicad_pcb` 提取封装 / 从 KiCad 官方库拷贝 | **KiCad 自带 python**(要 pcbnew) | +| `pcbimport` | n101:从网表建板(封装按页排停车场 + 挂网)并**逐焊盘验收**。代替不可信的 MCP `sync_schematic_to_board`(G-0039) | **KiCad 自带 python** | +| `pcbplace` | 布局:圆角板框、按占地中心摆放、正反面、实物禁布区(护套等)、占地重叠/出板检查(跨面只比通孔焊盘)、**自动摆位号** `place_refs()`、JLC 4 层叠层、出正反面 PNG | **KiCad 自带 python**(出图转 PNG 会找装了 fitz 的系统 python) | + +在工程脚本里: + +```python +import os, sys +sys.path.insert(0, os.path.expandvars(r"%USERPROFILE%\.claude\skills\kicad-flow\scripts")) +from schgen import Project, register_lib, R, C, L, GND, snap +``` + +## 画图原语(`Page`) + +两脚无源件**必须画进它服务的电路里**(G-0035/G-0037),别摆空地两头挂标签: + +| 原语 | 用途 | +|---|---| +| `comp(lib, ref, val, x, y, rot, fields=...)` | 放件;位号撞车直接报错(G-0028)。`fields`:above/left/right/top/none 或 `("xy", rx, ry, vx, vy)` | +| `inline(x0, y0, lib, ...)` / `vline(...)` | 在水平 / 竖直线上**串**一个件,返回线的另一端 | +| `shunt(bx, by, lib, ..., net, down=True)` | 从母线一点挂下(上)一个件到电源/地:退耦、上下拉 | +| `run_taps(px, py, ux, stops, net)` | 沿线打一串抽头挂 shunt,末端出标签 | +| `fan_series(...)` | 一组引脚各串一个件、排成一列(引脚距与件距不同时用) | +| `gnd_bus` / `gnd_row` | 多个地脚并一根母线只落一个地符号 | +| `stub(cx, cy, pp, pin, net, stagger)` | 引脚拉线接标签/电源符号;排针上相邻电源脚用 stagger 错开 | +| `dot` / `seg` / `path` / `nc_pin` / `frame` / `text` | 丁字接点(G-0036)/ 线段 / 折线 / 断路标记 / 虚线框(留位不贴)/ 注释 | +| `finish()` | 有重叠或越界:打印并**不写文件** | + +二极管等横放符号竖着挂时传 `rot=90/270`(`D_Schottky` 1 脚=K:`rot=270` 时 K 在上)。 + +## 标准流程(DVBS2_dmod 就是这么跑的) + +1. `gen/gen_sch.py`:`make_lib()`(box_symbol / grab_from_sch → `lib/.kicad_sym`)→ 每页一个函数 → + `render()` 框图 → `write_root()` → `write_project()` → `write_lib_tables()` +2. `python -m schgen.check <根图>`:ERC 0、器件数对得上、子电路 A/B 级 0 +3. `kicad-cli sch export pdf` → 用 fitz 渲染 PNG **逐页看图**(检查器不管"挤",只管"压") +4. n23:` gen/mkfp.py`(FpLib 从参考 PCB 按位号取件 + 自画件), + `gen_sch.py` 里 `fp_resolver` 按**数值**查表、按位号覆盖;再跑一次 check 看封装一行 +5. n101:` -m schgen.pcbimport <根图> <板.kicad_pcb> [--layers 4]`(在 scripts 目录下跑), + 输出最后一行"问题 0"才算导入成功。它每次重建整块板(DNP/不进 BOM/MPN 等字段一起同步) +6. 布局:工程里写 `gen/place.py`(示例见 DVBS2_dmod),用 `Placer` 按"板内坐标"摆件, + `check()` 为 0 后再 `kicad-cli pcb drc --schematic-parity`。建板 → 布局两步都是脚本, + 改原理图后可以一路重跑;**一旦开始手工布线就别再跑 pcbimport**(它会重建空板) + + 布局经验(DVBS2_dmod):先按信号流分区再摆件——并行总线让源端引脚与连接器引脚同序对齐, + 串阻排一行;开关电源回路(LX→二极管→BOOST 电容→PGND)收在芯片一角;DNP 的留位件 + (如 2.x 的大电感)可以放背面;金属壳 NIM 的正下方背面可以放它自己电源脚的 0402 去耦 + +## 坑(已在代码里处理,改代码前先看) + +- `justify center` 会让整页静默解析失败,网表空、ERC 0(G-0018)→ 判断读没读进去看**网表器件数** +- 标签角度只有 0/90,180/270 会反向压元件;字段角度要补偿元件旋转(G-0019) +- 取 unit 时要带 `NAME_0_x` 公共图形(G-0019) +- KiCad 库的 `(extends ...)` 派生符号要展开,属性块有嵌套括号必须配平替换,否则 ERC 报 lib_symbol_mismatch +- 连接器是 passive 脚,`PWR_FLAG` 放在电源进线连接器旁;稳压器 OUT 多脚时只留一个 power_out, + 其余 passive,否则 ERC 报 power_out 对 power_out +- `run_taps` 的每个挂件元组必须带另一端网络(第 6 项);`run_taps(net=None)` 之后若又手动把线往外延, + 最后一个抽头点要自己补 `dot()`(check 的 C 级会报"丁字口缺接点") +- 检查器的字宽按 1.2mm/字(ASCII)估:0.9、1.1 都在实板上漏报过数值文字压隔壁件; + 检查器只管"压"不管"挤",出图目视 + 独立 agent 复核仍然必要 +- 改 `sym-lib-table`/`fp-lib-table` 后要**整个退出 KiCad 再开**才生效(G-0020) +- KiCad 通用连接器符号(`Connector_Generic:Conn_*`)自带 `ki_fp_filters`(如 `Connector*:*_1x??_*`), + 封装选择器默认勾着"Apply footprint filters",工程库里的座子(从参考设计提取的 VH3.96-2P、IDC20_2MM) + 会被藏掉,用户看到的是"这个件没有封装"。做法:`set_fp_filter(src, "库:名*")` 后 + `rename(symbol_def(src), src, 新名)` 拷进工程符号库再用(示例见 DVBS2_dmod 的 make_lib)。 + **不要**直接覆盖内嵌的通用符号——ERC 会报 lib_symbol_mismatch +- `kicad-cli pcb drc --schematic-parity` 对**脚本生成的原理图 + pcbimport 建的板**,会在所有无名网络 + (`Net-(...)`)的焊盘上报 net_conflict "No corresponding pin found in schematic"(ipots 199 条、 + DVBS2 85 条),有名网络一条不报;pcbimport 的逐焊盘验收(按 kicad-cli 导出的网表)全部一致。 + 根因未查清,按已知告警处理,在 KiCad 界面里按一次 F8 再确认 +- 圆角板框要用 `SetArcGeometry(起, 中, 止)` 三点法;只给圆心+起止点,弧可能绕远路穿过板内 +- pcbnew `SetField()` 新建的字段默认可见,会被印成丝印,必须 `GetField(k).SetVisible(False)` +- **丝印(位号)**:`place_refs(size=0.8, thick=0.15, prefer={...})` 按"离器件近、横移小"的代价 + 顺序找位置,要求不压任何器件占地(含自己)、焊盘、丝印图形、已摆位号、板边;数值统一挪到 Fab。 + 用 KiCad 真实文字外框算碰撞——0.8mm 字高的位号外框实测 3.38 x 1.46mm(竖写 1.46 宽), + 所以**要竖写位号的缝至少留 1.8~1.9mm**,一排 0402 想统一竖写在正上方,间距要 ≥1.7mm。 + 摆不下的会列出来:那是布局问题(器件之间没给位号留地方),去改布局,别缩字号。 + 验收看 `kicad-cli pcb drc` 的 silk_over_copper / silk_overlap / silk_edge_clearance 三类都为 0 +- 判断工程封装库有没有加载:选择器标题里的总数 = KiCad 自带(10.0 为 15435)+ 全局额外库 + 工程库 diff --git a/kicad-flow/scripts/schgen/__init__.py b/kicad-flow/scripts/schgen/__init__.py new file mode 100644 index 0000000..4abd71b --- /dev/null +++ b/kicad-flow/scripts/schgen/__init__.py @@ -0,0 +1,22 @@ +"""kicad-flow 的原理图生成库(n10 画原理图用)。 + +由 ipots_tunerX10 载板生成器(E:\\Kicad_design\\ipots_tunerX10\\carrier\\gen)通用化而来, +去掉了写死的工程路径;本机路径一律从 ~/skills_env.md 读(KICAD_ROOT、可选 CHROME_EXE)。 + +模块: + env 读 skills_env + kicad_lib .kicad_sch 底层:符号提取 / 外框 / 旋转 / Sheet 写文件 + layout Canvas:占位登记 + 重叠/越界检查 + page Project / Page:画图原语(inline / shunt / fan_series / run_taps / gnd_bus ...) + symgen 工程符号库:从参考 .kicad_sch 抽符号 / 按引脚表生成方框符号 + diagram drawio(SVG 外壳或纯 XML)-> PNG + root 根图(层次页 + 框图插图)、.kicad_pro、库表 + check ERC + 网表器件数 + 子电路画法检查 + +在工程脚本里: + import sys, os + sys.path.insert(0, os.path.expandvars(r"%USERPROFILE%\\.claude\\skills\\kicad-flow\\scripts")) + from schgen import Project, register_lib, R, C, L, GND +""" +from .kicad_lib import register_lib, set_fp_filter, uid, snap, placed_pins, pin_dir, symbol_bbox, rot_bbox +from .page import Project, Page, R, C, L, GND diff --git a/kicad-flow/scripts/schgen/check.py b/kicad-flow/scripts/schgen/check.py new file mode 100644 index 0000000..e96a948 --- /dev/null +++ b/kicad-flow/scripts/schgen/check.py @@ -0,0 +1,331 @@ +"""原理图验收:ERC + 网表器件数 + 子电路画法检查。 + + python -m schgen.check <根图.kicad_sch> [--expect-refs 位号清单文件或逗号串] + +四件事: + 1. kicad-cli ERC(错误/警告条数与分类); + 2. 网表里的器件数与网络数——判断一页有没有真被 KiCad 读进去**看这个**, + 不要看 ERC 的 0(G-0018:一页解析失败时 ERC 照样 0 条);位号重名会被静默合并(G-0028), + 给了 --expect-refs 就逐个比对缺了哪些; + 3. 子电路画法(G-0035/G-0037): + A 级:两脚件两端都只挂标签、连通域里没有别的元件引脚——看不出它串在哪/退耦谁; + B 级:连通关系(可穿过其它无源件)到不了任何芯片/连接器引脚,且离最近的芯片/连接器 + 引脚也超过 NEAR mm——说明它没画在所服务的子电路里。 + 连通性含丁字口(端点落在另一条线中段,G-0036)。引脚坐标取自图纸自带的 lib_symbols, + 不依赖库表。 + C 级:三条以上线端(或线端+引脚)汇于一点却没画接点——电气连通但图面看不出(G-0036 的反面)。 + 4. 封装:每个器件的封装文件存在,符号引脚号与焊盘号一一对应。 +""" +import io +import math +import os +import re +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +from .env import kicad_cli +from .kicad_lib import balanced, rot_pt + +TOL = 0.02 +NEAR = 55.0 +TWO_PIN_PREFIX = ("R", "C", "L", "FB", "JP", "D") + + +def sexp(text): + toks = re.findall(r'\(|\)|"(?:[^"\\]|\\.)*"|[^\s()]+', text) + stack, cur = [], [] + for tk in toks: + if tk == '(': + stack.append(cur); cur = [] + elif tk == ')': + done = cur; cur = stack.pop(); cur.append(done) + else: + cur.append(tk[1:-1].replace('\\"', '"') if tk.startswith('"') else tk) + return cur + + +def find(node, name): + return [c for c in node if isinstance(c, list) and c and c[0] == name] + + +def at(node): + a = find(node, "at") + if not a: + return None + v = a[0] + return (float(v[1]), float(v[2]), float(v[3]) if len(v) > 3 else 0.0) + + +def embedded_pins(text): + """{lib_id: {unit: {pin: (dx, dy)}}},取自 .kicad_sch 的 lib_symbols。""" + i = text.find("(lib_symbols") + if i < 0: + return {} + ls = balanced(text, i) + res = {} + for m in re.finditer(r'\n\s*\(symbol "([^"]+:[^"]+)"', ls): + blk = balanced(ls, m.start() + ls[m.start():].find("(")) + lid = m.group(1) + units = {} + for sm in re.finditer(r'\(symbol "[^"]*_(\d+)_\d+"', blk): + u = int(sm.group(1)) + sub = balanced(blk, sm.start()) + for pm in re.finditer(r'\(pin\s+\S+\s+\S+\s*\(at\s+([-\d.]+)\s+([-\d.]+)', sub): + seg = sub[pm.end():pm.end() + 400] + nm = re.search(r'\(number "([^"]*)"', seg) + if nm: + units.setdefault(u, {})[nm.group(1)] = (float(pm.group(1)), float(pm.group(2))) + res[lid] = units + return res + + +class DS: + def __init__(self): + self.p = {} + + def find(self, k): + self.p.setdefault(k, k) + while self.p[k] != k: + self.p[k] = self.p[self.p[k]] + k = self.p[k] + return k + + def union(self, a, b): + ra, rb = self.find(a), self.find(b) + if ra != rb: + self.p[ra] = rb + + +def key(x, y): + return (round(x / TOL), round(y / TOL)) + + +def on_seg(px, py, x1, y1, x2, y2): + if abs((x2 - x1) * (py - y1) - (y2 - y1) * (px - x1)) > 0.05: + return False + return (min(x1, x2) - TOL <= px <= max(x1, x2) + TOL + and min(y1, y2) - TOL <= py <= max(y1, y2) + TOL) + + +def subckt(path): + text = io.open(path, encoding="utf-8").read() + lib = embedded_pins(text) + root = sexp(text)[0] + wires = [] + for w in find(root, "wire"): + xy = [(float(p[1]), float(p[2])) for p in find(find(w, "pts")[0], "xy")] + if len(xy) == 2: + wires.append((xy[0][0], xy[0][1], xy[1][0], xy[1][1])) + labels = [] + for kind in ("global_label", "hierarchical_label", "label"): + for lb in find(root, kind): + p = at(lb) + if p: + labels.append((p[0], p[1], lb[1])) + comps = [] + for s in find(root, "symbol"): + lid = find(s, "lib_id") + pos = at(s) + if not lid or pos is None: + continue + ref = "" + for pr in find(s, "property"): + if len(pr) > 2 and pr[1] == "Reference": + ref = pr[2] + un = find(s, "unit") + u = int(un[0][1]) if un else 1 + units = lib.get(lid[0][1], {}) + pins = dict(units.get(0, {})) + pins.update(units.get(u, {})) + ab = {} + for n, (dx, dy) in pins.items(): + rx, ry = rot_pt(dx, dy, pos[2]) + ab[n] = (pos[0] + rx, pos[1] - ry) + comps.append(dict(ref=ref, pins=ab)) + + ds = DS() + nodes = [(px, py, "pin", "%s.%s" % (c["ref"], n)) for c in comps for n, (px, py) in c["pins"].items()] + nodes += [(x, y, "label", nm) for x, y, nm in labels] + for (x1, y1, x2, y2) in wires: + ds.union(key(x1, y1), key(x2, y2)) + for w1 in wires: + for w in wires: + if w is w1: + continue + for (ex, ey) in ((w1[0], w1[1]), (w1[2], w1[3])): + if on_seg(ex, ey, *w): + ds.union(key(ex, ey), key(w[0], w[1])) + for (px, py, _, _) in nodes: + for w in wires: + if on_seg(px, py, *w): + ds.union(key(px, py), key(w[0], w[1])) + groups = {} + for (px, py, kind, nm) in nodes: + groups.setdefault(ds.find(key(px, py)), []).append((kind, nm)) + + def is_passive(c): + r = c["ref"] + return r and not r.startswith("#") and r.startswith(TWO_PIN_PREFIX) and len(c["pins"]) == 2 + + bad_a = [] + for c in comps: + if not is_passive(c): + continue + ends = [] + for n, (px, py) in c["pins"].items(): + g = groups.get(ds.find(key(px, py)), []) + others = [m for m in g if m[0] == "pin" and not m[1].startswith(c["ref"] + ".") + and not m[1].startswith("#")] + ends.append((len(others), any(m[0] == "label" for m in g))) + if all(n == 0 and lb for n, lb in ends): + bad_a.append(c["ref"]) + + anchors, apts = set(), [] + for c in comps: + if c["ref"] and not c["ref"].startswith("#") and not is_passive(c): + for n, pt in c["pins"].items(): + anchors.add("%s.%s" % (c["ref"], n)); apts.append(pt) + du = DS() + passives = [c for c in comps if is_passive(c)] + for c in passives: + rs = [ds.find(key(px, py)) for px, py in c["pins"].values()] + du.union(rs[0], rs[1]) + live = {du.find(r) for r, mem in groups.items() if any(k == "pin" and nm in anchors for k, nm in mem)} + bad_b = [] + for c in passives: + ok = any(du.find(ds.find(key(px, py))) in live for px, py in c["pins"].values()) + if not ok: + ok = any(math.hypot(px - ax, py - ay) <= NEAR + for px, py in c["pins"].values() for ax, ay in apts) + if not ok: + bad_b.append(c["ref"]) + # C 级:三条及以上线端(或线端+引脚)汇在一点却没有接点。KiCad 电气上算连通, + # 但图面上看不出是连上的(DVBS2_dmod 第 3 轮 d20 抓到过两处) + jset = {key(float(j[1][1]), float(j[1][2])) for j in + [(x, find(x, "at")[0]) for x in find(root, "junction")]} + ends = {} + for (x1, y1, x2, y2) in wires: + for p in ((x1, y1), (x2, y2)): + ends.setdefault(key(*p), []).append(p) + pinpts = {key(px, py) for c in comps for (px, py) in c["pins"].values()} + bad_c = [] + for k, pts in ends.items(): + n = len(pts) + (1 if k in pinpts else 0) + mid = sum(1 for w in wires if on_seg(pts[0][0], pts[0][1], *w) + and key(w[0], w[1]) != k and key(w[2], w[3]) != k) + if n + 2 * mid >= 3 and k not in jset: + bad_c.append("(%.2f,%.2f)" % pts[0]) + return len(comps), bad_a, bad_b, bad_c + + +def erc(root): + out = os.path.join(tempfile.mkdtemp(prefix="kf_erc_"), "erc.rpt") + subprocess.run([kicad_cli(), "sch", "erc", "--severity-error", "--severity-warning", + "-o", out, root], capture_output=True) + t = io.open(out, encoding="utf-8", errors="replace").read() if os.path.exists(out) else "" + kinds = {} + for m in re.finditer(r'\[(\w+)\]:\s*(.*)', t): + kinds.setdefault(m.group(1), []).append(m.group(2)) + return t, kinds + + +def netlist(root): + out = os.path.join(tempfile.mkdtemp(prefix="kf_net_"), "n.xml") + subprocess.run([kicad_cli(), "sch", "export", "netlist", "--format", "kicadxml", "-o", out, root], + capture_output=True) + r = ET.parse(out).getroot() + comps = {c.get("ref"): (c.findtext("value"), c.findtext("footprint")) for c in r.iter("comp")} + nets = {} + for n in r.iter("net"): + nets[n.get("name")] = [(x.get("ref"), x.get("pin")) for x in n.iter("node")] + return comps, nets, out + + +def _fp_dirs(prj_dir): + """库名 -> .pretty 目录:工程 fp-lib-table 优先,其余按 KiCad 自带库目录找。""" + from .env import kicad_footprint_dir + d = {} + t = os.path.join(prj_dir, "fp-lib-table") + if os.path.exists(t): + for m in re.finditer(r'\(name "([^"]+)"\).*?\(uri "([^"]+)"\)', io.open(t, encoding="utf-8").read()): + d[m.group(1)] = m.group(2).replace("${KIPRJMOD}", prj_dir) + return d, kicad_footprint_dir() + + +def fpcheck(net_xml, prj_dir): + """每个器件:封装文件存在;符号引脚号 ⊆ 焊盘号;有编号的焊盘都有对应引脚(否则它挂不上网)。 + 返回问题清单。""" + r = ET.parse(net_xml).getroot() + pins = {} + for lp in r.iter("libpart"): + k = (lp.get("lib"), lp.get("part")) + pins[k] = {p.get("num") for p in lp.iter("pin")} + dirs, share = _fp_dirs(prj_dir) + bad = [] + for c in r.iter("comp"): + ref, fp = c.get("ref"), c.findtext("footprint") or "" + if ref.startswith("#"): + continue + if not fp: + bad.append("%s 没有封装" % ref) + continue + lib, name = fp.split(":", 1) + base = dirs.get(lib) or os.path.join(share, lib + ".pretty") + f = os.path.join(base, name + ".kicad_mod") + if not os.path.exists(f): + bad.append("%s 的封装 %s 找不到(%s)" % (ref, fp, f)) + continue + pads = set(re.findall(r'\(pad\s+"([^"]+)"', io.open(f, encoding="utf-8").read())) + ls = c.find("libsource") + sp = pins.get((ls.get("lib"), ls.get("part")), set()) if ls is not None else set() + miss = sorted(sp - pads) + extra = sorted(pads - sp) + if miss: + bad.append("%s(%s) 符号引脚在封装里没有:%s" % (ref, name, ",".join(miss))) + if extra and sp: + bad.append("%s(%s) 焊盘没有对应符号引脚:%s" % (ref, name, ",".join(extra))) + return bad + + +def main(argv): + root = argv[0] + expect = None + if "--expect-refs" in argv: + v = argv[argv.index("--expect-refs") + 1] + expect = set((io.open(v).read() if os.path.exists(v) else v).replace("\n", ",").split(",")) + expect.discard("") + sys.stdout.reconfigure(encoding="utf-8") + rc = 0 + rpt, kinds = erc(root) + print("ERC:", ", ".join("%s x%d" % (k, len(v)) for k, v in sorted(kinds.items())) or "0 条") + comps, nets, npath = netlist(root) + real = {k: v for k, v in comps.items() if not k.startswith("#")} + print("网表: 器件 %d 个,网络 %d 条 (%s)" % (len(real), len(nets), npath)) + fb = fpcheck(npath, os.path.dirname(os.path.abspath(root))) + print("封装: %s" % ("全部 %d 个器件有封装且引脚/焊盘一一对应" % len(real) if not fb + else "%d 处问题" % len(fb))) + for b in fb: + print(" !! " + b) + if fb: + rc = 1 + if expect is not None: + miss = sorted(expect - set(real)) + if miss: + rc = 1 + print(" !! 网表里缺: %s" % " ".join(miss)) + d = os.path.dirname(os.path.abspath(root)) + files = sorted(set(re.findall(r'\(property "Sheetfile" "([^"]+)"', io.open(root, encoding="utf-8").read()))) + for f in files: + n, a, b, cj = subckt(os.path.join(d, f)) + flag = "OK " if not a and not b and not cj else "!! " + if a or b or cj: + rc = 1 + print("%s%-28s 元件 %3d A:两端只挂标签 %d B:没进子电路 %d C:丁字口缺接点 %d %s" + % (flag, f, n, len(a), len(b), len(cj), " ".join(sorted(set(a) | set(b)) + cj))) + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/kicad-flow/scripts/schgen/diagram.py b/kicad-flow/scripts/schgen/diagram.py new file mode 100644 index 0000000..fe91bd0 --- /dev/null +++ b/kicad-flow/scripts/schgen/diagram.py @@ -0,0 +1,103 @@ +"""把 drawio 框图渲染成 PNG,供原理图第一页当插图(n10 硬要求)。 + +两种 drawio 格式都吃: + * SVG 外壳(drawio MCP / 导出的 .drawio.svg):headless Chrome 直接截 SVG; + * 纯 mxfile XML(drawio 桌面版存的 .drawio):套一层 HTML,用 diagrams.net 的 + viewer-static.min.js 在 headless Chrome 里渲染后截图(需要能上网取 JS)。 + +不用 draw.io.exe --export:G-0022 实测它在 Windows 上报 input file not found,还会挂起。 + + python -m schgen.diagram [--scale 2] +""" +import html +import io +import os +import re +import struct +import subprocess +import sys +import tempfile + +from .env import chrome + +VIEWER_JS = "https://viewer.diagrams.net/js/viewer-static.min.js" + + +def _png_size(p): + d = io.open(p, "rb").read(32) + return struct.unpack(">II", d[16:24]) + + +def _page_size(xml): + m = re.search(r'pageWidth="(\d+)"[^>]*pageHeight="(\d+)"', xml) + if m: + return int(m.group(1)), int(m.group(2)) + return 1600, 1200 + + +def _bounds(xml): + """所有顶点几何的外包框,用来裁掉页面空白。""" + xs, ys = [], [] + for m in re.finditer(r']*?)/?>', xml): + a = dict(re.findall(r'(\w+)="([^"]*)"', m.group(1))) + if a.get("relative") == "1" or "width" not in a: + continue + x, y = float(a.get("x", 0)), float(a.get("y", 0)) + w, h = float(a["width"]), float(a["height"]) + xs += [x, x + w]; ys += [y, y + h] + if not xs: + return None + return min(xs), min(ys), max(xs), max(ys) + + +def render(src, png, scale=2, margin=20): + raw = io.open(src, encoding="utf-8", errors="replace").read() + tmpdir = tempfile.mkdtemp(prefix="kf_diag_") + if raw.lstrip().startswith("]*\swidth="([\d.]+)(?:px)?"[^>]*\sheight="([\d.]+)', raw) + w, h = (int(float(m.group(1))), int(float(m.group(2)))) if m else (1600, 1200) + url = "file:///" + os.path.abspath(src).replace("\\", "/") + budget = [] + else: + xml = raw + bb = _bounds(xml) + if bb: + w, h = int(bb[2] - bb[0] + 2 * margin), int(bb[3] - bb[1] + 2 * margin) + else: + w, h = _page_size(xml) + cfg = '{"highlight":"#0000ff","nav":false,"resize":false,"toolbar":"","edit":null,' \ + '"lightbox":false,"border":%d,"xml":%s}' % (margin, _json_str(xml)) + page = ('' + '' + '
' + '' % (html.escape(cfg, quote=True), VIEWER_JS)) + hp = os.path.join(tmpdir, "d.html") + io.open(hp, "w", encoding="utf-8").write(page) + url = "file:///" + hp.replace("\\", "/") + budget = ["--virtual-time-budget=15000"] + if os.path.exists(png): + os.remove(png) + os.makedirs(os.path.dirname(os.path.abspath(png)), exist_ok=True) + cmd = [chrome(), "--headless", "--disable-gpu", "--hide-scrollbars", + "--force-device-scale-factor=%d" % scale, "--default-background-color=FFFFFFFF", + "--window-size=%d,%d" % (w, h), "--screenshot=%s" % os.path.abspath(png)] + budget + [url] + subprocess.run(cmd, check=True, capture_output=True, timeout=120) + if not os.path.exists(png): + raise SystemExit("截图失败:%s" % png) + pw, ph = _png_size(png) + print("框图 -> %s %dx%d px(KiCad 按 300DPI 解释:scale=1 时 %.0fx%.0f mm)" + % (png, pw, ph, pw / 300.0 * 25.4, ph / 300.0 * 25.4)) + return png + + +def _json_str(s): + import json + return json.dumps(s) + + +if __name__ == "__main__": + a = sys.argv[1:] + sc = 2 + if "--scale" in a: + i = a.index("--scale"); sc = int(a[i + 1]); del a[i:i + 2] + render(a[0], a[1], scale=sc) diff --git a/kicad-flow/scripts/schgen/env.py b/kicad-flow/scripts/schgen/env.py new file mode 100644 index 0000000..2e49cbd --- /dev/null +++ b/kicad-flow/scripts/schgen/env.py @@ -0,0 +1,52 @@ +"""读 ~/skills_env.md 里的本机路径(KICAD_ROOT 等),不在脚本里写死。""" +import io +import os +import re + +_ENV = None + + +def env(key, default=None): + global _ENV + if _ENV is None: + _ENV = {} + p = os.path.join(os.path.expanduser("~"), "skills_env.md") + if os.path.exists(p): + for line in io.open(p, encoding="utf-8", errors="replace"): + m = re.match(r"^\s*([A-Z0-9_]+)\s*=\s*(.+?)\s*$", line) + if m: + _ENV.setdefault(m.group(1), re.split(r"\s+#", m.group(2))[0].strip()) + return os.environ.get(key) or _ENV.get(key) or default + + +def kicad_root(): + r = env("KICAD_ROOT") + if not r: + raise SystemExit("~/skills_env.md 里没有 KICAD_ROOT") + return r + + +def kicad_cli(): + return os.path.join(kicad_root(), "bin", "kicad-cli.exe") + + +def kicad_symbol_dir(): + return os.path.join(kicad_root(), "share", "kicad", "symbols") + + +def kicad_footprint_dir(): + return os.path.join(kicad_root(), "share", "kicad", "footprints") + + +def chrome(): + """headless 渲染框图用。skills_env 里可配 CHROME_EXE;否则按常见位置找 Chrome / Edge。""" + c = env("CHROME_EXE") + cands = [c] if c else [] + cands += [r"C:\Program Files\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", + r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", + r"C:\Program Files\Microsoft\Edge\Application\msedge.exe"] + for p in cands: + if p and os.path.exists(p): + return p + raise SystemExit("找不到 Chrome/Edge,请在 ~/skills_env.md 里配 CHROME_EXE") diff --git a/kicad-flow/scripts/schgen/fplib.py b/kicad-flow/scripts/schgen/fplib.py new file mode 100644 index 0000000..1231104 --- /dev/null +++ b/kicad-flow/scripts/schgen/fplib.py @@ -0,0 +1,138 @@ +"""工程封装库:从参考设计的 .kicad_pcb 按位号提取封装 / 从 KiCad 官方库拷贝。 + +**必须用 KiCad 自带的 python 跑**(要 import pcbnew):~/skills_env.md 的 KICAD_PYTHON。 + + from schgen.fplib import FpLib + lib = FpLib(r"E:\\...\\lib\\myboard.pretty") + lib.from_pcb(r"E:\\...\\SMOD_V1.1.kicad_pcb", [("WSON-10-0R5", "U703", "TPS7A9201")]) + lib.from_kicad([("SOT-23", "Package_TO_SOT_SMD", "SOT-23", "Q")]) + lib.report() + +取封装的顺序(G-0033):① 项目已有参考设计 ② KiCad 官方库(本地与线上)③ 第三方 +④ 按 datasheet 自己画。厂商私有 case style 往往就是某个标准封装,先看 datasheet 末页。 + +pcbnew swig 的坑(G-0038):便捷函数 FootprintLoad() 不能用,要走 PCB_IO_MGR 插件; +对 BOARD Remove() 过 FOOTPRINT 之后 swig 类型注册会坏,所以这里只读板子、不改板子。 +""" +import os + +import pcbnew + +MIN_TXT_H = pcbnew.FromMM(0.8) # 丝印下限(嘉立创):字高 0.8 / 笔宽 0.15 +MIN_TXT_W = pcbnew.FromMM(0.15) + + +class FpLib: + def __init__(self, pretty): + self.out = pretty + os.makedirs(pretty, exist_ok=True) + self.io = pcbnew.PCB_IO_MGR.FindPlugin(pcbnew.PCB_IO_MGR.KICAD_SEXP) + self.ok, self.miss = [], [] + self._boards = {} + + @staticmethod + def fixup(fp): + # 无编号且孔环为 0 的"焊盘"其实是机械定位孔,改成 NPTH,否则 DRC 报 annular_width + for pad in fp.Pads(): + if pad.GetNumber(): + continue + d, s = pad.GetDrillSize(), pad.GetSize() + if d.x and d.x >= s.x - 1 and d.y >= s.y - 1: + pad.SetAttribute(pcbnew.PAD_ATTRIB_NPTH) + pad.SetLayerSet(pcbnew.PAD.UnplatedHoleMask()) + texts = [fp.Reference(), fp.Value()] + for it in fp.GraphicalItems(): + if it.GetClass() in ("PCB_TEXT", "FP_TEXT"): + texts.append(it) + for it in texts: + if it.GetTextHeight() < MIN_TXT_H: + it.SetTextSize(pcbnew.VECTOR2I(MIN_TXT_H, MIN_TXT_H)) + if it.GetTextThickness() < MIN_TXT_W: + it.SetTextThickness(MIN_TXT_W) + + def save(self, fp, name, fix=None): + fp.SetOrientationDegrees(0) # 板上实例还原成库里的朝向 + if fp.IsFlipped(): + fp.Flip(fp.GetPosition(), pcbnew.FLIP_DIRECTION_TOP_BOTTOM + if hasattr(pcbnew, "FLIP_DIRECTION_TOP_BOTTOM") else False) + fp.SetPosition(pcbnew.VECTOR2I(0, 0)) + fp.SetFPID(pcbnew.LIB_ID("", name)) + fp.SetReference("REF**") + fp.SetValue(name) + fp.SetPath(pcbnew.KIID_PATH()) + self.fixup(fp) + if fix: + fix(fp) + self.io.FootprintSave(self.out, fp) + return sorted({p.GetNumber() for p in fp.Pads()}, key=lambda s: (len(s), s)) + + def from_pcb(self, pcb, items, fix=None): + """items: [(目标封装名, 参考板上的位号, 说明), ...]""" + if pcb not in self._boards: + print("载入 %s ..." % pcb) + self._boards[pcb] = pcbnew.LoadBoard(pcb) + brd = self._boards[pcb] + for name, ref, note in items: + fp = brd.FindFootprintByReference(ref) + if fp is None: + self.miss.append((name, "%s 里找不到位号 %s" % (os.path.basename(pcb), ref))) + continue + src = "%s:%s(%s)" % (os.path.basename(pcb), fp.GetFPID().GetUniStringLibItemName(), ref) + self.ok.append((name, src, self.save(fp, name, fix), note)) + + def from_kicad(self, items, fp_dir=None, fix=None): + """items: [(目标封装名, 库名, 库内封装名, 说明), ...];目标名给 None 表示沿用原名。""" + from .env import kicad_footprint_dir + fp_dir = fp_dir or kicad_footprint_dir() + for name, lib, src, note in items: + try: + fp = self.io.FootprintLoad(os.path.join(fp_dir, lib + ".pretty"), src) + if fp is None: + raise RuntimeError("FootprintLoad 返回 None") + self.ok.append((name or src, "KiCad:%s/%s" % (lib, src), + self.save(fp, name or src, fix), note)) + except Exception as e: + self.miss.append((name or src, "%s: %s" % (type(e).__name__, e))) + + def add_courtyard(self, name, rect=None, margin=0.25, silk_rect=False): + """给库里的封装补 F.CrtYd(参考设计提来的常常没有,DRC 就查不到它和别人重叠)。 + rect=(x0, y0, x1, y1) 给实物外形(封装本地坐标,mm,如简牛护套);不给就按焊盘+丝印外框。 + silk_rect=True 时同时在 F.SilkS 画出这个外形。已有 courtyard 的先清掉再画。""" + fp = self.io.FootprintLoad(self.out, name) + for g in list(fp.GraphicalItems()): + if g.GetLayer() == pcbnew.F_CrtYd: + fp.Remove(g) + if rect is None: + xs, ys = [], [] + for p in fp.Pads(): + bb = p.GetBoundingBox() + xs += [bb.GetX(), bb.GetRight()]; ys += [bb.GetY(), bb.GetBottom()] + for g in fp.GraphicalItems(): + if g.GetLayer() in (pcbnew.F_SilkS, pcbnew.F_Fab) and g.GetClass() != "PCB_TEXT": + bb = g.GetBoundingBox() + xs += [bb.GetX(), bb.GetRight()]; ys += [bb.GetY(), bb.GetBottom()] + rect = (pcbnew.ToMM(min(xs)), pcbnew.ToMM(min(ys)), pcbnew.ToMM(max(xs)), pcbnew.ToMM(max(ys))) + x0, y0, x1, y1 = rect[0] - margin, rect[1] - margin, rect[2] + margin, rect[3] + margin + layers = [pcbnew.F_CrtYd] + ([pcbnew.F_SilkS] if silk_rect else []) + for lay in layers: + s = pcbnew.PCB_SHAPE(fp) + s.SetShape(pcbnew.SHAPE_T_RECT) + s.SetStart(pcbnew.VECTOR2I(pcbnew.FromMM(x0 if lay == pcbnew.F_CrtYd else rect[0]), + pcbnew.FromMM(y0 if lay == pcbnew.F_CrtYd else rect[1]))) + s.SetEnd(pcbnew.VECTOR2I(pcbnew.FromMM(x1 if lay == pcbnew.F_CrtYd else rect[2]), + pcbnew.FromMM(y1 if lay == pcbnew.F_CrtYd else rect[3]))) + s.SetLayer(lay) + s.SetWidth(pcbnew.FromMM(0.05 if lay == pcbnew.F_CrtYd else 0.15)) + fp.Add(s) + fp.Reference().SetTextAngleDegrees(0) # 参考设计里常是 180°,看着像镜像 + self.io.FootprintSave(self.out, fp) + print(" courtyard %-20s (%.2f,%.2f)-(%.2f,%.2f)" % (name, x0, y0, x1, y1)) + + def report(self): + print("\n写出 %s" % self.out) + for name, src, pads, note in self.ok: + ps = ",".join(pads) if len(pads) <= 12 else "%s..%s (%d)" % (pads[0], pads[-1], len(pads)) + print(" %-34s pads=%-22s <- %-40s %s" % (name, ps, src, note)) + for name, why in self.miss: + print(" !! %-31s %s" % (name, why)) + return 0 if not self.miss else 1 diff --git a/kicad-flow/scripts/schgen/kicad_lib.py b/kicad-flow/scripts/schgen/kicad_lib.py new file mode 100644 index 0000000..d06dc74 --- /dev/null +++ b/kicad-flow/scripts/schgen/kicad_lib.py @@ -0,0 +1,335 @@ +"""写 .kicad_sch 的底层:符号提取、外框、旋转变换、页面拼装。 + +符号来源用注册制:KiCad 自带库按库名直接找;工程自己的库用 register_lib(name, path) +登记(例如 register_lib("myboard", r"...\\lib\\myboard.kicad_sym"))。 +""" +import io +import os +import re +import hashlib +import math + +from .env import kicad_symbol_dir + +_LIBS = {} # 库名 -> .kicad_sym 路径(工程库) +_cache = {} + + +_FP_FILTER = {} # lib_id -> 覆盖后的 ki_fp_filters + + +def set_fp_filter(lib_id, flt): + """覆盖某个符号的 ki_fp_filters(封装选择器的"Apply footprint filters"用它)。 + KiCad 通用连接器符号自带 Connector*:*_1x??_* 之类的过滤,工程自己的封装库 + (如从参考设计提取的 VH3.96-2P)会被它藏掉,看起来像"没有封装"。flt 用空格分隔多个模式。""" + _FP_FILTER[lib_id] = flt + + +def register_lib(name, path): + _LIBS[name] = path + _cache.pop(name, None) + _bbox_cache.clear() + + +def _read(libname): + if libname in _cache: + return _cache[libname] + p = _LIBS.get(libname) or os.path.join(kicad_symbol_dir(), libname + ".kicad_sym") + _cache[libname] = io.open(p, encoding="utf-8").read() + return _cache[libname] + + +def balanced(text, start): + """从 text[start] 的 '(' 起取配平的整段 s-expression(跳过字符串里的括号)。""" + d, j = 0, start + while j < len(text): + c = text[j] + if c == '"': + j += 1 + while j < len(text) and not (text[j] == '"' and text[j - 1] != '\\'): + j += 1 + elif c == '(': + d += 1 + elif c == ')': + d -= 1 + if d == 0: + return text[start:j + 1] + j += 1 + raise ValueError("unbalanced") + + +def symbol_def(lib_id): + """取出 (symbol "Lib:Name" ...) 定义,名字改成 lib_id 形式供 lib_symbols 用。 + KiCad 自带库里的 (extends "X") 派生符号会被展开成完整定义。""" + lib, name = lib_id.split(":", 1) + t = _read(lib) + i = t.find('(symbol "%s"' % name) + if i < 0: + raise KeyError(lib_id) + blk = balanced(t, i) + m = re.search(r'\(extends "([^"]+)"\)', blk) + if m: + # 派生符号:拿父符号的图形/引脚,属性用子符号的(属性块有嵌套括号,必须按配平取) + base = balanced(t, t.find('(symbol "%s"' % m.group(1))) + body = re.sub(r'\(symbol "%s_' % re.escape(m.group(1)), '(symbol "%s_' % name, base) + body = body.replace('(symbol "%s"' % m.group(1), '(symbol "%s"' % name, 1) + for pm in re.finditer(r'\(property "([^"]*)"', blk): + pr = balanced(blk, pm.start()) + k = body.find('(property "%s"' % pm.group(1)) + if k >= 0: + old = balanced(body, k) + body = body[:k] + pr + body[k + len(old):] + else: # 父符号没有的属性(如 ki_fp_filters)插在最后一个属性后 + k = body.rfind('(property "') + old = balanced(body, k) + body = body[:k + len(old)] + "\n\t\t" + pr + body[k + len(old):] + blk = body + blk = blk.replace('(symbol "%s"' % name, '(symbol "%s"' % lib_id, 1) + if lib_id in _FP_FILTER: + k = blk.find('(property "ki_fp_filters"') + if k >= 0: + old = balanced(blk, k) + blk = blk[:k] + re.sub(r'\(property "ki_fp_filters" "[^"]*"', + lambda _: '(property "ki_fp_filters" "%s"' % _FP_FILTER[lib_id], + old, count=1) + blk[k + len(old):] + return blk + + +def uid(*parts): + """确定性 uuid:同样的输入每次生成同样的 uuid,重跑脚本不会让 git diff 满屏。""" + h = hashlib.md5(("|".join(str(p) for p in parts)).encode()).hexdigest() + return "%s-%s-%s-%s-%s" % (h[0:8], h[8:12], h[12:16], h[16:20], h[20:32]) + + +def _unit_blocks(blk, unit): + """取出属于某个 unit 的子符号。KiCad 把各 unit 共用图形放在 NAME_0_x, + 引脚放在 NAME__x,两边都要,只取后者会丢掉整个本体图形(G-0019)。""" + if unit is None: + return blk + subs = [] + for m in re.finditer(r'\(symbol "([^"]*_(\d+)_\d+)"', blk): + if int(m.group(2)) in (0, unit): + subs.append(balanced(blk, m.start())) + return "\n".join(subs) if subs else blk + + +def pin_positions(lib_id, unit=None): + """{pin_number: (dx, dy, rot)},相对符号原点;符号坐标 y 向上为正。""" + blk = _unit_blocks(symbol_def(lib_id), unit) + res = {} + for m in re.finditer(r'\(pin\s+\S+\s+\S+\s*\(at\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\)', blk): + x, y, rot = float(m.group(1)), float(m.group(2)), float(m.group(3)) + seg = blk[m.end():m.end() + 400] + nm = re.search(r'\(number "([^"]*)"', seg) + if nm: + res[nm.group(1)] = (x, y, rot) + return res + + +GRID = 1.27 + + +def snap(v): + """吸附到 1.27mm 连接网格,否则 ERC 报 off-grid、线也连不上。""" + return round(float(v) / GRID) * GRID + + +_bbox_cache = {} + + +def symbol_bbox(lib_id, unit=None): + """符号外框 (minx, miny, maxx, maxy),符号坐标系(y 向上),含引脚端点。""" + key = (lib_id, unit) + if key in _bbox_cache: + return _bbox_cache[key] + blk = _unit_blocks(symbol_def(lib_id), unit) + xs, ys = [], [] + N = r'(-?\d+(?:\.\d+)?)' + for m in re.finditer(r'\((rectangle|polyline|circle|arc|bezier)\b', blk): + g = balanced(blk, m.start()) + for mm in re.finditer(r'\((?:start|end|mid|xy|center)\s+' + N + r'\s+' + N, g): + xs.append(float(mm.group(1))); ys.append(float(mm.group(2))) + cm = re.search(r'\(center\s+' + N + r'\s+' + N + r'\s*\)\s*\(radius\s+' + N, g) + if cm: + cx, cy, r = float(cm.group(1)), float(cm.group(2)), float(cm.group(3)) + xs += [cx - r, cx + r]; ys += [cy - r, cy + r] + for m in re.finditer(r'\(pin\b', blk): + g = balanced(blk, m.start()) + am = re.search(r'\(at\s+' + N + r'\s+' + N, g) + if am: + xs.append(float(am.group(1))); ys.append(float(am.group(2))) + if not xs: + xs, ys = [-2.54, 2.54], [-2.54, 2.54] + bb = (min(xs), min(ys), max(xs), max(ys)) + _bbox_cache[key] = bb + return bb + + +def rot_pt(dx, dy, rot): + a = math.radians(rot) + c, s = math.cos(a), math.sin(a) + return (dx * c - dy * s, dx * s + dy * c) + + +def rot_bbox(bb, rot): + xs, ys = [], [] + for x in (bb[0], bb[2]): + for y in (bb[1], bb[3]): + px, py = rot_pt(x, y, rot) + xs.append(px); ys.append(py) + return (min(xs), min(ys), max(xs), max(ys)) + + +def placed_pins(lib_id, unit=None, rot=0): + """{pin: (dx_sheet, dy_sheet, rot_sheet)}:已含旋转与 y 翻转,加到放置坐标上即图纸坐标。""" + out = {} + for k, (dx, dy, prot) in pin_positions(lib_id, unit).items(): + rx, ry = rot_pt(dx, dy, rot) + out[k] = (rx, -ry, (prot + rot) % 360) + return out + + +def pin_dir(rot_sheet): + """引脚出线方向(图纸坐标,y 向下)。引脚 angle 指向本体内侧,出线取反向。""" + a = math.radians(rot_sheet) + return (-math.cos(a), math.sin(a)) + + +def esc(s): + return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + + +class Sheet: + """一页 .kicad_sch 的内容。子图不写 sheet_instances(那是根图独有的)。""" + + def __init__(self, title, project, paper="A3", rev="A", company="", + sheet_uuid=None, root_uuid=None): + # sheet_uuid 必须等于根图里该页 sheet 符号的 uuid,否则 KiCad 判为批注错误 + self.sheet_uuid = sheet_uuid or uid("root", title) + self.root_uuid = root_uuid + self.title, self.project, self.paper = title, project, paper + self.rev, self.company = rev, company + self.items, self.libs, self.n = [], {}, 0 + + def _need(self, lib_id): + if lib_id not in self.libs: + self.libs[lib_id] = symbol_def(lib_id) + + def _path(self): + return "/%s/%s" % (self.root_uuid, self.sheet_uuid) if self.root_uuid \ + else "/%s" % self.sheet_uuid + + def comp(self, lib_id, ref, value, x, y, rot=0, unit=1, footprint="", + hide_ref=False, hide_val=False, ref_at=None, val_at=None, dnp=False, + fields=None, in_bom=True): + """放置元件。ref_at/val_at = (绝对x, 绝对y, 对齐),由调用方按外框算好。 + fields: 额外属性 {名字: 值}(如 MPN),一律隐藏。""" + x, y = snap(x), snap(y) + self._need(lib_id) + bb = rot_bbox(symbol_bbox(lib_id, unit), rot) + if ref_at is None: + ref_at = (x, y - bb[3] - 2.032, "center") + if val_at is None: + val_at = (x, y - bb[1] + 2.032, "center") + rxa, rya, rj = ref_at + vxa, vya, vj = val_at + # justify 只认 left/right/top/bottom/mirror;写 center 会让整页静默解析失败(G-0018) + rj = "" if rj == "center" else " (justify %s)" % rj + vj = "" if vj == "center" else " (justify %s)" % vj + hidr = " hide" if hide_ref else "" + hidv = " hide" if hide_val else "" + # 90/270 放置时 KiCad 把元件角度叠到字段上,反向补偿;180 走镜像(G-0019) + fa = {0: 0, 90: 270, 180: 0, 270: 90}[rot % 360] + self.n += 1 + u = uid(self.title, ref, unit, self.n) + extra = "" + for k, v in (fields or {}).items(): + extra += (' (property "%s" "%s" (at %.2f %.2f 0)' + ' (effects (font (size 1.27 1.27)) hide))\n' % (esc(k), esc(v), x, y)) + self.items.append( + ' (symbol (lib_id "%s") (at %.2f %.2f %d) (unit %d)\n' + ' (exclude_from_sim no) (in_bom %s) (on_board yes) (dnp %s)\n' + ' (uuid "%s")\n' + ' (property "Reference" "%s" (at %.2f %.2f %d)' + ' (effects (font (size 1.27 1.27))%s%s))\n' + ' (property "Value" "%s" (at %.2f %.2f %d)' + ' (effects (font (size 1.27 1.27))%s%s))\n' + ' (property "Footprint" "%s" (at %.2f %.2f 0) (effects (font (size 1.27 1.27)) hide))\n' + ' (property "Datasheet" "" (at %.2f %.2f 0) (effects (font (size 1.27 1.27)) hide))\n' + '%s' + ' (instances (project "%s" (path "%s" (reference "%s") (unit %d))))\n' + ' )' + % (lib_id, x, y, rot, unit, ("yes" if in_bom else "no"), ("yes" if dnp else "no"), u, + ref, rxa, rya, fa, rj, hidr, + esc(value), vxa, vya, fa, vj, hidv, + footprint, x, y, x, y, extra, self.project, self._path(), ref, unit)) + return (x, y) + + def wire(self, x1, y1, x2, y2): + x1, y1, x2, y2 = snap(x1), snap(y1), snap(x2), snap(y2) + self.n += 1 + self.items.append( + ' (wire (pts (xy %.2f %.2f) (xy %.2f %.2f))\n' + ' (stroke (width 0) (type default)) (uuid "%s"))' + % (x1, y1, x2, y2, uid(self.title, "w", x1, y1, x2, y2, self.n))) + + # 标签文字角度只有 0/90,朝向由 justify 决定;写 180/270 会反向压在元件上(G-0019) + GLROT = {0: (0, "left"), 180: (0, "right"), 90: (90, "left"), 270: (90, "right")} + + def glabel(self, name, x, y, direction=0, shape="bidirectional"): + """direction: 标签体伸出方向,0=右 90=上 180=左 270=下。""" + x, y = snap(x), snap(y) + ang, just = self.GLROT[direction % 360] + self.n += 1 + self.items.append( + ' (global_label "%s" (shape %s) (at %.2f %.2f %d) (fields_autoplaced yes)\n' + ' (effects (font (size 1.27 1.27)) (justify %s)) (uuid "%s"))' + % (name, shape, x, y, ang, just, uid(self.title, "gl", name, x, y, self.n))) + + def text(self, s, x, y, size=1.6): + self.n += 1 + self.items.append( + ' (text "%s" (at %.2f %.2f 0)\n' + ' (effects (font (size %.2f %.2f)) (justify left bottom)) (uuid "%s"))' + % (esc(s.replace('"', "'")), x, y, size, size, uid(self.title, "t", s, x, y, self.n))) + + def rect(self, x0, y0, x1, y1, dash=True): + """虚线框:圈出一个子电路(例如"留位不贴"的一组件)。""" + self.n += 1 + self.items.append( + ' (rectangle (start %.2f %.2f) (end %.2f %.2f)\n' + ' (stroke (width 0.2) (type %s)) (fill (type none)) (uuid "%s"))' + % (x0, y0, x1, y1, "dash" if dash else "default", + uid(self.title, "rect", x0, y0, self.n))) + + def junction(self, x, y): + """丁字接点:一条线的端点落在另一条线中段上,必须放 junction 才导通(G-0036)。""" + x, y = snap(x), snap(y) + self.n += 1 + self.items.append( + ' (junction (at %.2f %.2f) (diameter 0) (color 0 0 0 0)' + ' (uuid "%s"))' % (x, y, uid(self.title, "j", x, y, self.n))) + + def nc(self, x, y): + x, y = snap(x), snap(y) + self.n += 1 + self.items.append(' (no_connect (at %.2f %.2f) (uuid "%s"))' + % (x, y, uid(self.title, "nc", x, y, self.n))) + + def raw(self, sexpr): + self.items.append(sexpr) + + def dump(self, path, generator="kicad_flow_schgen"): + out = ['(kicad_sch (version 20231120) (generator "%s") (generator_version "8.0")' % generator, + ' (uuid "%s")' % uid("sheetfile", self.title), + ' (paper "%s")' % self.paper, + ' (title_block (title "%s") (rev "%s") (company "%s"))' + % (esc(self.title), self.rev, esc(self.company)), + ' (lib_symbols'] + for k in sorted(self.libs): + out.append(" " + self.libs[k].replace("\n", "\n ")) + out.append(' )') + out.extend(self.items) + out.append(')') + io.open(path, "w", encoding="utf-8").write("\n".join(out) + "\n") + return path diff --git a/kicad-flow/scripts/schgen/layout.py b/kicad-flow/scripts/schgen/layout.py new file mode 100644 index 0000000..36447d9 --- /dev/null +++ b/kicad-flow/scripts/schgen/layout.py @@ -0,0 +1,115 @@ +"""占位登记与重叠检测。 + +原理图上画的每一样东西都登记成一个矩形,最后逐对相交检查。 +要拦的是三类:文字压文字、文字压器件/走线、器件压器件。 +走线之间相交是正常的(交叉不等于连接),不算错。 +""" + +PAPER = {"A4": (297.0, 210.0), "A3": (420.0, 297.0), "A2": (594.0, 420.0), "A1": (841.0, 594.0)} + +CH_W = 1.20 # 1.27 号字下 ASCII 单字符宽度(mm)。原来用 0.90 会漏报:DVBS2_dmod 的 + # C405 "22u/35V" 实际压到隔壁 C406 的极板上而没报;1.10 又漏过 C412。宁可误报(G-0034) +CJK_W = 1.30 # 中文/全角字符宽度。KiCad 的笔画字体里全角字宽约等于字号, + # 按 ASCII 的 0.90 算会低估近 40% —— 09 RF 页就漏检过一处 + # 中文说明压住端接电阻的重叠,所以按字符类型分别估(G-0034)。 + + +def strw(s, size=1.27): + """按字符类型估算字符串宽度(mm)""" + k = size / 1.27 + return sum((CJK_W if ord(c) > 0x2E80 else CH_W) for c in s) * k +CH_H = 1.70 # 单行文字高度 +LBL_PAD = 3.0 # 全局标签外框比文字多出来的量(箭头/边框) + + +def cat(what): + return what.split(":", 1)[0] + + +class Canvas: + def __init__(self, paper="A3", name=""): + w, h = PAPER[paper] + # KiCad 图框内沿约在四边 10mm 处;左边还有行号栏,底部整条留给标题栏 + self.x0, self.y0 = 22.0, 14.0 + self.x1, self.y1 = w - 14.0, h - 36.0 + self.name, self.paper = name, paper + self.boxes = [] + + # ---- 登记 ---- + def add(self, x0, y0, x1, y1, what): + if x1 < x0: x0, x1 = x1, x0 + if y1 < y0: y0, y1 = y1, y0 + self.boxes.append((x0, y0, x1, y1, what)) + + def add_sym(self, cx, cy, bbox, what, pad=0.0): + """bbox 为符号坐标系(y 向上,已按放置角度旋转过)。""" + bx0, by0, bx1, by1 = bbox + self.add(cx + bx0 - pad, cy - by1 - pad, cx + bx1 + pad, cy - by0 + pad, what) + + def add_text(self, x, y, s, justify="left", size=1.27, what=None): + k = size / 1.27 + w = strw(s, size) + h = CH_H * k + if justify == "left": + x0, x1 = x, x + w + elif justify == "right": + x0, x1 = x - w, x + else: + x0, x1 = x - w / 2.0, x + w / 2.0 + self.add(x0, y - h * 0.8, x1, y + h * 0.4, what or ("text:" + s[:18])) + + def add_label(self, x, y, s, lrot, what=None): + """全局标签:锚点在 (x,y),标签体朝 lrot 指定的方向伸出。 + 0=向右 180=向左 90=向上 270=向下(KiCad 文字角度约定)。""" + w = strw(s) + LBL_PAD + t = 1.15 # 标签体半高(1.27 号字 + 边框,实测约 2.1~2.4mm 高;排针 2.54 行距刚好放得下) + d = 0.4 # 标签体把锚点包在里面,这一小段不算和自己的引线冲突 + if lrot == 0: + self.add(x + d, y - t, x + w, y + t, what) + elif lrot == 180: + self.add(x - w, y - t, x - d, y + t, what) + elif lrot == 90: + self.add(x - t, y - w, x + t, y - d, what) + else: + self.add(x - t, y + d, x + t, y + w, what) + + def add_wire(self, x1, y1, x2, y2, what="wire"): + t = 0.25 + self.add(min(x1, x2) - t, min(y1, y2) - t, max(x1, x2) + t, max(y1, y2) + t, what) + + # ---- 检查 ---- + def outside(self): + bad = [] + for b in self.boxes: + if b[0] < self.x0 - 0.01 or b[1] < self.y0 - 0.01 or \ + b[2] > self.x1 + 0.01 or b[3] > self.y1 + 0.01: + bad.append(b) + return bad + + @staticmethod + def _care(a, b): + ca, cb = cat(a), cat(b) + txt = ("text", "lbl", "ref") + # 断路标记画在引脚端点上,本来就贴着符号和走线,只管它别压着文字 + if "nc" in (ca, cb) or "jct" in (ca, cb): + return ca in txt or cb in txt + if ca in txt or cb in txt: + return True + return ca == "sym" and cb == "sym" + + def overlaps(self): + res, bs = [], self.boxes + for i in range(len(bs)): + ax0, ay0, ax1, ay1, aw = bs[i] + for j in range(i + 1, len(bs)): + bx0, by0, bx1, by1, bw = bs[j] + if ax1 <= bx0 + 0.01 or bx1 <= ax0 + 0.01 or \ + ay1 <= by0 + 0.01 or by1 <= ay0 + 0.01: + continue + if not self._care(aw, bw): + continue + res.append((aw, bw, round(ax0, 1), round(ay0, 1))) + return res + + def report(self): + return self.outside(), self.overlaps() diff --git a/kicad-flow/scripts/schgen/page.py b/kicad-flow/scripts/schgen/page.py new file mode 100644 index 0000000..e2c40ba --- /dev/null +++ b/kicad-flow/scripts/schgen/page.py @@ -0,0 +1,384 @@ +"""画原理图页的高层原语:Sheet 负责写文件,Canvas 负责记账做重叠/越界检查,两者成对更新。 + +用法(一个工程一个 Project,多页共享位号登记表与封装解析): + + from schgen import Project + prj = Project("MyBoard", out_dir=r"E:\\...\\MyBoard", + fp_resolver=lambda ref, val: FPMAP[val]) + pg = prj.page("02 Power", "02_Power.kicad_sch", paper="A3") + x, y, pp = pg.comp("Device:R", "R1", "10K", 100, 80) + ... + pg.finish() # 有重叠/越界 -> 打印并返回 False,不写文件 + +画法约定(G-0035 / G-0037):两脚无源件必须画进它服务的电路里—— +串联件用 inline()/fan_series(),退耦/上下拉用 shunt()/run_taps() 挂在母线上。 +不要 two_pin() 摆在空地两端各挂一个标签(只留给确实孤立的跳线位)。 +""" +import os + +from .kicad_lib import (Sheet, placed_pins, pin_dir, symbol_bbox, rot_bbox, uid, snap) +from .layout import Canvas, LBL_PAD, strw + +GND = "GND" +DEFAULT_PWRSYM = {"GND": "power:GND", "+3V3": "power:+3V3", "+5V": "power:+5V", + "+12V": "power:+12V", "+1V2": "power:+1V2", "+1V8": "power:+1V8", + "+2V5": "power:+2V5", "+3V3A": "power:+3V3A"} +R, C, L = "Device:R", "Device:C", "Device:L" + + +class Project: + def __init__(self, name, out_dir, fp_resolver=None, pwrsym=None, company="", rev="A", + props_resolver=None): + """fp_resolver(ref, val) -> "库:封装";props_resolver(ref, val) -> {"MPN": ..., ...} + (额外隐藏属性,BOM 用)。""" + self.name, self.out_dir = name, out_dir + self.fp_resolver = fp_resolver or (lambda ref, val: "") + self.props_resolver = props_resolver or (lambda ref, val: {}) + self.pwrsym = dict(DEFAULT_PWRSYM, **(pwrsym or {})) + self.company, self.rev = company, rev + self.refs = {} # 位号登记表:KiCad 对重名位号静默合并(G-0028) + self.npwr = 0 + self.pages = [] + + def page(self, title, fname, paper="A3"): + p = Page(self, title, fname, paper) + self.pages.append(p) + return p + + def footprint(self, ref, val): + if ref.startswith("#"): + return "" + fp = self.fp_resolver(ref, val) + if fp is None: + raise KeyError("没有为 %s (%s) 指定封装" % (ref, val)) + return fp + + +def pwr_rot(lib, ux, uy): + """选放置角度,让电源符号顺着出线方向伸出去,不倒压回走线。""" + bb = symbol_bbox(lib) + for rot in (0, 90, 180, 270): + b = rot_bbox(bb, rot) + sx = (b[0] + b[2]) / 2.0 + sy = -(b[1] + b[3]) / 2.0 + if ux * sx + uy * sy > 0.05: + return rot + return 0 + + +class Page: + def __init__(self, prj, title, fname, paper="A3"): + self.prj = prj + self.sh = Sheet(title, prj.name, paper=paper, rev=prj.rev, company=prj.company, + sheet_uuid=uid("sh", fname)) + self.cv = Canvas(paper=paper, name=fname) + self.fname = fname + self.frames = [] + + # ---------- 基本元素 ---------- + def text(self, s, x, y, size=1.6): + """多行用 \\n;每行都登记占位。""" + self.sh.text(s, x, y, size) + lines = s.split("\n") + for k, ln in enumerate(lines): + yy = y - (len(lines) - 1 - k) * size * 1.35 + self.cv.add_text(x, yy, ln, "left", size, what="text:" + ln[:20]) + + def wire(self, x1, y1, x2, y2): + x1, y1, x2, y2 = snap(x1), snap(y1), snap(x2), snap(y2) + self.sh.wire(x1, y1, x2, y2) + self.cv.add_wire(x1, y1, x2, y2) + + @staticmethod + def _lrot(ux, uy): + if ux > 0.5: + return 0 + if ux < -0.5: + return 180 + return 270 if uy > 0 else 90 + + def label(self, name, x, y, ux, uy): + """在 (x,y) 放全局标签,标签体朝 (ux,uy) 伸出。""" + lrot = self._lrot(ux, uy) + self.sh.glabel(name, x, y, lrot) + self.cv.add_label(x, y, name, lrot, what="lbl:" + name) + + def power(self, net, x, y, ux, uy): + """电源符号:藏 #PWRxxxx,但网名要显示。""" + lib = self.prj.pwrsym[net] + rot = pwr_rot(lib, ux, uy) + bb = rot_bbox(symbol_bbox(lib), rot) + self.prj.npwr += 1 + ref = "#PWR%04d" % self.prj.npwr + lrot = self._lrot(ux, uy) + if lrot == 0: + vx, vy, vj = x + bb[2] + 1.0, y, "left" + elif lrot == 180: + vx, vy, vj = x + bb[0] - 1.0, y, "right" + elif lrot == 90: + vx, vy, vj = x, y - bb[3] - 1.6, "center" + else: + vx, vy, vj = x, y - bb[1] + 1.6, "center" + self.sh.comp(lib, ref, net, x, y, rot=rot, hide_ref=True, + ref_at=(x, y, "left"), val_at=(vx, vy, vj)) + self.cv.add_sym(x, y, bb, "sym:" + ref) + self.cv.add_text(vx, vy, net, vj, what="ref:" + ref + "=") + + def nc(self, x, y): + self.sh.nc(x, y) + self.cv.add(x - 1.0, y - 1.0, x + 1.0, y + 1.0, "nc:NC") + + def frame(self, x0, y0, x1, y1, title=None, size=1.4): + """虚线框圈出一组件(如"留位不贴")。框线不参与重叠检查,标题文字参与。""" + self.sh.rect(x0, y0, x1, y1) + if title: + self.text(title, x0 + 1.5, y0 - 1.0, size) + + # ---------- 元件 ---------- + def comp(self, lib, ref, val, x, y, rot=0, unit=1, fields="above", show=True, + dnp=False, props=None, in_bom=True): + """fields: 'above' 位号上/数值下;'left' 叠在左侧;'right' 叠在右侧; + 'top' 都叠在上方;'none' 不显示;("xy", rx, ry, vx, vy) 手动指定(大芯片常用)。 + 返回 (x, y, 已旋转的引脚表)。""" + x, y = snap(x), snap(y) + key = (ref, unit) + prev = self.prj.refs.get(ref) + if key in self.prj.refs or (prev and prev[1] != val): + o = self.prj.refs.get(key, prev) + raise SystemExit("位号撞车 %s:%s 的 %s 与 %s 的 %s" % (ref, self.fname, val, o[0], o[1])) + self.prj.refs[key] = self.prj.refs[ref] = (self.fname, val) + bb = rot_bbox(symbol_bbox(lib, unit), rot) + fp = self.prj.footprint(ref, val) + if not ref.startswith("#"): + props = dict(self.prj.props_resolver(ref, val) or {}, **(props or {})) + dval = val # DNP 件 KiCad 只画灰色 + 红叉,数值字段原样,不加 "DNP" 字样 + if not show or fields == "none": + self.sh.comp(lib, ref, val, x, y, rot=rot, unit=unit, hide_ref=True, hide_val=True, + dnp=dnp, footprint=fp, fields=props, in_bom=in_bom) + else: + if isinstance(fields, tuple): # ("xy", 位号x, 位号y, 数值x, 数值y),左对齐 + ra, va = (fields[1], fields[2], "left"), (fields[3], fields[4], "left") + elif fields == "left": + fx = x + bb[0] - 1.6 + ra, va = (fx, y - 1.0, "right"), (fx, y + 2.0, "right") + elif fields == "right": + fx = x + bb[2] + 1.6 + ra, va = (fx, y - 1.0, "left"), (fx, y + 2.0, "left") + elif fields == "top": + ra = (x, y - bb[3] - 4.1, "center") + va = (x, y - bb[3] - 2.032, "center") + else: + ra = (x, y - bb[3] - 2.032, "center") + va = (x, y - bb[1] + 2.032, "center") + self.cv.add_text(ra[0], ra[1], ref, ra[2], what="ref:" + ref) + self.cv.add_text(va[0], va[1], dval, va[2], what="ref:" + ref + "=") + self.sh.comp(lib, ref, val, x, y, rot=rot, unit=unit, ref_at=ra, val_at=va, + dnp=dnp, footprint=fp, fields=props, in_bom=in_bom) + self.cv.add_sym(x, y, bb, "sym:" + ref) + pp = placed_pins(lib, unit, rot) + return x, y, pp + + def pin_xy(self, cx, cy, pp, pin): + return cx + pp[pin][0], cy + pp[pin][1] + + def stub(self, cx, cy, pp, pin, net, length=6.35, stagger=False): + """从引脚按其方向拉线,末端接全局标签或电源符号。 + stagger:相邻行电源引线一长一短错开,免得电源符号肩并肩顶在一起。""" + if pin not in pp: + raise KeyError("引脚 %s 不存在" % pin) + dx, dy, prot = pp[pin] + px, py = cx + dx, cy + dy + ux, uy = pin_dir(prot) + if stagger and net in self.prj.pwrsym: + row = int(round((dy if abs(ux) > 0.5 else dx) / 2.54)) + if row % 2: + length += 7.62 + ex, ey = snap(px + ux * length), snap(py + uy * length) + self.wire(px, py, ex, ey) + self.term(net, ex, ey, ux, uy) + return ex, ey + + def nc_pin(self, cx, cy, pp, pin): + px, py = self.pin_xy(cx, cy, pp, pin) + self.nc(px, py) + + def two_pin(self, lib, ref, val, x, y, na, nb, rot=0, stub=6.35, dnp=False): + """两脚件两端各挂标签。只用于确实孤立的件;外围件请用 inline/shunt。""" + pp = placed_pins(lib, 1, rot) + d1, d2 = pp["1"], pp["2"] + vertical = abs(d1[1] - d2[1]) > abs(d1[0] - d2[0]) + x, y, pp = self.comp(lib, ref, val, x, y, rot=rot, dnp=dnp, + fields="left" if vertical else "above") + for pin, net in (("1", na), ("2", nb)): + self.stub(x, y, pp, pin, net, stub) + return x, y + + # ---------- 子电路原语 ---------- + def _ends(self, lib, rot, ux, uy): + """沿 (ux,uy) 方向返回 (近端脚号, 远端脚号)。""" + p = placed_pins(lib, 1, rot) + a, b = sorted(p.keys())[:2] + ja = p[a][0] * ux + p[a][1] * uy + jb = p[b][0] * ux + p[b][1] * uy + return (a, b) if ja < jb else (b, a) + + def dot(self, x, y): + x, y = snap(x), snap(y) + self.sh.junction(x, y) + self.cv.add(x - 0.6, y - 0.6, x + 0.6, y + 0.6, "jct") + + def term(self, net, x, y, ux, uy): + """线的末端:标准轨用电源符号,其余用全局标签;net=None 表示什么都不接。""" + if net is None: + return + if net in self.prj.pwrsym: + self.power(net, snap(x), snap(y), ux, uy) + else: + self.label(net, snap(x), snap(y), ux, uy) + + def seg(self, x1, y1, x2, y2): + if abs(x1 - x2) > 0.01 or abs(y1 - y2) > 0.01: + self.wire(snap(x1), snap(y1), snap(x2), snap(y2)) + + def path(self, *pts): + """折线:path((x0,y0), (x1,y1), ...)""" + for a, b in zip(pts, pts[1:]): + self.seg(a[0], a[1], b[0], b[1]) + + def fan_series(self, cx, cy, pp, items, side=1, dist=40.0, pitch=12.7, + tail=6.35, ch0=18.0, chstep=3.81, taps=None): + """一组引脚上的串联件:引脚 -L形走线-> 元件 -> 标签,元件排成一列。 + items: [(引脚号, lib, 位号, 数值, 对外网名), ...];side +1 右 / -1 左。 + taps: {引脚号: 抽头点相对 cx 的偏移},在串阻外侧留接点挂上下拉。""" + ys = [cy + pp[p][1] for p, _, _, _, _ in items] + y0 = sum(ys) / len(ys) - pitch * (len(items) - 1) / 2.0 + ex = snap(cx + side * dist) + out = {} + near, far = self._ends(items[0][1], 90, side, 0) + for k, (pin, lib, ref, val, net) in enumerate(items): + px, py = cx + pp[pin][0], cy + pp[pin][1] + x, y, epp = self.comp(lib, ref, val, ex, snap(y0 + k * pitch), rot=90, fields="above") + nx, ny = x + epp[near][0], y + epp[near][1] + mx = snap(cx + side * (ch0 + k * chstep)) + self.seg(px, py, mx, py) + self.seg(mx, py, mx, ny) + self.seg(mx, ny, nx, ny) + fx, fy = x + epp[far][0], y + epp[far][1] + toff = (taps or {}).get(pin) + if toff is not None: + hx = snap(cx + side * toff) + self.seg(fx, fy, hx, fy) + self.dot(hx, fy) + fx, fy = hx, fy + out[pin] = (hx, fy) + tx = snap(fx + side * tail) + self.seg(fx, fy, tx, fy) + self.term(net, tx, fy, side, 0) + return out + + def shunt(self, bx, by, lib, ref, val, net, down=True, tail=6.35, dnp=False, + drop=9.0, fields="left", rot=0): + """从母线上一点往下(或往上)挂一个件,末端接电源符号或标签。 + 退耦电容、上下拉电阻都用它。返回 (件中心x, y, 远端x, 远端y)。""" + sgn = 1 if down else -1 + near, far = self._ends(lib, rot, 0, sgn) + x, y, epp = self.comp(lib, ref, val, snap(bx), snap(by + sgn * drop), rot=rot, + fields=fields, dnp=dnp) + nx, ny = x + epp[near][0], y + epp[near][1] + fx, fy = x + epp[far][0], y + epp[far][1] + self.seg(bx, by, nx, ny) + ty = snap(fy + sgn * tail) + if tail > 0: + self.seg(fx, fy, fx, ty) + self.term(net, fx, ty, 0, sgn) + return x, y, fx, fy + + def gnd_bus(self, cx, cy, pp, pins, side=1, dist=13.0, drop=9.0, net=GND): + """一侧若干地脚并到一条竖直母线上,底端只放一个地符号。""" + ys = [] + bx = snap(cx + side * dist) + for p in pins: + px, py = cx + pp[p][0], cy + pp[p][1] + self.seg(px, py, bx, py) + ys.append(snap(py)) + y0, y1 = min(ys), max(ys) + self.seg(bx, y0, bx, snap(y1 + drop)) + for yy in ys: + if abs(yy - y0) > 0.01: + self.dot(bx, yy) + self.power(net, bx, snap(y1 + drop), 0, 1) + + def run_taps(self, px, py, ux, stops, net=None, end=8.0): + """从 (px,py) 沿 ±x 拉线,沿途在指定 x 上打抽头挂件,末端出标签。 + stops: [(抽头 x, 'up'|'dn', lib, 位号, 数值, 另一端的网[, dnp]), ...]""" + cur = px + for k, st in enumerate(stops): + hx = snap(st[0]) + self.seg(cur, py, hx, py) + self.shunt(hx, py, st[2], st[3], st[4], st[5], down=(st[1] == "dn"), + dnp=(st[6] if len(st) > 6 else False)) + if k < len(stops) - 1 or net is not None: + self.dot(hx, py) + cur = hx + if net is not None: + tx = snap(cur + ux * end) + self.seg(cur, py, tx, py) + self.term(net, tx, py, ux, 0) + return cur + + def gnd_row(self, cx, cy, pp, pins, drop=6.0, tail=5.0, at=0, net=GND): + """一排朝下的地脚各自下拉一小段并到横母线,母线上只落一个地符号。""" + xs = [] + for p in pins: + px, py = cx + pp[p][0], cy + pp[p][1] + self.seg(px, py, px, snap(py + drop)) + xs.append(snap(px)) + yb = snap(cy + pp[pins[0]][1] + drop) + self.seg(min(xs), yb, max(xs), yb) + gx = xs[at] + self.seg(gx, yb, gx, snap(yb + tail)) + self.power(net, gx, snap(yb + tail), 0, 1) + for k, xx in enumerate(xs): + interior = 0.01 < abs(xx - min(xs)) and abs(xx - max(xs)) > 0.01 + if interior or k == at: + self.dot(xx, yb) + + def inline(self, x0, y0, lib, ref, val, side=1, length=12.7, dnp=False, fields="above", + rot=90): + """在水平线上串一个件:从 (x0,y0) 出发,返回线另一端坐标。 + 二极管这类本身横放的符号传 rot=0/180 来定正反向。""" + near, far = self._ends(lib, rot, side, 0) + cx = snap(x0 + side * length / 2.0) + x, y, epp = self.comp(lib, ref, val, cx, snap(y0), rot=rot, fields=fields, dnp=dnp) + nx, ny = x + epp[near][0], y + epp[near][1] + fx, fy = x + epp[far][0], y + epp[far][1] + self.seg(x0, y0, nx, ny) + return fx, fy + + def vline(self, x0, y0, lib, ref, val, down=True, length=12.7, dnp=False, fields="left", + rot=0): + """在竖直线上串一个件:从 (x0,y0) 出发,返回线另一端坐标。""" + sgn = 1 if down else -1 + near, far = self._ends(lib, rot, 0, sgn) + cy = snap(y0 + sgn * length / 2.0) + x, y, epp = self.comp(lib, ref, val, snap(x0), cy, rot=rot, fields=fields, dnp=dnp) + nx, ny = x + epp[near][0], y + epp[near][1] + fx, fy = x + epp[far][0], y + epp[far][1] + self.seg(x0, y0, nx, ny) + return fx, fy + + def finish(self, force=False): + out, ov = self.cv.report() + if out or ov: + print(" !! %s 越界 %d 处, 重叠 %d 处" % (self.fname, len(out), len(ov))) + for b in out[:15]: + print(" 越界 %-28s (%.1f,%.1f)-(%.1f,%.1f) 图框 (%.0f,%.0f)-(%.0f,%.0f)" + % (b[4], b[0], b[1], b[2], b[3], + self.cv.x0, self.cv.y0, self.cv.x1, self.cv.y1)) + for o in ov[:25]: + print(" 重叠 %-28s x %-28s @(%.1f,%.1f)" % o) + if not force: + return False + self.sh.dump(os.path.join(self.prj.out_dir, self.fname)) + print(" OK %-26s 元素 %4d 个" % (self.fname, len(self.cv.boxes))) + return True diff --git a/kicad-flow/scripts/schgen/pcbimport.py b/kicad-flow/scripts/schgen/pcbimport.py new file mode 100644 index 0000000..7efd512 --- /dev/null +++ b/kicad-flow/scripts/schgen/pcbimport.py @@ -0,0 +1,210 @@ +"""n101:从原理图网表确定性地建 PCB(封装 + 网络,封装按页排在"停车场"里),再逐焊盘验收。 + +**必须用 KiCad 自带的 python 跑**(要 pcbnew): + + -m schgen.pcbimport <根图.kicad_sch> <输出.kicad_pcb> [--layers 2|4] + +为什么不用 kicad MCP 的 sync_schematic_to_board(G-0039):它自报 success,实际 +ipots 载板写出 338 个封装(应 327)、191 个网(应 426);DVBS2_dmod 上 26 个网(应 60), +还冒出一个叫 PWR_FLAG 的网、U4 一串焊盘没网——所有 "Net-(...)" 无名网全丢。 +kicad-cli 没有"从原理图更新 PCB"子命令,GUI 的 F8 要人点,所以这里自己建。 + +pcbnew swig 的坑(G-0038):FootprintLoad() 便捷函数坏的,走 PCB_IO_KICAD_SEXPR; +对 BOARD Remove() 过 FOOTPRINT 后 swig 类型注册会坏,所以**每次都建一块新板**, +不做"载入旧板再删"。已有板子会被覆盖——布局开始之后就别再跑它了。 +""" +import io +import os +import re +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +import pcbnew + +from .env import kicad_cli, kicad_footprint_dir + +GAP, SHEET_GAP, FIELD_W = 2.0, 12.0, 250.0 + + +def mm(v): + return pcbnew.FromMM(float(v)) + + +def netlist(sch): + out = os.path.join(tempfile.mkdtemp(prefix="kf_pcb_"), "n.xml") + subprocess.check_call([kicad_cli(), "sch", "export", "netlist", "--format", "kicadxml", + "-o", out, sch], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + r = ET.parse(out).getroot() + comps = [] + for c in r.iter("comp"): + sh = c.find("sheetpath") + flds = {f.get("name"): (f.text or "") for f in c.iter("field")} + props = {pr.get("name") for pr in c.iter("property")} + comps.append(dict(ref=c.get("ref"), val=c.findtext("value") or "", fields=flds, + dnp="dnp" in props, no_bom="exclude_from_bom" in props, + fp=c.findtext("footprint") or "", + sheet=sh.get("names") if sh is not None else "", + path=(sh.get("tstamps") if sh is not None else "") + (c.findtext("tstamps") or ""))) + nets = [(n.get("name"), [(x.get("ref"), x.get("pin")) for x in n.iter("node")]) + for n in r.iter("net")] + return comps, nets + + +def fp_dirs(prj_dir): + d = {} + t = os.path.join(prj_dir, "fp-lib-table") + if os.path.exists(t): + for m in re.finditer(r'\(name "([^"]+)"\).*?\(uri "([^"]+)"\)', io.open(t, encoding="utf-8").read()): + d[m.group(1)] = m.group(2).replace("${KIPRJMOD}", prj_dir) + return d + + +_IO = pcbnew.PCB_IO_KICAD_SEXPR() + + +def load_fp(libdir, name): + fp = _IO.FootprintLoad(libdir, name) + if fp is not None and not hasattr(fp, "SetReference"): + fp = pcbnew.Cast_to_FOOTPRINT(fp) + return fp + + +def fp_box(fp): + """占地与包围盒相对原点的偏移——原点不一定在中心(连接器常在 1 脚)。""" + fp.SetPosition(pcbnew.VECTOR2I(0, 0)) + try: + bb = fp.GetBoundingBox(False, False) + except TypeError: + bb = fp.GetBoundingBox() + return (pcbnew.ToMM(bb.GetWidth()), pcbnew.ToMM(bb.GetHeight()), + pcbnew.ToMM(bb.GetX()), pcbnew.ToMM(bb.GetY())) + + +def note(board, s, x, y, size=2.0): + t = pcbnew.PCB_TEXT(board) + t.SetText(s) + t.SetPosition(pcbnew.VECTOR2I(mm(x), mm(y))) + t.SetLayer(pcbnew.Cmts_User) + t.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size))) + t.SetTextThickness(mm(size / 6.0)) + t.SetHorizJustify(pcbnew.GR_TEXT_H_ALIGN_LEFT) + board.Add(t) + + +def build(sch, pcb, layers=2, x0=20.0, y0=20.0): + prj_dir = os.path.dirname(os.path.abspath(sch)) + dirs = fp_dirs(prj_dir) + share = kicad_footprint_dir() + comps, nets = netlist(sch) + board = pcbnew.CreateEmptyBoard() + board.SetCopperLayerCount(layers) + placed, y = {}, y0 + for sheet in sorted(set(c["sheet"] for c in comps)): + grp = sorted((c for c in comps if c["sheet"] == sheet), key=lambda c: (c["fp"], c["ref"])) + note(board, "%s (%d)" % (sheet, len(grp)), x0, y - 4) + x, rowh = x0, 0.0 + for c in grp: + if not c["fp"]: + raise SystemExit("%s 没有封装" % c["ref"]) + lib, name = c["fp"].split(":", 1) + fp = load_fp(dirs.get(lib) or os.path.join(share, lib + ".pretty"), name) + if fp is None: + raise SystemExit("载入不了封装 %s(%s)" % (c["fp"], c["ref"])) + fp.SetFPID(pcbnew.LIB_ID(lib, name)) # 板上必须是 "库:名",否则以后无法从库更新 + fp.SetReference(c["ref"]) + fp.SetValue(c["val"]) + # 原理图上的附加字段(MPN 等)也写进封装,否则 DRC 原理图对照报 Missing symbol field + for k, v in c.get("fields", {}).items(): + if k not in ("Reference", "Value", "Footprint", "Datasheet") and v: + fp.SetField(k, v) + fd = fp.GetField(k) + if fd is not None: + fd.SetVisible(False) # 新建字段默认可见,会被印成丝印 + # 不贴(DNP)/不进 BOM 跟着原理图走,否则 DRC 报 footprint_symbol_mismatch + fp.SetDNP(c.get("dnp", False)) + fp.SetExcludedFromBOM(c.get("no_bom", False)) + if c["path"]: + try: + fp.SetPath(pcbnew.KIID_PATH(c["path"])) # F8 靠它认同一个器件 + except Exception: + pass + board.Add(fp) + w, h, ox, oy = fp_box(fp) + if x > x0 and x + w > x0 + FIELD_W: + x, y, rowh = x0, y + rowh + GAP, 0.0 + fp.SetPosition(pcbnew.VECTOR2I(mm(x - ox), mm(y - oy))) + placed[c["ref"]] = fp + x += w + GAP + rowh = max(rowh, h) + y += rowh + SHEET_GAP + miss = [] + for name, nodes in nets: + ni = pcbnew.NETINFO_ITEM(board, name) + board.Add(ni) + for ref, pin in nodes: + fp = placed.get(ref) + pads = [p for p in fp.Pads() if p.GetNumber() == pin] if fp else [] + if not pads: # 同号多焊盘(散热盘/安装脚)要全挂上 + miss.append("%s.%s" % (ref, pin)) + for pad in pads: + pad.SetNet(ni) + board.BuildListOfNets() + pcbnew.SaveBoard(pcb, board) + print("写出 %s:封装 %d,网 %d" % (pcb, len(placed), len(nets))) + if miss: + print("!! 连不上的节点 %d 个:%s" % (len(miss), " ".join(miss[:20]))) + return comps, nets, miss + + +def verify(sch, pcb): + """独立验收:重新载入板子,按网表逐焊盘比对(G-0039:别信导入工具的自报)。""" + comps, nets = netlist(sch) + b = pcbnew.LoadBoard(pcb) + fps = {f.GetReference(): f for f in b.GetFootprints()} + bad = [] + want = {c["ref"] for c in comps} + if set(fps) != want: + bad.append("器件不一致:多 %s 少 %s" % (sorted(set(fps) - want), sorted(want - set(fps)))) + for c in comps: + f = fps.get(c["ref"]) + if f and f.GetFPIDAsString() != c["fp"]: + bad.append("%s 封装 %s != %s" % (c["ref"], f.GetFPIDAsString(), c["fp"])) + pinnet = {(r, p): n for n, nodes in nets for r, p in nodes} + npads = 0 + for ref, f in fps.items(): + for pad in f.Pads(): + num = pad.GetNumber() + if not num: + continue # 无编号的机械孔 + npads += 1 + exp = pinnet.get((ref, num)) + got = str(pad.GetNetname()) + if exp is None and got: + bad.append("%s.%s 板上挂了 %s,网表里没有" % (ref, num, got)) + elif exp is not None and got != exp: + bad.append("%s.%s 应为 %s,板上是 %r" % (ref, num, exp, got)) + boardnets = {str(n) for n in b.GetNetsByName().keys() if str(n)} # swig 给的是 wxString + wantnets = {n for n, _ in nets} + if boardnets != wantnets: + bad.append("网名不一致:多 %s 少 %s" % (sorted(boardnets - wantnets)[:10], + sorted(wantnets - boardnets)[:10])) + print("验收:器件 %d/%d,网 %d/%d,有编号焊盘 %d,问题 %d" + % (len(fps), len(want), len(boardnets), len(wantnets), npads, len(bad))) + for x in bad[:30]: + print(" !! " + x) + return bad + + +def main(argv): + sch, pcb = argv[0], argv[1] + layers = int(argv[argv.index("--layers") + 1]) if "--layers" in argv else 2 + _, _, miss = build(sch, pcb, layers) + bad = verify(sch, pcb) + return 1 if (miss or bad) else 0 + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + sys.exit(main(sys.argv[1:])) diff --git a/kicad-flow/scripts/schgen/pcbplace.py b/kicad-flow/scripts/schgen/pcbplace.py new file mode 100644 index 0000000..b2542dd --- /dev/null +++ b/kicad-flow/scripts/schgen/pcbplace.py @@ -0,0 +1,375 @@ +"""PCB 布局辅助:板框 / 按占地中心摆放 / 正反面 / 叠层 / 占地重叠检查 / 出图。 + +**必须用 KiCad 自带的 python 跑**(要 pcbnew)。典型用法(见 DVBS2_dmod 的 carrier/gen/place.py): + + from schgen.pcbplace import Placer + pl = Placer(r"...\\board.kicad_pcb", origin=(100, 100)) # 以下坐标都是板内坐标(mm,左上为 0,0,y 向下) + pl.outline(74, 40, r=1.0) + pl.at("U1", 4.95, 7.0, rot=0) # 封装原点落在 (x, y) + pl.center("C304", 61.5, 12.0, rot=90) # 封装占地(courtyard)中心落在 (x, y) + pl.center("C303", 8.95, 11.6, rot=90, bottom=True) + pl.keepout("J1-shroud", 33.5, 29.5, 58.5, 38.5) # 封装里没画、但实物要占的地方 + pl.allow_overhang("U1") # F 头之类本来就伸出板边的件 + pl.save(); bad = pl.check() # 重叠/出板清单,空 = 干净 + pl.render(png) # 出图目视 + +pcbnew 坑(G-0038):只改位置/翻面不删件,所以直接在现有板上操作是安全的; +SetLayerType / 叠层写不进,叠层在 save() 之后按文本补(set_stackup)。 +""" +import io +import os +import re +import subprocess + +import pcbnew + +from .env import kicad_cli + + +def sys_python(): + """找一个 import 得了 fitz(pymupdf) 的 python:skills_env 的 SYS_PYTHON > 常见安装位置 > PATH。""" + import shutil + from .env import env + cands = [env("SYS_PYTHON"), r"C:\Python314\python.exe", r"C:\Python313\python.exe", + r"C:\Python312\python.exe", env("CONDA_LANGGRAPH_PYTHON"), shutil.which("python")] + for c in cands: + if c and os.path.exists(c) and subprocess.run([c, "-c", "import fitz"], + capture_output=True).returncode == 0: + return c + raise SystemExit("找不到装了 pymupdf 的 python,请在 ~/skills_env.md 配 SYS_PYTHON") + + +def mm(v): + return pcbnew.FromMM(float(v)) + + +# 嘉立创 4 层 1.6mm(JLC7628)——ipots 载板同款 +JLC4 = """ (setup + (stackup + (layer "F.SilkS" (type "Top Silk Screen")) + (layer "F.Paste" (type "Top Solder Paste")) + (layer "F.Mask" (type "Top Solder Mask") (thickness 0.01)) + (layer "F.Cu" (type "copper") (thickness 0.035)) + (layer "dielectric 1" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4) (loss_tangent 0.02)) + (layer "In1.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 2" (type "core") (thickness 1.065) (material "FR4") (epsilon_r 4.6) (loss_tangent 0.02)) + (layer "In2.Cu" (type "copper") (thickness 0.0152)) + (layer "dielectric 3" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4) (loss_tangent 0.02)) + (layer "B.Cu" (type "copper") (thickness 0.035)) + (layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01)) + (layer "B.Paste" (type "Bottom Solder Paste")) + (layer "B.SilkS" (type "Bottom Silk Screen")) + (copper_finish "ENIG") + (dielectric_constraints no) + ) +""" + + +class Placer: + def __init__(self, pcb, origin=(100.0, 100.0), strip_outline=False): + """strip_outline=True:载入前按文本删掉旧板框(Edge.Cuts 上的顶层图元),之后重画。 + 不能载入后用 board.Remove() 删——删任何对象之后本版 swig 类型注册就坏了(G-0038, + 实测删板框线也一样:之后 GetCourtyard() 返回裸 SwigPyObject)。""" + self.pcb = pcb + self.ox, self.oy = origin + if strip_outline: + self._strip_edge_cuts() + self.b = pcbnew.LoadBoard(pcb) + self.fp = {f.GetReference(): f for f in self.b.GetFootprints()} + self.w = self.h = None + self.keepouts = [] # (名字, x0, y0, x1, y1, 面) 板内坐标 + self.overhang = set() + self.placed = set() + + # ---------- 坐标 ---------- + def P(self, x, y): + return pcbnew.VECTOR2I(mm(self.ox + x), mm(self.oy + y)) + + def outline(self, w, h, r=1.0, width=0.1): + """圆角矩形板框(Edge.Cuts)。重复调用会叠加,只在新板上调一次。""" + self.w, self.h = w, h + L = pcbnew.Edge_Cuts + segs = [((r, 0), (w - r, 0)), ((w, r), (w, h - r)), ((w - r, h), (r, h)), ((0, h - r), (0, r))] + for a, c in segs: + s = pcbnew.PCB_SHAPE(self.b) + s.SetShape(pcbnew.SHAPE_T_SEGMENT) + s.SetStart(self.P(*a)); s.SetEnd(self.P(*c)) + s.SetLayer(L); s.SetWidth(mm(width)) + self.b.Add(s) + if r > 0: + # 三点法画圆角:只给圆心+起止点时 KiCad 会按方向取弧,可能绕远路穿过板内 + # (DVBS2_dmod 第一版就这样,DRC 报孔到板边 0mm);中间点放在外侧 45° 处就唯一了 + k = r * (1 - 0.5 ** 0.5) + for s, m, e in (((0, r), (k, k), (r, 0)), ((w - r, 0), (w - k, k), (w, r)), + ((w, h - r), (w - k, h - k), (w - r, h)), ((r, h), (k, h - k), (0, h - r))): + a = pcbnew.PCB_SHAPE(self.b) + a.SetShape(pcbnew.SHAPE_T_ARC) + a.SetArcGeometry(self.P(*s), self.P(*m), self.P(*e)) + a.SetLayer(L); a.SetWidth(mm(width)) + self.b.Add(a) + + def has_outline(self): + return any(d.GetLayer() == pcbnew.Edge_Cuts for d in self.b.GetDrawings()) + + def _strip_edge_cuts(self): + """按文本删掉顶层 Edge.Cuts 图元(gr_line/gr_arc/...),返回删掉的个数。""" + from .kicad_lib import balanced + t = io.open(self.pcb, encoding="utf-8").read() + pat = re.compile(r"\n\t\((gr_line|gr_arc|gr_rect|gr_circle|gr_poly)\b") + out, i, n = [], 0, 0 + for m in pat.finditer(t): + if m.start() < i: + continue + blk = balanced(t, m.start() + 2) + if '(layer "Edge.Cuts")' in blk: + out.append(t[i:m.start()]) + i = m.start() + 2 + len(blk) + n += 1 + out.append(t[i:]) + io.open(self.pcb, "w", encoding="utf-8", newline="\n").write("".join(out)) + return n + + # ---------- 摆放 ---------- + def _side(self, f, bottom): + if f.IsFlipped() != bottom: + try: + f.Flip(f.GetPosition(), pcbnew.FLIP_DIRECTION_TOP_BOTTOM) + except (AttributeError, TypeError): + f.Flip(f.GetPosition(), False) + + def at(self, ref, x, y, rot=0, bottom=False): + """封装原点落在板内坐标 (x, y)。rot 为俯视逆时针角度(底面件也按俯视给)。""" + f = self.fp[ref] + self._side(f, bottom) + f.SetOrientationDegrees(rot) + f.SetPosition(self.P(x, y)) + self.placed.add(ref) + return f + + def box(self, ref): + """封装占地(优先 courtyard,没有就用含焊盘/丝印的外框),板内坐标 (x0, y0, x1, y1)。""" + f = self.fp[ref] + cy = f.GetCourtyard(pcbnew.B_CrtYd if f.IsFlipped() else pcbnew.F_CrtYd) + if cy.OutlineCount(): + bb = cy.BBox() + x0, y0, w, h = bb.GetX(), bb.GetY(), bb.GetWidth(), bb.GetHeight() + else: + bb = f.GetBoundingBox(False, False) + x0, y0, w, h = bb.GetX(), bb.GetY(), bb.GetWidth(), bb.GetHeight() + X0, Y0 = pcbnew.ToMM(x0) - self.ox, pcbnew.ToMM(y0) - self.oy + return (X0, Y0, X0 + pcbnew.ToMM(w), Y0 + pcbnew.ToMM(h)) + + def center(self, ref, x, y, rot=0, bottom=False): + """封装占地中心落在 (x, y)——比按原点摆直观(原点常在 1 脚)。""" + self.at(ref, x, y, rot, bottom) + x0, y0, x1, y1 = self.box(ref) + dx, dy = x - (x0 + x1) / 2.0, y - (y0 + y1) / 2.0 + pos = self.fp[ref].GetPosition() + self.fp[ref].SetPosition(pcbnew.VECTOR2I(pos.x + mm(dx), pos.y + mm(dy))) + return self.box(ref) + + def keepout(self, name, x0, y0, x1, y1, bottom=False): + """登记一块实物占地(封装里没画的护套、螺丝头、线缆出口……)参与重叠检查。""" + self.keepouts.append((name, x0, y0, x1, y1, "B" if bottom else "F")) + + def allow_overhang(self, *refs): + self.overhang.update(refs) + + # ---------- 检查 ---------- + def check(self, gap=0.0, edge=0.3): + """同面占地两两不重叠(留 gap),占地在板框内(离边 edge,allow_overhang 的除外), + 全部封装都摆过。返回问题清单。""" + def hit(p, q, g): + return not (p[2] + g <= q[0] or q[2] + g <= p[0] or p[3] + g <= q[1] or q[3] + g <= p[1]) + + items = [] # (名字, 占地框, 面, 通孔焊盘框列表) + for ref, f in self.fp.items(): + side = "B" if f.IsFlipped() else "F" + th = [] + for p in f.Pads(): + if p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH): + bb = p.GetBoundingBox() + x0, y0 = pcbnew.ToMM(bb.GetX()) - self.ox, pcbnew.ToMM(bb.GetY()) - self.oy + th.append((x0, y0, x0 + pcbnew.ToMM(bb.GetWidth()), y0 + pcbnew.ToMM(bb.GetHeight()))) + items.append((ref, self.box(ref), side, th)) + for k in self.keepouts: + items.append((k[0], k[1:5], k[5], [])) + bad = [] + for i in range(len(items)): + a, ba, sa, ta = items[i] + for j in range(i + 1, len(items)): + c, bc, sc, tc = items[j] + if a.split("-")[0] == c.split("-")[0]: + continue # 器件和它自己登记的禁布区(如 J1-shroud) + if sa == sc: + if hit(ba, bc, gap): + bad.append("重叠 %s x %s" % (a, c)) + continue + # 不同面:只有通孔焊盘/引脚会穿过去,按焊盘比(金属壳 NIM 底下的背面去耦是合法的) + if any(hit(pb, bc, gap) for pb in ta) or any(hit(pb, ba, gap) for pb in tc): + bad.append("跨面 %s x %s(通孔焊盘)" % (a, c)) + if self.w: + for ref, bx, side, _ in items: + if ref in self.overhang: + continue + if bx[0] < edge or bx[1] < edge or bx[2] > self.w - edge or bx[3] > self.h - edge: + bad.append("出板/贴边 %s (%.1f,%.1f)-(%.1f,%.1f)" % ((ref,) + tuple(bx))) + miss = sorted(set(self.fp) - self.placed) + if miss: + bad.append("没摆的件:%s" % " ".join(miss)) + return bad + + # ---------- 丝印 ---------- + def _bb(self, item): + bb = item.GetBoundingBox() + x0, y0 = pcbnew.ToMM(bb.GetX()) - self.ox, pcbnew.ToMM(bb.GetY()) - self.oy + return (x0, y0, x0 + pcbnew.ToMM(bb.GetWidth()), y0 + pcbnew.ToMM(bb.GetHeight())) + + def silk_obstacles(self, side): + """某一面丝印要避开的东西:该面所有器件占地、所有通孔焊盘、该面所有贴片焊盘、 + 各器件画在该面丝印层上的图形(外框线)、登记的禁布区。返回 [(名字, 框)]。""" + silk = pcbnew.B_SilkS if side == "B" else pcbnew.F_SilkS + obs = [] + for ref, f in self.fp.items(): + fs = "B" if f.IsFlipped() else "F" + if fs == side: + obs.append((ref + "/body", self.box(ref))) + for p in f.Pads(): + th = p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH) + if th or fs == side: + obs.append((ref + "/pad", self._bb(p))) + for g in f.GraphicalItems(): + if g.GetLayer() == silk and g.GetClass() not in ("PCB_TEXT", "PCB_FIELD"): + obs.append((ref + "/silk", self._bb(g))) + for k in self.keepouts: + if k[5] == side: + obs.append((k[0], k[1:5])) + return obs + + def place_refs(self, size=0.8, thick=0.15, gap=0.15, edge=0.35, order=None, fixed=None, + max_off=4.0, prefer=None): + """自动摆位号:字高 size / 笔宽 thick(嘉立创丝印下限 0.8/0.15),数值一律移到 Fab 层。 + 每个位号在自己器件四周按"上/下/左/右 × 横/竖"逐步外移找空位,要求不压任何器件占地 + (含自己)、焊盘、丝印图形、已摆的位号、板边。fixed={位号: (x, y, 角度)} 手工指定。 + prefer={位号: (方位, 角度)},方位 u/d/l/r、角度 0/90:优先试这个组合(一排小件想要整齐 + 地竖写在正上方时用)。返回摆不下的位号清单。""" + fixed = fixed or {} + prefer = prefer or {} + placed = {"F": [], "B": []} + obs_cache = {} + fail = [] + refs = order or sorted(self.fp, key=lambda r: (self.box(r)[2] - self.box(r)[0]) * + (self.box(r)[3] - self.box(r)[1])) + for ref in refs: + f = self.fp[ref] + side = "B" if f.IsFlipped() else "F" + v = f.Value() + v.SetLayer(pcbnew.B_Fab if side == "B" else pcbnew.F_Fab) + r = f.Reference() + r.SetVisible(True) + r.SetLayer(pcbnew.B_SilkS if side == "B" else pcbnew.F_SilkS) + r.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size))) + r.SetTextThickness(mm(thick)) + if side not in obs_cache: + obs_cache[side] = self.silk_obstacles(side) + obs = obs_cache[side] + + def free(tb): + if tb[0] < edge or tb[1] < edge or (self.w and tb[2] > self.w - edge) or \ + (self.h and tb[3] > self.h - edge): + return False + g = gap + for _, o in obs: + if not (tb[2] + g <= o[0] or o[2] + g <= tb[0] or tb[3] + g <= o[1] or o[3] + g <= tb[1]): + return False + for o in placed[side]: + if not (tb[2] + g <= o[0] or o[2] + g <= tb[0] or tb[3] + g <= o[1] or o[3] + g <= tb[1]): + return False + return True + + def put(x, y, ang): + r.SetTextAngleDegrees(ang) + r.SetPosition(self.P(x, y)) + return self._bb(r) + + if ref in fixed: + x, y, ang = fixed[ref] + tb = put(x, y, ang) + placed[side].append(tb) + if not free(tb): + fail.append(ref + "(指定位置仍冲突)") + continue + x0, y0, x1, y1 = self.box(ref) + cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0 + done = False + # 候选位置按代价排序:离器件近、横向偏移小的优先(位号要一眼看出属于谁), + # 竖排略贵于横排;横向偏移不超过器件自身半宽/半高 + 1mm + sizes = {} + for ang in (0, 90): + tb0 = put(0, 0, ang) + sizes[ang] = (tb0[2] - tb0[0], tb0[3] - tb0[1]) + hx, hy = (x1 - x0) / 2.0 + 1.0, (y1 - y0) / 2.0 + 1.0 + cands = [] + for ang, (tw, th) in sizes.items(): + d = 0.0 + while d <= max_off + 1e-9: + for k in range(0, 41): + s = (k + 1) // 2 * 0.25 * (1 if k % 2 else -1) + for side_, (x, y), lim in (("u", (cx + s, y0 - gap - d - th / 2), hx), + ("d", (cx + s, y1 + gap + d + th / 2), hx), + ("l", (x0 - gap - d - tw / 2, cy + s), hy), + ("r", (x1 + gap + d + tw / 2, cy + s), hy)): + if abs(s) > lim: + continue + cost = d + 0.8 * abs(s) + (0.4 if ang == 90 else 0.0) + if prefer.get(ref) == (side_, ang): + cost -= 3.0 + cands.append((cost, x, y, ang)) + d += 0.25 + cands.sort(key=lambda c: c[0]) + for _, x, y, a in cands: + tb = put(x, y, a) + dx = x - (tb[0] + tb[2]) / 2.0 # 文字框中心校正到 (x, y) + dy = y - (tb[1] + tb[3]) / 2.0 + tb = put(x + dx, y + dy, a) + if free(tb): + placed[side].append(tb) + done = True + break + if not done: + fail.append(ref) + r.SetVisible(False) + return fail + + # ---------- 存盘 / 叠层 / 出图 ---------- + def layers(self, names=None, n=None): + if n: + self.b.SetCopperLayerCount(n) + for lid, nm in (names or {}).items(): + self.b.SetLayerName(lid, nm) + + def save(self, stackup=None, power_layers=()): + pcbnew.SaveBoard(self.pcb, self.b) + if stackup or power_layers: + t = io.open(self.pcb, encoding="utf-8").read() + head = "\t(setup\n" + if stackup and "(stackup" not in t and head in t: + t = t.replace(head, stackup, 1) + for ln in power_layers: # SetLayerType 写不进,按文本改(G-0038) + t = t.replace('"%s" signal' % ln, '"%s" power' % ln) + io.open(self.pcb, "w", encoding="utf-8", newline="\n").write(t) + + def render(self, png, layers="F.Cu,B.Cu,F.SilkS,B.SilkS,F.Fab,F.CrtYd,B.CrtYd,Edge.Cuts,Dwgs.User", + dpi=200): + """出一张总览 PNG(kicad-cli 出 SVG → headless 浏览器截图太麻烦,直接走 PDF + fitz)。""" + pdf = os.path.splitext(png)[0] + ".pdf" + subprocess.run([kicad_cli(), "pcb", "export", "pdf", "--layers", layers, "--mode-single", + "-o", pdf, self.pcb], capture_output=True) + clip = ((self.ox - 20) / 25.4 * 72, (self.oy - 8) / 25.4 * 72, + (self.ox + (self.w or 100) + 8) / 25.4 * 72, (self.oy + (self.h or 100) + 8) / 25.4 * 72) + code = ("import fitz,sys; d=fitz.open(sys.argv[1]); p=d[0]; r=fitz.Rect(%r,%r,%r,%r); " + "p.get_pixmap(dpi=%d, clip=r & p.rect).save(sys.argv[2])" % (clip + (dpi,))) + # KiCad 自带 python 通常没有 pymupdf,交给装了 fitz 的系统 python 转 PNG。 + # 注意在 KiCad python 里 PATH 上的 "python" 仍是它自己,所以按候选路径找。 + subprocess.run([sys_python(), "-c", code, pdf, png], check=True) + return png diff --git a/kicad-flow/scripts/schgen/root.py b/kicad-flow/scripts/schgen/root.py new file mode 100644 index 0000000..2bf5063 --- /dev/null +++ b/kicad-flow/scripts/schgen/root.py @@ -0,0 +1,117 @@ +"""根图(第 1 页):左栏层次页符号 + 右侧内嵌 drawio 框图 PNG。 + +跨页连接一律用全局标签,层次页不需要 sheet pin。子页的 sheet_uuid 由 uid("sh", 文件名) +推出,与 page.Page 的约定一致,所以根图和子图可以各自单独重生成。 + + from schgen.root import write_root + write_root(prj, [("02_Power.kicad_sch", "02 电源"), ...], png=r"...\\diagram.png", + title="MyBoard", note="一段说明文字") + +插图:KiCad 对没有 pHYs 的 PNG 按 300DPI 解释,scale=1 时宽 = 像素/300*25.4mm(G-0022), +据此反算 scale;(image (at 中心)) 给的是中心不是左上角;base64 每行 76 字符。 +""" +import base64 +import io +import os +import struct + +from .kicad_lib import uid, esc +from .layout import PAPER + +KICAD_BITMAP_DPI = 300.0 + + +def image_block(png, x0, y0, max_w, max_h, key="img"): + raw = io.open(png, "rb").read() + px, py = struct.unpack(">II", raw[16:24]) + nat_w, nat_h = px / KICAD_BITMAP_DPI * 25.4, py / KICAD_BITMAP_DPI * 25.4 + scale = min(max_w / nat_w, max_h / nat_h) + w, h = nat_w * scale, nat_h * scale + cx, cy = x0 + w / 2.0, y0 + h / 2.0 + b64 = base64.b64encode(raw).decode("ascii") + body = "\n\t\t\t".join('"%s"' % b64[i:i + 76] for i in range(0, len(b64), 76)) + blk = ('\t(image\n\t\t(at %.3f %.3f)\n\t\t(scale %.6f)\n\t\t(uuid "%s")\n' + '\t\t(data %s\n\t\t)\n\t)' % (cx, cy, scale, uid("root", key, os.path.basename(png)), body)) # 只用文件名:工程搬目录后重生成结果不变 + return blk, (x0, y0, w, h) + + +def write_root(prj, sheets, png=None, title=None, note="", paper="A2", + col_w=110.0, sheet_h=18.0, gap=10.0, img_note=None): + """sheets: [(文件名, 显示名), ...],按顺序排在左栏(页码 2、3…)。""" + PW, PH = PAPER[paper] + title = title or prj.name + root_u = uid("root", prj.name) + out = ['(kicad_sch (version 20231120) (generator "kicad_flow_schgen") (generator_version "8.0")', + ' (uuid "%s")' % root_u, + ' (paper "%s")' % paper, + ' (title_block (title "%s") (rev "%s") (company "%s"))' + % (esc(title), prj.rev, esc(prj.company)), + ' (lib_symbols)'] + x = 26.0 + y = 24.0 + if note: + out.append(' (text "%s" (at %.1f %.1f 0) (effects (font (size 1.8 1.8)) (justify left top))' + ' (uuid "%s"))' % (esc(note), x, y, uid("root", "note"))) + y += 6.0 + 2.6 * (note.count("\n") + 1) + for i, (fn, name) in enumerate(sheets): + sy = y + 6.0 + i * (sheet_h + gap) + out.append( + ' (sheet (at %.1f %.1f) (size %.1f %.1f) (fields_autoplaced yes)\n' + ' (stroke (width 0.1524) (type solid)) (fill (color 0 0 0 0.0))\n' + ' (uuid "%s")\n' + ' (property "Sheetname" "%s" (at %.1f %.2f 0)\n' + ' (effects (font (size 1.8 1.8)) (justify left bottom)))\n' + ' (property "Sheetfile" "%s" (at %.1f %.2f 0)\n' + ' (effects (font (size 1.4 1.4)) (justify left top)))\n' + ' (instances (project "%s" (path "/%s" (page "%d"))))\n' + ' )' % (x, sy, col_w, sheet_h, uid("sh", fn), esc(name), x, sy - 1.0, + fn, x, sy + sheet_h + 1.0, prj.name, root_u, i + 2)) + info = None + if png: + ix0 = x + col_w + 14.0 + iy0 = 24.0 + blk, info = image_block(png, ix0, iy0, PW - 14.0 - ix0, PH - 40.0 - iy0 - 8.0) + out.append(blk) + if img_note: + out.append(' (text "%s" (at %.1f %.1f 0) (effects (font (size 1.6 1.6)) (justify left top))' + ' (uuid "%s"))' % (esc(img_note), ix0, iy0 + info[3] + 3.0, + uid("root", "imgnote"))) + out.append(' (sheet_instances (path "/" (page "1")))') + out.append(')') + p = os.path.join(prj.out_dir, prj.name + ".kicad_sch") + io.open(p, "w", encoding="utf-8").write("\n".join(out) + "\n") + print("根图 %s (%.0f KB)%s" % (p, os.path.getsize(p) / 1024.0, + " 框图 %.0fx%.0f mm" % (info[2], info[3]) if info else "")) + return p + + +def write_project(prj): + """最小 .kicad_pro(没有它 KiCad 打不开工程);已存在就不动。""" + p = os.path.join(prj.out_dir, prj.name + ".kicad_pro") + if not os.path.exists(p): + io.open(p, "w", encoding="utf-8").write( + '{\n "meta": {"filename": "%s.kicad_pro", "version": 1},\n' + ' "schematic": {"legacy_lib_dir": "", "legacy_lib_list": []},\n' + ' "sheets": [],\n "boards": [],\n "libraries": {"pinned_footprint_libs": [],' + ' "pinned_symbol_libs": []}\n}\n' % prj.name) + return p + + +def write_lib_tables(prj, sym_libs=(), fp_libs=()): + """工程级 sym-lib-table / fp-lib-table,用 ${KIPRJMOD} 相对路径。 + 改完必须**整个退出 KiCad 再重启**才生效(G-0020)。""" + def table(kind, libs): + rows = ['(%s_lib_table' % kind, ' (version 7)'] + for name, rel in libs: + # 绝对路径原样写;ipots 工程里 ${KIPRJMOD} 版 fp-lib-table 曾没被认到(多半是没重启) + uri = rel if os.path.isabs(rel) else "${KIPRJMOD}/" + rel + rows.append(' (lib (name "%s") (type "KiCad") (uri "%s") (options "")' + ' (descr ""))' % (name, uri.replace("\\", "/"))) + rows.append(')') + return "\n".join(rows) + "\n" + if sym_libs: + io.open(os.path.join(prj.out_dir, "sym-lib-table"), "w", encoding="utf-8").write( + table("sym", sym_libs)) + if fp_libs: + io.open(os.path.join(prj.out_dir, "fp-lib-table"), "w", encoding="utf-8").write( + table("fp", fp_libs)) diff --git a/kicad-flow/scripts/schgen/symgen.py b/kicad-flow/scripts/schgen/symgen.py new file mode 100644 index 0000000..620af12 --- /dev/null +++ b/kicad-flow/scripts/schgen/symgen.py @@ -0,0 +1,130 @@ +"""工程符号库:从参考设计的 .kicad_sch 抽符号,或按引脚表程序化生成方框符号。 + + from schgen.symgen import grab_from_sch, box_symbol, write_lib + blocks = [grab_from_sch(r"...\\SMOD\\07_PWR.kicad_sch", "IC_SCH:root_0_TPS7A92_IC_SCH.SchLib", + "TPS7A9201DSK"), + box_symbol("TPS65235", ref="U", value="TPS65235", + left=[("2", "VIN", "power_in"), ...], right=[...], top=[...], bottom=[...])] + write_lib(r"...\\lib\\myboard.kicad_sym", blocks) + +先查参考设计有没有现成符号(抄过来最省事、引脚最不容易错),没有才自己生成。 +""" +import io +import re + +from .kicad_lib import balanced + +PIN_TYPES = {"input", "output", "bidirectional", "tri_state", "passive", "free", + "unspecified", "power_in", "power_out", "open_collector", "open_emitter", + "no_connect"} + + +def rename(block, old, new): + """把顶层符号名和子单元名 old_x_y 一并改掉。""" + block = block.replace('(symbol "%s"' % old, '(symbol "%s"' % new, 1) + for o in (old.split(':')[-1], old): + block = re.sub(r'\(symbol "%s(_\d+_\d+)"' % re.escape(o), + lambda m: '(symbol "%s%s"' % (new, m.group(1)), block) + return block + + +def grab_from_sch(sch_path, lib_id, new_name): + """从 .kicad_sch 的 lib_symbols 段里按 lib_id 抽出符号并改名。""" + t = io.open(sch_path, encoding="utf-8").read() + i = t.find('(symbol "%s"' % lib_id) + if i < 0: + raise KeyError("%s 里没有 %s" % (sch_path, lib_id)) + return rename(balanced(t, i), lib_id, new_name) + + +def pin_count(block): + return len(re.findall(r'\(pin\s+\w+\s+\w+', block)) + + +def box_symbol(name, ref="U", value=None, desc="", left=(), right=(), top=(), bottom=(), + pitch=2.54, pin_len=5.08, min_w=15.24, min_h=10.16, hidden_num=False, + footprint=""): + """方框符号。每边是 [(号, 名[, 类型]), ...],按顺序从上到下 / 从左到右排开; + 号为 None 表示那一格留空(用来把引脚分组)。宽高按引脚名长度与数量自动撑开。""" + value = value or name + + def norm(side): + out = [] + for p in side: + if p is None: + out.append(None) + continue + num, nm = p[0], p[1] + typ = p[2] if len(p) > 2 else "passive" + assert typ in PIN_TYPES, typ + out.append((num, nm, typ)) + return out + + L, Rr, T, B = norm(left), norm(right), norm(top), norm(bottom) + # pitch 可以是一个数,也可以按边分别给:{"left":7.62,"right":5.08,"top":12.7,"bottom":12.7} + pd = pitch if isinstance(pitch, dict) else {} + pl, pr_, pt, pb = (pd.get(k, 2.54) if pd else pitch for k in ("left", "right", "top", "bottom")) + namew = lambda side: max([len(p[1]) for p in side if p] or [0]) * 1.1 + nh = max(len(T), len(B), 0) + h = max(min_h, (len(L) + 1) * pl, (len(Rr) + 1) * pr_) + w = max(min_w, namew(L) + namew(Rr) + 5.08, (len(T) + 1) * pt, (len(B) + 1) * pb, + (max([len(p[1]) for p in T + B if p] or [0]) * 1.1 + 2.54) * 2 if nh else 0) + w = round(w / 2.54 / 2 + 0.49) * 2.54 * 2 # 宽高取 2.54 的偶数倍,引脚落格点 + h = round(h / 2.54 / 2 + 0.49) * 2.54 * 2 + hw, hh = w / 2.0, h / 2.0 + lines = [' (symbol "%s"' % name, + ' (pin_names (offset 0.762))%s' % (' (pin_numbers hide)' if hidden_num else ''), + ' (exclude_from_sim no) (in_bom yes) (on_board yes)', + ' (property "Reference" "%s" (at %.2f %.2f 0)' + ' (effects (font (size 1.27 1.27)) (justify left)))' % (ref, -hw, hh + 1.27), + ' (property "Value" "%s" (at %.2f %.2f 0)' + ' (effects (font (size 1.27 1.27)) (justify left)))' % (value, -hw, -hh - 1.27), + ' (property "Footprint" "%s" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))' + % footprint, + ' (property "Datasheet" "" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))', + ' (property "Description" "%s" (at 0 0 0) (effects (font (size 1.27 1.27)) hide))' + % desc.replace('"', "'"), + ' (symbol "%s_0_1"' % name, + ' (rectangle (start %.2f %.2f) (end %.2f %.2f)' + ' (stroke (width 0.254) (type default)) (fill (type background)))' % (-hw, hh, hw, -hh), + ' )', + ' (symbol "%s_1_1"' % name] + + def pin(num, nm, typ, x, y, ang): + lines.append(' (pin %s line (at %.2f %.2f %d) (length %.2f)' % (typ, x, y, ang, pin_len)) + lines.append(' (name "%s" (effects (font (size 1.27 1.27))))' % nm) + lines.append(' (number "%s" (effects (font (size 1.27 1.27)))))' % num) + + def col(n, pitch): # 竖边:从上往下,居中 + top_y = (n - 1) * pitch / 2.0 + return [round((top_y - k * pitch) / 1.27) * 1.27 for k in range(n)] + + def row(n, pitch): + x0 = -(n - 1) * pitch / 2.0 + return [round((x0 + k * pitch) / 1.27) * 1.27 for k in range(n)] + + for p, y in zip(L, col(len(L), pl)): + if p: + pin(p[0], p[1], p[2], -hw - pin_len, y, 0) + for p, y in zip(Rr, col(len(Rr), pr_)): + if p: + pin(p[0], p[1], p[2], hw + pin_len, y, 180) + for p, x in zip(T, row(len(T), pt)): + if p: + pin(p[0], p[1], p[2], x, hh + pin_len, 270) + for p, x in zip(B, row(len(B), pb)): + if p: + pin(p[0], p[1], p[2], x, -hh - pin_len, 90) + lines += [' )', ' )'] + return "\n".join(lines) + + +def write_lib(path, blocks): + out = ['(kicad_symbol_lib (version 20231120) (generator "kicad_flow_schgen") (generator_version "8.0")'] + out += blocks + out.append(')') + io.open(path, "w", encoding="utf-8").write("\n".join(out) + "\n") + names = [re.match(r'\s*\(symbol "([^"]+)"', b).group(1) for b in blocks] + for b, n in zip(blocks, names): + print(" 符号 %-24s 引脚 %3d" % (n, pin_count(b))) + return path