sync: HP_BOWEI 2026-09-09
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
---
|
||||
name: altium-kicad
|
||||
description: >
|
||||
把 Altium Designer 的 PCB 设计工程整体搬到 KiCad,并验证搬完的板与原设计电气
|
||||
等价、制造等价。驱动 E:\LangGraph_Design\Altium-Kicad 里的 LangGraph 工作流
|
||||
(11 节点 / 4 个判定回环),进程内 + SQLite 持久化,不用 langgraph dev。图在每
|
||||
个需要 MCP、CLI 或人在 GUI 里点的地方 interrupt 回到 Claude 会话,由本 skill
|
||||
执行真实动作再回填结论推进。附带把两次真实迁移里反复重写的脚本通用化:ASCII
|
||||
转换脚本生成、BGA 平面焊盘网络回填、覆铜 zone clearance 批改、逐页网表
|
||||
partition 比对、走线/过孔矢量等价比对。
|
||||
|
||||
只要用户提到 Altium 转 KiCad、AD 转 KiCad、把 PrjPCB 导入 KiCad、迁移/搬移
|
||||
PCB 工程、altium-kicad 工作流、"Header already parsed"、原理图转 ASCII、
|
||||
导入后 DRC 一堆短路、BGA 焊盘丢网络、铺铜避让变大、网表比对、Gerber 比对、
|
||||
或者说 "启动 altium-kicad"、"跑迁移流程"、"继续上次那个迁移"、"convert my
|
||||
Altium project to KiCad"、"import PrjPCB into KiCad"、"migrate PCB from
|
||||
Altium",就必须用这个 skill —— 哪怕他们没说 skill、没提 LangGraph,只说了
|
||||
"把这块板转成 KiCad"、"这板子迁移到一半,接着弄"。
|
||||
---
|
||||
|
||||
# Altium → KiCad 工程迁移
|
||||
|
||||
## 这个 skill 是什么
|
||||
|
||||
一次完整迁移 = 5 个阶段:**桥接 → 转 ASCII 并导入 → 修 PCB → 网表对齐 → 几何对齐 → 验收**。
|
||||
这些步骤有严格的先后依赖、有失败要退回重做的回环、单次会话跑不完(用户要去
|
||||
Altium 和 KiCad 里手动点很多次)。所以状态由 LangGraph 图持久化在 SQLite 里,
|
||||
你负责把图停下来的每一步翻译成真实动作,做完回填结论让它往下走。
|
||||
|
||||
**MCP 边界(必须理解,否则会白折腾)**:eda-agent(altium MCP)和 KiCAD-MCP-Server
|
||||
跑在**你这个 Claude 会话进程里**,不在 LangGraph 的 Python 进程里。所以图不可能
|
||||
自己去 ping Altium、自己去数文件、自己去改板 —— 它只做编排,凡是要碰真实世界的
|
||||
地方一律 `interrupt()` 交回给你。这是设计,不是缺陷。
|
||||
|
||||
## 环境
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 图工程根 | `E:\LangGraph_Design\Altium-Kicad`(可用 `--project-root` 或 `AK_PROJECT_ROOT` 改) |
|
||||
| Python | conda env `langgraph`(`~\skills_env.md` 的 `CONDA_LANGGRAPH_PYTHON`) |
|
||||
| KiCad python | `~\skills_env.md` 的 `KICAD_PYTHON`,本机 `D:\KiCad\10.0\bin\python.exe` |
|
||||
| checkpoint | `<图工程根>\runs\skill_checkpoint.sqlite`,按 thread 隔离 |
|
||||
| thread 命名 | 由 target_dir 目录名推导,如 `E:\Kicad_design\XMOD_V1p0` → `ak-xmod-v1p0` |
|
||||
| 驱动脚本 | `%USERPROFILE%\.claude\skills\altium-kicad\scripts\ak_flow.py` |
|
||||
|
||||
需要的 MCP:**eda-agent**(Altium 侧取数)、**kicad**(可选,KiCad 侧大多走
|
||||
`kicad-cli` 和 pcbnew 更稳)。
|
||||
|
||||
## 驱动命令
|
||||
|
||||
统一用 `conda run -n langgraph python` 跑 `ak_flow.py`。**退出码 2 = 停在
|
||||
interrupt 等你回填**(不是错误),0 = 图跑完,1 = 出错。
|
||||
|
||||
```powershell
|
||||
$AK = "$env:USERPROFILE\.claude\skills\altium-kicad\scripts\ak_flow.py"
|
||||
|
||||
# 新建 / 续跑(同一个 target-dir 重复跑就是续跑,不会重置)
|
||||
conda run -n langgraph python $AK start `
|
||||
--source-prj "E:\Altium_design\FOO\FOO.PrjPCB" `
|
||||
--target-dir "E:\Kicad_design\FOO"
|
||||
|
||||
# 看现在停在哪
|
||||
conda run -n langgraph python $AK status --target-dir "E:\Kicad_design\FOO"
|
||||
|
||||
# 回填结论、推进(--set 免转义,true/false 转 bool,纯数字转 int)
|
||||
conda run -n langgraph python $AK resume --target-dir "E:\Kicad_design\FOO" `
|
||||
--set bridge_ok=true
|
||||
|
||||
# 列出所有迁移线程 / 清掉某个线程重来
|
||||
conda run -n langgraph python $AK list
|
||||
conda run -n langgraph python $AK reset --target-dir "E:\Kicad_design\FOO"
|
||||
```
|
||||
|
||||
`--set` 的值含空格要整体加引号:`--set "report_path=E:\My Board\report.md"`。
|
||||
`start` 还可以调回环的重试上限(默认 桥接/导入 3 次、网表/几何 5 次,超限走 safety_end):
|
||||
`--max-bridge-retry N` `--max-import-retry N` `--max-netlist-retry N` `--max-gerber-retry N`。
|
||||
|
||||
`[INTERRUPT]` 后面的 JSON 里 `action` 字段告诉你该做哪一步,下面逐个说。
|
||||
|
||||
> **回填不能是空的**。langgraph 把 falsy 的 resume 当作「没提供」,会在同一个
|
||||
> 节点再中断一次,看起来像卡住。至少给一个键。
|
||||
|
||||
---
|
||||
|
||||
## 逐步执行
|
||||
|
||||
开始之前先问清楚两件事(没有默认值,猜错了会把工程写到别处):
|
||||
**源 `.PrjPCB` 的绝对路径**、**目标 KiCad 目录的绝对路径**。
|
||||
|
||||
### `prompt_altium_mcp` — 让用户开启 Altium 桥接
|
||||
|
||||
告诉用户在 Altium 里:`DXP/Preferences → Scripting System → Global Projects →
|
||||
Install` 选 `Altium_API.PrjScr`,然后 `File → Run Script → Dispatcher.pas →
|
||||
StartMCPServer → Run`,状态窗显示 `MCP: idle` 即通。
|
||||
|
||||
提醒两条脾气:桥接**约 10 分钟空闲自停**,**一次只能跑一个脚本**。
|
||||
|
||||
用户确认后:`--set acknowledged=true`
|
||||
|
||||
### `check_altium_bridge` — 验证桥接
|
||||
|
||||
调 eda-agent 的 `app_ping`。通了 `--set bridge_ok=true`,不通就 `false`
|
||||
(图会回到上一步让用户重开,最多 3 次)。
|
||||
|
||||
### `convert_ascii` — 转 ASCII(顺带做基线快照)
|
||||
|
||||
**先做基线快照,趁桥接还活着。** 后面 `check_file_count` 要页数、`rebuild_rules`
|
||||
要设计规则、网表比对要真值网表,这些全都只能从 Altium 侧取;而转 ASCII 的第一步
|
||||
就是停掉 eda-agent,停了再想取就得重开桥接。所以顺序是:
|
||||
|
||||
1. 用 eda-agent 落盘基线到 `<target_dir>\_migration\altium_baseline\`:
|
||||
`proj_get_stats` `proj_get_nets` `proj_get_bom` `pcb_get_board_statistics`
|
||||
`pcb_get_layer_stackup` `pcb_get_design_rules`(存成 `pcb_design_rules.json`,
|
||||
后面查 InPolygon clearance 要用)
|
||||
2. `proj_export_netlist`(**tabular**)导到 `_migration\reference\netlist_tabular.csv`
|
||||
—— 这是后面回填焊盘网络和比网表的**唯一真值**,别漏
|
||||
3. 记下原理图页数和 PCB 数(`check_file_count` 要用)
|
||||
|
||||
然后转 ASCII:
|
||||
|
||||
```powershell
|
||||
python "$env:USERPROFILE\.claude\skills\altium-kicad\scripts\stage_ascii.py" stage `
|
||||
--source-prj "E:\Altium_design\FOO\FOO.PrjPCB" `
|
||||
--migration-dir "E:\Kicad_design\FOO\_migration"
|
||||
```
|
||||
|
||||
它把整个工程复制到 `<工程目录>_ascii` staging(**原始工程一个字节都不动**),
|
||||
枚举出实际的 `.SchDoc` 并生成 `convert_to_ascii.pas`。
|
||||
|
||||
接着让用户:**先停掉 eda-agent**(不停会和 StartMCPServer 脚本冲突挂起),再在
|
||||
Altium 里 `File → Run Script` → 选生成的 `.pas` → `ConvertSchToAscii` → Run。
|
||||
|
||||
跑完验证:
|
||||
|
||||
```powershell
|
||||
python ...\stage_ascii.py verify --staging-dir "E:\Altium_design\FOO_ascii"
|
||||
```
|
||||
|
||||
**verify 通过了再回填**:`--set ascii_ok=true --set ascii_staging_dir=E:\Altium_design\FOO_ascii`
|
||||
|
||||
> ⚠️ `convert_ascii` 后面没有判定回环,回填 `ascii_ok=false` 图照样往下走。
|
||||
> 没转干净就进 KiCad 导入必然报 `Header already parsed`,白跑一轮。
|
||||
|
||||
### `prompt_kicad_import` — 用户在 KiCad 里手动导入
|
||||
|
||||
这一步**没有任何 CLI/MCP 通道**,只能人点。给用户精确路径:
|
||||
|
||||
打开 **KiCad 主程序(工程管理器)** → `File → Import Non-KiCad Project →
|
||||
Altium Project...`
|
||||
- 源:staging 里的 **`_ascii` 版** `.PrjPCB`(选错成原始工程就前功尽弃)
|
||||
- 目标:target_dir
|
||||
- 点 OK 等待,十几页 + 多层 PCB 要几十秒到几分钟
|
||||
|
||||
导完常见现象:报告里只有 `IEEE_SYMBOL` 未处理记录(无害);结构是扁平的,
|
||||
可能多出重复的 `.kicad_sch`(确认无引用后删掉)。
|
||||
|
||||
用户确认导完:`--set imported=true`
|
||||
|
||||
### `check_file_count` — 文件数核对
|
||||
|
||||
Altium 侧基线(原理图页数 + 1 块 PCB)对比 target_dir 下
|
||||
`*.kicad_sch` + `*.kicad_pcb` 的计数。payload 里的 `kicad_glob_hint` 是图那侧
|
||||
glob 出来的预填值(`-1` 表示它数不到),你要自己核实。
|
||||
|
||||
`--set altium_count=16 --set kicad_count=16`
|
||||
|
||||
不一致会退回重导(最多 3 次)。不一致时先看是不是多了重复页,或者某页导入失败。
|
||||
|
||||
### `rebuild_rules` — 设计规则重建 + PCB 修复
|
||||
|
||||
三件事,顺序不能反:
|
||||
|
||||
**1) 改 `.kicad_pro` 的规则**:按基线 `pcb_design_rules.json` 设间隙/线宽/过孔/
|
||||
孔/网络类。导入只搬几何,规则用的是 KiCad 默认(比 Altium 严),不改的话 DRC
|
||||
会有几千条误报。
|
||||
|
||||
**2) 回填焊盘网络 + 批改 zone clearance**(先干跑看量):
|
||||
|
||||
```powershell
|
||||
$KP = "D:\KiCad\10.0\bin\python.exe" # ~\skills_env.md 的 KICAD_PYTHON
|
||||
& $KP "...\scripts\fix_pcb.py" --board "E:\Kicad_design\FOO\FOO.kicad_pcb" `
|
||||
--netlist-csv "E:\Kicad_design\FOO\_migration\reference\netlist_tabular.csv" `
|
||||
--zone-clearance-mm 0.254 --dry-run
|
||||
```
|
||||
|
||||
`--zone-clearance-mm` 要去基线 `pcb_design_rules.json` 查 **InPolygon 规则**的
|
||||
polygon clearance,别照抄 0.254。确认后去掉 `--dry-run` 真改(会自动备份板文件)。
|
||||
|
||||
**3) 重填铺铜**:让用户在 KiCad GUI 里按 `B`,或走 IPC 后端。
|
||||
**大板不要用 SWIG/pcbnew refill**,会超时甚至段错误。
|
||||
|
||||
跑一次 DRC 确认数字降下来了(`run_drc` 或 `kicad-cli pcb drc`),
|
||||
然后 `--set rules_ok=true`。
|
||||
|
||||
> 这两个坑的根因写在 `references/playbook.md` §4,值得先读:
|
||||
> **BGA 平面焊盘丢网络**(假短路的真凶,Altium 靠负片内电层隐式连接)、
|
||||
> **zone 本地 clearance 被写成 0.5mm 且会盖过网络类**(只改网络类完全无效)。
|
||||
> `rebuild_rules` 后面同样没有回环,别在没修好的时候回填 true。
|
||||
|
||||
### `compare_netlist` — 网表对齐(可回环 5 次)
|
||||
|
||||
```powershell
|
||||
conda run -n langgraph python "...\scripts\netcmp.py" `
|
||||
--kicad-dir "E:\Kicad_design\FOO" `
|
||||
--netlist-csv "E:\Kicad_design\FOO\_migration\reference\netlist_tabular.csv" `
|
||||
--out "E:\Kicad_design\FOO\_migration\netcmp"
|
||||
```
|
||||
|
||||
脚本会**逐页**导出 kicadxml(扁平结构下整体导出会漏页)、剥掉 KiCad 网名的前导
|
||||
`/`、去掉 Altium 的 `net=?` 重复行,然后按 **net partition** 比等价类 —— 不比
|
||||
网络名,两个工具命名规则不同,逐名比必然一片红。
|
||||
|
||||
一致(退出码 0)→ `--set netlist_identical=true`。
|
||||
不一致 → 看 `diff.json` 改 KiCad 对齐,`--set netlist_identical=false` 会回到这一步
|
||||
再来一轮。改不动的时候,节点级差异往往不是真错,先按 playbook §5 的三条归一化
|
||||
自查。超过 5 轮会走 safety_end。
|
||||
|
||||
> payload 里写的是「调 `/goal` 让 Claude Code 或 Codex 自主迭代」。愿意的话可以
|
||||
> 那样做;直接用 `netcmp.py` + 自己改板通常更快也更可控。
|
||||
|
||||
### `compare_gerber` — 几何等价(可回环 5 次)
|
||||
|
||||
判定基准先说死:**逐层制造几何等价**。离散铜(走线/过孔/焊盘/孔/板框)要零差异;
|
||||
铺铜纹理、丝印字形、负片内电层是**工具固有容差项,不是缺陷、修不掉**。
|
||||
|
||||
决定性证据走矢量比对,不是栅格 XOR:
|
||||
|
||||
```powershell
|
||||
& $KP "...\scripts\dump_kicad_vec.py" --board "...\FOO.kicad_pcb" `
|
||||
--out "...\_migration\gerber\kicad_vec"
|
||||
# Altium 侧用 eda-agent 导同名 JSON 到 altium_vec\(格式见 references\vector-compare.md)
|
||||
python "...\scripts\cmp_vec.py" --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec `
|
||||
--layer-map ...\layer_map.json --report ...\vec_result.json
|
||||
```
|
||||
|
||||
`TOTAL unmatched primitives: 0` → `--set gerber_aligned=true`。
|
||||
|
||||
要出 Gerber 图片证据的话:KiCad 侧 `kicad-cli pcb export gerbers/drill`;
|
||||
**Altium 侧 eda-agent 生不了 Gerber**(工程没有 OutJob,`proj_run_output` 是空操作),
|
||||
必须让用户在 `File → Fabrication Outputs → Gerber Files` 手动导,参数见
|
||||
playbook §7。
|
||||
|
||||
### `acceptance_report` — 验收报告
|
||||
|
||||
写 `<migration_dir>\verification_report.md`,必须包含:
|
||||
|
||||
1. 各阶段 PASS/FAIL 与关键数字(页数、元件数、节点数、走线/过孔匹配数)
|
||||
2. **明确列出三项工具固有差异**(负片内电层 / 铺铜填充纹理 / 丝印字体)
|
||||
及其根因,注明是容差项不是缺陷 —— 不写清楚,后面看报告的人会当成没做完
|
||||
3. 证据文件索引
|
||||
|
||||
`--set report_path=E:\Kicad_design\FOO\_migration\verification_report.md`
|
||||
|
||||
回填后图结束,`ak_flow.py` 打印 `[DONE]`。
|
||||
|
||||
---
|
||||
|
||||
## 铁律速查
|
||||
|
||||
1. **原理图必须先转 ASCII**。AD26 二进制 `.SchDoc` 会让 KiCad 报
|
||||
`Header already parsed`。PCB 侧没这问题,只有原理图。
|
||||
2. **转 ASCII 之前先停 eda-agent**。Altium 一次只能跑一个脚本,桥接自己就是个脚本。
|
||||
3. **基线快照要趁桥接活着做完**,尤其 tabular 网表 —— 后面回填网络和比网表都靠它。
|
||||
4. **KiCad 导入只能人工点**,源必须选 `_ascii` 版。
|
||||
5. **`kicad-cli pcb import` 不能替换主板**,那会切断原理图↔PCB 关联,只能当参考。
|
||||
6. **zone 的本地 clearance 会盖过网络类**,只改 `.kicad_pro` 无效。
|
||||
7. **网表比 partition 不比网络名**,且必须逐页导 kicadxml。
|
||||
8. **跨工具像素级一致不可能**,判定基准是离散几何等价 + 三项固有容差。
|
||||
9. **原始 Altium 工程只读**,所有写操作只作用于 staging 副本和 target_dir。
|
||||
|
||||
## 排错
|
||||
|
||||
| 症状 | 处理 |
|
||||
|---|---|
|
||||
| resume 后又停在同一个节点 | 回填是空的或全 falsy。langgraph 把它当没提供。加一个非空键。 |
|
||||
| `app_ping` 突然不通 | 桥接空闲 10 分钟自停了。让用户重跑 `StartMCPServer`。 |
|
||||
| Altium 脚本跑起来卡死 | eda-agent 没停,两个脚本冲突。停掉再跑。 |
|
||||
| 导入报 `Header already parsed` | ASCII 没转干净,或者源选成了原始工程。`stage_ascii.py verify` 查。 |
|
||||
| 导入后 DRC 几千条 | 正常。先按基线改 `.kicad_pro` 规则,再回填焊盘网络、重填铺铜。 |
|
||||
| 一堆 "GND vs 空网络" 短路 | BGA 平面焊盘丢网络。`fix_pcb.py` 回填。playbook §4.1。 |
|
||||
| 改了网络类但铺铜避让没变 | zone 本地 clearance 盖过了。`fix_pcb.py --zone-clearance-mm`。playbook §4.2。 |
|
||||
| refill 超时/段错误 | 大板别用 SWIG。GUI 里按 `B`,或 IPC 后端。 |
|
||||
| 网表比对差一堆节点 | 多半是某页 kicadxml 没导出成功,看 `netcmp.py` 的 `[export] N/M`。 |
|
||||
| 矢量比对某层全 unmatched | `layer_map.json` 配错了。不带 `--layer-map` 跑一次看两侧每层走线数。 |
|
||||
| `[SAFETY-END]` | 某个回环超了重试上限,图优雅退出了。修好卡住的那步再 `start --restart` 重跑;确实需要更多轮次就加 `--max-netlist-retry 8` 这类参数。 |
|
||||
| kicad-cli 输出 `UnicodeDecodeError` | 别用 `text=True` 收 kicad-cli 的输出,中文 Windows 会按 GBK 解 UTF-8。`netcmp.py` 已处理。 |
|
||||
| `ModuleNotFoundError: langgraph` | `conda run -n langgraph pip install langgraph langgraph-checkpoint-sqlite` |
|
||||
| `ImportError: pcbnew` | `fix_pcb.py` / `dump_kicad_vec.py` 必须用 KiCad 自带 python 跑,不是 conda 的。 |
|
||||
|
||||
## 深入
|
||||
|
||||
- `references/playbook.md` — 每个坑的根因、XMOD/GQ 两次迁移的实测数字、
|
||||
各阶段该有的判定基准和证据目录约定。卡住了先读这个。
|
||||
- `references/vector-compare.md` — `altium_vec/` 各 JSON 的字段格式、
|
||||
`layer_map.json` 怎么定、结果怎么读。
|
||||
- 图的拓扑与 interrupt 契约:`E:\LangGraph_Design\Altium-Kicad\Notes\Task04.md`
|
||||
@@ -0,0 +1,227 @@
|
||||
# Altium → KiCad 迁移 Playbook
|
||||
|
||||
两次真实迁移(XMOD_V1.0:10层/1191元件/15页;GQ_EXCT7G_V1:ADRV9009 板)踩出来的东西。
|
||||
SKILL.md 只写「怎么走」,这里写「为什么」和「踩了会怎样」。遇到卡壳先回来查这里。
|
||||
|
||||
---
|
||||
|
||||
## 1. 为什么必须先转 ASCII
|
||||
|
||||
KiCad 10.0.4 的 Altium **原理图**解析器不支持 AD26 的二进制 `.SchDoc`。直接
|
||||
`File → Import Non-KiCad Project → Altium Project` 会报:
|
||||
|
||||
```
|
||||
Header already parsed
|
||||
```
|
||||
|
||||
PCB 解析器是好的,只有原理图这一侧不行。二进制 `.SchDoc` 是 CFB 复合文档
|
||||
(文件头 `D0 CF 11 E0`),ASCII 版则以 `|HEADER=Protel for Windows` 开头。
|
||||
|
||||
Altium 侧的出路是 `IServerDocument.DoFileSave('ASCII')`,没有 CLI,只能跑
|
||||
DelphiScript。两个坑:
|
||||
|
||||
- **不要经 MCP 驱动这个脚本**。`obj_run_process(ScriptingSystem:RunScript)` 会与
|
||||
桥接自己的 `StartMCPServer` 脚本冲突挂起 —— Altium 一次只能跑一个脚本。
|
||||
正确姿势:**先停掉 eda-agent**,再在 Altium GUI 里 `File → Run Script` 手动跑。
|
||||
- **DelphiScript 的 `FindFirst` 枚举不可靠**,脚本里的文件名必须是硬编码列表。
|
||||
`stage_ascii.py stage` 就是干这个的:用 Python 枚举出实际的 `.SchDoc`,把名字
|
||||
灌进生成的 `.pas` 里。
|
||||
|
||||
转换只对 staging 副本做,原始工程一个字节都不能动。
|
||||
|
||||
---
|
||||
|
||||
## 2. eda-agent(altium MCP)桥接的脾气
|
||||
|
||||
桥接不是常驻服务,是 Altium GUI 里跑的一个轮询 DelphiScript:
|
||||
|
||||
- **约 10 分钟空闲会自己停**。跑长任务中途回来发现 `app_ping` 不通是常态,
|
||||
重跑 `Dispatcher.pas → StartMCPServer` 即可,图里 `check_altium_bridge` 的
|
||||
回环就是为这个留的。
|
||||
- **一次只能跑一个脚本**,所以任何别的 DelphiScript 都要先停桥接。
|
||||
- 开启路径:`DXP/Preferences → Scripting System → Global Projects → Install`
|
||||
选 `Altium_API.PrjScr`,然后 `File → Run Script → Dispatcher.pas →
|
||||
StartMCPServer → Run`,状态窗出现 `MCP: idle` 就是通了。
|
||||
- **eda-agent 生不了 Gerber**。`proj_run_output(gerber)` 在没有 OutJob 的工程上
|
||||
是空操作。Altium 侧的 Gerber 只能让用户在 GUI 里
|
||||
`File → Fabrication Outputs → Gerber Files` 手动导。
|
||||
|
||||
---
|
||||
|
||||
## 3. KiCad 导入这一步没有自动化通道
|
||||
|
||||
整工程导入必须在 **KiCad 主程序(工程管理器)** 里点:
|
||||
`File → Import Non-KiCad Project → Altium Project...`
|
||||
源选 **staging 里的 `_ascii` 版 `.PrjPCB`**,目标选 target_dir。
|
||||
|
||||
`kicad-cli pcb import --format altium` 只能转 PCB 单板,**不要拿它替换主板** ——
|
||||
那样会切断原理图↔PCB 的关联。它有用的地方是另转一份到 `_migration/cli_pcb/`
|
||||
当几何参考/对照。
|
||||
|
||||
导入完成后常见的两个结构问题:
|
||||
|
||||
- **扁平层次**:导入器不生成 sheet 符号,所有页平铺;还可能把某一页当成根图。
|
||||
Save All 时可能生成重复的 `.kicad_sch`(例如 `12.XC7Z100_HP.kicad_sch` 多一份)。
|
||||
确认无引用后删掉重复文件即可,页数要能对回基线。
|
||||
- **`IEEE_SYMBOL` 未处理记录**:IEEE 装饰符号被跳过,无害,不用管。
|
||||
|
||||
文件数核对就是拿「Altium 原理图页数 + 1 块 PCB」对
|
||||
`target_dir` 下的 `*.kicad_sch` + `*.kicad_pcb` 计数。
|
||||
|
||||
---
|
||||
|
||||
## 4. 导入后 PCB 的两个必修项
|
||||
|
||||
### 4.1 BGA / 平面焊盘丢网络(假短路的真凶)
|
||||
|
||||
Altium 用**负片内电层**隐式连接电源和地,焊盘本身不带网络信息。KiCad 没有这套
|
||||
机制,导入时这些焊盘就空着网络。XMOD 实测 **527 个 BGA 电源/地焊盘丢网络**,
|
||||
DRC 里表现为 103 个「GND vs 空网络」短路。
|
||||
|
||||
修法:拿阶段 0 导出的 Altium 网表真值(`netlist_tabular.csv` 的
|
||||
component/pin/net)程序化回填。`fix_pcb.py` 就是干这个的,先 `--dry-run` 看量。
|
||||
|
||||
回填后还会剩一批查不到真值的空网络焊盘 —— 多是 RJ45 屏蔽壳、机械/安装孔焊盘,
|
||||
按实际情况单独指派 GND(XMOD 是 18 个)。
|
||||
|
||||
### 4.2 覆铜 zone 的本地 clearance(异 net 避让偏大的真凶)
|
||||
|
||||
这是最容易白费力气的一个坑。Altium 导入器把**每一块 zone 的本地 Clearance
|
||||
写成 KiCad 默认的 0.5mm**,落在 `.kicad_pcb` 里长这样:
|
||||
|
||||
```
|
||||
(zone ... (connect_pads (clearance 0.5)) ...)
|
||||
```
|
||||
|
||||
而 **zone 的本地值会盖过网络类 clearance**。所以只改 `.kicad_pro` 的网络类
|
||||
(比如 0.2mm)或板级规则,铺铜避让一点都不会变 —— 必须逐块 zone 改。
|
||||
|
||||
目标值取 Altium 的 **polygon clearance**(InPolygon 规则,去基线
|
||||
`pcb_design_rules.json` 查;XMOD 和 GQ 实测都是 **0.254mm**,但别照抄)。
|
||||
|
||||
**热焊盘不用动**:KiCad 的 `thermal_gap` 0.1651mm 已经匹配 Altium
|
||||
`PolygonConnect` 的 AirGap 0.165mm。
|
||||
|
||||
改完必须重填铺铜。大板上 **SWIG/pcbnew 的 refill 容易超时甚至段错误**,
|
||||
去 KiCad GUI 里按 `B`,或者走 IPC 后端。
|
||||
|
||||
### 4.3 DRC 数字的正常轨迹
|
||||
|
||||
导入只搬几何,规则用的是 KiCad 默认(比 Altium 严),所以初始 DRC 会很吓人。
|
||||
XMOD 的轨迹供参考:按基线改完 `.kicad_pro` 规则 **2878 → 1481**(错误
|
||||
1810→414,track/via/drill 三类规则误报各 199 清零);再回填 527 个焊盘网络 +
|
||||
重填铺铜后 clearance 282→0、via_dangling 199→15、shorting 103→92。
|
||||
残留的 92 是板角铺铜畸变和连接器,不是真短路。
|
||||
|
||||
---
|
||||
|
||||
## 5. 网表比对:比 partition,不比网络名
|
||||
|
||||
两个工具的命名规则不同,逐名比对必然一片红。要比的是**等价类**:把网表归约成
|
||||
「每个引脚 → 它所在网络的全部节点集合」,两侧集合相等即电气一致。
|
||||
|
||||
三个归一化,少一个就出假差异:
|
||||
|
||||
1. **KiCad 网名有前导 `/`**(层次路径),Altium 没有 → 剥掉。
|
||||
XMOD 初比出的 16 个差异全是 `R_I-` vs `/R_I-` 这种。
|
||||
2. **Altium tabular 导出有 `net=?` 的重复行**(隐藏的电源/平面引脚被重复列出)。
|
||||
XMOD 原始 9511 行里有 4500 行是 `?`,去重后才是 5011 个真实节点。
|
||||
3. **KiCad 扁平结构下整体导出网表会漏页** —— XMOD 整体导出只拿到根图的 98 个
|
||||
节点。必须**逐页** `kicad-cli sch export netlist --format kicadxml` 再按网名合并
|
||||
(5011 节点 / 1186 元件 / 1477 网)。格式必须是 **kicadxml**;PADS 格式会漏掉
|
||||
单节点网(只得 4154)。
|
||||
|
||||
XMOD 终判:5011=5011 节点全同、0 单边节点、等价类集合完全相等(1473=1473)、
|
||||
0 实质差异 → **网表 100% 一致**。
|
||||
|
||||
`netcmp.py` 把上面全部实现了,包括逐页导出。
|
||||
|
||||
---
|
||||
|
||||
## 6. Gerber / 几何比对:什么叫「100% 一致」
|
||||
|
||||
**跨工具 byte/像素级一致不可能**,这不是没做好,是物理上做不到:aperture、
|
||||
坐标格式、填充算法、字体引擎、正负片全都不同。所以判定基准必须先界定清楚:
|
||||
|
||||
**判定 = 逐层制造几何等价。** 离散铜(走线/焊盘/过孔/孔/板框)要零差异;
|
||||
铺铜填充纹理、丝印字形、负片内电层属于**工具固有容差项**。
|
||||
|
||||
### 6.1 决定性证据是矢量比对,不是栅格 XOR
|
||||
|
||||
栅格 XOR(pygerber 渲染后逐像素)只能给「像什么程度」的百分比,说服力有限。
|
||||
矢量比对是逐条逐个匹配走线和过孔,对上就是同一块板。
|
||||
|
||||
XMOD 的矢量结论:**走线 12144=12144、过孔 4808=4808 逐条逐个相同,
|
||||
`TOTAL_DISCRETE_DIFF=0`**。
|
||||
|
||||
坐标变换只有 2 个自由参数(平移),由两侧过孔质心解出;用 2 个参数去对上
|
||||
几千个图元,对上了就是实证而不是曲线拟合。Altium 与 KiCad 之间是**纯平移,
|
||||
无翻转无镜像**(XMOD 实测 `KiCad = Altium + (38.501, −180.004)`)。
|
||||
注意 Altium 的 y 轴向上、KiCad 向下,变换里 y 要取负。
|
||||
|
||||
容差取 **30um**:eda-agent 读出的 Altium 坐标是整数 mil,读数精度本身
|
||||
±12.7um,两端各一次就接近 25um;KiCad 侧是精确的。
|
||||
|
||||
`dump_kicad_vec.py` + `cmp_vec.py` 实现这一套,Altium 侧的 JSON 格式见
|
||||
`vector-compare.md`。
|
||||
|
||||
### 6.2 栅格比对的参考数值(XMOD 逐层)
|
||||
|
||||
| 层 | 差异 | 判定 |
|
||||
|----|------|------|
|
||||
| 焊盘 Paste | 0.035% | 完全一致(也用来验证平移变换的精度) |
|
||||
| 板框 Profile | 0.34% | 一致 |
|
||||
| 外层铜 F_Cu | 1.04% | 基本一致 |
|
||||
| 阻焊 Mask | ~2% | 扩展量微差 |
|
||||
| 丝印 Silk | 42–70% | 字体引擎不同,固有 |
|
||||
| 内电层 GND/POWER ×5 | 反相后 ~12% | Altium 负片 vs KiCad 正片 |
|
||||
| 内层信号 ×3 | 高 | 铺铜覆盖/填充差异 |
|
||||
|
||||
看到丝印 70% 不要慌,那是字体,不是错。负片内电层必须**先反相**才能比。
|
||||
|
||||
### 6.3 三个不可消除的工具固有差异(已查明根因,不是缺陷)
|
||||
|
||||
1. **负片内电层**:KiCad 架构里没有「内电层」这种层类型,也没有负片输出能力。
|
||||
与 Altium 的负片格式无法一致,但**物理铜等价**——制板厂显影结果相同。
|
||||
2. **铺铜填充纹理**:填充算法不同(热焊盘辐条/网格/反焊盘),即使轮廓和网络
|
||||
完全相同也不可能逐像素一致。
|
||||
3. **丝印字体**:字体引擎不同。
|
||||
|
||||
验收报告里要把这三条明确列为容差项,别当缺陷去修 —— 修不掉。
|
||||
|
||||
---
|
||||
|
||||
## 7. Altium 侧 Gerber 手动导出参数
|
||||
|
||||
eda-agent 生不了,让用户在 Altium 里导,参数要对齐 KiCad 侧:
|
||||
|
||||
- `File → Fabrication Outputs → Gerber Files`:单位 mm、精度 4:6、
|
||||
Embedded RS274X、**不镜像**、**绝对原点**
|
||||
- `File → Fabrication Outputs → NC Drill Files`
|
||||
- 输出落在 `Project Outputs for <工程名>\`,复制到 `_migration/gerber/altium/`
|
||||
|
||||
KiCad 侧对应:`kicad-cli pcb export gerbers`(精度 6、绝对原点)+
|
||||
`kicad-cli pcb export drill`(Excellon、mm)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 证据目录约定
|
||||
|
||||
全部落在 `<target_dir>\_migration\`:
|
||||
|
||||
```
|
||||
altium_baseline/ 阶段0 基线真值:stats / nets / bom / stackup / pcb_design_rules.json
|
||||
reference/ 参考网表(netlist_tabular.csv) + 原理图 PDF
|
||||
altium_source_snapshot/ 只读源副本
|
||||
convert_to_ascii.pas 由 stage_ascii.py 生成
|
||||
ascii_convert_log.txt DelphiScript 跑完的日志
|
||||
cli_pcb/ kicad-cli 忠实导入的 PCB(仅作几何参考,不替换主板)
|
||||
netcmp/ xml_*.xml 逐页网表 + diff.json + 报告
|
||||
gerber/ altium_vec/ kicad_vec/ layer_map.json + 比对报告
|
||||
backup/ 每次改板前的 .kicad_pcb 快照
|
||||
verification_report.md 最终验收报告
|
||||
```
|
||||
|
||||
XMOD 的基线真值(作为「基线该长什么样」的样例):15 页 / 元件 1191(PCB 1186)/
|
||||
管脚 9511 / 网络 1474 / 10 层 / 走线 21296 · 过孔 4808 · 焊盘 5154 /
|
||||
未布线 0 / 224×150mm / 41 条设计规则。
|
||||
@@ -0,0 +1,125 @@
|
||||
# 矢量几何比对:Altium 侧 JSON 怎么导
|
||||
|
||||
`cmp_vec.py` 要两个目录:`kicad_vec/` 由 `dump_kicad_vec.py` 生成;
|
||||
`altium_vec/` 得你在 Claude 会话里用 eda-agent 的 MCP 工具查出来再写盘 ——
|
||||
LangGraph 进程里没有 MCP,这一步必须由会话来做。
|
||||
|
||||
---
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
_migration\gerber\
|
||||
├── altium_vec\ meta.json vias.json pads.json tracks.json
|
||||
├── kicad_vec\ meta.json vias.json pads.json tracks.json (dump_kicad_vec.py 生成)
|
||||
└── layer_map.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## altium_vec 各文件的格式
|
||||
|
||||
`cmp_vec.py` 的 `load()` 容忍三种外壳:裸 list、`{"vias": [...]}`、`{"objects": [...]}`,
|
||||
所以直接把 MCP 返回的结果落盘一般就能用。字段名**区分大小写**,按下表来。
|
||||
|
||||
### meta.json
|
||||
|
||||
```json
|
||||
{
|
||||
"unit_note": "eda-agent 返回的是整数 mil,读数精度 ±0.5mil = 12.7um",
|
||||
"scale_to_mm": 0.0254,
|
||||
"counts": {"tracks": 10206, "arcs": 108, "pads": 3010, "vias": 2209}
|
||||
}
|
||||
```
|
||||
|
||||
`scale_to_mm` 是 `cmp_vec.py` 唯一会读的字段(也可以用 `--scale-to-mm` 覆盖)。
|
||||
eda-agent 返回整数 mil,所以是 `0.0254`。
|
||||
|
||||
### vias.json — 来自 `pcb_get_vias`
|
||||
|
||||
小写字段:
|
||||
|
||||
```json
|
||||
{"vias": [{"x": 22046, "y": 33005, "size": 20, "hole_size": 12,
|
||||
"net": "VCC3V3", "low_layer": "TopLayer", "high_layer": "BottomLayer"}]}
|
||||
```
|
||||
|
||||
`cmp_vec.py` 只用 `x` / `y`,其余留着供人核对。
|
||||
|
||||
### tracks.json — 来自 `obj_query`(PCB 文档,Track 对象)
|
||||
|
||||
**大写**字段(obj_query 原样返回 Altium 的属性名),值可以是字符串:
|
||||
|
||||
```json
|
||||
{"objects": [{"Layer": "TopLayer", "X1": "21739", "Y1": "33141",
|
||||
"X2": "21859", "Y2": "33141", "Width": "6", "Net": "PS232_RX"}]}
|
||||
```
|
||||
|
||||
`cmp_vec.py` 用 `Layer` / `X1,Y1` / `X2,Y2`,按中点逐层匹配。
|
||||
|
||||
### pads.json — 来自 `obj_query`(Pad 对象)
|
||||
|
||||
```json
|
||||
{"objects": [{"Name": "", "X": "22666", "Y": "32072", "Layer": "TopLayer",
|
||||
"Net": "GND", "HoleSize": "0", "TopXSize": "59",
|
||||
"TopYSize": "118", "Rotation": "90"}]}
|
||||
```
|
||||
|
||||
用 `X` / `Y`。
|
||||
|
||||
---
|
||||
|
||||
## layer_map.json
|
||||
|
||||
Altium 层名 → KiCad 层名。**必须逐工程写,不能套公式** —— `MidLayerN` 到
|
||||
`InN.Cu` 的对应取决于叠层顺序,想当然会全错。
|
||||
|
||||
```json
|
||||
{
|
||||
"TopLayer": "F.Cu",
|
||||
"MidLayer1": "In2.Cu",
|
||||
"MidLayer3": "In5.Cu",
|
||||
"MidLayer2": "In7.Cu",
|
||||
"BottomLayer": "B.Cu"
|
||||
}
|
||||
```
|
||||
|
||||
(上面是 GQ_EXCT7G_V1 的真实映射,注意 MidLayer2/3 是反的。)
|
||||
|
||||
怎么定:不带 `--layer-map` 跑一次 `cmp_vec.py`,它会打印两侧每层的走线条数,
|
||||
按计数配对;再用 `pcb_get_layer_stackup`(Altium 侧)和 `get_layer_list`
|
||||
(KiCad 侧)核对叠层顺序。内电层(负片平面)通常没有走线,不进 map。
|
||||
|
||||
---
|
||||
|
||||
## 跑一遍
|
||||
|
||||
```powershell
|
||||
# 1) KiCad 侧(下面的 KiCad python 路径取自 ~/skills_env.md 的 KICAD_PYTHON)
|
||||
& "D:\KiCad\10.0\bin\python.exe" dump_kicad_vec.py `
|
||||
--board "E:\Kicad_design\FOO\FOO.kicad_pcb" `
|
||||
--out "E:\Kicad_design\FOO\_migration\gerber\kicad_vec"
|
||||
|
||||
# 2) Altium 侧:在会话里用 eda-agent 查,按上面的格式写进 altium_vec\
|
||||
|
||||
# 3) 先不给 layer_map,看两侧每层走线数,定映射
|
||||
python cmp_vec.py --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec
|
||||
|
||||
# 4) 写好 layer_map.json 再跑完整比对
|
||||
python cmp_vec.py --altium-vec ...\altium_vec --kicad-vec ...\kicad_vec `
|
||||
--layer-map ...\layer_map.json --report ...\vec_result.json
|
||||
```
|
||||
|
||||
退出码 0 = 全部图元匹配。
|
||||
|
||||
---
|
||||
|
||||
## 结果怎么读
|
||||
|
||||
- `unmatched_altium` 和 `unmatched_kicad` **都要是 0**。只看一边会漏掉
|
||||
「KiCad 多出来的图元」。
|
||||
- `residual mm` 的 max 应该在容差内且远小于容差(XMOD/GQ 实测中位数在 1um 量级)。
|
||||
如果 max 贴着容差上限,多半是平移标定被拉偏了 —— 先查两侧过孔数是否相等。
|
||||
- 两侧过孔数不同 → 质心标定必然偏。先查是导入丢了过孔,还是 Altium 侧
|
||||
`obj_query` 漏了层,别硬比。
|
||||
- 某一层大面积 unmatched,而其它层是 0 → 十有八九是 `layer_map.json` 配错了。
|
||||
@@ -0,0 +1,337 @@
|
||||
"""ak_flow.py — 驱动 Altium→KiCad 迁移的 LangGraph 图(进程内,无 langgraph dev)。
|
||||
|
||||
图本体在 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT(app/main_graph.py),本脚本只做三件事:
|
||||
1) 用 SqliteSaver 打开持久化 checkpoint,按 thread_id 续跑;
|
||||
2) 把当前挂起的 interrupt 以结构化 JSON 打印给 Claude 会话;
|
||||
3) 把 Claude 执行完真实动作后的结论 Command(resume=...) 回填、推进到下一个 interrupt。
|
||||
|
||||
为什么不直接用工程里的 run.py:run.py 的 resume 只接受一个位置参数 JSON 字符串,
|
||||
在 PowerShell 里转义引号极易出错;且 thread 固定为 AK_THREAD 单值,多工程并发会串。
|
||||
本脚本用 --set key=value 免转义回填,thread 由 target_dir 推导,DB 沿用
|
||||
runs/skill_checkpoint.sqlite(已跑完的 ak-xmod-v1p0 / ak-gq-exct7g-v1 仍可续)。
|
||||
|
||||
退出码:0 = 图已结束(done);2 = 停在 interrupt 等回填(waiting);1 = 出错。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def _env_lookup(key: str, default: str | None = None) -> str:
|
||||
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
|
||||
v = os.environ.get(key)
|
||||
if v:
|
||||
return v
|
||||
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
m = re.match(rf"\s*{key}\s*=\s*(\S+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
if default is None:
|
||||
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
|
||||
return default
|
||||
|
||||
|
||||
# 图工程根是机器相关的:真源是 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。
|
||||
DEFAULT_PROJECT_ROOT = _env_lookup("ALTIUM_KICAD_GRAPH_ROOT", r"E:\LangGraph_Design\Altium-Kicad")
|
||||
DB_RELATIVE = ("runs", "skill_checkpoint.sqlite")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- utils
|
||||
def _out(*parts: str) -> None:
|
||||
print(*parts, flush=True)
|
||||
|
||||
|
||||
def _fatal(msg: str) -> "None":
|
||||
_out(f"[ERROR] {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def slug_for(target_dir: str) -> str:
|
||||
"""E:\\Kicad_design\\XMOD_V1p0 -> ak-xmod-v1p0 (与既有线程命名一致)。"""
|
||||
name = Path(target_dir.rstrip("\\/")).name or "run"
|
||||
name = re.sub(r"[^0-9A-Za-z]+", "-", name).strip("-").lower()
|
||||
return f"ak-{name or 'run'}"
|
||||
|
||||
|
||||
def coerce(raw: str):
|
||||
"""--set 的值做最小类型推断:true/false→bool,纯数字→int,其余原样字符串。"""
|
||||
low = raw.strip().lower()
|
||||
if low in ("true", "yes", "1"):
|
||||
return True
|
||||
if low in ("false", "no", "0"):
|
||||
return False
|
||||
if re.fullmatch(r"-?\d+", raw.strip()):
|
||||
return int(raw.strip())
|
||||
return raw
|
||||
|
||||
|
||||
def pending_interrupt(snap):
|
||||
for task in snap.tasks:
|
||||
if task.interrupts:
|
||||
return task.interrupts[0].value
|
||||
return None
|
||||
|
||||
|
||||
def load_graph(project_root: Path):
|
||||
if not (project_root / "app" / "main_graph.py").is_file():
|
||||
_fatal(f"工程根不对:{project_root} 下没有 app\\main_graph.py。"
|
||||
f"用 --project-root 指定,设环境变量 AK_PROJECT_ROOT,或改 ~/skills_env.md 的 ALTIUM_KICAD_GRAPH_ROOT。")
|
||||
sys.path.insert(0, str(project_root))
|
||||
from app.main_graph import build_graph # noqa: E402
|
||||
return build_graph
|
||||
|
||||
|
||||
RETRY_LIMITS = {"bridge": 3, "import": 3, "netlist": 5, "gerber": 5}
|
||||
|
||||
|
||||
def init_state(source_prj: str, target_dir: str, limits: dict[str, int]) -> dict:
|
||||
state = {
|
||||
"source_prj": source_prj,
|
||||
"target_dir": target_dir,
|
||||
"migration_dir": str(Path(target_dir) / "_migration"),
|
||||
"history": [],
|
||||
}
|
||||
for name, default in RETRY_LIMITS.items():
|
||||
state[f"{name}_retry"] = 0
|
||||
state[f"max_{name}_retry"] = limits.get(name) or default
|
||||
return state
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- report
|
||||
def report(graph, thread: dict, before_len: int) -> int:
|
||||
"""打印当前状态;返回进程退出码。"""
|
||||
snap = graph.get_state(thread)
|
||||
values = snap.values or {}
|
||||
history = values.get("history", [])
|
||||
walked = [h.get("node") for h in history[before_len:]]
|
||||
if walked:
|
||||
_out("[NODES] " + " -> ".join(str(n) for n in walked))
|
||||
|
||||
_out(f"[THREAD] {thread['configurable']['thread_id']}")
|
||||
if values.get("source_prj"):
|
||||
_out(f"[PATHS] source_prj = {values['source_prj']}")
|
||||
_out(f"[PATHS] target_dir = {values['target_dir']}")
|
||||
_out(f"[PATHS] migration = {values.get('migration_dir', '')}")
|
||||
|
||||
retries = {k: values.get(k) for k in
|
||||
("bridge_retry", "import_retry", "netlist_retry", "gerber_retry")
|
||||
if values.get(k)}
|
||||
if retries:
|
||||
_out("[RETRY] " + json.dumps(retries))
|
||||
|
||||
if snap.next:
|
||||
payload = pending_interrupt(snap)
|
||||
_out(f"[WAITING] next node: {', '.join(snap.next)}")
|
||||
if payload is not None:
|
||||
_out("[INTERRUPT] " + json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
_out("[INTERRUPT] (无 payload — 直接 resume 推进)")
|
||||
return 2
|
||||
|
||||
if any(h.get("node") == "safety_exit" for h in history):
|
||||
_out("[SAFETY-END] 某个判定回环超过重试上限,图已优雅退出。"
|
||||
"先修好卡住的那一步,再用 restart 重跑,或调高对应 max_*_retry。")
|
||||
return 0
|
||||
|
||||
_out("[DONE] 图已跑完。")
|
||||
_out(f"[REPORT] {values.get('report_path', '(未回填 report_path)')}")
|
||||
return 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- commands
|
||||
def cmd_start(args, graph_builder, db_path: Path):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
source = args.source_prj
|
||||
target = args.target_dir
|
||||
if not source or not target:
|
||||
_fatal("start 需要 --source-prj 与 --target-dir(均为绝对路径,无默认值)。")
|
||||
if not Path(source).is_file():
|
||||
_fatal(f"源工程不存在:{source}(应指向 .PrjPCB 文件本身)")
|
||||
Path(target).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
thread_id = args.thread or slug_for(target)
|
||||
thread = {"configurable": {"thread_id": thread_id}}
|
||||
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = graph_builder(checkpointer=cp)
|
||||
existing = graph.get_state(thread)
|
||||
if (existing.next or existing.values) and not args.restart:
|
||||
_out(f"[INFO] 线程 {thread_id} 已存在,续跑(不重置)。要从头开始请加 --restart。")
|
||||
before = len(existing.values.get("history", []))
|
||||
if existing.next and pending_interrupt(existing) is not None:
|
||||
# 已经停在 interrupt 上,什么都不推进,直接把 payload 亮出来
|
||||
return report(graph, thread, before)
|
||||
graph.invoke(None, thread)
|
||||
return report(graph, thread, before)
|
||||
|
||||
if args.restart:
|
||||
_out(f"[INFO] --restart:清空线程 {thread_id} 的 checkpoint。")
|
||||
wipe_thread(db_path, thread_id)
|
||||
|
||||
limits = {name: getattr(args, f"max_{name}_retry", None) for name in RETRY_LIMITS}
|
||||
_out(f"[INFO] 新建线程 {thread_id}")
|
||||
graph.invoke(init_state(source, target, limits), thread)
|
||||
return report(graph, thread, 0)
|
||||
|
||||
|
||||
def cmd_status(args, graph_builder, db_path: Path):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
thread_id = resolve_thread(args)
|
||||
thread = {"configurable": {"thread_id": thread_id}}
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = graph_builder(checkpointer=cp)
|
||||
snap = graph.get_state(thread)
|
||||
if not snap.values and not snap.next:
|
||||
_fatal(f"线程 {thread_id} 没有任何 checkpoint。先跑 start。")
|
||||
return report(graph, thread, len(snap.values.get("history", [])))
|
||||
|
||||
|
||||
def cmd_resume(args, graph_builder, db_path: Path):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
from langgraph.types import Command
|
||||
|
||||
payload: dict = {}
|
||||
if args.json:
|
||||
try:
|
||||
payload = json.loads(args.json)
|
||||
except json.JSONDecodeError as exc:
|
||||
_fatal(f"--json 不是合法 JSON:{exc}")
|
||||
for item in args.set or []:
|
||||
if "=" not in item:
|
||||
_fatal(f"--set 要写成 key=value,收到:{item}")
|
||||
key, raw = item.split("=", 1)
|
||||
payload[key.strip()] = coerce(raw)
|
||||
|
||||
if not payload:
|
||||
_fatal("resume 的回填不能为空——langgraph 会把 falsy 的 resume 当作『没提供』"
|
||||
"从而在同一个节点再次中断。至少给一个键,例如 --set acknowledged=true。")
|
||||
|
||||
thread_id = resolve_thread(args)
|
||||
thread = {"configurable": {"thread_id": thread_id}}
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = graph_builder(checkpointer=cp)
|
||||
snap = graph.get_state(thread)
|
||||
if not snap.next:
|
||||
_fatal(f"线程 {thread_id} 没有挂起的 interrupt(图已结束或未启动)。先跑 status。")
|
||||
before = len(snap.values.get("history", []))
|
||||
_out("[RESUME] " + json.dumps(payload, ensure_ascii=False))
|
||||
graph.invoke(Command(resume=payload), thread)
|
||||
return report(graph, thread, before)
|
||||
|
||||
|
||||
def cmd_list(args, graph_builder, db_path: Path):
|
||||
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||
|
||||
if not db_path.is_file():
|
||||
_out("[INFO] 还没有任何 checkpoint 数据库。")
|
||||
return 0
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
threads = sorted({r[0] for r in conn.execute("select distinct thread_id from checkpoints")})
|
||||
finally:
|
||||
conn.close()
|
||||
if not threads:
|
||||
_out("[INFO] 数据库里没有线程。")
|
||||
return 0
|
||||
with SqliteSaver.from_conn_string(str(db_path)) as cp:
|
||||
graph = graph_builder(checkpointer=cp)
|
||||
for tid in threads:
|
||||
snap = graph.get_state({"configurable": {"thread_id": tid}})
|
||||
v = snap.values or {}
|
||||
state = f"waiting@{','.join(snap.next)}" if snap.next else "done"
|
||||
_out(f"[THREAD] {tid:<24} {state:<28} target={v.get('target_dir', '?')}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_reset(args, graph_builder, db_path: Path):
|
||||
thread_id = resolve_thread(args)
|
||||
n = wipe_thread(db_path, thread_id)
|
||||
_out(f"[INFO] 已删除线程 {thread_id} 的 {n} 条 checkpoint 记录。")
|
||||
return 0
|
||||
|
||||
|
||||
def wipe_thread(db_path: Path, thread_id: str) -> int:
|
||||
if not db_path.is_file():
|
||||
return 0
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
n = conn.execute("delete from checkpoints where thread_id=?", (thread_id,)).rowcount
|
||||
conn.execute("delete from writes where thread_id=?", (thread_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return n
|
||||
|
||||
|
||||
def resolve_thread(args) -> str:
|
||||
if getattr(args, "thread", None):
|
||||
return args.thread
|
||||
if getattr(args, "target_dir", None):
|
||||
return slug_for(args.target_dir)
|
||||
_fatal("需要 --thread <id> 或 --target-dir <目标 KiCad 目录> 来定位线程。用 list 看所有线程。")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- main
|
||||
def main() -> int:
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ap = argparse.ArgumentParser(description="Drive the Altium->KiCad migration LangGraph flow.")
|
||||
ap.add_argument("--project-root", default=os.environ.get("AK_PROJECT_ROOT", DEFAULT_PROJECT_ROOT))
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("start", help="新建或续跑一次迁移")
|
||||
p.add_argument("--source-prj", help="源 Altium .PrjPCB 绝对路径")
|
||||
p.add_argument("--target-dir", help="目标 KiCad 工程目录绝对路径")
|
||||
p.add_argument("--thread", help="线程 id(默认由 target-dir 推导)")
|
||||
p.add_argument("--restart", action="store_true", help="清空该线程重新开始")
|
||||
for name, default in RETRY_LIMITS.items():
|
||||
p.add_argument(f"--max-{name}-retry", type=int,
|
||||
help=f"{name} 回环的重试上限(默认 {default},超限走 safety_end)")
|
||||
p.set_defaults(func=cmd_start)
|
||||
|
||||
p = sub.add_parser("status", help="看当前停在哪个 interrupt")
|
||||
p.add_argument("--thread")
|
||||
p.add_argument("--target-dir")
|
||||
p.set_defaults(func=cmd_status)
|
||||
|
||||
p = sub.add_parser("resume", help="回填结论并推进到下一个 interrupt")
|
||||
p.add_argument("--thread")
|
||||
p.add_argument("--target-dir")
|
||||
p.add_argument("--set", action="append", metavar="KEY=VALUE",
|
||||
help="免转义回填,可重复;true/false→bool,纯数字→int")
|
||||
p.add_argument("--json", help="直接给 JSON 对象(与 --set 可混用)")
|
||||
p.set_defaults(func=cmd_resume)
|
||||
|
||||
p = sub.add_parser("list", help="列出所有迁移线程")
|
||||
p.set_defaults(func=cmd_list)
|
||||
|
||||
p = sub.add_parser("reset", help="删除某个线程的 checkpoint")
|
||||
p.add_argument("--thread")
|
||||
p.add_argument("--target-dir")
|
||||
p.set_defaults(func=cmd_reset)
|
||||
|
||||
args = ap.parse_args()
|
||||
root = Path(args.project_root)
|
||||
db_path = root.joinpath(*DB_RELATIVE)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
graph_builder = load_graph(root)
|
||||
return args.func(args, graph_builder, db_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
"""cmp_vec.py — Altium 与 KiCad 的离散版图几何等价判定(阶段 3 的决定性证据)。
|
||||
|
||||
为什么这个比栅格 Gerber XOR 更权威:跨工具的 Gerber 不可能像素级一致
|
||||
(aperture / 坐标格式 / 填充算法 / 字体 / 负片都不同),栅格 XOR 只能给出
|
||||
「像什么程度」。而走线/过孔/焊盘是离散图元,可以逐条逐个匹配 —— 匹配上了
|
||||
就是同一块板,匹配不上就是真差异,没有解释空间。
|
||||
|
||||
坐标变换只有两个自由参数,不是曲线拟合:
|
||||
kx = s*ax + tx
|
||||
ky = -s*ay + ty (Altium y 向上,KiCad y 向下)
|
||||
s 由单位固定(Altium API 返回整数 mil -> mm 就是 0.0254),(tx,ty) 由两侧过孔
|
||||
质心解出。用 2 个参数去对上几千个过孔+焊盘+走线,对上了就是实证。
|
||||
|
||||
容差默认 30um:eda-agent 读出的 Altium 坐标是**整数 mil**,读数精度本身就有
|
||||
±12.7um,两端各一次就接近 25um。KiCad 侧是精确的(导入器直接解析二进制 PcbDoc)。
|
||||
|
||||
用法:
|
||||
python cmp_vec.py --altium-vec .../altium_vec --kicad-vec .../kicad_vec \\
|
||||
--layer-map .../layer_map.json [--tol-mm 0.030] [--report r.json]
|
||||
退出码 0 = 全部图元匹配;1 = 有未匹配图元。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load(path: Path, *containers: str):
|
||||
"""容忍两种外壳:裸 list,或 {"vias": [...]} / {"objects": [...]}。"""
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict):
|
||||
for key in containers + ("objects", "vias", "items"):
|
||||
if key in data and isinstance(data[key], list):
|
||||
return data[key]
|
||||
raise SystemExit(f"[ERROR] {path} 是 dict 但找不到列表字段(试过 {containers + ('objects',)})")
|
||||
return data
|
||||
|
||||
|
||||
def num(v) -> float:
|
||||
return float(v)
|
||||
|
||||
|
||||
def match(a_pts, k_pts, tol, label, out_lines):
|
||||
"""网格加速的最近邻一对一匹配。返回 (未匹配的A, 未匹配的K)。"""
|
||||
if not a_pts and not k_pts:
|
||||
return 0, 0
|
||||
cell = max(tol * 4, 0.05)
|
||||
grid = collections.defaultdict(list)
|
||||
for i, p in enumerate(k_pts):
|
||||
grid[(int(p[0] / cell), int(p[1] / cell))].append(i)
|
||||
used, res, unmatched = set(), [], 0
|
||||
for p in a_pts:
|
||||
gx, gy = int(p[0] / cell), int(p[1] / cell)
|
||||
best, bd = None, 1e9
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
for i in grid.get((gx + dx, gy + dy), ()):
|
||||
if i in used:
|
||||
continue
|
||||
d = math.hypot(k_pts[i][0] - p[0], k_pts[i][1] - p[1])
|
||||
if d < bd:
|
||||
bd, best = d, i
|
||||
if best is None or bd > tol:
|
||||
unmatched += 1
|
||||
else:
|
||||
used.add(best)
|
||||
res.append(bd)
|
||||
line = (f"{label}: altium={len(a_pts)} kicad={len(k_pts)} matched={len(res)} "
|
||||
f"unmatched_altium={unmatched} unmatched_kicad={len(k_pts) - len(used)}")
|
||||
if res:
|
||||
res.sort()
|
||||
line += (f"\n residual mm: max={res[-1]:.6f} "
|
||||
f"p99={res[min(int(len(res) * 0.99), len(res) - 1)]:.6f} "
|
||||
f"median={res[len(res) // 2]:.6f}")
|
||||
print("\n" + line)
|
||||
out_lines.append(line)
|
||||
return unmatched, len(k_pts) - len(used)
|
||||
|
||||
|
||||
def suggest_layer_map(a_tracks, k_tracks):
|
||||
"""没给 layer_map 时,按每层走线条数给个候选映射,供人核对。"""
|
||||
a_cnt = collections.Counter(str(t.get("Layer", t.get("layer"))) for t in a_tracks)
|
||||
k_cnt = collections.Counter(t["layer"] for t in k_tracks)
|
||||
print("\n[layer] Altium 各层走线数: " + json.dumps(a_cnt.most_common(), ensure_ascii=False))
|
||||
print("[layer] KiCad 各层走线数: " + json.dumps(k_cnt.most_common(), ensure_ascii=False))
|
||||
print("[layer] 没有 --layer-map。请对照上面两组计数写一个 layer_map.json,例如\n"
|
||||
' {"TopLayer": "F.Cu", "MidLayer1": "In2.Cu", "BottomLayer": "B.Cu"}\n'
|
||||
" 注意层序按叠层走,不要想当然 MidLayerN -> InN.Cu。")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Vector geometry equivalence: Altium vs KiCad")
|
||||
ap.add_argument("--altium-vec", required=True)
|
||||
ap.add_argument("--kicad-vec", required=True)
|
||||
ap.add_argument("--layer-map", help="{Altium层名: KiCad层名} 的 JSON;不给则只比过孔和焊盘并给出建议")
|
||||
ap.add_argument("--tol-mm", type=float, default=0.030)
|
||||
ap.add_argument("--scale-to-mm", type=float,
|
||||
help="Altium 原始单位 -> mm。默认读 altium_vec/meta.json,再默认 0.0254(mil)")
|
||||
ap.add_argument("--report", help="把结论写成 JSON")
|
||||
args = ap.parse_args()
|
||||
|
||||
av, kv = Path(args.altium_vec), Path(args.kicad_vec)
|
||||
scale = args.scale_to_mm
|
||||
if scale is None:
|
||||
meta_path = av / "meta.json"
|
||||
if meta_path.is_file():
|
||||
scale = json.loads(meta_path.read_text(encoding="utf-8")).get("scale_to_mm")
|
||||
scale = scale or 0.0254
|
||||
print(f"scale_to_mm : {scale} tol_mm : {args.tol_mm}")
|
||||
|
||||
a_vias = load(av / "vias.json", "vias")
|
||||
k_vias = load(kv / "vias.json", "vias")
|
||||
print(f"vias: altium={len(a_vias)} kicad={len(k_vias)}")
|
||||
if not a_vias or not k_vias:
|
||||
raise SystemExit("[ERROR] 两侧都必须有过孔才能标定平移(过孔是最干净的基准)。")
|
||||
if len(a_vias) != len(k_vias):
|
||||
print("[WARN] 两侧过孔数不同 —— 质心标定会被拉偏。先查清是导入丢了过孔,"
|
||||
"还是 Altium 侧导出漏了层,再比。")
|
||||
|
||||
ax = sum(num(v["x"]) * scale for v in a_vias) / len(a_vias)
|
||||
ay = sum(num(v["y"]) * scale for v in a_vias) / len(a_vias)
|
||||
kx = sum(v["x"] for v in k_vias) / len(k_vias)
|
||||
ky = sum(v["y"] for v in k_vias) / len(k_vias)
|
||||
TX, TY = kx - ax, ky + ay
|
||||
print(f"transform : kx = ax*{scale} + {TX:.6f} ky = -ay*{scale} + {TY:.6f}")
|
||||
|
||||
def T(o, kx_="x", ky_="y"):
|
||||
return (num(o[kx_]) * scale + TX, TY - num(o[ky_]) * scale)
|
||||
|
||||
lines: list[str] = []
|
||||
bad = 0
|
||||
skipped_tracks = False
|
||||
bad += sum(match([T(v) for v in a_vias],
|
||||
[(v["x"], v["y"]) for v in k_vias], args.tol_mm, "VIAS", lines))
|
||||
|
||||
a_pads_p, k_pads_p = av / "pads.json", kv / "pads.json"
|
||||
if a_pads_p.is_file() and k_pads_p.is_file():
|
||||
a_pads, k_pads = load(a_pads_p), load(k_pads_p)
|
||||
bad += sum(match([T(p, "X", "Y") for p in a_pads],
|
||||
[(p["x"], p["y"]) for p in k_pads], args.tol_mm, "PADS", lines))
|
||||
|
||||
a_tr_p, k_tr_p = av / "tracks.json", kv / "tracks.json"
|
||||
if a_tr_p.is_file() and k_tr_p.is_file():
|
||||
a_tr, k_tr = load(a_tr_p), load(k_tr_p)
|
||||
if not args.layer_map:
|
||||
skipped_tracks = True
|
||||
suggest_layer_map(a_tr, k_tr)
|
||||
else:
|
||||
laymap = json.loads(Path(args.layer_map).read_text(encoding="utf-8"))
|
||||
a_cu = [t for t in a_tr if str(t.get("Layer")) in laymap]
|
||||
print(f"\naltium tracks total={len(a_tr)} on mapped copper={len(a_cu)} "
|
||||
f"kicad copper tracks={len(k_tr)}")
|
||||
|
||||
def mid_a(t):
|
||||
p1, p2 = T(t, "X1", "Y1"), T(t, "X2", "Y2")
|
||||
return ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)
|
||||
|
||||
for alay, klay in sorted(laymap.items()):
|
||||
# 逐层比,否则相邻层上位置重合的走线会互相错配
|
||||
aa = [mid_a(t) for t in a_cu if str(t.get("Layer")) == alay]
|
||||
kk = [((t["x1"] + t["x2"]) / 2, (t["y1"] + t["y2"]) / 2)
|
||||
for t in k_tr if t["layer"] == klay]
|
||||
if not aa and not kk:
|
||||
continue
|
||||
bad += sum(match(aa, kk, args.tol_mm, f"TRACKS {alay} -> {klay}", lines))
|
||||
|
||||
equivalent = bad == 0 and not skipped_tracks
|
||||
print("\n" + "=" * 60)
|
||||
print(f"TOTAL unmatched primitives: {bad}"
|
||||
+ (" (走线未比对)" if skipped_tracks else ""))
|
||||
print("=" * 60)
|
||||
if skipped_tracks:
|
||||
print("[INCOMPLETE] 没给 --layer-map,走线整个跳过了 —— 上面这个 0 只覆盖过孔和焊盘,"
|
||||
"不能当作等价结论。按上面的层计数写好 layer_map.json 再跑一次。")
|
||||
elif equivalent:
|
||||
print("离散几何 100% 等价 —— 走线/过孔/焊盘逐条对上。铺铜纹理、丝印字形、"
|
||||
"负片内电层的差异是工具固有项,不计入本判定(理由见 references\\playbook.md)。")
|
||||
|
||||
if args.report:
|
||||
Path(args.report).write_text(json.dumps({
|
||||
"unmatched_total": bad, "equivalent": equivalent,
|
||||
"tracks_compared": not skipped_tracks,
|
||||
"scale_to_mm": scale, "tol_mm": args.tol_mm,
|
||||
"transform": {"tx": TX, "ty": TY},
|
||||
"details": lines,
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"[report] {args.report}")
|
||||
return 0 if equivalent else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,100 @@
|
||||
"""dump_kicad_vec.py — 把 KiCad 板的离散几何导成 JSON,供 cmp_vec.py 比对。必须用 KiCad python 跑。
|
||||
|
||||
输出(单位一律 mm,KiCad 原生坐标系:y 向下):
|
||||
vias.json [{x, y, drill, top, bot, net}]
|
||||
pads.json [{x, y, layer, net, ref, pad, drill}]
|
||||
tracks.json [{x1, y1, x2, y2, width, layer, net}]
|
||||
meta.json {counts, layers}
|
||||
|
||||
用法(KiCad python 路径见 ~/skills_env.md 的 KICAD_PYTHON):
|
||||
D:\\KiCad\\10.0\\bin\\python.exe dump_kicad_vec.py \\
|
||||
--board E:\\Kicad_design\\FOO\\FOO.kicad_pcb \\
|
||||
--out E:\\Kicad_design\\FOO\\_migration\\gerber\\kicad_vec
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Dump KiCad board discrete geometry to JSON (mm)")
|
||||
ap.add_argument("--board", required=True)
|
||||
ap.add_argument("--out", required=True)
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import pcbnew
|
||||
except ImportError:
|
||||
print("[ERROR] 导不到 pcbnew —— 用 KiCad 自带 python 跑(~\\skills_env.md 的 KICAD_PYTHON)。")
|
||||
return 1
|
||||
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
board = pcbnew.LoadBoard(args.board)
|
||||
mm = pcbnew.ToMM
|
||||
|
||||
def via_size(v) -> float:
|
||||
"""KiCad 10 起 PCB_VIA::GetWidth() 必须带层参数,不带会断言中止进程。
|
||||
老版本又只接受无参形式,所以两种都试。size 只是给人看的,取不到就算了。"""
|
||||
for call in (lambda: v.GetWidth(v.TopLayer()), lambda: v.GetWidth()):
|
||||
try:
|
||||
return mm(call())
|
||||
except Exception:
|
||||
continue
|
||||
return 0.0
|
||||
|
||||
vias, tracks = [], []
|
||||
for t in board.GetTracks():
|
||||
if isinstance(t, pcbnew.PCB_VIA):
|
||||
vias.append({
|
||||
"x": mm(t.GetPosition().x), "y": mm(t.GetPosition().y),
|
||||
"drill": mm(t.GetDrill()), "size": via_size(t),
|
||||
"top": board.GetLayerName(t.TopLayer()),
|
||||
"bot": board.GetLayerName(t.BottomLayer()),
|
||||
"net": t.GetNetname(),
|
||||
})
|
||||
else:
|
||||
tracks.append({
|
||||
"x1": mm(t.GetStart().x), "y1": mm(t.GetStart().y),
|
||||
"x2": mm(t.GetEnd().x), "y2": mm(t.GetEnd().y),
|
||||
"width": mm(t.GetWidth()),
|
||||
"layer": board.GetLayerName(t.GetLayer()),
|
||||
"net": t.GetNetname(),
|
||||
})
|
||||
|
||||
pads = []
|
||||
for fp in board.GetFootprints():
|
||||
ref = fp.GetReference()
|
||||
for p in fp.Pads():
|
||||
pads.append({
|
||||
"x": mm(p.GetPosition().x), "y": mm(p.GetPosition().y),
|
||||
"layer": board.GetLayerName(p.GetLayer()),
|
||||
"net": p.GetNetname(), "ref": ref, "pad": p.GetNumber(),
|
||||
"drill": mm(p.GetDrillSizeX()),
|
||||
})
|
||||
|
||||
layers = sorted({t["layer"] for t in tracks})
|
||||
for name, data in (("vias", vias), ("tracks", tracks), ("pads", pads)):
|
||||
(out / f"{name}.json").write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
(out / "meta.json").write_text(json.dumps({
|
||||
"units": "mm", "y_axis": "down (KiCad native)",
|
||||
"counts": {"vias": len(vias), "tracks": len(tracks), "pads": len(pads)},
|
||||
"copper_layers_with_tracks": layers,
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
print(f"[dump] vias={len(vias)} tracks={len(tracks)} pads={len(pads)} -> {out}")
|
||||
print(f"[layers] 有走线的铜层:{', '.join(layers)}")
|
||||
print("[next] Altium 侧用 eda-agent 导同名 JSON 到 altium_vec/(格式见 "
|
||||
"references\\vector-compare.md),然后跑 cmp_vec.py。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""fix_pcb.py — 导入后 PCB 修复(阶段 1.5 的两个硬骨头)。必须用 KiCad 自带 python 跑。
|
||||
|
||||
A) BGA / 平面焊盘丢网络回填
|
||||
Altium 用负片内电层隐式连接电源地,KiCad 导入时这些焊盘拿不到网络,
|
||||
表现为成百上千个 "GND vs 空网络" 假短路。用阶段 0 导出的 Altium 网表真值
|
||||
(component,pin,net) 把空网络焊盘重新指派回去。
|
||||
|
||||
B) 覆铜 zone 的本地 clearance
|
||||
Altium 导入器把【每块 zone 的本地 Clearance 写成 KiCad 默认 0.5mm】
|
||||
((zone ... (connect_pads (clearance 0.5)))),而本地值会盖过网络类 clearance
|
||||
—— 这就是导入后异 net 避让间隔明显偏大的根因。只改 .kicad_pro 的网络类
|
||||
或板级规则没有用,必须逐块 zone 改。目标值取 Altium 的 InPolygon 规则
|
||||
(XMOD/GQ 两个工程实测都是 0.254mm,但别照抄,去基线 pcb_design_rules.json 里查)。
|
||||
热焊盘 thermal_gap 通常已经对上 Altium PolygonConnect AirGap(0.165mm),不要动。
|
||||
|
||||
本脚本改完不 refill。大板上 SWIG refill 容易超时甚至段错误,去 KiCad GUI 里按 B
|
||||
填充,或走 IPC 后端。
|
||||
|
||||
用法(KICAD_PYTHON 见 ~\\skills_env.md):
|
||||
# 先干跑看看要动多少
|
||||
D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\
|
||||
--zone-clearance-mm 0.254 --dry-run
|
||||
# 确认后真改(会先备份 .kicad_pcb 到 <board>.bak_fixpcb)
|
||||
D:\\KiCad\\10.0\\bin\\python.exe fix_pcb.py --board X.kicad_pcb --netlist-csv t.csv \\
|
||||
--zone-clearance-mm 0.254
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
COMP_KEYS = ("component", "designator", "ref", "reference")
|
||||
PIN_KEYS = ("pin", "pin_number", "pad", "pin designator")
|
||||
NET_KEYS = ("net", "netname", "net name")
|
||||
|
||||
|
||||
def pick(fieldnames, candidates) -> str | None:
|
||||
lowered = {(f or "").strip().lower(): f for f in fieldnames}
|
||||
for c in candidates:
|
||||
if c in lowered:
|
||||
return lowered[c]
|
||||
return None
|
||||
|
||||
|
||||
def load_truth(csv_path: Path) -> dict[tuple[str, str], str]:
|
||||
"""Altium 网表真值 (component, pin) -> net。
|
||||
|
||||
Altium 的 tabular 导出会把隐藏的电源/平面引脚重复列成 net='?' 的行,
|
||||
这些行没有信息量,直接丢掉。
|
||||
"""
|
||||
with csv_path.open(newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
c_key = pick(reader.fieldnames or [], COMP_KEYS)
|
||||
p_key = pick(reader.fieldnames or [], PIN_KEYS)
|
||||
n_key = pick(reader.fieldnames or [], NET_KEYS)
|
||||
if not (c_key and p_key and n_key):
|
||||
raise SystemExit(
|
||||
f"[ERROR] 认不出 CSV 的列名:{reader.fieldnames}\n"
|
||||
f" 需要 component / pin / net 三列(大小写和别名可容忍)。")
|
||||
truth: dict[tuple[str, str], str] = {}
|
||||
dropped = 0
|
||||
for row in reader:
|
||||
comp = (row.get(c_key) or "").strip()
|
||||
pin = (row.get(p_key) or "").strip()
|
||||
net = (row.get(n_key) or "").strip()
|
||||
if not (comp and pin):
|
||||
continue
|
||||
if not net or net == "?":
|
||||
dropped += 1
|
||||
continue
|
||||
truth[(comp, pin)] = net
|
||||
print(f"[csv] 真值引脚 {len(truth)} 条(丢弃 net='?'/空 的重复行 {dropped} 条)")
|
||||
return truth
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Post-import KiCad PCB repair (nets + zone clearance)")
|
||||
ap.add_argument("--board", required=True, help=".kicad_pcb 路径")
|
||||
ap.add_argument("--netlist-csv", required=True,
|
||||
help="Altium 侧网表真值 CSV(阶段0 proj_export_netlist tabular)")
|
||||
ap.add_argument("--zone-clearance-mm", type=float, required=True,
|
||||
help="Altium InPolygon 规则的 polygon clearance,去基线 pcb_design_rules.json 查")
|
||||
ap.add_argument("--dry-run", action="store_true", help="只统计不落盘")
|
||||
ap.add_argument("--report", help="把统计写成 JSON")
|
||||
ap.add_argument("--skip-nets", action="store_true", help="只改 zone clearance,不动焊盘网络")
|
||||
ap.add_argument("--skip-zones", action="store_true", help="只回填焊盘网络,不动 zone clearance")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import pcbnew
|
||||
except ImportError:
|
||||
print("[ERROR] 导不到 pcbnew —— 本脚本必须用 KiCad 自带的 python 跑,"
|
||||
"例如 D:\\KiCad\\10.0\\bin\\python.exe(见 ~\\skills_env.md 的 KICAD_PYTHON)。")
|
||||
return 1
|
||||
|
||||
board_path = Path(args.board)
|
||||
if not board_path.is_file():
|
||||
print(f"[ERROR] 板文件不存在:{board_path}")
|
||||
return 1
|
||||
|
||||
truth = load_truth(Path(args.netlist_csv)) if not args.skip_nets else {}
|
||||
board = pcbnew.LoadBoard(str(board_path))
|
||||
|
||||
stats = {"assigned": 0, "no_truth": 0, "no_ref": 0, "already": 0,
|
||||
"zones_fixed": 0, "zones_skipped": 0}
|
||||
missing_examples: list[str] = []
|
||||
|
||||
# ---- A) 焊盘网络回填 ----
|
||||
if not args.skip_nets:
|
||||
def get_net(name):
|
||||
ni = board.FindNet(name)
|
||||
if ni is None:
|
||||
ni = pcbnew.NETINFO_ITEM(board, name)
|
||||
board.Add(ni)
|
||||
return ni
|
||||
|
||||
for fp in board.GetFootprints():
|
||||
ref = fp.GetReference()
|
||||
for pad in fp.Pads():
|
||||
if pad.GetNetCode() != 0 and pad.GetNetname() != "":
|
||||
stats["already"] += 1
|
||||
continue
|
||||
if not ref:
|
||||
stats["no_ref"] += 1
|
||||
continue
|
||||
net = truth.get((ref, pad.GetNumber()))
|
||||
if not net:
|
||||
stats["no_truth"] += 1
|
||||
if len(missing_examples) < 20:
|
||||
missing_examples.append(f"{ref}.{pad.GetNumber()}")
|
||||
continue
|
||||
if not args.dry_run:
|
||||
pad.SetNet(get_net(net))
|
||||
stats["assigned"] += 1
|
||||
print(f"[nets] 待回填={stats['assigned']} 真值里查不到={stats['no_truth']} "
|
||||
f"无位号={stats['no_ref']} 本来就有网络={stats['already']}")
|
||||
if missing_examples:
|
||||
print(f"[nets] 查不到真值的空网络焊盘举例:{', '.join(missing_examples)}")
|
||||
print(" 多为机械/屏蔽/安装孔焊盘。确认属实的话,按需单独指派 GND。")
|
||||
|
||||
# ---- B) zone 本地 clearance ----
|
||||
if not args.skip_zones:
|
||||
clr = pcbnew.FromMM(args.zone_clearance_mm)
|
||||
for z in board.Zones():
|
||||
if z.GetIsRuleArea() or not z.IsOnCopperLayer():
|
||||
stats["zones_skipped"] += 1
|
||||
continue
|
||||
if not args.dry_run:
|
||||
z.SetLocalClearance(clr)
|
||||
stats["zones_fixed"] += 1
|
||||
print(f"[zones] 铺铜 zone 本地 clearance -> {args.zone_clearance_mm}mm "
|
||||
f"共 {stats['zones_fixed']} 块;跳过 {stats['zones_skipped']} 块(规则区/非铜层)")
|
||||
|
||||
if args.dry_run:
|
||||
print("[dry-run] 没有写盘。去掉 --dry-run 才会真正修改。")
|
||||
else:
|
||||
backup = board_path.with_suffix(board_path.suffix + ".bak_fixpcb")
|
||||
shutil.copy2(board_path, backup)
|
||||
board.Save(str(board_path))
|
||||
print(f"[save] 已写回 {board_path}(原文件备份为 {backup.name})")
|
||||
print("[next] 还要重填铺铜:KiCad GUI 里按 B,或走 IPC 后端。"
|
||||
"大板不要用 SWIG refill,会超时/段错误。")
|
||||
|
||||
if args.report:
|
||||
Path(args.report).write_text(
|
||||
json.dumps({**stats, "zone_clearance_mm": args.zone_clearance_mm,
|
||||
"dry_run": args.dry_run,
|
||||
"no_truth_examples": missing_examples},
|
||||
ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"[report] {args.report}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,223 @@
|
||||
"""netcmp.py — 原理图网表比对(阶段 2)。判定「电气连接是否与 Altium 完全一致」。
|
||||
|
||||
比对方法(两次迁移验证过,别改成比网络名):
|
||||
两个工具的网络命名规则不同,逐名比对必然一片红。真正要比的是 **net partition**:
|
||||
把每份网表归约成「每个引脚 -> 它所在网络的全部节点集合」,两侧的等价类集合相等
|
||||
即电气连接一致。
|
||||
|
||||
三个必须做的归一化,少一个就会出假差异:
|
||||
1. KiCad 网名带前导 '/'(层次路径),Altium 不带 -> 剥掉前导 '/'。
|
||||
2. Altium tabular 导出把隐藏的电源/平面引脚重复列成 net='?' 的行 -> 去掉。
|
||||
3. KiCad 扁平导入后,整体导出网表只能拿到根图的一小部分 -> 必须**逐页**
|
||||
`kicad-cli sch export netlist --format kicadxml` 再按网名合并。
|
||||
(格式必须是 kicadxml;PADS 格式会漏掉单节点网。)
|
||||
|
||||
用法:
|
||||
python netcmp.py --kicad-dir E:\\Kicad_design\\FOO \\
|
||||
--netlist-csv E:\\Kicad_design\\FOO\\_migration\\reference\\netlist_tabular.csv \\
|
||||
--out E:\\Kicad_design\\FOO\\_migration\\netcmp
|
||||
退出码 0 = 100% 一致;1 = 有差异(详情看 diff.json / 终端前 30 条)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import csv
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
COMP_KEYS = ("component", "designator", "ref", "reference")
|
||||
PIN_KEYS = ("pin", "pin_number", "pad", "pin designator")
|
||||
NET_KEYS = ("net", "netname", "net name")
|
||||
|
||||
|
||||
def pick(fieldnames, candidates):
|
||||
lowered = {(f or "").strip().lower(): f for f in fieldnames}
|
||||
for c in candidates:
|
||||
if c in lowered:
|
||||
return lowered[c]
|
||||
return None
|
||||
|
||||
|
||||
def norm_net(name: str | None) -> str | None:
|
||||
"""剥掉 KiCad 层次网名的前导 '/',让两侧命名可比。"""
|
||||
if name is None:
|
||||
return None
|
||||
return name.strip().lstrip("/")
|
||||
|
||||
|
||||
def _env_lookup(key: str, default: str | None = None) -> str:
|
||||
"""环境变量优先,其次 ~/skills_env.md 的 `KEY = value` 行(机器相关参数的真源)。"""
|
||||
v = os.environ.get(key)
|
||||
if v:
|
||||
return v
|
||||
path = os.path.join(os.path.expanduser("~"), "skills_env.md")
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
m = re.match(rf"\s*{key}\s*=\s*(\S+)", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
except OSError:
|
||||
pass
|
||||
if default is None:
|
||||
sys.exit(f"missing {key}: add it to ~/skills_env.md or set it as an env var")
|
||||
return default
|
||||
|
||||
|
||||
def find_kicad_cli(explicit: str | None) -> str:
|
||||
if explicit:
|
||||
return explicit
|
||||
# 安装路径是机器相关的:真源是 ~/skills_env.md 的 KICAD_ROOT。
|
||||
kicad_root = _env_lookup("KICAD_ROOT", "")
|
||||
for cand in (os.environ.get("KICAD_CLI"),
|
||||
os.path.join(kicad_root, "bin", "kicad-cli.exe") if kicad_root else None,
|
||||
"kicad-cli"):
|
||||
if not cand:
|
||||
continue
|
||||
if cand == "kicad-cli" or Path(cand).is_file():
|
||||
return cand
|
||||
return "kicad-cli"
|
||||
|
||||
|
||||
def export_pages(kicad_dir: Path, out_dir: Path, cli: str) -> list[Path]:
|
||||
"""逐页导出 kicadxml。整体导出在扁平结构下会漏页,所以必须一页一页来。"""
|
||||
sheets = sorted(kicad_dir.glob("*.kicad_sch"))
|
||||
if not sheets:
|
||||
raise SystemExit(f"[ERROR] {kicad_dir} 下没有 .kicad_sch")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
made = []
|
||||
for sch in sheets:
|
||||
dst = out_dir / f"xml_{sch.stem}.xml"
|
||||
cmd = [cli, "sch", "export", "netlist", "--format", "kicadxml",
|
||||
"-o", str(dst), str(sch)]
|
||||
# 不能用 text=True:那会按系统区域编码(中文 Windows 是 GBK)解码,
|
||||
# 而 kicad-cli 输出 UTF-8,遇到非 ASCII 直接 UnicodeDecodeError。
|
||||
proc = subprocess.run(cmd, capture_output=True,
|
||||
encoding="utf-8", errors="replace")
|
||||
if proc.returncode != 0 or not dst.is_file():
|
||||
print(f"[WARN] 导出失败 {sch.name}: {proc.stderr.strip()[:200]}")
|
||||
continue
|
||||
made.append(dst)
|
||||
print(f"[export] {len(made)}/{len(sheets)} 页导出成功 -> {out_dir}")
|
||||
return made
|
||||
|
||||
|
||||
def load_altium(csv_path: Path) -> dict[str, str]:
|
||||
with csv_path.open(newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
c_key = pick(reader.fieldnames or [], COMP_KEYS)
|
||||
p_key = pick(reader.fieldnames or [], PIN_KEYS)
|
||||
n_key = pick(reader.fieldnames or [], NET_KEYS)
|
||||
if not (c_key and p_key and n_key):
|
||||
raise SystemExit(f"[ERROR] 认不出 CSV 列名:{reader.fieldnames}(需要 component/pin/net)")
|
||||
real = collections.defaultdict(set)
|
||||
rows = 0
|
||||
for row in reader:
|
||||
rows += 1
|
||||
comp = (row.get(c_key) or "").strip()
|
||||
pin = (row.get(p_key) or "").strip()
|
||||
net = (row.get(n_key) or "").strip()
|
||||
if comp and pin and net and net != "?":
|
||||
real[(comp, pin)].add(norm_net(net))
|
||||
print(f"[altium] 原始 {rows} 行 -> 去 '?' 重复后 {len(real)} 个唯一节点")
|
||||
return {f"{c}.{p}": sorted(v)[0] for (c, p), v in real.items()}
|
||||
|
||||
|
||||
def load_kicad(xml_paths: list[Path]) -> dict[str, str]:
|
||||
nn = collections.defaultdict(set)
|
||||
for fn in xml_paths:
|
||||
root = ET.parse(fn).getroot()
|
||||
for net in root.iter("net"):
|
||||
nm = norm_net(net.get("name"))
|
||||
for nd in net.findall("node"):
|
||||
nn[f"{nd.get('ref')}.{nd.get('pin')}"].add(nm)
|
||||
print(f"[kicad] 合并 {len(xml_paths)} 页 -> {len(nn)} 个节点")
|
||||
# 同一节点在多页上出现在同名网络里是正常的;名字冲突时保留全部以便暴露问题
|
||||
return {nd: (sorted(names)[0] if len(names) == 1 else "|".join(sorted(names)))
|
||||
for nd, names in nn.items()}
|
||||
|
||||
|
||||
def partition(node_net: dict[str, str]):
|
||||
groups = collections.defaultdict(set)
|
||||
for node, net in node_net.items():
|
||||
groups[net].add(node)
|
||||
return {node: frozenset(groups[net]) for node, net in node_net.items()}, groups
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Altium vs KiCad schematic netlist partition compare")
|
||||
ap.add_argument("--kicad-dir", required=True, help="KiCad 工程目录(含 *.kicad_sch)")
|
||||
ap.add_argument("--netlist-csv", required=True, help="Altium tabular 网表真值 CSV")
|
||||
ap.add_argument("--out", required=True, help="输出目录,放 xml_*.xml / diff.json / 报告")
|
||||
ap.add_argument("--kicad-cli", help="kicad-cli 路径,默认自动找")
|
||||
ap.add_argument("--skip-export", action="store_true", help="复用 --out 下已有的 xml_*.xml")
|
||||
args = ap.parse_args()
|
||||
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.skip_export:
|
||||
xmls = [Path(p) for p in sorted(glob.glob(str(out_dir / "xml_*.xml")))]
|
||||
if not xmls:
|
||||
raise SystemExit(f"[ERROR] --skip-export 但 {out_dir} 下没有 xml_*.xml")
|
||||
print(f"[export] 跳过,复用已有 {len(xmls)} 个 xml")
|
||||
else:
|
||||
xmls = export_pages(Path(args.kicad_dir), out_dir, find_kicad_cli(args.kicad_cli))
|
||||
|
||||
node_net_a = load_altium(Path(args.netlist_csv))
|
||||
node_net_k = load_kicad(xmls)
|
||||
|
||||
pa, ga = partition(node_net_a)
|
||||
pk, gk = partition(node_net_k)
|
||||
na, nk = set(node_net_a), set(node_net_k)
|
||||
only_a, only_k = sorted(na - nk), sorted(nk - na)
|
||||
mismatch = sorted(nd for nd in (na & nk) if pa[nd] != pk[nd])
|
||||
|
||||
classes_a = {frozenset(v) for v in ga.values()}
|
||||
classes_k = {frozenset(v) for v in gk.values()}
|
||||
identical = (classes_a == classes_k) and not only_a and not only_k
|
||||
|
||||
print(f"\n节点 Altium={len(na)} KiCad={len(nk)} 只在A={len(only_a)} 只在K={len(only_k)}")
|
||||
print(f"网络 Altium={len(ga)} KiCad={len(gk)} 等价类集合相等={classes_a == classes_k}")
|
||||
print(f"同伴集不同的节点:{len(mismatch)}")
|
||||
for nd in only_a[:15]:
|
||||
print(f" 只在Altium {nd}: net={node_net_a[nd]}")
|
||||
for nd in only_k[:15]:
|
||||
print(f" 只在KiCad {nd}: net={node_net_k[nd]}")
|
||||
for nd in mismatch[:30]:
|
||||
am, km = pa[nd] - {nd}, pk[nd] - {nd}
|
||||
print(f" {nd}: A={node_net_a[nd]} K={node_net_k[nd]} | "
|
||||
f"只A的同伴:{sorted(am - km)[:5]} 只K的同伴:{sorted(km - am)[:5]}")
|
||||
|
||||
diff_path = out_dir / "diff.json"
|
||||
diff_path.write_text(json.dumps({
|
||||
"identical": identical,
|
||||
"altium_nodes": len(na), "kicad_nodes": len(nk),
|
||||
"altium_nets": len(ga), "kicad_nets": len(gk),
|
||||
"onlyA_nodes": only_a, "onlyK_nodes": only_k,
|
||||
"mismatch_nodes": mismatch,
|
||||
"onlyA_classes": len(classes_a - classes_k),
|
||||
"onlyK_classes": len(classes_k - classes_a),
|
||||
}, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
|
||||
print(f"\n[diff] {diff_path}")
|
||||
print(f"identical: {identical}")
|
||||
if not identical:
|
||||
print("差异不一定是真错:先确认 KiCad 网名前导 '/' 已剥、Altium 的 '?' 行已去,"
|
||||
"再看是不是某页 kicadxml 没导出成功(上面 [export] 的比例)。")
|
||||
return 0 if identical else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,208 @@
|
||||
"""stage_ascii.py — convert_ascii 这一步的机械部分(阶段 1.2)。
|
||||
|
||||
做三件确定性的事,剩下的「在 Altium GUI 里跑脚本」只能人来点:
|
||||
stage 把整个 Altium 工程复制到 <工程目录>_ascii staging,并按实际枚举到的
|
||||
.SchDoc 生成 convert_to_ascii.pas(DelphiScript 的 FindFirst 在
|
||||
Altium 里枚举不可靠,所以文件名必须在生成期硬编码进脚本)。
|
||||
verify 检查 staging 里每个 .SchDoc 的文件头,判断是否真的转成了 ASCII。
|
||||
ASCII 版以 "|HEADER=Protel for Windows" 开头;AD26 二进制是 CFB
|
||||
复合文档,以 D0 CF 11 E0 开头 —— 后者会让 KiCad 导入器报
|
||||
"Header already parsed" 而失败。
|
||||
|
||||
原始工程绝不改动:所有操作只作用于 staging 副本。
|
||||
|
||||
用法:
|
||||
python stage_ascii.py stage --source-prj E:\\Altium_design\\FOO\\FOO.PrjPCB \\
|
||||
--migration-dir E:\\Kicad_design\\FOO\\_migration
|
||||
python stage_ascii.py verify --staging-dir E:\\Altium_design\\FOO_ascii
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ASCII_MAGIC = b"|HEADER=Protel for Windows"
|
||||
CFB_MAGIC = b"\xd0\xcf\x11\xe0"
|
||||
|
||||
PAS_TEMPLATE = """{{ convert_to_ascii.pas — 由 stage_ascii.py 生成,请勿手改文件列表。
|
||||
|
||||
把 staging 副本里的 {count} 个 .SchDoc 全部另存为 ASCII 格式,绕开 KiCad 导入器
|
||||
对 AD26 二进制原理图的 "Header already parsed" 失败。
|
||||
|
||||
运行方式(必须在 Altium GUI 内手动跑,不要经 eda-agent/MCP 驱动——
|
||||
MCP 的 StartMCPServer 与本脚本会互相冲突挂起):
|
||||
先停掉 eda-agent -> Altium: File > Run Script > 选本文件 > ConvertSchToAscii > Run
|
||||
|
||||
只作用于 staging:{dir}
|
||||
原始工程不受影响。
|
||||
}}
|
||||
|
||||
Procedure ConvertSchToAscii;
|
||||
Var
|
||||
Names : TStringList;
|
||||
LogList : TStringList;
|
||||
Dir : String;
|
||||
Path : String;
|
||||
SD : IServerDocument;
|
||||
i : Integer;
|
||||
Found : Integer;
|
||||
Failed : Integer;
|
||||
Begin
|
||||
Dir := '{dir}\\';
|
||||
|
||||
Names := TStringList.Create;
|
||||
{names}
|
||||
LogList := TStringList.Create;
|
||||
Found := 0;
|
||||
Failed := 0;
|
||||
|
||||
For i := 0 To Names.Count - 1 Do
|
||||
Begin
|
||||
Path := Dir + Names[i];
|
||||
Try
|
||||
SD := Client.OpenDocument('SCH', Path);
|
||||
If SD <> Nil Then
|
||||
Begin
|
||||
Client.ShowDocument(SD);
|
||||
SD.SetModified(True);
|
||||
SD.DoFileSave('ASCII');
|
||||
Client.CloseDocument(SD);
|
||||
LogList.Add('OK ' + Names[i]);
|
||||
Inc(Found);
|
||||
End
|
||||
Else
|
||||
Begin
|
||||
LogList.Add('FAIL(open) ' + Names[i]);
|
||||
Inc(Failed);
|
||||
End;
|
||||
Except
|
||||
LogList.Add('FAIL(exc) ' + Names[i]);
|
||||
Inc(Failed);
|
||||
End;
|
||||
End;
|
||||
|
||||
LogList.Insert(0, 'ASCII conversion: ' + IntToStr(Found) + ' OK, ' + IntToStr(Failed) + ' failed');
|
||||
LogList.SaveToFile('{log}');
|
||||
Names.Free;
|
||||
ShowMessage('ASCII done: ' + IntToStr(Found) + ' OK, ' + IntToStr(Failed) + ' failed');
|
||||
LogList.Free;
|
||||
End;
|
||||
"""
|
||||
|
||||
|
||||
def sch_docs(folder: Path) -> list[Path]:
|
||||
"""staging 里的原理图页,按名字排序(含子目录,Altium 工程偶尔分目录放)。"""
|
||||
return sorted(folder.rglob("*.SchDoc"), key=lambda p: str(p).lower())
|
||||
|
||||
|
||||
def cmd_stage(args) -> int:
|
||||
source = Path(args.source_prj)
|
||||
if not source.is_file():
|
||||
print(f"[ERROR] 源工程不存在:{source}")
|
||||
return 1
|
||||
src_dir = source.parent
|
||||
staging = Path(args.staging_dir) if args.staging_dir else src_dir.parent / (src_dir.name + "_ascii")
|
||||
migration = Path(args.migration_dir)
|
||||
migration.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if staging.exists():
|
||||
if not args.force:
|
||||
print(f"[ERROR] staging 已存在:{staging}\n"
|
||||
f" 加 --force 覆盖重建(会先删除该目录),或用 --staging-dir 换个位置。")
|
||||
return 1
|
||||
print(f"[INFO] --force:删除已有 staging {staging}")
|
||||
shutil.rmtree(staging)
|
||||
|
||||
print(f"[COPY] {src_dir} -> {staging}")
|
||||
shutil.copytree(src_dir, staging)
|
||||
|
||||
docs = sch_docs(staging)
|
||||
if not docs:
|
||||
print(f"[ERROR] staging 里没找到任何 .SchDoc:{staging}")
|
||||
return 1
|
||||
|
||||
names = "".join(
|
||||
f" Names.Add('{d.relative_to(staging).as_posix().replace('/', chr(92))}');\n"
|
||||
for d in docs
|
||||
)
|
||||
log_path = migration / "ascii_convert_log.txt"
|
||||
# DelphiScript 的字符串字面量不做反斜杠转义,路径按原样写进去
|
||||
pas = PAS_TEMPLATE.format(count=len(docs), dir=str(staging), names=names,
|
||||
log=str(log_path))
|
||||
pas_path = migration / "convert_to_ascii.pas"
|
||||
pas_path.write_text(pas, encoding="utf-8")
|
||||
|
||||
print(f"[SCHDOC] 枚举到 {len(docs)} 张原理图页:")
|
||||
for d in docs:
|
||||
print(f" {d.relative_to(staging)}")
|
||||
print(f"[SCRIPT] {pas_path}")
|
||||
print(f"[LOG] 脚本跑完会写到 {log_path}")
|
||||
print(f"[STAGING] {staging}")
|
||||
print("\n下一步(必须人工在 Altium GUI 内做,不要走 MCP):"
|
||||
"\n 1. 停掉 eda-agent(避免与 StartMCPServer 脚本冲突挂起)"
|
||||
f"\n 2. Altium: File > Run Script > 选 {pas_path} > ConvertSchToAscii > Run"
|
||||
"\n 3. 回来跑 `python stage_ascii.py verify --staging-dir \"%s\"` 确认" % staging)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_verify(args) -> int:
|
||||
staging = Path(args.staging_dir)
|
||||
if not staging.is_dir():
|
||||
print(f"[ERROR] staging 目录不存在:{staging}")
|
||||
return 1
|
||||
docs = sch_docs(staging)
|
||||
if not docs:
|
||||
print(f"[ERROR] staging 里没有 .SchDoc:{staging}")
|
||||
return 1
|
||||
|
||||
ascii_n, binary_n, unknown = 0, 0, []
|
||||
for d in docs:
|
||||
head = d.open("rb").read(64)
|
||||
if head.startswith(ASCII_MAGIC):
|
||||
ascii_n += 1
|
||||
elif head.startswith(CFB_MAGIC):
|
||||
binary_n += 1
|
||||
unknown.append((d, "binary CFB — 未转换"))
|
||||
else:
|
||||
unknown.append((d, f"未知文件头 {head[:12]!r}"))
|
||||
|
||||
print(f"[VERIFY] {staging}")
|
||||
print(f" ASCII: {ascii_n} / {len(docs)}")
|
||||
for d, why in unknown:
|
||||
print(f" [!] {d.relative_to(staging)} {why}")
|
||||
|
||||
if ascii_n == len(docs):
|
||||
print("[OK] 全部原理图已是 ASCII,可以进 KiCad 导入了。")
|
||||
return 0
|
||||
print("[FAIL] 还有页没转成 ASCII —— 直接导入会报 'Header already parsed'。"
|
||||
"\n 回 Altium 重跑 convert_to_ascii.pas(注意 eda-agent 要停掉)。")
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("stage", help="复制到 _ascii staging 并生成 convert_to_ascii.pas")
|
||||
p.add_argument("--source-prj", required=True)
|
||||
p.add_argument("--migration-dir", required=True)
|
||||
p.add_argument("--staging-dir", help="默认 <工程目录>_ascii,与原工程同级")
|
||||
p.add_argument("--force", action="store_true", help="staging 已存在时先删除重建")
|
||||
p.set_defaults(func=cmd_stage)
|
||||
|
||||
p = sub.add_parser("verify", help="检查 staging 里 .SchDoc 是否都成了 ASCII")
|
||||
p.add_argument("--staging-dir", required=True)
|
||||
p.set_defaults(func=cmd_verify)
|
||||
|
||||
args = ap.parse_args()
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user