Rename the core package to periscopex, update UI/docs/Docker/deploy defaults to periscope.michelebigi.it, and keep legacy version/storage key aliases so existing projects keep working. Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
9.8 KiB
Python
312 lines
9.8 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
|
|
|
|
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 _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 = _fnum(thick[1]) if thick and len(thick) > 1 else None
|
|
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,
|
|
))
|
|
if len(copper) < 2 or len(dielectrics) != len(copper) - 1:
|
|
return None
|
|
t = thicknesses[0] if thicknesses else 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)
|
|
zones: list[LayoutZone] = []
|
|
for poly in _kids(node, "filled_polygon"):
|
|
layer = _val(poly, "layer")
|
|
pts = _pts_xy(poly)
|
|
if layer and len(pts) >= 3:
|
|
zones.append(LayoutZone(net=net, layer=layer, outlines=[pts]))
|
|
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 == "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,
|
|
)
|