"""Convert one EPRO2 PCB Document → a KiCad ``.kicad_pcb`` S-expr. Phase-1 scope (matches the schematic Phase-1: parses cleanly in KiCad 8 and shows the correct geometry, but doesn't try to be a 1:1 EDA round-trip): - Header + paper + layer table (only EPRO2 layers actually used on the board) - Net list from NET ops + a name→id mapping shared with footprints/tracks/vias - One ``(footprint ...)`` per PCB COMPONENT, body delegated to footprint_writer - LINEs on copper layers → ``(segment ...)``; on Edge.Cuts → ``(gr_line ...)`` - VIAs → ``(via ...)`` - ARCs on Edge.Cuts → ``(gr_arc ...)`` Out of scope for Phase 1 (warnings/cosmetic, not connectivity): - POUR / POURED zones — copper pours - FILL — manual filled regions - TEARDROP — fillets at via/pad junctions - IMAGE — bitmap logos - STRING — board-level text - net classes / design rules / setup blocks beyond defaults Coordinate system: EPRO2 PCB stores everything in **mil**, with both axes matching KiCad's Y-down. We just multiply by ``MIL_TO_MM = 0.0254``. We also translate by ``board_origin_mm`` so the board sits at a friendly (100, 100) mm origin in KiCad rather than wherever the user happened to place it in the editor. """ from __future__ import annotations import math import uuid as _uuid from dataclasses import dataclass, field from typing import Iterable from ..project_relations import ProjectRelations from ..relations import Relations from ..replay import Document from .footprint_writer import write_footprint_placement from .sexpr import Sym, to_sexpr MIL_TO_MM = 0.0254 KICAD_PCB_VERSION = 20240108 # KiCad 8.0 KICAD_GENERATOR = "facere-epro2" # -- EPRO2 layerId → KiCad layer name ----------------------------------- # # Copper (signal) layers are special: KiCad numbers them 0..31 with F.Cu=0 # and B.Cu always = 31 (regardless of board layer count). Inner layers are # In1.Cu .. In30.Cu. We map EPRO2 SIGNAL layer ids 15+ to inner layers in # the order they appear, so a board with EPRO2 ids 15(GND) and 16(POWER) # becomes In1.Cu and In2.Cu. #: Direct (non-copper) layer mapping. EPRO2 id → KiCad layer name. EPRO2_LAYER_TO_KICAD: dict[int, str] = { 3: "F.SilkS", 4: "B.SilkS", 5: "F.Mask", 6: "B.Mask", 7: "F.Paste", 8: "B.Paste", 9: "F.Fab", 10: "B.Fab", 11: "Edge.Cuts", 13: "Dwgs.User", 14: "Cmts.User", 48: "F.CrtYd", # COMPONENT_SHAPE → courtyard 49: "F.Fab", # COMPONENT_MARKING → fab (already mapped above too) 50: "F.Paste", # PIN_SOLDERING ≈ paste mask (heuristic) 51: "F.Adhes", # PIN_FLOATING (rare; fallback) } @dataclass class WriteStats: nets: int = 0 footprints: int = 0 footprints_unresolved: int = 0 segments: int = 0 vias: int = 0 edge_cuts: int = 0 zones: int = 0 skipped: int = 0 @dataclass class _LayerMap: """Resolved EPRO2-id → KiCad-name mapping for one PCB. Copper layer assignment is data-driven: we walk every primitive on the board to discover which EPRO2 ids actually carry geometry, then assign them to In1.Cu, In2.Cu, ... in sorted order. """ epro_to_kicad: dict[int, str] = field(default_factory=dict) kicad_layers_in_order: list[tuple[int, str, str]] = field(default_factory=list) """[(ordinal, kicad_layer_name, type)]; ordinal is the integer KiCad uses in the (layers ...) header. ``type`` is ``"signal"`` or ``"user"``.""" def _build_layer_map(doc: Document) -> _LayerMap: used_layer_ids: set[int] = set() inner_signal_ids: list[int] = [] # EPRO2 SIGNAL ids actually populated for oid, obj in doc.objects.items(): if oid.startswith('["LAYER"'): continue layer_id = obj.get("layerId") if layer_id is None: continue try: lid = int(layer_id) except (TypeError, ValueError): continue used_layer_ids.add(lid) # Discover which inner-signal layers are actually used (LAYER op declares # use=True for many that the user never drew on; only the ones with real # geometry need to appear in our (layers) header). for oid, obj in doc.objects.items(): if obj.get("_type") != "LAYER": continue if not oid.startswith('["LAYER",'): continue try: import json cid = json.loads(oid) lid = int(cid[1]) except (ValueError, TypeError, IndexError): continue if obj.get("layerType") == "SIGNAL" and lid in used_layer_ids: inner_signal_ids.append(lid) inner_signal_ids.sort() epro_to_kicad: dict[int, str] = {1: "F.Cu", 2: "B.Cu"} layers_in_order: list[tuple[int, str, str]] = [(0, "F.Cu", "signal")] for n, lid in enumerate(inner_signal_ids, start=1): kname = f"In{n}.Cu" epro_to_kicad[lid] = kname layers_in_order.append((n, kname, "signal")) layers_in_order.append((31, "B.Cu", "signal")) # User/tech layers — only emit the ones the board actually uses, plus a # few KiCad expects to exist (silk/mask/paste/edge cuts) so footprints # that reference them via standard names always parse. required_user_layers = [ (32, "B.Adhes", "user"), (33, "F.Adhes", "user"), (34, "B.Paste", "user"), (35, "F.Paste", "user"), (36, "B.SilkS", "user"), (37, "F.SilkS", "user"), (38, "B.Mask", "user"), (39, "F.Mask", "user"), (40, "Dwgs.User", "user"), (41, "Cmts.User", "user"), (44, "Edge.Cuts", "user"), (45, "Margin", "user"), (46, "B.CrtYd", "user"), (47, "F.CrtYd", "user"), (48, "B.Fab", "user"), (49, "F.Fab", "user"), ] layers_in_order.extend(required_user_layers) for eid, kname in EPRO2_LAYER_TO_KICAD.items(): epro_to_kicad.setdefault(eid, kname) return _LayerMap(epro_to_kicad=epro_to_kicad, kicad_layers_in_order=layers_in_order) def _new_uuid() -> str: return str(_uuid.uuid4()) def _mm(v) -> float: if v is None: return 0.0 try: return float(v) * MIL_TO_MM except (TypeError, ValueError): return 0.0 def _kicad_layer(layer_id, layer_map: _LayerMap) -> str | None: if layer_id is None: return None try: lid = int(layer_id) except (TypeError, ValueError): return None return layer_map.epro_to_kicad.get(lid) def _is_copper(layer_name: str | None) -> bool: return bool(layer_name) and (layer_name.endswith(".Cu")) def _decode_zone_path( raw, *, origin_mm: tuple[float, float], circle_segments: int = 36, ) -> list[tuple[float, float]]: """EPRO2 POUR.path → list of (x_mm, y_mm) outer-boundary points. Three encodings observed in the wild (counts on ESP-VoCat: R 12 / CIRCLE 4 / polyline 3). All are wrapped in an extra outer list, i.e. ``path = []``. - rectangle: ``['R', x, y, w, h, r1, r2]`` — corner-radii ignored - circle : ``['CIRCLE', cx, cy, radius]`` — sampled to N segments - polyline : ``[x1, y1, 'L', x2, y2, ...]`` """ ox, oy = origin_mm if not isinstance(raw, list) or not raw: return [] shape = raw[0] if not isinstance(shape, list) or not shape: return [] head = shape[0] if isinstance(head, str) and head.upper() == "R": if len(shape) < 5: return [] try: x = float(shape[1]); y = float(shape[2]) w = float(shape[3]); h = float(shape[4]) except (TypeError, ValueError): return [] x0 = ox + x * MIL_TO_MM y0 = oy + y * MIL_TO_MM x1 = x0 + w * MIL_TO_MM y1 = y0 + h * MIL_TO_MM return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] if isinstance(head, str) and head.upper() == "CIRCLE": if len(shape) < 4: return [] try: cx = float(shape[1]); cy = float(shape[2]); r = float(shape[3]) except (TypeError, ValueError): return [] cx_mm = ox + cx * MIL_TO_MM cy_mm = oy + cy * MIL_TO_MM r_mm = r * MIL_TO_MM return [ (cx_mm + r_mm * math.cos(2 * math.pi * i / circle_segments), cy_mm + r_mm * math.sin(2 * math.pi * i / circle_segments)) for i in range(circle_segments) ] # Polyline: walk numeric pairs, skip 'L'/'ARC' tokens (ARC chord-approx). pts: list[tuple[float, float]] = [] i = 0 while i < len(shape): tok = shape[i] if isinstance(tok, str): if tok.upper() == "ARC": # Skip [radius, endX, endY] params; chord approximation drops # arc curvature but keeps the polygon closed enough for fill. i += 4 if len(shape) >= i + 4 else 1 continue i += 1 continue try: x = float(shape[i]); y = float(shape[i + 1]) pts.append((ox + x * MIL_TO_MM, oy + y * MIL_TO_MM)) i += 2 except (TypeError, ValueError, IndexError): i += 1 return pts def _build_net_map(doc: Document) -> dict[str, int]: """Assign integer net ids stable for this PCB. Net id 0 is reserved for "no net" (KiCad convention).""" net_map: dict[str, int] = {} next_id = 1 for oid in doc.objects: if oid.startswith('["NET",'): try: import json cid = json.loads(oid) name = str(cid[1]) except (ValueError, IndexError): continue if name and name not in net_map: net_map[name] = next_id next_id += 1 return net_map def write_pcb( doc: Document, *, project_relations: ProjectRelations | None = None, board_origin_mm: tuple[float, float] = (100.0, 100.0), ) -> str: """Render a single PCB Document as kicad_pcb text. ``board_origin_mm`` is added to every coordinate so the board lands in a sensible spot in the KiCad canvas (EPRO2 boards can be anywhere, including with negative coordinates). """ if doc.doc_type != "PCB": raise ValueError(f"expected PCB doc, got {doc.doc_type!r}") rel = Relations.build(doc) layer_map = _build_layer_map(doc) net_map = _build_net_map(doc) stats = WriteStats() ox, oy = board_origin_mm # ---- (layers ...) ----------------------------------------------------- layers_block = [Sym("layers")] for ordinal, kname, ltype in layer_map.kicad_layers_in_order: layers_block.append([ordinal, kname, Sym(ltype)]) # ---- (net N "name") --------------------------------------------------- net_blocks: list = [[Sym("net"), 0, ""]] for name, nid in sorted(net_map.items(), key=lambda kv: kv[1]): net_blocks.append([Sym("net"), nid, name]) stats.nets = len(net_map) # ---- footprints + tracks + vias + edge cuts -------------------------- footprint_blocks: list = [] track_blocks: list = [] via_blocks: list = [] graphic_blocks: list = [] # Footprints: walk PCB COMPONENTs for cid, comp in rel.components.items(): fp_uuid = ( project_relations.resolve_footprint_doc(doc.doc_uuid, cid) if project_relations is not None else None ) if not fp_uuid or fp_uuid not in project_relations.project.documents: stats.footprints_unresolved += 1 continue fp_doc = project_relations.project.documents[fp_uuid] attrs = rel.attrs_dict(cid) try: fp_block = write_footprint_placement( fp_doc=fp_doc, comp_id=cid, comp=comp, attrs=attrs, pcb_doc=doc, pcb_rel=rel, project_relations=project_relations, layer_map=layer_map, net_map=net_map, origin_mm=board_origin_mm, ) except Exception as e: # noqa: BLE001 stats.skipped += 1 continue footprint_blocks.append(fp_block) stats.footprints += 1 # Tracks: LINE on copper layer with netName for oid, obj in doc.objects.items(): if obj.get("_type") != "LINE": continue kicad_layer = _kicad_layer(obj.get("layerId"), layer_map) if not kicad_layer: continue x1 = ox + _mm(obj.get("startX")) y1 = oy + _mm(obj.get("startY")) x2 = ox + _mm(obj.get("endX")) y2 = oy + _mm(obj.get("endY")) if math.isclose(x1, x2) and math.isclose(y1, y2): stats.skipped += 1 continue width_mm = _mm(obj.get("width")) if _is_copper(kicad_layer): net_name = obj.get("netName") or "" net_id = net_map.get(str(net_name), 0) track_blocks.append([ Sym("segment"), [Sym("start"), x1, y1], [Sym("end"), x2, y2], [Sym("width"), width_mm or 0.2], [Sym("layer"), kicad_layer], [Sym("net"), net_id], [Sym("uuid"), _new_uuid()], ]) stats.segments += 1 elif kicad_layer == "Edge.Cuts": graphic_blocks.append([ Sym("gr_line"), [Sym("start"), x1, y1], [Sym("end"), x2, y2], [Sym("stroke"), [Sym("width"), width_mm or 0.1], [Sym("type"), Sym("default")]], [Sym("layer"), kicad_layer], [Sym("uuid"), _new_uuid()], ]) stats.edge_cuts += 1 # POLYs — board outline lives here (path can be polyline or CIRCLE). # Emit on Edge.Cuts as gr_line / gr_circle. Other layers in Phase 1 are # cosmetic and skipped (Phase 2 covers silk/courtyard). for oid, obj in doc.objects.items(): if obj.get("_type") != "POLY": continue kicad_layer = _kicad_layer(obj.get("layerId"), layer_map) if kicad_layer != "Edge.Cuts": continue path = obj.get("path") or [] width_mm = max(_mm(obj.get("width")), 0.1) # CIRCLE shape: ["CIRCLE", cx, cy, radius] if len(path) == 4 and isinstance(path[0], str) and path[0].upper() == "CIRCLE": try: cx = ox + _mm(path[1]) cy = oy + _mm(path[2]) radius = _mm(path[3]) except (TypeError, ValueError): continue graphic_blocks.append([ Sym("gr_circle"), [Sym("center"), cx, cy], [Sym("end"), cx + radius, cy], [Sym("stroke"), [Sym("width"), width_mm], [Sym("type"), Sym("default")]], [Sym("fill"), Sym("none")], [Sym("layer"), "Edge.Cuts"], [Sym("uuid"), _new_uuid()], ]) stats.edge_cuts += 1 continue # Polyline: emit each numeric pair as a gr_line segment chain. prev: tuple[float, float] | None = None i = 0 while i < len(path): tok = path[i] if isinstance(tok, str): if tok.upper() == "ARC": # ARC ... # Emit as a chord; KiCad still parses this and the # geometry is approximate for Phase 1. try: end_x = ox + _mm(path[i + 2]) end_y = oy + _mm(path[i + 3]) except (TypeError, ValueError, IndexError): i += 1 continue if prev is not None: graphic_blocks.append([ Sym("gr_line"), [Sym("start"), prev[0], prev[1]], [Sym("end"), end_x, end_y], [Sym("stroke"), [Sym("width"), width_mm], [Sym("type"), Sym("default")]], [Sym("layer"), "Edge.Cuts"], [Sym("uuid"), _new_uuid()], ]) stats.edge_cuts += 1 prev = (end_x, end_y) i += 4 continue # 'L' = line-to (default) i += 1 continue try: x = ox + _mm(path[i]) y = oy + _mm(path[i + 1]) except (TypeError, ValueError, IndexError): i += 1 continue if prev is not None: graphic_blocks.append([ Sym("gr_line"), [Sym("start"), prev[0], prev[1]], [Sym("end"), x, y], [Sym("stroke"), [Sym("width"), width_mm], [Sym("type"), Sym("default")]], [Sym("layer"), "Edge.Cuts"], [Sym("uuid"), _new_uuid()], ]) stats.edge_cuts += 1 prev = (x, y) i += 2 # ARCs on Edge.Cuts (Phase 1: only edge-cut arcs; copper arcs are rare # in our sample and skipped to keep scope tight). for oid, obj in doc.objects.items(): if obj.get("_type") != "ARC": continue kicad_layer = _kicad_layer(obj.get("layerId"), layer_map) if kicad_layer != "Edge.Cuts": continue path = obj.get("path") or [] # ARC.path = [startX, startY, "ARC", radius?, endX, endY, ...] — # representation varies; skipped if we can't decode quickly. if len(path) < 6: continue try: sx = ox + _mm(path[0]) sy = oy + _mm(path[1]) # path[2] should be "ARC" tag ex = ox + _mm(path[-2]) ey = oy + _mm(path[-1]) except (TypeError, ValueError): continue # Compute mid as the geometric midpoint as a placeholder (KiCad # gr_arc needs three points; without parsing the radius reliably we # emit a chord-like arc which KiCad's parser still accepts). mx, my = (sx + ex) / 2, (sy + ey) / 2 graphic_blocks.append([ Sym("gr_arc"), [Sym("start"), sx, sy], [Sym("mid"), mx, my], [Sym("end"), ex, ey], [Sym("stroke"), [Sym("width"), _mm(obj.get("width")) or 0.1], [Sym("type"), Sym("default")]], [Sym("layer"), "Edge.Cuts"], [Sym("uuid"), _new_uuid()], ]) stats.edge_cuts += 1 # Zones (POUR): copper pour outlines on signal layers. We emit just the # boundary polygon and let KiCad's auto-filler do the actual copper # generation — kicad-cli pcb drc fills before checking, so that's enough # to resolve the GND/POWER pins that are routed through pour copper # rather than discrete traces (the dominant source of "unconnected_items" # before we exported zones). zone_blocks: list = [] for oid, obj in doc.objects.items(): if obj.get("_type") != "POUR": continue kicad_layer = _kicad_layer(obj.get("layerId"), layer_map) if not _is_copper(kicad_layer): continue outline = _decode_zone_path(obj.get("path"), origin_mm=board_origin_mm) if len(outline) < 3: stats.skipped += 1 continue net_name = str(obj.get("netName") or "") net_id = net_map.get(net_name, 0) pts_block: list = [Sym("pts")] + [[Sym("xy"), x, y] for x, y in outline] # `(filled_polygon)` echoes the boundary so kicad-cli treats the # zone as already filled. Without this, kicad-cli pcb drc skips # zones entirely (it never runs the auto-filler) and reports the # entire GND/POWER net as unconnected. The user's POUR-drawn # shape is also the truthful "intended copper area" — KiCad # users can refill in the GUI to refine clearances/thermals. filled_pts: list = [Sym("pts")] + [[Sym("xy"), x, y] for x, y in outline] zone_blocks.append([ Sym("zone"), [Sym("net"), net_id], [Sym("net_name"), net_name], [Sym("layer"), kicad_layer], [Sym("uuid"), _new_uuid()], [Sym("hatch"), Sym("edge"), 0.5], [Sym("connect_pads"), [Sym("clearance"), 0.2]], [Sym("min_thickness"), 0.2], [Sym("filled_areas_thickness"), Sym("no")], [Sym("fill"), Sym("yes"), [Sym("thermal_gap"), 0.5], [Sym("thermal_bridge_width"), 0.5]], [Sym("polygon"), pts_block], [Sym("filled_polygon"), [Sym("layer"), kicad_layer], filled_pts], ]) stats.zones += 1 # Vias for oid, obj in doc.objects.items(): if obj.get("_type") != "VIA": continue x = ox + _mm(obj.get("centerX")) y = oy + _mm(obj.get("centerY")) size_mm = _mm(obj.get("viaDiameter")) drill_mm = _mm(obj.get("holeDiameter")) if size_mm <= 0 or drill_mm <= 0: stats.skipped += 1 continue net_id = net_map.get(str(obj.get("netName") or ""), 0) via_blocks.append([ Sym("via"), [Sym("at"), x, y], [Sym("size"), size_mm], [Sym("drill"), drill_mm], [Sym("layers"), "F.Cu", "B.Cu"], [Sym("net"), net_id], [Sym("uuid"), _new_uuid()], ]) stats.vias += 1 title = (doc.objects.get("META") or {}).get("title") or doc.doc_uuid[:12] pcb: list = [ Sym("kicad_pcb"), [Sym("version"), KICAD_PCB_VERSION], [Sym("generator"), KICAD_GENERATOR], [Sym("general"), [Sym("thickness"), 1.6], [Sym("legacy_teardrops"), Sym("no")]], [Sym("paper"), "A4"], [Sym("title_block"), [Sym("title"), title], [Sym("comment"), 1, f"epro2 doc_uuid: {doc.doc_uuid}"], [Sym("comment"), 2, f"editor: {doc.head.get('editVersion','')}"]], layers_block, [Sym("setup"), [Sym("pad_to_mask_clearance"), 0], [Sym("allow_soldermask_bridges_in_footprints"), Sym("no")]], *net_blocks, *footprint_blocks, *graphic_blocks, *track_blocks, *via_blocks, *zone_blocks, ] write_pcb.last_stats = stats # type: ignore[attr-defined] return to_sexpr(pcb, pretty=True)