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

57
log.md
View File

@@ -4,6 +4,63 @@
--- ---
## 2026-04-29 00:30 KiCad 导出 Phase 3 hierarchicalroot + global_label + 5-Voltage 电源端口
**Claude 会话**
`54f0173`。Handoff #3 多 sheet hierarchical`kicad-cli sch erc` 在 project 视角而不是单 sheet 视角下校验,理论上能把 single-sheet ERC 看不见跨 sheet 的 248+111 残留压下去。
### 三件事一起做
1. **`(label)``(global_label)`**`tools/epro2/kicad/sch_writer.py`
EPRO2 的 NET 是项目全局——一个 GND 名字横跨整个 schematic 而且通过 PCB 走线到隔壁板子。KiCad 的 `(label)` 是 sheet-scoped单 sheet ERC 看到一个名字只出现一次就报 dangling`(global_label)` 才是项目级hierarchical ERC 在 root 上能跨 sheet 解。
2. **`tools/epro2/kicad/root_sch_writer.py` 新模块**
按 EPRO2 的 `SCH_PAGE.META.schematic` 把页分组,每组 emit 一个 root `.kicad_sch`,里面 N 个 `(sheet ...)` 引子页。子页的 `(sheet_instances (path "/<assigned_uuid>" ...))` 必须回引 root 给它分配的 uuid——少了这一步 ERC 把子页当孤岛走。
3. **5-Voltage 电源端口识别**`sch_writer.py` COMPONENT 循环里加判断)
实测 ESP-VoCat 有 365 个 partId=`pid8a0e77bacb214e` 的 COMPONENT——挖了下发现这是 EPRO2 内部的 "Voltage" 占位符号,对应 KiCad 的 `(power_symbol)`。**网络名不在 PART 里,而是 placement 自己的 `Global Net Name` ATTR**101 个有名字、264 个还是空的草稿态)。每次有名字就在 placement 位置 emit 一个 `(global_label)`
### CLI 改造
`uv run python -m tools.epro2.kicad <project> --all-sch` 现在默认 hierarchical 输出:每个 SCH 一个目录,里面 root + 子页。`--flat` 兜回老行为(一张图一个文件)。`DELETE_DOC.isDelete=True` 的 SCH 直接跳——LCD-BD 那个被删了的没生成。
### ESP-VoCat 实测
`kicad-cli sch erc <each-root>` 跨 5 个 root 累加:
| Type | flat baseline | hier 后 | Δ |
|---|---:|---:|---:|
| wire_dangling | 52 | 52 | 0 |
| pin_not_connected | 196 | 190 | -6 |
| label_dangling → global_label_dangling | 111 | 105 | -6 |
| pin_not_driven | 23 | 21 | -2 |
| endpoint_off_grid | 1372 | 1340 | -32 (LCD-BD 移除带走的) |
| lib_symbol_issues | 571 | 557 | -14 (同上) |
| **TOTAL** | **2325** | **2265** | **-60** |
**修得不够多——为什么**EPRO2 的 6 个 SCH 里 5 个只有 1 pagehierarchical 对它们没用;只有 CoreBoard 是真 4-page 多 sheet。CoreBoard 自己里面也只有 GND / MCU_3V3 / VCC_3V3 是真跨 sheet 共享的网其它GPIO4, CHIP_PU, AUDIO_I2C_*, I2S_*)都是 sheet-local——名字虽然 unique 但只在一个 sheet 上有 wire 引用hierarchical ERC 也救不了,依然 dangling。
**剩下 190 PNC 的真原因**:抽样发现 C44/C45/R19/R20 这类 0402 元件wire 从一个 pin 出来直直穿过另一个 pin但 pin 落在 wire **中段**而不是端点。KiCad ERC 要求 pin 落在 wire 端点或 explicit junction 处才认连接wire 中段穿过的 pin 不自动连。EPRO2 源里这种连接合法但导出时丢了——要修需要做 wire-pin 几何相交,在中段 pin 位置 split wire 或 emit `(junction)`。下一轮再做。
### 决策Why
- **不走单 root 包全部 9 page**6 块 PCB 是物理独立板子merge 进一个 root 会把 BaseBoard 的 GND 和 CoreBoard 的 GND 误判成同一个网。EPRO2 已经按 SCH 分好组,按 SCH 拆 root 是结构对齐做法。
- **`(global_label (shape passive))`**不知道方向input/output/bidirectional 都会触发更多 ERC 检查(如 pin_not_drivenpassive 最保守。power 网用 power_in 才理想,但需要 driver 元件,超出本轮范围。
- **保留占位符号 placement 同时再 emit global_label**5-Voltage 占位符在 KiCad 里画出来虽然冗余但不影响 ERC删了反而丢视觉信息。
- **不实现自动 junction**geometry 计算成本明显高于本轮收益(<10 PNC 的预期降幅),做成下一轮独立改动更清晰。
### 测试
47 → 52 单测全过root_sch_writer 3 个 + power-port label 2 个 + sheet_path/page_num propagate 1 个 - test_named_wire 改 global_label 重写 1 个。
### 下一步建议
- **wire-pin junction emission**(中等工作量):算每个 COMPONENT 的 abs pin 位置,对每条 wire 查"非端点 pin 命中",命中就 split wire 或 emit junction。预期把 PNC 从 190 再砍一半左右。
- 或者直接进 **Phase 3 真正的 .kicad_pcb 导出**——schematic 这边已经够用PCB 才是 Forge 投影最后一块。
---
## 2026-04-28 23:55 KiCad 导出修真实连接错wire_dangling -88%, pin_not_connected -52% ## 2026-04-28 23:55 KiCad 导出修真实连接错wire_dangling -88%, pin_not_connected -52%
**Claude 会话** **Claude 会话**

View File

@@ -3,9 +3,14 @@
Usage: Usage:
uv run python -m tools.epro2.kicad <project_dir> --doc <sch_uuid> --out <dir> 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> uv run python -m tools.epro2.kicad <project_dir> --all-sch --out <dir>
uv run python -m tools.epro2.kicad <project_dir> --all-sch --flat --out <dir>
The ``--all-sch`` form converts every SCH_PAGE in the project, naming each By default ``--all-sch`` produces a hierarchical project: pages are grouped
output by its document title (or doc_uuid prefix as fallback). 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 from __future__ import annotations
@@ -16,7 +21,8 @@ import sys
from pathlib import Path from pathlib import Path
from ..project_relations import ProjectRelations from ..project_relations import ProjectRelations
from ..replay import Project, replay_project from ..replay import Document, Project, replay_project
from .root_sch_writer import ChildSheet, new_sheet_uuid, write_root_sheet
from .sch_writer import write_sch_page from .sch_writer import write_sch_page
@@ -28,12 +34,27 @@ def _safe_filename(s: str) -> str:
return s or "untitled" return s or "untitled"
def _convert_one( 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, proj: Project,
doc_uuid: str, doc_uuid: str,
out_dir: Path, out_dir: Path,
pr: ProjectRelations | None = None, pr: ProjectRelations | None = None,
) -> Path: ) -> Path:
"""Standalone single-page export — no hierarchy. Useful for inspecting one page."""
if doc_uuid not in proj.documents: if doc_uuid not in proj.documents:
candidates = [u for u in proj.documents if u.startswith(doc_uuid)] candidates = [u for u in proj.documents if u.startswith(doc_uuid)]
if len(candidates) != 1: if len(candidates) != 1:
@@ -44,21 +65,96 @@ def _convert_one(
raise SystemExit(f"doc {doc_uuid} is {doc.doc_type!r}, not SCH_PAGE") raise SystemExit(f"doc {doc_uuid} is {doc.doc_type!r}, not SCH_PAGE")
text = write_sch_page(doc, project_relations=pr) text = write_sch_page(doc, project_relations=pr)
title = (doc.objects.get("META") or {}).get("title") or doc_uuid[:12] out_path = out_dir / f"{_safe_filename(_page_title(doc))}_{doc_uuid[:8]}.kicad_sch"
out_path = out_dir / f"{_safe_filename(title)}_{doc_uuid[:8]}.kicad_sch"
out_path.write_text(text, encoding="utf-8") out_path.write_text(text, encoding="utf-8")
stats = getattr(write_sch_page, "last_stats", None) _print_stats(out_path.name)
if stats:
print(
f" {out_path.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}"
)
return out_path 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 main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic exporter (Phase 1)") ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic exporter (Phase 3 hierarchical)")
ap.add_argument("project_dir", type=Path) ap.add_argument("project_dir", type=Path)
g = ap.add_mutually_exclusive_group(required=True) 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("--doc", help="SCH_PAGE doc uuid (or unique prefix) to convert")
@@ -69,6 +165,11 @@ def main(argv: list[str] | None = None) -> int:
action="store_true", action="store_true",
help="skip lib_symbols generation (Phase 1 mode — placements only, red ?)", 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) args = ap.parse_args(argv)
proj = replay_project(args.project_dir) proj = replay_project(args.project_dir)
@@ -76,19 +177,23 @@ def main(argv: list[str] | None = None) -> int:
pr = None if args.no_lib_symbols else ProjectRelations.build(proj) pr = None if args.no_lib_symbols else ProjectRelations.build(proj)
if args.doc: if args.doc:
_convert_one(proj, args.doc, args.out, pr=pr) _convert_one_flat(proj, args.doc, args.out, pr=pr)
return 0 return 0
targets = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"] if args.flat:
if not targets: targets = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"]
print("no SCH_PAGE docs in this project", file=sys.stderr) if not targets:
return 1 print("no SCH_PAGE docs in this project", file=sys.stderr)
print(f"Converting {len(targets)} SCH_PAGE docs → {args.out}") return 1
for u in targets: print(f"Flat mode: converting {len(targets)} SCH_PAGE docs → {args.out}")
try: for u in targets:
_convert_one(proj, u, args.out, pr=pr) try:
except Exception as e: # noqa: BLE001 _convert_one_flat(proj, u, args.out, pr=pr)
print(f" FAIL {u[:12]}: {e}", file=sys.stderr) 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 return 0

View File

@@ -0,0 +1,125 @@
"""Generate a root ``.kicad_sch`` that ties multiple SCH_PAGE files into one
hierarchical project.
EPRO2 multi-page schematics live as N standalone ``SCH_PAGE`` docs all
referencing the same ``SCH`` doc through ``META.schematic``. When we exported
each page as its own ``.kicad_sch`` and ran ``kicad-cli sch erc`` against it
in isolation, every cross-sheet net showed up as ``label_dangling`` /
``pin_not_connected`` because single-sheet ERC can't see the matching
endpoint on a sibling page.
Hierarchical mode fixes that:
- Root .kicad_sch carries one ``(sheet ...)`` block per child page,
each with a uuid the root assigns and a ``Sheetfile`` pointing at the
child filename in the same directory.
- Each child .kicad_sch declares its position in the hierarchy with
``(sheet_instances (path "/<assigned_uuid>" (page "<n>")))`` so KiCad
can bind it to the root.
- With both halves wired up, ``kicad-cli sch erc <root>`` walks the whole
project, resolving global_labels across pages.
Usage:
Build a list of (filename, page_title, assigned_uuid) tuples — one per
child — *before* writing the children, then pass it here to emit the
root. The same uuid you stored in this list goes into the child writer
via its ``sheet_path="/<uuid>"`` argument.
"""
from __future__ import annotations
import uuid as _uuid
from dataclasses import dataclass
from .sexpr import Sym, to_sexpr
KICAD_SCH_VERSION = 20231120
KICAD_GENERATOR = "facere-epro2"
@dataclass
class ChildSheet:
"""One child entry in a root sheet.
``filename`` is relative to the root .kicad_sch's directory.
``sheet_uuid`` is the uuid the root assigns to this child; the child's
own (sheet_instances) must echo it back as ``"/<sheet_uuid>"``.
"""
filename: str
title: str
sheet_uuid: str
def new_sheet_uuid() -> str:
return str(_uuid.uuid4())
def _font(size: float = 1.27) -> list:
return [Sym("effects"), [Sym("font"), [Sym("size"), size, size]],
[Sym("justify"), Sym("left"), Sym("bottom")]]
def _file_font(size: float = 1.27) -> list:
return [Sym("effects"), [Sym("font"), [Sym("size"), size, size]],
[Sym("justify"), Sym("left"), Sym("top")]]
def write_root_sheet(
title: str,
children: list[ChildSheet],
*,
project_name: str = "facere",
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),
columns: int = 3,
) -> str:
"""Render a root ``.kicad_sch`` listing ``children`` as ``(sheet ...)`` blocks.
The boxes are laid out on a simple ``columns``-wide grid so they don't
overlap on the canvas; visual layout is irrelevant to ERC but KiCad's
GUI needs non-zero positions/sizes for the sheet to be visible/editable.
"""
elements: list = []
bw, bh = box_size_mm
px, py = box_pitch_mm
ox, oy = grid_origin_mm
for idx, child in enumerate(children):
col = idx % columns
row = idx // columns
x = ox + col * px
y = oy + row * py
page_num = idx + 2 # root is page 1, children start at 2
elements.append([
Sym("sheet"),
[Sym("at"), x, y],
[Sym("size"), bw, bh],
[Sym("stroke"), [Sym("width"), 0.1524], [Sym("type"), Sym("solid")]],
[Sym("fill"), [Sym("color"), 0, 0, 0, 0.0]],
[Sym("uuid"), child.sheet_uuid],
[Sym("property"), "Sheetname", child.title,
[Sym("at"), x, y - 0.5, 0], _font()],
[Sym("property"), "Sheetfile", child.filename,
[Sym("at"), x, y + bh + 0.5, 0], _file_font()],
[Sym("instances"),
[Sym("project"), project_name,
[Sym("path"), "/", [Sym("page"), str(page_num)]]]],
])
sch: list = [
Sym("kicad_sch"),
[Sym("version"), KICAD_SCH_VERSION],
[Sym("generator"), KICAD_GENERATOR],
[Sym("uuid"), new_sheet_uuid()],
[Sym("paper"), "A4"],
[Sym("title_block"),
[Sym("title"), title],
[Sym("comment"), 1, f"hierarchical root for {len(children)} child sheet(s)"]],
[Sym("lib_symbols")],
*elements,
[Sym("sheet_instances"),
[Sym("path"), "/", [Sym("page"), "1"]]],
]
return to_sexpr(sch, pretty=True)

View File

@@ -71,6 +71,8 @@ def write_sch_page(
title: str | None = None, title: str | None = None,
sheet_origin_mm: tuple[float, float] = (25.4, 25.4), sheet_origin_mm: tuple[float, float] = (25.4, 25.4),
project_relations: ProjectRelations | None = None, project_relations: ProjectRelations | None = None,
sheet_path: str = "/",
page_num: int = 1,
) -> str: ) -> str:
"""Render a single SCH_PAGE Document as kicad_sch text. """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 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 :func:`sym_writer.write_lib_symbol`. If a partId can't be resolved, we
still emit the placement (KiCad shows a red ``?``). 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": if doc.doc_type != "SCH_PAGE":
raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}") 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] net = wire_net_cache[wid]
if not net: if not net:
continue 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([ elements.append([
Sym("label"), str(net), Sym("global_label"), str(net),
[Sym("shape"), Sym("passive")],
[Sym("at"), x1, y1, 0], [Sym("at"), x1, y1, 0],
[Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]],
[Sym("justify"), Sym("left"), Sym("bottom")]], [Sym("justify"), Sym("left")]],
[Sym("uuid"), _new_uuid()], [Sym("uuid"), _new_uuid()],
]) ])
stats.labels += 1 stats.labels += 1
@@ -178,6 +198,24 @@ def write_sch_page(
elements.append(sym_block) elements.append(sym_block)
stats.symbol_placements += 1 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). # 3. Text labels from TEXT objects (best-effort — only those with a non-empty value).
for oid, obj in doc.objects.items(): for oid, obj in doc.objects.items():
if obj.get("_type") != "TEXT": if obj.get("_type") != "TEXT":
@@ -219,7 +257,7 @@ def write_sch_page(
lib_symbols, lib_symbols,
*elements, *elements,
[Sym("sheet_instances"), [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. # Stash stats on the function for tests / CLI to inspect.

View File

@@ -0,0 +1,68 @@
"""Root sheet writer regression: hierarchical parent .kicad_sch."""
from tools.epro2.kicad._sexpr_reader import parse
from tools.epro2.kicad.root_sch_writer import ChildSheet, write_root_sheet
def _block(parsed, name):
return [c for c in parsed if isinstance(c, list) and c and c[0] == name]
def test_root_emits_one_sheet_block_per_child():
children = [
ChildSheet("Overview.kicad_sch", "Overview", "uuid-1"),
ChildSheet("MCU.kicad_sch", "MCU", "uuid-2"),
ChildSheet("Codec.kicad_sch", "Codec", "uuid-3"),
]
parsed = parse(write_root_sheet("CoreBoard", children))
sheets = _block(parsed, "sheet")
assert len(sheets) == 3
# Each sheet block carries the assigned uuid + Sheetname + Sheetfile
titles = []
files = []
uuids = []
for sh in sheets:
uuids.append(next(c for c in sh if isinstance(c, list) and c[0] == "uuid")[1])
for c in sh:
if isinstance(c, list) and c[0] == "property":
if c[1] == "Sheetname":
titles.append(c[2])
elif c[1] == "Sheetfile":
files.append(c[2])
assert titles == ["Overview", "MCU", "Codec"]
assert files == ["Overview.kicad_sch", "MCU.kicad_sch", "Codec.kicad_sch"]
assert uuids == ["uuid-1", "uuid-2", "uuid-3"]
def test_root_assigns_sequential_page_numbers_starting_at_2():
"""Root itself is page 1 of the hierarchy; children start at page 2 so
that a child's (sheet_instances (page "<n>")) lines up with the root's
(instances ... (page "<n>"))."""
children = [
ChildSheet("a.kicad_sch", "A", "u-a"),
ChildSheet("b.kicad_sch", "B", "u-b"),
]
parsed = parse(write_root_sheet("Project", children))
sheets = _block(parsed, "sheet")
page_nums = []
for sh in sheets:
inst = next(c for c in sh if isinstance(c, list) and c[0] == "instances")
proj = next(c for c in inst if isinstance(c, list) and c[0] == "project")
path = next(c for c in proj if isinstance(c, list) and c[0] == "path")
page = next(c for c in path if isinstance(c, list) and c[0] == "page")
page_nums.append(page[1])
assert page_nums == ["2", "3"]
# And the root's own page is 1
root_inst = _block(parsed, "sheet_instances")[0]
root_path = next(c for c in root_inst if isinstance(c, list) and c[0] == "path")
root_page = next(c for c in root_path if isinstance(c, list) and c[0] == "page")
assert root_page[1] == "1"
def test_root_handles_zero_children():
"""Empty schematic: still emit a valid kicad_sch with no (sheet) blocks."""
parsed = parse(write_root_sheet("Empty", []))
assert parsed[0] == "kicad_sch"
assert _block(parsed, "sheet") == []
# sheet_instances still required (KiCad demands at least one path)
assert _block(parsed, "sheet_instances")

View File

@@ -100,11 +100,13 @@ def test_text_object_emits_text_block_when_non_empty():
assert texts[0][1] == "Hello" assert texts[0][1] == "Hello"
def test_named_wire_emits_label_at_line_start(): def test_named_wire_emits_global_label_at_line_start():
"""EPRO2 binds wire segments into nets by NAME (WIRE.NET attr), not by """EPRO2 binds wire segments into nets by NAME (WIRE.NET attr), not by
geometry alone. Each LINE whose lineGroup points to a WIRE with a NET geometry alone. Each LINE whose lineGroup points to a WIRE with a NET
attr must get a (label "<NET>") at one endpoint — same-named labels on attr gets a (global_label "<NET>") at one endpoint — global, not local,
distinct LINEs are how KiCad's ERC recognizes a multi-segment net.""" because EPRO2 nets span every page of the schematic and (via PCB) the
whole project; local (label) on a single page would always be flagged
`label_dangling` for cross-sheet nets."""
d = _doc([ d = _doc([
("w1", {"_type": "WIRE"}), ("w1", {"_type": "WIRE"}),
("a1", {"_type": "ATTR", "parentId": "w1", "key": "NET", "value": "GND"}), ("a1", {"_type": "ATTR", "parentId": "w1", "key": "NET", "value": "GND"}),
@@ -115,7 +117,7 @@ def test_named_wire_emits_label_at_line_start():
]) ])
text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0)) text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0))
p = parse(text) p = parse(text)
labels = _block(p, "label") labels = _block(p, "global_label")
assert len(labels) == 2 # one label per non-degenerate LINE assert len(labels) == 2 # one label per non-degenerate LINE
assert all(lab[1] == "GND" for lab in labels) assert all(lab[1] == "GND" for lab in labels)
# First label sits at the first LINE's start endpoint # First label sits at the first LINE's start endpoint
@@ -136,10 +138,61 @@ def test_unnamed_wire_emits_no_label():
]) ])
text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0)) text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0))
p = parse(text) p = parse(text)
assert _block(p, "label") == [] assert _block(p, "global_label") == []
assert getattr(write_sch_page, "last_stats").labels == 0 assert getattr(write_sch_page, "last_stats").labels == 0
def test_power_port_component_emits_global_label_at_placement():
"""EPRO2 represents power rails (VCC/GND/VBUS/...) as generic
placeholder COMPONENTs whose net name lives in a `Global Net Name`
ATTR on the placement (not on the underlying PART). Without an
explicit (global_label) at the pin tip, every such instance reads
as pin_not_connected even when the symbol's pin sits on a wire."""
d = _doc([
("e1", {"_type": "COMPONENT", "partId": "pid8a0e77bacb214e",
"x": 100, "y": 50, "rotation": 0}),
("a1", {"_type": "ATTR", "parentId": "e1",
"key": "Global Net Name", "value": "VBUS"}),
])
text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0))
p = parse(text)
labels = _block(p, "global_label")
assert len(labels) == 1
assert labels[0][1] == "VBUS"
at = next(c for c in labels[0] if isinstance(c, list) and c[0] == "at")
assert at[1] == 100 * MIL_TO_MM
assert at[2] == 50 * MIL_TO_MM
def test_unnamed_power_port_emits_no_label():
"""Unnamed placeholder power-ports (Global Net Name absent) stay as
bare symbol placements — we have no rail name to bind them to."""
d = _doc([
("e1", {"_type": "COMPONENT", "partId": "pid8a0e77bacb214e",
"x": 0, "y": 0}),
])
text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0))
p = parse(text)
assert _block(p, "global_label") == []
def test_sheet_path_and_page_num_propagate_to_sheet_instances():
"""When a page is written as a child of a hierarchical root, its
(sheet_instances) must echo the uuid the root assigned (path
"/<assigned_uuid>") and its hierarchy page number — without that the
root can't bind the child and ERC treats it as standalone."""
d = _doc([("META", {"_type": "META", "title": "child"})])
text = write_sch_page(
d, sheet_path="/22222222-3333-4444-5555-666666666666", page_num=3,
)
p = parse(text)
inst = _block(p, "sheet_instances")[0]
path = next(c for c in inst if isinstance(c, list) and c[0] == "path")
assert path[1] == "/22222222-3333-4444-5555-666666666666"
page = next(c for c in path if isinstance(c, list) and c[0] == "page")
assert page[1] == "3"
def test_non_sch_page_doc_rejected(): def test_non_sch_page_doc_rejected():
d = Document(doc_uuid="x", doc_type="PCB") d = Document(doc_uuid="x", doc_type="PCB")
try: try: