376 lines
18 KiB
Python
376 lines
18 KiB
Python
"""PCB 布局辅助:板框 / 按占地中心摆放 / 正反面 / 叠层 / 占地重叠检查 / 出图。
|
||
|
||
**必须用 KiCad 自带的 python 跑**(要 pcbnew)。典型用法(见 DVBS2_dmod 的 carrier/gen/place.py):
|
||
|
||
from schgen.pcbplace import Placer
|
||
pl = Placer(r"...\\board.kicad_pcb", origin=(100, 100)) # 以下坐标都是板内坐标(mm,左上为 0,0,y 向下)
|
||
pl.outline(74, 40, r=1.0)
|
||
pl.at("U1", 4.95, 7.0, rot=0) # 封装原点落在 (x, y)
|
||
pl.center("C304", 61.5, 12.0, rot=90) # 封装占地(courtyard)中心落在 (x, y)
|
||
pl.center("C303", 8.95, 11.6, rot=90, bottom=True)
|
||
pl.keepout("J1-shroud", 33.5, 29.5, 58.5, 38.5) # 封装里没画、但实物要占的地方
|
||
pl.allow_overhang("U1") # F 头之类本来就伸出板边的件
|
||
pl.save(); bad = pl.check() # 重叠/出板清单,空 = 干净
|
||
pl.render(png) # 出图目视
|
||
|
||
pcbnew 坑(G-0038):只改位置/翻面不删件,所以直接在现有板上操作是安全的;
|
||
SetLayerType / 叠层写不进,叠层在 save() 之后按文本补(set_stackup)。
|
||
"""
|
||
import io
|
||
import os
|
||
import re
|
||
import subprocess
|
||
|
||
import pcbnew
|
||
|
||
from .env import kicad_cli
|
||
|
||
|
||
def sys_python():
|
||
"""找一个 import 得了 fitz(pymupdf) 的 python:skills_env 的 SYS_PYTHON > 常见安装位置 > PATH。"""
|
||
import shutil
|
||
from .env import env
|
||
cands = [env("SYS_PYTHON"), r"C:\Python314\python.exe", r"C:\Python313\python.exe",
|
||
r"C:\Python312\python.exe", env("CONDA_LANGGRAPH_PYTHON"), shutil.which("python")]
|
||
for c in cands:
|
||
if c and os.path.exists(c) and subprocess.run([c, "-c", "import fitz"],
|
||
capture_output=True).returncode == 0:
|
||
return c
|
||
raise SystemExit("找不到装了 pymupdf 的 python,请在 ~/skills_env.md 配 SYS_PYTHON")
|
||
|
||
|
||
def mm(v):
|
||
return pcbnew.FromMM(float(v))
|
||
|
||
|
||
# 嘉立创 4 层 1.6mm(JLC7628)——ipots 载板同款
|
||
JLC4 = """ (setup
|
||
(stackup
|
||
(layer "F.SilkS" (type "Top Silk Screen"))
|
||
(layer "F.Paste" (type "Top Solder Paste"))
|
||
(layer "F.Mask" (type "Top Solder Mask") (thickness 0.01))
|
||
(layer "F.Cu" (type "copper") (thickness 0.035))
|
||
(layer "dielectric 1" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4) (loss_tangent 0.02))
|
||
(layer "In1.Cu" (type "copper") (thickness 0.0152))
|
||
(layer "dielectric 2" (type "core") (thickness 1.065) (material "FR4") (epsilon_r 4.6) (loss_tangent 0.02))
|
||
(layer "In2.Cu" (type "copper") (thickness 0.0152))
|
||
(layer "dielectric 3" (type "prepreg") (thickness 0.2104) (material "FR4") (epsilon_r 4.4) (loss_tangent 0.02))
|
||
(layer "B.Cu" (type "copper") (thickness 0.035))
|
||
(layer "B.Mask" (type "Bottom Solder Mask") (thickness 0.01))
|
||
(layer "B.Paste" (type "Bottom Solder Paste"))
|
||
(layer "B.SilkS" (type "Bottom Silk Screen"))
|
||
(copper_finish "ENIG")
|
||
(dielectric_constraints no)
|
||
)
|
||
"""
|
||
|
||
|
||
class Placer:
|
||
def __init__(self, pcb, origin=(100.0, 100.0), strip_outline=False):
|
||
"""strip_outline=True:载入前按文本删掉旧板框(Edge.Cuts 上的顶层图元),之后重画。
|
||
不能载入后用 board.Remove() 删——删任何对象之后本版 swig 类型注册就坏了(G-0038,
|
||
实测删板框线也一样:之后 GetCourtyard() 返回裸 SwigPyObject)。"""
|
||
self.pcb = pcb
|
||
self.ox, self.oy = origin
|
||
if strip_outline:
|
||
self._strip_edge_cuts()
|
||
self.b = pcbnew.LoadBoard(pcb)
|
||
self.fp = {f.GetReference(): f for f in self.b.GetFootprints()}
|
||
self.w = self.h = None
|
||
self.keepouts = [] # (名字, x0, y0, x1, y1, 面) 板内坐标
|
||
self.overhang = set()
|
||
self.placed = set()
|
||
|
||
# ---------- 坐标 ----------
|
||
def P(self, x, y):
|
||
return pcbnew.VECTOR2I(mm(self.ox + x), mm(self.oy + y))
|
||
|
||
def outline(self, w, h, r=1.0, width=0.1):
|
||
"""圆角矩形板框(Edge.Cuts)。重复调用会叠加,只在新板上调一次。"""
|
||
self.w, self.h = w, h
|
||
L = pcbnew.Edge_Cuts
|
||
segs = [((r, 0), (w - r, 0)), ((w, r), (w, h - r)), ((w - r, h), (r, h)), ((0, h - r), (0, r))]
|
||
for a, c in segs:
|
||
s = pcbnew.PCB_SHAPE(self.b)
|
||
s.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||
s.SetStart(self.P(*a)); s.SetEnd(self.P(*c))
|
||
s.SetLayer(L); s.SetWidth(mm(width))
|
||
self.b.Add(s)
|
||
if r > 0:
|
||
# 三点法画圆角:只给圆心+起止点时 KiCad 会按方向取弧,可能绕远路穿过板内
|
||
# (DVBS2_dmod 第一版就这样,DRC 报孔到板边 0mm);中间点放在外侧 45° 处就唯一了
|
||
k = r * (1 - 0.5 ** 0.5)
|
||
for s, m, e in (((0, r), (k, k), (r, 0)), ((w - r, 0), (w - k, k), (w, r)),
|
||
((w, h - r), (w - k, h - k), (w - r, h)), ((r, h), (k, h - k), (0, h - r))):
|
||
a = pcbnew.PCB_SHAPE(self.b)
|
||
a.SetShape(pcbnew.SHAPE_T_ARC)
|
||
a.SetArcGeometry(self.P(*s), self.P(*m), self.P(*e))
|
||
a.SetLayer(L); a.SetWidth(mm(width))
|
||
self.b.Add(a)
|
||
|
||
def has_outline(self):
|
||
return any(d.GetLayer() == pcbnew.Edge_Cuts for d in self.b.GetDrawings())
|
||
|
||
def _strip_edge_cuts(self):
|
||
"""按文本删掉顶层 Edge.Cuts 图元(gr_line/gr_arc/...),返回删掉的个数。"""
|
||
from .kicad_lib import balanced
|
||
t = io.open(self.pcb, encoding="utf-8").read()
|
||
pat = re.compile(r"\n\t\((gr_line|gr_arc|gr_rect|gr_circle|gr_poly)\b")
|
||
out, i, n = [], 0, 0
|
||
for m in pat.finditer(t):
|
||
if m.start() < i:
|
||
continue
|
||
blk = balanced(t, m.start() + 2)
|
||
if '(layer "Edge.Cuts")' in blk:
|
||
out.append(t[i:m.start()])
|
||
i = m.start() + 2 + len(blk)
|
||
n += 1
|
||
out.append(t[i:])
|
||
io.open(self.pcb, "w", encoding="utf-8", newline="\n").write("".join(out))
|
||
return n
|
||
|
||
# ---------- 摆放 ----------
|
||
def _side(self, f, bottom):
|
||
if f.IsFlipped() != bottom:
|
||
try:
|
||
f.Flip(f.GetPosition(), pcbnew.FLIP_DIRECTION_TOP_BOTTOM)
|
||
except (AttributeError, TypeError):
|
||
f.Flip(f.GetPosition(), False)
|
||
|
||
def at(self, ref, x, y, rot=0, bottom=False):
|
||
"""封装原点落在板内坐标 (x, y)。rot 为俯视逆时针角度(底面件也按俯视给)。"""
|
||
f = self.fp[ref]
|
||
self._side(f, bottom)
|
||
f.SetOrientationDegrees(rot)
|
||
f.SetPosition(self.P(x, y))
|
||
self.placed.add(ref)
|
||
return f
|
||
|
||
def box(self, ref):
|
||
"""封装占地(优先 courtyard,没有就用含焊盘/丝印的外框),板内坐标 (x0, y0, x1, y1)。"""
|
||
f = self.fp[ref]
|
||
cy = f.GetCourtyard(pcbnew.B_CrtYd if f.IsFlipped() else pcbnew.F_CrtYd)
|
||
if cy.OutlineCount():
|
||
bb = cy.BBox()
|
||
x0, y0, w, h = bb.GetX(), bb.GetY(), bb.GetWidth(), bb.GetHeight()
|
||
else:
|
||
bb = f.GetBoundingBox(False, False)
|
||
x0, y0, w, h = bb.GetX(), bb.GetY(), bb.GetWidth(), bb.GetHeight()
|
||
X0, Y0 = pcbnew.ToMM(x0) - self.ox, pcbnew.ToMM(y0) - self.oy
|
||
return (X0, Y0, X0 + pcbnew.ToMM(w), Y0 + pcbnew.ToMM(h))
|
||
|
||
def center(self, ref, x, y, rot=0, bottom=False):
|
||
"""封装占地中心落在 (x, y)——比按原点摆直观(原点常在 1 脚)。"""
|
||
self.at(ref, x, y, rot, bottom)
|
||
x0, y0, x1, y1 = self.box(ref)
|
||
dx, dy = x - (x0 + x1) / 2.0, y - (y0 + y1) / 2.0
|
||
pos = self.fp[ref].GetPosition()
|
||
self.fp[ref].SetPosition(pcbnew.VECTOR2I(pos.x + mm(dx), pos.y + mm(dy)))
|
||
return self.box(ref)
|
||
|
||
def keepout(self, name, x0, y0, x1, y1, bottom=False):
|
||
"""登记一块实物占地(封装里没画的护套、螺丝头、线缆出口……)参与重叠检查。"""
|
||
self.keepouts.append((name, x0, y0, x1, y1, "B" if bottom else "F"))
|
||
|
||
def allow_overhang(self, *refs):
|
||
self.overhang.update(refs)
|
||
|
||
# ---------- 检查 ----------
|
||
def check(self, gap=0.0, edge=0.3):
|
||
"""同面占地两两不重叠(留 gap),占地在板框内(离边 edge,allow_overhang 的除外),
|
||
全部封装都摆过。返回问题清单。"""
|
||
def hit(p, q, g):
|
||
return not (p[2] + g <= q[0] or q[2] + g <= p[0] or p[3] + g <= q[1] or q[3] + g <= p[1])
|
||
|
||
items = [] # (名字, 占地框, 面, 通孔焊盘框列表)
|
||
for ref, f in self.fp.items():
|
||
side = "B" if f.IsFlipped() else "F"
|
||
th = []
|
||
for p in f.Pads():
|
||
if p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH):
|
||
bb = p.GetBoundingBox()
|
||
x0, y0 = pcbnew.ToMM(bb.GetX()) - self.ox, pcbnew.ToMM(bb.GetY()) - self.oy
|
||
th.append((x0, y0, x0 + pcbnew.ToMM(bb.GetWidth()), y0 + pcbnew.ToMM(bb.GetHeight())))
|
||
items.append((ref, self.box(ref), side, th))
|
||
for k in self.keepouts:
|
||
items.append((k[0], k[1:5], k[5], []))
|
||
bad = []
|
||
for i in range(len(items)):
|
||
a, ba, sa, ta = items[i]
|
||
for j in range(i + 1, len(items)):
|
||
c, bc, sc, tc = items[j]
|
||
if a.split("-")[0] == c.split("-")[0]:
|
||
continue # 器件和它自己登记的禁布区(如 J1-shroud)
|
||
if sa == sc:
|
||
if hit(ba, bc, gap):
|
||
bad.append("重叠 %s x %s" % (a, c))
|
||
continue
|
||
# 不同面:只有通孔焊盘/引脚会穿过去,按焊盘比(金属壳 NIM 底下的背面去耦是合法的)
|
||
if any(hit(pb, bc, gap) for pb in ta) or any(hit(pb, ba, gap) for pb in tc):
|
||
bad.append("跨面 %s x %s(通孔焊盘)" % (a, c))
|
||
if self.w:
|
||
for ref, bx, side, _ in items:
|
||
if ref in self.overhang:
|
||
continue
|
||
if bx[0] < edge or bx[1] < edge or bx[2] > self.w - edge or bx[3] > self.h - edge:
|
||
bad.append("出板/贴边 %s (%.1f,%.1f)-(%.1f,%.1f)" % ((ref,) + tuple(bx)))
|
||
miss = sorted(set(self.fp) - self.placed)
|
||
if miss:
|
||
bad.append("没摆的件:%s" % " ".join(miss))
|
||
return bad
|
||
|
||
# ---------- 丝印 ----------
|
||
def _bb(self, item):
|
||
bb = item.GetBoundingBox()
|
||
x0, y0 = pcbnew.ToMM(bb.GetX()) - self.ox, pcbnew.ToMM(bb.GetY()) - self.oy
|
||
return (x0, y0, x0 + pcbnew.ToMM(bb.GetWidth()), y0 + pcbnew.ToMM(bb.GetHeight()))
|
||
|
||
def silk_obstacles(self, side):
|
||
"""某一面丝印要避开的东西:该面所有器件占地、所有通孔焊盘、该面所有贴片焊盘、
|
||
各器件画在该面丝印层上的图形(外框线)、登记的禁布区。返回 [(名字, 框)]。"""
|
||
silk = pcbnew.B_SilkS if side == "B" else pcbnew.F_SilkS
|
||
obs = []
|
||
for ref, f in self.fp.items():
|
||
fs = "B" if f.IsFlipped() else "F"
|
||
if fs == side:
|
||
obs.append((ref + "/body", self.box(ref)))
|
||
for p in f.Pads():
|
||
th = p.GetAttribute() in (pcbnew.PAD_ATTRIB_PTH, pcbnew.PAD_ATTRIB_NPTH)
|
||
if th or fs == side:
|
||
obs.append((ref + "/pad", self._bb(p)))
|
||
for g in f.GraphicalItems():
|
||
if g.GetLayer() == silk and g.GetClass() not in ("PCB_TEXT", "PCB_FIELD"):
|
||
obs.append((ref + "/silk", self._bb(g)))
|
||
for k in self.keepouts:
|
||
if k[5] == side:
|
||
obs.append((k[0], k[1:5]))
|
||
return obs
|
||
|
||
def place_refs(self, size=0.8, thick=0.15, gap=0.15, edge=0.35, order=None, fixed=None,
|
||
max_off=4.0, prefer=None):
|
||
"""自动摆位号:字高 size / 笔宽 thick(嘉立创丝印下限 0.8/0.15),数值一律移到 Fab 层。
|
||
每个位号在自己器件四周按"上/下/左/右 × 横/竖"逐步外移找空位,要求不压任何器件占地
|
||
(含自己)、焊盘、丝印图形、已摆的位号、板边。fixed={位号: (x, y, 角度)} 手工指定。
|
||
prefer={位号: (方位, 角度)},方位 u/d/l/r、角度 0/90:优先试这个组合(一排小件想要整齐
|
||
地竖写在正上方时用)。返回摆不下的位号清单。"""
|
||
fixed = fixed or {}
|
||
prefer = prefer or {}
|
||
placed = {"F": [], "B": []}
|
||
obs_cache = {}
|
||
fail = []
|
||
refs = order or sorted(self.fp, key=lambda r: (self.box(r)[2] - self.box(r)[0]) *
|
||
(self.box(r)[3] - self.box(r)[1]))
|
||
for ref in refs:
|
||
f = self.fp[ref]
|
||
side = "B" if f.IsFlipped() else "F"
|
||
v = f.Value()
|
||
v.SetLayer(pcbnew.B_Fab if side == "B" else pcbnew.F_Fab)
|
||
r = f.Reference()
|
||
r.SetVisible(True)
|
||
r.SetLayer(pcbnew.B_SilkS if side == "B" else pcbnew.F_SilkS)
|
||
r.SetTextSize(pcbnew.VECTOR2I(mm(size), mm(size)))
|
||
r.SetTextThickness(mm(thick))
|
||
if side not in obs_cache:
|
||
obs_cache[side] = self.silk_obstacles(side)
|
||
obs = obs_cache[side]
|
||
|
||
def free(tb):
|
||
if tb[0] < edge or tb[1] < edge or (self.w and tb[2] > self.w - edge) or \
|
||
(self.h and tb[3] > self.h - edge):
|
||
return False
|
||
g = gap
|
||
for _, o in obs:
|
||
if not (tb[2] + g <= o[0] or o[2] + g <= tb[0] or tb[3] + g <= o[1] or o[3] + g <= tb[1]):
|
||
return False
|
||
for o in placed[side]:
|
||
if not (tb[2] + g <= o[0] or o[2] + g <= tb[0] or tb[3] + g <= o[1] or o[3] + g <= tb[1]):
|
||
return False
|
||
return True
|
||
|
||
def put(x, y, ang):
|
||
r.SetTextAngleDegrees(ang)
|
||
r.SetPosition(self.P(x, y))
|
||
return self._bb(r)
|
||
|
||
if ref in fixed:
|
||
x, y, ang = fixed[ref]
|
||
tb = put(x, y, ang)
|
||
placed[side].append(tb)
|
||
if not free(tb):
|
||
fail.append(ref + "(指定位置仍冲突)")
|
||
continue
|
||
x0, y0, x1, y1 = self.box(ref)
|
||
cx, cy = (x0 + x1) / 2.0, (y0 + y1) / 2.0
|
||
done = False
|
||
# 候选位置按代价排序:离器件近、横向偏移小的优先(位号要一眼看出属于谁),
|
||
# 竖排略贵于横排;横向偏移不超过器件自身半宽/半高 + 1mm
|
||
sizes = {}
|
||
for ang in (0, 90):
|
||
tb0 = put(0, 0, ang)
|
||
sizes[ang] = (tb0[2] - tb0[0], tb0[3] - tb0[1])
|
||
hx, hy = (x1 - x0) / 2.0 + 1.0, (y1 - y0) / 2.0 + 1.0
|
||
cands = []
|
||
for ang, (tw, th) in sizes.items():
|
||
d = 0.0
|
||
while d <= max_off + 1e-9:
|
||
for k in range(0, 41):
|
||
s = (k + 1) // 2 * 0.25 * (1 if k % 2 else -1)
|
||
for side_, (x, y), lim in (("u", (cx + s, y0 - gap - d - th / 2), hx),
|
||
("d", (cx + s, y1 + gap + d + th / 2), hx),
|
||
("l", (x0 - gap - d - tw / 2, cy + s), hy),
|
||
("r", (x1 + gap + d + tw / 2, cy + s), hy)):
|
||
if abs(s) > lim:
|
||
continue
|
||
cost = d + 0.8 * abs(s) + (0.4 if ang == 90 else 0.0)
|
||
if prefer.get(ref) == (side_, ang):
|
||
cost -= 3.0
|
||
cands.append((cost, x, y, ang))
|
||
d += 0.25
|
||
cands.sort(key=lambda c: c[0])
|
||
for _, x, y, a in cands:
|
||
tb = put(x, y, a)
|
||
dx = x - (tb[0] + tb[2]) / 2.0 # 文字框中心校正到 (x, y)
|
||
dy = y - (tb[1] + tb[3]) / 2.0
|
||
tb = put(x + dx, y + dy, a)
|
||
if free(tb):
|
||
placed[side].append(tb)
|
||
done = True
|
||
break
|
||
if not done:
|
||
fail.append(ref)
|
||
r.SetVisible(False)
|
||
return fail
|
||
|
||
# ---------- 存盘 / 叠层 / 出图 ----------
|
||
def layers(self, names=None, n=None):
|
||
if n:
|
||
self.b.SetCopperLayerCount(n)
|
||
for lid, nm in (names or {}).items():
|
||
self.b.SetLayerName(lid, nm)
|
||
|
||
def save(self, stackup=None, power_layers=()):
|
||
pcbnew.SaveBoard(self.pcb, self.b)
|
||
if stackup or power_layers:
|
||
t = io.open(self.pcb, encoding="utf-8").read()
|
||
head = "\t(setup\n"
|
||
if stackup and "(stackup" not in t and head in t:
|
||
t = t.replace(head, stackup, 1)
|
||
for ln in power_layers: # SetLayerType 写不进,按文本改(G-0038)
|
||
t = t.replace('"%s" signal' % ln, '"%s" power' % ln)
|
||
io.open(self.pcb, "w", encoding="utf-8", newline="\n").write(t)
|
||
|
||
def render(self, png, layers="F.Cu,B.Cu,F.SilkS,B.SilkS,F.Fab,F.CrtYd,B.CrtYd,Edge.Cuts,Dwgs.User",
|
||
dpi=200):
|
||
"""出一张总览 PNG(kicad-cli 出 SVG → headless 浏览器截图太麻烦,直接走 PDF + fitz)。"""
|
||
pdf = os.path.splitext(png)[0] + ".pdf"
|
||
subprocess.run([kicad_cli(), "pcb", "export", "pdf", "--layers", layers, "--mode-single",
|
||
"-o", pdf, self.pcb], capture_output=True)
|
||
clip = ((self.ox - 20) / 25.4 * 72, (self.oy - 8) / 25.4 * 72,
|
||
(self.ox + (self.w or 100) + 8) / 25.4 * 72, (self.oy + (self.h or 100) + 8) / 25.4 * 72)
|
||
code = ("import fitz,sys; d=fitz.open(sys.argv[1]); p=d[0]; r=fitz.Rect(%r,%r,%r,%r); "
|
||
"p.get_pixmap(dpi=%d, clip=r & p.rect).save(sys.argv[2])" % (clip + (dpi,)))
|
||
# KiCad 自带 python 通常没有 pymupdf,交给装了 fitz 的系统 python 转 PNG。
|
||
# 注意在 KiCad python 里 PATH 上的 "python" 仍是它自己,所以按候选路径找。
|
||
subprocess.run([sys_python(), "-c", code, pdf, png], check=True)
|
||
return png
|