sync: HP_BOWEI 2026-10-15

This commit is contained in:
Myfro9
2026-10-15 01:59:40 +08:00
parent 771acd4171
commit a0a3445bfd
21 changed files with 3042 additions and 16 deletions
+119
View File
@@ -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/<YYYYMMDD-HHMM>-<短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/<YYYYMMDD-HHMM>-<slug>/`,写 `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。
请先读任务单:<Rnn-Tk-task.md 绝对路径>,按其中的目标、背景和验收标准完成任务。
- 只做任务单要求的事,不扩大范围;遇到任务单没覆盖的关键决策,停下并在报告里写明,而不是自己拍板。
- 不要再派生子 agent。
- 完成(或失败/受阻)后,按任务单末尾给的报告格式,把报告写到:<Rnn-Tk-report.md 绝对路径>
- 报告要写事实:做了什么、改了哪些文件、命令输出/测量值等证据、验收标准逐条是否满足、遗留问题。失败就如实写失败。
- 这份报告是唯一会被交回大脑的东西,除此之外不要在 ./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 的就是未完成的),新起一个大脑(这是接替,不是第二个),让它通读该目录后给出下一轮决策,轮次编号接着往下排。
+62
View File
@@ -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/<run>/`)里的记录。必要时也可以只读地查看工程文件来核实 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 完成后
- 目标: <一句话>
- 背景: <worker 需要知道的上下文——worker 看不到你的思考,也没读过之前的报告,必须写全:相关文件路径、已知事实、前几轮的结论>
- 步骤建议: <可选>
- 禁止事项: <可选,如"不要上电"、"不要改 xxx 文件">
- 验收标准:
1. <可客观验证的条件,以及需要在报告里给出的证据>
- 报告格式: 按 templates 的 worker 报告格式
## 向用户提问
(状态为 ASK_USER 时填写:问题、可选项、你的倾向及理由)
## 总结
(状态为 DONE 时填写:最终结果、关键证据、改动清单、遗留问题与建议)
```
**自主模式**:若调度员转告"用户不允许提问",ASK_USER 就不再可用——所有原本要问用户的事由你裁定。在"分析"里把这些裁定单列为"大脑裁定(原拟问用户)"并给依据;DONE 的总结里汇总全部此类裁定,供用户事后复核。
判断 DONE 的标准:用户在 `00-goal.md` 里的目标已达成,并且有 worker 报告中的证据支撑。没有证据的"完成"不算完成。如果目标本身有歧义或需要用户拍板(如有风险的硬件操作、改变需求),用 ASK_USER,不要猜。
+98
View File
@@ -0,0 +1,98 @@
# claude-brain 记录模板
全部文件只放在 `./Notes/auto/<YYYYMMDD-HHMM>-<slug>/` 下。
## 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 数量及各自模型。
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 核实 claude-brain 运行中每个子 agent 实际使用的模型。
# 原理:Claude Code 把每个 subagent 的转录写在
# ~/.claude/projects/<项目slug>/<会话ID>/subagents/agent-<id>.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
+89
View File
@@ -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 <model> <task-file> <report-path>",供调度员直接派 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])
+51 -11
View File
@@ -4,14 +4,15 @@ description: >
从一份需求 md 出发,驱动 KiCad 原理图的完整设计流程:画 drawio 电路框图、跟用户 从一份需求 md 出发,驱动 KiCad 原理图的完整设计流程:画 drawio 电路框图、跟用户
确认功能划分、整理参考资料、必要时做原理仿真、画 KiCad 原理图、校验 FPGA/MCU 的 确认功能划分、整理参考资料、必要时做原理仿真、画 KiCad 原理图、校验 FPGA/MCU 的
管脚与接口、加封装、回写 drawio、三道独立 agent 复核(一致性/排版/需求符合性), 管脚与接口、加封装、回写 drawio、三道独立 agent 复核(一致性/排版/需求符合性),
最后导入 PCB。驱动 E:\LangGraph_Design\kicad-flow 里的 LangGraph 工作流(27 节点 / 导入 PCB,再按用户布局要求布局、必要时为布线调整原理图连线(过 FPGA/MCU 管脚校验),
8 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每个要动 MCP、 最后完成阻抗线/差分线/等长线布线。驱动 E:\LangGraph_Design\kicad-flow 里的 LangGraph
工作流(41 节点 / 10 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每个要动 MCP、
要派 agent、要人拍板的地方 interrupt 回到 Claude 会话,由本 skill 执行真实动作再 要派 agent、要人拍板的地方 interrupt 回到 Claude 会话,由本 skill 执行真实动作再
回填推进。数据库落在被设计的那个 KiCad 工程下,不在图工程目录下。 回填推进。数据库落在被设计的那个 KiCad 工程下,不在图工程目录下。
只要用户提到 画原理图、设计一块板、KiCad 原理图设计、从需求做电路、电路框图、 只要用户提到 画原理图、设计一块板、KiCad 原理图设计、从需求做电路、电路框图、
kicad-flow 工作流、加封装/封装补齐、原理图排版检查、原理图与框图对不上、 kicad-flow 工作流、加封装/封装补齐、原理图排版检查、原理图与框图对不上、
原理图导入 PCB、或者说 "启动 kicad-flow"、"跑原理图流程"、"接着上次那块板"、 原理图导入 PCB、PCB 布局、差分线/等长线/阻抗线布线、为布线换管脚、或者说 "启动 kicad-flow"、"跑原理图流程"、"接着上次那块板"、
"design a schematic in KiCad"、"start the KiCad schematic flow",就必须用这个 "design a schematic in KiCad"、"start the KiCad schematic flow",就必须用这个
skill —— 哪怕他们没说 skill、没提 LangGraph,只说了 "帮我把这块板的原理图画出来"、 skill —— 哪怕他们没说 skill、没提 LangGraph,只说了 "帮我把这块板的原理图画出来"、
"这板子画到一半,接着弄"。 "这板子画到一半,接着弄"。
@@ -23,8 +24,8 @@ description: >
## 这个 skill 是什么 ## 这个 skill 是什么
一块板从需求到原理图 = 4 个阶段:**框图与资料 → 画原理图并校验平台 → 加封装 → 一块板从需求到关键线布完 = 5 个阶段:**框图与资料 → 画原理图并校验平台 → 加封装 →
回写与三道复核 → 导入 PCB**。这些步骤有严格先后、有失败要退回重做的回环、 回写与三道复核 → 导入 PCB、布局、为布线调整原理图、关键线布线**。这些步骤有严格先后、有失败要退回重做的回环、
单次会话跑不完(用户要在 drawio、KiCad、Vivado 里手动看很多次)。所以状态由 单次会话跑不完(用户要在 drawio、KiCad、Vivado 里手动看很多次)。所以状态由
LangGraph 图持久化在 SQLite 里,你负责把图停下来的每一步翻译成真实动作, LangGraph 图持久化在 SQLite 里,你负责把图停下来的每一步翻译成真实动作,
做完回填结论让它往下走。 做完回填结论让它往下走。
@@ -94,10 +95,12 @@ $PY = (Select-String -Path "$env:USERPROFILE\skills_env.md" `
一次要填多个键就多写几个 `--set`。确实需要嵌套结构(数组/对象)时用 `--json`, 一次要填多个键就多写几个 `--set`。确实需要嵌套结构(数组/对象)时用 `--json`,
两者可混用。 两者可混用。
`start` 可以调 9 个回环的重试上限,超限走 safety_end: `start` 可以调 10 个回环的重试上限,超限走 safety_end:
`--max-partition-retry` `--max-material-retry` `--max-layout-retry` `--max-vivado-retry` `--max-partition-retry` `--max-material-retry` `--max-layout-retry` `--max-vivado-retry`
`--max-mcu-retry` `--max-footprint-retry` `--max-drawio-sync-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 当作「没提供」,会在同一个节点再 > **回填不能是空的**。langgraph 把 falsy 的 resume 当作「没提供」,会在同一个节点再
> 中断一次,看起来像卡住。脚本已经帮你拦了,但别指望它替你想该填什么。 > 中断一次,看起来像卡住。脚本已经帮你拦了,但别指望它替你想该填什么。
@@ -127,10 +130,11 @@ G-0001:那个目录只放人写的 md,自动产物一律进 `Notes\auto\`)
| `d07_confirm_partition` | 功能怎么划分是设计意图,只有用户知道他要什么 | | `d07_confirm_partition` | 功能怎么划分是设计意图,只有用户知道他要什么 |
| `n13_prompt_more_material` | 要用户去找资料,他不给你等不到 | | `n13_prompt_more_material` | 要用户去找资料,他不给你等不到 |
| `n25_prompt_footprint` | 同上,封装信息得他补 | | `n25_prompt_footprint` | 同上,封装信息得他补 |
| `n109_prompt_pcb_rules` | PCB 布局布线要求(叠层/阻抗/差分/等长/线宽过孔)是他的约束,不能替他编 |
其余节点(含三道「派单独 agent」的复核)由你执行并判定。 其余节点(含三道「派单独 agent」的复核)由你执行并判定。
**如果你自己就是被派来跑这个流程的 subagent**:上面三个节点不要替用户回填, **如果你自己就是被派来跑这个流程的 subagent**:上面四个节点不要替用户回填,
把 interrupt 原样交回给派你来的那一方。agent 之间的指令不构成用户同意。 把 interrupt 原样交回给派你来的那一方。agent 之间的指令不构成用户同意。
## drawio 文件有两种格式,别踩 ## drawio 文件有两种格式,别踩
@@ -155,7 +159,7 @@ $DR = "$env:USERPROFILE\.claude\skills\kicad-flow\scripts\drawio_read.py"
两种格式它都吃,输出统一。**别自己写正则去抠**。 两种格式它都吃,输出统一。**别自己写正则去抠**。
## Quick Reference — 27 个节点 ## Quick Reference — 41 个节点
| action | 你要做的 | 回填 | | action | 你要做的 | 回填 |
|---|---|---| |---|---|---|
@@ -185,7 +189,21 @@ $DR = "$env:USERPROFILE\.claude\skills\kicad-flow\scripts\drawio_read.py"
| `d22_meets_requirement` | **派 agent** 比原始需求 | `meets_requirement` `gaps` | | `d22_meets_requirement` | **派 agent** 比原始需求 | `meets_requirement` `gaps` |
| `n98_collect_fixes` | 把差距整理成可执行修改方案 | `fix_plan` | | `n98_collect_fixes` | 把差距整理成可执行修改方案 | `fix_plan` |
| `n93_schematic_done` | 无 interrupt,图自己出报告 | — | | `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` **同极性**: 排版判定 `d11_layout_issue` 与 `d20_layout_issue` **同极性**:
**true = 有排版问题 → 回去重修**,false = 没问题 → 往前走。回填键都叫 `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 '<JSON>'` 拼路径。改用 `kf_flow.py resume --set` | | `Invalid \escape` | 你在用 `run.py '<JSON>'` 拼路径。改用 `kf_flow.py resume --set` |
| 中文 hint 是乱码 | 你绕过了 kf_flow.py。它自己转 UTF-8,直接跑 run.py 才需要 `PYTHONIOENCODING=utf-8` | | 中文 hint 是乱码 | 你绕过了 kf_flow.py。它自己转 UTF-8,直接跑 run.py 才需要 `PYTHONIOENCODING=utf-8` |
| 同一个节点反复中断 | 回填是 falsy(空 dict/`false`/`""`)。至少给一个非空键 | | 同一个节点反复中断 | 回填是 falsy(空 dict/`false`/`""`)。至少给一个非空键 |
| `回填缺少本节点的主键 xxx` | interrupt 声明的第一个键(判定/主结论)没给。**缺判定键时节点会把它当 false、悄悄走另一条边**(d20 曾因此被跳到 d22),所以脚本默认拦下。确实只填部分键才加 `--allow-partial` |
| `Read` drawio 只看到 base64 | SVG 外壳+压缩格式。用 `drawio_read.py` | | `Read` drawio 只看到 base64 | SVG 外壳+压缩格式。用 `drawio_read.py` |
| `拒绝启动:...会落在本工作流设计目录内` | `--prj-dir` 指到 `E:\LangGraph_Design\kicad-flow` 了。要指目标板子的工程目录 | | `拒绝启动:...会落在本工作流设计目录内` | `--prj-dir` 指到 `E:\LangGraph_Design\kicad-flow` 了。要指目标板子的工程目录 |
| `KF_PRJ_DIR 指向的目录不存在` | 先把工程目录建出来 | | `KF_PRJ_DIR 指向的目录不存在` | 先把工程目录建出来 |
| `[SAFETY-END]` | 某回环超上限。先解决报告里指出的卡点,再 `start --restart`,或 `--max-*-retry` 调高 | | `[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 <工程>\<prj>.kicad_sch # 在 scripts 目录下跑:ERC/器件数/封装/子电路/接点
& <KICAD_PYTHON> <工程>\gen\mkfp.py # n23:从参考 PCB 提取封装(要 pcbnew)
& <KICAD_PYTHON> -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`,以它的逐焊盘验收为准。
## 相关 ## 相关
+140 -4
View File
@@ -132,7 +132,9 @@ payload 的 `retry` 和 `sim_results` 告诉你上一轮发生了什么,**把
> 典型应用电路或参考设计保持一致,便于逐项比对、便于照抄取值,也便于后面 `n101` > 典型应用电路或参考设计保持一致,便于逐项比对、便于照抄取值,也便于后面 `n101`
> 导入 PCB 后直接沿用这层就近关系。 > 导入 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 会告诉你这轮为什么回来: 这是三条回环的汇合点,payload 会告诉你这轮为什么回来:
- `fix_plan` 非空 → `d22` 判了不符合需求,**逐条按方案返工** - `fix_plan` 非空 → `d22` 判了不符合需求,**逐条按方案返工**
@@ -207,6 +209,10 @@ STM32 可用 cubemx MCP 的 `get_pin_functions` / `get_pinout` 交叉验证。
2. 按型号或 datasheet 从网上下载并安装 2. 按型号或 datasheet 从网上下载并安装
3. 按型号或 datasheet 自己画(`create_footprint`) 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_footprints` / `list_library_footprints` 找库内现成的,
`search_jlcpcb_parts` 可顺带确认可采购性。 `search_jlcpcb_parts` 可顺带确认可采购性。
@@ -304,9 +310,139 @@ STM32 可用 cubemx MCP 的 `get_pin_functions` / `get_pinout` 交叉验证。
### `n101_import_to_pcb`(101)— 导入 PCB ### `n101_import_to_pcb`(101)— 导入 PCB
`sync_schematic_to_board` 把原理图导入 PCB(器件带封装落板)。导入后确认器件数与 **用 `schgen.pcbimport`,不要用 MCP 的 `sync_schematic_to_board`**(G-0039):后者自报 success,
网表数与原理图一致(`get_component_list`、`get_nets_list`)。 实测 ipots 载板 338/327 封装、191/426 网;DVBS2_dmod 26/60 网(`Net-(...)` 无名网全丢、
多出一个 PWR_FLAG 网、U4 一串焊盘没网)。
PCB 的布局/布线/DRC **不属于本流程**,导入成功即收工。 ```powershell
cd $env:USERPROFILE\.claude\skills\kicad-flow\scripts
& <KICAD_PYTHON> -m schgen.pcbimport <根图.kicad_sch> <工程>\<prj>.kicad_pcb [--layers 4]
```
它从 kicad-cli 网表建一块新板(封装按页排在停车场、挂网),再重新载入**逐焊盘**比对网表,
末行"验收:… 问题 0"才算成功。每次都是重建整块板,布局开始后别再跑。
导入成功后直接进入阶段五(PCB)。
回填:`--set pcb_path="<.kicad_pcb 路径>" --set imported=true` 回填:`--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="<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="<md 路径>" --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="<md 路径>"`
+17 -1
View File
@@ -58,6 +58,9 @@ DEFAULT_GRAPH_ROOT = _env_lookup("KICAD_FLOW_ROOT", r"E:\LangGraph_Design\kicad-
RETRY_LIMITS = { RETRY_LIMITS = {
"partition": 3, "material": 5, "layout": 5, "vivado": 3, "mcu": 3, "partition": 3, "material": 5, "layout": 5, "vivado": 3, "mcu": 3,
"footprint": 5, "drawio-sync": 3, "recheck": 3, "requirement": 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 调高上限。") _out("先解决这个卡点,再用 `start --restart` 重跑,或用 --max-*-retry 调高上限。")
return 0 return 0
_out("[DONE] 图已跑完:原理图设计完成并已导入 PCB。") _out("[DONE] 图已跑完:原理图完成、PCB 已布局、关键线(阻抗/差分/等长)已布完。")
_out(f"[REPORT] {values.get('report_path', '(未生成)')}") _out(f"[REPORT] {values.get('report_path', '(未生成)')}")
_out(f"[PCB] {values.get('pcb_path', '(未回填)')}") _out(f"[PCB] {values.get('pcb_path', '(未回填)')}")
return 0 return 0
@@ -250,6 +253,17 @@ def cmd_resume(args):
snap = graph.get_state(thread) snap = graph.get_state(thread)
if not snap.next: if not snap.next:
_fatal("当前没有挂起的 interrupt,无需回填。先跑 status 看状态。") _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", [])) before = len((snap.values or {}).get("history", []))
_out(f"[RESUME] {json.dumps(payload, ensure_ascii=False)}") _out(f"[RESUME] {json.dumps(payload, ensure_ascii=False)}")
graph.invoke(Command(resume=payload), thread) graph.invoke(Command(resume=payload), thread)
@@ -342,6 +356,8 @@ def main() -> int:
p.add_argument("--set", action="append", metavar="KEY=VALUE", p.add_argument("--set", action="append", metavar="KEY=VALUE",
help="免转义回填;true/false→bool,纯数字→int,[ 或 { 开头→JSON") help="免转义回填;true/false→bool,纯数字→int,[ 或 { 开头→JSON")
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)") p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
p.add_argument("--allow-partial", action="store_true",
help="允许不填 interrupt 声明的主键(默认缺主键直接报错)")
p.set_defaults(func=cmd_resume) p.set_defaults(func=cmd_resume)
p = sub.add_parser("list", help="列出该工程库里的所有线程") p = sub.add_parser("list", help="列出该工程库里的所有线程")
+100
View File
@@ -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/<prj>.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:`<KICAD_PYTHON> gen/mkfp.py`(FpLib 从参考 PCB 按位号取件 + 自画件),
`gen_sch.py` 里 `fp_resolver` 按**数值**查表、按位号覆盖;再跑一次 check 看封装一行
5. n101:`<KICAD_PYTHON> -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)+ 全局额外库 + 工程库
+22
View File
@@ -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
+331
View File
@@ -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:]))
+103
View File
@@ -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 <in.drawio> <out.png> [--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'<mxGeometry\s+([^>]*?)/?>', 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("<svg") or "<svg" in raw[:500]:
m = re.search(r'<svg[^>]*\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 = ('<!DOCTYPE html><html><head><meta charset="utf-8">'
'<style>html,body{margin:0;padding:0;background:#fff}</style></head><body>'
'<div class="mxgraph" style="max-width:100%%" data-mxgraph="%s"></div>'
'<script src="%s"></script></body></html>' % (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)
+52
View File
@@ -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")
+138
View File
@@ -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
+335
View File
@@ -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_<unit>_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
+115
View File
@@ -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()
+384
View File
@@ -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
+210
View File
@@ -0,0 +1,210 @@
"""n101:从原理图网表确定性地建 PCB(封装 + 网络,封装按页排在"停车场"里),再逐焊盘验收。
**必须用 KiCad 自带的 python 跑**(要 pcbnew):
<KICAD_PYTHON> -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:]))
+375
View File
@@ -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
+117
View File
@@ -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))
+130
View File
@@ -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