# -*- coding: utf-8 -*- u"""R2 刀片 PCB 摆位(M4c-2;2026-09-08 改成**双面**:器件在手机的另一面)。 /c/Python314/python .claude/r2/pcb_place.py # 只算 + 自检(不下发) /c/Python314/python .claude/r2/pcb_place.py --push # 下发摆位 /c/Python314/python .claude/r2/pcb_place.py --verify # 读回校验 ## ⛔ 分面:**手机在 A 面,器件全部在 B 面**(2026-09-09 用户拍板) 小米8 主板压在 **A 面**(顶层)。A 面**只剩 `K1~K4` 四个裸铜盘**(主板弹片直接压, 物理上只能在这一面)和两面都是指的 `J1`;**其余 78 件全部在 B 面**。 `J600` 的电池排线和 `J1401` 的尾插排线从接口边翻下来,落到 A 面服务带后**穿板** (`pcb_blade.flex_slots()`,接口边外侧 y 24.8~27.8 的两条铣穿槽),U 形折回 B 面, 贴着 B 面往 +y 走到**手机投影下方**的 `J5` / `J4` —— 和 R1 的 J8n / J7n 一模一样。 排线在 B 面占的车道 `LANES` 里**一件不放**。 **高度预算分两段**(槽距 20.32,板厚 1.6,螺柱间隙 1.0): | B 面区域 | 对面是什么 | 净空 | 落地 | |---|---|---|---| | 服务带 y 12~28 | 隔壁刀片的 A 面服务带,**现在是空的** | 20.32 − 1.6 = **18.7** | `C60`(10.5) / `J2` / `J3`(5) 只能放这里 | | 手机投影 y ≥ 29 | 隔壁刀片的手机主板 | 20.32 − 1.6 − 1.0 − 7.6 = **10.1**(h_phone 取物理上限)| `J4` BM24 + 排线、`L1` 3.0 都够 | ⚠ 以前「B 面只有 3.0」是把 `L1` 的高度当预算写的,不是净空;净空一直有 10 mm。 `HEIGHT` 里登记的超高件禁止进手机投影,自检会拦。 **`J5` / `J4` 的朝向**:排线穿槽折回是绕 x 轴翻 180°(x 不变、y 镜像),立创底层 正是「先 y 镜像再转 rot」(方案 §25.3)⇒ **底层 rot 0**,pin1 仍在西边(`J5.1 = P+` 在 −x,与 §13.5 用户实测「P+ 在左」一致)。⚠ 这是推导 + R1 §25.16 同一结论, **装配首件仍要两点通断复核**,几何校验抓不到接反。 ## 三类件,三种定位方式 1. **机械锚定件**(位置由小米8 主板决定,不许挪):`K1~K4` 侧键触点盘、 `J5` 电池排线焊盘、`J4` BM24 尾插座。坐标从 **`.claude/mi8_mech.json`** 换算, 不手抄。手机主板在刀片上的落点是 `PHONE_ORG`(方案 §3.1:x 3~70.09 / y 29~73.66, 长边水平、**接口边朝下**)。 2. **手工钉死件**:`J1` 金手指(`pcb_blade.J1_X`)、`C60` Ø10.3 的 2200 µF、 `J2` D± 退路座、`J3` EDL 座 —— 各有各的理由,见 `ANCHORS` 的注释。 3. **其余 73 件**:按功能分组、按「电流流向」排序,塞进六个区(`REGIONS`), 用一个简单的货架式排布器摆。**顺序是设计意图,坐标不是** —— 要改布局改 `GROUPS` 里的顺序和区,不要去改具体坐标。 ## 分区(方案 §3.1,坐标见 `pcb_blade.py`;除 K1~K4 外全部是 B 面) | 区 | 范围 | 放什么 | |---|---|---| | 金手指区 | y 0~11 | **只有 J1,其余一件不放**(插槽体 ~9 mm 要让开),两面都不放 | | 排线槽 + 车道 | BAT x 20.8~32.8 / TAIL x 46.6~66.6,y 24.8 → J5/J4 顶 | **铣穿 + 排线平贴,一件不放** | | `KEY` | x 0.6~13 / y 22~38 | 侧键 MOS + 栅极电阻,`K1~K4` 的正背面 | | `DIS` | x 21~34.2 / y 12~24 | `VO_P` 泄放,电池槽下方(VO-L3 岛内) | | `CTRL` | x 54~63 / y 47~56 | I²C 上拉,跟着 `U4` 走(M7 起在板中央) | | `SVC_M` | x 33~42.5 / y 29~50 | `VO_P` 阻尼组,电池车道东侧、`J5` 旁边(H4 螺钉头以西) | | `SVC_R` | x 47.5~65.5 / y 35.5~46 | 尾插 USB + VBUS 开关,`J4` **北边(插头背后)** | | `SVC_E` | x 66.6~72.5 / y 36.4~50 | EDL 模拟开关,尾插车道与功率列之间的走廊,`J3` 正北 | | 功率列 | x 72.3~97.5 / y 12~74 | Buck / eFuse / 检流,**按「离手机越远越吵」内外分两条** | 功率列内部:外条(x 86~97.5,靠板边)放 SW 节点、电感、Cin —— 最吵的; 内条(x 72~84,贴手机)放 FB 分压、DAC 低通、INA226、eFuse 外围 —— 最怕吵的。 这是方案 §3.1 的「按离手机越远越吵排」和风险 R-12。 ## M7(2026-09-09):为「先自动布线」重排 B 面 用户拍板「B 面全部重排 → 先跑 EDA 自动布线 → 再优化」。这一版**动的是拥塞,不是拓扑** —— §7.2 那四条关键回路(输入环路 / SW 面积 / FB 远离 SW / Kelvin)靠的是功率链手排, 一个数都没改;机械锚定件(`K1~K4` / `J5` / `J4` / `J1` / 七个孔)同样一个数没改。改的是: | 改动 | 为什么 | |---|---| | 排布器节距 1.1 → **1.8** | M6 欠的 5 只脚全是「出口被自家邻脚封死」。1.1 只够过一根线;板面占用率才 6%,加节距是白捡的 | | `U4` PCA9555 从 (76.3, 18.5) 挪到 **(48, 52)** | 它 8×8 的本体卡在「功率列内条 × 金手指北岸」那个口袋里,五只不通的脚全在它周边一圈 | | `EFUSE_AUX` 从 9.5×11.5 扩到 **11.7×16**,东沿退到 84.0 | 给 `U2` 西边留 2.55 的扇出通道(M6 只有 1.55) | | `U2` 上下的缝 1.8 → **2.6 / 2.4**,`U1_Y` 49.5 → **50.9** | WQFN-20 上下两排脚要横着爬出去;抬 U1 是为了不把链尾 `U3` 顶到金手指禁布区上 | """ import io import json import math import os import sys sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) # ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place), # 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份, # 而且**不报错**,只在调到 R2 专有的函数时才炸。 sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 import pcb_blade as B # noqa: E402 MIL = B.MIL PCB = B.PCB BBOX = os.path.join(HERE, '_blade_bbox.json') MECH = os.path.join(os.path.dirname(HERE), 'mi8_mech.json') GAP = 0.6 # 件间净距(mm):**装配**下限,自检按它判重叠 # ⛔ 排布器的**摆放节距**要比装配下限大得多 —— 0.6 的缝里塞不进一根线: # 0.2 的线要 0.2 + 2×0.18(净距) = **0.56**,再加上 0.25 的网格吸附, # 实际上等于没有通道。实测 0.6 排下来,17 处「出不来」里大半在 **0402 电阻**上 # (不是细间距 IC),布线器怎么调顺序都是 28~30 条接不上。 # 1.1 能过一根线还剩余量;区里都有空地(`EFUSE_AUX` 12 件只占了 79 mm² 中的 45)。 PACK_GAP = 1.8 # 排布器的摆放节距(M7 从 1.1 加到 1.8,见下) # ⛔ **M7(2026-09-09)把节距从 1.1 加到 1.8**:1.1 只够过**一根** 0.2 的线 # (0.2 + 2×0.18 = 0.56,加 0.25 网格吸附实际剩不到一根的余量),而 M6 欠的 # 5 只脚全是「出口被自家邻脚封死」——不是没地方走线,是**脚出不来**。 # 1.8 能并排走两根线还剩余量。板子一点都不挤:83 件本体面积加起来 ~430 mm², # 板面 7448 mm²,占用率 6%,加节距是白捡的。 # 个别区可以覆盖(值 = 该区的节距)。 PACK_GAP_BY_REGION = { 'BUCK_FB': 1.4, # U1 右边只有 6 mm 宽的缝,1.8 会退化成单列 } MILL_KEEP = 0.5 # 器件离铣穿内槽的净距 # 排线在 B 面占的车道:从槽顶到连接器顶沿,宽 = 排线宽 + 2×0.5。**一件不放**(排线要平贴)。 # ⛔ 2026-09-11 刀片 98×76 → 90×68:本文件里的手排坐标都是 M7(手机原点 (3, 29)、板宽 98) # 那一版算出来的。**跟手机绑定的**加 (DX, DY),**功率区**加 PX(整块西移,内部回路关系不动)。 M7_ORG = (3.0, 29.0) DX = round(B.PHONE_ORG[0] - M7_ORG[0], 3) # −1.5 DY = round(B.PHONE_ORG[1] - M7_ORG[1], 3) # −6.66 PX = round(B.BW - 98.0, 3) # −8.0 LANE_TOP = {'BAT': round(34.5 + DY, 3), 'TAIL': round(35.0 + DY, 3)} # 手机主板在刀片上的落点:本体 (u,v) -> 板 (x,y) = (PHONE_ORG + (u,v)) # 方案 §3.1:主板 67.09 × 44.66,长边水平、**接口边(v=0 那条,J600/J1401/侧键都在上面)朝下**。 # ⚠ 事实来源在 pcb_blade(板级),这里只做别名,不要抄第二份 PHONE_ORG = B.PHONE_ORG PHONE_WH = B.PHONE_WH # 焊盘外框量不到本体的几个件:给真实本体尺寸(mm) BODY = { 'C60': (11.0, 11.0), # Ø10.3 铝电解,2200 µF(T2 = 高 10.5) 'L1': (7.6, 7.0), # SPM6530T-4R7M 'J4': (13.2, 3.6), # BM24-50DS 'U4': (8.0, 8.0), } # ── 分面 ─────────────────────────────────────────────────────────────── # **A 面(顶层)只留这 10 件**,其余全部 B 面。理由逐条写在模块头的表里。 TOP_ONLY = ['J1', 'K1', 'K2', 'K3', 'K4'] # 装配高度(mm)。只登记**可能超手机投影预算**的件 —— 放进 B 面手机投影会顶到 # 隔壁槽的手机主板,而**几何自检和 DRC 一条都不报**。 HEIGHT = { 'C60': 10.5, # Ø10.3 铝电解(T2 实测) 'J3': 5.0, # 1.25 mm 2P 线座(估) 'L1': 3.0, # SPM6530T } H_B_PHONE = 20.32 - 1.6 - 1.0 - 7.6 # B 面手机投影下的净空(h_phone 取物理上限)= 10.12 H_B_SVC = 20.32 - 1.6 # B 面服务带的净空(对面 A 面服务带是空的)= 18.72 def phone(u, v): return (round(PHONE_ORG[0] + u, 3), round(PHONE_ORG[1] + v, 3)) def lanes(): u"""排线在 B 面的车道 {名字: (x0, y0, x1, y1)}:槽顶 → 连接器顶沿,一件不放。""" out = {} for k, cx in B.flex_x().items(): hw = B.FLEX_W[k] / 2.0 + 0.5 out[k] = (round(cx - hw, 3), B.SLOT_Y[1], round(cx + hw, 3), LANE_TOP[k]) return out def anchors(): u"""机械锚定 + 手工钉死的件 -> (x, y, rot)。K1~K4 在 A 面,其余 B 面。""" m = json.load(io.open(MECH, encoding='utf-8')) sk = m['side_key_contacts'] fx = B.flex_x() a = {} # ── 侧键触点盘:主板自己的弹片直接压在这四个裸铜盘上(R1 §22.3e 中框式)── # 位置**完全由主板决定**,Ø1.5 的盘只容许 0.50 mm 位置误差(pcb2_place 的算账)。 for ref, key in (('K1', 'PWR'), ('K2', 'VUP'), ('K3', 'VDN'), ('K4', 'GND')): a[ref] = phone(*sk[key]) + (0,) # ── 电池排线焊盘 J5:B 面、电池槽正北、手机投影下方(排线穿槽后折回 +y)── # y 由排线长度定:J600→接口边 4.6 + 绕边 5 + 到槽心 2.7 + 穿板 1.6 + 背面 (y−26.3) # ≈ 19.6 ≤ 35(实长 38)。焊盘 4 高,底沿 30 离槽顶 27.8 有 2.2。**底层 rot 0**(见模块头)。 a['J5'] = (fx['BAT'], round(32.0 + DY, 3), 0) # ── BM24 尾插座 J4:B 面、尾插槽正北。2.48 + 5 + 2.7 + 1.6 + 6.7 ≈ 18.5 ≤ 50(实长 55)。 # 本体 2.6 高 → 31.7~34.3;H4 (44.9,30.9) 在西边 5.4 mm 外。**底层 rot 0**。 a['J4'] = (fx['TAIL'], round(33.0 + DY, 3), 0) # ── C60:Ø10.3 / 高 10.5,只能在服务带(B 面净空 18.7)。夹在两条槽之间, # x 取两槽正中(焊盘外沿 12.9 宽,离两侧铣槽各 0.45)。 # ⛔ y **贴着金手指区上沿**(本体 11 的下沿 = 11.0),不是居中:居中(上下各余 0.17)时 # H4 螺钉头圈只剩 2.07 < HOLE_KEEP 2.2;贴下沿之后上沿 22.0 离接口边 22.34 余 0.34, # 离 H4 2.24。(真实罐体 Ø10.3,比 BODY 的 11 每边还小 0.35。) s = B.flex_slots() a['C60'] = (round((s['BAT'][2] + s['TAIL'][0]) / 2.0, 3), round(B.ZONES['finger'][3] + BODY['C60'][1] / 2.0, 3), 0) # (J2 D± 退路座 2026-09-11 已删,见 b4_edge.py 表头) # ── J3:EDL 座。两根飞线要去主板朝上面的 TP1204/1205,只能**跟着尾插排线穿尾插槽** # (R1 同一做法)⇒ 座子贴着尾插槽东头。90×68 之后车道东侧那条走廊被功率区西移吃掉了, # 改放**服务带里、尾插槽东头与功率列之间**(x 65.6~70.6 / y 11~22): # 竖放 rot 270 ⇒ 本体 5.2×8.35,信号盘朝**西**正对槽口。 # ⚠ 底层「先 y 镜像再转」:rot 0 读回信号盘在南,按 p = R(rot)·(lx, −ly) 推 rot 270 在西 # (原 J2 实测同一结论:rot 90 在东、rot 270 在西)。放完用焊盘读回核一次。 tail = s['TAIL'] a['J3'] = (round(tail[2] + MILL_KEEP + GAP / 2 + 5.2 / 2 + 0.2, 3), round(B.ZONES['finger'][3] + GAP / 2 + 8.35 / 2 + 0.5, 3), 270) return a # ── 功率级:**手排**,不交给排布器 ──────────────────────────────────── # ⛔ 上一版把功率级也交给货架排布器,结果 §7.2 的四条关键回路**破了三条**: # Cin 离 U1 **12.4 mm**(输入环路)、SW 从 U1 绕到 L1 走了 ~7 mm(SW 面积)、 # FB 分压压在 SW 那一带。货架算法只会「按面积塞满」,它不认识回路。 # # **`U1` 转 90° 是这一版的关键一步**:TPS54622 的 VIN/GND 在一条长边、SW 在对边, # 不转的话 Cin 和电感必然分居左右两侧 —— 而 §3.1 要求 SW / 电感 / Cin **同时** # 靠板外边(x 88~98,离手机最远)。转成横放之后 VIN/GND 朝上、SW 朝下, # 于是竖着堆成一条:**D1/F1 → Cin ×4 → U1 → L1 → Cout → eFuse → 分流 → INA**, # 电流自上而下,出口 `VO_P` 正好落在 `VO-L3` 岛(y ≤ 28.8)里。 # # 三条回路各自的落地判据(改坐标前先想清楚会破哪一条): # · **输入环路**:`C4/C1/C2/C3` 正压在 U1 的 VIN/GND 引脚上方,最近一颗环路 ~1.0 mm; # · **SW 面积**:`L1` 转 90° 让 1 号脚朝上,和 U1 的 SW 脚**焊盘对焊盘 0.6 mm**, # SW 铜就是那一小块 3.4 mm 宽的方块,绕不出去; # · **Kelvin**:`U3` 转 180° 让 9/10 脚朝上正对 `Rs1` —— **不转就是交叉** # (9 脚在 10 脚右边,却要接左边那个焊盘),一交叉就得打过孔,§7.2 明令不许。 STACK_GAP = 0.65 # 手排竖直堆叠的件间净距 # (位号, rot, dx):dx = 相对功率列中心线 PWR_X 的横向偏移;y 由 stack() 自上而下算 PWR_X = round(89.0 + PX, 3) # 2026-09-11:板宽 98 → 90,整条功率链西移 8(81.0) # 功率链的锚:U1 的中心 y。⚠ 抬高 2.5 mm 是**布线逼出来的**:链尾的 `U3`(INA226) # 底边五个 0.5 mm 间距的脚要朝下扇出到金手指那一侧,原来只剩 1.0 mm 净空, # 布线器报「INA_ALERT 一个焊盘都出不来」。抬完剩 3.5 mm,五根线排得开。 U1_Y = 50.9 # ⚠ M7 从 49.5 抬到 50.9:U2 上下的缝各加了 0.8/0.6, # 整条链会往下顺移同样多,而链尾 `U3`(INA226) 底边那五个 0.5 mm 间距的脚 # 离金手指禁布区只剩 1.67 mm —— 正是 M4c 当初抬高 2.5 mm 要解决的那件事。 # 抬 1.4 抵掉,U3 底沿回到 14.07(禁布区 11),扇出仍有 3.0 mm。 STACK_UP = [ # U1 往上:Cin 由近到远,再是保险/TVS ('C4', 180, 0.0), # 0402 HF,压在 VIN/GND 脚正上方 ('C1', 180, 0.0), ('C2', 180, 0.0), ('C3', 180, 0.0), ('F1', 90, 0.0), # rot=90 ⇒ p1(+12V) 朝上接 V12-L3 岛、p2(+12V_F) 朝下 ] # 第 4 个字段 = 这一件和上一件之间的净距(缺省 STACK_GAP)。 # ⚠ `U2` 上下要留 1.8:它是 WQFN-20,四条边都有脚,**上下两排要从 0.65 mm 的 # 缝里横着爬出去** —— 实测这一带堵掉了 eFuse 的 6 个信号($1N147/$1N151/ # $1N164/EFUSE_EN/EFUSE_PG/EFUSE_FLT)。功率链在 y 上有 15 mm 富余,加得起。 STACK_DN = [ # U1 往下:电感 → 输出电容 → eFuse → 分流 → INA # ⚠ 这个 1.5 不是随便给的:U1 底边除了 SW(11/12) 还有 p9/p10/p13(BOOT), # 贴到 0.65 等于把整条边封死,那三只脚**只能横着爬**而两侧都是焊盘 —— # 实测 `$1N136`/`$1N138` 死活出不来。1.5 给它们让出一条通道, # SW 焊盘净距从 0.65 变 1.5,仍远小于 §7.2 的 2.0 判据。 ('L1', 270, -0.25, 1.5), # p1(SW) 朝上正对 U1 的 SW 脚 ('C6', 0, -0.25), ('C7', 0, -0.25), ('C8', 0, -0.25), # 输出 HF,接着 C6/C7 排在同一条线上 # ⚠ M7 从 1.8 加到 2.6 / 2.4:U2 是 WQFN-20,上下两排脚要横着爬出去, # M6 欠的 5 只脚里有 3 只(U2.15 / U2.17 / U2.2)就死在这两条缝里。 # 功率链在 y 上还有 ~14 mm 富余(U3 底 14.06 vs 禁布区 11),加得起。 ('U2', 180, 0.0, 2.6), # rot=180 ⇒ VO 从上边进、$1N165 从下边出 ('Rs1', 0, -4.2, 2.4), # p2(右) 接 U2 的输出、p1(左) = VO_P 出岛 ('U3', 180, -4.95, 1.4), # rot=180 ⇒ 9/10 脚朝上,正对 Rs1 两端(不交叉) ] # 不进竖直堆叠、但位置由引脚决定的几件 HAND = { # BOOT 电容:BOOT 脚(p13)在 U1 下边、SW 也在下边,但正下方被 L1 占满了。 # ⇒ 摆到 L1 左侧,`SW` 那一脚接 **L1 的 1 号焊盘**(同一个节点,不必绕回 U1)。 'C5': (PWR_X - 5.3, U1_Y - 6.3, 0), # U1 的 p9($1N135) 旁路:p9 在下边偏右,摆到 U1 右下角外 'C9': (PWR_X + 3.45, U1_Y - 1.72, 0), # 和 p9 同一条水平线,2.0 mm # TVS:竖在 Cin 堆左侧,p1(+12V_F) 朝上,和保险的出线端同一节点 # ⚠ M7:从 U1_Y+2.5 挪到 +3.1(北移 0.6),给 `R3` 让出 U1 西边那个口袋(见下)。 'D1': (PWR_X - 4.2, U1_Y + 3.1, 90), # ── 尾插那两颗(U7 ESD / U5 VBUS 开关):J4 现在也在 B 面,排线从南边来, # 它们只能在 J4 **北边**(插头背后)—— 交给 SVC_R 区的排布器,U7 排第一个 # 落在区的西南角(离 J4 的 D± 脚最近)。 # ── PCA9555:TSSOP-24,两排各 12 只脚 0.65 mm 间距 ──────────────────── # **M7 用户拍板:挪到左下角**(原来 M7 第一版放板中央 (48,52),更早在功率列 # 那个拥塞口袋 (76.3,18.5))。左下角这块 x 4~20 / y 11.6~21 本来是空的, # 四面净空都 ≥ 2 mm,而且离侧键 MOS(`Q3~Q5` 在 x 2~5.5 / y 26~30)最近 —— # `KEY_PWR/VUP/VDN` 三根从 M6 到现在一直是布不通的钉子户,这一挪从 70 mm 变 12 mm。 # ⛔ **y 不能照用户拖的 10.67**:本体 8×8 ⇒ 下沿会落到 **6.67**, # 而 y < 11 是**金手指区**,PCIe 插槽体(~9 mm 高)要从两面夹住这一段板 —— # 放件进去装不上,几何自检会拦(`侵入金手指区`)。抬到 15.6:下沿 11.6, # 离禁布区 0.6,离 KEY 区下沿(22)2.4。 # ⚠ 用户拖过来时是 rot 180,**改成 rot 0**:两者都是「南北两排」, # 差别在哪一排朝哪边 —— rot180 时 `p1~p12`(`KEY_PWR/VUP/VDN`、`INT#`、 # `EDL_EN`、`VBUS_EN`)朝**南**,而侧键 MOS `Q3~Q5` 在**北**边(y 26~30); # 同时 `p13~p24`(`SDA/SCL/+3V3` + 四路状态)朝北,而金手指在**南**边。 # 两排正好各自背对自己的去向,等于每根线都要绕整颗芯片一圈 —— M6 在功率列 # 上就是这么丢掉 4 处的(原 `HAND` 注释里那段「转 180」说的是同一件事)。 'U4': (12.5, 15.6, 0), # PCA9555 去耦 + I²C 上拉:**跟着 U4 走**,坐标由 `pin_xy()` 从板上读回的 # U4 引脚位置现算(见该函数的说明),不手抄。 # ⛔ `R3`(EN 分压的下臂,`$1N128`)**必须在 U1 西边**:`U1.1`(EN) 和 `p14`(BUCK_PG) # 都在西短边,而 `BUCK_FB` 区在 U1 **东**边(那是给 FB/COMP 的,见 REGIONS 注释)。 # 摆在东边的话这根线要绕整颗芯片 —— 布线器就是这么报「$1N128: U1.1 没布通」的, # `place_audit.py` 也把它列在「摆错边」里。 # 口袋是 L1 顶(47.2) / U1 西沿(86.875) / D1 南沿(50.6,已北移让路) 夹出来的那一小块。 'R3': (round(85.95 + PX, 3), 48.8, 90), # INA226 的 +3V3 旁路,摆在 U3 右侧(U3 上下两排脚都被 Kelvin / I2C 占着) 'C13': (round(86.8 + PX, 3), 18.0, 0), # ── U7(D± ESD):**手排在 J4 那两只 D± 脚正北**(2026-09-11,90×68 + U7 两侧对调之后)── # 下排 6/4 脚(DMJ 西 / DPJ 东)正对 J4 的 4/6 脚(DMJ 51.23 西 / DPJ 51.58 东), # 直直往南 ~2 mm 接过去,零交叉;上排 1/3 脚(USB_DM / USB_DP)朝北, # 金手指来的一对在顶层掉头从北边进来、脚旁各一个孔下 B 面(手性推导见 b3b_usb.py)。 # J4 两只 D± 脚相对本体 (−3.85/−3.50, +1.06),U7 焊盘排距 ±1.15(都是 dump 出来的封装常量); # 本体下沿离 J4 本体顶 0.73,在尾插车道上沿(LANE_TOP)以北。 'U7': (round(B.flex_x()['TAIL'] - 3.675, 3), round(33.0 + DY + 1.059 + 2.0 + 1.148, 3), 0), # EDL 模拟开关:跟着 J3 走,交给 SVC_E 区排布器(U6 排第一个) # ── 侧键 MOS:转 90° 让 3 脚(接触点盘那一路)朝上,排在 K1~K4 正下方 ── # ⚠ 90×68:y 跟手机走(+DY),**x 不动** —— Q4 本来就贴着板边(x 0.73~3.28), # 跟着手机左移 1.5 就出板;三颗一起不动,彼此间距不变,离 K 盘的上下余量也不变。 'Q3': (5.5, round(29.9 + DY, 3), 90), 'Q4': (2.0, round(29.9 + DY, 3), 90), 'Q5': (5.5, round(26.1 + DY, 3), 90), } def stack(pos, sz): u"""把 STACK_UP / STACK_DN 按 bbox 高度自 U1 向两头堆开,返回补进去的坐标。 ⚠ 只用 bbox 高度 + `STACK_GAP` 算 —— 不手抄 y。改一颗件的封装, 整条链自动重排,不会留下一个「当初算好、现在对不上」的常数。 """ out = {'U1': (PWR_X, U1_Y, 270)} y = U1_Y + sz['U1'][1] / 2.0 for item in STACK_UP: ref, rot, dx = item[:3] gap = item[3] if len(item) > 3 else STACK_GAP h = sz[ref][0] if rot % 180 else sz[ref][1] y += gap + h / 2.0 out[ref] = (round(PWR_X + dx, 3), round(y, 3), rot) y += h / 2.0 y = U1_Y - sz['U1'][1] / 2.0 for item in STACK_DN: ref, rot, dx = item[:3] gap = item[3] if len(item) > 3 else STACK_GAP h = sz[ref][0] if rot % 180 else sz[ref][1] y -= gap + h / 2.0 out[ref] = (round(PWR_X + dx, 3), round(y, 3), rot) y -= h / 2.0 return out # ── 分组:**顺序 = 电流/信号流向**,排布器按这个顺序摆 ────────────────── GROUPS = [ # (区, 组名, [位号...]) # 功率链本体已经手排(STACK_*/HAND),这里只剩「跟着某颗 IC 走」的外围。 ('CTRL', u'I2C 上拉 + 去耦(正常情况下由 ctrl_hand 钉在 U4 脚上)', ['C40', 'R40', 'R41']), # ⚠ `VO_P` 泄放支路(R19 10Ω/1W + Q2)挪到**服务带左段**:它接的是 `VO_P`, # 而 `VO_P` 在 L3 上是整块岛(服务带全覆盖),放哪一头都一样近; # 放这里把功率列内条腾出来给 U4 的扇出带。 ('DIS', u'VO_P 泄放支路', ['R19', 'Q2']), # ⛔ M7 三版拆成东西两组。`U2` 是 WQFN-20,**四条边都有脚**,而 M7 二版把 12 件 # 全塞在它西边 —— 接东边 `p17~p20`(`$1N156`/`$1N158`/`$1N151`/`EFUSE_FLT`)的 # 四件要绕整颗芯片才够得着,U2 周围本来就只有那么几个出口,一绕就把别人也堵死 # (M6/M7 一直布不通的 `$1N156: U2.17` 就是这么来的)。 ('EFUSE_E', u'eFuse 东侧(p17~p20)', ['R11', 'C14', 'R12', 'R13']), # ⚠ 90×68:EDL 开关排在 EFUSE_AUX **之前** —— 两区在 J3 正北重叠,谁先摆谁先挑; # 第一版反过来,U6 被挤到 y 37.5(离 J3 20 mm),`$3N45: J3.2 ↔ U6.4` 没布通。 ('SVC_E', u'EDL 模拟开关', ['U6', 'R63', 'C69']), ('EFUSE_AUX', u'eFuse 西侧 + 北侧', ['R9', 'R10', 'R16', 'R17', 'R15', 'R14', 'Q1', 'R18']), # ⚠ FB / 补偿**必须在这个区**:它贴着 U1 的左边(FB/COMP 脚都在左长边), # 同时离 SW(x 87~94、y 34~42 那一块)≥ 2 mm —— §7.2 第三条「FB 远离 SW」。 ('BUCK_FB', u'FB 分压 + 补偿 + DAC 低通', ['R1', 'R2', 'R7', 'R8', 'C12', 'R4', 'R5', 'R6', 'C10', 'C11']), # R3 见 HAND # ⚠ 侧键组:三颗 MOS 已手排在 K1~K4 正下方,这里只剩栅极电阻,跟着 MOS 走。 ('KEY', u'侧键栅极电阻', ['R42', 'R43', 'R44', 'R45', 'R46', 'R47']), ('SVC_M', u'VO_P 阻尼组 + 电池外围', ['C61', 'C62', 'C63', 'C64', 'C65', 'R60', 'R61']), ('SVC_R', u'尾插 USB + VBUS 外围', ['U7', 'U5', 'C66', 'C67', 'C68', 'R62', 'R64', 'R65']), ] # ── 区(x0, y0, x1, y1),排布器在里面自下而上、自左而右铺 ──────────────── # ⚠ 三个服务带小区各自贴着自己那件连接器: # SVC_M ↔ J5(26.8,32),SVC_R ↔ J4(56.6,33) 北边,SVC_E ↔ J3(69.9,33) 北边。 # 区改小是故意的:范围给大了货架排布器会把件铺到离连接器 15 mm 外去。 # ⚠ KEY 区上沿 36.6 = H1 螺钉头(4.4,38.9) 让 2.2。 REGIONS = { # ⚠ M7:跟着 U4 搬到板中央,落在 U4 东列(x 51.0)外侧 —— SDA/SCL 上拉 # 就地接到 p22/p23 上,不用绕。北边到 H5(60.4,70.2) 还有 14 mm。 # ⚠ M7 二版:跟着 U4 挪到左下角,占 U4 东边那条 4 mm 宽的缝(DIS 区从 x 21 起)。 # ⚠ 90×68:上沿收到电池槽下沿(槽现在在 y 18.14~21.14) 'CTRL': (17.2, 12.0, 20.8, round(B.SLOT_Y[0] - MILL_KEEP - 0.1, 3)), # ⚠ 必须落在 VO-L3 岛(x ≥ 20)里:R19 的 VO_P 脚要就地扎过孔进岛, # 放到岛外面布线器会报「找不到落过孔的地方」。东沿 34.2 贴 C60 本体西沿 34.5。 # ⚠ M7 上沿从 18.8 抬到 24.0(= 电池铣槽下沿 24.8 让 0.8):节距加到 1.8 之后 # R19(7.4×3.5) + Q2 在 6.8 高的带子里排不下第二行。 # ⛔ 90×68 第三版:挪到**电池车道正北、手机投影下**(B 面,R19 2512 / Q2 都 < 1 mm 高)。 # 第一版放在电池槽正下方(y 11.2~17.6),而服务带压到 11.34 之后,**槽下那条 6.4 mm 高的带 # 是 U4(左下角)往东去的唯一走廊** —— ~8 个网(SDA/SCL/BUCK_PG/EDL_EN/VBUS_EN/EFUSE_FLT/ # INA_ALERT/EFUSE_PG)要从这儿过,R19+Q2 把 B 面那一层从 x 18 到 32 整段占满,服务器上 # 调布线顺序只能此消彼长(2026-09-11 第三轮:三个长网通了、SDA 整网断成 4 块)。 # 新位置紧挨电池座 J5(R19 本来就接 VO_P = 电池正)、正下方是 VO-L3 岛的北叶,就地扎孔照旧。 'DIS': (17.5, round(LANE_TOP['BAT'] + 0.46, 3), 32.0, 35.0), # ⚠ M7 大改:U4 搬走腾出的整块口袋都给它(原来 9.5×11.5 塞 12 件,节距 1.1)。 # 南沿 13.0 = 金手指禁布区 11 让 2.0;东沿 84.0 给 U2 西边留 2.55 的扇出通道 # (U2 本体西沿 86.55),M6 就是这条通道只有 1.55 才把 R11/R16 憋死的。 # ⚠ 南沿定在 20.0 而不是 13.0:货架排布器是**自下而上**填的,y0 给到 13 # 会把 12 件全堆到离 U2(y 27.2~31.1) 最远的那一头,ILIM/dV-dt 这些 # 本该贴着芯片的件跑到 12 mm 外。y 13~20 那块留空,正好是 U3 底排 # 五只 0.5 mm 间距脚往金手指方向扇出的地方。 # `U2` 东边(本体东沿 91.45)到板边之间那条缝。上沿 33.5 让开 `C8`(y 32.3~32.9) 所在的 # 功率链中心线;下沿 23.5 让开 `Rs1`(y 19.9~23.4)。4 件一列,顺序由「按连线就近落位」 # 自动排成 p17→p20 的顺序(目标点精确到焊盘)。 # ⚠ 北沿放到 36.0:目标驱动的排布器会为了靠近自己那只脚把件排在非对齐的 y 上, # 5.4×10 的框里 4 件就可能塞不下(实测 `R13` 放不下)。东边这一条本来就是空的。 'EFUSE_E': (round(92.0 + PX, 3), 23.0, round(97.4 + PX, 3), 36.0), # ⚠ 90×68:整体西移 8 会压上尾插槽 / 车道(东沿 65.58)和新的 J3 ⇒ 区整体放到 J3 **以北**、 # 车道东沿以东,宽度保持 M7 的 11.2(东沿仍离 U2 西沿 2.55)。第一版把西沿收到 68.6(宽 7.4), # R16/R17 被挤到离 U2 7~11 mm,`$1N165: R16.1` 没布通。 'EFUSE_AUX': (64.8, 20.7, round(84.0 + PX, 3), 40.0), # ⚠ FB / 补偿在 U1 的**右短边**(转 270 之后 p7=FB、p8=COMP 都在 x=+1.72 那一侧), # 所以这个区在 U1 右边、板边那一条 6 mm 的缝里 —— 离 SW(x 87~90)≥ 2 mm。 # ⚠ M7 只把 y 拉长(1.4 的节距下 6.1 宽刚好两列),不动 x:再往东就是板边。 'BUCK_FB': (round(91.3 + PX, 3), U1_Y - 8.0, round(97.4 + PX, 3), U1_Y + 15.0), # ⚠ M7 东沿从 8.5 拉到 13.0:K1~K4 四个盘 + 三颗 MOS 已经占掉了西边这一条, # 1.8 节距下六只栅极电阻排不进去(x 9~13 这一带本来就是空的)。 'KEY': (0.6, round(22.0 + DY, 3), 13.0, round(38.0 + DY, 3)), # 电池车道东沿 31.77+0.5 → 33;H4 (44.9,30.9) 螺钉头西沿 42.7。 # ⚠ M7 三版:北沿 **45.5**(不是 50)—— `VO-L3` 岛的北叶只到 **y=46** # (`pcb_pour.VO_LOBE`),排到 48.8 的那颗阻尼电容 `VO_P` 脚就地扎不了过孔, # 布线器报「找不到落过孔的地方」。而且阻尼组本来就该贴着 `J5`(26.8,32), # 越往北越没意义。少的高度往横里补:东沿 42.5 → 44.5(H4 螺钉头的避让圈 # 由 blockers() 兜着)。 'SVC_M': (round(33.0 + DX, 3), round(29.0 + DY, 3), round(44.5 + DX, 3), round(45.5 + DY, 3)), # J4 北边:本体顶 34.3 + 0.6。⚠ M7 北沿收到 42.0(给 U4 的南面让出 6 mm 扇出净空), # 东沿从 66 收到 64.5(尾插车道东沿 66.08 之内),少的高度靠 1.8 节距下仍够 8 件。 # ⚠ 90×68:只留 U7 换层孔空地**以东**那一块(U7 已手排)—— 第一版给了整条 18 mm, # 排布器的「同尺寸互换」把 C67 / C68 换到了离 U5 6~8 mm 的地方,`$5N71: U5.4` 没布通。 'SVC_R': (53.6, round(35.5 + DY, 3), 65.0, round(46.0 + DY, 3)), # ⚠ 90×68:J3 挪到服务带(尾插槽东头)之后,EDL 开关排在它**正北**那一小块 # (车道东沿 64.58 以东、J3 本体顶 20.45 以北);GROUPS 里排在 EFUSE_AUX 前面。 'SVC_E': (64.8, 20.7, 70.4, 32.0), } # ⛔ 排布器的障碍**要分面**。A 面的 `J4` / `J5` / `C60` / `J2` / `J3` 挡的是 A 面, # 把它们当成 B 面的障碍,「USB 组落在 J4 正背面」这条设计意图就永远实现不了 # (排布器会绕开 J4 的本体框,把件推到 5 mm 以外)。 # ⇒ 只有下面这几件对**两面**都算障碍: # · `K1~K4`:Ø1.5 裸铜盘,B 面要在旁边扎过孔把漏极引下来,正背面得空着; # 而且手机弹片压在上面,底下不摆件也让受力干净。 # · `J1`:金手指,两面都是指。 BLOCK_BOTH = ('J1', 'K1', 'K2', 'K3', 'K4') def bottom_refs(pos=None): u"""B 面件 = 全部 - TOP_ONLY。""" d = json.load(io.open(BBOX, encoding='utf-8')) return [r for r in (pos or d) if r not in TOP_ONLY] def sizes(): d = json.load(io.open(BBOX, encoding='utf-8')) out = {} for ref, v in d.items(): w, h = BODY.get(ref, (v['w'], v['h'])) out[ref] = (max(w, 0.6), max(h, 0.6)) return out 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(): u"""排布器的**固定障碍**:铣穿的排线槽 + 排线车道 + 七个定位孔的螺钉头避让圈。 ⛔ 不把它们喂进排布器,货架算法照样会往里塞件,然后自检报一堆错 —— 窗里那几件是**摆在空气里**(内槽铣穿),孔那几件是被螺钉头压着。 """ out = [] for (a, b, c, d) in B.mill_cuts(): out.append((a - MILL_KEEP, b - MILL_KEEP, c + MILL_KEEP, d + MILL_KEEP)) out += list(lanes().values()) for (hx, hy) in B.phone_holes().values(): out.append((hx - B.HOLE_KEEP, hy - B.HOLE_KEEP, hx + B.HOLE_KEEP, hy + B.HOLE_KEEP)) # D± 在顶层从北边来、在 U7 上排(1/3 脚)北边换层下 B 面 ⇒ 那一小块 B 面不放件 ux, uy, _r = HAND['U7'] out.append((ux - 1.8, uy + 1.7, ux + 1.8, uy + 4.2)) return out def wh(ref, rot, sz): u"""转 90/270 之后 bbox 的宽高要对调。**手排件全靠这一步**—— 忘了对调,`L1`(7.6×7.0 转 90) 和 `D1`(6.8×2.2 转 90) 的占位就是错的, 而错的方向是「看起来更小」,于是重叠检查放行、截图上却压在一起。""" w, h = sz[ref] return (h, w) if rot % 180 else (w, h) def plan(): u"""返回 {位号: (x, y, rot)} 和占位矩形表。""" sz = sizes() pos = dict(anchors()) pos['J1'] = (B.J1_X, B.J1_Y, B.J1_ROT) pos.update(stack(pos, sz)) # 功率链(手排,竖直堆叠) pos.update(HAND) # 其余按引脚钉死的手排件 pos.update(ctrl_hand(pos, sz)) # C40/R40/R41 跟着 U4 的引脚现算 # 排布器只摆 B 面的件 ⇒ 障碍表里只放「对 B 面也成立」的东西(见 BLOCK_BOTH) # ⛔ M7 修掉一个 M5 遗留:这里原来把 `anchors()`(J2/J3/J4/J5/C60)从障碍表里 # 排除掉,理由是「它们在 A 面,挡的是 A 面」—— 但 M6 已经把它们全翻到 B 面了, # 排除条件没跟着改。M6 没暴露是因为当时没有哪个区压到连接器上;M7 的 # EFUSE_AUX 一往西扩就把 Q1 摆进了 J3 的本体里(自检那一关拦下来的)。 # ⇒ 只要不在 TOP_ONLY 里,就是 B 面的固定障碍。 fixed = list(BLOCK_BOTH) + sorted(set(pos) - set(TOP_ONLY)) rects = list(blockers()) for ref in fixed: x, y, r = pos[ref] 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)) # 每个区一个 y 游标:**一个组占一条完整的横带**,下一组从带子上沿重新开始。 # 不这么做的话小件会回填到前面组的空隙里,功能级在 y 上互相咬 —— # 后面 L3 要按「哪一段是 12V、哪一段是 VO」切铜面就切不出来了。 cursor = {k: v[1] for k, v in REGIONS.items()} unplaced = [] slots = {} # {(区, 尺寸类): [(位号, (x,y))]} peers0 = net_peers() STEP = 0.5 # 候选格点的步长(和老版扫描一致) for region, _name, refs in GROUPS: x0, _y0, x1, y1 = REGIONS[region] gap = PACK_GAP_BY_REGION.get(region, PACK_GAP) y0 = cursor[region] todo = [r for r in refs if r not in pos] if not todo: continue # ⛔ **挑「离目标最近的空位」,不是「自下而上第一个空位」**。 # 老版按面积从下往上塞,位置和这件连到哪儿毫无关系 —— 用户一眼看出来 # 「没摆顺」(`U2` 东边四只脚的外围件全被塞到西边去了)。 # 目标点由 `target_point()` 精确到**对端焊盘**给出。 # 大件先挑(它们的目标最硬、可选位置最少),同尺寸按位号稳定排序。 order = sorted(todo, key=lambda r: (-sz[r][0] * sz[r][1], r)) band_top = y0 for ref in order: w, h = sz[ref][0] + gap, sz[ref][1] + gap t = target_point(ref, peers0, pos, ((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]) if t else 0.0), yy, xx) if best is None or key < best[0]: best = (key, r, cx, cy) xx += STEP yy += STEP if best is None: unplaced.append((ref, region)) continue _key, r, cx, cy = best rects.append(r) pos[ref] = (round(cx, 3), round(cy, 3), 0) slots.setdefault((region, size_class(sz[ref])), []).append((ref, (round(cx, 3), round(cy, 3)))) band_top = max(band_top, r[3]) cursor[region] = band_top + 0.4 peers = net_peers() if peers: # ⚠ 迭代 3 轮:同一组里的件互为目标点(`C68` 要贴 `U5`,而 `U5` 自己也是 # 排布器摆的),一轮收敛不了 —— 第一轮 `C68` 按 `U5` 的**初始货架位**去靠, # 而 `U5` 那一轮也在动。实测第 2 轮就基本不动了,第 3 轮兜底。 for _ in range(3): if not reassign(pos, slots, peers): break # ⚠ 网格微调**所有件**(含手排的功率链):0.5 mm 间距的 QFN 不落在 0.25 格点上时, # 相邻两只脚的出线只能挤到 0.25,比净距(0.15+0.16)还窄,布线器就报「出不来」。 # `U2.19` / `U1.1` 都是这么卡住的。挪完再逐对校核净距,破了 GAP 的**退回去**。 before = dict(pos) grid_snap(pos) # ⚠ 退回要**迭代到不动为止**:退回 A 会让它和「已经挪过、没退回」的 B 之间重新变窄 # (实测 C1 退回、C2 没退,缝从 0.65 变 0.55,自检报重叠)。 done = set() for _ in range(6): bad = snap_conflicts(pos, sz) - done if not bad: break for ref in bad: pos[ref] = before[ref] done |= bad return pos, rects, unplaced, sz def u4_local_pins(): u"""从 `_blade_pads.json` 读回 `U4` 24 只脚**相对质心**的局部偏移 {脚号: (dx, dy)}。 TSSOP 两排脚对称 ⇒ 质心就是本体中心,不需要另外知道当初摆在哪。 ⚠ 这份文件是**上一次下发之后 dump 的**:改了 `U4` 的旋转角要先 `pcb_route.py --dump` 再重跑一次 `--push`(两趟收敛),否则下面 `ctrl_hand()` 会认出「排布方向对不上」并退回给货架排布器。 """ if not os.path.exists(PADS_JSON): return {} d = [r for r in json.load(io.open(PADS_JSON, encoding='utf-8')) if r[0] == 'U4'] if len(d) != 24: return {} cx = sum(r[2] for r in d) / len(d) cy = sum(r[3] for r in d) / len(d) return {r[1]: (round(r[2] - cx, 3), round(r[3] - cy, 3)) for r in d} def ctrl_hand(pos, sz=None): u"""`C40` / `R40` / `R41` 跟着 `U4` 的引脚现算位置 —— 不手抄坐标。 · `C40` 100nF 去耦要贴 `p24(+3V3)` / `p21(GND)`:交给货架排布器它会落到区角上、 离电源脚 5 mm 以上,去耦离得远等于没有。 · `R40` / `R41` I²C 上拉贴 `p22(SCL)` / `p23(SDA)`。 ⛔ 三件不能各自贴各自的脚:`p22/p23/p24` 相邻只差 **0.65 mm**,而 0402 加装配净距 要 2 mm 才排得下 —— 第一版就是这么写的,自检直接报三处重叠。 ⇒ 排成**一排**,间距 `ROW_PITCH`,整排贴在 `U4` 本体那一侧的外面 (`本体半宽 + GAP + 件的半高`,从 `BODY['U4']` 现算,不写死)。 排布方向(南北两排 / 东西两列)从局部偏移反推,和计划的 rot 对不上就返回空, 让这三件回到 `CTRL` 区由排布器摆(下一趟 `--dump` + `--push` 自动归位)。 """ lp = u4_local_pins() if not lp or 'U4' not in pos: return {} ux, uy, urot = pos['U4'] rows = len(set(round(v[1], 2) for v in lp.values())) == 2 # 南北两排 if rows != (urot % 180 == 0): print(u' [!] _blade_pads.json 里 U4 还是上一版的朝向,C40/R40/R41 先交给排布器;' u'跑一次 pcb_route.py --dump 再 --push 就归位') return {} ROW_PITCH = 2.2 sz = sz or sizes() half_u = (BODY['U4'][1] if rows else BODY['U4'][0]) / 2.0 items = [('C40', '24'), ('R41', '23'), ('R40', '22')] # 顺序 = 脚的顺序 pins = [lp[q] for _r, q in items if q in lp] if len(pins) != len(items): return {} out = {} # 整排放在这几只脚所在的那一侧外面;件转 90°(长边垂直于本体边),半高 0.67 side = 1.0 if (pins[0][1] if rows else pins[0][0]) > 0 else -1.0 # ⚠ 半高要按**转 90° 之后**的 bbox 取(0402 转 90 是 0.54 宽 × 1.43 高,半高 0.715)—— # 写死 0.67 的那一版差 0.045 mm,自检报「R41 和 U4 重叠」。 half_sat = max(wh(r, 90 if rows else 0, sz)[1 if rows else 0] for r, _q in (('C40', '24'), ('R41', '23'), ('R40', '22'))) / 2.0 # ⚠ 试过「贴引脚排而不是本体框」(想把这三件塞到 U4 南边、脚的正下方):**不行**。 # TSSOP 的焊盘本身长 ~1.5 mm,脚心 ±3.02 加上焊盘就是 ±3.8,再加净距和件的半高, # 南边那一排会落到 y≈11.3 —— 而金手指禁布区上沿是 11.0,等于贴着禁区边缘, # 自检直接报「和 U4 重叠」。⇒ 仍按**本体框**让开,靠下面的退路走西边。 off = half_u + GAP + half_sat ctr = sum((q[0] if rows else q[1]) for q in pins) / len(pins) # 首选:贴着这三只脚所在的那条边,排成一排。 cand = [] for k, (ref, _q) in enumerate(items): t = ctr + (k - (len(items) - 1) / 2.0) * ROW_PITCH cand.append((ref, (round(ux + t, 3), round(uy + side * off, 3), 90) if rows else (round(ux + side * off, 3), round(uy + t, 3), 0))) # ⛔ 但这一排可能落进禁区:U4 挪到左下角之后,`p13~p24` 那条边朝南, # 往南 5.3 mm 就是**金手指区**(y < 11,插槽体要夹住)。 # ⇒ 退到**垂直的那条边**(离板边近的一侧),y 对齐引脚排,横着堆。 lim = B.ZONES['finger'][3] + 0.3 if rows and min(c[1][1] for c in cand) - 0.72 < lim: sx = -1.0 if ux < B.BW / 2.0 else 1.0 py = uy + (pins[0][1] if rows else 0.0) # 引脚排的 y cand = [(ref, (round(ux + sx * off, 3), round(py + k * ROW_PITCH, 3), 0)) for k, (ref, _q) in enumerate(items)] return dict(cand) # ── M7:按连线就近落位(货架排完之后,在**同尺寸**的位置之间重排)──────── PADS_JSON = os.path.join(HERE, '_blade_pads.json') SNAP_JSON = os.path.join(HERE, '_blade_pad_offsets.json') GRID = 0.25 # 和 router.py 的网格一致 # 平面网 / 巨网:不参与「往哪儿靠」的计算 # GND / VO_P / +12V 走铜面(`router.py` 的 SKIP / PLANE_VIA_NETS / ISLAND_NETS), # 把它们算进去等于把所有件都往铜面中心拽,没有意义。 PLANE_NETS = ('GND', 'VO_P', '+12V', '+12V_F') def size_class(wh_): u"""重排时哪些件可以互换位置。 ⛔ 不能按「尺寸完全相同」分类:`C14`(1.88×0.54) 和 `R12`(1.43×0.54) 差 0.45 mm 就换不了位置了,于是 `U2` 东侧那一列排成 p17→p19→p18→p20,两根线白白交叉一次。 ⇒ 只要都是**小贴片**(≤ 2.5 × 1.5)就归一类:货架格子是按 1.8 的节距留的 (1.43 + 1.8 = 3.23),塞个 1.88 的件进去仍有 1.35 的富余, 而真挤到了自检那一关(同面重叠,净距 0.6)会拦下来。 """ w, h = wh_ if w <= 2.5 and h <= 1.5: return 'SMALL' if h <= 2.5 and w <= 1.5: return 'SMALL90' return wh_ def net_peers(): u"""{位号: [(网名, [(对端位号, 焊盘局部偏移 dx, dy), ...])]},从 `_blade_pads.json` 反查。 ⚠ 存的是**对端焊盘相对它自己本体中心的偏移**,不是绝对坐标 —— 焊盘表是上一次 下发之后 dump 的,绝对坐标会过期;局部偏移只跟封装和旋转角有关。本体中心用 该件所有焊盘的**质心**近似(两排对称的封装上就是精确值)。 ⛔ 为什么非要精确到**焊盘**:按「器件中心」算目标点的话,`U2` 周围那 12 个外围件 的目标点全是同一个点,谁摆哪儿都一样 —— 排出来就是「没摆顺」:接东边 `p17~p20` 的件被扔到西边去,线要绕整颗芯片一圈。精确到焊盘之后 `R11`→`p17`、`C14`→`p18`、`R12`→`p19`、`R13`→`p20`,一列排下来顺序自然就对。 """ if not os.path.exists(PADS_JSON): return {} d = json.load(io.open(PADS_JSON, encoding='utf-8')) cen = {} for row in d: cen.setdefault(row[0], []).append((row[2], row[3])) npin = {r: len(ps) for r, ps in cen.items()} cen = {r: (sum(p[0] for p in ps) / len(ps), sum(p[1] for p in ps) / len(ps)) for r, ps in cen.items()} bynet = {} for row in d: ref, net = row[0], row[7] if not net or net in PLANE_NETS: continue cx, cy = cen[ref] bynet.setdefault(net, []).append((ref, round(row[2] - cx, 3), round(row[3] - cy, 3), npin[ref])) out = {} for net, items in bynet.items(): for r in set(it[0] for it in items): others = [it for it in items if it[0] != r] if others: out.setdefault(r, []).append((net, others)) return out def target_point(ref, peers, pos, ref_pt=None): u"""这个件「该往哪儿去」:每个网取**离参考点最近的那个对端焊盘**,再按网平均。 对端焊盘位置 = 对端件在本次方案里的中心 + 它的焊盘局部偏移 ⇒ 不吃过期坐标。 ⛔ 不用「同网所有对端焊盘的质心」:`C66` 是 `U5` 的 `+5V` 输入电容,而 `+5V` 这个网上 还挂着金手指 `J1.A19/A32`(在板子最南边)—— 质心被拽到 10 mm 外, 排出来「去耦离电源脚 9.9 mm」。取**最近的那一个**才是人排板的做法: 旁路电容跟着芯片的那只脚走,不管这个网还去了哪儿。 `ref_pt` 是「从哪儿看最近」,一般给所在区的中心;不给就用件当前位置(若已摆)。 """ if ref not in peers: return None if ref_pt is None: ref_pt = pos.get(ref, (0.0, 0.0))[:2] xs, ys = [], [] for _net, others in peers[ref]: cand = [(pos[q][0] + dx, pos[q][1] + dy, n) for q, dx, dy, n in others if q in pos] if not cand: continue # ⛔ 有芯片对端时**只认芯片**:`C11` 是 `U1` 的补偿电容,同网还挂着 `R6`, # 按「最近的对端」会跟着 `R6` 跑到 9 mm 外。旁路 / 补偿件要跟着**芯片的那只脚**。 ic = [c for c in cand if c[2] >= 4] pick = ic or cand q = min(pick, key=lambda c: math.hypot(c[0] - ref_pt[0], c[1] - ref_pt[1])) xs.append(q[0]) ys.append(q[1]) if not xs: return None return (sum(xs) / len(xs), sum(ys) / len(ys)) def reassign(pos, slots, peers): u"""在**同一区、同一尺寸**的一组货架位置之间重排,让每件离自己的对端更近。 先贪心(按「最想去的位置」排序取),再做 2-opt 交换到不能再降。 位置只在同尺寸之间换 —— 换了尺寸货架占位就错了,而**几何自检抓不到** (它只看重叠,不看这个位置当初是给谁留的)。 """ moved = 0 for (region, cls), items in sorted(slots.items(), key=lambda kv: (kv[0][0], str(kv[0][1]))): if len(items) < 2: continue refs = [r for r, _xy in items] pts = [xy for _r, xy in items] tgt = {r: target_point(r, peers, pos) for r in refs} if not any(tgt.values()): continue def cost(assign): c = 0.0 for r, k in assign.items(): t = tgt.get(r) if t: c += math.hypot(pts[k][0] - t[0], pts[k][1] - t[1]) return c order = sorted(range(len(refs)), key=lambda i: -(1 if tgt[refs[i]] else 0)) assign, used = {}, set() for i in order: r = refs[i] t = tgt.get(r) cand = sorted((k for k in range(len(pts)) if k not in used), key=lambda k: (math.hypot(pts[k][0] - t[0], pts[k][1] - t[1]) if t else k)) assign[r] = cand[0] used.add(cand[0]) improved = True while improved: improved = False for a in range(len(refs)): for b in range(a + 1, len(refs)): ra, rb = refs[a], refs[b] c0 = cost(assign) assign[ra], assign[rb] = assign[rb], assign[ra] if cost(assign) < c0 - 1e-9: improved = True else: assign[ra], assign[rb] = assign[rb], assign[ra] for r, k in assign.items(): if pos[r][:2] != pts[k]: moved += 1 pos[r] = (pts[k][0], pts[k][1], pos[r][2]) return moved def grid_snap(pos, only=None): u"""把货架排布器摆的件各挪 ≤ ±0.125 mm,让焊盘尽量落在 **0.25 mm 布线网格**上。 `router.py` 是 0.25 网格 A*,焊盘不在格点上就要先长一段**引出脚**; M6 欠的 5 只脚全是「出口被自家邻脚的引出脚封死」(方案 §26.6)。 焊盘落到格点上,这一类堵塞就没有了。 ⚠ 一个件只有一个 (dx, dy):**只有焊盘间距是 0.25 的整数倍时才能全对齐** (0402 两脚共线 ⇒ 能对齐一只;QFN 0.5 ⇒ 整排都能;TSSOP 0.65 / BM24 0.4 ⇒ 不能)。 判据是「对齐数变多」,不是「全对齐」。 ⛔ 手排件(功率链 `STACK_*` / `HAND`)和机械锚定件不许动: 前者的净距是按 §7.2 的回路算死的(Cin 堆只有 0.65),两件相向挪 0.125 就破 0.6 的装配下限; 后者的位置是小米8 主板定的。 """ if not os.path.exists(SNAP_JSON): return 0 offs = json.load(io.open(SNAP_JSON, encoding='utf-8')) n = 0 for ref, po in offs.items(): if ref not in pos or (only is not None and ref not in only): continue x, y, r = pos[ref] def best(vals): bd, bs = 0.0, None for k in range(-125, 126): d = k / 1000.0 sc = sum(abs((v + d) / GRID - round((v + d) / GRID)) for v in vals) if bs is None or sc < bs - 1e-9: bd, bs = d, sc return bd dx = best([x + o[0] for o in po]) dy = best([y + o[1] for o in po]) if dx or dy: pos[ref] = (round(x + dx, 3), round(y + dy, 3), r) n += 1 return n def snap_conflicts(pos, sz): u"""网格微调之后,哪些件把净距挤到了 `GAP` 以下 —— 这些退回原位。 ⛔ 不能一刀切「手排件不许动」:`U2` / `U1` 这些 0.5 间距的 QFN 恰恰**最需要** 落在格点上。也不能一刀切「都动」:Cin 堆的缝只有 0.65,两件相向挪 0.125 就破 0.6。 ⇒ 挪完再算一遍,只把真挤到的退回去。 """ bad = set() items = sorted(pos.items()) rect = {} for ref, (x, y, r) in items: w, h = wh(ref, r, sz) rect[ref] = (x - w / 2, y - h / 2, x + w / 2, y + h / 2) for i in range(len(items)): for j in range(i + 1, len(items)): a, b = items[i][0], items[j][0] ra, rb = rect[a], rect[b] dx = max(ra[0] - rb[2], rb[0] - ra[2]) dy = max(ra[1] - rb[3], rb[1] - ra[3]) if dx < GAP and dy < GAP and (dx < 0 or dy < 0 or True): if max(dx, dy) < GAP: bad.add(a) bad.add(b) return bad def bands(): u"""每个组占的 y 带 —— L3 切铜面按这个来,别手抄坐标。""" pos, _r, _u, sz = plan() out = [] for region, name, refs in GROUPS: ys = [(pos[r][1] - wh(r, pos[r][2], sz)[1] / 2, pos[r][1] + wh(r, pos[r][2], sz)[1] / 2) for r in refs if r in pos] if ys: out.append((region, name, round(min(a for a, _ in ys), 2), round(max(b for _, b in ys), 2))) return out def selfcheck(pos, rects, unplaced, sz): bad = [] if unplaced: bad.append(u'放不下: %s' % ', '.join(u'%s(%s)' % t for t in unplaced)) d = json.load(io.open(BBOX, encoding='utf-8')) missing = sorted(set(d) - set(pos)) if missing: bad.append(u'没安排位置: %s' % ' '.join(missing)) bot = set(bottom_refs(pos)) # 重叠。⚠ K1~K4 例外:四个盘的位置是**主板定的**(实测最小间距 1.78 mm、 # 盘 Ø1.5 ⇒ 净距 0.28),通用的 GAP=0.6 会把它们互判成重叠。 # 它们之间改按「铜净距 ≥ 0.2」单独校核。 # ⚠ **只判同一面**:A 面的 J4/J5/C60 和 B 面的 USB/阻尼组是**故意重叠**的 # (正背面),跨面判重叠会把这一版的核心设计意图全报成错。 import math keys = ['K1', 'K2', 'K3', 'K4'] rmap = {} for ref in pos: if ref in keys: continue x, y, rr = pos[ref] w, h = wh(ref, rr, sz) rmap[ref] = (x - w / 2 - GAP / 2, y - h / 2 - GAP / 2, x + w / 2 + GAP / 2, y + h / 2 + GAP / 2) rl = sorted(rmap.items()) for i in range(len(rl)): for j in range(i + 1, len(rl)): if (rl[i][0] in bot) != (rl[j][0] in bot): continue a, b = rl[i][1], rl[j][1] if a[0] < b[2] - 1e-6 and b[0] < a[2] - 1e-6 and \ a[1] < b[3] - 1e-6 and b[1] < a[3] - 1e-6: bad.append(u'%s 和 %s 重叠' % (rl[i][0], rl[j][0])) PAD_D = 1.5 for i in range(len(keys)): for j in range(i + 1, len(keys)): ax, ay, _ = pos[keys[i]] bx, by, _ = pos[keys[j]] gap = math.hypot(ax - bx, ay - by) - PAD_D if gap < 0.2: bad.append(u'%s/%s 铜净距 %.2f < 0.2' % (keys[i], keys[j], gap)) # 全部在板内 for ref, (x, y, rr) in pos.items(): w, h = wh(ref, rr, sz) if ref == 'J1': continue if x - w / 2 < 0.5 or x + w / 2 > B.BW - 0.5 or \ y - h / 2 < 0.5 or y + h / 2 > B.BH - 0.5: bad.append(u'%s 出板框: (%.2f,%.2f) %.1fx%.1f' % (ref, x, y, w, h)) # 金手指区(y<11)除 J1 外一件不许有 —— 两面都是(插槽体夹住的是整块板) for ref, (x, y, rr) in pos.items(): if ref == 'J1': continue if y - wh(ref, rr, sz)[1] / 2 < 11.0: bad.append(u'%s 侵入金手指区(y=%.2f,插槽体要 ~9 mm)' % (ref, y)) # ⛔ 铣穿的排线槽:**两面都不许有件**(摆进去等于摆在空气里); # B 面的排线车道也一件不放(排线要平贴着走到连接器)。 for ref, (x, y, rr) in pos.items(): w, h = wh(ref, rr, sz) for (a, b, c, d2) in B.mill_cuts(): if x + w / 2 > a - MILL_KEEP and x - w / 2 < c + MILL_KEEP and \ y + h / 2 > b - MILL_KEEP and y - h / 2 < d2 + MILL_KEEP: bad.append(u'%s 落进铣穿的排线槽(那里没有板子)' % ref) break if ref in bot and ref not in ('J4', 'J5'): for k, (a, b, c, d2) in lanes().items(): if x + w / 2 > a and x - w / 2 < c and y + h / 2 > b and y - h / 2 < d2: bad.append(u'%s 压在 %s 排线车道上' % (ref, k)) break # A 面的手机投影里只许 K1~K4(螺柱间隙只有 1.0 mm)。 # ⚠ **B 面不受这一条约束** —— 这正是这一版把器件翻过去的原因。 px0, py0 = PHONE_ORG px1, py1 = px0 + PHONE_WH[0], py0 + PHONE_WH[1] for ref, (x, y, rr) in pos.items(): if ref.startswith('K') or ref in bot: continue w, h = wh(ref, rr, sz) if x + w / 2 > px0 and x - w / 2 < px1 and y + h / 2 > py0 and y - h / 2 < py1: bad.append(u'%s 在 A 面落进手机投影(螺柱间隙只有 1.0 mm)' % ref) # ⛔ B 面高度预算分两段(模块头的表)。超高件进手机投影会顶到隔壁槽的手机主板, # 而这件事几何自检和 DRC **一条都不报**。 for ref, hh in HEIGHT.items(): if ref not in bot: continue x, y, rr = pos[ref] w, h = wh(ref, rr, sz) in_phone = y + h / 2 > py0 and x + w / 2 > px0 and x - w / 2 < px1 lim = H_B_PHONE if in_phone else H_B_SVC if hh > lim: bad.append(u'%s 高 %.1f mm 放在 B 面%s(净空只有 %.1f)' % (ref, hh, u'手机投影下' if in_phone else u'服务带', lim)) # ⛔ 螺钉头避让:任何元件离主板定位孔中心都要 >= HOLE_KEEP。 # M4c 第一版把这七个孔整个忘了 —— 高度预算和 K1~K4 的接触压力全靠它们。 # ⚠ 两面都要让:螺钉过孔是 NPTH,头在 B 面、尼龙立柱在 A 面。 for hname, (hx, hy) in B.phone_holes().items(): for ref, (x, y, rr) in pos.items(): w, h = wh(ref, rr, sz) dx = max(abs(hx - x) - w / 2.0, 0.0) dy = max(abs(hy - y) - h / 2.0, 0.0) if math.hypot(dx, dy) < B.HOLE_KEEP: bad.append(u'%s 离定位孔 %s 只有 %.2f < %.1f(螺钉头压上去)' % (ref, hname, math.hypot(dx, dy), B.HOLE_KEEP)) # C60 不许在手机底下(已由上一条覆盖),另外校核它确实在服务带/功率列 cx, cy, _ = pos['C60'] if not (B.ZONES['finger'][3] - 1e-9 <= cy - BODY['C60'][1] / 2 and cy + BODY['C60'][1] / 2 <= B.PHONE_ORG[1] + 1e-9): bad.append(u'C60 不在服务带里(B 面只有服务带有 18.7 的净空)') return bad OPEN_JS = (u"await eda.dmt_EditorControl.openDocument('%s');" u"await new Promise(r=>setTimeout(r,2500));") # ⚠ **必须分批**:桥接服务端的单请求上限是**硬编码的 30 s** # (`bridge-server.mjs` 的 `REQUEST_TIMEOUT_MS`,客户端 `timeout=` 调不动它)。 # 83 件 × 2 次 modify 一口气发过去必超时,而且**超时不等于没执行** —— # 客户端还在跑,只是结果回不来,于是「读回校验」会看到做了一半。 PUSH_JS = u""" const MIL = %s, P = %s, BOT = %s, ONLY = %s; const bot = new Set(BOT), only = new Set(ONLY); let n = 0, miss = []; for (const c of await eda.pcb_PrimitiveComponent.getAll()) { const d = c.designator; if (!only.has(d)) continue; if (!P[d]) { miss.push(d); continue; } const id = c.getState_PrimitiveId(); await eda.pcb_PrimitiveComponent.modify(id, {layer: bot.has(d) ? 2 : 1}); await eda.pcb_PrimitiveComponent.modify(id, {x: P[d][0]*MIL, y: P[d][1]*MIL, rotation: P[d][2]}); n++; } await eda.pcb_Document.save(); return {n, miss}; """ BATCH = 12 def push(pos): r = run(OPEN_JS % PCB + 'return 1;', timeout=120) if not r.get('success'): sys.exit(json.dumps(r, ensure_ascii=False)[:600]) bot = json.dumps(bottom_refs(pos)) refs = sorted(pos) done, miss = 0, [] for k in range(0, len(refs), BATCH): chunk = refs[k:k + BATCH] r = run(PUSH_JS % (MIL, json.dumps(pos), bot, json.dumps(chunk)), timeout=120) if not r.get('success'): sys.exit(json.dumps(r, ensure_ascii=False)[:600]) done += r['result']['n'] miss += r['result']['miss'] print(u' %d/%d' % (done, len(refs))) print(u'已摆 %d 件;没找到位置的: %s' % (done, miss or u'无')) def verify(pos, sz): r = run("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2000));" "return (await eda.pcb_PrimitiveComponent.getAll()).map(" " c=>[c.designator, c.x, c.y, c.rotation, c.layer]);" % PCB, timeout=300) if not r.get('success'): sys.exit(json.dumps(r, ensure_ascii=False)[:600]) got = {a[0]: (a[1] / MIL, a[2] / MIL, a[3], a[4]) for a in r['result']} bot = set(bottom_refs(pos)) bad = [] for ref, (x, y, rot) in pos.items(): if ref not in got: bad.append(u'板上没有 %s' % ref) continue gx, gy, grot, glay = got[ref] if abs(gx - x) > 0.02 or abs(gy - y) > 0.02: bad.append(u'%s 在 (%.2f,%.2f),应为 (%.2f,%.2f)' % (ref, gx, gy, x, y)) want_lay = 2 if ref in bot else 1 if glay != want_lay: bad.append(u'%s 在 layer %s,应为 %s' % (ref, glay, want_lay)) print(u'读回 %d 件(A 面 %d / B 面 %d)' % (len(got), sum(1 for v in got.values() if v[3] == 1), sum(1 for v in got.values() if v[3] == 2))) if bad: print(u'[X] %d 条不过:' % len(bad)) for b in bad[:20]: print(u' ' + b) else: print(u'[OK] %d 件位置 / 层 全部对上' % len(got)) return bad if __name__ == '__main__': pos, rects, unplaced, sz = plan() bad = selfcheck(pos, rects, unplaced, sz) bot = set(bottom_refs(pos)) print(u'安排了 %d 件(含 J1):A 面 %d / B 面 %d' % (len(pos), len(pos) - len(bot), len(bot))) for region, name, refs in GROUPS: xs = [pos[r] for r in refs if r in pos] if xs: print(u' %-11s %-24s x %5.1f~%5.1f y %5.1f~%5.1f %d 件' % (region, name, min(p[0] for p in xs), max(p[0] for p in xs), min(p[1] for p in xs), max(p[1] for p in xs), len(xs))) print(u' A 面留守: ' + ' '.join(u'%s(%.1f,%.1f)' % (k, pos[k][0], pos[k][1]) for k in TOP_ONLY)) if bad: print(u'[X] 自检不过 (%d):' % len(bad)) for b in bad[:20]: print(u' ' + b) if '--push' in sys.argv: sys.exit(1) else: print(u'[OK] 自检通过:同面无重叠 / 全在板内 / 金手指区干净 / 槽与车道内无件 /' u' A 面只有 K1~K4 / B 面高度分段达标 / 七个螺钉头都让开了') if '--push' in sys.argv: push(pos) verify(pos, sz) elif '--verify' in sys.argv: verify(pos, sz)