"""CLI: convert EPRO2 SCH_PAGE docs to KiCad ``.kicad_sch`` files. Usage: uv run python -m tools.epro2.kicad --doc --out uv run python -m tools.epro2.kicad --all-sch --out uv run python -m tools.epro2.kicad --all-sch --flat --out By default ``--all-sch`` produces a hierarchical project: pages are grouped by their parent SCH (``META.schematic``) and each group gets a directory with a root ``.kicad_sch`` plus its child pages. ``kicad-cli sch erc`` on the root then resolves cross-page nets via ``(global_label)``. Pass ``--flat`` to fall back to one file per page in a single directory (the old behaviour, still useful when you only care about a single page). """ from __future__ import annotations import argparse import re import sys 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 _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 _page_title(doc: Document) -> str: return (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12] def _print_stats(name: str) -> None: stats = getattr(write_sch_page, "last_stats", None) if stats: print( f" {name}: wires={stats.wires} symbols={stats.symbol_placements} " f"text={stats.text} labels={stats.labels} skipped={stats.skipped} " f"lib_emb={stats.lib_symbols_embedded} lib_miss={stats.lib_symbols_missing}" ) def _convert_one_flat( proj: Project, doc_uuid: str, out_dir: Path, pr: ProjectRelations | None = None, ) -> Path: """Standalone single-page export — no hierarchy. Useful for inspecting one page.""" 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, project_relations=pr) out_path = out_dir / f"{_safe_filename(_page_title(doc))}_{doc_uuid[:8]}.kicad_sch" out_path.write_text(text, encoding="utf-8") _print_stats(out_path.name) return out_path def _group_pages_by_sch(proj: Project) -> dict[str, list[str]]: """Group SCH_PAGE doc uuids by their parent SCH uuid (META.schematic). Pages without a META.schematic land in the bucket keyed by their own uuid (treated as a single-page schematic). Deleted SCHs (``DELETE_DOC``) are skipped — their pages are dropped silently. """ 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, list[str]] = {} for u, d in proj.documents.items(): if d.doc_type != "SCH_PAGE": continue meta = d.objects.get("META") or {} sch_uuid = str(meta.get("schematic") or u) if sch_uuid in deleted_schs: continue out.setdefault(sch_uuid, []).append(u) # Sort pages within each SCH by zIndex (the editor's tab order) for sch_uuid, pages in out.items(): pages.sort(key=lambda u: (proj.documents[u].objects.get("META") or {}).get("zIndex") or 0) return out def _convert_hierarchical( proj: Project, out_dir: Path, pr: ProjectRelations | None, ) -> int: """Emit one root + N children per SCH group. Returns root count.""" groups = _group_pages_by_sch(proj) if not groups: print("no SCH_PAGE docs in this project", file=sys.stderr) return 0 root_count = 0 for sch_uuid, page_uuids in groups.items(): sch_doc = proj.documents.get(sch_uuid) sch_title = ( (sch_doc.objects.get("META") or {}).get("title") if sch_doc and sch_doc.doc_type == "SCH" else None ) or _page_title(proj.documents[page_uuids[0]]) sch_dir = out_dir / _safe_filename(sch_title) sch_dir.mkdir(parents=True, exist_ok=True) print(f"Schematic '{sch_title}' ({len(page_uuids)} page(s)) → {sch_dir}") # Pre-assign each child a uuid and a filename; the child page # writer needs the uuid to declare its hierarchy path, and the # root needs both to emit its (sheet ...) entries. children: list[ChildSheet] = [] for page_uuid in page_uuids: page_doc = proj.documents[page_uuid] title = _page_title(page_doc) children.append(ChildSheet( filename=f"{_safe_filename(title)}_{page_uuid[:8]}.kicad_sch", title=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, # root takes page 1 ) (sch_dir / child.filename).write_text(text, encoding="utf-8") _print_stats(child.filename) root_text = write_root_sheet(sch_title, children, project_name=_safe_filename(sch_title)) root_path = sch_dir / f"{_safe_filename(sch_title)}.kicad_sch" root_path.write_text(root_text, encoding="utf-8") print(f" ROOT: {root_path}") root_count += 1 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 with the schematic export layout.""" pcb_uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"] if not pcb_uuids: print("no PCB docs in this project", file=sys.stderr) return 0 print(f"Converting {len(pcb_uuids)} PCB doc(s) → {out_dir}") n = 0 for u in pcb_uuids: doc = proj.documents[u] title = (doc.objects.get("META") or {}).get("title") or u[:12] try: text = write_pcb(doc, project_relations=pr) except Exception as e: # noqa: BLE001 print(f" FAIL {u[:12]}: {e}", file=sys.stderr) continue out_path = out_dir / f"{_safe_filename(title)}.kicad_pcb" out_path.write_text(text, encoding="utf-8") stats = getattr(write_pcb, "last_stats", None) if stats: print( f" {out_path.name}: nets={stats.nets} fps={stats.footprints} " f"fps_unresolved={stats.footprints_unresolved} " f"segments={stats.segments} vias={stats.vias} " f"zones={stats.zones} edge={stats.edge_cuts}" ) n += 1 return n def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic + PCB exporter") 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 (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", action="store_true", help="skip lib_symbols generation (Phase 1 mode — placements only, red ?)", ) ap.add_argument( "--flat", action="store_true", help="emit one flat .kicad_sch per page (no hierarchy); only with --all-sch or --doc", ) args = ap.parse_args(argv) proj = replay_project(args.project_dir) args.out.mkdir(parents=True, exist_ok=True) pr = None if args.no_lib_symbols else ProjectRelations.build(proj) if args.all_pcb: if pr is None: pr = ProjectRelations.build(proj) _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 if args.flat: 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"Flat mode: converting {len(targets)} SCH_PAGE docs → {args.out}") for u in targets: try: _convert_one_flat(proj, u, args.out, pr=pr) except Exception as e: # noqa: BLE001 print(f" FAIL {u[:12]}: {e}", file=sys.stderr) return 0 _convert_hierarchical(proj, args.out, pr=pr) return 0 if __name__ == "__main__": raise SystemExit(main())