Files
periscope/backend/periscopex/parsers_kicad_pcb.py
T
michele facbaa2305 Show Action on finding cards and inject PCB geometry into AI context.
Layout and AI findings always get action (fallback recommendation). The card
renders that sentence in the body. PCB review context now includes vias under
the footprint, copper thickness, nearby widths, courtyard, and keepout polygons.
2026-09-20 09:13:51 +02:00

352 lines
11 KiB
Python

"""KiCad `.kicad_pcb` ingest — footprints, pads, nets, segments, vias.
No SI/DRC. Schema validation stays complete without this file.
"""
from __future__ import annotations
import re
from pathlib import Path
from backend.periscopex.models import (
LayoutDielectric,
LayoutFootprint,
LayoutGraph,
LayoutPad,
LayoutSegment,
LayoutStackup,
LayoutVia,
LayoutZone,
)
from backend.periscopex.parsers_kicad import (
_at,
_fnum,
_kid,
_kids,
_parse_sexp,
_rotate,
_tag,
_val,
)
def _copper_thickness_token(raw: object) -> float | None:
"""Parse KiCad stackup thickness; units mm/um/mil. Never default 1 oz."""
if isinstance(raw, (int, float)) and not isinstance(raw, bool):
v = float(raw)
return v if v > 0 else None
s = str(raw or "").strip().lower().replace(" ", "")
m = re.match(r"^([0-9]*\.?[0-9]+)(mm|um|µm|mil)?", s)
if not m:
return None
n = float(m.group(1))
unit = m.group(2) or "mm"
if unit in {"um", "µm"}:
n = n / 1000.0
elif unit == "mil":
n = n * 0.0254
return n if n > 0 else None
def _xy(node: object, name: str) -> tuple[float, float]:
k = _kid(node, name)
if not k or len(k) < 3:
return 0.0, 0.0
return _fnum(k[1]), _fnum(k[2])
def _prop(node: object, key: str) -> str:
for p in _kids(node, "property"):
if len(p) >= 3 and str(p[1]) == key:
return str(p[2])
return ""
def _pad_net(pad: object) -> str:
n = _kid(pad, "net")
if not n or len(n) < 2:
return ""
# KiCad 9/10 often stores ``(net "GND")`` without a numeric code.
if len(n) == 2 and not isinstance(n[1], list):
return str(n[1])
# Legacy ``(net 3 "GND")``.
if len(n) >= 3:
return str(n[2])
return ""
def _normalize_pcb_net_name(name: str) -> str:
"""Strip KiCad root-sheet ``/`` prefixes; keep empty / unconnected as-is."""
n = (name or "").strip()
if not n:
return ""
while n.startswith("/"):
n = n[1:]
return n
def nets_from_pcb(layout: LayoutGraph) -> dict[str, list[tuple[str, str]]]:
"""Pad connectivity from a parsed board — authoritative when sch geometry fails."""
nets: dict[str, list[tuple[str, str]]] = {}
seen: set[tuple[str, str, str]] = set()
for ref, fp in layout.footprints.items():
for pad in fp.pads:
raw = pad.net or ""
if not raw:
continue
name = _normalize_pcb_net_name(raw)
if not name:
continue
key = (name, ref, pad.number)
if key in seen:
continue
seen.add(key)
nets.setdefault(name, []).append((ref, pad.number))
return nets
def _net_name(node: object, nets: dict[str, int]) -> str:
n = _kid(node, "net")
if not n or len(n) < 2:
named = _val(node, "net_name")
return named
# ``(net "GND")`` or ``(net 1)`` or ``(net 1 "GND")``
if len(n) == 2 and not isinstance(n[1], list):
token = n[1]
if isinstance(token, str) and not str(token).replace(".", "", 1).isdigit():
return str(token)
try:
code = int(_fnum(token))
except (TypeError, ValueError):
return str(token)
return next((name for name, c in nets.items() if c == code), str(code))
if len(n) >= 3:
return str(n[2])
try:
code = int(_fnum(n[1]))
except (TypeError, ValueError):
return ""
return next((name for name, c in nets.items() if c == code), str(code))
def _layer_type(node: object) -> str:
return str(_val(node, "type") or "").lower()
def _parse_stackup(tree: object) -> LayoutStackup | None:
setup = _kid(tree, "setup")
if not setup:
return None
stack = _kid(setup, "stackup")
if not stack:
return None
copper: list[str] = []
dielectrics: list[LayoutDielectric] = []
thicknesses: list[float] = []
for layer in _kids(stack, "layer"):
name = str(layer[1]) if len(layer) > 1 and not isinstance(layer[1], list) else ""
kind = _layer_type(layer)
thick = _kid(layer, "thickness")
height = None
if thick and len(thick) > 1:
height = _copper_thickness_token(thick[1])
if height is None and len(thick) > 2:
height = _copper_thickness_token(f"{thick[1]}{thick[2]}")
if kind == "copper" or name.endswith(".Cu"):
if name:
copper.append(name)
if height is not None and height > 0:
thicknesses.append(height)
continue
if kind in {"core", "prepreg", "dielectric"} or name.lower().startswith("dielectric"):
er_el = _kid(layer, "epsilon_r")
if er_el is None:
er_el = _kid(layer, "epsilonr")
er = _fnum(er_el[1]) if er_el and len(er_el) > 1 else None
if er is None or height is None or er <= 0 or height <= 0:
continue
dielectrics.append(LayoutDielectric(
name=name or f"dielectric_{len(dielectrics)}",
er=er,
height_mm=height,
))
t = thicknesses[0] if thicknesses else None
if not copper and t is None:
return None
return LayoutStackup(
copper_layers=copper,
dielectrics=dielectrics,
copper_thickness_mm=t,
)
def _pts_xy(node: object) -> list[tuple[float, float]]:
pts_el = _kid(node, "pts")
if not pts_el:
return []
out: list[tuple[float, float]] = []
for xy in pts_el[1:]:
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
out.append((_fnum(xy[1]), _fnum(xy[2])))
return out
def _parse_zone(node: object, nets: dict[str, int]) -> list[LayoutZone]:
net = str(_val(node, "net_name") or "") or _net_name(node, nets)
keepout = _kid(node, "keepout") is not None
name = str(_val(node, "name") or "")
layer_default = str(_val(node, "layer") or "")
layers_el = _kid(node, "layers")
if not layer_default and layers_el and len(layers_el) > 1:
layer_default = " ".join(str(x) for x in layers_el[1:] if not isinstance(x, list))
zones: list[LayoutZone] = []
for poly in list(_kids(node, "filled_polygon")) + list(_kids(node, "polygon")):
layer = str(_val(poly, "layer") or layer_default)
pts = _pts_xy(poly)
if len(pts) >= 3:
zones.append(LayoutZone(
net=net, layer=layer, outlines=[pts], keepout=keepout, name=name,
))
return zones
def _is_crtyd(layer: str) -> bool:
return str(layer).endswith("CrtYd")
def _abs(fx: float, fy: float, frot: float, lx: float, ly: float) -> tuple[float, float]:
rx, ry = _rotate(lx, ly, frot)
return fx + rx, fy + ry
def _courtyard_pts(node: object, fx: float, fy: float, frot: float) -> list[tuple[float, float]]:
"""Courtyard vertices from the PCB file. Empty if KiCad has no CrtYd."""
pts: list[tuple[float, float]] = []
for poly in _kids(node, "fp_poly"):
if not _is_crtyd(_val(poly, "layer")):
continue
pts_el = _kid(poly, "pts")
if not pts_el:
continue
for xy in pts_el[1:]:
if isinstance(xy, list) and xy and xy[0] == "xy" and len(xy) >= 3:
pts.append(_abs(fx, fy, frot, _fnum(xy[1]), _fnum(xy[2])))
if pts:
return pts
for rect in _kids(node, "fp_rect"):
if not _is_crtyd(_val(rect, "layer")):
continue
sx, sy = _xy(rect, "start")
ex, ey = _xy(rect, "end")
return [
_abs(fx, fy, frot, sx, sy),
_abs(fx, fy, frot, ex, sy),
_abs(fx, fy, frot, ex, ey),
_abs(fx, fy, frot, sx, ey),
]
for line in _kids(node, "fp_line"):
if not _is_crtyd(_val(line, "layer")):
continue
sx, sy = _xy(line, "start")
ex, ey = _xy(line, "end")
a = _abs(fx, fy, frot, sx, sy)
b = _abs(fx, fy, frot, ex, ey)
if not pts or pts[-1] != a:
pts.append(a)
if pts[-1] != b:
pts.append(b)
return pts
def parse_kicad_pcb(path: str | Path) -> LayoutGraph:
p = Path(path)
tree = _parse_sexp(p.read_text(encoding="utf-8", errors="replace"))
if _tag(tree) != "kicad_pcb":
raise ValueError(f"Expected kicad_pcb, got {_tag(tree)!r}")
nets: dict[str, int] = {}
footprints: dict[str, LayoutFootprint] = {}
segments: list[LayoutSegment] = []
vias: list[LayoutVia] = []
zones: list[LayoutZone] = []
for node in tree[1:]:
if not isinstance(node, list) or not node:
continue
tag = _tag(node)
if tag == "net" and len(node) >= 3 and not any(isinstance(x, list) and x and x[0] == "node" for x in node[1:]):
try:
code = int(_fnum(node[1]))
except (TypeError, ValueError):
continue
name = str(node[2])
if name:
nets[name] = code
continue
if tag in {"footprint", "module"}:
fp_name = str(node[1]) if len(node) > 1 and not isinstance(node[1], list) else ""
fx, fy, frot = _at(node)
layer = _val(node, "layer")
ref = _prop(node, "Reference")
if not ref or ref.startswith("#"):
continue
pads: list[LayoutPad] = []
for pad in _kids(node, "pad"):
num = str(pad[1]) if len(pad) > 1 else ""
if not num:
continue
px, py, _ = _at(pad)
rx, ry = _rotate(px, py, frot)
pads.append(LayoutPad(
number=num,
x=fx + rx,
y=fy + ry,
net=_pad_net(pad),
))
footprints[ref] = LayoutFootprint(
reference=ref,
footprint=fp_name,
x=fx,
y=fy,
layer=layer,
pads=pads,
courtyard=_courtyard_pts(node, fx, fy, frot),
)
continue
if tag == "segment":
segments.append(LayoutSegment(
start=_xy(node, "start"),
end=_xy(node, "end"),
width=_fnum(_val(node, "width") or 0),
layer=_val(node, "layer"),
net=_net_name(node, nets),
))
continue
if tag == "arc":
segments.append(LayoutSegment(
start=_xy(node, "start"),
end=_xy(node, "end"),
width=_fnum(_val(node, "width") or 0),
layer=_val(node, "layer"),
net=_net_name(node, nets),
))
continue
if tag == "via":
drill_el = _kid(node, "drill")
drill = _fnum(drill_el[1]) if drill_el and len(drill_el) > 1 else None
vx, vy, _ = _at(node)
vias.append(LayoutVia(x=vx, y=vy, net=_net_name(node, nets), drill=drill))
continue
if tag == "zone":
zones.extend(_parse_zone(node, nets))
continue
return LayoutGraph(
nets=nets,
footprints=footprints,
segments=segments,
vias=vias,
stackup=_parse_stackup(tree),
zones=zones,
)