"""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 ..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 skipped: 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), ) -> 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. """ 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 ...). 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 # 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 # 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] ) 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','')}"]], [Sym("lib_symbols")], # empty for Phase 1; Phase 2 will populate *elements, [Sym("sheet_instances"), [Sym("path"), "/", [Sym("page"), "1"]]], ] # 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)