tools/epro2/kicad: Phase-1 .kicad_pcb exporter — 6/6 boards open in KiCad 8

Phase-1 scope: produce a .kicad_pcb that kicad-cli loads cleanly and
that has the right geometry (nets, footprints, tracks, vias, board
outline) — not a 1:1 EDA round-trip. Skipped on purpose for Phase 2:
copper pours (POUR/POURED), manual FILL, teardrops, board-level
strings/images, ARC circle-center recovery.

What lands:
  - pcb_writer.write_pcb(): header/general, data-driven layer table
    (F.Cu = ord 0; B.Cu = ord 31; SIGNAL inner ids 15+ allocated to
    In1.Cu/In2.Cu/... in EPRO2-id sorted order so used inner layers
    stay contiguous), net-name → integer id map (id 0 reserved for the
    empty net per KiCad convention), LINE→segment / LINE→gr_line on
    Edge.Cuts, layer-11 POLY paths walked into Edge.Cuts gr_line chains
    (the actual board outline lives on POLY here, not LINE — without
    this stats showed edge=0), VIA→via.
  - footprint_writer.write_footprint_placement(): inline (footprint ...)
    blocks per PCB COMPONENT. EPRO2 RECT/ELLIPSE/OVAL/POLYGON pad
    shapes mapped to KiCad rect/circle/oval/custom; SMD vs THT detected
    by PAD.hole presence; SLOT holes use (drill oval w h). Pad nets
    resolved cross-doc via the existing PCB.PAD_NET → footprint.pad
    chain in ProjectRelations. layerId=2 component → (layer B.Cu) +
    text on B.SilkS so bottom-side parts render correctly.

Smoke test on ESP-VoCat (6 PCBs): all 6 pass `kicad-cli pcb export svg`
and render. DRC on smallest (MicBoard) reports 145 violations + 75
unconnected — most of the unconnected are GND nets that the EPRO2
source resolves through POUR copper, which Phase 2 will export.

CLI: `python -m tools.epro2.kicad <project> --all-pcb --out <dir>`
emits one .kicad_pcb per PCB doc.

52 → 65 unit tests pass. Float comparisons in tests use math.isclose
because the s-expr 6-decimal trim doesn't preserve strict equality
through `value * MIL_TO_MM` round-trips.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:18:32 +08:00
parent fc2a45f658
commit e61404478e
6 changed files with 1211 additions and 1 deletions

View File

@@ -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