# -*- coding: utf-8 -*- u"""R2 底板 PCB 摆位(M8c)。 /c/Python314/python .claude/r2/place_base.py # 只算 + 自检,不下发 /c/Python314/python .claude/r2/place_base.py --push # 下发摆位 /c/Python314/python .claude/r2/place_base.py --verify # 读回校验(含朝向断言) /c/Python314/python .claude/r2/place_base.py --dump # 重抓焊盘坐标 _base_pads.json /c/Python314/python .claude/r2/place_base.py --bbox # 重抓封装外框 _base_bbox.json 算法照搬刀片 M7 那一版(方案 §27.5):**大件先挑「离目标最近的空位」**, 目标点精确到对端焊盘;不是「按面积自下而上塞」。 ## 五条带(`pcb_base.ZONES`) | 带 | y | 放什么 | 为什么 | |---|---|---|---| | input | 0~15.5 | A1-INPUT 全部 + `J23` USB-C | **投影带外**,唯一能放 Φ8 直立电解 / DC 座的地方 | | hub | 15.5~43.5 | A4-HUB | `U32` 顶着槽阵南沿,D± 一出脚就往北 | | slots | 43.5~85.5 | 只有 `J34~J38` | 走廊要留给 12 根/条的信号,**一件都不放** | | ctrl | 85.5~113.5 | A3-CTRL + A2-AUX + 风扇小件 | 全部 ≤8 mm,压在刀片投影下没问题 | | fan | 113.5~125 | `J26/J27` 风扇座 + `J33` 应急口 | **投影带外**,TH 立式 11 mm 高只能在这 | ## ⛔ 三条「几何验证抓不到、错了就整板报废」的朝向 1. **`U32` rot=270**:下行口 pin1~16 在封装原生朝向是**西边**(实测 dx=-4.45), 转 270° 才朝北正对槽阵。转 90° 会朝南 —— 五对 D± 得从芯片底下绕出来。 2. **插槽物理顺序是反的:西 → 东 = `J38 J37 J36 J35 J34`**。 `U32` 转 270° 之后北排的 x 顺序是 **USB1 在东、USB5 在西**(pin1 dy=+3.75 → x=+3.75)。 若按位号自然顺序把 `J34` 摆在西边,五对 D± 会**互相交叉**;两层板上交叉 = 打过孔换到 L2 = 在 D± 走廊正下方割地平面,正是方案 §6.2 第 1 条明令禁止的那件事。 ⇒ 宁可位号顺序看着别扭。丝印上会写清楚。 3. **插槽 rot=90**:封装原生长边沿 x、A 排在 +y。转 90°(逆时针)之后 A1/B1 落在**南**(y=48)、A 排在**西**列、B 排在**东**列。 于是 `+12V`(A2/A3/B1~B3,y 48~50)正对南边的入口链, `D±`(B14/B15,y 63/64)在东列 —— 与上面第 2 条一起决定了走廊分工。 ⚠ 旋转方向(顺/逆)是**推导 + 读回校验**的:`verify()` 里三条断言就是干这个的, 断言不过说明本机客户端的 rot 语义与假设相反,把 90/270 对调即可。 ## D± 长度:最远一路 ~70 mm,是**已知并接受**的(2026-09-09 用户拍板) 方案 §6.3 预估 41 mm,前提是 §6.2 第 3 条「D± 排在靠 Hub 那一端的针位」。 但 M2d 定稿的针位表把 `USB_DP/DM` 放在 **B14/B15**,换算到底板 y=63/64 —— **正好在 42 mm 长插槽的正中**。Hub 只能坐在槽阵南边,于是每一路都要 「出脚 → 沿 y≈41 走 x → 拐进走廊 → 北上 20 mm」。端槽 x 距离 40.6 mm 是几何定死的。 ⇒ 最远 ~70 / 最近 ~30。仍远短于 R1 载板的 92 mm,全程 L2 完整地平面做参考, 且 `J39~J43` 五个 D± 退路座(D12)本来就是为这一条留的。 """ import io import json import math import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 import pcb_base as B # noqa: E402 PCB = B.PCB MIL = B.MIL BBOX = os.path.join(HERE, '_base_bbox.json') NETS = os.path.join(HERE, '_base_pcb_nets.json') PADS = os.path.join(HERE, '_base_pads.json') POS_JSON = os.path.join(HERE, '_base_place.json') GAP = 0.6 # 件间净距(装配下限,自检按它判重叠) PACK_GAP = 1.5 # 排布器摆放节距(刀片 M7 用 1.8;底板件多、板面紧,1.5) EDGE_KEEP = 1.0 # 件离板边 STEP = 0.5 # 候选格点步长 SNAP = 0.25 # 布线网格 # ── 高度表(mm):投影带(y 15.5~113.5)内**硬上限 8**,方案 §6.6 ────────── HEIGHT = { 'J1': 11.0, # DC-005 桶身 'C2': 12.0, 'C3': 12.0, # Φ8 直立电解 470 µF 'J26': 11.0, 'J27': 11.0, # 2510 4P 立式 'J33': 11.5, # 2.54 排针立式 'J23': 3.3, # USB-C 沉板座 'U20': 3.1, 'L3': 4.5, 'L2': 4.0, 'F1': 2.0, } H_DEFAULT = 2.0 # ── 焊盘外框量不到本体的件:给真实本体(mm)。⛔ 不给的话自检会让小件贴到 # 直立电解的肚子上 / 排针的塑壳里 —— 焊盘框只有 5.1×1.6,本体是 Φ8。 BODY = { 'J1': (14.5, 9.0), # DC-005 桶身(桶口朝板边,见 EDGE_PARTS) 'C2': (8.0, 8.0), 'C3': (8.0, 8.0), # Φ8 直立电解 470 µF 'J23': (9.0, 7.5), # USB-C 沉板座本体 'J26': (10.2, 5.0), 'J27': (10.2, 5.0), # 2510 4P 带卡扣 'J33': (12.7, 2.54), # 2.54 排针塑座 'L3': (7.6, 6.5), # 焊盘只量到 3.4,本体 6.5 'U20': (19.0, 19.0), # 焊盘外框实测 19.002 × 18.99;天线那 7.5 mm 另有 antenna_keepout 管 } # 本体相对**焊盘质心**的偏移(本体不居中的件)。R1 §pcb1_place 实测: # DC-005 的本体在 x −10.68~+3.92 ⇒ 中心偏 −3.38,桶口朝 −X(rot 0)。 BODY_OFF = { 'J1': (-3.38, 0.0), 'J23': (0.0, -1.5), # USB-C 焊盘在里、口在外 } # 本体越过板边是**对的**那几个(接口件):不判「出板」,改判「够不够得着板边」。 # 值 = 开口朝哪条边。⚠ 朝向靠 rot 换算:DC-005 桶口原生朝 −X ⇒ 朝南要 rot 90。 EDGE_PARTS = {'J1': 'S', 'J23': 'S'} # ── 手排件:位置有理由的都钉死,其余交给排布器 ──────────────────────────── def hand(): u"""{位号: (x, y, rot)}。每一条都写清楚为什么在这。""" H = {} # 5 个插槽:**物理顺序反着来**(见文件头第 2 条) for i, ref in enumerate(SLOT_ORDER, start=1): H[ref] = (B.slot_x(i), B.SLOT_CY, 90) # Hub:顶着槽阵南沿(本体 9.75,顶边 = 37 + 4.9 = 41.9,离槽体 1.6) # ⛔ y 只能是 24。12V 横脊挪到输入带之后 hub 带整条 28 mm 归 U32: # 南边逃逸 15.5~19.1(3.6,够 7 根 strap)/ 本体 19.1~28.9 / **北边 D± 扇出 14.6**。 # 扇出带的账:10 根 1.2 mm 的线要从 0.5 mm 脚距斜着散到 1.41 mm 线距 ⇒ 4.1 mm, # 再加三条 2.7 的车道 = 12.2。放 37(扇出 1.6)或 30(扇出 8.6)都不够, # 布线器的现象是内侧那几对「布了 0 段」。 H['U32'] = (55.0, 24.0, 270) # 晶振:rot270 之后 XTAL1/2(pin 60/61)在**东**边 y≈+2 ⇒ 贴着东边放 H['Y1'] = (63.0, 26.6, 0) # 上行 USB-C:前边(y=0),东端。⚠ 座子开口朝 -y(原生朝向焊盘在里、口在外), # 本体深 ~7.5 ⇒ 焊盘中心 y=4.2 时前脸正好压在板边。**这一条只能靠截图确认。** H['J23'] = (97.0, 5.25, 0) # 本体 y 0~7.5,前脸压板边 # ── 入口链:**整条手排**,西 → 东,电流方向 = +x ─────────────────────── # ⛔ 交给排布器的下场(第一版实测):`F1`/`Q1`/`D1`/栅极 RC 被「离目标最近」 # 摊到整条 110 mm 的带子上,`VIN_FUSED` 一个网的焊盘外框就有 42 mm 宽, # 四段铺铜互相重叠 —— 而这是**全板电流最大的一条链(9 A)**, # 它的形状决定断面够不够,不能让通用排布器决定。 H['J1'] = (15.0, 11.2, 90) # rot 90 = 桶口朝南;本体 y −0.5~14.1(偏移抄 R1 pcb1_place) H['F1'] = (22.5, 14.0, 0) # 1206 保险,卧着放,电流顺着 +x 直穿 H['Q1'] = (28.5, 11.0, 0) # SOP-8 P-MOS 防反接:1~4 脚在西(VIN_FUSED)、5~8 在东(VIN_SW) H['D1'] = (35.5, 12.5, 0) # SMBJ13A TVS,贴在漏极铜的东缘 H['C2'] = (44.2, 10.5, 0) # Φ8 直立电解 470 µF(南沿 6.5 让开 12V 横脊的 5.8) H['C3'] = (53.6, 10.5, 0) H['C4'] = (33.5, 8.6, 0) # 0805 VIN_SW 旁路:挪到 Q1/D1 底下那块空地 —— 输入带被横脊吃掉 5 mm 之后, # Φ8 电解占满剩下的 8.6 mm 高,它俩挤不进电解那一列 H['C5'] = (37.8, 8.6, 0) H['C6'] = (22.5, 9.0, 0) # 100nF 高频旁路:原位置 y=2.5 被 12V 横脊占了 # 0402 高频旁路,压在储能铜的南沿; # ⚠ 它是 VIN_SW 的一员,交给排布器会被摊到 x=77, # 把 VIN_SW 的铜面一路撑进 12V 母线的地盘 # 检流:Rs6 与 U13 贴在一起走 Kelvin(同 R1 / 刀片 U3-Rs1) H['Rs6'] = (62.0, 10.5, 0) # 2512 5 mΩ,卧着放(南沿 10.9 让开横脊) # ⚠ `U13` 挪到 hub 带:输入带被 12V 横脊吃掉南边 5 mm 之后,剩下的 9.2 mm # 放不下「Rs6 3.46 + U13 5.84 + 净距 0.6 = 9.9」。Kelvin 取样线因此长了几毫米 —— # 判据不变:两根取样线的端点必须落在 `Rs6` 的两个焊盘上,中间不许有过孔。 H['U13'] = (62.5, 19.0, 0) # INA226,Rs6 正北 # 风墙:两只风扇座 + 应急串口,全在投影带外 H['J26'] = (20.0, 119.0, 0) H['J27'] = (40.0, 119.0, 0) H['J33'] = (92.0, 119.0, 0) # ESP32:天线在封装的 +y 那一头(焊盘只有三面,北面 7 mm 是天线)。 # ⛔ **2026-09-09 用户拍板改成 rot=90(天线朝西)+ 挪到西板边 + 板材挖缺口**。 # 旧值 (17.0, 96.0, 0) 把天线埋在板中央:天线尖正前方 1.0 mm 就是整块地平面, # 再往北 3 mm 是风扇带的 RT601/RT602 —— 等于给天线加了块反射板, # 而设计文档 §PCB 要点要求的是「模组**放板边**」。见 `pcb_base.ANT_NOTCH`。 # rot 是 CCW:+y 转 90° → −x(朝西),与 `U32` rot=270「西→北」同一套语义。 # x=18.0 ⇒ 焊盘西沿 8.5,离缺口边(x=7.5)1.0 mm;天线段 x 1.0~8.5 基本悬空。 # ⚠ 连带代价(已知、可接受):pin1~14 从西列转到**南排**(FAN_* / PRSNT / USB6 D±), # pin15~26 从南排转到**东列**(EFUSE_EN1~5),pin27~40 转到**北排**(I²C / NTC / UART)。 # 风扇那几根因此要绕模组东侧往北走 ~40 mm —— 都是低速信号,交给布线器。 H['U20'] = (18.0, 96.0, 90) # mux / DAC / 两颗 Buck:ctrl 带里按功能分块,剩下的交给排布器 H['U35'] = (40.0, 90.0, 0) # I²C mux,南面 SCL1~4 朝槽阵 H['U6'] = (52.0, 90.0, 0) # DAC 1/2/4 H['U7'] = (62.0, 90.0, 0) # DAC 3/5 H['U19'] = (78.0, 90.0, 0) # 3V3 Buck H['U30'] = (95.0, 90.0, 0) # 5V Buck # ⚠ `U31`(AMS1117)和五个 D± 退路座 `J39~J43` **交给排布器**: # 12V 母线改成梳子之后,hub 带里的空位被脊和五根齿切得很碎,手写坐标一改 # 母线就得跟着改一遍。它们的目标点很明确(`U31`→`U32`,退路座→自己那一路的 # `USBn_DP/DM`),排布器按「离目标最近的空位」摆比手写稳。 return H # 西 → 东。见文件头第 2 条。 SLOT_ORDER = ['J38', 'J37', 'J36', 'J35', 'J34'] # ⛔ 2026-09-09 用户拍板:底板上的 5 个 D± 退路座 `J39~J43` 全部删掉 # (原理图 A5B-SLOTS 也删了)。底板侧不再有 D± 退路 —— # `USBn_DP/DM` 从 `U32` 直达槽针,一条路走到黑。 def corridor_x(i): u"""槽 i(1=最西)东侧走廊的中心 x。""" xs = B.slot_xs() if i < B.SLOT_N: return round((xs[i - 1] + xs[i]) / 2.0, 3) return round((xs[-1] + B.SLOT_BODY_W / 2 + B.BW) / 2.0, 3) # ── 分区:谁进哪条带(页 → 带,加少量覆盖)──────────────────────────────── PAGE_REGION = { 'A1-INPUT': 'input', 'A4-HUB': 'hub', 'A5-SLOTS': 'slots', 'A5B-SLOTS': 'hub', 'A3-CTRL': 'ctrl', 'A2-AUX': 'ctrl', 'A6-FAN': 'fan', } REGION_OVERRIDE = { 'J23': 'input', # USB-C 要在前边板沿,不在 hub 带 'J33': 'fan', # 立式排针 11.5 mm,只能在投影带外 } # 泄放块(TL431 + 10 Ω/10 W 五颗 2512 + 驱动)整块挪到 ctrl 带 —— 方案 §6.5 就是这么分的。 # ⛔ 留在 input 带的后果实测过:排布器按「离目标最近」把 `Q3` 塞进 `VIN_SW` 的铜面正中, # 9 A 那段铜被它的两只脚掐到 3.0 mm(要 8)。泄放只在回灌瞬态导通,占空比极低, # 放在刀片投影下(2512 高 0.6 mm)没有高度问题,而且正对风道。 for _r in ('U29', 'Q3', 'R4', 'R5', 'R6', 'C11', 'R9A', 'R9B', 'R9C', 'R9D', 'R9E'): REGION_OVERRIDE[_r] = 'ctrl' # `Q1` 的栅极 RC(缓启动)挪到 hub 带:它们是 GATE / VIN_FUSED 网,留在输入带就会 # 坐在 **9 A 的 `VIN_SW` 铜面正中**,把断面从 8.7 掐到 5.0(实测)。 # 栅极 RC 是低速软启动回路,离 Q1 十来毫米没有影响。 for _r in ('DZ1', 'C1', 'R1'): REGION_OVERRIDE[_r] = 'hub' # 排不下就往这条带溢出(风扇带只有 11.5 mm 深,27 个小件塞不满也塞不下全部) OVERFLOW = {'fan': 'ctrl', 'ctrl': 'hub', 'input': 'hub', 'hub': 'ctrl'} # 排布器可用矩形 = 分区带按 EDGE_KEEP 内缩 def regions(): out = {} for k, (x0, y0, x1, y1) in B.ZONES.items(): out[k] = (max(x0 + EDGE_KEEP, EDGE_KEEP), y0 + (0.2 if y0 > 0 else EDGE_KEEP), min(x1 - EDGE_KEEP, B.BW - EDGE_KEEP), y1 - (0.2 if y1 < B.BH else EDGE_KEEP)) return out # ── 数据 ───────────────────────────────────────────────────────────────── def sizes(): d = json.load(io.open(BBOX, encoding='utf-8')) out = {} for k, v in d.items(): bw, bh = BODY.get(k, (0.0, 0.0)) out[k] = (max(v['w'], bw, 0.6), max(v['h'], bh, 0.6)) return out def page_of(): u"""{位号: 页名},从原理图 BOM 反查。""" bom = json.load(io.open(os.path.join(HERE, '_base_sch_bom.json'), encoding='utf-8')) return {r['d']: pg for pg, rows in bom.items() for r in rows} def net_pins(): u"""{网名: [(位号, 脚号)]},去掉 GND 这种到处都是的。""" nets = json.load(io.open(NETS, encoding='utf-8')) out = {} for ref, pads in nets.items(): for pn, net in pads: if net: out.setdefault(net, []).append((ref, str(pn))) return out BIG_NETS = {'GND', '+12V', '+3V3', '+5V', '+3V3_HUB'} def peers(): u"""{位号: [对端位号]},只算「小网」(≤6 只脚),大网不带方向信息。""" out = {} for net, pins in net_pins().items(): if net in BIG_NETS or len(pins) > 6: continue refs = sorted(set(r for r, _ in pins)) for a in refs: out.setdefault(a, []).extend(b for b in refs if b != a) return out def wh(ref, rot, sz): w, h = sz[ref] return (h, w) if rot % 180 else (w, h) def body_rect(ref, x, y, rot, sz): u"""本体矩形(板坐标)。偏移要跟着 rot 转 —— 不转的话 DC 座的桶身会算到反方向去。""" w, h = wh(ref, rot, sz) ox, oy = BODY_OFF.get(ref, (0.0, 0.0)) t = math.radians(rot % 360) dx = ox * math.cos(t) - oy * math.sin(t) dy = ox * math.sin(t) + oy * math.cos(t) cx, cy = x + dx, y + dy return (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) def height(ref): return HEIGHT.get(ref, H_DEFAULT) def overlaps(r, rects): for q in rects: if r[0] < q[2] and q[0] < r[2] and r[1] < q[3] and q[1] < r[3]: return True return False def blockers(sz): u"""固定障碍:四个螺钉头避让圈 + 板角 R5 之外的地方 + `U20` 的天线净空。""" out = [] k = B.HOLE_KEEP for _n, (hx, hy) in B.MOUNT_HOLES.items(): out.append((hx - k, hy - k, hx + k, hy + k)) # ESP32 天线净空(跟着 rot 转;rot=90 时在模组**西边**,正对板框缺口) out.append(antenna_keepout(sz)) # ⛔ 12V 母线 + D± 扇出带是摆位禁区。往母线上塞件,铜面被挖成细条而断面校核 # 只看得到「有铜」;往扇出带塞件,五对 D± 直接布不出来。 out += list(B.keepout_rects()) return out ANT_LEN = 7.5 # 天线段:本体 25.5 − (焊盘框 19.0 − 每边出脚 0.5) = 7.0,留 0.5 ANT_SIDE = 0.5 # 天线两侧各留 def antenna_keepout(sz): u"""`U20` 天线净空:两层都不许铺铜、不许走线、不许放件。 ⛔ **必须跟着 rot 转**。天线在封装的 +y 那一头;rot 是 CCW ⇒ 90° 之后朝西。 写死成「焊盘北沿之外那一块」的下场:模组转过去了、净空还留在北边, 于是铺铜照样把铜铺到天线底下 —— 而摆位自检 / DRC / 几何校验**一条都不会响** (这一版就是这么来的:模组在板中央、天线尖前 1 mm 一整块地平面)。 """ ax, ay, rot = hand()['U20'] w, h = wh('U20', rot, sz) r = int(round(rot)) % 360 if r == 0: # 天线朝北 return (ax - w / 2 - ANT_SIDE, ay + h / 2, ax + w / 2 + ANT_SIDE, ay + h / 2 + ANT_LEN) if r == 90: # 天线朝西 return (ax - w / 2 - ANT_LEN, ay - h / 2 - ANT_SIDE, ax - w / 2, ay + h / 2 + ANT_SIDE) if r == 180: # 天线朝南 return (ax - w / 2 - ANT_SIDE, ay - h / 2 - ANT_LEN, ax + w / 2 + ANT_SIDE, ay - h / 2) if r == 270: # 天线朝东 return (ax + w / 2, ay - h / 2 - ANT_SIDE, ax + w / 2 + ANT_LEN, ay + h / 2 + ANT_SIDE) raise ValueError(u'U20 只支持 0/90/180/270,拿到 %s' % rot) # ── 排布 ───────────────────────────────────────────────────────────────── def target_point(ref, pr, pos, sz, fallback): u"""目标点 = 已定位的对端件的质心(有芯片对端时只认芯片)。""" ps = [p for p in pr.get(ref, []) if p in pos] if not ps: return fallback chips = [p for p in ps if sz.get(p, (0, 0))[0] * sz.get(p, (0, 0))[1] > 12.0] use = chips or ps return (sum(pos[p][0] for p in use) / len(use), sum(pos[p][1] for p in use) / len(use)) def plan(): sz = sizes() pg = page_of() pos = dict(hand()) rects = list(blockers(sz)) for ref, (x, y, r) in pos.items(): w, h = wh(ref, r, sz) rects.append((x - w / 2 - GAP / 2, y - h / 2 - GAP / 2, x + w / 2 + GAP / 2, y + h / 2 + GAP / 2)) R = regions() todo = {} for ref in sz: if ref in pos: continue reg = REGION_OVERRIDE.get(ref) or PAGE_REGION[pg[ref]] todo.setdefault(reg, []).append(ref) pr = peers() unplaced = [] order = ['input', 'hub', 'ctrl', 'fan'] for reg in order: refs = sorted(todo.get(reg, []), key=lambda r: (-sz[r][0] * sz[r][1], r)) for ref in refs: placed = False chain = [reg] nxt = OVERFLOW.get(reg) while nxt and nxt not in chain: chain.append(nxt) nxt = OVERFLOW.get(nxt) for rg in chain: x0, y0, x1, y1 = R[rg] w, h = sz[ref][0] + PACK_GAP, sz[ref][1] + PACK_GAP t = target_point(ref, pr, pos, sz, ((x0 + x1) / 2.0, (y0 + y1) / 2.0)) best = None yy = y0 while yy + h <= y1 + 1e-9: xx = x0 while xx + w <= x1 + 1e-9: r = (xx, yy, xx + w, yy + h) if not overlaps(r, rects): cx, cy = xx + w / 2, yy + h / 2 key = (math.hypot(cx - t[0], cy - t[1]), yy, xx) if best is None or key < best[0]: best = (key, r, cx, cy) xx += STEP yy += STEP if best is not None: _k, r, cx, cy = best rects.append(r) pos[ref] = (round(cx, 3), round(cy, 3), 0) placed = True break if not placed: unplaced.append((ref, reg)) snap(pos, sz, rects) return pos, rects, unplaced, sz # ⛔ **插槽不许吸网格**:两列焊盘相距 7.2 mm(= 28.8 格),两列不可能同时落在 0.25 上, # 而吸一下就把 20.32 的槽距打成 20.25/20.5 交替 —— 3D 打印导轨是按 20.32 等距做的。 # ⇒ 槽距的精确值比焊盘对格重要,插槽整体排除。 NO_SNAP = set(SLOT_ORDER) def snap(pos, sz, rects): u"""把件吸到 0.25 布线网格上;破了 GAP 的退回去(刀片 M7 §27.9 的做法)。""" before = dict(pos) for ref, (x, y, r) in list(pos.items()): if ref in NO_SNAP: continue pos[ref] = (round(round(x / SNAP) * SNAP, 3), round(round(y / SNAP) * SNAP, 3), r) for _ in range(6): bad = set() items = sorted(pos) for i, a in enumerate(items): wa, ha = wh(a, pos[a][2], sz) ra = (pos[a][0] - wa / 2, pos[a][1] - ha / 2, pos[a][0] + wa / 2, pos[a][1] + ha / 2) for b in items[i + 1:]: wb, hb = wh(b, pos[b][2], sz) rb = (pos[b][0] - wb / 2, pos[b][1] - hb / 2, pos[b][0] + wb / 2, pos[b][1] + hb / 2) if ra[0] < rb[2] + GAP and rb[0] < ra[2] + GAP and \ ra[1] < rb[3] + GAP and rb[1] < ra[3] + GAP: bad.add(a if pos[a] != before[a] else b) bad = {r for r in bad if pos[r] != before[r]} if not bad: break for ref in bad: pos[ref] = before[ref] return pos # ── 自检 ───────────────────────────────────────────────────────────────── def selfcheck(pos, unplaced, sz): bad = [] if unplaced: bad.append(u'排不下 %d 件: %s' % (len(unplaced), ' '.join(r for r, _ in unplaced))) items = sorted(pos) keep = antenna_keepout(sz) for i, a in enumerate(items): x, y, r = pos[a] w, h = wh(a, r, sz) # 板内 + 离板边。⚠ 接口件本体越过板边是对的,改判「够得着板边」 if a in EDGE_PARTS: br = body_rect(a, x, y, r, sz) if abs(br[1]) > 1.0: bad.append(u'%s 是接口件,本体南沿 y=%.2f 没贴到板边(差 %.2f)' % (a, br[1], abs(br[1]))) if not (EDGE_KEEP - 0.01 <= x - w / 2 and x + w / 2 <= B.BW - EDGE_KEEP + 0.01): bad.append(u'%s 东西向出板: x=%.2f w=%.2f' % (a, x, w)) elif not (EDGE_KEEP - 0.01 <= x - w / 2 and x + w / 2 <= B.BW - EDGE_KEEP + 0.01 and EDGE_KEEP - 0.01 <= y - h / 2 and y + h / 2 <= B.BH - EDGE_KEEP + 0.01): bad.append(u'%s 出板或贴边: (%.2f,%.2f) %.2fx%.2f' % (a, x, y, w, h)) # ⛔ ESP32 天线缺口那一块**没有板材**:谁伸进去谁悬空 # (`U20` 除外 —— 它的天线本来就该悬在那儿,这是这个缺口存在的理由) if a != 'U20': nk = B.notch_rect() if (x + w / 2 > nk[0] - EDGE_KEEP + 0.01 and x - w / 2 < nk[2] + EDGE_KEEP - 0.01 and y + h / 2 > nk[1] - EDGE_KEEP + 0.01 and y - h / 2 < nk[3] + EDGE_KEEP - 0.01): bad.append(u'%s 伸进了 ESP32 天线缺口(那儿没板材): (%.2f,%.2f)' % (a, x, y)) # 刀片投影内不许放高件(硬碰撞) if not B.height_ok(y, height(a)): bad.append(u'%s 高 %.1f mm 落在刀片投影 y=%.1f 上(上限 %.0f)' % (a, height(a), y, B.H_PROJ)) # 螺钉头 for n, (hx, hy) in B.MOUNT_HOLES.items(): if abs(x - hx) < w / 2 + B.HOLE_KEEP and abs(y - hy) < h / 2 + B.HOLE_KEEP: bad.append(u'%s 压在螺钉 %s 的头下' % (a, n)) # 天线净空 if x + w / 2 > keep[0] and keep[2] > x - w / 2 and y + h / 2 > keep[1] and keep[3] > y - h / 2: if a != 'U20': bad.append(u'%s 进了 U20 的天线净空' % a) # 槽带里除了插槽什么都不许有 if a not in SLOT_ORDER and B.ZONES['slots'][1] < y < B.ZONES['slots'][3]: bad.append(u'%s 摆进了插槽带 y=%.1f(走廊要留给布线)' % (a, y)) for b in items[i + 1:]: x2, y2, r2 = pos[b] w2, h2 = wh(b, r2, sz) if abs(x - x2) < (w + w2) / 2 + GAP and abs(y - y2) < (h + h2) / 2 + GAP: bad.append(u'%s 与 %s 挨太近(净距 < %.1f)' % (a, b, GAP)) return bad # ── 下发 / 读回 ─────────────────────────────────────────────────────────── OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2500));" % PCB) def call(js, timeout=300): r = run(OPEN + js, timeout=timeout) if not r.get('success'): print(u'失败: ' + json.dumps(r, ensure_ascii=False)[:700]) sys.exit(1) return r['result'] def push(pos): arr = [[k, round(v[0] * MIL, 4), round(v[1] * MIL, 4), v[2]] for k, v in sorted(pos.items())] r = call(""" const P = %s; const by = {}; for (const c of await eda.pcb_PrimitiveComponent.getAll()) by[c.designator] = c; let k = 0, miss = []; for (const [d, x, y, rot] of P) { const c = by[d]; if (!c) { miss.push(d); continue; } await eda.pcb_PrimitiveComponent.modify(c.getState_PrimitiveId(), {x: x, y: y, rotation: rot}); k++; } await eda.pcb_Document.save(); return {moved: k, miss: miss}; """ % json.dumps(arr), timeout=600) print(u'摆位下发 %d 件%s' % (r['moved'], (u';板上没有: %s' % r['miss']) if r['miss'] else u'')) return r BBOX_JS = u""" const MIL = %s; const out = {}; for (const c of await eda.pcb_PrimitiveComponent.getAll()) { const ps = await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(c.getState_PrimitiveId()); let x0=1e9, y0=1e9, x1=-1e9, y1=-1e9; for (const p of ps) { const pad = p.pad || []; let w = (typeof pad[1]==='number')?pad[1]:0, h = (typeof pad[2]==='number')?pad[2]:0; let poly = false; if (String(pad[0])==='POLYGON' && Array.isArray(pad[1])) { poly = true; const nums = pad[1].filter(v=>typeof v==='number'); let mnx=1e9,mny=1e9,mxx=-1e9,mxy=-1e9; for (let k=0;k+1 45 && prot < 135) { const t=w; w=h; h=t; } x0=Math.min(x0,p.x-w/2); x1=Math.max(x1,p.x+w/2); y0=Math.min(y0,p.y-h/2); y1=Math.max(y1,p.y+h/2); } out[c.designator] = {n: ps.length, w: (x1-x0)/MIL, h: (y1-y0)/MIL, rot: c.rotation, fp: (c.footprint && c.footprint.name) ? c.footprint.name : c.footprint}; } return out; """ % MIL def dump_bbox(): u"""抓封装外框 → `_base_bbox.json`。 ⛔ **必须按 rot 反转回封装自身的朝向**:板上读回来的是**板坐标**的外框, 件转了 90° 之后 42×9.2 会记成 9.2×42。而 `wh()` 又会按 rot 再转一次 —— 转两次等于没转,自检立刻报「插槽互相重叠 / 出板」。 (第一版就是这么翻车的:摆位下发之后重抓一次 bbox,五个插槽全变成 44×7.9。) """ d = call(BBOX_JS, timeout=600) for k, v in d.items(): if int(v.get('rot') or 0) % 180 == 90: v['w'], v['h'] = v['h'], v['w'] v['w'] = round(v['w'], 3) v['h'] = round(v['h'], 3) io.open(BBOX, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False, indent=0)) print(u'已写 _base_bbox.json:%d 件(已按 rot 反转回封装朝向)' % len(d)) return d DUMP_JS = u""" const MIL = %s; const out = []; for (const c of await eda.pcb_PrimitiveComponent.getAll()) { const ps = await eda.pcb_PrimitiveComponent.getAllPinsByPrimitiveId(c.getState_PrimitiveId()); for (const p of ps) { const pad = p.pad || []; let w = (typeof pad[1]==='number')?pad[1]:0, h = (typeof pad[2]==='number')?pad[2]:0; let cx = p.x, cy = p.y, poly = false; if (String(pad[0]) === 'POLYGON' && Array.isArray(pad[1])) { poly = true; const nums = pad[1].filter(v => typeof v === 'number'); const pts = []; for (let k=0; k+1q[1]))].sort((a,b)=>a-b); for (let b2=0; b2+1a-b); for (let k=0; k+1 45 && rot < 135) { const t=w; w=h; h=t; } out.push([c.designator, String(p.padNumber), Math.round(cx/MIL*1000)/1000, Math.round(cy/MIL*1000)/1000, Math.round(w/MIL*1000)/1000, Math.round(h/MIL*1000)/1000, p.layer, p.net||'', String(pad[0])]); } } return out; """ % MIL def dump_pads(): d = call(DUMP_JS, timeout=600) io.open(PADS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False)) print(u'已写 _base_pads.json:%d 个焊盘' % len(d)) return d def verify(pos, sz): u"""读回坐标逐件对账 + **三条朝向断言**(几何验证抓不到的那三条)。""" d = dump_pads() got = {} for ref, pn, x, y, _w, _h, _lay, net, _sh in d: got.setdefault(ref, {})[pn] = (x, y, net) bad = [] r = call("const o={}; for (const c of await eda.pcb_PrimitiveComponent.getAll())" " o[c.designator]=[c.x/%s, c.y/%s, c.rotation]; return o;" % (MIL, MIL)) for ref, (x, y, rot) in sorted(pos.items()): if ref not in r: bad.append(u'%s 板上没有' % ref) continue gx, gy, grot = r[ref] if abs(gx - x) > 0.02 or abs(gy - y) > 0.02 or (grot or 0) % 360 != rot % 360: bad.append(u'%s 读回 (%.2f,%.2f,%s) != (%.2f,%.2f,%s)' % (ref, gx, gy, grot, x, y, rot)) def pad(ref, pn): return got.get(ref, {}).get(pn) # 断言 1:U32 下行口在**北**边,且 USB1 在东、USB5 在西 p1, p11 = pad('U32', '2'), pad('U32', '12') # USB1_DP / USB5_DP if not p1 or not p11: bad.append(u'U32 的 USB1_DP/USB5_DP 焊盘没读到') else: if p1[1] <= pos['U32'][1] or p11[1] <= pos['U32'][1]: bad.append(u'U32 下行口没朝北(USB1_DP y=%.2f,芯片中心 y=%.2f)⇒ rot 语义相反,' u'把 270 换成 90' % (p1[1], pos['U32'][1])) if p1[0] <= p11[0]: bad.append(u'U32 北排顺序不对:USB1 x=%.2f 应该在 USB5 x=%.2f 的**东**边' % (p1[0], p11[0])) # 断言 2:插槽 A1/B1 在南、D± 在东列 for ref in SLOT_ORDER: p2, p46, p47 = pad(ref, '2'), pad(ref, '46'), pad(ref, '47') # A2=+12V / B14,B15=D± cx, cy = pos[ref][0], pos[ref][1] if not p2 or not p46: bad.append(u'%s 的 2/46 脚没读到' % ref) continue if p2[1] > cy: bad.append(u'%s 的 +12V 脚(A2)在北边 y=%.2f,应该在南 ⇒ 插槽 rot 反了' % (ref, p2[1])) if p46[0] < cx: bad.append(u'%s 的 D± 在西列 x=%.2f,应该在东列 ⇒ 插槽 rot 反了' % (ref, p46[0])) if abs(p46[1] - p47[1]) > 1.2 or abs(p46[0] - p47[0]) > 0.1: bad.append(u'%s 的 D± 两只脚不在同一列' % ref) # 断言 3:五对 D± 从 U32 到各槽**不交叉**(按 x 单调) xs = [] for i, ref in enumerate(SLOT_ORDER, start=1): pn = {'J38': '12', 'J37': '9', 'J36': '7', 'J35': '4', 'J34': '2'}[ref] # U32 侧 DP 脚 pu = pad('U32', pn) ps = pad(ref, '46') if pu and ps: xs.append((pu[0], ps[0], ref)) for a, b in zip(xs, xs[1:]): if not (a[0] < b[0] and a[1] < b[1]): bad.append(u'D± 扇出会交叉:%s(U32 x=%.2f→槽 x=%.2f) 与 %s(%.2f→%.2f)' % (a[2], a[0], a[1], b[2], b[0], b[1])) # 断言 4:`U20` 的天线朝**西**且整段落在板框缺口里 # ⛔ 这一条是 2026-09-09 补的:天线朝向 rot 一改就全变,而 # 摆位自检只看焊盘外框(19×19,天线那 7 mm 根本不在模型里)、 # DRC 只看铜、`board_check` 只看连通性 —— 没有一道能看出天线埋在板里。 # 判据用**板上读回的焊盘**:pin1 / pin40 是紧挨天线那一头的两只脚。 pa, pb = pad('U20', '1'), pad('U20', '40') if not pa or not pb: bad.append(u'U20 的 1/40 脚没读到,天线朝向无法判定') else: mx, my = (pa[0] + pb[0]) / 2.0, (pa[1] + pb[1]) / 2.0 cx, cy = pos['U20'][0], pos['U20'][1] if mx > cx - 5.0: bad.append(u'U20 天线没朝西:pin1/40 中点 x=%.2f,模组中心 x=%.2f ' u'⇒ rot 不是 90 或 rot 语义相反' % (mx, cx)) nk = B.notch_rect() tip = mx - ANT_LEN # tip = 天线尖;判据是「尖落在缺口的深度里 + 那一头的焊盘还在板上 + y 在缺口里」 if not (nk[0] - 0.01 <= tip <= nk[2] + 0.01 and mx > nk[2] and nk[1] <= my <= nk[3]): bad.append(u'U20 天线尖 x=%.2f (y=%.2f) 没落在板框缺口 x %.1f~%.1f / y %.1f~%.1f 里 ' u'⇒ 天线又埋回板子里了' % (tip, my, nk[0], nk[2], nk[1], nk[3])) if bad: print(u'[X] 读回校验 %d 条不过:' % len(bad)) for t in bad[:25]: print(u' ' + t) else: print(u'[OK] %d 件坐标/角度逐件对上;四条朝向断言全过' % len(pos) + u'(U32 下行口朝北且 USB1 在东、插槽 +12V 在南 / D± 在东列、' u'五对 D± 不交叉、U20 天线朝西且落在板框缺口里)') return bad def report(pos, sz): print(u'') print(u'带内件数:') cnt = {} for ref, (x, y, _r) in pos.items(): cnt.setdefault(B.zone_of(y) or '?', []).append(ref) for k in ('input', 'hub', 'slots', 'ctrl', 'fan'): v = cnt.get(k, []) z = B.ZONES[k] area = (z[2] - z[0]) * (z[3] - z[1]) used = sum(sz[r][0] * sz[r][1] for r in v) print(u' %-6s %3d 件 焊盘外框 %5.0f / %5.0f mm² (%.0f%%)' % (k, len(v), used, area, 100.0 * used / area)) print(u'') print(u'插槽(西 → 东,注意位号是**反**的):') for i, ref in enumerate(SLOT_ORDER, start=1): print(u' %s x=%.2f 东侧走廊中心 x=%.2f' % (ref, pos[ref][0], corridor_x(i))) print(u'') print(u'D± 估算长度(曼哈顿,U32 脚 → 走廊 → 槽脚):') for i, ref in enumerate(SLOT_ORDER, start=1): cx = corridor_x(i) ux, uy = pos['U32'][0], pos['U32'][1] + sz['U32'][1] / 2 sx = pos[ref][0] + B.PAD_COL_DX + B.PAD_L / 2 sy = B.SLOT_CY - 1.5 L = abs(cx - ux) + abs(B.ZONES['slots'][1] - uy) + abs(sy - B.ZONES['slots'][1]) + abs(sx - cx) print(u' %s ≈ %.0f mm' % (ref, L)) if __name__ == '__main__': if '--bbox' in sys.argv: dump_bbox() sys.exit(0) if '--dump' in sys.argv: dump_pads() sys.exit(0) pos, rects, unplaced, sz = plan() bad = selfcheck(pos, unplaced, sz) print(u'底板摆位:%d 件(手排 %d + 排布 %d)' % (len(pos), len(hand()), len(pos) - len(hand()))) if bad: print(u'[X] 自检 %d 条不过:' % len(bad)) for t in bad[:30]: print(u' ' + t) else: print(u'[OK] 自检全过(不出板 / 不重叠 / 投影带限高 / 螺钉头 / 槽带空 / 天线净空)') report(pos, sz) io.open(POS_JSON, 'w', encoding='utf-8').write( json.dumps({k: list(v) for k, v in pos.items()}, ensure_ascii=False, indent=0)) if '--push' in sys.argv: if bad: print(u'!! 自检不过,不下发') sys.exit(1) push(pos) verify(pos, sz) elif '--verify' in sys.argv: verify(pos, sz)