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:
287
tools/epro2/kicad/footprint_writer.py
Normal file
287
tools/epro2/kicad/footprint_writer.py
Normal 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
|
||||
Reference in New Issue
Block a user