tools/epro2/kicad: Phase-1 EPRO2 → KiCad schematic exporter

写第一版 EPRO2 → .kicad_sch 转换:把 SCH_PAGE Document 的 wires +
COMPONENT placements + TEXT 输出到一个可被 KiCad 7+ 打开的 sch 文件。
不含 symbol 主体(lib_symbols 留空 stub),所以 KiCad 里组件会渲染
成红色 "?" 占位,但布线 + 位置 + Designator/Value 属性都正确。完整
symbol 库导出留 Phase 2。

模块结构:
  tools/epro2/kicad/sexpr.py        手写 S-expr emitter,Sym 标记裸符号,
                                    str 自动加引号 + 转义;float 去尾零;
                                    bool→yes/no;NaN/Inf 主动报错
  tools/epro2/kicad/_sexpr_reader.py  极简 S-expr parser,仅给 round-trip
                                    测试用(非完整 KiCad reader)
  tools/epro2/kicad/sch_writer.py   write_sch_page(doc) → str;处理:
                                      LINE  → (wire (pts ...) ...)
                                      COMPONENT → (symbol (lib_id facere:<partId>)
                                                  (at x y rot) (property Reference ...) ...)
                                      TEXT  → (text "..." (at ...))
                                    单位 mil → mm × 0.0254;零长 wire 跳过
  tools/epro2/kicad/__main__.py     CLI: --doc <uuid> | --all-sch

ESP-VoCat 验证(python -m tools.epro2.kicad <project> --all-sch):
  9 SCH_PAGE 全部转换成功
  P1_408c9f4f.kicad_sch    wires=  6  symbols= 10  text=  0  skipped= 2  (370 lines)
  P1_ee409917.kicad_sch    wires= 20  symbols= 14  text=  0  skipped= 3
  P1_54743d77.kicad_sch    wires= 42  symbols= 30  text=  3
  Overview_dc13d6d2.kicad_sch wires=  0  symbols=  1  text= 34   (说明页)
  MCU_510cff33.kicad_sch   wires= 91  symbols= 86  text=  9
  Interface_b336a7c7.kicad_sch wires= 99  symbols= 95  text=  6
  P1_5c38f45b.kicad_sch    wires=179  symbols= 86  text=  9
  P1_45092758.kicad_sch    wires=187  symbols=138  text= 10  (主图)
  codec_0b0163fa.kicad_sch wires=190  symbols=112  text= 10

输出落在 data/processed/kicad_sch/<filename>.kicad_sch(gitignore 内,
可重新生成;不入库)。

测试:6 个 sexpr 测 + 6 个 sch_writer 测,含 round-trip parse 验证。
parser/relations/project_relations 的旧 21 个不动,合计 **33/33 通过**。

下一步:
1. Phase 2 — symbol library 导出 (.kicad_sym),把 SYMBOL doc 的 PIN/RECT/
   TEXT primitives 转 KiCad symbol 主体;填 lib_symbols 块让组件渲染
   出真正的 schematic 符号
2. footprint library + .kicad_pcb 导出
3. 用 KiCad CLI (kicad-cli sch erc) 跑 ERC 校验

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:29:15 +08:00
parent 3052e42991
commit 9213429a57
7 changed files with 615 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
"""CLI: convert EPRO2 SCH_PAGE docs to KiCad ``.kicad_sch`` files.
Usage:
uv run python -m tools.epro2.kicad <project_dir> --doc <sch_uuid> --out <dir>
uv run python -m tools.epro2.kicad <project_dir> --all-sch --out <dir>
The ``--all-sch`` form converts every SCH_PAGE in the project, naming each
output by its document title (or doc_uuid prefix as fallback).
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from ..replay import Project, replay_project
from .sch_writer import write_sch_page
_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._\-一-鿿]+")
def _safe_filename(s: str) -> str:
s = _SAFE_CHARS.sub("_", s).strip("_")
return s or "untitled"
def _convert_one(proj: Project, doc_uuid: str, out_dir: Path) -> Path:
if doc_uuid not in proj.documents:
candidates = [u for u in proj.documents if u.startswith(doc_uuid)]
if len(candidates) != 1:
raise SystemExit(f"no unique match for {doc_uuid!r} (candidates: {candidates[:5]})")
doc_uuid = candidates[0]
doc = proj.documents[doc_uuid]
if doc.doc_type != "SCH_PAGE":
raise SystemExit(f"doc {doc_uuid} is {doc.doc_type!r}, not SCH_PAGE")
text = write_sch_page(doc)
title = (doc.objects.get("META") or {}).get("title") or doc_uuid[:12]
out_path = out_dir / f"{_safe_filename(title)}_{doc_uuid[:8]}.kicad_sch"
out_path.write_text(text, encoding="utf-8")
stats = getattr(write_sch_page, "last_stats", None)
if stats:
print(
f" {out_path.name}: wires={stats.wires} symbols={stats.symbol_placements} "
f"text={stats.text} skipped={stats.skipped}"
)
return out_path
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic exporter (Phase 1)")
ap.add_argument("project_dir", type=Path)
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--doc", help="SCH_PAGE doc uuid (or unique prefix) to convert")
g.add_argument("--all-sch", action="store_true", help="convert every SCH_PAGE")
ap.add_argument("--out", type=Path, default=Path("data/processed/kicad_sch"))
args = ap.parse_args(argv)
proj = replay_project(args.project_dir)
args.out.mkdir(parents=True, exist_ok=True)
if args.doc:
_convert_one(proj, args.doc, args.out)
return 0
targets = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
if not targets:
print("no SCH_PAGE docs in this project", file=sys.stderr)
return 1
print(f"Converting {len(targets)} SCH_PAGE docs → {args.out}")
for u in targets:
try:
_convert_one(proj, u, args.out)
except Exception as e: # noqa: BLE001
print(f" FAIL {u[:12]}: {e}", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())