# -*- 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 底层 rot 90** 桶口朝南;**J23 底层 rot 180** 口朝南、信号脚朝北。 4. **Q1 底层 rot 180**:VIN_FUSED 排在南(进)、VIN_SW 排在北(出),9 A 顺着 +y 走。 ## 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 # 同面件间净距(装配下限,自检按它判) # 卫星件(排布器摆的小件)之间的**排布**间距。0.6 只是装配下限,不给布线留余量: # 第六 / 七轮剩下的信号网全卡在 U20 北沿那排上拉 / NTC 阻容、U32 西侧、mux 区 —— 0402 按 0.6 一字排开, # 两只脚、它们的 GND 孔和进出的线全挤在一起(2026-09-10 用户选定:加大小件间距重排)。 # 排不下时退回 GAP(`plan()` 里第二遍),所以不会出现「排不下」。 SAT_GAP = 1.5 STEP = 0.25 # 候选格点 = 布线网格 ANCHOR_AREA = 9.0 # 焊盘外框面积 > 这个算「锚」(Y1 10.4 要算进来:负载电容贴晶振) # ── 高度(mm):背面限高 B.H_BOTTOM ───────────────────────────────────── HEIGHT = { 'J1': 11.0, # DC-005 桶身 'C2': 12.0, 'C3': 12.0, # Φ8 直立电解 470 µF 'J26': 11.0, 'J27': 11.0, # 2510 4P 立式 'J33': 11.5, # 2.54 排针立式 'J23': 3.3, 'U20': 3.1, 'L3': 4.5, 'L2': 4.0, 'F1': 2.0, } H_DEFAULT = 2.0 # 焊盘外框量不到本体的件:真实本体(mm,封装原生朝向)。 BODY = { 'J1': (14.5, 9.0), # DC-005 桶身(桶口朝封装 −x) '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 管 } # 本体中心相对器件原点的偏移(封装原生坐标)。R1 实测 DC-005 本体 x −10.68~+3.92。 BODY_OFF = {'J1': (-3.38, 0.0), '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 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} 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' # 监测 DC-005 接触热 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) 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()) 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] 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 西侧(XTAL1/2 在西沿北段),抬 3.1 让开 上行/USB6 的过孔带。 H['Y1'] = (round(HUB_CX - 8.7, 3), round(HUB_CY + 3.1, 3), 270) # ── 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:输入链自南向北,电流顺 +y ──────────────────────────────────────── # J1 桶身 9 × 14.5,本体 x 8~17 / y 0~14.5(让开 H1 的 3.2 圈)。 H['J1'] = (12.5, 10.63, 90) H['F1'] = (12.5, 17.75, 90) # VIN_RAW 在南、VIN_FUSED 在北 H['Q1'] = (12.5, 24.5, 180) 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 H['RT602'] = (19.5, 8.0, 0) # 贴 J1 东侧,测 DC-005 接触热 # 泄放链:Q3 在西头,R9A→R9E 自西向东一字排,每颗在 90/270 里挑,让相邻两颗的公共网脚在同一侧 H['Q3'] = (2.95, 52.0, 180) # 漏极排($1N114)朝北 prev = ('Q3', '$1N114') 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:两只风扇座贴北板边内侧 ───────────────────────────────────────── H['J26'] = (13.5, 94.5, 0) H['J27'] = (13.5, 88.0, 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]] 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) key = (zd > 0, 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)] def _ord(r): a = sz[r][0] * sz[r][1] nn = len(nat.get(r, [])) # 去耦排在其它两脚件前面:它们必须贴脚,先占近处(第一版 SAT_GAP 把 C746 挤到 U32 脚 20 mm 外) return (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))] # 第一遍按 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): 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) 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: if not B.height_ok(height(a)): bad.append(u'%s 高 %.1f 超背面限高 %.0f' % (a, height(a), B.H_BOTTOM)) if a != 'U20': for q in fixed: if _hit(br, q): bad.append(u'%s 压到禁区 %s' % (a, tuple(round(v, 2) for v in q))) break for o, q in rings: if o != a and _hit(br, q): bad.append(u'%s 进了 %s 的扇出圈' % (a, o)) for b in items[i + 1:]: if layer_of(b) != lay: 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'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): u"""layer + x + y + rotation 一次 modify。⚠ rotation 0 会被当成「未传」,传 360。""" 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())] 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""" 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; """ % 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""" 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; """ % 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)) if len(d) != 138: raise SystemExit(u'板上读到 %d 件,应为 138;拒绝覆盖 LIVE 快照' % len(d)) out = {ref: [round(float(v[0]), 4), round(float(v[1]), 4), int(round(v[2])) % 360, v[3]] for ref, v in sorted(d.items())} io.open(LIVE_POS_JSON, 'w', encoding='utf-8').write(json.dumps(out, ensure_ascii=False, indent=2)) 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 = {}, {} 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)) 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. 离线镜像模型 == 板上焊盘(全部件逐脚) nat = native() worst = (0.0, '') for ref, (x, y, rot) in pos.items(): for pn, _n, px, py in pads_at(nat, ref, x, y, rot): gs = allp.get((ref, pn)) if gs: e = min(math.hypot(gx - px, gy - py) for gx, gy in gs) 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. 接口 / 输入链朝向 j1, j1c = pad('J1', '1'), pos['J1'] if j1 and j1[1] <= j1c[1]: bad.append(u'J1 的 VIN_RAW 脚在南(y=%.2f),桶口朝向不对' % j1[1]) a6 = pad('J23', 'A6') if a6 and a6[1] <= pos['J23'][1]: bad.append(u'J23 的信号脚在南(y=%.2f),口朝向不对' % a6[1]) qs = [pad('Q1', n) for n in ('1', '2', '3', '5', '6', '7', '8')] if all(qs) and not (sum(q[1] for q in qs[3:]) / 4 > sum(q[1] for q in qs[:3]) / 3): bad.append(u'Q1 的 VIN_SW 排不在 VIN_FUSED 排北边') 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/Q1 朝向全对' % (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() bad = selfcheck(pos, unplaced, sz) 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: if bad: print(u'!! 自检不过,不下发') sys.exit(1) push(pos) verify(pos, sz) elif '--verify' in sys.argv: verify(pos, sz)