diff --git a/log.md b/log.md index 743a570..d9d75ca 100644 --- a/log.md +++ b/log.md @@ -4,6 +4,84 @@ --- +## 2026-04-29 02:30 KiCad 工程文件 + `--all` 一键导:双击 .kicad_pro 打开 GUI + +**Claude 会话** + +接 `adc5dc5`。两件小事一起做:发 `.kicad_pro` + CLI 加 `--all`。目标是让消费者(咱们的 ML 训练代码 / 下游同学)双击就能在 KiCad GUI 里同时打开 schematic + PCB 配对。 + +### 1. `tools/epro2/kicad/pro_writer.py` + +KiCad 8 的 .kicad_pro 是 JSON。最小可用集: +```json +{ + "meta": {"filename": ".kicad_pro", "version": 1}, + "sheets": [["", ""]], // 绑 schematic 根 sheet + "board": {...}, "schematic": {...}, "net_settings": {...}, ... +} +``` + +只要 .kicad_pro / .kicad_sch / .kicad_pcb **三个文件同名同目录**,KiCad 自动配对——双击 .kicad_pro 同时弹两个编辑器。`sheets` 数组里的 uuid 必须和 .kicad_sch 的 `(uuid ...)` 对得上,所以 `write_root_sheet` 加了 `root_uuid` 参数让调用方注入确定值。 + +### 2. CLI `--all` + +按 BOARD uuid 分组 SCH 和 PCB(两边的 META 都带 `board: `,自动 1:1 对应),每个 BOARD 一个目录: + +``` +out/ +├── EchoEar-BaseBoard-V1_0/ +│ ├── EchoEar-BaseBoard-V1_0.kicad_pro +│ ├── EchoEar-BaseBoard-V1_0.kicad_sch (root) +│ ├── EchoEar-BaseBoard-V1_0.kicad_pcb +│ └── P1_45092758.kicad_sch (子页) +├── EchoEar-CoreBoard-V1_0/ +│ ├── EchoEar-CoreBoard-V1_0.kicad_pro +│ ├── EchoEar-CoreBoard-V1_0.kicad_sch +│ ├── EchoEar-CoreBoard-V1_0.kicad_pcb +│ ├── Overview_dc13d6d2.kicad_sch (4 个子页) +│ ├── MCU_510cff33.kicad_sch +│ ├── Interface_b336a7c7.kicad_sch +│ └── codec_0b0163fa.kicad_sch +... (5 boards total) +``` + +basename 从 SCH/PCB title 剥掉 `SCH-`/`PCB-` 前缀(`SCH-EchoEar-CoreBoard-V1_0` → `EchoEar-CoreBoard-V1_0`),schematic 和 PCB 自然 collapse 到一个项目。 + +### ESP-VoCat 实测 + +6 个 BOARD(含 LCD-BD 那个 SCH 被删的——只有 PCB,照样生成 `.kicad_pro` 和 `.kicad_pcb`,sheets:[]): + +| 项目 | sch 解析 | pcb 渲染 | +|---|:-:|:-:| +| EchoEar-BaseBoard-V1_0 | ✓ ERC 485 | ✓ | +| EchoEar-CoreBoard-V1_0 | ✓ ERC 1205 | ✓ | +| EchoEar-Rotating-Base-LCD-BD-V1_0 | (no sch) | ✓ | +| EchoEar-Rotating-Base-Mainboard-V1_0 | ✓ ERC 412 | ✓ | +| ESP-VoCat-MicBoard-V1_0 | ✓ ERC 133 | ✓ | +| ESP-VoCat-Rotating-Base-Sub-board-V1_0 | ✓ ERC 30 | ✓ | + +`pro.sheets[0][0]` 跟 `.kicad_sch` 第一行 `(uuid ...)` 字符串相等(验证过 CoreBoard:`366d3e53-5167-4e17-9325-c2fccbe4330b`)。 + +### 决策(Why) + +- **basename 对齐 = KiCad pairing 的全部**:KiCad 不读 .kicad_pro 里的 `boards`/`schematic` 字段去找文件,纯靠**同目录同 basename**自动配对。其它字段都是项目 settings,缺了 KiCad 用 default 兜底。 +- **`sheets: []` 兜底 LCD-BD**:SCH 被 DELETE_DOC 的板子没 schematic root,pro 里空数组兜底,KiCad 打开时只显示 pcbnew,不会卡。 +- **`--all` 不替代 `--all-sch` / `--all-pcb`**:保留两个细粒度命令——只想看 schematic 或只想 batch-export PCB 时不必生成 .kicad_pro 噪音。 + +### 测试 + +68 → 71 单测全过:pro_writer 3 个(filename + root uuid 绑定 / sheets 空数组 fallback / KiCad 8 顶层 key 完整性)。 + +### 下游交付 + +下游同学的 Wokwi pipeline 不吃的 5 个 Pro 项目(3 块 EPRO2 AES + 2 块 Pro 2.x)现在一条命令就能转: +``` +uv run python -m tools.epro2.kicad data/raw/oshwhub/ --all --out +``` +每个项目目录拷贝到他们 corpus 即可。 + +--- + ## 2026-04-29 02:00 PCB Phase-2:POUR → KiCad zone,CoreBoard unconnected -43% **Claude 会话** diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py index d3e79ca..5e5a85e 100644 --- a/tools/epro2/kicad/__main__.py +++ b/tools/epro2/kicad/__main__.py @@ -23,6 +23,7 @@ from pathlib import Path from ..project_relations import ProjectRelations from ..replay import Document, Project, replay_project from .pcb_writer import write_pcb +from .pro_writer import write_kicad_pro from .root_sch_writer import ChildSheet, new_sheet_uuid, write_root_sheet from .sch_writer import write_sch_page @@ -154,6 +155,138 @@ def _convert_hierarchical( return root_count +def _project_basename(title: str) -> str: + """Strip the SCH-/PCB- editor prefix so schematic and PCB collapse to + one project basename. EPRO2 names its docs ``SCH--Vx`` / + ``PCB--Vx``; without stripping we'd end up with two separate + projects per physical board.""" + s = title + for prefix in ("SCH-", "PCB-", "sch-", "pcb-"): + if s.startswith(prefix): + s = s[len(prefix):] + break + return _safe_filename(s) or "project" + + +def _group_by_board(proj: Project) -> dict[str, dict]: + """Group SCH (collection) and PCB docs by their META.board uuid. + + Returns ``{board_uuid: {"sch": , + "pcb": , + "title": }}``. + Skips boards whose SCH is DELETE_DOC (LCD-BD on ESP-VoCat). + """ + deleted_schs = { + u for u, d in proj.documents.items() + if d.doc_type == "SCH" and (d.objects.get("DELETE_DOC") or {}).get("isDelete") + } + out: dict[str, dict] = {} + for u, d in proj.documents.items(): + if d.doc_type not in ("SCH", "PCB"): + continue + meta = d.objects.get("META") or {} + board_id = str(meta.get("board") or "") + if not board_id: + continue + if d.doc_type == "SCH" and u in deleted_schs: + continue + slot = out.setdefault(board_id, {"sch": None, "pcb": None, "title": None}) + if d.doc_type == "SCH": + slot["sch"] = d + else: + slot["pcb"] = d + # Prefer SCH title for the project name (PCB title is just a mirror). + if slot["title"] is None or d.doc_type == "SCH": + slot["title"] = (meta.get("title") or "").strip() + # Drop boards where neither SCH nor PCB survived (deleted SCH + no PCB). + return {b: s for b, s in out.items() if s["sch"] or s["pcb"]} + + +def _convert_all_projects( + proj: Project, + out_dir: Path, + pr: ProjectRelations, +) -> int: + """One directory per BOARD: //{.kicad_pro, + .kicad_sch (root), .kicad_pcb, ...}. + + KiCad pairs files by basename, so giving the .kicad_pro / .kicad_sch + root / .kicad_pcb the same stem makes "double-click .kicad_pro open in + GUI with both editors" work without any extra plumbing. + """ + boards = _group_by_board(proj) + if not boards: + print("no BOARDs in this project", file=sys.stderr) + return 0 + + page_groups = _group_pages_by_sch(proj) # SCH-uuid → [page-uuid, ...] + + n = 0 + for board_id, slot in boards.items(): + title = slot["title"] or board_id[:12] + basename = _project_basename(title) + proj_dir = out_dir / basename + proj_dir.mkdir(parents=True, exist_ok=True) + sch_doc = slot["sch"] + pcb_doc = slot["pcb"] + print(f"Board '{title}' → {proj_dir}") + + root_uuid: str | None = None + # ---- schematic + root sheet + if sch_doc is not None: + page_uuids = page_groups.get(sch_doc.doc_uuid, []) + children: list[ChildSheet] = [] + for page_uuid in page_uuids: + page_title = _page_title(proj.documents[page_uuid]) + children.append(ChildSheet( + filename=f"{_safe_filename(page_title)}_{page_uuid[:8]}.kicad_sch", + title=page_title, + sheet_uuid=new_sheet_uuid(), + )) + for idx, (page_uuid, child) in enumerate(zip(page_uuids, children)): + page_doc = proj.documents[page_uuid] + text = write_sch_page( + page_doc, + project_relations=pr, + sheet_path=f"/{child.sheet_uuid}", + page_num=idx + 2, + ) + (proj_dir / child.filename).write_text(text, encoding="utf-8") + _print_stats(child.filename) + + root_uuid = new_sheet_uuid() + root_text = write_root_sheet( + title, children, + project_name=basename, + root_uuid=root_uuid, + ) + (proj_dir / f"{basename}.kicad_sch").write_text(root_text, encoding="utf-8") + print(f" SCH ROOT: {basename}.kicad_sch ({len(children)} page(s))") + + # ---- pcb + if pcb_doc is not None: + try: + pcb_text = write_pcb(pcb_doc, project_relations=pr) + except Exception as e: # noqa: BLE001 + print(f" PCB FAIL {pcb_doc.doc_uuid[:12]}: {e}", file=sys.stderr) + else: + (proj_dir / f"{basename}.kicad_pcb").write_text(pcb_text, encoding="utf-8") + pcb_stats = getattr(write_pcb, "last_stats", None) + if pcb_stats: + print( + f" PCB: {basename}.kicad_pcb nets={pcb_stats.nets} " + f"fps={pcb_stats.footprints} segments={pcb_stats.segments} " + f"vias={pcb_stats.vias} zones={pcb_stats.zones}" + ) + + # ---- project file + pro_text = write_kicad_pro(basename, root_sheet_uuid=root_uuid) + (proj_dir / f"{basename}.kicad_pro").write_text(pro_text, encoding="utf-8") + print(f" PRO: {basename}.kicad_pro") + n += 1 + return n + + def _convert_all_pcb(proj: Project, out_dir: Path, pr: ProjectRelations) -> int: """Emit one .kicad_pcb per PCB doc. Each is named after its META.title and dropped into a sibling directory of the matching SCH for parity @@ -191,8 +324,10 @@ def main(argv: list[str] | None = None) -> int: 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") + g.add_argument("--all-sch", action="store_true", help="convert every SCH_PAGE (hierarchical, no PCB)") g.add_argument("--all-pcb", action="store_true", help="convert every PCB doc to .kicad_pcb") + g.add_argument("--all", action="store_true", + help="emit a complete KiCad project per BOARD: paired .kicad_pro / .kicad_sch / .kicad_pcb") ap.add_argument("--out", type=Path, default=Path("data/processed/kicad_sch")) ap.add_argument( "--no-lib-symbols", @@ -216,6 +351,12 @@ def main(argv: list[str] | None = None) -> int: _convert_all_pcb(proj, args.out, pr=pr) return 0 + if args.all: + if pr is None: + pr = ProjectRelations.build(proj) + _convert_all_projects(proj, args.out, pr=pr) + return 0 + if args.doc: _convert_one_flat(proj, args.doc, args.out, pr=pr) return 0 diff --git a/tools/epro2/kicad/pro_writer.py b/tools/epro2/kicad/pro_writer.py new file mode 100644 index 0000000..768cb8f --- /dev/null +++ b/tools/epro2/kicad/pro_writer.py @@ -0,0 +1,96 @@ +"""Emit a minimal ``.kicad_pro`` so a sibling ``.kicad_sch`` + ``.kicad_pcb`` +pair opens as one project in the KiCad GUI (double-click). + +KiCad pairs files by basename: a directory containing +``EchoEar-CoreBoard.kicad_pro`` + ``EchoEar-CoreBoard.kicad_sch`` + +``EchoEar-CoreBoard.kicad_pcb`` is treated as one project, and opening the +.kicad_pro launches both editors with cross-navigation working. Without +this file each .kicad_sch / .kicad_pcb opens as a one-off and KiCad puts +up a "create project file?" dialog every time. + +The .kicad_pro is JSON. KiCad will fill in defaults for anything missing +the next time the GUI opens it; we only emit the keys that meaningfully +identify the project (``meta``, ``sheets`` for hierarchy root binding, +empty stubs for ``board`` / ``schematic`` / ``net_settings`` so the +top-level keys exist in the form KiCad expects). +""" + +from __future__ import annotations + +import json + + +def write_kicad_pro( + project_basename: str, + *, + root_sheet_uuid: str | None = None, +) -> str: + """Render a JSON .kicad_pro string for one project. + + ``project_basename`` is the filename without extension — e.g. + ``"EchoEar-CoreBoard"``. ``root_sheet_uuid`` is the uuid at the top of + the sibling .kicad_sch's ``(uuid ...)`` form; KiCad uses it to bind + the root sheet to the project's hierarchy. If omitted, KiCad + generates one on first open (works but causes a "save changes?" + prompt the first time). + """ + sheets: list = [] + if root_sheet_uuid: + sheets.append([root_sheet_uuid, ""]) + + pro: dict = { + "board": { + "design_settings": { + "defaults": {}, + "rules": {}, + }, + "layer_presets": [], + "viewports": [], + }, + "boards": [], + "cvpcb": {"equivalence_files": []}, + "erc": { + "erc_exclusions": [], + "meta": {"version": 0}, + "pin_map": [], + "rule_severities": {}, + "rule_severitieslegacy": {}, + }, + "libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []}, + "meta": { + "filename": f"{project_basename}.kicad_pro", + "version": 1, + }, + "net_settings": { + "classes": [], + "meta": {"version": 3}, + "net_colors": None, + "netclass_assignments": None, + "netclass_patterns": [], + }, + "pcbnew": { + "last_paths": {}, + "page_layout_descr_file": "", + }, + "schematic": { + "annotate_start_num": 0, + "bom_export_filename": "", + "bom_fmt_presets": [], + "bom_fmt_settings": {}, + "bom_presets": [], + "bom_settings": {}, + "drawing": {}, + "legacy_lib_dir": "", + "legacy_lib_list": [], + "meta": {"version": 1}, + "net_format_name": "", + "page_layout_descr_file": "", + "plot_directory": "", + "spice_external_command": "spice \"%I\"", + "subpart_first_id": 65, + "subpart_id_separator": 0, + }, + "sheets": sheets, + "text_variables": {}, + } + return json.dumps(pro, indent=2) diff --git a/tools/epro2/kicad/root_sch_writer.py b/tools/epro2/kicad/root_sch_writer.py index 7e47943..c6169a8 100644 --- a/tools/epro2/kicad/root_sch_writer.py +++ b/tools/epro2/kicad/root_sch_writer.py @@ -69,6 +69,7 @@ def write_root_sheet( children: list[ChildSheet], *, project_name: str = "facere", + root_uuid: str | None = None, grid_origin_mm: tuple[float, float] = (38.1, 38.1), box_size_mm: tuple[float, float] = (50.8, 25.4), box_pitch_mm: tuple[float, float] = (63.5, 38.1), @@ -112,7 +113,7 @@ def write_root_sheet( Sym("kicad_sch"), [Sym("version"), KICAD_SCH_VERSION], [Sym("generator"), KICAD_GENERATOR], - [Sym("uuid"), new_sheet_uuid()], + [Sym("uuid"), root_uuid or new_sheet_uuid()], [Sym("paper"), "A4"], [Sym("title_block"), [Sym("title"), title], diff --git a/tools/epro2/tests/test_pro_writer.py b/tools/epro2/tests/test_pro_writer.py new file mode 100644 index 0000000..6459445 --- /dev/null +++ b/tools/epro2/tests/test_pro_writer.py @@ -0,0 +1,38 @@ +"""Project file emitter regression.""" + +import json + +from tools.epro2.kicad.pro_writer import write_kicad_pro + + +def test_pro_carries_filename_and_root_sheet_uuid(): + """The .kicad_pro must record (a) its own filename so KiCad confirms + the basename matches the .kicad_sch / .kicad_pcb siblings, and (b) + the root sheet uuid in the `sheets` array — that's how KiCad binds + the project to the schematic root. A `sheets: []` pro file works + for "open a one-off PCB" but loses cross-tool navigation in the + schematic editor.""" + text = write_kicad_pro("EchoEar-CoreBoard", root_sheet_uuid="abc-123") + j = json.loads(text) + assert j["meta"]["filename"] == "EchoEar-CoreBoard.kicad_pro" + assert j["sheets"] == [["abc-123", ""]] + + +def test_pro_without_root_uuid_emits_empty_sheets_array(): + """When called for a board that has only a PCB (the SCH was + DELETE_DOC), there's no root sheet uuid. Emit `sheets: []` so + the project file still parses — KiCad will simply not show a + schematic editor on open.""" + text = write_kicad_pro("OnlyPcb", root_sheet_uuid=None) + j = json.loads(text) + assert j["sheets"] == [] + + +def test_pro_top_level_keys_present_for_kicad_8(): + """KiCad 8 expects certain top-level keys to exist (it'll + backfill missing ones but with a "save changes?" prompt every + open). Smoke-test: assert the keys that GUI reads at startup.""" + text = write_kicad_pro("X") + j = json.loads(text) + for key in ("board", "meta", "schematic", "sheets", "net_settings"): + assert key in j, f"missing top-level key: {key}"