# -*- 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 `:只在 `--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 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+1q[1]))].sort((a,b)=>a-b); for (let b2=0; b2+1a-b); for (let k=0; k+1 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)