文件
phonerail/.claude/r2/_place_base_m8.py.bak
zhou yang和Claude Opus 5 efcba30a0c R2 底板改 4 层(D15):规则 / 几何 / 背面摆位 / 4 层布线与铺铜,已下发
- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base)
- 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base)
- 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、
  东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base)
- 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面
- 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、
  J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder
  开关(route_base、remote_route.ps1);剩 37 个网待手工补
- 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base);
  已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查)
- 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
2026-09-10 21:02:10 +08:00

773 行
38 KiB
Plaintext

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