"""Convert one EPRO2 PCB COMPONENT + its FOOTPRINT doc → an inline ``(footprint ...)`` block embedded in the parent .kicad_pcb. Phase-1 scope: - Pad emission with EPRO2's RECT / ELLIPSE / OVAL / POLYGON shapes - Pad nets resolved via PAD_NET ops on the PCB (cross-doc lookup) - SMD vs THT detection via FOOTPRINT PAD.hole presence - Silkscreen graphics from POLY ops on silk/courtyard layers - Reference + Value text properties, placed at the COMPONENT origin Out of scope: 3D models, fab notes, custom pad layers per-pad, paste-mask expansion, complex SPECIAL pads with per-layer overrides. """ from __future__ import annotations import math import uuid as _uuid from typing import TYPE_CHECKING from ..relations import Relations from ..replay import Document from .sexpr import Sym if TYPE_CHECKING: from ..project_relations import ProjectRelations from .pcb_writer import _LayerMap MIL_TO_MM = 0.0254 # -- EPRO2 pad shape → KiCad pad shape ---------------------------------- PAD_SHAPE_MAP = { "RECT": "rect", "ELLIPSE": "circle", # square-ish ELLIPSE, KiCad has no 'ellipse' pad — use circle "OVAL": "oval", "POLYGON": "custom", # KiCad custom pad with primitive polygon } 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_name(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 _pad_layers(pad_layer_id: int, has_hole: bool) -> list[str]: """Layers a pad lives on, KiCad-style. EPRO2 layerId convention for pads: - 1 (TOP) → SMD on top: F.Cu + F.Mask + F.Paste - 2 (BOT) → SMD on bottom: B.Cu + B.Mask + B.Paste - 12 (MULTI)→ THT: all-copper + F&B Mask """ if has_hole: return ["*.Cu", "*.Mask"] if pad_layer_id == 1: return ["F.Cu", "F.Mask", "F.Paste"] if pad_layer_id == 2: return ["B.Cu", "B.Mask", "B.Paste"] return ["*.Cu", "*.Mask"] def write_footprint_placement( *, fp_doc: Document, comp_id: str, comp: dict, attrs: dict, pcb_doc: Document, pcb_rel: Relations, project_relations: "ProjectRelations", layer_map: "_LayerMap", net_map: dict[str, int], origin_mm: tuple[float, float], ) -> list: """Build a single ``(footprint ...)`` S-expr block for one PCB component. Returns a Python list ready for ``to_sexpr``. """ ox, oy = origin_mm px = ox + _mm(comp.get("x")) py = oy + _mm(comp.get("y")) rotation = float(comp.get("angle") or 0) on_bottom = int(comp.get("layerId") or 1) == 2 fp_layer = "B.Cu" if on_bottom else "F.Cu" designator = str(attrs.get("Designator") or "") value = str(attrs.get("Value") or "") fp_meta = (fp_doc.objects.get("META") or {}).get("title") or fp_doc.doc_uuid[:8] body: list = [ Sym("footprint"), f"facere:{fp_meta}", [Sym("layer"), fp_layer], [Sym("uuid"), _new_uuid()], [Sym("at"), px, py, rotation], [Sym("attr"), Sym("smd")], [Sym("property"), "Reference", designator, [Sym("at"), 0, -1.5, 0], [Sym("layer"), "B.SilkS" if on_bottom else "F.SilkS"], [Sym("uuid"), _new_uuid()], [Sym("effects"), [Sym("font"), [Sym("size"), 1.0, 1.0], [Sym("thickness"), 0.15]]]], [Sym("property"), "Value", value, [Sym("at"), 0, 1.5, 0], [Sym("layer"), "B.Fab" if on_bottom else "F.Fab"], [Sym("uuid"), _new_uuid()], [Sym("effects"), [Sym("font"), [Sym("size"), 1.0, 1.0], [Sym("thickness"), 0.15]]]], [Sym("property"), "Footprint", "", [Sym("at"), 0, 0, 0], [Sym("layer"), "F.Fab"], [Sym("hide"), Sym("yes")], [Sym("uuid"), _new_uuid()], [Sym("effects"), [Sym("font"), [Sym("size"), 1.0, 1.0]]]], [Sym("property"), "Datasheet", "", [Sym("at"), 0, 0, 0], [Sym("layer"), "F.Fab"], [Sym("hide"), Sym("yes")], [Sym("uuid"), _new_uuid()], [Sym("effects"), [Sym("font"), [Sym("size"), 1.0, 1.0]]]], ] fp_rel = project_relations.per_doc[fp_doc.doc_uuid] pcb_uuid = pcb_doc.doc_uuid # ---- pads -------------------------------------------------------------- for pad_id, pad in fp_rel.pads.items(): pad_block = _emit_pad( pad=pad, pad_id=pad_id, comp_id=comp_id, pcb_doc=pcb_doc, pcb_rel=pcb_rel, net_map=net_map, ) if pad_block is not None: body.append(pad_block) # ---- silkscreen / courtyard graphics from FOOTPRINT POLY/FILL --------- # Local to the footprint origin — KiCad applies the placement at/rot # transform on top, so we emit footprint-relative coords here. for oid, obj in fp_doc.objects.items(): t = obj.get("_type") if t not in ("POLY", "FILL"): continue kicad_layer = _kicad_layer_name(obj.get("layerId"), layer_map) if not kicad_layer: continue # We only emit non-copper graphics inside footprints — copper inside # a footprint that isn't a pad is unusual and best ignored for Phase 1. if kicad_layer.endswith(".Cu"): continue path = obj.get("path") or [] pts = _decode_path(path) if len(pts) < 2: continue body.append([ Sym("fp_poly") if t == "FILL" else Sym("fp_poly"), [Sym("pts"), *[[Sym("xy"), x, y] for x, y in pts]], [Sym("stroke"), [Sym("width"), max(_mm(obj.get("width")), 0.05)], [Sym("type"), Sym("default")]], [Sym("fill"), Sym("solid") if t == "FILL" else Sym("none")], [Sym("layer"), kicad_layer], [Sym("uuid"), _new_uuid()], ]) return body def _emit_pad( *, pad: dict, pad_id: str, comp_id: str, pcb_doc: Document, pcb_rel: Relations, net_map: dict[str, int], ) -> list | None: """Emit a single ``(pad ...)`` block for one PAD inside a footprint. Returns None when the pad is unrenderable (unknown shape, missing geometry). """ default_pad = pad.get("defaultPad") or {} shape = PAD_SHAPE_MAP.get(default_pad.get("padType")) if shape is None: return None cx = _mm(pad.get("centerX")) cy = _mm(pad.get("centerY")) w = _mm(default_pad.get("width")) h = _mm(default_pad.get("height")) if w <= 0 or h <= 0: return None rotation = float(pad.get("padAngle") or 0) pin_num = str(pad.get("num") or "") layer_id = int(pad.get("layerId") or 1) hole = pad.get("hole") # Resolve net via PAD_NET cross-reference on the PCB doc. net_name = "" for record in pcb_rel.pad_nets_by_pad.get(pad_id, []): if record.get("comp") == comp_id: net_name = record.get("net_name") or "" break net_id = net_map.get(str(net_name), 0) pad_type: str if hole: pad_type = "thru_hole" else: pad_type = "smd" block: list = [ Sym("pad"), pin_num, Sym(pad_type), Sym(shape), [Sym("at"), cx, cy, rotation] if rotation else [Sym("at"), cx, cy], [Sym("size"), w, h], [Sym("layers"), *_pad_layers(layer_id, bool(hole))], ] if hole: ht = (hole.get("holeType") or "ROUND").upper() if ht == "SLOT": hw = _mm(hole.get("width")) hh = _mm(hole.get("height")) block.append([Sym("drill"), Sym("oval"), max(hw, 0.1), max(hh, 0.1)]) else: d = _mm(hole.get("width") or hole.get("diameter")) block.append([Sym("drill"), max(d, 0.1)]) if net_id: block.append([Sym("net"), net_id, net_name]) block.append([Sym("uuid"), _new_uuid()]) return block def _decode_path(path: list) -> list[tuple[float, float]]: """EPRO2 graphic path → list of (x, y) in mm. Supported shapes: - flat ``[x1, y1, "L", x2, y2, x3, y3, ...]`` (line segments) - flat ``[x1, y1, "L", x2, y2, ..., "ARC", ...]`` — arcs are skipped in Phase 1; we collect the surrounding polyline points. Anything we can't decode returns an empty list, which the caller treats as "skip this primitive". """ pts: list[tuple[float, float]] = [] if not isinstance(path, list) or not path: return pts i = 0 while i < len(path): item = path[i] if isinstance(item, str): # Skip the verb token; numeric pairs follow. if item == "ARC": # Arc params: radius, endX, endY (typical) — bail to keep # Phase 1 simple. KiCad fp_poly with chord is geometrically # wrong but won't fail to parse. i += 1 continue i += 1 continue # Treat as numeric pair try: x = float(item) y = float(path[i + 1]) pts.append((x * MIL_TO_MM, y * MIL_TO_MM)) i += 2 except (TypeError, ValueError, IndexError): i += 1 return pts