tools/epro2/kicad: --all emits paired .kicad_pro + .kicad_sch + .kicad_pcb per BOARD

KiCad pairs project files purely by basename + same directory: a folder
holding `Foo.kicad_pro`, `Foo.kicad_sch`, `Foo.kicad_pcb` opens as one
project on double-click of the .kicad_pro, with cross-tool navigation
(open footprint from schematic etc) wired up automatically.

  - pro_writer.write_kicad_pro() renders the minimal KiCad 8 JSON we
    need: meta.filename pinning the basename, sheets=[[<root_uuid>,
    ""]] binding the schematic root, and stub blocks for board /
    schematic / net_settings / erc that KiCad expects to find on the
    first GUI load.
  - root_sch_writer.write_root_sheet() now accepts an optional
    root_uuid so the caller can pass the same uuid into the .kicad_pro
    and .kicad_sch (the binding fails silently with mismatched ids).
  - CLI gains `--all`: groups SCH/PCB docs by their META.board uuid
    (1:1 in EPRO2), strips SCH-/PCB- editor prefixes from titles to
    derive a shared project basename, and emits one directory per
    BOARD with paired files. BOARDs whose SCH is DELETE_DOC (LCD-BD on
    ESP-VoCat) still get a .kicad_pro with sheets:[] + .kicad_pcb so
    pcbnew opens cleanly.

ESP-VoCat smoke: 6 boards → 6 project dirs, all pairs validated by
kicad-cli sch erc / pcb export svg. The CoreBoard pro/sch/pcb trio
shares root uuid 366d3e53...c2fccbe4330b end-to-end.

68 → 71 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:39:58 +08:00
parent adc5dc5e1b
commit 3c00edf6db
5 changed files with 356 additions and 2 deletions

View File

@@ -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-<board>-Vx`` /
``PCB-<board>-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": <SCH doc or None>,
"pcb": <PCB doc or None>,
"title": <board name>}}``.
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: <out>/<basename>/{<basename>.kicad_pro,
<basename>.kicad_sch (root), <basename>.kicad_pcb, <child pages>...}.
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

View File

@@ -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)

View File

@@ -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],