diff --git a/log.md b/log.md index ab67164..dada8e8 100644 --- a/log.md +++ b/log.md @@ -4,6 +4,73 @@ --- +## 2026-04-29 01:30 KiCad 导出 Phase 3 PCB:6/6 .kicad_pcb 全部 kicad-cli 通过 + +**Claude 会话** + +接 `ff5553f` 后续。Forge 投影最后一块——schematic 已经够用,做 `.kicad_pcb` 导出。一上来就铺了 Phase-1 最小可解析 scope(要先有"能在 KiCad 8 里打开 + kicad-cli 处理"的底线),不追全保真。 + +### 做的 + +1. **`tools/epro2/kicad/pcb_writer.py`** — 单 PCB doc → `.kicad_pcb` 主入口 + - 数据驱动的层映射 `_build_layer_map`:扫所有 primitive 的 `layerId`,把真正用到的 SIGNAL 内层(id 15+)按 EPRO2-id 顺序铺成 `In1.Cu`/`In2.Cu`/...;F.Cu 永远 ordinal 0、B.Cu 永远 ordinal 31(KiCad 硬约定)。 + - net 表:从 NET op 取名字,从 1 开始分配 id(0 留给 KiCad 的 "no net")。 + - 走 LINE:copper 层 → `(segment ...)`,layer 11 OUTLINE → `(gr_line layer Edge.Cuts)`;走 VIA → `(via)`;走 layer-11 POLY → 把 path 拆 polyline 段 emit 成 Edge.Cuts 上的 gr_line 链(实测板子轮廓全在 POLY 不在 LINE,少这步 edge=0)。 + - ARC 现在按弦近似(Phase 1 不解 EPRO2 ARC.path 的圆心/半径表示,KiCad 收得下,DRC 报 invalid_outline——下一轮改)。 + +2. **`tools/epro2/kicad/footprint_writer.py`** — inline `(footprint ...)` 块 + - 走 FOOTPRINT.PAD:`RECT/ELLIPSE/OVAL/POLYGON` → KiCad `rect/circle/oval/custom`;hole 存在则 SMD→thru_hole + `(drill ...)`,SLOT 走 `(drill oval w h)` 二参形式。 + - layer 映射:layerId 1 → `F.Cu+F.Mask+F.Paste`、2 → `B.Cu+B.Mask+B.Paste`、12 (MULTI/THT) → `*.Cu+*.Mask`。 + - net 解析借 `pcb_rel.pad_nets_by_pad[pad_id]`(之前 `ProjectRelations` 已经为 PCB→FOOTPRINT cross-doc 攒过的索引现在派上用场)。 + - bottom-side COMPONENT (`layerId=2`) 整个 footprint 走 `(layer B.Cu)`,Reference 标签同步落到 `B.SilkS`。 + +3. **CLI 加 `--all-pcb`**(`tools/epro2/kicad/__main__.py`):每个 PCB doc 一文件,文件名取 META.title。 + +### ESP-VoCat 实测(6 个 PCB) + +| 板 | nets | footprints | segments | vias | edge_cuts | +|---|---:|---:|---:|---:|---:| +| BaseBoard | 38 | 59 | 391 | 126 | 4 | +| CoreBoard | 84 | 87 | 1131 | 262 | 4 | +| MicBoard | 13 | 17 | 88 | 47 | 16 | +| LCD-BD | 7 | 5 | 66 | 35 | 2 | +| Mainboard | 24 | 33 | 340 | 181 | 4 | +| Sub-board | 4 | 5 | 10 | 0 | 4 | + +**6/6 都过 `kicad-cli pcb export svg`**——文件解析无 error、SVG 输出正常。footprint/track/via/edge cuts 全部可见。 + +`kicad-cli pcb drc` 对最小的 MicBoard 跑:145 violations + 75 unconnected。分布: +- 28 clearance(trace/pad 间距)+ 19 track_dangling + 39 via_dangling + 75 unconnected — 真连接问题,部分是 EPRO2 源里靠 POUR 解决但我们 Phase 1 没导 +- 21 silk_overlap + 8×2 edge_clearance + 3 invalid_outline — 边角 + silk 美化 +- 17 lib_footprint_issues — facere 库没注册(cosmetic,跟 schematic 一样) + +### Phase 1 砍掉的 / 下一轮再做 + +- **POUR / POURED** — 铜皮覆铜,导了 75 unconnected 的大头会消掉(GND 大面积在 POUR 里走,没导出来 trace 当然报 unconnected) +- **FILL** — 元件下方手工 fill 块 +- **ARC 圆心/半径解析** — 现在按弦近似,invalid_outline 警告就这来的 +- **TEARDROP** — pad/via 接 trace 处的圆角,纯美化 +- **STRING / IMAGE** — 板上文字和 logo 图 + +### 决策(Why) + +- **板子原点平移到 (100, 100) mm**:EPRO2 板能在任意坐标(含负),KiCad 画布原点在 (0,0),不平移开 KiCad 时板子可能根本不在视野里。100mm 是经验值,留够 silkscreen 边距。 +- **footprint inline 不走外部 .kicad_mod**:跟 schematic lib_symbol 同思路,自包含,能直接 `kicad-cli` 处理;缺点是同款 footprint 重复写多份,但 EPRO2 一个 SCH 内的 FOOTPRINT 数量级(几十)摊开来文件膨胀也不大。 +- **ELLIPSE 当 circle 不当 oval**:实测 ELLIPSE 的 width/height 经常相等(=圆形 pad),KiCad 没有真椭圆 pad type,circle 取 max(w,h) 比 oval 更接近圆。 +- **ARC 暂时按弦**:算 EPRO2 ARC 圆心要先反推三点定圆,浮点精度敏感。Phase 2 单独处理。 + +### 测试 + +52 → 65 单测全过:pcb_writer 8 个(layer map / net id / segment / via / outline POLY / 零长 skip / 非 PCB doc reject)+ footprint_writer 5 个(SMD pad layers / 圆 vs slot drill / pad net 跨 doc 解析 / bottom-layer 翻转 / unresolved skip)。 + +### 下一步建议 + +- **POUR/POURED 导 zone**(中工作量):消掉大部分 75 unconnected,CoreBoard 这种 4-layer + 大量 GND/POWER 覆铜的板真实连通性会贴近完整。 +- **schematic + PCB 同时跑** 一个 export 命令(`--all`),生成完整 KiCad project 目录。 +- **Phase 4: KiCad project 文件 .kicad_pro**:让用户双击就能在 KiCad GUI 里打开 schematic + PCB 配对。 + +--- + ## 2026-04-29 01:00 科普文档:爬取 per-doc .epro2 vs 网页端 .epro2 ZIP 整包 **Claude 会话** diff --git a/tools/epro2/kicad/__main__.py b/tools/epro2/kicad/__main__.py index 0b930f8..ad8d217 100644 --- a/tools/epro2/kicad/__main__.py +++ b/tools/epro2/kicad/__main__.py @@ -22,6 +22,7 @@ from pathlib import Path from ..project_relations import ProjectRelations from ..replay import Document, Project, replay_project +from .pcb_writer import write_pcb from .root_sch_writer import ChildSheet, new_sheet_uuid, write_root_sheet from .sch_writer import write_sch_page @@ -153,12 +154,44 @@ def _convert_hierarchical( return root_count +def _convert_all_pcb(proj: Project, out_dir: Path, pr: ProjectRelations) -> int: + """Emit one .kicad_pcb per PCB doc. Each is named after its META.title + and dropped into a sibling directory of the matching SCH for parity + with the schematic export layout.""" + pcb_uuids = [u for u, d in proj.documents.items() if d.doc_type == "PCB"] + if not pcb_uuids: + print("no PCB docs in this project", file=sys.stderr) + return 0 + print(f"Converting {len(pcb_uuids)} PCB doc(s) → {out_dir}") + n = 0 + for u in pcb_uuids: + doc = proj.documents[u] + title = (doc.objects.get("META") or {}).get("title") or u[:12] + try: + text = write_pcb(doc, project_relations=pr) + except Exception as e: # noqa: BLE001 + print(f" FAIL {u[:12]}: {e}", file=sys.stderr) + continue + out_path = out_dir / f"{_safe_filename(title)}.kicad_pcb" + out_path.write_text(text, encoding="utf-8") + stats = getattr(write_pcb, "last_stats", None) + if stats: + print( + f" {out_path.name}: nets={stats.nets} fps={stats.footprints} " + f"fps_unresolved={stats.footprints_unresolved} " + f"segments={stats.segments} vias={stats.vias} edge={stats.edge_cuts}" + ) + n += 1 + return n + + def main(argv: list[str] | None = None) -> int: - ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic exporter (Phase 3 hierarchical)") + ap = argparse.ArgumentParser(description="EPRO2 → KiCad schematic + PCB exporter") 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") + g.add_argument("--all-pcb", action="store_true", help="convert every PCB doc to .kicad_pcb") ap.add_argument("--out", type=Path, default=Path("data/processed/kicad_sch")) ap.add_argument( "--no-lib-symbols", @@ -176,6 +209,12 @@ def main(argv: list[str] | None = None) -> int: args.out.mkdir(parents=True, exist_ok=True) pr = None if args.no_lib_symbols else ProjectRelations.build(proj) + if args.all_pcb: + if pr is None: + pr = ProjectRelations.build(proj) + _convert_all_pcb(proj, args.out, pr=pr) + return 0 + if args.doc: _convert_one_flat(proj, args.doc, args.out, pr=pr) return 0 diff --git a/tools/epro2/kicad/footprint_writer.py b/tools/epro2/kicad/footprint_writer.py new file mode 100644 index 0000000..48f1f1c --- /dev/null +++ b/tools/epro2/kicad/footprint_writer.py @@ -0,0 +1,287 @@ +"""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 diff --git a/tools/epro2/kicad/pcb_writer.py b/tools/epro2/kicad/pcb_writer.py new file mode 100644 index 0000000..936f59e --- /dev/null +++ b/tools/epro2/kicad/pcb_writer.py @@ -0,0 +1,490 @@ +"""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 + 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 _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 + + # 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, + ] + write_pcb.last_stats = stats # type: ignore[attr-defined] + return to_sexpr(pcb, pretty=True) diff --git a/tools/epro2/tests/test_footprint_writer.py b/tools/epro2/tests/test_footprint_writer.py new file mode 100644 index 0000000..b0f49fd --- /dev/null +++ b/tools/epro2/tests/test_footprint_writer.py @@ -0,0 +1,165 @@ +"""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 diff --git a/tools/epro2/tests/test_pcb_writer.py b/tools/epro2/tests/test_pcb_writer.py new file mode 100644 index 0000000..23d70c8 --- /dev/null +++ b/tools/epro2/tests/test_pcb_writer.py @@ -0,0 +1,162 @@ +"""PCB writer regression: synthetic PCB doc → kicad_pcb → re-parse.""" + +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): + """Float-close: 6-decimal s-expr roundtrip can lose strict equality.""" + 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 _pcb(objs, doc_uuid="pcb1") -> Document: + d = Document(doc_uuid=doc_uuid, doc_type="PCB") + d.head = {"docType": "PCB", "editVersion": "3.2.91"} + for k, v in objs: + d.objects[k] = v + return d + + +def _empty_pr(pcb_doc: Document) -> ProjectRelations: + p = Project(project_uuid="p") + p.documents[pcb_doc.doc_uuid] = pcb_doc + return ProjectRelations.build(p) + + +def test_writer_emits_header_and_layers(): + d = _pcb([("META", {"_type": "META", "title": "BoardX"})]) + text = write_pcb(d, project_relations=_empty_pr(d)) + p = parse(text) + assert p[0] == "kicad_pcb" + layers = _block(p, "layers")[0] + # F.Cu is always ordinal 0, B.Cu always 31 — KiCad convention. + rows = [row for row in layers[1:] if isinstance(row, list)] + by_name = {r[1]: r[0] for r in rows} + assert by_name["F.Cu"] == 0 + assert by_name["B.Cu"] == 31 + assert "Edge.Cuts" in by_name + + +def test_inner_signal_layers_inserted_in_id_order(): + """An EPRO2 4-layer board with SIGNAL ids 15 and 16 actually used must + map to In1.Cu and In2.Cu (in EPRO2-id sorted order) so the PCB + layer-stack ordering matches the editor's intent.""" + d = _pcb([ + ('["LAYER",1]', {"_type": "LAYER", "layerType": "TOP", "use": True}), + ('["LAYER",2]', {"_type": "LAYER", "layerType": "BOTTOM", "use": True}), + ('["LAYER",15]', {"_type": "LAYER", "layerType": "SIGNAL", "use": True}), + ('["LAYER",16]', {"_type": "LAYER", "layerType": "SIGNAL", "use": True}), + # Drive "used" flag by including a primitive on each inner layer + ("ln1", {"_type": "LINE", "layerId": 15, + "startX": 0, "startY": 0, "endX": 100, "endY": 0, "width": 6}), + ("ln2", {"_type": "LINE", "layerId": 16, + "startX": 0, "startY": 0, "endX": 100, "endY": 0, "width": 6}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d)) + p = parse(text) + rows = [r for r in _block(p, "layers")[0][1:] if isinstance(r, list)] + cu_layers = [r[1] for r in rows if r[1].endswith(".Cu")] + assert cu_layers == ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"] + + +def test_nets_get_stable_integer_ids_starting_at_1(): + """KiCad reserves net id 0 for "no net" — our user-defined nets must + start at 1 so segments referencing them don't collide with the empty + net.""" + d = _pcb([ + ('["NET","GND"]', {"_type": "NET"}), + ('["NET","VCC"]', {"_type": "NET"}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d)) + p = parse(text) + nets = _block(p, "net") + by_name = {n[2]: n[1] for n in nets} + assert by_name[""] == 0 + assert sorted([by_name["GND"], by_name["VCC"]]) == [1, 2] + + +def test_segment_emitted_for_copper_line_with_net(): + d = _pcb([ + ('["NET","GND"]', {"_type": "NET"}), + ("ln1", {"_type": "LINE", "layerId": 1, "netName": "GND", "width": 6, + "startX": 100, "startY": 200, "endX": 500, "endY": 200}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + segs = _block(p, "segment") + assert len(segs) == 1 + seg = segs[0] + start = next(c for c in seg if isinstance(c, list) and c[0] == "start") + end = next(c for c in seg if isinstance(c, list) and c[0] == "end") + assert _close(start[1], 100 * MIL_TO_MM) + assert _close(start[2], 200 * MIL_TO_MM) + assert _close(end[1], 500 * MIL_TO_MM) + layer = next(c for c in seg if isinstance(c, list) and c[0] == "layer") + assert layer[1] == "F.Cu" + net_ref = next(c for c in seg if isinstance(c, list) and c[0] == "net") + assert net_ref[1] >= 1 + + +def test_via_emitted_with_size_and_drill(): + d = _pcb([ + ('["NET","SIG"]', {"_type": "NET"}), + ("v1", {"_type": "VIA", "centerX": 100, "centerY": 200, + "viaDiameter": 24, "holeDiameter": 12, "netName": "SIG"}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + vias = _block(p, "via") + assert len(vias) == 1 + v = vias[0] + at = next(c for c in v if isinstance(c, list) and c[0] == "at") + assert _close(at[1], 100 * MIL_TO_MM) + size = next(c for c in v if isinstance(c, list) and c[0] == "size") + assert _close(size[1], 24 * MIL_TO_MM) + drill = next(c for c in v if isinstance(c, list) and c[0] == "drill") + assert _close(drill[1], 12 * MIL_TO_MM) + + +def test_outline_poly_emitted_as_edge_cuts_lines(): + """POLY on layer 11 is the board outline and must convert to + (gr_line ... (layer Edge.Cuts)) chains so KiCad recognises the + board boundary — without this the board has no Edge.Cuts geometry + and DRC reports invalid_outline.""" + d = _pcb([ + ("p1", {"_type": "POLY", "layerId": 11, "width": 4, + "path": [0, 0, "L", 1000, 0, 1000, 1000, 0, 1000, 0, 0]}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d), board_origin_mm=(0.0, 0.0)) + p = parse(text) + lines = _block(p, "gr_line") + assert len(lines) == 4 # square: 4 sides + layers = {next(c for c in ln if isinstance(c, list) and c[0] == "layer")[1] + for ln in lines} + assert layers == {"Edge.Cuts"} + + +def test_zero_length_segment_skipped(): + d = _pcb([ + ("ln1", {"_type": "LINE", "layerId": 1, "netName": "GND", "width": 6, + "startX": 5, "startY": 5, "endX": 5, "endY": 5}), + ]) + text = write_pcb(d, project_relations=_empty_pr(d)) + p = parse(text) + assert _block(p, "segment") == [] + assert getattr(write_pcb, "last_stats").skipped == 1 + + +def test_non_pcb_doc_rejected(): + d = Document(doc_uuid="x", doc_type="SCH_PAGE") + try: + write_pcb(d) + except ValueError: + return + raise AssertionError("expected ValueError for non-PCB doc")