"""Footprint writer regression: synthetic PCB+FOOTPRINT pair → (footprint ...).""" import math from tools.epro2.kicad._sexpr_reader import parse from tools.epro2.kicad.pcb_writer import MIL_TO_MM, write_pcb from tools.epro2.project_relations import ProjectRelations from tools.epro2.replay import Document, Project def _close(a, b): return math.isclose(a, b, abs_tol=1e-6) def _block(parsed, name): return [c for c in parsed if isinstance(c, list) and c and c[0] == name] def _build(fp_objs: list[tuple[str, dict]], pcb_objs: list[tuple[str, dict]], comp_id: str = "C1", fp_uuid: str = "FP1") -> str: fp = Document(doc_uuid=fp_uuid, doc_type="FOOTPRINT") fp.objects["META"] = {"_type": "META", "title": "TestFp"} for k, v in fp_objs: fp.objects[k] = v pcb = Document(doc_uuid="pcb1", doc_type="PCB") pcb.head = {"docType": "PCB", "editVersion": "3.2.91"} for k, v in pcb_objs: pcb.objects[k] = v proj = Project(project_uuid="p") proj.documents[fp_uuid] = fp proj.documents["pcb1"] = pcb pr = ProjectRelations.build(proj) return write_pcb(pcb, project_relations=pr, board_origin_mm=(0.0, 0.0)) def test_smd_rect_pad_lands_on_top_copper_mask_and_paste(): """A rectangular SMD pad on EPRO2 layer 1 (TOP) must write its layers as F.Cu/F.Mask/F.Paste in KiCad — leaving any of those out drops the pad from solder mask / paste stencil and the board fails fab.""" text = _build( fp_objs=[ ("p1", {"_type": "PAD", "num": "1", "centerX": 0, "centerY": 0, "padAngle": 0, "layerId": 1, "hole": None, "defaultPad": {"padType": "RECT", "width": 30, "height": 20}, "specialPad": []}), ], pcb_objs=[ ("C1", {"_type": "COMPONENT", "x": 100, "y": 100, "angle": 0, "layerId": 1}), ("a1", {"_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "FP1"}), ], ) p = parse(text) fp = _block(p, "footprint")[0] pads = [c for c in fp if isinstance(c, list) and c[0] == "pad"] assert len(pads) == 1 pad = pads[0] assert pad[1] == "1" assert pad[2] == "smd" assert pad[3] == "rect" layers = next(c for c in pad if isinstance(c, list) and c[0] == "layers") assert set(layers[1:]) == {"F.Cu", "F.Mask", "F.Paste"} def test_pad_with_hole_writes_thru_hole_and_drill(): """A PAD with `hole` set is THT — must be `thru_hole`, span `*.Cu` and have a `(drill ...)` block. Slot holes additionally need the `(drill oval w h)` two-arg form.""" text = _build( fp_objs=[ ("p1", {"_type": "PAD", "num": "1", "centerX": 0, "centerY": 0, "padAngle": 0, "layerId": 12, "hole": {"holeType": "ROUND", "width": 12, "height": 12}, "defaultPad": {"padType": "ELLIPSE", "width": 24, "height": 24}}), ("p2", {"_type": "PAD", "num": "2", "centerX": 50, "centerY": 0, "padAngle": 0, "layerId": 12, "hole": {"holeType": "SLOT", "width": 30, "height": 12}, "defaultPad": {"padType": "OVAL", "width": 50, "height": 24}}), ], pcb_objs=[ ("C1", {"_type": "COMPONENT", "x": 100, "y": 100, "angle": 0}), ("a1", {"_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "FP1"}), ], ) p = parse(text) fp = _block(p, "footprint")[0] pads = [c for c in fp if isinstance(c, list) and c[0] == "pad"] assert len(pads) == 2 by_num = {pad[1]: pad for pad in pads} # Round through-hole p1 = by_num["1"] assert p1[2] == "thru_hole" drill1 = next(c for c in p1 if isinstance(c, list) and c[0] == "drill") assert _close(drill1[1], 12 * MIL_TO_MM) # Slot through-hole p2 = by_num["2"] drill2 = next(c for c in p2 if isinstance(c, list) and c[0] == "drill") assert drill2[1] == "oval" assert _close(drill2[2], 30 * MIL_TO_MM) assert _close(drill2[3], 12 * MIL_TO_MM) def test_pad_net_resolved_via_pad_net_op(): """KiCad needs `(net N "name")` on each pad to know which net it belongs to. The mapping comes from the PCB-level PAD_NET op (not from anything in the footprint), so it has to be pulled cross-doc.""" text = _build( fp_objs=[ ("p1", {"_type": "PAD", "num": "1", "centerX": 0, "centerY": 0, "layerId": 1, "hole": None, "defaultPad": {"padType": "RECT", "width": 30, "height": 20}}), ], pcb_objs=[ ('["NET","GND"]', {"_type": "NET"}), ("C1", {"_type": "COMPONENT", "x": 0, "y": 0, "angle": 0}), ("a1", {"_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "FP1"}), ('["PAD_NET","C1","1","p1"]', {"_type": "PAD_NET", "padNet": "GND"}), ], ) p = parse(text) fp = _block(p, "footprint")[0] pad = next(c for c in fp if isinstance(c, list) and c[0] == "pad") net_block = next(c for c in pad if isinstance(c, list) and c[0] == "net") assert net_block[2] == "GND" assert net_block[1] >= 1 def test_component_on_bottom_layer_gets_b_cu_layer(): """COMPONENT.layerId=2 places the footprint on B.Cu, with text properties moved to B.SilkS / B.Fab. Without this a bottom-side component renders on F.Cu and the netlist routes through phantom geometry.""" text = _build( fp_objs=[ ("p1", {"_type": "PAD", "num": "1", "centerX": 0, "centerY": 0, "layerId": 1, "hole": None, "defaultPad": {"padType": "RECT", "width": 30, "height": 20}}), ], pcb_objs=[ ("C1", {"_type": "COMPONENT", "x": 0, "y": 0, "angle": 0, "layerId": 2}), ("a1", {"_type": "ATTR", "parentId": "C1", "key": "Footprint", "value": "FP1"}), ], ) p = parse(text) fp = _block(p, "footprint")[0] layer = next(c for c in fp if isinstance(c, list) and c[0] == "layer") assert layer[1] == "B.Cu" def test_unresolved_footprint_skipped_with_diagnostic(): """A COMPONENT without a Footprint ATTR pointing at a real FOOTPRINT doc must be skipped — emitting an empty (footprint ...) block would crash kicad-cli on load.""" pcb = Document(doc_uuid="pcb1", doc_type="PCB") pcb.head = {"docType": "PCB", "editVersion": "3.2.91"} pcb.objects["C1"] = {"_type": "COMPONENT", "x": 0, "y": 0} # No matching FOOTPRINT doc, no Footprint ATTR proj = Project(project_uuid="p") proj.documents["pcb1"] = pcb pr = ProjectRelations.build(proj) text = write_pcb(pcb, project_relations=pr) p = parse(text) assert _block(p, "footprint") == [] assert getattr(write_pcb, "last_stats").footprints_unresolved == 1