tools/epro2/kicad: hierarchical export + global_label + 5-Voltage power ports

Three coupled changes so kicad-cli sch erc runs at the project level
(across all sheets of one schematic) instead of single-sheet:

1. (label) → (global_label (shape passive)). EPRO2 nets are
   project-global by construction (named rails span every page in the
   SCH and physically wire across PCBs); KiCad's local label is sheet-
   scoped and triggers `label_dangling` for any name not duplicated on
   the same page.

2. New root_sch_writer that groups SCH_PAGE docs by their parent SCH
   (META.schematic), emits one root .kicad_sch per group with one
   (sheet ...) entry per child, and threads the root-assigned uuid back
   into each child's (sheet_instances) so KiCad can bind them.
   --all-sch now defaults to this; --flat falls back to one-file-per-page.

3. EPRO2's "5-Voltage" placeholder COMPONENT (partId
   pid8a0e77bacb214e, 365 instances on ESP-VoCat) is the editor's power
   port. The rail name lives in the placement's `Global Net Name` ATTR,
   not in the PART. We now emit a (global_label "<rail>") at the
   placement coords whenever that attr is set (101/365 of them on
   ESP-VoCat — the rest are unconfigured drafts).

ESP-VoCat 5 hierarchical roots: 2325 → 2265 violations. Modest because
5 of 6 SCHs are single-page (no cross-sheet nets to resolve), and the
one 4-page schematic (CoreBoard) shares only a handful of names across
sheets — most net names are de-facto sheet-local. The remaining ~190
pin_not_connected are dominated by 0402-style passives whose pin tip
lies on a wire's interior, not at an endpoint; KiCad needs an explicit
(junction) at those points and we don't yet emit one. Marked as the
next follow-up in log.md.

47 → 52 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:05:47 +08:00
parent 54f0173947
commit ff5553fb06
6 changed files with 479 additions and 33 deletions

View File

@@ -71,6 +71,8 @@ def write_sch_page(
title: str | None = None,
sheet_origin_mm: tuple[float, float] = (25.4, 25.4),
project_relations: ProjectRelations | None = None,
sheet_path: str = "/",
page_num: int = 1,
) -> str:
"""Render a single SCH_PAGE Document as kicad_sch text.
@@ -82,6 +84,16 @@ def write_sch_page(
hosting that PART; the body of the first matching SYMBOL is rendered via
:func:`sym_writer.write_lib_symbol`. If a partId can't be resolved, we
still emit the placement (KiCad shows a red ``?``).
``sheet_path`` and ``page_num`` describe this page's place in a
hierarchical project. For a standalone page, the defaults emit
``(sheet_instances (path "/" (page "1")))`` which KiCad reads as a
single-sheet project. When this page is included as a child of a root
schematic, pass ``sheet_path="/<assigned_child_uuid>"`` (the uuid the
root assigned to its ``(sheet ...)`` block for this child) and the
page index in the hierarchy (root=1, children=2..N). Without this the
KiCad hierarchy can't bind the child file to its root entry, and ERC
treats the child as a disconnected island.
"""
if doc.doc_type != "SCH_PAGE":
raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}")
@@ -129,11 +141,19 @@ def write_sch_page(
net = wire_net_cache[wid]
if not net:
continue
# global_label, not local label: EPRO2 nets are project-wide
# (a "GND" net spans every page in the schematic and physically
# connects to GND wires on neighbour PCBs). KiCad's local (label)
# is sheet-scoped and triggers `label_dangling` whenever a name
# only appears on one sheet — exactly the case we hit on every
# cross-sheet net before. (global_label) is project-scoped and
# gets resolved by hierarchical ERC on the root sheet.
elements.append([
Sym("label"), str(net),
Sym("global_label"), str(net),
[Sym("shape"), Sym("passive")],
[Sym("at"), x1, y1, 0],
[Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]],
[Sym("justify"), Sym("left"), Sym("bottom")]],
[Sym("justify"), Sym("left")]],
[Sym("uuid"), _new_uuid()],
])
stats.labels += 1
@@ -178,6 +198,24 @@ def write_sch_page(
elements.append(sym_block)
stats.symbol_placements += 1
# Power-port instances: EPRO2 expresses things like VCC / GND / VBUS
# as a generic "5-Voltage" symbol whose net name is carried by the
# placement's `Global Net Name` ATTR (not by the underlying PART).
# Without a global_label at the pin tip, every such placement
# shows up as pin_not_connected even though it should anchor the
# pin to a global rail.
gnn = attrs.get("Global Net Name")
if gnn:
elements.append([
Sym("global_label"), str(gnn),
[Sym("shape"), Sym("passive")],
[Sym("at"), x, y, 0],
[Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]],
[Sym("justify"), Sym("left")]],
[Sym("uuid"), _new_uuid()],
])
stats.labels += 1
# 3. Text labels from TEXT objects (best-effort — only those with a non-empty value).
for oid, obj in doc.objects.items():
if obj.get("_type") != "TEXT":
@@ -219,7 +257,7 @@ def write_sch_page(
lib_symbols,
*elements,
[Sym("sheet_instances"),
[Sym("path"), "/", [Sym("page"), "1"]]],
[Sym("path"), sheet_path, [Sym("page"), str(page_num)]]],
]
# Stash stats on the function for tests / CLI to inspect.