Rebrand Pinscope to Periscope across product and codebase.
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>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""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,
|
||||
)
|
||||
Reference in New Issue
Block a user