文件

1451 行
78 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""R2 底板 PCB 摆位(M5,4 层 98 × 98,D15):五个插槽在 L1,其余 133 件全部翻到 L4。
/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 --native # 由板上焊盘 + 位姿重建 _base_native_pads.json
2 层版(南带 / 西区 / 北带、ROUTE_FIX / USER_INPUT)整份在 `archive_base2L/place_base.py`。
## 背面镜像(2026-09-10 实测:U13 翻面逐 rot 读焊盘)
封装原生偏移 (dx, dy) 在底层变成 **R(rot)·(dx, −dy)**:
rot 0 → (dx, −dy);90 → (dy, dx);180 → (−dx, dy);270 → (−dy, −dx)。
`pad_off()` 就按这一条算 —— 卫星件瞄的是**翻面之后真实的脚位**,不是芯片质心。
原生偏移表 `_base_native_pads.json` 由「板上焊盘 + 板上位姿」反推(`--native`),下发后要重建。
## 朝向(几何验证抓不到、错了要重画的那几条)—— `verify()` 用板上读回焊盘逐条断言
1. **U32 底层 rot 270**:下行口朝**北**,西 → 东 = USB1 … USB5;XTAL / 上行 / USB6 在**西**侧;
strap 在南;VDD18 在东。USB1/USB2 直接北上进 J34/J35;USB3~5 从北沿出来往东,
绕 U32 东侧(那一边除 VDD18 / 一只 3V3 外全是 NC)南下。
上行 / USB6 在西侧打孔上 L1,从 U32 顶上往东走(L1 走廊是空的),不和 D± 同层交叉。
⛔ 顶层那种「朝西、1 脚在北」**底层做不出来**:底层 rot 0 = 朝西但 1 脚在南(五对全交叉),
rot 180 = 朝东但晶振 / 上行在北沿,北沿到槽 J35 只剩 0.9 mm。
2. **U20 底层 rot 90**:天线(封装 +y 端)朝东伸进缺口;EFUSE_EN1~5 / PRSNT5# 那排朝西对着槽阵。
3. **J1 / J23 底层 rot 180**(同一颗 TYPE-C-31-M-12):口朝南、信号脚朝北。J1 = D16 取电口(2026-09-11 起,原 DC-005)。
4. **U37 底层 rot 270**(D16 LM61495):SW 边朝**西**接 L4、信号边(FB/RT/VCC/EN)朝**东**;
**L4 底层 rot 180**:SW 端(1 脚)朝东贴 U37,VIN_SW 端朝西落在 C2/C5 那边。
为什么这样转:U37 两侧 VIN 要连通,TI 做法是走另一层(用户选 L1 VIN_FUSED 小岛);信号边朝东,
FB 采样线才能沿东侧空带北上回 VIN_SW,不用跨 VIN(设计记录 R2 §36.5)。
## D± 长度 → Hub 放走廊 2(2026-09-10 用户拍板)
分区草图原本 Hub 在走廊 3、mux 在走廊 2:USB3~5 三对都要绕 U32 东侧,最远 USB5 ≈ 80 mm。
对调之后只有 USB4/5 绕行,估算 45 / 23 / 3 / 40 / 58 mm。方案 §6.3 的 45 mm 只有把 U32
放进中间槽正下方的过孔带才做得到(没选);2 层版是 ~70 mm。`report()` 每次都打出估算值。
上面第 1 条写的「USB1/USB2 北上」是走廊 3 的情形;走廊 2 时北上的是 USB1~3,绕行的是 USB4/5。
"""
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')
NATIVE = os.path.join(HERE, '_base_native_pads.json')
POS_JSON = os.path.join(HERE, '_base_place.json')
ROT_JSON = os.path.join(HERE, '_base_rotations.json')
LIVE_POS_JSON = os.path.join(HERE, '_base_place_live.json')
GAP = 0.6 # 同面件间净距(装配下限,自检按它判)
# 顶面件(`pcb_base.TOP_EXTRA`)避让刀片:刀片竖在 y = 槽心,板厚 1.6 ⇒ 板面占 ±0.8。
# ⚠ 只管横向:刀片自己 y 0~12 是金手指禁布区(两面都没件)⇒ 顶面这几件的高度
# (11~12)不受限,撞不撞全看横向让没让开。**结论对,但原来写的理由是错的**:
# ⛔ 2026-09-12 晚更正:原注释说「刀片底边被插槽抬到板上约 11 mm」—— 11.56 是**插槽
# 塑料体自己的高度**,被当成了离板高度。SolidWorks 实测:塑料体顶面在底板局部
# z=12.33、板面 z=1.67、插入深度 d=**7.45** ⇒ **刀片底边只在 z≈4.88**(离板面 3.21)。
# (d 被刀片缺口深 NOTCH_D=7.45 卡住,不是槽底 8.00 —— 防呆键筋是通到槽底的全高。)
# 所幸结论不变:顶面件最高 13.17 ⇒ 只够到刀片局部 y ≤ 8.29,仍在 0~12 禁布区内。
# (刀片本体下沿最低的 U4 在 y=11.75,换算到底板 z=16.63,比 13.17 高 3.46。)
# ⇒ **派生上限**:顶面件一旦高过 z=16.88(板面以上 15.21)就会够到刀片器件带,
# 届时横向要按「槽心 −0.835 ~ +5.535」判(器件面朝 +y),而不是下面这个对称的 ±1.335。
# 现状最高 13.17,余量 3.71。
CARD_HALF = 0.8
CARD_CLR = 0.5 # 顶面件本体到刀片板面的净距
# 卫星件(排布器摆的小件)之间的**排布**间距。0.6 只是装配下限,不给布线留余量:
# 第六 / 七轮剩下的信号网全卡在 U20 北沿那排上拉 / NTC 阻容、U32 西侧、mux 区 —— 0402 按 0.6 一字排开,
# 两只脚、它们的 GND 孔和进出的线全挤在一起(2026-09-10 用户选定:加大小件间距重排)。
# 排不下时退回 GAP(`plan()` 里第二遍),所以不会出现「排不下」。
SAT_GAP = 1.5
# `--fan-gap <mm>`:只在 `--only-fan` 时加大这一窝的排布间距。
# ⛔ 2026-09-12 实测:22 件按 1.5 排在两只座周围,14 个网在 L4 上**布不下**
# (L1 那一带整片是 12V 大铜、上不去,等于单层)。失败的脚周围往往只有一两个障碍、
# 出口格点也合法 —— 不是「出不来」,是真没路。A6 分区当时只用掉 13% 的面积 ⇒ 摊开来排。
if '--fan-gap' in sys.argv:
SAT_GAP = float(sys.argv[sys.argv.index('--fan-gap') + 1])
STEP = 0.25 # 候选格点 = 布线网格
ANCHOR_AREA = 9.0 # 焊盘外框面积 > 这个算「锚」(Y1 10.4 要算进来:负载电容贴晶振)
# ── 高度(mm):背面限高 B.H_BOTTOM ─────────────────────────────────────
HEIGHT = {
'J1': 3.3, # USB-C(D16,同 J23)
'L4': 6.5, 'D3': 2.4, # SRP1265A / SMB(D16)
'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,
'U20': 3.1, 'L3': 4.5, 'L2': 4.0, 'F1': 2.0,
}
H_DEFAULT = 2.0
# 焊盘外框量不到本体的件:真实本体(mm,封装原生朝向)。
BODY = {
'J1': (9.0, 7.5), # USB-C(D16,同 J23)
'L4': (12.6, 12.5), # SRP1265A-4R7M 本体;焊盘外框 15 × 5 另由 bbox 取大
'C2': (8.0, 8.0), 'C3': (8.0, 8.0),
'J23': (9.0, 7.5),
'J26': (10.2, 5.0), 'J27': (10.2, 5.0),
'J33': (12.7, 2.54),
'L3': (7.6, 6.5),
'U20': (19.0, 19.0), # 焊盘框;天线 7.5 mm 另由 antenna_keepout 管
}
# 本体中心相对器件原点的偏移(封装原生坐标)。J1 与 J23 同料(D16 前 J1 是 DC-005:(-3.38, 0))。
BODY_OFF = {'J1': (0.0, -1.5), 'J23': (0.0, -1.5)}
# 本体越过 / 贴着板边才对的接口件:值 = 开口朝哪条边
EDGE_PARTS = {'J1': 'S', 'J23': 'S'}
# 南 → 北 = 物理槽位 1..5。USB1 = J34 = 最北(pcb_base.USB_SLOT_REF)。
SLOT_ORDER = ['J38', 'J37', 'J36', 'J35', 'J34']
# ── Hub 坐哪条走廊 ─────────────────────────────────────────────────────────
# 2 = **定稿**(2026-09-10 用户拍板,与分区草图相比 Hub / mux 对调):USB1~3 北上,
# 只有 USB4/5 绕 U32 东侧南下,D± 最远 ≈ 58 mm。
# 3 = 分区草图原样(`--hub3`,只留作对比):USB3~5 都要绕,最远 USB5 ≈ 80 mm。
# 两条走廊正好差一个槽距,对调 = Hub 那一窝整体 −20.32、mux 那一窝 +20.32。
HUB_K = 3 if '--hub3' in sys.argv else 2
MUX_K = 5 - HUB_K
_CH, _CM = B.corridor(HUB_K), B.corridor(MUX_K)
# x:走廊 3 时 USB2(−2.5)正对北邻槽 J35 的 D±(47.5/48.5)。
# 走廊 2 时 **东移 2.5 mm(49.0 → 51.5)**,让 USB3 的脚(50.25/50.75)落在 J36 D± 的东边:
# 北去三对极性都是反的(U32 北沿 DM 在西、槽上 D+ 在西),换位靠一次 U 形掉头(左右自然对调)。
# USB3 的目标槽就在正上方 1 mm,脚正对着焊盘就没有掉头的地方(第二轮服务器实测:
# 换位绕在北沿上把 USB1_DP / USB4_DM 封死)。东移后它从 J36 南排底下北上、在两排之间向西掉头、
# 打孔上 L1 回到焊盘 —— 见 route_base.usb3_uturn()。
HUB_CX = 50.5 if HUB_K == 3 else 51.5
# y:比走廊中心南移 0.26,北沿到北邻槽焊盘留 0.9 给那一对上 L1 的过孔
HUB_CY = round((_CH[1] + _CH[3]) / 2.0 - 0.26, 3)
MUX_CY = round((_CM[1] + _CM[3]) / 2.0, 3)
ZONES = dict(B.ZONES)
ZONES['A4H'] = (28.5, _CH[1], 69.5, _CH[3])
ZONES['A4M'] = (28.5, _CM[1], 69.5, _CM[3])
# ── 布线车道(摆位禁区,只在两条放件的走廊里)──────────────────────────────
# I²C:U35(mux 走廊西段)→ 另一侧那两槽的 SCL/SDA(x 36.5/37.5 在南排),要穿过 Hub 走廊。
# DP_H:绕 U32 东侧南下的那几对。DP_M:穿过 mux 走廊的那几对(回到 x≈48 附近)。
# U32_WVIA:上行 / USB6 在 U32 西侧打孔上 L1 的落点。
LANES = {
'I2C_H': (28.5, _CH[1], 31.5, _CH[3]),
'I2C_M': (28.5, _CM[1], 31.5, _CM[3]),
# U32 东侧焊盘外沿 + 1.2 扇出圈之后,再留 ~1.9 mm 给 VDD18 / 3V3_HUB 去耦(它们在 USB4/5 绕行环的**里面**)
'DP_H': (round(HUB_CX + 8.0, 3), _CH[1], round(HUB_CX + 12.0, 3), _CH[3]),
'DP_M': (46.5, _CM[1], 51.5, _CM[3]) if HUB_K == 3 else (44.0, _CM[1], 49.0, _CM[3]),
'U32_WVIA': (round(HUB_CX - 6.6, 3), round(HUB_CY - 2.3, 3),
round(HUB_CX - 5.4, 3), round(HUB_CY + 1.7, 3)),
}
# J23 针排北侧的出口带:D±(含两座同名脚的桥,route_base.j23_bridges)/ CC / VBUS 全从这里往北出。
# ⛔ 不留的话排布器会把 CC 下拉 R717 摆在针排正北 —— 布线静态校验实测 DM 桥离 R717 净距 −0.075。
# J23 底层 rot 180 在 (84.0, 5.25):信号针排 y = 7.72 ± 0.65(封装原生 +2.47),x 80.8~87.2。
J23_XY = (84.0, 5.25)
LANES['J23_ESC'] = (J23_XY[0] - 4.2, J23_XY[1] + 3.15, J23_XY[0] + 4.2, J23_XY[1] + 5.05)
# Rs6.2 西侧 9 A 上 L1 的过孔阵(route_base.V12_PATCH / V12_VIA_*):只禁过孔那一段,不含 Rs6 本体。
# ⛔ 以前只写在布线脚本里,摆位不知道要避开 —— 小件间距放大重排之后 R6(BLD_A)被挤进去,
# 布线静态校验实测净距 −0.55(v12_handoff 拦下,服务器那轮没跑)。
LANES['V12_VIAS'] = (5.3, 44.4, 10.55, 48.05)
# ── 扇出圈(2026-09-10 用户选定:芯片扇出上 L1 + U32 按构造画)─────────────────
# 器件全在背面,芯片的脚、它的去耦 / strap、这些件的 GND 孔全挤在 L4 同一圈里 ——
# 第三轮服务器 198 条里绝大多数是这几颗「脚出不去」。芯片正上方的 L1 是空的,
# 但要在脚边落得下过孔才上得去 ⇒ 焊盘外框外留一圈别的件不许进,给扇出过孔。
FAN_RING = {'U32': 1.2, 'U35': 1.6, 'U6': 1.6, 'U7': 1.6, 'U20': 1.6}
def fan_rings(sz, pos):
u"""[(主人, 矩形)]:主人自己不受它约束。"""
out = []
for ref, m in FAN_RING.items():
if ref in pos:
x, y, rot = pos[ref][:3]
r = body_rect(ref, x, y, rot, sz)
out.append((ref, (r[0] - m, r[1] - m, r[2] + m, r[3] + m)))
return out
# ── U32 卫星件逐脚手排(2026-09-10 深夜,按 Microchip USB2517 硬件检查清单)────────────────
# 走廊 2 净高 11.12,U32 自己占 9.75 ⇒ 北 / 南两边都贴着插槽过孔带,**件只能放在 U32 的西 / 东两侧**;
# 西沿中段 53~59 脚又是上行 / USB6 的过孔车道(LANES['U32_WVIA'])。所以:
# 西北角(60~64 脚):C743 VDD18PLL 1µF / R708 RBIAS / C746 VDD33PLL 一列紧贴,各自独立地孔(列的西侧);
# 晶振 Y1 rot 0(X1 西北、X2 东南)退到这一列西边,XTAL2 从过孔车道上沿平着进 X2,
# XTAL1 从 Y1 的两排焊盘之间穿过去进 X1;C740 / C741 / R723 1MΩ 围着 Y1 成团。
# 西南角:C744(52 脚)/ R726(47,南排)一列,51 / 49 脚的线从两颗之间向西穿过去接 R724 / R725;
# 57 脚的 C745 在过孔车道西边(线从 USB6 / 上行两组过孔之间斜下去)。
# 东侧一列:R727(33)/ R728(31)/ C742 VDD18 1µF(25)/ C748(24)/ R729(17),信号脚朝西、地孔在东。
# 19 / 13 脚是 GND,从焊盘内端直接接散热盘。
# ⭐ 2026-09-11 用户拍板:**放开 U32 正南(J37 北排投影)/ 正北(J36 南排投影)两条窄带给卫星件**(HUB_STRIPS)。
# 南带:C749(46 脚 VDD33)正对 46 脚;45 → 40 脚的件往东排成「阶梯」—— 每只脚先下到自己那一层
# (40 脚最高 33.15、每往西一脚低 0.30),向东走到自己的件再落进上脚,互不交叉;
# R709 挂在 44 脚那一层的延长线上(与 R710 同网 VBUS_DET)。最西一颗 R720 下沿到 y 30.27(进 J37 两排之间约 1 mm)。
# 这一段 J37 北排槽针的过孔改打到两排之间(它们本来就多是 GND)。
# 北带:C750 / C747 正对 5 / 10 脚(VDDA33),D± 出脚后各让 ~0.06 mm;电容地脚朝北,
# **地孔打在正上方 J36 南排的 GND 针里(49.5 / 52.5,盘中孔)**—— 第 4 阶段画 D± 构造时一起定。
# 值 = (dx, dy 相对 U32 中心, 'h' 横放 / 'v' 竖放 / rot, 朝向用的网, 朝向目标)
# 朝向目标:'U32.<脚>' = 那只脚;('pt', dx, dy) = 相对 U32 中心的点;'Y1.<脚>' = 晶振的脚。
# 间距:`sizes()` 把本体最小边抬到 0.6,同面净距 GAP 0.6 ⇒ 0402 并排的中心距 ≥ 1.21。
HUB_SAT = {
# 西北列(x −6.20)
'C743': (-6.20, 3.06, 'h', 'VDD18PLL', 'U32.62'),
'R708': (-6.20, 4.27, 'h', 'RBIAS', 'U32.63'),
'C746': (-6.20, 5.48, 'h', '+3V3_HUB', 'U32.64'),
# 晶振团
'Y1': (-9.40, 2.47, 0, None, None),
'C740': (-10.50, 4.83, 'h', 'XTAL1', 'Y1.1'),
'C741': (-8.30, 0.10, 'h', 'XTAL2', ('pt', -10.0, 0.10)), # XTAL2 脚朝西,R723 从西边接
'R723': (-10.35, 0.10, 'h', 'XTAL2', ('pt', -8.0, 0.10)), # XTAL2 脚朝东接 C741,XTAL1 从 Y1 西侧下来
# 西南
'C745': (-7.50, -1.12, 'h', '+3V3_HUB', 'U32.57'),
'C744': (-6.20, -2.76, 'h', '+3V3_HUB', 'U32.52'), # 北边 0.19 是 USB6_DM 的西侧孔(第 4 阶段静态校验)
'R726': (-6.20, -4.98, 'h', '$4N598', 'U32.47'),
'R724': (-8.30, -3.23, 'h', '$4N596', ('pt', -6.0, -3.23)),
'R725': (-8.30, -4.43, 'h', '$4N597', ('pt', -6.0, -4.43)),
# 南带(竖放,信号脚朝北)
'C749': (-2.75, -6.16, 'v', '+3V3_HUB', 'U32.46'),
'R720': (-1.54, -7.59, 'v', 'NONREM0', ('pt', -1.54, 0.0)),
'R710': (-0.33, -7.29, 'v', 'VBUS_DET', ('pt', -0.33, 0.0)),
'R709': (0.88, -7.29, 'v', 'VBUS_DET', ('pt', 0.88, 0.0)),
'R716': (2.09, -6.99, 'v', 'HUB_RST', ('pt', 2.09, 0.0)),
'R712': (3.30, -6.69, 'v', 'CFGSEL1', ('pt', 3.30, 0.0)),
'R711': (4.51, -6.39, 'v', 'CFGSEL0', ('pt', 4.51, 0.0)),
'R719': (5.72, -6.20, 'v', 'NONREM1', ('pt', 5.72, 0.0)),
# 北带(竖放,+3V3_HUB 脚朝南对着脚)。第 4 阶段按 D± 构造定位(route_base.u32_north / dp_construct):
# 5 / 10 脚除了去耦还要**供电**:脚的线一路北上到 J36 两排之间打孔上 L1(窄带正上方是 J36 南排焊盘,
# 孔只能落在两排之间),电容偏到这根线的一侧、横接一小段。邻脚 D± 脚距 0.5,所以电容要等 D± 拐开再放:
# C750 在 5 脚西侧:4 脚(USB2_DM)44.505 起 45° 西去,电容西南角离那根斜线 ≥ 0.29 ⇒ 下沿 ≥ 45.235;
# C747 在 10 脚东侧:11 脚(USB5_DM)44.255 东拐,电容压在它上面 ⇒ 下沿 ≥ 44.58。
'C750': (-2.30, 7.33, 'v', '+3V3_HUB', 'U32.5'),
'C747': (1.45, 6.67, 'v', '+3V3_HUB', 'U32.10'),
# 东列(x +6.20)
'R727': (6.20, -4.40, 'h', '$4N602', 'U32.33'),
'R728': (6.20, -3.18, 'h', '$4N603', 'U32.31'),
'C742': (6.20, -0.38, 'h', 'VDD18', 'U32.25'),
'C748': (6.20, 0.83, 'h', '+3V3_HUB', 'U32.24'),
'R729': (6.20, 3.87, 'h', '$4N604', 'U32.17'),
# 大容量 10µF(0805)放 DP_H 车道东边,+3V3_HUB 从 U31 过来的入口
'C751': (14.00, -2.08, 'h', '+3V3_HUB', ('pt', 0.0, -2.08)),
}
# 允许 HUB_SAT 件压进插槽过孔带的范围(用户 2026-09-11 拍板;南带含阶梯最西那颗伸进 J37 两排之间的 ~1 mm)
HUB_STRIPS = [(44.0, 30.2, 58.2, 34.0), (44.0, 43.4, 58.0, 46.7)] # 各含 ~0.7 mm 走廊边,件跨在分界线上
# 南带里 40~45 脚「阶梯」的 R 系列件:pin → 件(report 用)
HUB_SAT_EXTRA_PIN = {'R720': '45', 'R710': '44', 'R709': '44', 'R716': '43', 'R712': '42',
'R711': '41', 'R719': '40', 'R724': '51', 'R725': '49'}
HUB_SAT_PIN = {k: v[4][4:] for k, v in HUB_SAT.items() if isinstance(v[4], str) and v[4].startswith('U32.')}
HUB_SAT_PIN.update(HUB_SAT_EXTRA_PIN)
HUB_REFS = set(HUB_SAT) | {'U32'}
def hub_sats(nat):
u"""{位号: (x, y, rot)}:按 HUB_SAT 逐件算,朝向由 pick_rot 按「朝向用的网那只脚离目标最近」挑。"""
out = {}
u32 = {pn: (px, py) for pn, _n, px, py in pads_at(nat, 'U32', HUB_CX, HUB_CY, 270)}
for ref in ('Y1',) + tuple(k for k in HUB_SAT if k != 'Y1'):
dx, dy, ori, net, tgt = HUB_SAT[ref]
x, y = round(HUB_CX + dx, 3), round(HUB_CY + dy, 3)
if not isinstance(ori, str):
out[ref] = (x, y, ori)
continue
if isinstance(tgt, tuple):
pt = (HUB_CX + tgt[1], HUB_CY + tgt[2])
elif tgt.startswith('U32.'):
pt = u32[tgt[4:]]
else: # 'Y1.<脚>'
r0, p0 = tgt.split('.')
yx, yy, yr = out[r0]
pt = [(px, py) for pn, _n, px, py in pads_at(nat, r0, yx, yy, yr) if pn == p0][0]
out[ref] = (x, y, pick_rot(nat, ref, x, y, net, pt, (0, 180) if ori == 'h' else (90, 270)))
return out
def hub_report(pos, nat=None):
u"""卫星件信号脚 → U32 目标脚的焊盘中心距(mm)。"""
nat = nat or native()
ux, uy, ur = pos['U32']
u32 = {pn: (px, py) for pn, _n, px, py in pads_at(nat, 'U32', ux, uy, ur)}
rows = []
for ref, pn in sorted(HUB_SAT_PIN.items(), key=lambda t: int(t[1])):
net = HUB_SAT[ref][3]
x, y, r = pos[ref]
pp = [(px, py) for _pn, n, px, py in pads_at(nat, ref, x, y, r) if n == net]
d = min(math.hypot(px - u32[pn][0], py - u32[pn][1]) for px, py in pp)
rows.append((ref, pn, net, d))
return rows
def zone_at(x, y):
for k, z in ZONES.items():
if z[0] <= x <= z[2] and z[1] <= y <= z[3]:
return k
return None
# ── 分区:原理图页 → 背面分区;个别件按功能改派 ─────────────────────────────
PAGE_ZONE = {'A1-INPUT': 'A1', 'A2-AUX': 'A2', 'A3-CTRL': 'A3', 'A4-HUB': 'A4H',
'A6-FAN': 'A6', 'A5-SLOTS': None, 'A5B-SLOTS': None, 'A1B-PD': 'A1'}
ZONE_OVERRIDE = {}
for _r in ('U35', 'U6', 'U7', 'C604', 'C607', 'C608', 'C609', 'C610', 'C611', 'C612',
'R614', 'R615', 'R616', 'R617'):
ZONE_OVERRIDE[_r] = 'A4M' # mux / DAC 与各自的去耦、参考 RC
for _r in ('J23', 'U31', 'C737', 'C752', 'C753', 'R717', 'R718', 'R721', 'R722', 'RT601'):
ZONE_OVERRIDE[_r] = 'A5' # 上行口 + Hub LDO(RT601 监测 U31)
ZONE_OVERRIDE['RT602'] = 'A1' # 监测降压 U37 / L4(D16 前测 DC-005 接触热;用户 2026-09-11 选)
for _r in ('C736', 'C739', 'R660', 'R661'):
ZONE_OVERRIDE[_r] = 'A3' # ESP32 ADC 的 NTC 分压 / 滤波
ZONE_ORDER = ['A4H', 'A4M', 'A3', 'A5', 'A1', 'A2', 'A6']
BIG_NETS = ('+3V3', '+12V', '+5V', '+3V3_HUB') # 遍布全板,只当次选目标
# ── 几何:镜像 + 旋转 ────────────────────────────────────────────────────
def rot_vec(rot, a, b):
r = int(round(rot)) % 360
return {0: (a, b), 90: (-b, a), 180: (-a, -b), 270: (b, -a)}[r]
def layer_of(ref):
return B.side_of(ref)
def pad_off(nx, ny, rot, layer):
u"""封装原生偏移 → 板上偏移。底层先 y 取反再转(实测,见文件头)。"""
return rot_vec(rot, nx, -ny if layer == B.BOTTOM else ny)
def native():
return json.load(io.open(NATIVE, encoding='utf-8'))
def pads_at(nat, ref, x, y, rot):
u"""[(脚号, 网, 板 x, 板 y)]。"""
out = []
for pn, nx, ny, _w, _h, net, _sh in nat.get(ref, []):
dx, dy = pad_off(nx, ny, rot, layer_of(ref))
out.append((pn, net, round(x + dx, 4), round(y + dy, 4)))
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 body_rect(ref, x, y, rot, sz, pad=0.0):
w, h = sz[ref]
if int(round(rot)) % 180:
w, h = h, w
ox, oy = BODY_OFF.get(ref, (0.0, 0.0))
dx, dy = pad_off(ox, oy, rot, layer_of(ref))
cx, cy = x + dx, y + dy
return (cx - w / 2 - pad, cy - h / 2 - pad, cx + w / 2 + pad, cy + h / 2 + pad)
def height(ref):
return HEIGHT.get(ref, H_DEFAULT)
def _hit(r, q):
return r[0] < q[2] and q[0] < r[2] and r[1] < q[3] and q[1] < r[3]
def _rect_dist(px, py, r):
dx = max(r[0] - px, px - r[2], 0.0)
dy = max(r[1] - py, py - r[3], 0.0)
return math.hypot(dx, dy)
# ── 固定障碍(背面)─────────────────────────────────────────────────────
ANT_LEN = 7.5 # 天线段伸出焊盘框
ANT_SIDE = 0.5
def antenna_keepout(sz, pos):
u"""U20 天线净空:天线在封装 +y 端,方向 = pad_off(0, 1)。**跟着 rot 和翻面走**。"""
ax, ay, rot = pos['U20']
w, h = sz['U20']
if int(round(rot)) % 180:
w, h = h, w
dx, dy = pad_off(0, 1, rot, layer_of('U20'))
if dx > 0.5:
return (ax + w / 2, ay - h / 2 - ANT_SIDE, ax + w / 2 + ANT_LEN, ay + h / 2 + ANT_SIDE)
if dx < -0.5:
return (ax - w / 2 - ANT_LEN, ay - h / 2 - ANT_SIDE, ax - w / 2, ay + h / 2 + ANT_SIDE)
if dy > 0.5:
return (ax - w / 2 - ANT_SIDE, ay + h / 2, ax + w / 2 + ANT_SIDE, ay + h / 2 + ANT_LEN)
return (ax - w / 2 - ANT_SIDE, ay - h / 2 - ANT_LEN, ax + w / 2 + ANT_SIDE, ay - h / 2)
TH_PAD_CLR = 0.25 # 通孔焊盘到别的件(规则 0.15,留余量给钻孔那一支)
def th_pad_rect(ref, x, y, rot, nat=None, clr=TH_PAD_CLR):
u"""顶面通孔件的**焊盘外框**(板坐标,外扩 clr)。拿不到原生焊盘时返回 None。"""
nat = nat if nat is not None else native()
qs = nat.get(ref)
if not qs:
return None
xs, ys = [], []
for _pn, nx, ny, w, h, _net, _sh in qs:
dx, dy = pad_off(nx, ny, rot, B.TOP)
ww, hh = (w, h) if int(rot) % 180 == 0 else (h, w)
xs += [x + dx - ww / 2.0, x + dx + ww / 2.0]
ys += [y + dy - hh / 2.0, y + dy + hh / 2.0]
return (min(xs) - clr, min(ys) - clr, max(xs) + clr, max(ys) + clr)
def th_pad_blocks(pos, nat=None):
u"""⛔ 顶面通孔件(`pcb_base.TOP_EXTRA`)的焊盘是**穿透两层**的,背面件不许摆上去。
2026-09-12 实测:`selfcheck` 的「同面不重叠」遇到不同层就 `continue`,而座子在 L1、
驱动件在 L4 ⇒ 排布器把 C738 / R746 / Q7 / R754 直接摆到两只座的通孔盘上
(最深 −0.496,钻孔只剩 −0.196)。几何全过、DRC 才报出来。
"""
out = []
nat = nat if nat is not None else native()
for ref in B.TOP_EXTRA:
v = pos.get(ref)
if not v:
continue
r = th_pad_rect(ref, v[0], v[1], v[2], nat)
if r:
out.append(r)
return out
WALL_NEAR = 8.0 # 「被通孔列劈开」只在这个范围内判(见 chain_split_by)
TRACK_CLR = 0.20 # 件(焊盘外框)到板上**现有铜**
FAN_INPUT = os.path.join(HERE, '_base_fan_input.json')
def track_blocks():
u"""⛔ 板上**现有铜**也是障碍 —— 在已经布过线的板上重排时必须加。
2026-09-12 实测:排布器只避开「别的件 + 禁区」,**不看铜**。于是 `--only-fan` 把
R751 / R752 直接摆到板上既有的 `+3V3` 走线上(实测净距 **−0.14 / −0.03**),
A* 当然报「R751.1 出不来」,而且这本身就是 DRC 违规(焊盘到导线)。
症状看着像「那一带太挤」,其实是摆位阶段就把件放到铜上了。
只取 L4(背面件那一层)的段,按线半宽 + `TRACK_CLR` 外扩成包围盒。
斜线用包围盒偏保守,A6 里斜线很少,够用。
"""
if not os.path.exists(FAN_INPUT):
return []
try:
d = json.load(io.open(FAN_INPUT, encoding='utf-8'))
except ValueError:
return []
out = []
for s in d.get('segs', []):
lay, x1, y1, x2, y2, w = s[0], s[1], s[2], s[3], s[4], s[5]
if lay != B.BOTTOM:
continue
m = w / 2.0 + TRACK_CLR
out.append((min(x1, x2) - m, min(y1, y2) - m, max(x1, x2) + m, max(y1, y2) + m))
return out
def chain_split_by(ref, pos, sz, nat=None):
u"""⛔ 这个件所在的**本地链**,会不会被顶面通孔件的焊盘列劈成两半。
2026-09-12 实测(`$6N49` 布不通的真因):两只座 rot90 竖放后四只脚排成**一列**
(J26 x=4.57 / J27 x=10.79,y 79.43~87.05),而 `$6N49` 的脚跨 x 8.00~13.93 ——
`DZ3.2`/`R746.1` 落在列西、`Q7.1`/`R747.2` 落在列东,**所有东西向连接都要绕过那列
1.6 mm 的通孔盘**。本机绕行验证:三对有路(0.34~0.51),`R746.1→R747.2` 无路(0.107)。
顶进 `priority` 最前也没用 —— 这不是顺序问题,是**摆位**问题。
⇒ 摆位阶段就拦掉:同一条链的件必须落在同一侧。返回劈开它的那个件名(没被劈就 None)。
"""
nat = nat if nat is not None else native()
# ⛔ 按**网**判,不按 `chain_id()` 的并查集组判:`$6N48` 共用了 `Q8` / `R747`,
# 会把 `$6N45` + `$6N48` + `$6N49` 传染成一条 7 件的大链(`DZ3 Q7 Q8 R746 R747 R748 R749`),
# 那条链横跨 x 8~18、被座子列劈开是必然的,报出来也没法处理(把 7 件全挤到一侧既不现实、
# 又会把 `$6N45` 推离它自己的位置)。真正要拦的是**单个网**的脚被劈到两边。
ln = local_nets()
mynets = set(q[5] for q in nat.get(ref, []) if q[5] in ln)
if not mynets:
return None
# ⛔ 只管**多脚的立式连接器**(座子 rot90 四脚排成一列才是墙)。
# 2026-09-12 漏了这条:`TOP_EXTRA` 里的 C2 / C3 是两脚 Φ8 电解,脚距 3.5、盘 1.6 ⇒
# 焊盘框也窄于 3 mm,被当成墙,于是 Hub 区的 R712/R719/R725/U32、入口链的 Rs6/U13/R9A
# 这些**隔着半块板**的件全被报「被 C2 劈开」。墙只由 ≥3 脚的通孔件构成。
WALL_MIN_PINS = 3
mates = set()
for r, qs in nat.items():
if r == ref or r not in pos:
continue
if mynets & set(q[5] for q in qs if q[5] in ln):
mates.add(r)
if not mates:
return None
mates = sorted(mates)
for th in B.TOP_EXTRA:
v = pos.get(th)
if not v:
continue
if len(nat.get(th, [])) < WALL_MIN_PINS:
continue
r = th_pad_rect(th, v[0], v[1], v[2], nat, 0.0)
if not r:
continue
# ⛔ 墙只管**贴着它的那一窝**:J26/J27 的列不该去劈隔着半块板的 Rs6 / U13 / R9A。
# 判据 = 本件与所有同网件都得落在墙的 WALL_NEAR 范围内,才谈得上「被这堵墙劈开」。
if not (r[0] - WALL_NEAR <= pos[ref][0] <= r[2] + WALL_NEAR
and r[1] - WALL_NEAR <= pos[ref][1] <= r[3] + WALL_NEAR):
continue
# ⛔ 本网自己就有脚接在这个件上(`FAN_12V_SW` / `FAN_IN_TACH_RAW` / `FAN_PWM_L` 的
# 一端就是 J26.2 / J27.2 / J26.3 …),那它**天生跨列**,不是摆位错误。
# 2026-09-12 漏了这一条,误报从 4 件涨到 10 件(多出来的全是挂在座子上的网)。
if mynets & set(q[5] for q in nat.get(th, [])):
continue
# 只管「细长成一列」的(座子竖放就是这种):列宽 < 3 mm 才算一道墙
if (r[2] - r[0]) < 3.0:
lo, hi = r[0], r[2]
mine = pos[ref][0]
for m in mates:
if (mine < lo and pos[m][0] > hi) or (mine > hi and pos[m][0] < lo):
return th
if (r[3] - r[1]) < 3.0:
lo, hi = r[1], r[3]
mine = pos[ref][1]
for m in mates:
if (mine < lo and pos[m][1] > hi) or (mine > hi and pos[m][1] < lo):
return th
return None
def blockers(sz, pos):
out = []
k = B.HOLE_KEEP
for _n, (hx, hy) in B.MOUNT_HOLES.items():
out.append((hx - k, hy - k, hx + k, hy + k))
n = B.notch_rect()
out.append((n[0] - 0.5, n[1] - 0.5, n[2], n[3] + 0.5))
out.append(antenna_keepout(sz, pos))
out += B.via_bands()
out += list(LANES.values())
out += th_pad_blocks(pos)
# ⛔ 现有铜**不在这里加**:`blockers()` 是全板口径,而 `plan()` 内部要把全部 78 个排布件
# 都摆一遍(`--only-fan` 只是最后限定下发范围)。2026-09-12 在这儿加过一次 ——
# 别的分区的件(C607 / C610 / C611 / C10 …)立刻被板上现有铜挡住,自检 17 → 139 条、3 件没摆。
# ⇒ 只对正在重排的那个分区加,见 `plan()` 里 `zfixed` 那一段。
return out
# ── 手排件 ───────────────────────────────────────────────────────────────
def pick_rot(nat, ref, x, y, net, point, cands=(0, 90, 180, 270)):
u"""cands 里挑一个 rot,让 ref 上网为 net 的那只脚离 point 最近。"""
best = None
for r in cands:
for pn, n, px, py in pads_at(nat, ref, x, y, r):
if n == net:
d = math.hypot(px - point[0], py - point[1])
if best is None or d < best[0] - 1e-6:
best = (d, r)
return best[1]
# ── D16:南带 PD 取电入口,逐件手排(2026-09-11,离线四版迭代,设计记录 R2 §36.5)────────────
# 走向:J1(口朝南)→ 两组 VBUS 在 F1 南合流 → F1 → VIN_FUSED 母线向东 → U37 南侧输入电容;
# U37 北侧 VIN 只挂 C19,经 L1 VIN_FUSED 小岛与南侧连通(用户选)→ SW 朝西接 L4 → L4 西端 VIN_SW
# → 输出电容一行(L4 北沿)→ 原有 C2 / D1 / C3 储能 → Rs6。
# 留给出线的空区(摆位不许占,PD_FREE):J1 两组 VBUS 之间 = CC 打孔上 L1 + VIN_RAW 合流;
# U37 北侧 BST 出路(C18 在西北角落 L4.1 上沿);U37 南侧 EN/SYNC/SPSP 出口;SW 连接段。
PD_HAND = {
'J1': (12.5, 5.25, 180), 'F1': (12.5, 11.8, 90), # F1:VIN_RAW 在南、VIN_FUSED 在北
'D3': (19.25, 4.0, 270),
'U36': (23.7, 10.4, 270), 'C12': (26.9, 15.5, 270), 'R7': (19.0, 11.25, 90), # CC 列朝西、I²C/VHV 列朝东
'U37': (21.0, 20.7, 270), 'L4': (10.4, 20.7, 180),
'C20': (19.75, 17.80, 0), 'C19': (19.75, 23.60, 0), # 热回路 100n:GND 在西贴 PGND、VIN 在东贴 VIN
'C21': (19.2, 15.1, 90), 'C22': (23.9, 15.0, 0), # 南侧大电容
'C23': (23.9, 26.3, 0), 'C24': (23.9, 28.3, 0), # 北侧大电容(经 L1 小岛)
'C18': (19.2, 24.85, 0), # BOOT:BST 在东、SW 在西
'R8': (24.85, 20.2, 180), 'R11': (24.85, 21.42, 180), 'R10': (24.85, 22.64, 0), 'C13': (24.85, 23.86, 180),
'R13': (24.2, 17.80, 0), 'R12': (24.2, 16.58, 0),
'C5': (1.5, 21.75, 270),
'C14': (2.95, 28.45, 0), 'C15': (8.25, 28.45, 0), 'C16': (13.55, 28.45, 0), 'C17': (18.85, 28.45, 0),
'RT602': (26.9, 19.3, 90), # 贴 U37 东侧测降压
}
PD_REFS = set(PD_HAND)
# 风扇驱动那一窝(`--only-fan` 只重排这些,两只座 J26 / J27 连同别的件一起钉在板上现位)。
# 2026-09-12:用户在客户端把两只座挪成 rot90 竖放并排(顶面),外围件还留在老位置上互相压着
# (实测 D2↔R749 −8.04 / C738↔R750 −6.42 / Q6↔D2 −5.09 …)⇒ 按新座位重排这 22 件。
# ⚠ 名单里**不含** NTC 那几件(C736 / C739 / R660 / R661 / RT601 / RT602):它们虽然画在 A6-FAN 页上,
# 但 `ZONE_OVERRIDE` 已把它们改派到 A3 / A5 / A1(跟着 ESP32 的 ADC 和被测的那颗芯片走)。
FAN_REFS = {'Q6', 'Q7', 'Q8', 'Q9', 'Q10', 'D2', 'DZ3', 'C738',
'R733', 'R734', 'R746', 'R747', 'R748', 'R749', 'R750',
'R751', 'R752', 'R753', 'R754', 'R755', 'R756', 'R757'}
PD_FREE = {u'CC 换层 + VIN_RAW 合流': (9.0, 8.3, 16.0, 9.7), u'BST 出路': (20.5, 22.7, 22.4, 25.2),
u'EN/SYNC/SPSP 出口': (21.1, 16.3, 22.7, 18.7), u'SW 连接': (17.9, 19.9, 18.5, 21.5)}
def hand(nat=None):
u"""{位号: (x, y, rot)}。层由 `pcb_base.side_of` 定(插槽 L1,其余 L4)。"""
nat = nat or native()
H = {}
for i, ref in enumerate(SLOT_ORDER, start=1):
H[ref] = (B.SLOT_CX, B.slot_y(i), B.SLOT_ROT)
# ── A4H:Hub(走廊 HUB_K;x / y 的来历见 HUB_CX / HUB_CY)───────────────
# 走廊 3 时焊盘 54.0~63.8,北沿到 J35 焊盘(64.72)留 0.9 给 USB2 那对过孔。
H['U32'] = (HUB_CX, HUB_CY, 270)
# U32 卫星件(晶振 / 去耦 / RBIAS / strap)逐脚手排,见 HUB_SAT。
H.update(hub_sats(nat))
# ── A4M:mux 在西段、两颗 DAC 在东段(中间 DP_M 车道) ─────────────────────
# U35 底层 rot 0:原生南排(MUX_RST#、SDA1~SCL4)翻到北边。
H['U35'] = (39.0, MUX_CY, 0)
H['U6'] = (56.0, MUX_CY, 0)
H['U7'] = (64.0, MUX_CY, 0)
# ── A3:ESP32 天线朝东伸进缺口(58~80)──────────────────────────────────
# 焊盘框 71.5~90.5 × 59.5~78.5,天线段 90.5~98。
H['U20'] = (81.0, 69.0, 90)
H['J33'] = (80.0, 95.5, 0) # 应急串口,北板边内侧(让开 H4)
# ── A5:上行 USB-C 在南板边,LDO 在它北边 ────────────────────────────────
H['J23'] = (J23_XY[0], J23_XY[1], 180)
H['U31'] = (80.0, 26.0, 180) # 三只脚朝西(朝板中),散热片朝东
H['RT601'] = (86.0, 26.0, 0) # 贴 U31 散热片(东侧),测 Hub LDO 最坏 ~0.7 W
# ⚠ 不能交给排布器:它的同网脚是 U20.39(NTC1),会被拉到 A5 北沿
# ── A1:D16 PD 取电入口(J1 → F1 → U37 → L4 → VIN_SW),逐件手排,见 PD_HAND ─────────
H.update(PD_HAND)
H['D1'] = (12.5, 34.0, 90) # TVS 竖放在两颗电解中间,VIN_SW 脚朝南
H['C2'] = (5.5, 35.0, 180) # VIN_SW 脚朝东(朝 D1 / Q1)
H['C3'] = (19.5, 35.0, 0) # VIN_SW 脚朝西
H['Rs6'] = (12.5, 43.5, 90) # VIN_SW 在南、+12V 在北 —— +12V 在这打孔阵上 L1 大铺铜
H['U13'] = (19.5, 43.5, 270) # INA226 Kelvin 脚(原生北排)朝西贴 Rs6
# 泄放链:Q3 在西头,R9A→R9E 自西向东一字排,每颗在 90/270 里挑,让相邻两颗的公共网脚在同一侧
H['Q3'] = (2.95, 52.0, 180) # 漏极排朝北
# ⚠ 漏极网名按 5 脚现取:它是自动网名,整页重画 / 导入会换号($1N114 → $1N182 → D16 导入后 $1N213),
# 写死的话同网脚一个都找不到,下面求目标点直接除零
prev = ('Q3', [q[5] for q in nat['Q3'] if q[0] == '5'][0])
for k, (ref, x) in enumerate((('R9A', 7.6), ('R9B', 11.95), ('R9C', 16.3),
('R9D', 20.65), ('R9E', 25.0))):
px, py = H[prev[0]][0], H[prev[0]][1]
pts = [(q[2], q[3]) for q in pads_at(nat, prev[0], px, py, H[prev[0]][2])
if q[1] == prev[1]]
tgt = (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts))
H[ref] = (x, 52.0, pick_rot(nat, ref, x, 52.0, prev[1], tgt, (90, 270)))
prev = (ref, [q[5] for q in nat[ref] if q[5] != prev[1]][0])
# ── A2:两颗 Buck 一字排,IC → 电感紧邻(SW 最短)─────────────────────────
H['U19'] = (5.0, 68.5, 0) # SW 脚(原生东)朝东
H['L2'] = (11.0, 68.5, 0) # SW 脚(原生西)朝西
H['U30'] = (17.0, 68.5, 0)
H['L3'] = (23.5, 68.5, 0)
# ── A6:两只风扇座(2026-09-12 起在**顶面**,pcb_base.TOP_EXTRA)────────────
# **J26 一格没动**(还是北板边内侧那条空带):它的四只脚原地翻面 ⇒ 孔位、针序、铜全不变,
# 旧布线原样有效。⛔ J27 非挪不可:刀片板面竖在 y = 槽心 ±0.8,原位 (13.5, 88.0) 的本体
# y 85.5~90.5 正好穿过 J34 那片刀片(89.64)。
#
# ⚠ 为什么 J27 不是简单地往南平移:x = 13.5 那一列从 y 86 往南,焊盘先压 C738、再压 R754
# (通孔盘 1.6,背面那一窝驱动件一个都让不开)⇒ 只能往西挪到 (9.5, 85.75),离原位 4.59 mm,
# 是「与 J26 本体不重叠 + 让开刀片 / 槽体 / 螺钉 + 焊盘不压背面件与现有铜」里最近的一个。
#
# ⛔ 曾经试过「两只都南移进走廊」(用户 2026-09-12 先选的那版):扫了 800 个候选落位、
# 修了布线器的通孔盘放大量、压线宽、拆线重布,**始终有 3~4 条布不通**。根因是西北角
# L1 整片是 +12V 大铜(`pcb_base.l1_12v_rects` 主块 x 0.5~27.8 / y 0.5~97.5)⇒ 过孔上不去,
# 那一带等于**单层布线**,而 A6 的 L4 已经挤满驱动件。⇒ 能不动的座子就别动。
# `--only-fan`:两只座以**板上现位**为准(2026-09-12 用户自己在客户端摆的:rot90 竖放并排),
# 排布器按它重排外围。⛔ 不能用下面写死的坐标 —— `blockers()` 要拿座子的**通孔焊盘**当禁区,
# 位置错了就等于避开了一个不存在的地方,而真正的座子底下照摆不误。
if '--only-fan' in sys.argv:
_lv = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
H['J26'] = tuple(_lv['J26'][:3])
H['J27'] = tuple(_lv['J27'][:3])
else:
H['J26'] = (13.5, 94.5, 0)
H['J27'] = (9.5, 85.75, 0)
return H
# ── 排布 ─────────────────────────────────────────────────────────────────
def page_of():
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 zone_of_ref(ref, pg):
return ZONE_OVERRIDE.get(ref) or PAGE_ZONE[pg[ref]]
_LOCAL_NETS = [None]
def local_nets():
u"""{网名}:脚全落在**同一个分区**里的网(见 `sat_target` 里「本地链优先」那一段)。
风扇那一窝的 MOS 栅极 RC(`$6N45` / `$6N49` / `$6N55` / `$6N64` / `$6N74` …)就是这种:
一条链两三只脚,全在 A6 内。跨区网(`FAN_TACH_IN` → U20、`+12V`、`+3V3`)不算。
"""
if _LOCAL_NETS[0] is None:
nat = native()
pg = page_of()
byn = {}
for ref, qs in nat.items():
if ref not in pg:
continue
try:
z = zone_of_ref(ref, pg)
except KeyError:
continue
for q in qs:
if q[5] and q[5] != 'GND':
byn.setdefault(q[5], set()).add(z)
_LOCAL_NETS[0] = set(n for n, zs in byn.items() if len(zs) == 1)
return _LOCAL_NETS[0]
CHAIN_MAX_PINS = 4 # 超过这么多只脚的网不当「小链」并(见 chain_id 的注释)
_CHAIN = [None]
def chain_id():
u"""{位号: 链号}:靠**本地链网**(`local_nets`)把件并成一组(并查集)。
风扇那一窝的 MOS 栅极 RC 就是一条链:`Q9 + R751 + R752`(`$6N64`)、`Q10 + R755 + R756`(`$6N74`)…
⛔ 2026-09-12 的教训:光给 `sat_target` 加「本地链优先」**没有用** —— 一个件常同时挂两个本地网
(R751 = `$6N64` + `FAN_IN_TACH_RAW`),键没有区分度。真正拉散链的是**摆放顺序**:
大件 Q9 先摆、被跨区的 `FAN_TACH_IN` 拉到东缘,小电阻后摆时只好贴西边座子上的那只脚,
于是同一条 `$6N64` 拉成 23.8 mm、布不通。⇒ 同链的件要**连续摆**(`_ord`),
且后摆的优先贴**同链已摆的那件**(下面 key 里的 `same_chain`)。
"""
if _CHAIN[0] is None:
nat = native()
ln = local_nets()
par = {}
def find(a):
while par.setdefault(a, a) != a:
par[a] = par.setdefault(par[a], par[a])
a = par[a]
return a
byn = {}
for ref, qs in nat.items():
for q in qs:
if q[5] in ln:
byn.setdefault(q[5], []).append(ref)
for _n, refs in byn.items():
# ⛔ 只并**小链**:脚数 ≤ CHAIN_MAX_PINS,且不含手排的锚件(`pcb_base.TOP_EXTRA`:
# 两只座 / 电解 / 排针)。2026-09-12 第一版没这两条过滤,`FAN_12V_SW`(7 只脚、
# 串起 C738 / D2 / 两只座 / Q7 / R750 / R754)把整窝 22 件并成**一个组**,
# 「同链连续摆」退化成「整窝连续摆」,等于没加 —— 和上一次失败同源。
if len(refs) > CHAIN_MAX_PINS or any(r in B.TOP_EXTRA for r in refs):
continue
for r in refs[1:]:
ra, rb = find(refs[0]), find(r)
if ra != rb:
par[ra] = rb
_CHAIN[0] = dict((r, find(r)) for r in list(par))
return _CHAIN[0]
def sat_target(ref, nat, pos, sz, zone, claims):
u"""目标 = 已摆的锚上**同网的那只脚**(翻面后的真实坐标)。
排序:**分区内的脚优先** → 非大网优先 → 离分区近的优先 → 被别的去耦认领得少的优先 → 锚小的优先
(晶振负载电容认 Y1 而不是 U32;八颗 3V3_HUB 去耦分摊到 U32 的八只电源脚上)。
⚠ 「分区内优先」必须排第一:风扇驱动的同网脚一头在 J26/J27(A6 内)、一头在 U20(A3),
按「锚大优先」会全体瞄 60 mm 外的 U20,挤到 A6 的东南角。
返回 (x, y, 本件用来对准的网, 认领键) 或 None。
"""
nets = [q[5] for q in nat.get(ref, []) if q[5] and q[5] != 'GND']
if not nets:
return None
small = [n for n in nets if n not in BIG_NETS]
want = set(small or nets)
zr = ZONES[zone]
best = None
for other, (ox, oy, orot) in pos.items():
if other == ref:
continue
area = sz[other][0] * sz[other][1]
if area <= ANCHOR_AREA and small:
pri0 = 1
elif area <= ANCHOR_AREA:
continue
else:
pri0 = 0
for pn, n, px, py in pads_at(nat, other, ox, oy, orot):
if n not in want:
continue
zd = round(_rect_dist(px, py, zr), 1)
# 排在分区之后、大网之前(2026-09-12):
# `same_chain` —— 锚件和本件属于**同一条本地链**(`chain_id`)。这一项才是有效的那个:
# 一个件常同时挂两个本地网(R751 = `$6N64` + `FAN_IN_TACH_RAW`),光按「网是不是本地」
# 分不出来;按「锚件是不是我这条链上的」就分得出来 —— 贴它,链才聚得成一小团。
# `local` —— 网的脚全在同一分区(跨区网会把件拉到几十毫米外的 U20 那边)。
same_chain = 0 if (chain_id().get(other) is not None
and chain_id().get(other) == chain_id().get(ref)) else 1
key = (zd > 0, same_chain, 0 if n in local_nets() else 1,
pri0 + (2 if n in BIG_NETS else 0), zd,
claims.get((other, pn), 0), area, other, pn)
if best is None or key < best[0]:
best = (key, px, py, n, (other, pn))
if best is None:
return None
return best[1], best[2], best[3], best[4]
def _cands(zr, sz_ref, t):
u"""分区内格点,按离目标由近到远。"""
m = max(sz_ref) / 2.0
xs, ys = [], []
v = math.ceil(zr[0] / STEP) * STEP
while v <= zr[2] + 1e-9:
xs.append(round(v, 3))
v += STEP
v = math.ceil(zr[1] / STEP) * STEP
while v <= zr[3] + 1e-9:
ys.append(round(v, 3))
v += STEP
pts = [(x, y) for x in xs for y in ys
if zr[0] - m <= x <= zr[2] + m and zr[1] - m <= y <= zr[3] + m]
pts.sort(key=lambda p: (math.hypot(p[0] - t[0], p[1] - t[1]), p[1], p[0]))
return pts
TIGHT = [] # 上一次 plan() 里退回 GAP 的件(报告用)
POWER_NETS = ('+3V3', '+3V3_HUB', '+5V', '+12V', 'VDD18', 'VDD18PLL', 'VIN_SW')
_NAT = [None]
def is_decap(ref):
u"""两只脚、一只 GND 一只电源 ⇒ 去耦 / 储能。"""
if _NAT[0] is None:
_NAT[0] = native()
nets = set(q[5] for q in _NAT[0].get(ref, []))
return len(_NAT[0].get(ref, [])) == 2 and 'GND' in nets and bool(nets & set(POWER_NETS))
def _inside(r, zr):
return zr[0] - 1e-6 <= r[0] and r[2] <= zr[2] + 1e-6 and zr[1] - 1e-6 <= r[1] and r[3] <= zr[3] + 1e-6
def plan():
sz = sizes()
nat = native()
pg = page_of()
pos = dict(hand(nat))
fixed = list(blockers(sz, pos)) + [r for _o, r in fan_rings(sz, pos)]
placed = [] # (ref, 带 GAP/2 的本体框),只收背面件
for ref, (x, y, r) in pos.items():
if layer_of(ref) == B.BOTTOM:
placed.append((ref, body_rect(ref, x, y, r, sz, GAP / 2)))
todo = {}
for ref in sz:
if ref not in pos:
todo.setdefault(zone_of_ref(ref, pg), []).append(ref)
claims = {}
unplaced = []
aim = {}
tight = [] # 只能按装配下限 GAP 塞进去的件
TIGHT[:] = tight
for zone in ZONE_ORDER:
zr = ZONES[zone]
zfixed = [q for q in fixed if _hit(q, zr)]
# 在**已经布过线**的板上重排某一窝时,板上现有铜也是障碍(`track_blocks` 的注释)。
# 只给正在重排的那个分区加 —— 别的分区当初是在空板上排的,全局加会直接排不下。
if '--only-fan' in sys.argv and zone == 'A6':
zfixed += [q for q in track_blocks() if _hit(q, zr)]
# 同一条本地链的件**连续摆**:组的次序按组内最大面积(大件领头),组内仍按原来的规则。
# ⛔ 只在 `--only-fan` 时启用 —— 别的分区(A1 / A4H 的那些手排与去耦)是按现在这套调出来的,
# 2026-09-12 这次只该动风扇那一窝。
_grp = {}
if '--only-fan' in sys.argv:
cid = chain_id()
for r in todo.get(zone, []):
k = cid.get(r)
if k is None:
continue
_grp[k] = max(_grp.get(k, 0.0), sz[r][0] * sz[r][1])
def _ord(r):
a = sz[r][0] * sz[r][1]
nn = len(nat.get(r, []))
k = chain_id().get(r) if _grp else None
# 组键:组内最大面积的负值(大组先),没链的件排在最后;组内再按原来的顺序
gk = (-_grp[k], str(k)) if (k is not None and k in _grp) else (0.0, '~')
# 去耦排在其它两脚件前面:它们必须贴脚,先占近处(第一版 SAT_GAP 把 C746 挤到 U32 脚 20 mm 外)
return (gk, 0 if a > ANCHOR_AREA else 1,
0 if nn > 2 else (1 if is_decap(r) else 2), -a, r)
for ref in sorted(todo.get(zone, []), key=_ord):
t = sat_target(ref, nat, pos, sz, zone, claims)
tgt = (t[0], t[1]) if t else ((zr[0] + zr[2]) / 2.0, (zr[1] + zr[3]) / 2.0)
net = t[2] if t else None
done = None
zplaced = [q for _r, q in placed if _hit(q, (zr[0] - 12, zr[1] - 12,
zr[2] + 12, zr[3] + 12))]
# ⛔ 「同一个网的脚不许被顶面通孔件的焊盘列劈到两边」——`chain_split_by()` 的注释。
# 只在 `--only-fan` 时启用:这条判据是 2026-09-12 为「座子 rot90 竖放、四脚排成一列」
# 这种摆法加的,别的分区没有这种墙。挑位置时就排除掉,光在 selfcheck 里报警没用。
_no_split = '--only-fan' in sys.argv and chain_id().get(ref) is not None
# 第一遍按 SAT_GAP 找;整个分区都找不到才按装配下限 GAP 再找一遍。
# ⚠ 去耦不放宽:它的价值全在「贴着脚」,放宽之后近处的位子被上拉 / 阻容占走,去耦被挤到十几毫米外。
gaps = (GAP / 2,) if is_decap(ref) else (SAT_GAP / 2, GAP / 2)
for mc in gaps:
for (x, y) in _cands(zr, sz[ref], tgt):
# 「同网的脚不许被通孔列劈到两边」——挑位置时就排除,光报警不解决问题
if _no_split:
_try = dict(pos)
_try[ref] = (x, y, 0)
if chain_split_by(ref, _try, sz, nat):
continue
ok = []
for rot in (0, 90, 180, 270):
body = body_rect(ref, x, y, rot, sz)
if not _inside(body, zr):
continue
rg = body_rect(ref, x, y, rot, sz, mc)
if any(_hit(rg, q) for q in zfixed) or any(_hit(rg, q) for q in zplaced):
continue
if net:
d = min(math.hypot(px - tgt[0], py - tgt[1])
for _pn, n, px, py in pads_at(nat, ref, x, y, rot) if n == net)
else:
d = math.hypot(x - tgt[0], y - tgt[1])
ok.append((d, rot))
if ok:
d, rot = min(ok)
done = (x, y, rot, d)
break
if done is not None:
if mc < SAT_GAP / 2 and not is_decap(ref):
tight.append(ref)
break
if done is None:
unplaced.append((ref, zone))
continue
x, y, rot, d = done
pos[ref] = (x, y, rot)
# 按 SAT_GAP 的一半存:后来的件离它也至少 SAT_GAP(它自己退回 GAP 时也一样)。
# 去耦按 GAP 存:别的去耦可以紧挨着它(一只脚两颗去耦的常见排法)。
placed.append((ref, body_rect(ref, x, y, rot, sz, (GAP if is_decap(ref) else SAT_GAP) / 2)))
if t:
claims[t[3]] = claims.get(t[3], 0) + 1
aim[ref] = (t[3][0], t[3][1], net, round(d, 2))
return pos, unplaced, sz, aim
# ── 自检 ─────────────────────────────────────────────────────────────────
def selfcheck(pos, unplaced, sz):
bad = []
if unplaced:
bad.append(u'排不下 %d 件: %s' % (len(unplaced), ' '.join('%s(%s)' % u for u in unplaced)))
miss = sorted(set(sz) - set(pos))
if miss:
bad.append(u'没摆: %s' % ' '.join(miss))
fixed = blockers(sz, pos)
vbands = B.via_bands()
rings = fan_rings(sz, pos)
items = sorted(pos)
for i, a in enumerate(items):
x, y, r = pos[a]
br = body_rect(a, x, y, r, sz)
lay = layer_of(a)
if a in EDGE_PARTS:
if abs(br[1]) > 1.0:
bad.append(u'%s 是接口件,本体南沿 y=%.2f 没贴板边' % (a, br[1]))
if br[0] < 0.25 or br[2] > B.BW - 0.25:
bad.append(u'%s 东西向出板' % a)
elif br[0] < 0.25 or br[1] < 0.25 or br[2] > B.BW - 0.25 or br[3] > B.BH - 0.25:
bad.append(u'%s 出板或贴边: %s' % (a, tuple(round(v, 2) for v in br)))
if lay == B.BOTTOM and '--only-fan' in sys.argv:
_th = chain_split_by(a, pos, sz)
if _th:
bad.append(u'%s 与同链的件被 %s 的通孔焊盘列劈在两边(东西向连接要绕过那一列,实测布不通)'
% (a, _th))
if lay == B.TOP and a not in B.SLOT_REFS:
# 顶面件(`pcb_base.TOP_EXTRA`):限高不归 H_BOTTOM 管(插槽把刀片底边抬到板上
# 约 11 mm,刀片自己 y 0~11 又是金手指禁布区,两面都没件)⇒ 只有**横向**约束:
# 不许碰竖在 y = 槽心 ±0.8 的刀片板面,也不许压槽体。
for _i, _cy in enumerate(B.slot_ys(), 1):
if br[1] < _cy + CARD_HALF + CARD_CLR and br[3] > _cy - CARD_HALF - CARD_CLR:
bad.append(u'%s 在顶面撞槽%d的刀片板面(y=%.2f±%.1f,本体 y %.2f~%.2f)'
% (a, _i, _cy, CARD_HALF, br[1], br[3]))
if _hit(br, B.slot_body(_i)):
bad.append(u'%s 在顶面压到槽%d座体' % (a, _i))
if lay == B.BOTTOM:
if not B.height_ok(height(a)):
bad.append(u'%s 高 %.1f 超背面限高 %.0f' % (a, height(a), B.H_BOTTOM))
in_strip = a in HUB_SAT and any(_inside(br, s) for s in HUB_STRIPS)
if a != 'U20':
for q in fixed:
if in_strip and q in vbands:
continue
if _hit(br, q):
bad.append(u'%s 压到禁区 %s' % (a, tuple(round(v, 2) for v in q)))
break
for o, q in rings:
# U32 的卫星件是逐脚手排的(它们正是要贴着脚),扇出圈只拦排布器摆的件
if o != a and not (o == 'U32' and a in HUB_SAT) and _hit(br, q):
bad.append(u'%s 进了 %s 的扇出圈' % (a, o))
for b in items[i + 1:]:
if layer_of(b) != lay:
# ⛔ 跨层也要比一种情况:顶面通孔件的焊盘穿透两层(见 th_pad_blocks 的注释)。
for _t, _o in ((a, b), (b, a)):
if _t not in B.TOP_EXTRA or _o in B.TOP_EXTRA:
continue
_r = th_pad_rect(_t, pos[_t][0], pos[_t][1], pos[_t][2])
_ob = body_rect(_o, pos[_o][0], pos[_o][1], pos[_o][2], sz)
if _r and _hit(_r, _ob):
bad.append(u'%s 摆在 %s 的通孔焊盘上(穿透两层)' % (_o, _t))
continue
rb = body_rect(b, pos[b][0], pos[b][1], pos[b][2], sz)
if _hit((br[0] - GAP + 1e-6, br[1] - GAP + 1e-6, br[2] + GAP - 1e-6, br[3] + GAP - 1e-6), rb):
bad.append(u'%s 与 %s 挨太近(< %.1f)' % (a, b, GAP))
return bad
# ── 体检 ─────────────────────────────────────────────────────────────────
def report(pos, sz, aim):
nat = native()
pg = page_of()
print(u'')
print(u'分区占用(本体面积 / 分区面积):')
cnt = {}
for ref, (x, y, _r) in pos.items():
if layer_of(ref) == B.BOTTOM:
cnt.setdefault(zone_at(x, y) or '?', []).append(ref)
for k in ZONE_ORDER + ['?']:
v = cnt.get(k, [])
if not v:
continue
if k == '?':
print(u' 不在任何分区: %s' % ' '.join(sorted(v)))
continue
z = 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' %-4s %3d 件 %5.0f / %5.0f mm² (%2.0f%%)' % (k, len(v), used, area, 100.0 * used / area))
wrong = [r for r, (x, y, _r) in pos.items() if layer_of(r) == B.BOTTOM and r in pg
and zone_at(x, y) not in (zone_of_ref(r, pg), None)]
if wrong:
print(u' ⚠ 落到别的分区: %s' % ' '.join(sorted(wrong)))
print(u'')
far = sorted(((v[3], r, v[0], v[1], v[2]) for r, v in aim.items()), reverse=True)
print(u'卫星件 → 目标脚的距离:平均 %.1f mm;最远 8 件:' % (sum(f[0] for f in far) / max(len(far), 1)))
for d, r, o, pn, net in far[:8]:
print(u' %-6s → %s.%s (%s) %.1f mm' % (r, o, pn, net, d))
print(u'')
print(u'U32 卫星件 → 目标脚(焊盘中心距):')
for ref, pn, net, d in hub_report(pos, nat):
print(u' U32.%-3s %-9s ← %-5s %.2f mm' % (pn, net, ref, d))
print(u'')
print(u'D± 估算(曼哈顿:U32 北沿脚 → 槽南排 D± 脚;南去的另加绕 U32 东侧)— Hub 在走廊 %d:' % HUB_K)
L = dp_lengths(pos, nat)
for i, ref, v in L:
print(u' USB%d → %s ≈ %.0f mm' % (i, ref, v))
print(u' 最远 ≈ %.0f mm(M5 验收线 45)' % max(v for _i, _r, v in L))
def dp_lengths(pos, nat):
u"""[(USB 号, 槽位号, 估算长度 mm)]。北去的直走;南去的先上到北邻槽底下、向东进 DP_H、再南下。"""
ux, uy, ur = pos['U32']
pp = {pn: (px, py) for pn, _n, px, py in pads_at(nat, 'U32', ux, uy, ur)}
up = _CH[3] + 1.3
east = LANES['DP_H'][0] + 1.0
out = []
for i in range(1, B.SLOT_N + 1):
ref = B.USB_SLOT_REF[i]
pn = {1: '2', 2: '4', 3: '7', 4: '9', 5: '12'}[i]
sx, sy = B.pin_x(-1.5), pos[ref][1] - B.PAD_ROW_DY
px, py = pp[pn]
if sy > py:
v = abs(sx - px) + abs(sy - py)
else:
v = (up - py) + (east - px) + (up - sy) + abs(east - sx)
out.append((i, ref, round(v, 1)))
return out
# ── 下发 / 读回 ───────────────────────────────────────────────────────────
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, only=None):
u"""layer + x + y + rotation 一次 modify。⚠ rotation 0 会被当成「未传」,传 360。only = 只下发这些位号。"""
arr = [[k, layer_of(k), round(v[0] * MIL, 4), round(v[1] * MIL, 4),
int(v[2]) % 360 or 360] for k, v in sorted(pos.items()) if only is None or k in only]
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, lay, x, y, rot] of P) {
const c = by[d];
if (!c) { miss.push(d); continue; }
await eda.pcb_PrimitiveComponent.modify(c.getState_PrimitiveId(),
{layer: lay, x: x, y: y, rotation: rot});
k++;
}
await eda.pcb_Document.save();
return {moved: k, miss: miss};
""" % json.dumps(arr), timeout=900)
print(u'摆位下发 %d 件%s' % (r['moved'], (u';板上没有: %s' % r['miss']) if r['miss'] else u''))
return r
BBOX_JS = u"""
await eda.dmt_EditorControl.openDocument('%s'); // 自己开文档(skill §9l,同 DUMP_JS)
await new Promise(r => setTimeout(r, 1500));
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° 之后要换过来
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;
""" % (PCB, MIL)
def dump_bbox():
u"""封装外框 → `_base_bbox.json`,**按 rot 反转回封装朝向**(翻面不换长宽)。"""
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 件' % len(d))
return d
DUMP_JS = u"""
// ⚠ 必须自己开文档(skill §9l):2026-09-11 导入变更之后前台是原理图,这段打过去 getAll() 回 null,报 reading 'map'
await eda.dmt_EditorControl.openDocument('%s');
await new Promise(r => setTimeout(r, 1500));
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;
""" % (PCB, MIL)
def dump_pads():
d = call(DUMP_JS, timeout=600)
io.open(PADS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
live = dump_live_positions()
io.open(ROT_JSON, 'w', encoding='utf-8').write(
json.dumps({k: v[2] for k, v in live.items()}, ensure_ascii=False, sort_keys=True))
print(u'已写 _base_pads.json:%d 个焊盘' % len(d))
return d
def dump_live_positions():
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.getState_Layer()]; return o;"
% (MIL, MIL))
want = len(page_of()) # = 原理图 BOM 件数(2026-09-10 导入 R723~R729 后 145)
if len(d) != want:
raise SystemExit(u'板上读到 %d 件,应为 %d(原理图 BOM);拒绝覆盖 LIVE 快照' % (len(d), want))
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))
return out
def rebuild_native():
u"""`_base_pads.json` + `_base_place_live.json` → 封装原生偏移(反用镜像规则)。"""
P = json.load(io.open(PADS, encoding='utf-8'))
L = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
nat = {}
for p in P:
x0, y0, rot, lay = L[p[0]]
a, b = rot_vec((360 - rot) % 360, p[2] - x0, p[3] - y0)
if lay == B.BOTTOM:
b = -b
w, h = (p[4], p[5]) if rot % 180 == 0 else (p[5], p[4])
nat.setdefault(p[0], []).append([p[1], round(a, 4), round(b, 4), round(w, 4),
round(h, 4), p[7], p[8]])
io.open(NATIVE, 'w', encoding='utf-8').write(json.dumps(nat, ensure_ascii=False))
print(u'已重建 _base_native_pads.json:%d 件' % len(nat))
return nat
def verify(pos, sz):
u"""读回位姿逐件对账 + 朝向断言(用**板上读回的焊盘**,不信离线模型)。"""
d = dump_pads()
live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
got, allp = {}, {}
boxb = {}
for ref, pn, x, y, w, h, _lay, net, _sh in d:
got.setdefault(ref, {}).setdefault(pn, (x, y, net))
# ⚠ 脚号会重复:插槽两个外壳盘都叫 65(u = ±19.5),J23 的 A1B12 / A4B9 各有好几块
allp.setdefault((ref, pn), []).append((x, y))
if w > 0 and h > 0:
bb = boxb.setdefault((ref, pn), [1e9, 1e9, -1e9, -1e9])
bb[:] = [min(bb[0], x - w / 2), min(bb[1], y - h / 2), max(bb[2], x + w / 2), max(bb[3], y + h / 2)]
bad = []
for ref, (x, y, rot) in sorted(pos.items()):
g = live.get(ref)
if not g:
bad.append(u'%s 板上没有' % ref)
continue
if abs(g[0] - x) > 0.02 or abs(g[1] - y) > 0.02 or g[2] % 360 != int(rot) % 360 \
or g[3] != layer_of(ref):
bad.append(u'%s 读回 (%.2f,%.2f,%s°,L%s) != (%.2f,%.2f,%s°,L%s)'
% (ref, g[0], g[1], g[2], g[3], x, y, rot, layer_of(ref)))
def pad(ref, pn):
return got.get(ref, {}).get(pn)
# 1. 离线镜像模型 == 板上焊盘(全部件逐脚,比**每只脚所有焊盘块合起来的外框**)
# ⚠ 不能比块中心:多边形焊盘(U37 的 1/6/10/15、C 座的 VBUS/GND)抓回来是按**板上水平条带**拆的,
# 件一转 90° / 翻面,同一块焊盘拆出来的条带就不一样 —— D16 下发 U37 时按中心比报了假的 0.375 mm
nat = native()
worst = (0.0, '')
for ref, (x, y, rot) in pos.items():
mb = {}
for pn, nx, ny, w0, h0, _n, _sh in nat.get(ref, []):
if w0 <= 0 or h0 <= 0:
continue
dx, dy = pad_off(nx, ny, rot, layer_of(ref))
w, h = (w0, h0) if int(rot) % 180 == 0 else (h0, w0)
bb = mb.setdefault(pn, [1e9, 1e9, -1e9, -1e9])
bb[:] = [min(bb[0], x + dx - w / 2), min(bb[1], y + dy - h / 2),
max(bb[2], x + dx + w / 2), max(bb[3], y + dy + h / 2)]
for pn, bb in mb.items():
gb = boxb.get((ref, pn))
if gb:
e = max(abs(u - v) for u, v in zip(bb, gb))
if e > worst[0]:
worst = (e, '%s.%s' % (ref, pn))
if worst[0] > 0.05:
bad.append(u'离线镜像模型和板上焊盘差 %.3f mm(%s)⇒ pad_off 的规则不对' % worst)
# 2. U32 下行口朝北、USB1 在西
ux, uy = pos['U32'][0], pos['U32'][1]
p2, p12 = pad('U32', '2'), pad('U32', '12')
if not p2 or not p12:
bad.append(u'U32 的 2/12 脚没读到')
elif not (p2[1] > uy + 3.5 and p12[1] > uy + 3.5 and p2[0] < p12[0]):
bad.append(u'U32 下行口不对:USB1_DP (%.2f,%.2f) USB5_DP (%.2f,%.2f),中心 y=%.2f'
% (p2[0], p2[1], p12[0], p12[1], uy))
x61 = pad('U32', '61')
if x61 and x61[0] > ux - 3.5:
bad.append(u'U32 的 XTAL1 不在西侧(x=%.2f)' % x61[0])
# 3. 插槽:+12V 在西、D± 在南排 x 47.5/48.5
for i, ref in enumerate(SLOT_ORDER, start=1):
p2, p46, p47 = pad(ref, '2'), pad(ref, '46'), pad(ref, '47')
cy = B.slot_y(i)
if not (p2 and p46 and p47):
bad.append(u'%s 的 2/46/47 脚没读到' % ref)
continue
if p2[2] != '+12V' or p2[0] > B.SLOT_CX:
bad.append(u'%s 的 2 脚 %s 在 x=%.2f,应为西侧 +12V' % (ref, p2[2], p2[0]))
if abs(p46[0] - B.pin_x(-1.5)) > 0.05 or abs(p46[1] - (cy - B.PAD_ROW_DY)) > 0.05:
bad.append(u'%s 的 D+ 在 (%.2f,%.2f),应在 (%.1f,%.2f)'
% (ref, p46[0], p46[1], B.pin_x(-1.5), cy - B.PAD_ROW_DY))
# 4. U20 天线朝东、尖落在缺口里
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
nk = B.notch_rect()
cx = pos['U20'][0]
if mx < cx + 5.0 or not (nk[1] <= my <= nk[3]) or not (nk[0] - 1.5 <= mx <= nk[0] + 0.5):
bad.append(u'U20 天线端(pin1/40 中点 %.2f,%.2f)没朝东对着缺口 x %.1f / y %.1f~%.1f'
% (mx, my, nk[0], nk[1], nk[3]))
# 5. 接口 / 输入链朝向
for jr in ('J1', 'J23'):
a6 = pad(jr, 'A6')
if a6 and a6[1] <= pos[jr][1]:
bad.append(u'%s 的信号脚在南(y=%.2f),口朝向不对' % (jr, a6[1]))
# D16:U37 的 SW(16 脚)在西、FB(7 脚)在东;L4 的 SW 端(1 脚)在东、正对 U37
sw, fb, l1p = pad('U37', '16'), pad('U37', '7'), pad('L4', '1')
if not (sw and fb and l1p):
bad.append(u'U37.16 / U37.7 / L4.1 没读到')
else:
if not (sw[0] < pos['U37'][0] < fb[0]):
bad.append(u'U37 的 SW 不在西、FB 不在东(SW x=%.2f / FB x=%.2f)' % (sw[0], fb[0]))
if not (l1p[0] > pos['L4'][0] and abs(l1p[1] - sw[1]) < 0.5):
bad.append(u'L4 的 SW 端没朝东对着 U37(L4.1 在 %.2f,%.2f)' % (l1p[0], l1p[1]))
if bad:
print(u'[X] 读回校验 %d 条不过:' % len(bad))
for t in bad[:25]:
print(u' ' + t)
else:
print(u'[OK] %d 件位姿 / 层逐件对上;离线镜像模型与板上焊盘最大差 %.3f mm;'
u'U32 口朝北 USB1 在西、插槽 12V 在西 D± 在南排、U20 天线朝东入缺口、J1/J23 口朝南、U37 SW 朝西接 L4 全对'
% (len(pos), worst[0]))
return bad
def route_fixed_pads(pads):
u"""2 层版遗留接口:4 层版没有 ROUTE_FIX,恒等变换。"""
return pads, []
if __name__ == '__main__':
if '--bbox' in sys.argv:
dump_bbox()
sys.exit(0)
if '--dump' in sys.argv:
dump_pads()
sys.exit(0)
if '--native' in sys.argv:
dump_pads()
rebuild_native()
sys.exit(0)
pos, unplaced, sz, aim = plan()
only = None
if '--only-hub' in sys.argv:
# 只动 U32 那一窝:其余件钉在板上现位(_base_place_live.json,--native / --dump 时刷新),
# 免得排布器因缓存(bbox 重抓、新件)的细微变化把别的分区也挪了。
live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
for k in pos:
if k not in HUB_REFS and k in live:
pos[k] = (live[k][0], live[k][1], live[k][2])
only = HUB_REFS
unplaced = [u for u in unplaced if u[0] in HUB_REFS]
if '--only-pd' in sys.argv:
# D16:只动南带 PD 入口那一窝,其余件钉在板上现位(同 --only-hub)
live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
for k in pos:
if k not in PD_REFS and k in live:
pos[k] = (live[k][0], live[k][1], live[k][2])
only = PD_REFS
unplaced = [u for u in unplaced if u[0] in PD_REFS]
if '--only-fan' in sys.argv:
# 只重排风扇驱动那一窝,**两只座钉在板上现位**(用户自己摆的,见 FAN_REFS 的注释)。
# ⚠ 座子是锚不是被排的件:`sat_target` 会把 Q6/R733 这些按「同网的那只脚」贴过去,
# 所以座子必须先钉死、且用板上真实位姿(rot90 竖放时四只脚沿 y 排,朝向完全变了)。
live = json.load(io.open(LIVE_POS_JSON, encoding='utf-8'))
for k in pos:
if k not in FAN_REFS and k in live:
pos[k] = (live[k][0], live[k][1], live[k][2])
only = FAN_REFS
unplaced = [u for u in unplaced if u[0] in FAN_REFS]
bad = selfcheck(pos, unplaced, sz)
for _name, _q in PD_FREE.items():
for _r in PD_REFS:
if _r in pos and _hit(body_rect(_r, pos[_r][0], pos[_r][1], pos[_r][2], sz), _q):
bad.append(u'%s 占了留空区「%s」' % (_r, _name))
nh = len(hand())
print(u'底板摆位:%d 件(手排 %d + 排布 %d);L1 %d 件 / L4 %d 件'
% (len(pos), nh, len(pos) - nh, sum(1 for r in pos if layer_of(r) == B.TOP),
sum(1 for r in pos if layer_of(r) == B.BOTTOM)))
if bad:
print(u'[X] 自检 %d 条不过:' % len(bad))
for t in bad[:30]:
print(u' ' + t)
else:
print(u'[OK] 自检全过(全摆上 / 不出板 / 同面不重叠 / 背面限高 / 螺钉 / 过孔带 / 车道 / 天线净空)')
report(pos, sz, aim)
io.open(POS_JSON, 'w', encoding='utf-8').write(
json.dumps({k: list(v) + [layer_of(k)] for k, v in pos.items()}, ensure_ascii=False, indent=0))
if '--push' in sys.argv:
# --only-xxx 时只拿本次下发范围里的件判能不能下发:范围外的问题(钉在现位的件)照打,但不挡
gate = bad
if only is not None:
gate = [t for t in bad if any(t.startswith(r + u' ') or (u' ' + r + u' ') in t or t.endswith(u' ' + r)
for r in only)]
if len(gate) < len(bad):
print(u' (其中 %d 条与本次下发的件无关,不挡下发)' % (len(bad) - len(gate)))
if gate:
print(u'!! 自检不过,不下发')
sys.exit(1)
push(pos, only)
verify(pos, sz)
elif '--verify' in sys.argv:
verify(pos, sz)