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