# -*- 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 /c/Python314/python .claude/r2/place_base.py --adopt-live --verify # 采用 EasyEDA 当前 138 件布局 算法照搬刀片 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') ROT_JSON = os.path.join(HERE, '_base_rotations.json') PRE_FLIP_POS = os.path.join(HERE, '_base_place_pre_slot_flip.json') LIVE_POS_JSON = os.path.join(HERE, '_base_place_live.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'} # 2026-09-10 用户在 EasyEDA 客户端定稿的输入链(截图读回坐标)。 # 它们是机械/热布局锚点,不再进行 0.25 mm 自动吸附。 USER_INPUT = { 'J1': (20.83, 10.79, 90), 'F1': (31.08, 3.63, 0), 'Q1': (37.08, 6.63, 0), 'D1': (44.08, 5.13, 0), 'C1': (35.77, 1.25, 0), 'R1': (38.27, 1.25, 0), 'DZ1': (43.31, 1.78, 180), 'C4': (36.96, 12.83, 0), 'C5': (46.08, 13.13, 0), 'C6': (33.27, 11.56, 0), 'C2': (68.12, 4.30, 270), 'C3': (68.12, 13.19, 90), 'Rs6': (75.99, 8.74, 90), 'U13': (76.71, 16.89, 180), 'RT602': (32.07, 9.16, 0), 'C11': (87.95, 37.72, 270), } # M12 路由预检确认这两颗 0402 横断 U32 -> U20 的 USB6 唯一顶层通道。 ROUTE_FIX = { 'J34': (46.50, 107.14, 0), 'J35': (46.50, 86.82, 0), 'J36': (46.50, 66.50, 0), 'J37': (46.50, 46.18, 0), 'J38': (46.50, 25.86, 0), 'C7': (55.75, 120.75, 90), 'C731': (49.25, 117.50, 270), 'C10': (69.00, 122.50, 0), 'R610': (76.50, 116.25, 0), 'R722': (76.50, 118.00, 180), 'C737': (85.50, 67.00, 270), 'R9C': (88.50, 13.4266, 0), 'R9D': (88.50, 17.9266, 180), 'R9E': (88.50, 22.4266, 0), 'Rs6': (63.75, 4.00, 0), 'U13': (63.75, 10.50, 180), 'U29': (67.00, 13.00, 180), 'DZ1': (31.00, 1.65, 0), # C1/R1 在 34.0/36.0 时 C1 本体离 DZ1 仅 0.22 mm(装配净距要 0.6);东移 0.5 后自检无新增冲突。 # R1 翻 180°:GATE 脚朝西、与 C1.2 相邻直连;GND 脚朝东,落在 GATE 缺口里的地铜上。 'C1': (34.50, 1.20, 0), 'R1': (36.50, 1.20, 180), } def route_fix_moves(): u"""ROUTE_FIX 相对 EasyEDA 实测布局还没落地的改位:{ref: (实测, 目标, 转角)}。""" live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8')) out = {} for ref, t in ROUTE_FIX.items(): if ref not in live: raise RuntimeError(u'实测布局快照缺少 %s,不能计算路由坐标' % ref) o = live[ref] dr = (int(round(t[2])) - int(round(o[2]))) % 360 if abs(t[0] - o[0]) > 1e-9 or abs(t[1] - o[1]) > 1e-9 or dr: out[ref] = (o, t, dr) return out def route_fixed_pads(pads): u"""把焊盘换算到 ROUTE_FIX 的计划位置,返回 (pads, 改位说明)。原地改。 布线(`route_base`)和铺铜(`pour_base`)**必须用同一份**:以前铺铜读实测焊盘、 布线读改位后的,DZ1 一挪,两边算出来的 VIN_FUSED/VIN_SW 分界差 2.4 mm。 180° 翻转:焊盘相对器件原点的偏移取反,长宽不变。90°/270° 要交换焊盘长宽, 离线模型不做 —— 先下发再重抓。板端下发并 `--adopt-live --dump` 之后改位量归零, 这里就是恒等变换。 """ moved = [] for ref, (o, t, dr) in sorted(route_fix_moves().items()): if dr not in (0, 180): raise RuntimeError(u'%s 的 ROUTE_FIX 转了 %d°;离线模型只支持平移与 180° 翻转' % (ref, dr)) sgn = -1.0 if dr == 180 else 1.0 for p in pads: if p[0] == ref: p[2] = round(t[0] + sgn * (p[2] - o[0]), 4) p[3] = round(t[1] + sgn * (p[3] - o[1]), 4) moved.append(u'%s(%+.2f,%+.2f%s)' % (ref, t[0] - o[0], t[1] - o[1], u',翻180°' if dr else u'')) return pads, moved # ── 手排件:位置有理由的都钉死,其余交给排布器 ──────────────────────────── def hand(): u"""{位号: (x, y, rot)}。每一条都写清楚为什么在这。M9 布局见 `pcb_base` 文件头。""" H = {} # ── 5 个插槽:长轴沿 x、**A1 端朝东**(rot 180),沿 y 从南到北 ──────── # ⛔ 位号顺序不是随便排的:`U32` rot=180(下行口朝东)之后,USB1 在**南**、 # USB5 在**北**(rot 270→180 是 −90°,原来朝东的 USB1 转到朝南)。 # 照着排 ⇒ 五对 D± 从 Hub 到各槽单调、互不交叉;排反了会全部交叉, # 而两层板上交叉 = 打过孔换层 = 在 D± 底下割地平面(方案 §6.2 明令禁止)。 # **这条几何验证抓不到**,靠下面 verify() 的读回断言钉死。 for i, ref in enumerate(SLOT_ORDER, start=1): H[ref] = (B.SLOT_CX, B.slot_y(i), B.SLOT_ROT) # ── Hub:西区正中,下行口朝东正对槽阵 ──────────────────────────────── # y 取阵列中心 ⇒ 五对 D± 往南北各散两条,任一 y 上最多 3 对并行(通道 8 mm 够)。 # x 取 20:本体 9.75 ⇒ 15.1~24.9,东边留 4 mm 给逃逸扇形接进 D± 通道(29~37)。 # y 取 66 不是阵列中心 62.5:D± 焊盘在各槽的 +3.6 那一排,取中心会让最北一路 # 比最南一路长 7 mm(实测 72 / 79)。挪到 66 之后两头都是 ~75,压在验收线上。 H['U32'] = (20.0, 67.0, 180) # 晶振贴着 XTAL1/2。rot 180 之后那两只脚在**南**边(x 21.8/22.2, y 62.5)。 # ⛔ 原来放 (20,53) 离脚 **14 mm** —— 负载电容贴上去了、晶振本体却拉在外面,等于白贴。 # x 取 24:让开南边 `USB6`/上行的逃逸扇形(x 18.25~21.25,往南走到 y=57.5)。 # 晶振挪到南沿**西端**:南沿净空只有 3.2 mm(U32 本体 62.1 到走廊2 的 58.9), # 晶振本体 2.9 一放,VDD18PLL/RBIAS 那两颗就没地方了 ⇒ 让出脚正下方给它们。 H['Y1'] = (17.5, 60.0, 0) # 本体 15.7~19.3 / 58.55~61.45,离 XTAL 脚 4.7 # 负载电容要贴**晶振**(不是芯片脚)—— 排布器只认芯片当锚,会把 C741 摆到 5.3 mm 外。 H['C740'] = (14.0, 60.0, 0) # 贴 Y1 西侧(Y1 本体 3.6 宽 ⇒ 边到边 0.6) H['C741'] = (21.0, 59.0, 0) # 贴 Y1 东南(让开 C743 那一列) # ⛔ 用户 2026-09-09 指出这两颗摆错:`C743`(VDD18PLL 去耦)在 5.6 mm 外, # `R708`(RBIAS 基准电阻)坐在 `U32` 东边那条 D± 逃逸窄缝里(x 24.4~29)。 # RBIAS 是 Hub 的电流基准,去耦是内部 LDO 的 —— 两颗都要贴着自己那只脚, # 而且都不能占那条缝(十根 D± 从那儿往东扇出)。⇒ 一起放到南沿脚正下方。 H['C743'] = (22.75, 61.0, 0) # VDD18PLL 脚在 (22.75, 62.55) ⇒ 1.55 mm H['R708'] = (25.3, 61.0, 0) # RBIAS 脚在 (23.25, 62.55) ⇒ 2.7 mm,且让开逃逸缝 # ── ESP32:西区北部,天线朝西伸进板框缺口(ANT_NOTCH y 86~108)──────── # 焊盘框 19×19 ⇒ x 7.5~26.5,天线段 x 0~7.5 悬在缺口里;东边到 D± 通道还有 2.5 mm。 H['U20'] = (17.0, 97.0, 90) # ── 上行 USB-C:南带**西头**板沿(M8 在东头,那是因为 Hub 在板中;现在 Hub 在西) H['J23'] = (20.0, 5.25, 0) # ── 入口链:南带,西 → 东(电流方向 +x),末端接东缘高速路 ────────────── # 整条手排的理由同 M8:全板电流最大(9 A),形状决定断面,不能交给通用排布器。 H['J1'] = (44.0, 10.00, 90) # DC-005,rot 90 = 桶口朝南;本体 y −0.6~13.9 # ⚠ 上沿 17.25 卡着槽 1 的槽体(17.26),别再往北挪 # ⚠ 南带只有 17.26 高,桶身 14.5 ⇒ y 只能在 7.3~8.5 之间 H['F1'] = (51.5, 14.0, 0) # 1206 保险,卧着放,电流顺着 +x 直穿 H['Q1'] = (57.5, 11.0, 0) # SOP-8 P-MOS 防反接 H['D1'] = (64.5, 12.5, 0) # SMBJ13A TVS H['C2'] = (73.0, 11.0, 0) # Φ8 直立电解 470 µF(南带 17.26 高,放得下) H['C3'] = (82.5, 11.0, 0) H['C4'] = (59.5, 4.5, 0) # 0805 VIN_SW 旁路 H['C5'] = (66.5, 4.5, 0) H['C6'] = (51.5, 5.0, 0) # 100nF 高频旁路,贴 F1 南边 H['Rs6'] = (91.0, 11.0, 0) # 2512 5 mΩ 检流,接东缘高速路的南头 H['U13'] = (91.0, 5.5, 0) # INA226 贴着 Rs6 走 Kelvin # ── 风墙:两只风扇座 + 应急串口,全在北带(投影外,可高)─────────────── H['J26'] = (13.0, 118.5, 0) # 别压 H3 螺钉头(4,121) H['J27'] = (26.0, 118.5, 0) H['J33'] = (39.5, 118.5, 0) # ── 两个 Buck 的环路**必须手排** ──────────────────────────────────── # ⛔ 交给排布器的实测下场:`U19`→`L2`(SW 节点)隔 **18.4 mm**, # 自举电容 `C8` 落在 **69 mm** 外;`U30`→`L3` 隔 31 mm、`C733` 29 mm。 # SW 和 BST 是开关电源里最吵的两个网,拉这么长直接变 EMI 源, # 而 DRC / 几何自检 / 布线器**一个都不会响**(它们只看得见「连上了没有」)。 # 摆法:IC → 电感紧邻(SW 最短)→ 自举电容贴 IC → FB 分压贴 FB 脚。 H['U19'] = (51.5, 116.5, 0) # 3V3 Buck(西侧留给 FB,东侧紧贴电感) H['C8'] = (51.5, 112.5, 0) # BST/SW 跨接,放 U19 南侧避开北边出脚 H['L2'] = (57.0, 116.5, 0) # SW → +3V3,紧邻 H['R2'] = (47.25, 118.25, 0) # FB 分压贴 U19 西侧 pin4 H['R3'] = (47.25, 115.75, 0) H['U30'] = (64.5, 116.5, 0) # 5V Buck H['C733'] = (64.5, 120.5, 0) # BST,贴着 U30 H['L3'] = (71.5, 116.5, 0) # SW → +5V,紧邻(本体 7.6×6.5,别贴穿 U30) H['R706'] = (61.0, 118.25, 0) # FB 分压贴 U30 西侧 pin4 H['R707'] = (61.0, 115.75, 0) # ── 泄放块:五颗 2512 串联链排成一列,U29 / 分压贴着 Q3 ──────────── # 它们只在回灌瞬态导通,占空比极低;排成列是为了让 `$1N114…$1N129` 那串 # 短接线不要横跨半块板(实测 R9A~R9E 摊在 30 mm 上)。 # ⛔ 泄放整块放在**槽体那一段 y**(38.6~47.8),不能放走廊:走廊 x17~29 是喉口, # 西区所有去槽的信号都从那儿过(用户 2026-09-09 指出「电阻挡住通道」)。 H['Q3'] = (25.0, 43.0, 0) # D-PAK 4.38×7.57 H['R9A'] = (4.0, 14.0, 90) # 五颗 2512 转 90° 排成一行(转后 3.46×7.42,间距 4.5) H['R9B'] = (8.5, 14.0, 90) H['R9C'] = (13.0, 14.0, 90) H['R9D'] = (17.5, 14.0, 90) H['R9E'] = (22.0, 14.0, 90) H['U29'] = (20.0, 43.5, 0) H['R4'] = (18.0, 40.5, 0) H['R5'] = (18.0, 46.0, 0) H['R6'] = (5.0, 40.5, 0) # U35 本体 7.55×7.7,让开 # ── mux / DAC:西区中南部,就近喂槽阵的 SCL/SDA 与 VDAC ──────────────── H['U35'] = (12.0, 45.0, 0) # I²C mux H['U6'] = (12.0, 35.0, 0) # DAC 1/2/4 H['U7'] = (12.0, 25.0, 0) # DAC 3/5 # DAC 参考/去耦不许由排布器挤到芯片另一侧。 H['C608'] = (16.25, 37.75, 0) # U6.13 H['C611'] = (16.25, 27.75, 0) # U7.13 H['C612'] = (9.25, 28.0, 0) # U7.16 # ESP32 ADC/EN 的 RC 贴模组南北排;三颗都低于刀片投影限高。 H['C603'] = (10.75, 85.5, 0) # EN,贴 U20.3 H['C736'] = (9.5, 108.0, 0) # NTC1,贴 U20.39 H['C739'] = (11.5, 108.0, 0) # NTC2,贴 U20.38 # 两只风扇座共用的储能电容,放在两座中间的出脚空档。 H['C738'] = (18.25, 122.5, 0) # ⚠ `U31`(AMS1117)交给排布器:它跟着 U32 走,母线一改就得跟着改。 # 插槽翻转后功能分区整体水平镜像:12V 去西侧,控制/USB 去东侧。 for ref, (x, y, rot) in list(H.items()): if ref in SLOT_ORDER: continue H[ref] = (round(B.BW - x, 3), y, (180 - rot) % 360) H['J23'] = (B.BW - 20.0, 5.25, 0) # 接口仍朝南板边 H['U20'] = (B.BW - 17.0, 97.0, 270) # 天线朝东,伸入新的东边缺口 # 水平镜像后多引脚器件的脚侧会变,关键 RC/去耦按读回脚位重新钉位。 H['C603'] = (99.5, 108.0, 0) # ESP32 EN,贴 U20.3 H['C736'] = (100.5, 85.5, 0) # NTC1,贴 U20.39 H['C739'] = (98.5, 85.5, 0) # NTC2,贴 U20.38 H['C742'] = (88.5, 61.0, 0) # U32 VDD18,贴南排 pin25,让开晶振 H['C743'] = (87.25, 73.5, 0) # U32 VDD18PLL,贴北排 pin62 H['C608'] = (95.0, 31.0, 180) # U6.13 参考/去耦,信号端朝芯片 H['C609'] = (101.0, 32.0, 0) # U6.16 H['R615'] = (101.0, 38.5, 0) # U6.5 H['C611'] = (95.0, 20.5, 180) # U7.13,信号端朝芯片 H['C612'] = (101.0, 22.0, 0) # U7.16 H['R617'] = (101.0, 28.5, 0) # U7.5 # 5V Buck 输出电容回到 U30/L3 功率环;Hub LDO 的输入/输出电容贴 U31。 H['C734'] = (31.0, 114.0, 0) H['C735'] = (31.0, 119.0, 0) H['C737'] = (82.0, 17.5, 0) H['C752'] = (85.5, 27.5, 0) H['C753'] = (89.0, 27.5, 0) H['C10'] = (69.0, 110.5, 0) # 3V3 Buck 输出储能,位于北侧功率带 H['C7'] = (63.0, 110.25, 0) # U19 12V 输入去耦 H['C731'] = (54.0, 112.0, 0) H['C732'] = (45.5, 112.0, 0) # U30 12V 输入去耦 H['C9'] = (50.0, 122.5, 0) # U19/L2 3V3 输出储能 H['DZ1'] = (47.0, 17.0, 0) # Q1 GATE/VIN_FUSED RC 紧贴芯片北西侧 H['C1'] = (50.5, 17.0, 0) H['R1'] = (53.0, 17.0, 0) H['R614'] = (103.0, 33.5, 0) # U6.16 的参考 RC H['C601'] = (96.5, 111.0, 0) # ESP32 22uF H['C602'] = (98.5, 108.0, 0) # ESP32 100nF H['R601'] = (96.5, 108.0, 0) # EN 上拉 H['C603'] = (100.5, 108.0, 0) # EN 复位 RC,贴 U20.3 H['R609'] = (87.5, 108.0, 0) # PRSNT4# H['R608'] = (89.75, 108.0, 0) # PRSNT3# H['R607'] = (92.0, 108.0, 0) # PRSNT2# H['R606'] = (94.25, 108.0, 0) # PRSNT1# H['R610'] = (82.0, 95.0, 0) # PRSNT5#,贴 U20 西侧 pin22 H['R604'] = (90.0, 85.5, 0) # INT# 上拉 H['R605'] = (92.5, 85.5, 0) # INA_ALERT 上拉 H['R611'] = (95.0, 85.5, 0) # MUX_RST# 上拉 H['R710'] = (102.0, 65.5, 0) # U32 strap 电阻列 H['R711'] = (102.0, 67.0, 0) H['R712'] = (102.0, 68.5, 0) H['R720'] = (102.0, 70.0, 0) H['R716'] = (102.0, 72.0, 0) # HUB_RST 上拉 H['C604'] = (104.0, 42.0, 0) # U35 VCC 去耦 H['R602'] = (104.0, 39.0, 0) # SDA 上拉 H['R603'] = (104.0, 45.0, 0) # SCL 上拉 H['C607'] = (98.0, 30.0, 0) # U6 电源去耦 H['C610'] = (98.0, 19.0, 0) # U7 电源去耦 H['R616'] = (103.0, 24.5, 0) # U7.16 的参考 RC # USB2517I 的八颗 3V3_HUB 去耦沿 U32 右侧就近排列。 H['C744'] = (97.0, 63.0, 0) H['C745'] = (97.0, 64.5, 0) H['C746'] = (97.0, 66.0, 0) H['C747'] = (99.0, 66.0, 0) H['C748'] = (99.0, 67.5, 0) H['C749'] = (99.0, 69.0, 0) H['C750'] = (99.0, 70.5, 0) H['C751'] = (98.0, 74.0, 0) # 北边只放两只风扇座。J33 是 ESP32 应急串口,用户在客户端定位到 # 右板边侧向接插;这里按实测坐标固化,避免后续 --push 把它拉回北边。 H['J27'] = (70.0, 118.5, 180) H['J26'] = (83.0, 118.5, 180) H['J33'] = (107.06, 76.83, 270) H['D2'] = (76.5, 113.5, 180) H['R755'] = (64.5, 113.5, 180) H['R757'] = (68.0, 113.5, 180) H['R753'] = (71.75, 113.5, 180) H['R748'] = (81.25, 113.5, 180) # 两路板温传感器放到最高热密度点: # RT601 监测 Hub LDO U31(最坏约 0.71 W);RT602 监测超额定的 DC-005/J1 接触热。 H['RT601'] = (92.0, 24.5, 270) # pin1/NTC1 朝北,贴 U31 右侧 H['RT602'] = (72.5, 10.0, 180) # pin1/NTC2 朝东,贴 J1 右侧 H['U13'] = (19.0, 5.5, 0) # INA226 采样脚 8/9/10 朝上正对 Rs6 # 北侧风扇小信号组:Q6 靠风扇 PWM 座,Q10 靠 U20 FAN_TACH_OUT。 H['Q6'] = (72.5, 113.5, 180) H['R733'] = (68.0, 113.5, 180) H['R734'] = (76.0, 113.5, 180) H['D2'] = (84.0, 113.5, 180) H['R753'] = (78.5, 113.5, 180) H['R748'] = (88.5, 113.5, 180) H['Q10'] = (98.0, 114.5, 90) H['R754'] = (91.5, 113.5, 180) H['R755'] = (94.0, 113.5, 180) H['R756'] = (96.5, 118.0, 180) H['R757'] = (99.0, 118.0, 180) H['R747'] = (101.5, 118.0, 180) # Q7 gate 网紧贴 Q7.1 H['R748'] = (88.75, 113.5, 180) H['R660'] = (101.0, 80.25, 180) # NTC1 上臂回到 U20 ADC 滤波组 H['R661'] = (103.25, 80.25, 180) # NTC2 上臂 H.update(USER_INPUT) return H # 西 → 东。见文件头第 2 条。 # 南 → 北。`U32` rot=180 之后 USB1 在南、USB5 在北 ⇒ 位号顺序跟着来, # 五对 D± 才不会互相交叉(见 hand() 里那段)。 SLOT_ORDER = ['J38', 'J37', 'J36', 'J35', 'J34'] def corridor_y(k): u"""走廊 k(0 = 阵列最南那条)的中心 y。""" c = B.corridor(k) return round((c[1] + c[3]) / 2.0, 3) # ── 分区:谁进哪个区(M9 新布局)────────────────────────────────────────── # 纵向三条:西区(器件)/ 中区(槽体)/ 东区(12V);横向两头:南带 / 北带(可放高件)。 # ⇒ 器件只有三个去处:**西区**、**南带**、**北带**。东区留给 12V 铜,不放件。 PAGE_REGION = { 'A1-INPUT': 'input', 'A4-HUB': 'west', 'A5-SLOTS': 'slots', 'A5B-SLOTS': 'west', 'A3-CTRL': 'west', 'A2-AUX': 'west', 'A6-FAN': 'fan', } REGION_OVERRIDE = { 'J23': 'input', 'J33': 'fan', } for _r in ('U29', 'Q3', 'R4', 'R5', 'R6', 'C11', 'R9A', 'R9B', 'R9C', 'R9D', 'R9E'): REGION_OVERRIDE[_r] = 'west' for _r in ('DZ1', 'C1', 'R1'): REGION_OVERRIDE[_r] = 'west' # 两颗 Buck 和储能:12V 从**东缘高速路的北头**过来 ⇒ 放北带东侧 for _r in ('U19', 'U30', 'L2', 'L3', 'C8', 'C733', 'R706', 'R707', 'R2', 'R3'): REGION_OVERRIDE[_r] = 'fan' OVERFLOW = {'fan': 'west', 'west': 'fan', 'input': 'west'} def regions(): u"""排布器可用矩形。**东区不给**:那儿从 x=72 起全是 12V 铜墙和高速路。""" k = EDGE_KEEP return { 'input': (k, k, B.BW - k, B.PROJ[0] - 0.2), 'fan': (k, B.PROJ[1] + 0.2, B.BW - k, B.BH - k), 'west': (B.EAST[0], B.PROJ[0] + 0.2, B.BW - k, B.PROJ[1] - 0.2), } # ── 数据 ───────────────────────────────────────────────────────────────── 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± 直接布不出来。 # ⚠ 缺口不只是「不许压上去」:那儿没板材,件还要离**新的板边** EDGE_KEEP n = B.notch_rect() out.append((n[0] - EDGE_KEEP, n[1] - EDGE_KEEP, n[2] + EDGE_KEEP, n[3] + EDGE_KEEP)) 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) # ── 排布 ───────────────────────────────────────────────────────────────── # ── 外围件的「紧迫度」与目标脚(2026-09-09 用户提出:外围器件摆放不合理)────── # ⛔ 原来的顺序是**按面积从大到小**,于是直立电解 / 电感 / 座子先把 IC 周围的空位抢光, # 0402 的去耦最后只能落在 20 mm 外。实测 `U32` 那一窝:30 件平均离芯片 **21.2 mm**, # 22 件 > 15 mm —— 晶振负载电容 `C740/C741` 拉到 **17~18 mm**(起振风险), # `+3V3_HUB` 八颗去耦 20~28 mm(等于没有去耦)。 # 这类错 **DRC 不报、几何自检不报、布线器只会报「出不来」**(症状不是病因)。 # ⇒ 改成按「离得越近越要紧」分级摆,并且目标点取**它自己那只脚**而不是芯片质心。 CLS_XTAL, CLS_DECAP, CLS_NEAR, CLS_REST = 0, 1, 2, 3 CLS_MAX = {CLS_XTAL: 5.0, CLS_DECAP: 4.0, CLS_NEAR: 20.0, CLS_REST: 1e9} PLANE_NETS = ('GND',) BIG_NETS = ('+3V3', '+12V', '+5V', '+3V3_HUB') # 遍布全板,不能当「对端脚」看 def _pad_index(): u"""{位号: [(网, x, y), …]},从 `_base_pads.json` 读。手排的 IC 不动, 所以它们的脚坐标在重排之间是稳定的 —— 这正是我们要瞄的目标。""" out = {} try: d = json.load(io.open(PADS, encoding='utf-8')) except Exception: return out for p in d: if p[7]: out.setdefault(p[0], []).append((p[7], p[2], p[3])) return out def sat_class(ref, px): u"""这件有多「怕远」。""" nets = set(n for n, _x, _y in px.get(ref, [])) if any(n.startswith('XTAL') for n in nets): return CLS_XTAL if len(px.get(ref, [])) <= 2 and 'GND' in nets and len(nets) == 2: return CLS_DECAP return CLS_NEAR if nets else CLS_REST def sat_target(ref, px, pos, sz): u"""目标点 = **同网对端芯片上那只脚**(不是芯片质心)。 去耦要贴的是电源脚,不是芯片中心;QFN 四条边都有脚时这两者能差 5 mm 以上。 """ nets = [n for n, _x, _y in px.get(ref, []) if n not in PLANE_NETS] small = [n for n in nets if n not in BIG_NETS] or nets best = None for other, lst in px.items(): if other == ref or other not in pos: continue if sz.get(other, (0, 0))[0] * sz.get(other, (0, 0))[1] <= 12.0: continue # 只认芯片当锚 for n, x, y in lst: if n in small: pri = 0 if n not in BIG_NETS else 1 if best is None or pri < best[0]: best = (pri, x, y) return (best[1], best[2]) if best else None 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()) # 非手排件使用旋转前已通过碰撞检查的布局做水平镜像, # 保留原理图页的功能聚类,不让通用排布器把模块重新打散。 if B.SLOT_ROT == 0 and os.path.exists(PRE_FLIP_POS): old = json.load(io.open(PRE_FLIP_POS, encoding='utf-8')) for ref, v in old.items(): if ref in sz and ref not in pos: pos[ref] = (round(B.BW - float(v[0]), 3), float(v[1]), (180 - int(round(v[2]))) % 360) live_layout = os.path.exists(LIVE_POS_JSON) if live_layout: live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8')) for ref, v in live.items(): if ref in sz: pos[ref] = (float(v[0]), float(v[1]), int(round(v[2])) % 360) pos.update(ROUTE_FIX) 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() px = _pad_index() unplaced = [] order = ['input', 'west', 'fan'] # M9 只有三个可放件的区 for reg in order: # ⛔ 顺序有两条,缺一不可: # ① **锚(芯片/电感/座子这些大件)必须排在它的卫星件前面**。 # 反了的话,摆卫星件时锚还不在板上,目标点退回「区中心」—— # 实测 `C8`(Buck 输出电容)因此飞到离 `L2` **57.8 mm** 的地方, # 一个开关电源的环路就这么散了,而没有任何一道检查会响。 # ② 锚之后**按「怕远」的级别**排,再按面积。只按面积的下场: # 大件先把 IC 周围抢光,0402 去耦落在 20 mm 外(`U32` 那一窝实测 21.2 mm)。 def _ord(r): big = sz[r][0] * sz[r][1] > 12.0 return (0 if big else 1, 0 if big else sat_class(r, px), -sz[r][0] * sz[r][1], r) refs = sorted(todo.get(reg, []), key=_ord) 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 = (sat_target(ref, px, pos, sz) or 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)) if not live_layout: snap(pos, sz, rects) nf = 0 if live_layout or B.SLOT_ROT == 0 else orient_sats(pos, px, sz) if nf: print(u'两脚件朝向:翻转 %d 件(信号脚朝锚、GND 脚朝外)' % nf) 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) | {'J33'} | set(USER_INPUT) 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)) edge_keep = 0.25 if a in USER_INPUT else EDGE_KEEP if a not in EDGE_PARTS and 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), x): 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) # ⛔ 谁都不许压在槽体上,也不许进 D± 竖向通道 if a not in SLOT_ORDER: # ⚠ 循环变量不能叫 i —— 外面那层 `enumerate(items)` 也在用 i, # 覆盖掉之后 `items[i+1:]` 就错位了(现象是「C736 与 C736 挨太近」)。 for si in range(1, B.SLOT_N + 1): b0, b1, b2, b3 = B.slot_body(si) if x + w / 2 > b0 and b2 > x - w / 2 and y + h / 2 > b1 and b3 > y - h / 2: bad.append(u'%s 压在槽 %d 的槽体上' % (a, si)) d = B.DP_CHAN if a != 'U13' and x + w / 2 > d[0] and d[2] > x - w / 2 and \ y + h / 2 > d[1] and d[3] > y - h / 2: bad.append(u'%s 进了 D± 竖向通道 x %.0f~%.0f' % (a, d[0], d[2])) d = B.USB6_CHAN if a not in ('U20', 'U32') and x + w / 2 > d[0] and d[2] > x - w / 2 and \ y + h / 2 > d[1] and d[3] > y - h / 2: bad.append(u'%s 进了 U32→U20 的 USB6 专用通道' % a) for b in items[i + 1:]: x2, y2, r2 = pos[b] ra = body_rect(a, x, y, r, sz) rb = body_rect(b, x2, y2, r2, sz) 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.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 def rotate_slots_only(): u"""只旋转 J34~J38,不触碰其他 133 件。用于机械朝向已确定后的定点修改。""" refs = SLOT_ORDER js = u""" const refs=%s, out={}, by={}; for (const c of await eda.pcb_PrimitiveComponent.getAll()) by[c.designator]=c; for (const ref of refs) { const c=by[ref]; if (!c) { out[ref]='missing'; continue; } // modify() 会把数值 0 当成“未传”;传 360 由客户端归一到 0°。 await eda.pcb_PrimitiveComponent.modify(c.getState_PrimitiveId(), {rotation:%d}); } await eda.pcb_Document.save(); for (const ref of refs) { const c=by[ref]; out[ref]=c ? ((c.rotation||0)%%360) : 'missing'; } return out; """ % (json.dumps(refs), 360 if B.SLOT_ROT == 0 else B.SLOT_ROT) got = call(js, timeout=300) bad = [r for r in refs if got.get(r) != B.SLOT_ROT] print(u'插槽旋转读回: %s' % ' '.join('%s=%s°' % (r, got.get(r)) for r in refs)) if bad: raise SystemExit(u'插槽旋转未生效: ' + ' '.join(bad)) dump_pads() counts = call("return {lines:(await eda.pcb_PrimitiveLine.getAll()||[]).filter(x=>x.layer===1||x.layer===2).length," "vias:(await eda.pcb_PrimitiveVia.getAll()||[]).length," "pours:(await eda.pcb_PrimitivePour.getAll()||[]).length};") print(u'底板铜图元: 导线 %(lines)d / 过孔 %(vias)d / 铜面 %(pours)d' % counts) 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)) rots = call("const o={}; for (const c of await eda.pcb_PrimitiveComponent.getAll())" " o[c.designator]=(c.rotation||0)%360; return o;") io.open(ROT_JSON, 'w', encoding='utf-8').write( json.dumps(rots, ensure_ascii=False, sort_keys=True)) print(u'已写 _base_pads.json:%d 个焊盘' % len(d)) return d def dump_live_positions(): u"""将 EasyEDA 当前底板布局固化为后续计算的最高优先级输入。""" d = call("const o={}; for(const c of await eda.pcb_PrimitiveComponent.getAll())" " o[c.designator]=[c.x/%s,c.y/%s,(c.rotation||0)%%360,c.layer]; return o;" % (MIL, MIL)) if len(d) != 138: raise SystemExit(u'当前板上读到 %d 件,应为 138;拒绝覆盖 LIVE 快照' % len(d)) out = {ref: [round(float(v[0]), 4), round(float(v[1]), 4), int(round(v[2])) % 360, v[3]] for ref, v in sorted(d.items())} io.open(LIVE_POS_JSON, 'w', encoding='utf-8').write( json.dumps(out, ensure_ascii=False, indent=2)) print(u'已采用 EasyEDA 当前布局:%d 件 → %s' % (len(out), LIVE_POS_JSON)) return out 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 在东侧,下行口朝西对着槽阵。 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[0] >= pos['U32'][0] or p11[0] >= pos['U32'][0]: bad.append(u'U32 下行口没朝西(USB1_DP x=%.2f,芯片中心 x=%.2f)⇒ rot 反了' % (p1[0], pos['U32'][0])) if p1[1] <= p11[1]: bad.append(u'U32 西排顺序不对:USB1 y=%.2f 应该在 USB5 y=%.2f 的**北**边' % (p1[1], p11[1])) # 断言 2:插槽经用户要求再转 180°,+12V 脚在西,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[0] > cx: bad.append(u'%s 的 +12V 脚(A2)在东边 x=%.2f,用户要求旋转后应在西 ⇒ 插槽应为 rot 0' % (ref, p2[0])) if abs(p46[0] - B.pin_x(-1.5)) > 1.0: bad.append(u'%s 的 D± 不在长轴中部:x=%.2f,应 ≈%.1f' % (ref, p46[0], B.pin_x(-1.5))) if abs(p46[0] - p47[0]) > 1.2 or abs(p46[1] - p47[1]) > 0.1: bad.append(u'%s 的 D± 两只脚不在同一排' % ref) # 断言 3:五对 D± 从 U32 到各槽**不交叉**(按 y 单调) ys = [] for i, ref in enumerate(SLOT_ORDER, start=1): pn = {'J34': '2', 'J35': '4', 'J36': '7', 'J37': '9', 'J38': '12'}[ref] # U32 侧 DP 脚 pu = pad('U32', pn) ps = pad(ref, '46') if pu and ps: ys.append((pu[1], ps[1], ref)) for a, b in zip(ys, ys[1:]): if not (a[0] < b[0] and a[1] < b[1]): bad.append(u'D± 扇出会交叉:%s(U32 y=%.2f→槽 y=%.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 不是 270 或 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[0] 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 orient_sats(pos, px, sz): u"""两脚件的**朝向**:让信号脚朝着它的锚,GND 脚朝外。 ⛔ 2026-09-09 用户指出的 `R710/R711/R719/R720`(`U32` 西沿那四颗下拉)就是反的: 信号脚在西、GND 脚在东 —— 信号要**绕过整个电阻体**才够得着芯片, 而 GND 脚正横在中间。0402 只有 1 mm,可板上一共几十颗这样的件, 每颗都逼布线器多绕一次、多占一格。 **DRC 不报、几何自检不报、体检也不报**(距离没变),只有人眼看得出来。 ⇒ 判据能算:两只脚里哪一只离锚近,就把它转到朝锚那一侧(只在 0/180 之间选, 竖着摆的件在 90/270 之间选),不动位置。 """ n = 0 try: current_rot = json.load(io.open(ROT_JSON, encoding='utf-8')) except Exception: current_rot = {} for ref, (x, y, rot) in list(pos.items()): ps = px.get(ref) or [] if len(ps) != 2: continue gnd = [q for q in ps if q[0] == 'GND'] sig = [q for q in ps if q[0] != 'GND'] if len(gnd) != 1 or len(sig) != 1: continue t = sat_target(ref, px, pos, sz) if t is None: continue # 焊盘是板上**当前**姿态,而 pos 是本轮的计划坐标。偏移必须先相对 # 板上两盘中心求,不能相对新坐标求;目标角度也要从板上当前角度推导。 cx = (sig[0][1] + gnd[0][1]) / 2.0 cy = (sig[0][2] + gnd[0][2]) / 2.0 ds = (sig[0][1] - cx, sig[0][2] - cy) d_now = math.hypot(x + ds[0] - t[0], y + ds[1] - t[1]) d_flip = math.hypot(x - ds[0] - t[0], y - ds[1] - t[1]) base = int(round(current_rot.get(ref, rot))) % 360 if d_flip < d_now - 0.05: pos[ref] = (x, y, (base + 180) % 360) n += 1 else: pos[ref] = (x, y, base) return n def report_sat(pos, sz): u"""外围件体检:每颗芯片的卫星件离**它自己那只脚**有多远。 ⛔ 这一条是 2026-09-09 用户看出来的:`U32` 那一窝 30 件平均 **21.2 mm**、 22 件 > 15 mm,晶振负载电容拉到 17~18 mm(起振风险)。 **DRC 不报、几何自检不报、布线器只报「出不来」**(症状不是病因), 所以判据必须固化在这里,每次摆位都打出来。 """ px = _pad_index() hd = set(hand()) chips = [r for r in pos if sz.get(r, (0, 0))[0] * sz.get(r, (0, 0))[1] > 12.0] print(u'') print(u'外围件体检(卫星件 → 它自己那只脚的距离):') nm = {CLS_XTAL: u'晶振类', CLS_DECAP: u'去耦/两脚', CLS_NEAR: u'一般外围'} rows = [] for ref, (x, y, _r) in pos.items(): if ref in hd or ref not in px or len(px[ref]) > 4: continue c = sat_class(ref, px) if c == CLS_REST: continue t = sat_target(ref, px, pos, sz) if t is None: continue # ⚠ 只在**同一个带**里比才有意义:风扇带的件离 `U20` 90 mm 是对的 # (它是风扇块的件),拿来当「摆得不合理」是误报。 own = _owner(ref, px, pos, sz) if own is None or B.zone_of(pos[own][1]) != B.zone_of(y): continue rows.append((c, math.hypot(x - t[0], y - t[1]), ref)) for c in (CLS_XTAL, CLS_DECAP, CLS_NEAR): v = sorted(t for t in rows if t[0] == c) if not v: continue over = [t for t in v if t[1] > CLS_MAX[c]] print(u' %-9s %2d 件 平均 %5.1f 最远 %5.1f(%s) 超 %.0f mm 的 %d 件%s' % (nm[c], len(v), sum(t[1] for t in v) / len(v), v[-1][1], v[-1][2], CLS_MAX[c], len(over), u': ' + u' '.join('%s(%.0f)' % (t[2], t[1]) for t in over[:8]) if over else u'')) print(u' 按芯片看(卫星件的平均距离):') for ch in sorted(chips): v = [d for c, d, r in rows if sat_target(r, px, pos, sz) and _owner(r, px, pos, sz) == ch] if v: print(u' %-6s %2d 件 平均 %.1f mm 最远 %.1f' % (ch, len(v), sum(v) / len(v), max(v))) def _owner(ref, px, pos, sz): u"""这件的「主人」芯片(同网、非平面网、面积够大的那颗,最近的一只脚)。""" nets = [n for n, _x, _y in px.get(ref, []) if n not in PLANE_NETS] small = [n for n in nets if n not in BIG_NETS] or nets best = None for other, lst in px.items(): if other == ref or other not in pos: continue if sz.get(other, (0, 0))[0] * sz.get(other, (0, 0))[1] <= 12.0: continue for n, x, y in lst: if n in small: d = math.hypot(pos[ref][0] - x, pos[ref][1] - y) if best is None or d < best[0]: best = (d, other) return best[1] if best else None 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', 'array', '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' 槽%d %s y=%.2f 南侧走廊中心 y=%.2f / 北侧 %.2f' % (i, ref, pos[ref][1], corridor_y(i - 1), corridor_y(i))) print(u'') print(u'D± 估算长度(曼哈顿,U32 西排 → 走廊 → 槽针 x=%.1f):' % B.pin_x(-1.5)) ux, uy = pos['U32'][0] - sz['U32'][0] / 2, pos['U32'][1] for i in range(1, B.SLOT_N + 1): ref = B.USB_SLOT_REF[i] sy = pos[ref][1] - B.PAD_ROW_DY L = abs(B.pin_x(-1.5) - ux) + abs(sy - uy) print(u' USB%d %s ≈ %.0f mm' % (i, ref, L)) if __name__ == '__main__': if '--slots-only' in sys.argv: rotate_slots_only() sys.exit(0) if '--bbox' in sys.argv: dump_bbox() sys.exit(0) if '--dump' in sys.argv: dump_pads() sys.exit(0) if '--adopt-live' in sys.argv: dump_live_positions() 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) report_sat(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)