"""Convert one EPRO2 SCH_PAGE Document → a KiCad ``.kicad_sch`` S-expr. Phase-1 scope: paper + wires + junctions + symbol placements (no symbol body bundled in lib_symbols). KiCad will render the resulting .kicad_sch with visible wires/junctions but the symbol instances will appear as red question marks until lib_symbols is populated (next phase). Coordinate system: - EPRO2 schematic uses **mil** as its internal unit. - KiCad uses **mm**, with origin top-left, +Y down. - Y-axis already aligned (both Y-down), so we just apply ``MIL_TO_MM``. - We additionally translate so that the schematic's own bounding box has a small margin from page origin — keeps everything visible on A4. """ from __future__ import annotations 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 MIL_TO_MM = 0.0254 # KiCad sch S-expr metadata that doesn't depend on content. KICAD_SCH_VERSION = 20231120 KICAD_GENERATOR = "facere-epro2" # Default A4 in mm (KiCad uses these by default for "A4"). PAPER_A4_MM_W = 297.0 PAPER_A4_MM_H = 210.0 @dataclass class WriteStats: wires: int = 0 junctions: int = 0 symbol_placements: int = 0 text: int = 0 labels: int = 0 skipped: int = 0 lib_symbols_embedded: int = 0 lib_symbols_missing: int = 0 def _new_uuid() -> str: return str(_uuid.uuid4()) def _mil(v) -> float: """Coerce an EPRO2 mil value to mm. None / non-numeric → 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.0, kind: str = "default") -> list: return [Sym("stroke"), [Sym("width"), width], [Sym("type"), Sym(kind)]] def write_sch_page( doc: Document, *, 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. ``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 ``?``). ``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="/"`` (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}") rel = Relations.build(doc) stats = WriteStats() ox, oy = sheet_origin_mm elements: list = [] # 1. Wires from LINE primitives. Each LINE contributes one (wire ...). # EPRO2 binds wires into nets by NAME (WIRE.NET attr), not just geometry, # so we also emit a (label "") at one endpoint of each named LINE. # Same-named labels on physically distinct LINEs are how KiCad's ERC # recognizes a multi-segment net — without them every LINE looks like a # dangling stub. We label per-LINE (not per-WIRE id) because a single # WIRE op may contain segments that don't share endpoints, and KiCad # flags any unlabeled segment in such a group as wire_dangling. wire_net_cache: dict[str, str | None] = {} for oid, obj in doc.objects.items(): if obj.get("_type") != "LINE": continue x1 = ox + _mil(obj.get("startX")) y1 = oy + _mil(obj.get("startY")) x2 = ox + _mil(obj.get("endX")) y2 = oy + _mil(obj.get("endY")) # KiCad rejects degenerate zero-length wires if math.isclose(x1, x2) and math.isclose(y1, y2): stats.skipped += 1 continue elements.append([ Sym("wire"), [Sym("pts"), [Sym("xy"), x1, y1], [Sym("xy"), x2, y2]], _stroke(0.0), [Sym("uuid"), _new_uuid()], ]) stats.wires += 1 wire_id = obj.get("lineGroup") if not wire_id: continue wid = str(wire_id) if wid not in wire_net_cache: wire_net_cache[wid] = (rel.attrs_dict(wid) or {}).get("NET") 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("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("uuid"), _new_uuid()], ]) stats.labels += 1 # 2. Symbol placements from COMPONENT ops. Body deferred to Phase 2 (lib_symbols). # For now we emit (symbol ...) entries that reference a placeholder lib_id. # KiCad will draw a red ? but the position + properties are correct. for cid, comp in rel.components.items(): x = ox + _mil(comp.get("x")) y = oy + _mil(comp.get("y")) rot = float(comp.get("rotation") or 0) part_id = str(comp.get("partId") or "Unknown") attrs = rel.attrs_dict(cid) designator = str(attrs.get("Designator") or "") value = str(attrs.get("Value") or "") sym_block: list = [ Sym("symbol"), [Sym("lib_id"), f"facere:{part_id}"], [Sym("at"), x, y, rot], [Sym("unit"), 1], [Sym("exclude_from_sim"), Sym("no")], [Sym("in_bom"), Sym("yes")], [Sym("on_board"), Sym("yes")], [Sym("dnp"), Sym("no")], [Sym("uuid"), _new_uuid()], [Sym("property"), "Reference", designator, [Sym("at"), x, y - 5, 0], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]]]], [Sym("property"), "Value", value, [Sym("at"), x, y + 5, 0], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]]]], [Sym("property"), "Footprint", "", [Sym("at"), x, y, 0], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]], [Sym("hide"), Sym("yes")]]], [Sym("property"), "Datasheet", "", [Sym("at"), x, y, 0], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]], [Sym("hide"), Sym("yes")]]], ] 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": continue val = str(obj.get("value") or "").strip() if not val: continue x = ox + _mil(obj.get("x")) y = oy + _mil(obj.get("y")) rot = float(obj.get("rotation") or 0) elements.append([ Sym("text"), val, [Sym("at"), x, y, rot], [Sym("effects"), [Sym("font"), [Sym("size"), 1.27, 1.27]]], [Sym("uuid"), _new_uuid()], ]) stats.text += 1 sch_title = title or ( (doc.objects.get("META") or {}).get("title") 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], [Sym("generator"), KICAD_GENERATOR], [Sym("uuid"), _new_uuid()], [Sym("paper"), "A4"], [Sym("title_block"), [Sym("title"), sch_title], [Sym("comment"), 1, f"epro2 doc_uuid: {doc.doc_uuid}"], [Sym("comment"), 2, f"editor: {doc.head.get('editVersion','')}"]], lib_symbols, *elements, [Sym("sheet_instances"), [Sym("path"), sheet_path, [Sym("page"), str(page_num)]]], ] # 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"), , ...]`` 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