文件

1116 行
64 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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)