Files
2026-10-15 01:59:40 +08:00

376 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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