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.
352 lines
11 KiB
Python
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,
|
|
)
|