diff --git a/tools/epro2/kicad/__init__.py b/tools/epro2/kicad/__init__.py new file mode 100644 index 0000000..9dbc553 --- /dev/null +++ b/tools/epro2/kicad/__init__.py @@ -0,0 +1,20 @@ +"""KiCad export from EPRO2. + +Phase 1 (current): schematic only — paper, wires, junctions, symbol placements + without symbol bodies. Output is a single .kicad_sch file + that KiCad will open (showing wires + placeholders for + missing symbols). + +Phase 2 (planned): symbol library export (.kicad_sym) from SYMBOL docs; + wire it via lib_symbols block in the .kicad_sch. + +Phase 3 (planned): footprint library + .kicad_pcb generation. + +Coordinates: EasyEDA schematic stream stores in **mil** internally; KiCad uses +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 + +__all__ = ["Sym", "to_sexpr", "write_sch_page"] diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py new file mode 100644 index 0000000..9cefaa9 --- /dev/null +++ b/tools/epro2/kicad/__main__.py @@ -0,0 +1,83 @@ +"""CLI: convert EPRO2 SCH_PAGE docs to KiCad ``.kicad_sch`` files. + +Usage: + uv run python -m tools.epro2.kicad --doc --out + uv run python -m tools.epro2.kicad --all-sch --out + +The ``--all-sch`` form converts every SCH_PAGE in the project, naming each +output by its document title (or doc_uuid prefix as fallback). +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from ..replay import Project, replay_project +from .sch_writer import write_sch_page + + +_SAFE_CHARS = re.compile(r"[^A-Za-z0-9._\-一-鿿]+") + + +def _safe_filename(s: str) -> str: + s = _SAFE_CHARS.sub("_", s).strip("_") + return s or "untitled" + + +def _convert_one(proj: Project, doc_uuid: str, out_dir: Path) -> Path: + if doc_uuid not in proj.documents: + candidates = [u for u in proj.documents if u.startswith(doc_uuid)] + if len(candidates) != 1: + raise SystemExit(f"no unique match for {doc_uuid!r} (candidates: {candidates[:5]})") + doc_uuid = candidates[0] + doc = proj.documents[doc_uuid] + 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) + 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") + stats = getattr(write_sch_page, "last_stats", None) + if stats: + print( + f" {out_path.name}: wires={stats.wires} symbols={stats.symbol_placements} " + f"text={stats.text} skipped={stats.skipped}" + ) + return out_path + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic exporter (Phase 1)") + ap.add_argument("project_dir", type=Path) + 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("--all-sch", action="store_true", help="convert every SCH_PAGE") + ap.add_argument("--out", type=Path, default=Path("data/processed/kicad_sch")) + args = ap.parse_args(argv) + + proj = replay_project(args.project_dir) + args.out.mkdir(parents=True, exist_ok=True) + + if args.doc: + _convert_one(proj, args.doc, args.out) + return 0 + + targets = [u for u, d in proj.documents.items() if d.doc_type == "SCH_PAGE"] + if not targets: + print("no SCH_PAGE docs in this project", file=sys.stderr) + return 1 + print(f"Converting {len(targets)} SCH_PAGE docs → {args.out}") + for u in targets: + try: + _convert_one(proj, u, args.out) + except Exception as e: # noqa: BLE001 + print(f" FAIL {u[:12]}: {e}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/epro2/kicad/_sexpr_reader.py b/tools/epro2/kicad/_sexpr_reader.py new file mode 100644 index 0000000..008eb47 --- /dev/null +++ b/tools/epro2/kicad/_sexpr_reader.py @@ -0,0 +1,88 @@ +"""Tiny S-expression parser used by tests for round-trip validation. + +Not a full KiCad reader — just enough to confirm our writer output is +syntactically valid Lisp-flavored S-expr (balanced parens, quoted strings, +numbers, symbols). +""" + +from __future__ import annotations + +from typing import Iterator + + +def tokens(src: str) -> Iterator[str]: + i, n = 0, len(src) + while i < n: + c = src[i] + if c.isspace(): + i += 1 + continue + if c == "(": + yield "(" + i += 1 + continue + if c == ")": + yield ")" + i += 1 + continue + if c == '"': + j = i + 1 + buf = [] + while j < n: + ch = src[j] + if ch == "\\" and j + 1 < n: + nxt = src[j + 1] + buf.append({"\\": "\\", '"': '"', "n": "\n", "t": "\t"}.get(nxt, nxt)) + j += 2 + continue + if ch == '"': + break + buf.append(ch) + j += 1 + if j >= n: + raise SyntaxError("unterminated string") + yield '"' + "".join(buf) + '"' + i = j + 1 + continue + # bare token until whitespace or paren + j = i + while j < n and not src[j].isspace() and src[j] not in "()": + j += 1 + yield src[i:j] + i = j + + +def parse(src: str): + it = iter(tokens(src)) + def _read(tok): + if tok == "(": + out = [] + while True: + try: + nxt = next(it) + except StopIteration: + raise SyntaxError("unterminated list") + if nxt == ")": + return out + out.append(_read(nxt)) + if tok == ")": + raise SyntaxError("unexpected )") + if tok.startswith('"') and tok.endswith('"'): + return tok[1:-1] + try: + return int(tok) + except ValueError: + try: + return float(tok) + except ValueError: + return tok # symbol + + try: + first = next(it) + except StopIteration: + raise SyntaxError("empty input") + val = _read(first) + # Expect EOF + for extra in it: + raise SyntaxError(f"unexpected trailing token: {extra!r}") + return val diff --git a/tools/epro2/kicad/sch_writer.py b/tools/epro2/kicad/sch_writer.py new file mode 100644 index 0000000..df13900 --- /dev/null +++ b/tools/epro2/kicad/sch_writer.py @@ -0,0 +1,186 @@ +"""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) diff --git a/tools/epro2/kicad/sexpr.py b/tools/epro2/kicad/sexpr.py new file mode 100644 index 0000000..e3af2f8 --- /dev/null +++ b/tools/epro2/kicad/sexpr.py @@ -0,0 +1,75 @@ +"""Hand-rolled S-expression emitter for KiCad files. + +KiCad reads a Lisp-flavored S-expr; values are atoms (symbols / strings / +numbers) and lists. We emit lists as nested Python lists, with ``Sym`` marking +strings that should serialize as bare symbols (unquoted) — e.g. ``Sym("xy")``, +``Sym("kicad_sch")``, ``Sym("yes")``. + +Plain ``str`` values get JSON-style double-quoting + backslash escaping. +Floats render with up to 6 decimals trimmed of trailing zeros (KiCad reads +either form, but trimmed is what kicad-cli emits). +""" + +from __future__ import annotations + +import io +import math +from typing import Any + + +class Sym(str): + """Marker subclass: render as a bare S-expr symbol (unquoted).""" + + __slots__ = () + + +def _fmt_number(n: float | int) -> str: + if isinstance(n, bool): # bool is int subclass — guard first + return "yes" if n else "no" + if isinstance(n, int): + return str(n) + if math.isnan(n) or math.isinf(n): + raise ValueError(f"can't serialize non-finite number: {n}") + s = f"{n:.6f}".rstrip("0").rstrip(".") + return s if s else "0" + + +def _emit(value: Any, out: io.StringIO, indent: int, *, pretty: bool) -> None: + if isinstance(value, list): + out.write("(") + for i, item in enumerate(value): + if i > 0: + if pretty and isinstance(item, list) and len(item) > 1: + out.write("\n" + "\t" * (indent + 1)) + else: + out.write(" ") + _emit(item, out, indent + 1, pretty=pretty) + out.write(")") + elif isinstance(value, Sym): + out.write(str(value)) + elif value is True or value is False: + out.write("yes" if value else "no") + elif isinstance(value, (int, float)): + out.write(_fmt_number(value)) + elif isinstance(value, str): + escaped = value.replace("\\", "\\\\").replace('"', '\\"') + out.write(f'"{escaped}"') + elif value is None: + # rare but sometimes a slot is intentionally empty + out.write('""') + else: + raise TypeError(f"can't S-expr serialize {type(value).__name__}: {value!r}") + + +def to_sexpr(value: Any, *, pretty: bool = True) -> str: + """Render a Python data structure as a KiCad-style S-expression. + + Top-level value is usually a list whose first element is a ``Sym`` (the + block tag, e.g. ``Sym("kicad_sch")``). When ``pretty=True``, child lists + of length > 1 go onto their own indented line. + """ + buf = io.StringIO() + _emit(value, buf, indent=0, pretty=pretty) + if pretty: + buf.write("\n") + return buf.getvalue() diff --git a/tools/epro2/tests/test_sch_writer.py b/tools/epro2/tests/test_sch_writer.py new file mode 100644 index 0000000..4d4ca57 --- /dev/null +++ b/tools/epro2/tests/test_sch_writer.py @@ -0,0 +1,109 @@ +"""Schematic writer regression: synthetic SCH_PAGE → kicad_sch → re-parse.""" + +from tools.epro2.kicad._sexpr_reader import parse +from tools.epro2.kicad.sch_writer import MIL_TO_MM, write_sch_page +from tools.epro2.replay import Document + + +def _doc(objs, doc_uuid="d_test"): + d = Document(doc_uuid=doc_uuid, doc_type="SCH_PAGE") + d.head = {"docType": "SCH_PAGE", "editVersion": "3.2.91"} + for k, v in objs: + d.objects[k] = v + return d + + +def _block(parsed, name): + """Return all top-level child blocks of a kicad_sch named ``name``.""" + return [c for c in parsed if isinstance(c, list) and c and c[0] == name] + + +def test_writer_emits_paper_title_and_lib_symbols(): + d = _doc([("META", {"_type": "META", "title": "Test"})]) + text = write_sch_page(d) + p = parse(text) + assert p[0] == "kicad_sch" + assert _block(p, "paper")[0][1] == "A4" + title = _block(p, "title_block")[0] + assert title[1] == ["title", "Test"] + # lib_symbols stub present (Phase 1) + assert _block(p, "lib_symbols") == [["lib_symbols"]] + + +def test_wires_emit_one_block_per_line_with_mil_to_mm_conversion(): + d = _doc([ + ("ln1", {"_type": "LINE", "lineGroup": "w1", + "startX": 0, "startY": 0, "endX": 1000, "endY": 0}), + ]) + text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0)) + p = parse(text) + wires = _block(p, "wire") + assert len(wires) == 1 + pts = wires[0][1] # ['pts', ['xy', x1, y1], ['xy', x2, y2]] + assert pts[0] == "pts" + assert pts[1][1:] == [0.0, 0.0] + assert pts[2][1] == 1000 * MIL_TO_MM # 25.4 mm + assert pts[2][2] == 0.0 + assert getattr(write_sch_page, "last_stats").wires == 1 + + +def test_zero_length_wire_skipped(): + d = _doc([ + ("ln1", {"_type": "LINE", "startX": 5, "startY": 5, "endX": 5, "endY": 5}), + ]) + text = write_sch_page(d) + p = parse(text) + assert _block(p, "wire") == [] + stats = getattr(write_sch_page, "last_stats") + assert stats.wires == 0 + assert stats.skipped == 1 + + +def test_component_emits_symbol_placement_with_designator(): + d = _doc([ + ("e1", {"_type": "COMPONENT", "partId": "MyPart.1", + "x": 100, "y": 200, "rotation": 90}), + ("a1", {"_type": "ATTR", "parentId": "e1", + "key": "Designator", "value": "R1"}), + ("a2", {"_type": "ATTR", "parentId": "e1", + "key": "Value", "value": "10kΩ"}), + ]) + text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0)) + p = parse(text) + syms = _block(p, "symbol") + assert len(syms) == 1 + sym = syms[0] + # lib_id contains our partId + lib_id = next(c for c in sym if isinstance(c, list) and c[0] == "lib_id") + assert "MyPart.1" in lib_id[1] + # at xy applied + at = next(c for c in sym if isinstance(c, list) and c[0] == "at") + assert at[1] == 100 * MIL_TO_MM + assert at[2] == 200 * MIL_TO_MM + assert at[3] == 90.0 + # properties carry ATTR values + props = [c for c in sym if isinstance(c, list) and c[0] == "property"] + by_name = {p[1]: p[2] for p in props} + assert by_name["Reference"] == "R1" + assert by_name["Value"] == "10kΩ" + + +def test_text_object_emits_text_block_when_non_empty(): + d = _doc([ + ("t1", {"_type": "TEXT", "value": "Hello", "x": 50, "y": 50}), + ("t2", {"_type": "TEXT", "value": "", "x": 60, "y": 60}), # skipped + ]) + text = write_sch_page(d, sheet_origin_mm=(0.0, 0.0)) + p = parse(text) + texts = _block(p, "text") + assert len(texts) == 1 + assert texts[0][1] == "Hello" + + +def test_non_sch_page_doc_rejected(): + d = Document(doc_uuid="x", doc_type="PCB") + try: + write_sch_page(d) + except ValueError: + return + raise AssertionError("expected ValueError for non-SCH_PAGE doc") diff --git a/tools/epro2/tests/test_sexpr.py b/tools/epro2/tests/test_sexpr.py new file mode 100644 index 0000000..6ca78e2 --- /dev/null +++ b/tools/epro2/tests/test_sexpr.py @@ -0,0 +1,54 @@ +"""S-expression emitter tests.""" + +from tools.epro2.kicad._sexpr_reader import parse +from tools.epro2.kicad.sexpr import Sym, to_sexpr + + +def test_round_trip_simple(): + src = [Sym("kicad_sch"), [Sym("version"), 20231120], [Sym("paper"), "A4"]] + text = to_sexpr(src, pretty=False) + parsed = parse(text) + assert parsed[0] == "kicad_sch" + assert parsed[1] == ["version", 20231120] + assert parsed[2] == ["paper", "A4"] + + +def test_string_escaping(): + text = to_sexpr([Sym("title"), 'a "quote" + \\backslash'], pretty=False) + parsed = parse(text) + assert parsed[1] == 'a "quote" + \\backslash' + + +def test_float_formatting_strips_trailing_zeros(): + text = to_sexpr([Sym("x"), 1.500000], pretty=False) + assert "1.5" in text + assert "1.500000" not in text + + +def test_bool_renders_as_yes_no(): + text = to_sexpr([Sym("hide"), True, False], pretty=False) + parsed = parse(text) + assert parsed == ["hide", "yes", "no"] + + +def test_pretty_indent_keeps_round_trip(): + src = [Sym("kicad_sch"), + [Sym("wire"), [Sym("pts"), [Sym("xy"), 1.0, 2.0], [Sym("xy"), 3.0, 4.0]]]] + pretty = to_sexpr(src, pretty=True) + flat = to_sexpr(src, pretty=False) + assert parse(pretty) == parse(flat) + + +def test_nan_inf_rejected(): + import math + try: + to_sexpr([Sym("x"), float("nan")]) + except ValueError: + pass + else: + raise AssertionError("expected ValueError on NaN") + try: + to_sexpr([Sym("x"), math.inf]) + except ValueError: + return + raise AssertionError("expected ValueError on Inf")