From 8a91ce43f4c2d38ae98a00251b63e4914af32915 Mon Sep 17 00:00:00 2001 From: Knowit Date: Tue, 28 Apr 2026 22:45:19 +0800 Subject: [PATCH] =?UTF-8?q?tools/epro2/kicad:=20Phase-2=20lib=5Fsymbols=20?= =?UTF-8?q?=E2=80=94=20render=20symbol=20bodies=20from=20SYMBOL=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 emit的 .kicad_sch 里组件位置 + 属性都对,但 lib_symbols 是空 stub —— KiCad 渲染时每个组件显示成红色 "?"。Phase 2 把 SYMBOL 文档 里的 PART + RECT/POLY/CIRCLE/TEXT/PIN primitives 翻成 KiCad lib symbol 块,填到 lib_symbols 里,让 KiCad 显示真正的原理图符号。 新增 tools/epro2/kicad/sym_writer.py: write_lib_symbol(symbol_doc) → S-expr list 形如: (symbol "facere:" (pin_numbers (hide no)) (pin_names (offset 1.016)) (in_bom yes) (on_board yes) (property "Reference" "U" ...) (property "Value" "" ...) (property "Footprint" "" hide) (property "Datasheet" "" hide) (symbol "<partId>_1_1" (rectangle ...) ← from RECT.dotX1/Y1/dotX2/Y2 (polyline (pts ...)) ← from POLY.points + closed → fill (circle ...) ← from CIRCLE.center/radius (text "..." ...) ← from TEXT.value/x/y/rotation (pin <type> line (at ...) (length ...) (name ...) (number ...)) ← from PIN + sibling ATTR ops )) PIN 名字/编号/电气类型解析(这是关键数据探测点): EPRO2 PIN 不直接带 number/name/type 字段;这些信息存为独立 ATTR 操作 (parentId=<pin_id>, key="Pin Name"/"Pin Number"/"Pin Type") Pin Type 取值映射:IN→input, OUT→output, BIDIR→bidirectional, POWER_IN→power_in, POWER_OUT→power_out, NC→no_connect, ... 默认 passive(保守) sch_writer 集成(lib_symbols 自动填): write_sch_page(doc, project_relations=pr) — 增 pr 可选参数 内部 _build_lib_symbols(): 收集本 sheet 用到的 partIds → 通过 ProjectRelations.parts_by_id 解析到 SYMBOL 文档 → write_lib_symbol → 组装 (lib_symbols ...) 块;同 partId 多 SYMBOL 候选取第一个,去重 WriteStats 增 lib_symbols_embedded / lib_symbols_missing CLI 加 --no-lib-symbols 用于回到 Phase-1 行为(占位符调试用)。 ESP-VoCat 重导出验证:9/9 SCH_PAGE 全部 0 lib_miss P1_45092758.kicad_sch wires=187 symbols=138 lib_emb=29 codec_0b0163fa.kicad_sch wires=190 symbols=112 lib_emb=20 Interface_b336a7c7.kicad_sch symbols=95 lib_emb=13 ... P1_408c9f4f.kicad_sch wires= 6 symbols= 10 lib_emb= 3 测试:6 个新单测覆盖 outer wrapper / pin ATTR pull / 多形状 primitives / sch_writer 集成路径 / 缺失 lib 计数 / no-pr 回退到 Phase 1。 合计 **39/39 通过**(parser 6 + relations 9 + project_relations 6 + sexpr 6 + sch_writer 6 + sym_writer 6)。 下一步 Phase 3:footprint library + .kicad_pcb 导出。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- tools/epro2/kicad/__init__.py | 3 +- tools/epro2/kicad/__main__.py | 23 +++- tools/epro2/kicad/sch_writer.py | 59 ++++++++- tools/epro2/kicad/sym_writer.py | 179 +++++++++++++++++++++++++++ tools/epro2/tests/test_sym_writer.py | 126 +++++++++++++++++++ 5 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 tools/epro2/kicad/sym_writer.py create mode 100644 tools/epro2/tests/test_sym_writer.py diff --git a/tools/epro2/kicad/__init__.py b/tools/epro2/kicad/__init__.py index 9dbc553..904f546 100644 --- a/tools/epro2/kicad/__init__.py +++ b/tools/epro2/kicad/__init__.py @@ -16,5 +16,6 @@ mm. Conversion factor ``MIL_TO_MM = 0.0254`` is applied at writer level. from .sch_writer import write_sch_page # noqa: F401 from .sexpr import Sym, to_sexpr # noqa: F401 +from .sym_writer import write_lib_symbol # noqa: F401 -__all__ = ["Sym", "to_sexpr", "write_sch_page"] +__all__ = ["Sym", "to_sexpr", "write_sch_page", "write_lib_symbol"] diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py index 9cefaa9..6d477a3 100644 --- a/tools/epro2/kicad/__main__.py +++ b/tools/epro2/kicad/__main__.py @@ -15,6 +15,7 @@ import re import sys from pathlib import Path +from ..project_relations import ProjectRelations from ..replay import Project, replay_project from .sch_writer import write_sch_page @@ -27,7 +28,12 @@ def _safe_filename(s: str) -> str: return s or "untitled" -def _convert_one(proj: Project, doc_uuid: str, out_dir: Path) -> Path: +def _convert_one( + proj: Project, + doc_uuid: str, + out_dir: Path, + pr: ProjectRelations | None = None, +) -> Path: if doc_uuid not in proj.documents: candidates = [u for u in proj.documents if u.startswith(doc_uuid)] if len(candidates) != 1: @@ -37,7 +43,7 @@ def _convert_one(proj: Project, doc_uuid: str, out_dir: Path) -> Path: 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) + 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(title)}_{doc_uuid[:8]}.kicad_sch" out_path.write_text(text, encoding="utf-8") @@ -45,7 +51,8 @@ def _convert_one(proj: Project, doc_uuid: str, out_dir: Path) -> Path: if stats: print( f" {out_path.name}: wires={stats.wires} symbols={stats.symbol_placements} " - f"text={stats.text} skipped={stats.skipped}" + f"text={stats.text} skipped={stats.skipped} " + f"lib_emb={stats.lib_symbols_embedded} lib_miss={stats.lib_symbols_missing}" ) return out_path @@ -57,13 +64,19 @@ def main(argv: list[str] | None = None) -> int: 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") 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 ?)", + ) 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.doc: - _convert_one(proj, args.doc, args.out) + _convert_one(proj, args.doc, args.out, pr=pr) return 0 targets = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"] @@ -73,7 +86,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Converting {len(targets)} SCH_PAGE docs → {args.out}") for u in targets: try: - _convert_one(proj, u, args.out) + _convert_one(proj, u, args.out, pr=pr) except Exception as e: # noqa: BLE001 print(f" FAIL {u[:12]}: {e}", file=sys.stderr) return 0 diff --git a/tools/epro2/kicad/sch_writer.py b/tools/epro2/kicad/sch_writer.py index df13900..40f69eb 100644 --- a/tools/epro2/kicad/sch_writer.py +++ b/tools/epro2/kicad/sch_writer.py @@ -19,6 +19,7 @@ import math import uuid as _uuid from dataclasses import dataclass +from ..project_relations import ProjectRelations from ..relations import Relations from ..replay import Document from .sexpr import Sym, to_sexpr @@ -41,6 +42,8 @@ class WriteStats: symbol_placements: int = 0 text: int = 0 skipped: int = 0 + lib_symbols_embedded: int = 0 + lib_symbols_missing: int = 0 def _new_uuid() -> str: @@ -66,11 +69,18 @@ def write_sch_page( *, title: str | None = None, sheet_origin_mm: tuple[float, float] = (25.4, 25.4), + project_relations: ProjectRelations | None = None, ) -> str: """Render a single SCH_PAGE Document as kicad_sch text. ``sheet_origin_mm`` is added to every coordinate so the schematic doesn't sit at (0,0) (KiCad's title block lives there). Default 1 inch margin. + + When ``project_relations`` is given, the ``lib_symbols`` block is + populated by resolving each placement's ``partId`` to the SYMBOL doc(s) + 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 ``?``). """ if doc.doc_type != "SCH_PAGE": raise ValueError(f"expected SCH_PAGE doc, got {doc.doc_type!r}") @@ -165,6 +175,10 @@ def write_sch_page( or doc.doc_uuid[:12] ) + # Populate lib_symbols block from used partIds (Phase 2). + lib_symbols = _build_lib_symbols(rel, project_relations, stats) \ + if project_relations is not None else [Sym("lib_symbols")] + sch: list = [ Sym("kicad_sch"), [Sym("version"), KICAD_SCH_VERSION], @@ -175,7 +189,7 @@ def write_sch_page( [Sym("title"), sch_title], [Sym("comment"), 1, f"epro2 doc_uuid: {doc.doc_uuid}"], [Sym("comment"), 2, f"editor: {doc.head.get('editVersion','')}"]], - [Sym("lib_symbols")], # empty for Phase 1; Phase 2 will populate + lib_symbols, *elements, [Sym("sheet_instances"), [Sym("path"), "/", [Sym("page"), "1"]]], @@ -184,3 +198,46 @@ def write_sch_page( # Stash stats on the function for tests / CLI to inspect. write_sch_page.last_stats = stats # type: ignore[attr-defined] return to_sexpr(sch, pretty=True) + + +def _build_lib_symbols( + sch_rel: Relations, + pr: ProjectRelations, + stats: WriteStats, +) -> list: + """Resolve every COMPONENT.partId on this sheet to a SYMBOL doc and + embed its body in the ``(lib_symbols ...)`` block. + + Returns the full ``[Sym("lib_symbols"), <symbol entry>, ...]`` list. + """ + from .sym_writer import write_lib_symbol # local to avoid cycle + + used_part_ids: set[str] = set() + for cid, comp in sch_rel.components.items(): + pid = comp.get("partId") + if pid: + used_part_ids.add(str(pid)) + + block: list = [Sym("lib_symbols")] + seen_emitted: set[str] = set() + for pid in sorted(used_part_ids): + sym_docs = pr.parts_by_id.get(pid, []) + if not sym_docs: + stats.lib_symbols_missing += 1 + continue + # Use first SYMBOL doc. Skip if same partId already emitted (dedupe). + if pid in seen_emitted: + continue + sym_doc = pr.project.documents.get(sym_docs[0]) + if not sym_doc: + stats.lib_symbols_missing += 1 + continue + entry = write_lib_symbol(sym_doc) + if entry is None: + stats.lib_symbols_missing += 1 + continue + block.append(entry) + seen_emitted.add(pid) + stats.lib_symbols_embedded += 1 + + return block diff --git a/tools/epro2/kicad/sym_writer.py b/tools/epro2/kicad/sym_writer.py new file mode 100644 index 0000000..b8e9205 --- /dev/null +++ b/tools/epro2/kicad/sym_writer.py @@ -0,0 +1,179 @@ +"""Convert one EPRO2 SYMBOL Document → a KiCad ``(symbol ...)`` lib entry. + +Phase-2 scope: render the SYMBOL primitives so KiCad's lib_symbols block can +provide a real graphical body for each placement (vs the Phase-1 red ``?``). + +Coverage / fidelity: + - PART → outer (symbol "facere:<partId>" ...) wrapper + properties + - PIN + ATTR → (pin <type> line (at x y rot) (length L) (name ...) (number ...)) + with ATTR(parent=pin, key="Pin Name"/"Pin Number"/"Pin Type") + pulled from sibling ATTR ops + - RECT → (rectangle (start ...) (end ...) ...) + - POLY → (polyline (pts (xy ...) ...) ...) with closed→fill + - CIRCLE → (circle (center ...) (radius ...) ...) + - TEXT → (text "..." (at ...) ...) + - ATTR (no parent / on PART) → contributes to symbol-level Reference/Value/... + (best-effort; mostly ignored at body level) + +Coordinate convention: + EPRO2 SYMBOL primitives use **mil** (same as schematic); we convert via + ``MIL_TO_MM = 0.0254``. KiCad lib symbol coords are **Y-up** internally, + but the placement of pins relative to body origin is what matters; for + ESP-VoCat the empirical Y orientation is consistent (pins on left at -X, + pins on right at +X), so we do not flip Y. If KiCad renders flipped, the + fix is a per-axis sign in ``_pt`` here. +""" + +from __future__ import annotations + +from ..relations import Relations +from ..replay import Document +from .sch_writer import MIL_TO_MM +from .sexpr import Sym + +# EPRO2 Pin Type -> KiCad electrical type. Empirical mapping; defaults to passive. +PIN_TYPE_MAP: dict[str, str] = { + "IN": "input", + "OUT": "output", + "BIDIR": "bidirectional", + "BIDIRECTIONAL": "bidirectional", + "TRI_STATE": "tri_state", + "TRISTATE": "tri_state", + "PASSIVE": "passive", + "POWER_IN": "power_in", + "POWER_OUT": "power_out", + "OPEN_COLLECTOR": "open_collector", + "OPEN_EMITTER": "open_emitter", + "NC": "no_connect", + "NOT_CONNECTED": "no_connect", + "UNSPECIFIED": "unspecified", +} + + +def _pt(v) -> float: + """Coerce a mil number to mm. None → 0.0.""" + if v is None: + return 0.0 + try: + return float(v) * MIL_TO_MM + except (TypeError, ValueError): + return 0.0 + + +def _stroke(width: float = 0.254) -> list: + return [Sym("stroke"), [Sym("width"), width], [Sym("type"), Sym("default")]] + + +def _fill(kind: str = "none") -> list: + return [Sym("fill"), [Sym("type"), Sym(kind)]] + + +def _font(size: float = 1.27) -> list: + return [Sym("effects"), [Sym("font"), [Sym("size"), size, size]]] + + +def _hidden_font(size: float = 1.27) -> list: + return [Sym("effects"), [Sym("font"), [Sym("size"), size, size]], + [Sym("hide"), Sym("yes")]] + + +def _property(name: str, value: str, idx: int, *, hide: bool = False) -> list: + return [ + Sym("property"), name, value, + [Sym("at"), 0, 0, 0], + _hidden_font() if hide else _font(), + ] + + +def write_lib_symbol(doc: Document, *, lib_prefix: str = "facere") -> list | None: + """Render a SYMBOL Document as a KiCad ``(symbol ...)`` block (S-expr list). + + Returns ``None`` if the doc has no PART (malformed lib doc). The caller + embeds the returned list into a parent ``(lib_symbols ...)`` container. + """ + if doc.doc_type != "SYMBOL": + return None + + rel = Relations.build(doc) + if not rel.parts: + return None + + # Pick the first PART (ESP-VoCat probe shows 1 PART per SYMBOL doc). + part_id, part = next(iter(rel.parts.items())) + title = str(part.get("title") or part_id) + + # Body primitives: RECT, POLY, CIRCLE, TEXT, PIN. + body: list = [Sym("symbol"), f"{part_id}_1_1"] + for oid, obj in doc.objects.items(): + t = obj.get("_type") + # Filter to primitives owned by this part (ignore stray objects) + if obj.get("partId") != part_id: + continue + if t == "RECT": + x1, y1 = _pt(obj.get("dotX1")), _pt(obj.get("dotY1")) + x2, y2 = _pt(obj.get("dotX2")), _pt(obj.get("dotY2")) + body.append([ + Sym("rectangle"), + [Sym("start"), x1, y1], + [Sym("end"), x2, y2], + _stroke(), + _fill(), + ]) + elif t == "POLY": + pts = obj.get("points") or [] + xy_list = [Sym("pts")] + [ + [Sym("xy"), _pt(p.get("x")), _pt(p.get("y"))] for p in pts + if isinstance(p, dict) + ] + if len(xy_list) >= 3: # at least 2 points + the "pts" tag + body.append([ + Sym("polyline"), + xy_list, + _stroke(), + _fill("outline" if obj.get("closed") else "none"), + ]) + elif t == "CIRCLE": + body.append([ + Sym("circle"), + [Sym("center"), _pt(obj.get("centerX")), _pt(obj.get("centerY"))], + [Sym("radius"), _pt(obj.get("radius"))], + _stroke(), + _fill(), + ]) + elif t == "TEXT": + val = str(obj.get("value") or "").strip() + if not val: + continue + body.append([ + Sym("text"), val, + [Sym("at"), _pt(obj.get("x")), _pt(obj.get("y")), + float(obj.get("rotation") or 0)], + _font(), + ]) + elif t == "PIN": + attrs = rel.attrs_dict(oid) + pin_number = str(attrs.get("Pin Number") or "") + pin_name = str(attrs.get("Pin Name") or "") + pin_type_raw = str(attrs.get("Pin Type") or "").upper() + elec = PIN_TYPE_MAP.get(pin_type_raw, "passive") + body.append([ + Sym("pin"), Sym(elec), Sym("line"), + [Sym("at"), _pt(obj.get("x")), _pt(obj.get("y")), + float(obj.get("rotation") or 0)], + [Sym("length"), _pt(obj.get("length"))], + [Sym("name"), pin_name or "~", _font()], + [Sym("number"), pin_number or "~", _font()], + ]) + + return [ + Sym("symbol"), f"{lib_prefix}:{part_id}", + [Sym("pin_numbers"), [Sym("hide"), Sym("no")]], + [Sym("pin_names"), [Sym("offset"), 1.016]], + [Sym("in_bom"), Sym("yes")], + [Sym("on_board"), Sym("yes")], + _property("Reference", "U", 0), + _property("Value", title, 1), + _property("Footprint", "", 2, hide=True), + _property("Datasheet", "", 3, hide=True), + body, + ] diff --git a/tools/epro2/tests/test_sym_writer.py b/tools/epro2/tests/test_sym_writer.py new file mode 100644 index 0000000..7d381ac --- /dev/null +++ b/tools/epro2/tests/test_sym_writer.py @@ -0,0 +1,126 @@ +"""Symbol writer regression: synthetic SYMBOL doc → KiCad lib symbol entry.""" + +from tools.epro2.kicad._sexpr_reader import parse +from tools.epro2.kicad.sch_writer import write_sch_page +from tools.epro2.kicad.sexpr import to_sexpr +from tools.epro2.kicad.sym_writer import write_lib_symbol +from tools.epro2.project_relations import ProjectRelations +from tools.epro2.replay import Document, Project + + +def _sym_doc(uuid: str, part_id: str, primitives: list[tuple[str, dict]]) -> Document: + d = Document(doc_uuid=uuid, doc_type="SYMBOL") + d.objects[part_id] = {"_type": "PART", "BBOX": [-10, 10, 10, -10], "title": part_id} + for k, v in primitives: + d.objects[k] = v + return d + + +def _block(parsed, name): + return [c for c in parsed if isinstance(c, list) and c and c[0] == name] + + +def test_write_lib_symbol_emits_outer_wrapper_and_body(): + d = _sym_doc("sym1", "MyPart.1", []) + entry = write_lib_symbol(d) + assert entry is not None + text = to_sexpr(entry) + p = parse(text) + assert p[0] == "symbol" + assert p[1] == "facere:MyPart.1" + # Properties: Reference / Value / Footprint / Datasheet + props = _block(p, "property") + by_name = {x[1]: x[2] for x in props} + assert by_name["Reference"] == "U" + assert by_name["Value"] == "MyPart.1" + assert by_name["Footprint"] == "" + # Inner body wrapper named <part>_1_1 + inner = _block(p, "symbol") + assert len(inner) == 1 + assert inner[0][1] == "MyPart.1_1_1" + + +def test_pin_renders_with_attr_pulled_metadata(): + d = _sym_doc("sym1", "MyPart.1", [ + ("e5", {"_type": "PIN", "partId": "MyPart.1", + "x": -15, "y": 0, "length": 10, "rotation": 0}), + ("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "VCC"}), + ("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}), + ("e8", {"_type": "ATTR", "parentId": "e5", "key": "Pin Type", "value": "POWER_IN"}), + ]) + entry = write_lib_symbol(d) + text = to_sexpr(entry) + parsed = parse(text) + inner_body = next(c for c in parsed if isinstance(c, list) and c and c[0] == "symbol") + pins = [c for c in inner_body if isinstance(c, list) and c and c[0] == "pin"] + assert len(pins) == 1 + pin = pins[0] + assert pin[1] == "power_in" # electrical type mapped + assert pin[2] == "line" # shape + name_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "name") + num_block = next(c for c in pin if isinstance(c, list) and c and c[0] == "number") + assert name_block[1] == "VCC" + assert num_block[1] == "1" + + +def test_rect_poly_circle_text_emit_correct_shapes(): + d = _sym_doc("sym1", "MyPart.1", [ + ("r1", {"_type": "RECT", "partId": "MyPart.1", + "dotX1": -10, "dotY1": -5, "dotX2": 10, "dotY2": 5}), + ("p1", {"_type": "POLY", "partId": "MyPart.1", + "points": [{"x": 0, "y": 0}, {"x": 5, "y": 0}, {"x": 5, "y": 5}], + "closed": True}), + ("c1", {"_type": "CIRCLE", "partId": "MyPart.1", + "centerX": 0, "centerY": 0, "radius": 3}), + ("t1", {"_type": "TEXT", "partId": "MyPart.1", + "x": 0, "y": -5, "value": "label"}), + ]) + entry = write_lib_symbol(d) + parsed = parse(to_sexpr(entry)) + inner = next(c for c in parsed if isinstance(c, list) and c[0] == "symbol") + types = [c[0] for c in inner if isinstance(c, list)] + assert "rectangle" in types + assert "polyline" in types + assert "circle" in types + assert "text" in types + + +def test_non_symbol_doc_returns_none(): + d = Document(doc_uuid="x", doc_type="SCH_PAGE") + assert write_lib_symbol(d) is None + + +def test_sch_writer_embeds_lib_symbols_via_project_relations(): + sym = _sym_doc("sym1", "MyPart.1", [ + ("e5", {"_type": "PIN", "partId": "MyPart.1", "x": 0, "y": 0, "length": 10}), + ("e6", {"_type": "ATTR", "parentId": "e5", "key": "Pin Name", "value": "1"}), + ("e7", {"_type": "ATTR", "parentId": "e5", "key": "Pin Number", "value": "1"}), + ]) + sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE") + sch.objects["e1"] = {"_type": "COMPONENT", "partId": "MyPart.1", "x": 0, "y": 0, "rotation": 0} + sch.objects["e2"] = {"_type": "COMPONENT", "partId": "GhostPart.99", "x": 50, "y": 50} + + p = Project(project_uuid="p") + p.documents["sym1"] = sym + p.documents["sch1"] = sch + pr = ProjectRelations.build(p) + + text = write_sch_page(sch, project_relations=pr) + parsed = parse(text) + lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols") + embedded = [c for c in lib[1:] if isinstance(c, list) and c[0] == "symbol"] + assert len(embedded) == 1 # MyPart found, GhostPart missed + assert embedded[0][1] == "facere:MyPart.1" + stats = getattr(write_sch_page, "last_stats") + assert stats.lib_symbols_embedded == 1 + assert stats.lib_symbols_missing == 1 + + +def test_sch_writer_without_project_relations_emits_empty_lib_symbols(): + sch = Document(doc_uuid="sch1", doc_type="SCH_PAGE") + sch.objects["e1"] = {"_type": "COMPONENT", "partId": "X.1", "x": 0, "y": 0} + text = write_sch_page(sch) # no pr passed + parsed = parse(text) + lib = next(c for c in parsed if isinstance(c, list) and c[0] == "lib_symbols") + # Phase-1 stub: just `(lib_symbols)` with no children + assert lib == ["lib_symbols"]