# -*- coding: utf-8 -*- u"""下板 PCB1 的**信号布线** —— 复用载板 `pcb2_route_sig.py` 的栅格迷宫引擎。 /c/Python314/python .claude/pcb1_route_sig.py # 全部算 + 校核,不下发 /c/Python314/python .claude/pcb1_route_sig.py --net SCL SDA # 只算这几个网络 /c/Python314/python .claude/pcb1_route_sig.py --limit 20 # 只算前 20 个(试水) /c/Python314/python .claude/pcb1_route_sig.py --push # 校核跑绿才下发 /c/Python314/python .claude/pcb1_route_sig.py --ripup # 拆线重布(见下面第 5 条) /c/Python314/python .claude/pcb1_route_sig.py --verify # 只按实测铜核一遍 /c/Python314/python .claude/pcb1_route_sig.py --refresh # 重抓板上快照(动过板必加) /c/Python314/python .claude/pcb1_route_sig.py --clean --force-clean --push # 整块重来 ## 和载板那一版最要紧的五处不同 **1. 板上已经有铜了 —— 这一版只「补」,绝不「整网重画」。** 载板那个布线器的做法是「本脚本负责的网络整网删掉重画」。下板现在有 **691 条走线 + 495 个过孔 + 15 块铺铜**(扇出 + `pcb1_route_pwr.py` 的五路功率级), 照抄过来第一步就会把 §10.2 那四条关键回路抹掉。 ⇒ 这里的任务表来自 `pcb1_netlist.py`:把每个网络**现有的铜**算成若干连通碎片, 只补「碎片之间还差的那几条」。删除也只删本脚本自己画过的(按坐标对账,同 `pcb1_route_pwr.clean()`)。 **2. 铺铜是障碍。** 顶层三块 8 A 宽铜(`VINRAW/VINFUSED/VINSW-TOP`)和底层五块 `VOn_P` 小岛占了外层不少地方,而载板那一版的障碍表里根本没有「铺铜」这个东西。 这里把 `pcb1_pour.REGIONS` 里 layer 1/2 的八块**光栅化成矩形条**进障碍表 (内层的 GND / +12V / +3V3 平面不进 —— 它们不挡外层走线;新过孔穿过去时 铺铜会自己挖反焊盘,所以**布完线必须重跑 `pcb1_pour.py --push`**)。 **3. 取样脚(Kelvin)必须连到指定的那个焊盘,不是「最近的同网铜」。** `U13.9`(VIN−) 离 +12V 平面只有几毫米,布线器会毫不犹豫地就近扎一个孔下去 —— 连通性、DRC、净距三样全过,而**分流电阻两端的取样点错了**,8 A 下读数直接失真。 ⇒ 这类连接写在 `FORCE` 表里,先于一切布线单独连,两端都钉死在焊盘上。 (五路 `U*.9 → Rs*01.1` 已经由 `pcb1_route_pwr.py` 画好,这里只剩 U13 那三脚。) **4. 线宽按 `pcb1_rules` 的网络分类取,但要被「落脚的焊盘」封顶。** `VO1` 是 `PWR4A`(默认 1.5 mm),可它还差的两条是到 `R101.1` / `R109.1` —— 分压电阻的抽头,0402 焊盘只有 0.50 × 0.541。1.5 mm 的线根本进不去, 而收窄的下限又被规则卡在 **0.30**(`strokeWidthMin`,细过它 DRC 报「物理错误·导线」)。 ⇒ `w = clamp(min(类默认, 2×两端最小焊盘短边), 类最小, 类默认)`。 **5. 拆线重布(`--ripup`):能拆的只有「本脚本自己画过的」。** 一遍过的布线器会被自己先布的线堵死 —— §26.14 剩下的 91 条几乎全是这一类 (单独布每一条都通)。`--ripup` 打开之后,一条连接在「换出线方向 + 放回队尾再等一轮」 都失败之后,会去拆挡路的**自己的**旧路径,重算,再把被拆的按原端点布回来。 扇出 / 功率级 / 铺铜 / 焊盘一律不动,Kelvin 和 USB 差分对也钉死不拆。 认「哪些铜是自己画的」靠 `_pcb1_sig_plan_all.json` + `own_of()` 的覆盖判据, **不是坐标精确比对**(EasyEDA 会合并 / 劈开线段,精确比对只认得出 59%)。 ## 校核(下发前后同一段代码) 1. `pcb2_route_dp.check()`:线/过孔 vs 焊盘 / 过孔 / 旧线 / 本轮新线,逐对按 0.20 判 2. `hole_check()`:钻孔到钻孔 0.30(**不分网络**,`check()` 不管这一条) 3. `pour_check()`:线/过孔 vs 外层铺铜的**真实多边形**净距(栅格漏得掉 0.1 mm,§26.11) 4. `sharp_corners()`:> 46° 的拐角(全板不许直角走线) 5. 连通性:下发后 `pcb1_netlist.py` 再跑一遍,还欠几条要归零 """ import importlib.util import io import json import math import os import sys import time HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import numpy as np # noqa: E402 import pcb1_netlist as NL # noqa: E402 import pcb1_pour as PO # noqa: E402 import pcb1_rules as RU # noqa: E402 from eda import run # noqa: E402 # ══════════════════ 引擎:载板那一版,换掉板子相关的常量 ══════════════════ _spec = importlib.util.spec_from_file_location( 'pcb1_route_engine', os.path.join(HERE, 'pcb2_route_sig.py')) E = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(E) D = sys.modules['pcb2_route_dp'] # 引擎内部的几何 / 校核模块 PCB = '7e9415a16a909c51' BW, BH = 160.0, 125.0 MIL = 39.3700787401575 L_TOP, L_BOT = 1, 2 CLR = 0.20 VIA_D, VIA_H = 0.61, 0.305 HOLE_CLR = 0.30 # ⚠️ 这几行是**换板子**,漏一行就是拿载板的板框去布下板。 # `_CORNERS` 是模块加载时按旧 BW/BH 算好的常量,必须跟着重算。 E.BW, E.BH = BW, BH E.CORNER_R = 5.0 E._CORNERS = ((E.CORNER_R, E.CORNER_R), (BW - E.CORNER_R, E.CORNER_R), (E.CORNER_R, BH - E.CORNER_R), (BW - E.CORNER_R, BH - E.CORNER_R)) E.PCB = PCB D.PAIR_OF = {'USB_DP': 'USB_DM', 'USB_DM': 'USB_DP'} # 下板只有这一对 # ⚠️ **路径 key 的分隔符不能用 '#'**。引擎的 `netof()` 是 `netof(key)`, # 而下板有两个网络自己就带井号:`KEY_OE#` 和 `IOEXP_INT#`。 # `'IOEXP_INT#' + '#0'` 拆回来是 `IOEXP_INT`,于是校核时它和自己的焊盘被当成异网 —— # 实测报「线-焊盘 IOEXP_INT vs U20.31(IOEXP_INT#) 净距 −0.100」, # 看上去像压线,其实两者本来就该连在一起。改用 NUL 当分隔符,网名里不可能有。 KSEP = chr(0) D.netof = lambda key: key.split(KSEP)[0] def netof(key): return key.split(KSEP)[0] _GUARD = ('BW', 'BH', '_CORNERS', 'GRID', 'CLR', 'VIA_R', 'VIA_H', 'HOLE_CLR', 'VIA_COST', 'BBOX_PAD', 'MAX_CELLS', 'Field', 'route_grid', 'polish', 'Comp', '_connect', '_widths', 'entry_stub', 'clear_exact', 'expand', 'sh_rect', 'sh_via', 'sh_seg', '_mask', '_end_via', '_snap_end') _missing = [k for k in _GUARD if not hasattr(E, k)] if _missing: sys.exit(u'引擎 pcb2_route_sig.py 里少了 %s —— 它被改过,先看一眼再跑' % _missing) if abs(E.CLR - CLR) > 1e-9 or abs(E.VIA_R - VIA_D / 2) > 1e-9 \ or abs(E.VIA_H - VIA_H) > 1e-9 or abs(E.HOLE_CLR - HOLE_CLR) > 1e-9: sys.exit(u'引擎的净距/过孔常量和下板对不上:CLR=%s VIA_R=%s VIA_H=%s HOLE_CLR=%s' % (E.CLR, E.VIA_R, E.VIA_H, E.HOLE_CLR)) SNAP = os.path.join(HERE, '_pcb1_place_now.json') ROUTES = os.path.join(HERE, '_pcb1_sig_routes.json') RAWROUTES = os.path.join(HERE, '_pcb1_sig_plan.json') # 算好的路径,留着不重算就能改校核/下发 # ══════════════════ 线宽:按 pcb1_rules 的网络分类 ══════════════════ W_SIG = 0.20 # 一般信号(默认类 strokeWidthMin 是 0.102,留足余量) W_SIG_MIN = 0.15 # 一般信号挤不过去时的下限 _AUTO = RU.resolve_auto() NET_CLASS = RU.net_track_full(_AUTO) # 网名 -> 'PWR4A' 一类,含反查出来的自动网 def wclass(net): u"""(默认线宽, 最小线宽)。没分类的网络走一般信号那一档。""" c = NET_CLASS.get(net) if c is None: return W_SIG, W_SIG_MIN r = RU.TRACK_RULES[c] return r['strokeWidthDefault'], r['strokeWidthMin'] def width_for(net, pads_a, pads_b): u"""这一条连接用多宽。**被两端最小的焊盘封顶**(理由见文件头第 4 条)。""" dflt, wmin = wclass(net) cap = 1e9 for q in list(pads_a) + list(pads_b): cap = min(cap, 2.0 * min(q['hw'], q['hh'])) w = min(dflt, cap) if cap < 1e8 else dflt return max(wmin, min(dflt, round(w, 3))) # ══════════════════ 必须连到指定焊盘的那几条(Kelvin)══════════════════ # 格式:(A 焊盘, B 焊盘, 说明)。线宽照 width_for 算,两端都钉在焊盘中心。 # ⚠️ 五路的 `U*.9 → Rs*01.1` 不在这里 —— `pcb1_route_pwr.py` 已经画好了(§26.13)。 # ⛔ 不交给这个布线器的网络。 # `USB_DP` / `USB_DM` 是**一对差分**(规则 `USBPAIR`,W 0.28 / S 0.18), # U20.13/14 到 J22 有 55 mm。这个布线器一次只认一根线,两根各走各的最短路 # 就不再是一对了(耦合、偏斜、参考面全散)。⇒ 单独走 `pcb1_route_usb.py`: # 先按「一整束」的宽度布一条主路径,再把另一根按 0.46 mm 平行偏移出来。 SKIP = {'USB_DP', 'USB_DM'} FORCE = [ ('U13.10', 'Rs6.1', u'INA226 的 VIN+:取样点必须在分流电阻 Rs6 上游焊盘上(§10.4)'), ('U13.9', 'Rs6.2', u'INA226 的 VIN−:取样点必须在 Rs6 下游焊盘上,不许就近扎进 +12V 平面'), ('U13.8', 'U13.9', u'VBUS 取样并到 VIN− 的 Kelvin 线上(µA 级,§10.4)'), ] # ══════════════════ POLYGON 焊盘:外形不是包围盒 ══════════════════ # ⚠️⚠️ **这是这一轮抓到的真错。** 五路 Buck(TPS54622 VQFN-14)的散热盘 pin 15 是一个 # **带缺口的多边形**:2.032 x 4.320 的包围盒,但四个角上各挖掉一块,让出 pin 1/7/8/14。 # 按包围盒当障碍的话,那四个脚**整个泡在 GND 铜里** —— `entry_stub` 一个出线方向都找不到, # `BUCK_PG`(U1~U5 的 pin 14)于是「五路一条都布不通」,而报出来的样子只是「布不通」。 # 板上一共 11 个 POLYGON 焊盘(5 个 Buck EP + U6/U7 EP + J22 的 4 个屏蔽脚), # 这里按**真实轮廓**光栅化成矩形条再进障碍表。 # (同一件事在 §26.13 已经踩过一次:那次是 POLYGON 焊盘的 `hole` 是假的。 # ⇒ **POLYGON 焊盘的任何一个标量属性都不能直接当几何用。**) POLYPADS = os.path.join(HERE, '_pcb1_polypads.json') JS_POLY = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1500)); const MIL = 39.3700787401575, out = []; for (const p of await eda.pcb_PrimitivePad.getAll()) { if (!Array.isArray(p.pad) || p.pad[0] !== 'POLYGON') continue; out.push({net: p.net || '', x: +(p.x/MIL).toFixed(4), y: +(p.y/MIL).toFixed(4), num: String(p.padNumber || p.number || ''), layer: p.layer, pts: p.pad[1]}); } return out; """ % PCB def _poly_rects(pts_mil, g=0.02, grow=0.02): u"""多边形(绝对 mil 坐标,中间夹着 'L' 一类的字符串标记)-> 一串矩形条。""" nums = [v for v in pts_mil if isinstance(v, (int, float))] poly = [(nums[i] / MIL, nums[i + 1] / MIL) for i in range(0, len(nums) - 1, 2)] xs = [p[0] for p in poly] ys = [p[1] for p in poly] i0, i1 = int(min(xs) / g) - 1, int(max(xs) / g) + 1 j0, j1 = int(min(ys) / g) - 1, int(max(ys) / g) + 1 rows = {} for j in range(j0, j1 + 1): yc = (j + 0.5) * g spans, run_i = [], None for i in range(i0, i1 + 2): ins = i <= i1 and PO.inside(poly, (i + 0.5) * g, yc) if ins and run_i is None: run_i = i elif not ins and run_i is not None: spans.append((run_i, i)) run_i = None rows[j] = spans out, open_ = [], {} for j in range(j0, j1 + 2): cur = set(rows.get(j, ())) for sp in list(open_): if sp not in cur: ja = open_.pop(sp) xa, xb = sp[0] * g - grow, sp[1] * g + grow ya, yb = ja * g - grow, j * g + grow out.append(((xa + xb) / 2, (ya + yb) / 2, (xb - xa) / 2, (yb - ya) / 2)) for sp in cur: open_.setdefault(sp, j) return out def poly_pads(refresh=False): if refresh or not os.path.exists(POLYPADS): r = run(JS_POLY, timeout=300) if not r.get('success'): sys.exit(u'读 POLYGON 焊盘失败: ' + json.dumps(r, ensure_ascii=False)[:400]) io.open(POLYPADS, 'w', encoding='utf-8').write( json.dumps(r['result'], ensure_ascii=False)) return json.load(io.open(POLYPADS, encoding='utf-8')) # ══════════════════ 读板 ══════════════════ def board(refresh=False): u"""返回引擎认的 (pads, vias, lines)。`lay == 12` = 通孔焊盘。 POLYGON 焊盘多带一个 `rects`(真实轮廓切成的矩形条); 凡是拿焊盘当**几何**用的地方(障碍 / 校核 / relax / Comp)一律走 `pad_rects()`。 """ pads0, vias, lines = NL.geom(refresh) pads = [dict(net=p['net'], x=p['x'], y=p['y'], hw=p['hw'], hh=p['hh'], lay=(12 if p['th'] else p['plane']), ref=p['ref'], num=p['num'], tag=p['tag']) for p in pads0] pol = poly_pads(refresh) used = 0 for e in pol: best, bd = None, 0.05 for q in pads: if q['net'] != e['net'] or 'rects' in q: continue d = abs(q['x'] - e['x']) + abs(q['y'] - e['y']) if d < bd: best, bd = q, d if best is None: sys.exit(u'POLYGON 焊盘 (%s %s @%.3f,%.3f) 在元件表里配不上 —— ' u'pcb1-parts.json 过期了?' % (e['net'], e['num'], e['x'], e['y'])) best['rects'] = _poly_rects(e['pts']) used += 1 if used != len(pol): sys.exit(u'POLYGON 焊盘配对 %d / %d' % (used, len(pol))) return pads, vias, lines def pad_rects(q): u"""焊盘的真实占铜:普通盘就是它自己,POLYGON 盘是一串矩形条。""" return q.get('rects') or [(q['x'], q['y'], q['hw'], q['hh'])] def pad_layers(q): return (L_TOP, L_BOT) if q['lay'] == 12 else (q['lay'],) def expand_pads(pads): u"""把 POLYGON 焊盘摊成若干个矩形焊盘,**给几何用**(位号/引脚号原样带着, 这样 `check()` 报错时还认得出是谁)。""" out = [] for q in pads: rs = pad_rects(q) if len(rs) == 1 and 'rects' not in q: out.append(q) continue for cx, cy, hw, hh in rs: e = dict(q) e['x'], e['y'], e['hw'], e['hh'] = cx, cy, hw, hh e.pop('rects', None) out.append(e) return out def pour_rects(g=0.1, grow=0.1): u"""外层(Top/Bottom)铺铜 -> 一串矩形障碍条。 ⚠️ 只有 layer 1 / 2 的铺铜进障碍表。内层 GND / +12V / +3V3 平面**不挡外层走线**, 把它们算进来等于整块板不许走线。代价是:新过孔穿过内层平面时那里还没有反焊盘 ⇒ **布完线必须重跑 `pcb1_pour.py --push`**,让平面按新的过孔重铺。 光栅化按 0.1 mm 取格心是否在多边形内,把每一行的连续段并成一条, **再把上下相邻、左右端点一样的行并成一块**(不并的话 `VINSW-TOP` 一块就是几百条, 每建一次搜索窗都要把它们全过一遍),最后每块往外胀 `grow` —— 偏保守(多挡一点),不会漏。 """ out = [] for r in PO.REGIONS: if r['layer'] not in (L_TOP, L_BOT): continue poly = r['poly'] xs = [p[0] for p in poly] ys = [p[1] for p in poly] i0, i1 = int(min(xs) / g) - 1, int(max(xs) / g) + 1 j0, j1 = int(min(ys) / g) - 1, int(max(ys) / g) + 1 rows = {} for j in range(j0, j1 + 1): yc = (j + 0.5) * g spans, run_i = [], None for i in range(i0, i1 + 2): ins = i <= i1 and PO.inside(poly, (i + 0.5) * g, yc) if ins and run_i is None: run_i = i elif not ins and run_i is not None: spans.append((run_i, i)) run_i = None rows[j] = spans open_ = {} # (ia, ib) -> 起始行 for j in range(j0, j1 + 2): cur = set(rows.get(j, ())) for sp in list(open_): if sp not in cur: ja = open_.pop(sp) xa, xb = sp[0] * g - grow, sp[1] * g + grow ya, yb = ja * g - grow, j * g + grow out.append((E.sh_rect((xa + xb) / 2, (ya + yb) / 2, (xb - xa) / 2, (yb - ya) / 2, (r['layer'],)), r['net'])) for sp in cur: open_.setdefault(sp, j) return out # ESP32 模组天线区:**禁止铺铜、也禁止走线**(设计文档 §10.3)。 # `pcb1_pour` 已经把它从每一块铜面里挖掉了,但布线器不知道这回事 —— # 板框判据只管板外,天线区在板内。这里当成一块「谁都不许进」的障碍。 ANT = PO.ANT def pour_pads(pours): u"""把铺铜矩形条包装成「假焊盘」,好让引擎的 `check()` / `chamfer_routes()` 也把铺铜算进净距。 ⚠️ 不这么做的话 **45° 切角会切进铺铜里**:切角是往拐角**内侧**补铜, 而 `chamfer_routes` 的 `seg_slack()` 只认焊盘 / 过孔 / 走线三样。 实测 `SDA` 在 (6.10, 65.50) 被切进 `VO1_P` 小岛,只剩 0.043。 """ return [dict(net=n, x=s[1], y=s[2], hw=s[3], hh=s[4], lay=s[-1][0], ref='POUR', num=n, tag='POUR.' + n) for s, n in pours] def obstacles_of(pads, vias, lines, pours, lown=None, vown=None): u"""障碍表。给了归属表(`own_of()`)的话,**板上的线和孔会带上「这是本脚本 哪几条路径画的」** —— 拆线重布靠这个把自己上一版的铜摘下来。""" # ⚠️ 焊盘一律走 `expand_pads()`:POLYGON 盘的包围盒会把邻脚整个吞掉(见上面那一段) obs = Obs() obs.extend(E.shapes_of(expand_pads(pads), (), ())) for i, v in enumerate(vias): obs.append((E.sh_via(v['x'], v['y'], v['r']), v['net']), (vown[i] or None) if vown else None) for i, l in enumerate(lines): obs.append((E.sh_seg(l['x0'], l['y0'], l['x1'], l['y1'], l['w'], l['lay']), l['net']), (lown[i] or None) if lown else None) obs.extend(pours) x0, y0, x1, y1 = ANT obs.append((E.sh_rect((x0 + x1) / 2, (y0 + y1) / 2, (x1 - x0) / 2, (y1 - y0) / 2, (L_TOP, L_BOT)), '\0ANT')) # 网名不可能撞上,永远是障碍 return obs # ══════════════════ 任务表 ══════════════════ def jobs(pads, want=None, refresh=False, extra_lines=(), extra_vias=(), skip=()): u"""每个网络还差哪几段:返回 [(net, [碎片, ...])],碎片是 dict(pads, vias, lines)。 ⚠️ `extra_*` 是**本轮已经画好、还没下发**的铜(Kelvin 那几条)。 不把它们算进来的话,任务表会把 `U13.9` 再当成一个孤立焊盘连第二遍 —— 两条线并在同一个网上,DRC 一声不吭(同网重叠合法),而板上多一段没人要的铜。 """ pgrids = NL.pour_grids(refresh) nl_pads, nl_vias, nl_lines = NL.geom(False) nl_lines = list(nl_lines) + list(extra_lines) nl_vias = list(nl_vias) + list(extra_vias) bytag = {p['tag']: p for p in pads} out = [] nets = sorted(set(p['net'] for p in pads if p['net'])) for net in nets: if want and net not in want: continue if net in skip: continue if sum(1 for p in pads if p['net'] == net) < 2: continue items, groups = NL.analyze(net, nl_pads, nl_vias, nl_lines, pgrids) real = [gp for gp in groups if any(items[i][0] != 'line' for i in gp)] if len(real) <= 1: continue frags = [] for gp in real: fp, fv, fl = [], [], [] for i in gp: k, _lay, o, _lab = items[i] if k == 'pad': q = bytag.get(o['tag']) if q: fp.append(q) elif k == 'via': fv.append(o) elif k == 'line': fl.append(o) frags.append(dict(pads=fp, vias=fv, lines=fl)) out.append((net, frags)) return out def comp_of(frag): u"""碎片 -> 引擎的 `Comp`。单焊盘的碎片把起终点钉在焊盘中心(见 `Comp` 注释)。""" shp, xs, ys = [], [], [] for q in frag['pads']: lay = pad_layers(q) for cx, cy, hw, hh in pad_rects(q): shp.append(E.sh_rect(cx, cy, hw, hh, lay)) xs.append(q['x']) ys.append(q['y']) for v in frag['vias']: shp.append(E.sh_via(v['x'], v['y'], v['r'])) xs.append(v['x']) ys.append(v['y']) for l in frag['lines']: shp.append(E.sh_seg(l['x0'], l['y0'], l['x1'], l['y1'], l['w'], l['lay'])) xs += [l['x0'], l['x1']] ys += [l['y0'], l['y1']] anchor = (sum(xs) / len(xs), sum(ys) / len(ys)) if xs else (0.0, 0.0) if len(frag['pads']) == 1 and not frag['vias'] and not frag['lines']: q = frag['pads'][0] lay = (L_TOP, L_BOT) if q['lay'] == 12 else (q['lay'],) c = E.Comp(shp, (q['x'], q['y']), center=(q['x'], q['y']), layers=lay) c.frag_pads = list(frag['pads']) return c c = E.Comp(shp, anchor) c.frag_pads = list(frag['pads']) return c def frag_span(frags): xs, ys = [], [] for f in frags: for q in f['pads']: xs.append(q['x']) ys.append(q['y']) if not xs: return 0.0 return math.hypot(max(xs) - min(xs), max(ys) - min(ys)) # ══════════════════ 连一段(引擎 `_connect` 的下板版)══════════════════ def _shbox(s): if s[0] == 'rect': return (s[1] - s[3], s[2] - s[4], s[1] + s[3], s[2] + s[4]) if s[0] == 'via': return (s[1] - s[3], s[2] - s[3], s[1] + s[3], s[2] + s[3]) return (min(s[1], s[3]) - s[5], min(s[2], s[4]) - s[5], max(s[1], s[3]) + s[5], max(s[2], s[4]) + s[5]) def _near_shapes(a, xy, frac): u"""`a` 里离 `xy` 最近的那一批形状(距离 <= 最近距离 x frac + 5 mm)。""" if frac >= 1e6 or len(a.shapes) <= 1: return list(a.shapes) d = [] for s in a.shapes: x0, y0, x1, y1 = _shbox(s) dx = max(x0 - xy[0], 0.0, xy[0] - x1) dy = max(y0 - xy[1], 0.0, xy[1] - y1) d.append((math.hypot(dx, dy), s)) dmin = min(t[0] for t in d) lim = dmin * frac + 5.0 return [s for dd, s in d if dd <= lim] def _geo_ok(net, segs, vs, obstacles): u"""按**真实几何**(不过格子)卡一遍这条路径。 ⚠️⚠️ **栅格说通不等于真的通。** `relax` 把本网焊盘整块放开(不放开根本出不了焊盘), 可放开的是「焊盘那块铜」;而一条**比焊盘还宽**的线画在焊盘上会从边上**溢出去**, 溢出的那一圈没有任何校核管得着 —— 栅格不管(那些格被 relax 放行了)、 `_snap_end` 把端点拽到焊盘中心那一小段更是从来没进过场。 实测:`$1N227` 用 2.0 的线接 0.91 的 `DZ1.1`,多出来的 1 mm 压在 `POGO1_PWR` 上 −0.199。 ⇒ 每条路径返回之前都要过这一关;过不去就换下一档线宽 / 下一个窗口。 """ for sx0, sy0, sx1, sy1, sl, sw in segs: if E.clear_exact(sx0, sy0, sx1, sy1, sl, sw, net, obstacles) < CLR - 1e-4: return False for vx, vy in vs: for L in (L_TOP, L_BOT): if E.clear_exact(vx, vy, vx, vy, L, VIA_D, net, obstacles) < CLR - 1e-4: return False return True def connect(net, a, b, obstacles, widths, relax=None): u"""把两坨铜连起来。窗口先小后大、栅格先粗后细,找到就返回 `(path, shapes)`。 ## 和引擎那一版的差别:**窗口不许用「整坨 a」的包围盒** 下板上一个网络布到一半的 `cur` 常常已经横跨整块板(`+3V3` 64 个焊盘、 `SCL`/`SDA` 各 14 个)。引擎的 `all` 档拿 `a.shapes + b.shapes` 的包围盒开窗, 那就是 160 x 125 的整板 —— 2.5 M 格 > `MAX_CELLS`,栅格被自动放粗到 0.2, 判据里的 QERR 跟着从 0.071 涨到 0.141,**0.2 mm 的线要求走廊净宽 0.88 mm**。 于是「单独布能通、全量布布不通」:`SDA` / `INA_ALERT` / `BUCK_PG` 都栽在这里, 而报出来的样子是「布不通」,看不出根因是栅格精度。 ⇒ 这里按「`a` 里**离目标最近的那一批**形状」开窗,一档一档放宽(1.05 / 1.5 / 3 / 全部)。 两点之间的窗口是一条走廊,长边再长也就一两千格,短边只有几十 mm —— **0.1 mm 的栅格保得住**。 """ if not isinstance(widths, (list, tuple)): widths = [widths] # ⚠️ **拆线重布会把一个碎片的铜拆到一根不剩**,于是 `Comp.shapes` 是空表, # 下面 `E._bbox()` 里 `min([])` 直接 `ValueError` 把整轮打断 —— # 而 route_sig 是**跑完才一次性下发**的,一崩就等于整轮白跑 # (2026-09-06 实测:拆掉 530 段旧映射铜之后重布,跑到一半崩在这里, # 板上"拆了没补")。空碎片当成"这一条布不通",别让它掀桌子。 if not a.shapes or not b.shapes: return None # ⚠️ **线宽阶梯放在窗口里面。** `Field` 只和障碍/栅格有关,和线宽无关 —— # 放外面的话失败一次要重建 3 遍同样的场,实测整轮慢 3 倍。 tries = ((0.0, 0, 1), (0.0, 0, 2), (0.0, 0, 4), (0.0, 1, 2), (0.0, 1, 4), (1.05, 0, 1), (1.05, 0, 2), (1.05, 1, 1), (3.0, 0, 1), (3.0, 0, 2), (3.0, 1, 1), (1e9, 0, 1), (1e9, 1, 1), (1e9, 2, 4)) for frac, grow, fine in tries: if frac == 0.0: # 只用目标附近开窗(引擎的 'tgt' 档) x0, y0, x1, y1 = E._bbox(b.shapes, E.BBOX_PAD + grow * 20.0) x0, y0, x1, y1 = max(0.0, x0), max(0.0, y0), min(BW, x1), min(BH, y1) sub = [s for s in a.shapes if _shbox(s)[2] > x0 and _shbox(s)[0] < x1 and _shbox(s)[3] > y0 and _shbox(s)[1] < y1] if not sub: continue else: sub = _near_shapes(a, b.anchor, frac) x0, y0, x1, y1 = E._bbox(sub + b.shapes, E.BBOX_PAD + grow * 20.0) x0, y0, x1, y1 = max(0.0, x0), max(0.0, y0), min(BW, x1), min(BH, y1) a_use = E.Comp(sub, a.anchor, center=a.center, layers=a.layers) g = E.GRID / float(fine) while ((x1 - x0) / g + 2) * ((y1 - y0) / g + 2) > E.MAX_CELLS: g *= 2 f = E.Field(obstacles, (x0, y0, x1, y1), skip_net=net, grid=g) if relax: f.set_relax(relax) src, tg = E._mask(f, a_use), E._mask(f, b) got = None for wtry in widths: cells = E.route_grid(f, src, tg, wtry) if cells is None: continue w = wtry path = [] for lay, xy in E.polish(f, E._runs(cells), w): for x, y in xy: path.append((round(float(x), 3), round(float(y), 3), lay, w)) path = E._dedup(path) if a.center: E._snap_end(path, a.center, False) if b.center: E._snap_end(path, b.center, True) path = E._end_via(path) segs, vs = E.expand(path) if _geo_ok(net, segs, vs, obstacles): got = (path, segs, vs) break if got is None: continue path, segs, vs = got shp = [E.sh_seg(sx0, sy0, sx1, sy1, sw, sl) for sx0, sy0, sx1, sy1, sl, sw in segs] shp += [E.sh_via(vx, vy) for vx, vy in vs] return path, shp return None # ══════════════════ 逃逸布线:0.5 脚距的脚要用细栅格先走出来 ══════════════════ # # ⚠️⚠️ **这一段是这一轮最要紧的一条。** 0.5 mm 脚距的 MSOP-10 / QFN 中间脚, # 两侧邻脚各占 0.5 mm,隔壁的扇出过孔又常常正对着自己的出线走廊。 # 算一算 `U8.3`(INA226 的 ALERT):西边 1.00 mm 是 U8.2 的 GND 扇出孔, # 东边 1.00 mm 是 U8.4 的出线段 —— # # 需要 = 0.305(孔铜) + 0.20(净距) + w/2 + [w/2 + 0.20 + 0.10(邻线半宽)] # # w = 0.20 时是 **1.005 mm**,而只有 **1.00 mm**:差 5 µm。 # w = 0.15 时是 0.955 mm,**放得下** —— 但 0.1 mm 的栅格上判据要各加一个 QERR 0.0707, # 变成 1.096 mm,于是「明明放得下」在栅格上整片不可达。 # 0.025 mm 的栅格 QERR 只有 0.0177,同一条缝就通了。 # # ⇒ 凡是**在 0.1 mm 栅格上出不来**的脚,先在焊盘周围 3 mm 内用 **0.025 mm 栅格** # 单独把它走出去(半径 `ESC_R` 的环就是终点),再把这一小段当成本网已有的铜 # 交给主布线器。全板 563 个要接的孤立焊盘里只有 20 来个走这条路,代价可以接受。 ESC_R = 4.0 # 逃逸环半径(mm):走出这个圈才算真的脱身 # 2.5 不够:INA226 的两排脚之间就有 2.6 mm,走到 2.5 还在芯片肚子里, # 主布线器接手之后照样出不去(实测 `U8.3` 的逃逸段停在 (14.75,51.35),仍在夹层里)。 ESC_GRID = 0.025 # 逃逸用的细栅格 def _ring_mask(f, q, r): xs = f.x0 + np.arange(f.nx) * f.g ys = f.y0 + np.arange(f.ny) * f.g XX, YY = np.meshgrid(xs, ys) m = np.hypot(XX - q['x'], YY - q['y']) >= r return {L_TOP: m, L_BOT: m} def sx_sy(q, st): return st[0] if st else (q['x'], q['y']) def breathes(q, st, obstacles, w, grid=None): u"""在 `grid` 栅格上,这个脚能不能走到 ESC_R 开外。返回 (f, src, cells)。""" grid = grid or E.GRID sx, sy = sx_sy(q, st) pad = ESC_R + 0.6 f = E.Field(obstacles, (q['x'] - pad, q['y'] - pad, q['x'] + pad, q['y'] + pad), skip_net=q['net'], grid=grid) f.set_relax([E.sh_rect(cx, cy, hw, hh, pad_layers(q)) for cx, cy, hw, hh in pad_rects(q)] + ([E.sh_seg(q['x'], q['y'], sx, sy, st[1], q['lay'])] if st else [])) lay = q['lay'] if q['lay'] != 12 else L_TOP src = {L: np.zeros((f.ny, f.nx), bool) for L in (L_TOP, L_BOT)} i, j = f._idx(sx, sy) if not (0 <= i < f.nx and 0 <= j < f.ny): return None src[lay][j, i] = True return f, src, E.route_grid(f, src, _ring_mask(f, q, ESC_R), w) def escape_of(q, st, obstacles, widths): u"""细栅格逃逸段。返回 (path, shapes) 或 None。""" for w in widths: r = breathes(q, st, obstacles, w, grid=ESC_GRID) if r is None: return None f, _src, cells = r if cells is None: continue path = [] for lay, xy in E.polish(f, E._runs(cells), w): for x, y in xy: path.append((round(float(x), 3), round(float(y), 3), lay, w)) path = E._dedup(path) # ⚠️ **不要用 `_snap_end` 把起点拽回焊盘中心** —— 那会凭空造出一段 # 没过净距校核的线(实测 `U11.4` 因此压到 `U11.5` 只剩 0.059)。 # 正确做法是把 `entry_stub` 已经校核过的那一小段**接在前面**。 head = [(round(q['x'], 4), round(q['y'], 4), q['lay'], st[1])] if st else [] path = E._dedup(head + path) path = E._end_via(path) segs, vs = E.expand(path) shp = [E.sh_seg(a_, b_, c_, d_, ww, ll) for a_, b_, c_, d_, ll, ww in segs] shp += [E.sh_via(vx, vy) for vx, vy in vs] return path, shp return None def plan_escapes(pads, obstacles, need_tags, stubs, verbose=True): u"""给「在 0.1 栅格上出不来」的脚补一段细栅格逃逸线。返回 ({tag: (path, shapes)}, 死掉的)。""" bytag = {p['tag']: p for p in pads} out, stuck = {}, [] for tag in sorted(need_tags): q = bytag.get(tag) if q is None: continue dflt, wmin = wclass(q['net']) w = max(wmin, min(dflt, 2.0 * min(q['hw'], q['hh']))) st = stubs.get(tag) r = breathes(q, st, obstacles, w) if r is None or r[2] is not None: continue # 0.1 栅格上本来就出得来 esc = escape_of(q, st, obstacles, _wladder(w, wmin)) if esc is None: stuck.append(tag) continue out[tag] = esc obstacles.extend((sp, q['net']) for sp in esc[1]) if verbose: print(u' 细栅格逃逸:%d 个脚在 0.1 栅格上出不来,救回 %d 个' % (len(out) + len(stuck), len(out))) if stuck: print(u' [!!] 还有 %d 个**几何上就出不去**(要动摆位或扇出):%s' % (len(stuck), ' '.join(stuck))) return out, stuck # ══════════════════ 布一个网络 ══════════════════ def route_net(net, frags, pads_all, obstacles, stubs, alts, escapes=None, verbose=True, rip=None): u"""把这个网络剩下的碎片接起来。返回 [(路径, 形状表), ...]。 生成树用 Prim(照引擎里的理由:最近邻链会在大网络上串成长蛇)。 `escapes` 是 `plan_escapes()` 给的细栅格逃逸段:有的话就用它当这个焊盘的「铜」, 起终点钉在逃逸段的**远端**(那里已经在开阔地上了)。 """ escapes = escapes or {} comps = [] for f in frags: c = comp_of(f) if len(f['pads']) == 1 and not f['vias'] and not f['lines']: q = f['pads'][0] esc = escapes.get(q['tag']) st = stubs.get(q['tag']) if esc is not None: path, shp = esc end = (path[-1][0], path[-1][1]) c = E.Comp([c.shapes[0]] + list(shp), end, center=end, layers=(path[-1][2],)) c.pad, c.rect, c.escape = q, c.shapes[0], esc elif st is not None: end, ws = st seg = E.sh_seg(q['x'], q['y'], end[0], end[1], ws, q['lay']) c = E.Comp([c.shapes[0], seg], end, center=end, layers=c.layers) c.pad, c.rect, c.stub = q, c.shapes[0], (end, ws) c.stub_alts = list(alts.get(q['tag'], [])[1:]) comps.append(c) if len(comps) < 2: return [] # relax:本网**没有出线段**的焊盘(有出线段的已经有合法出入口,再放开会被从背面钻进来) relax = [E.sh_rect(cx, cy, hw, hh, pad_layers(q)) for q in pads_all if q['net'] == net and q['tag'] not in stubs and q['tag'] not in escapes for cx, cy, hw, hh in pad_rects(q)] out = [] # 出线段 / 逃逸段本身也是要下发的铜 for c in comps: esc = getattr(c, 'escape', None) if esc is not None: out.append((esc[0], list(esc[1]))) c.stub_i = len(out) - 1 continue st = getattr(c, 'stub', None) if st is not None: q, (end, ws) = c.pad, st out.append(([(round(q['x'], 4), round(q['y'], 4), q['lay'], ws), (round(end[0], 4), round(end[1], 4), q['lay'], ws)], [E.sh_seg(q['x'], q['y'], end[0], end[1], ws, q['lay'])])) c.stub_i = len(out) - 1 dflt, wmin = wclass(net) # ⚠️ **从最大的那一坨起头**,不是从 comps[0]。 # Prim 的起点整轮都当 `cur`,它要是一个细间距单焊盘、出线方向又选错了, # 这个网络就一条也连不上 —— 实测 `SDA`(R604.2) / `INA_ALERT`(R607.2) 就是这样, # 单独布能全通,全量布时四条五条一起报「布不通」,而卡住的其实只有起点那一处。 comps.sort(key=lambda c: -len(c.shapes)) cur, rest = comps[0], comps[1:] anchors = [cur.anchor] fails = 0 stalled = 0 while rest: rest.sort(key=lambda c: min(math.hypot(c.anchor[0] - a[0], c.anchor[1] - a[1]) for a in anchors)) tgt = rest.pop(0) w = width_for(net, _pads_of(cur), _pads_of(tgt)) seg = connect(net, cur, tgt, obstacles, _wladder(w, wmin), relax=relax) # 首选出线方向走不通 -> 换个方向再试(**两端都试**) if seg is None: for who in ('tgt', 'cur'): c0 = tgt if who == 'tgt' else cur # 已经并过别的碎片的 `cur` 不再是「一个焊盘 + 一段出线」, # `layers` 被 merge() 清成 None 了,换出线方向这件事对它没有意义。 if getattr(c0, 'pad', None) is None or c0.layers is None: continue for end2, ws2 in list(getattr(c0, 'stub_alts', ()) or ()): q = c0.pad c2 = E.Comp([c0.rect, E.sh_seg(q['x'], q['y'], end2[0], end2[1], ws2, q['lay'])], end2, center=end2, layers=c0.layers) c2.pad, c2.rect, c2.stub_i = q, c0.rect, c0.stub_i c2.stub_alts = [] pair = (cur, c2) if who == 'tgt' else (c2, tgt) seg = connect(net, pair[0], pair[1], obstacles, _wladder(w, wmin), relax=relax) if seg is not None: out[c0.stub_i] = ( [(round(q['x'], 4), round(q['y'], 4), q['lay'], ws2), (round(end2[0], 4), round(end2[1], 4), q['lay'], ws2)], [E.sh_seg(q['x'], q['y'], end2[0], end2[1], ws2, q['lay'])]) if who == 'tgt': tgt = c2 else: cur = c2 if verbose: print(u' %s 改从 (%.2f,%.2f) 出线' % (q['tag'], end2[0], end2[1])) break if seg is not None: break if seg is None: # **先放回队尾再试一轮**:`cur` 会随着别的碎片并进来越长越大, # 刚才够不着的地方,等它长到附近就够得着了。 # 只有「一整圈谁都连不上」才认输 —— 那才是真的布不通。 if stalled < min(6, len(rest)): stalled += 1 rest.append(tgt) continue # 一整圈都连不上了 —— 到这一步才允许**拆线重布**。 # ⚠️ 不能提前拆:上面那一轮「放回队尾」常常自己就通了,早拆就是白拆。 if rip is not None: seg = rip.attempt(net, cur, tgt, obstacles, _wladder(w, wmin), relax, verbose) if seg is None: fails += 1 if verbose: print(u' [!!] %s 布不通:(%.2f,%.2f) -> (%.2f,%.2f)' % (net, cur.anchor[0], cur.anchor[1], tgt.anchor[0], tgt.anchor[1])) cur.merge(tgt, []) anchors.append(tgt.anchor) continue stalled = 0 path, shp = seg out.append((path, shp)) obstacles.extend((sp, net) for sp in shp) cur.merge(tgt, shp) anchors.append(tgt.anchor) return out, fails def _pads_of(c): u"""这一坨铜里的焊盘。**不能只返回 `c.pad`** —— 那个属性只有「细间距、留了出线段」 的单焊盘碎片才有;像 `DZ1.1`(0.91 x 0.84,够粗,不留出线段)就查不到, 于是 `width_for()` 拿不到封顶值、直接用类默认宽度 **2.0 mm** 去接一个 0.91 的焊盘, 多出来的 1 mm 铜从焊盘边上溢出去,正好压在 `POGO1_PWR` 上(实测 −0.199)。 """ if getattr(c, 'frag_pads', None): return list(c.frag_pads) if getattr(c, 'pad', None) is not None: return [c.pad] return [] def _wladder(w, wmin): out = [w] for v in (round(w * 0.7, 3), wmin): if v < out[-1] - 1e-6 and v >= wmin - 1e-6: out.append(v) return out # ══════════════════ USB 差分对:一整束布,再平行偏移 ══════════════════ # U20(ESP32-S3)的原生 USB 到调试口 J22,**55 mm**。 # 布线器一次只认一根线;两根各走各的最短路就不再是一对了。 # ⇒ 先按「一整束」的宽度 `2W + S = 0.74` 布一条中心线(它占的地方和两根并排一模一样), # 再把中心线往两侧各偏移 `(W + S)/2 = 0.23` 生成 DP / DM。 # # **接哪两个脚是算出来的,不是随便挑的。** Type-C 的 A/B 两面各有一对 D±: # J22 南→北:B7(DM) 51.250 / A6(DP) 51.750 / A7(DM) 52.250 / B6(DP) 52.751 # U20 那边 DP(pin14, y 25.814) 在 DM(pin13, y 24.544) 的**北边**。 # 偏移出来的两根线左右关系沿整条路是不变的 ⇒ 目的地那一对也必须是「DP 在北」, # 只有 **B6/B7** 满足。接 A6/A7 的话整条对要在中途交叉一次(得多两个过孔)。 # A↔B 两面的短接(A6↔B6 / A7↔B7)交给通用布线器,它们就在连接器边上。 USB_DP_PADS = ('U20.14', 'J22.B6') USB_DM_PADS = ('U20.13', 'J22.B7') USB_W, USB_S = 0.28, 0.18 # 束的两端锚点(束在**底层**跑 —— 顶层这一路被 CH5 和控制块占满,底层整片是空的)。 USB_A, USB_B = (104.0, 25.18), (147.0, 52.00) # ⚠️ 两端各**硬接一段水平引出线**,长度 USB_LEAD。 # 没有它的话,束的第一段是布线器自己挑的(实测是 45° 斜着往东北走), # 偏移出来的两个端点就不是「上下差 0.46」而是「斜着差 0.46」—— # y 上只差 0.32,扇入段一走平就压到 0.045 的净距上(对内要求 0.15)。 # 水平引出线让两端的偏移方向一定是正南正北,扇入才有确定的几何。 USB_LEAD = 2.0 # 翻层的过孔:**两根必须错开打**。0.46 mm 的对内间距下并排打是不行的 —— # 钻孔到钻孔要 0.30(孔 Ø0.305 ⇒ 中心距 ≥ 0.605),铜到铜要 0.20(Ø0.61 ⇒ ≥ 0.81)。 # 所以一根先翻、另一根往前走一段再翻,两孔至少错开 1.5 mm。 USB_VIA = { 'USB_DP': ((101.2, 25.814), (150.0, 52.751)), 'USB_DM': ((99.5, 24.544), (150.6, 51.250)), } def _offset_path(path, off): u"""折线整体往左(off > 0)平移 `off`,拐角按**斜接**(miter)算。 换层点在路径里是两个同坐标不同层的点,方向按前后最近的非零段取 —— 于是过孔也跟着挪到偏移后的位置,两根线各打各的孔。 """ pts = [(p[0], p[1]) for p in path] n = len(pts) seg = [None] * n for i in range(n - 1): dx, dy = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1] L = math.hypot(dx, dy) seg[i] = (dx / L, dy / L) if L > 1e-9 else None out_d, cur = [None] * n, None for i in range(n - 1, -1, -1): if seg[i] is not None: cur = seg[i] out_d[i] = cur in_d, cur = [None] * n, None for i in range(n): if i > 0 and seg[i - 1] is not None: cur = seg[i - 1] in_d[i] = cur res = [] for i, (x, y) in enumerate(pts): a = in_d[i] or out_d[i] b = out_d[i] or in_d[i] na, nb = (-a[1], a[0]), (-b[1], b[0]) mx, my = na[0] + nb[0], na[1] + nb[1] L = math.hypot(mx, my) if L < 1e-9: # 掉头(180°),退化成法线方向 mx, my, L = na[0], na[1], 1.0 c = max(-0.999999, na[0] * nb[0] + na[1] * nb[1]) t = off / math.sqrt((1.0 + c) / 2.0) res.append((round(x + mx / L * t, 4), round(y + my / L * t, 4), path[i][2], path[i][3])) return res def usb_pair(pads, obstacles, bytag): u"""返回 {key: path};布不通就返回 {} 并说明卡在哪。""" qdp0, qdp1 = bytag.get(USB_DP_PADS[0]), bytag.get(USB_DP_PADS[1]) qdm0, qdm1 = bytag.get(USB_DM_PADS[0]), bytag.get(USB_DM_PADS[1]) if not all((qdp0, qdp1, qdm0, qdm1)): sys.exit(u'USB 差分对的四个焊盘板上找不到全 —— 位号或摆位变过了') a, b = USB_A, USB_B wb = 2 * USB_W + USB_S # 束自己的两个 USB 网都不算障碍(它们的焊盘就是终点) obs = [(s, n) for s, n in obstacles if n not in ('USB_DP', 'USB_DM')] ca = E.Comp([E.sh_rect(a[0], a[1], 0.05, 0.05, (L_TOP, L_BOT))], a, center=a, layers=(L_TOP, L_BOT)) cb = E.Comp([E.sh_rect(b[0], b[1], 0.05, 0.05, (L_TOP, L_BOT))], b, center=b, layers=(L_TOP, L_BOT)) seg = connect('USB_BUNDLE', ca, cb, obs, wb) if seg is None: print(u' [!!] USB 束(%.1f mm 宽)布不通:(%.1f,%.1f) -> (%.1f,%.1f)' % (wb, a[0], a[1], b[0], b[1])) return {} # 切角要按**束宽**校核:切一刀是往拐角内侧补铜(不是只拿掉铜), # 内侧那个夹角里要是正好蹲着一个异网焊盘,不校核就压上去了。 bundle = D.chamfer45( seg[0], lambda x0, y0, x1, y1, lay, _w: E.clear_exact( x0, y0, x1, y1, lay, wb, 'USB_BUNDLE', obs) >= CLR - 1e-4) # 两端硬接水平引出线(见 USB_LEAD 的注释) la = (round(a[0] - USB_LEAD, 4), round(a[1], 4), bundle[0][2], wb) lb = (round(b[0] + USB_LEAD, 4), round(b[1], 4), bundle[-1][2], wb) for p, q in ((la, bundle[0]), (bundle[-1], lb)): if E.clear_exact(p[0], p[1], q[0], q[1], p[2], wb, 'USB_BUNDLE', obs) < CLR - 1e-4: print(u' [!!] USB 束的水平引出线 (%.2f,%.2f)-(%.2f,%.2f) 净距不够' % (p[0], p[1], q[0], q[1])) return {} bundle = [la] + bundle + [lb] off = (USB_W + USB_S) / 2.0 left = _offset_path(bundle, +off) right = _offset_path(bundle, -off) # 哪一侧是 DP:看起点那一侧离 U20.14 近还是离 U20.13 近 dl = math.hypot(left[0][0] - qdp0['x'], left[0][1] - qdp0['y']) dr = math.hypot(right[0][0] - qdp0['x'], right[0][1] - qdp0['y']) dp_path, dm_path = (left, right) if dl < dr else (right, left) # 终点那一侧必须还是同一个关系,否则这一对要在中途交叉(换 A6/A7 或另想办法) e_dp = math.hypot(dp_path[-1][0] - qdp1['x'], dp_path[-1][1] - qdp1['y']) e_dm = math.hypot(dp_path[-1][0] - qdm1['x'], dp_path[-1][1] - qdm1['y']) if e_dp > e_dm: print(u' [!!] USB 束在终点左右关系反了 —— DP 侧离 J22.B6 %.2f、离 B7 %.2f。' u'这一对得中途交叉,不能就这么偏移。' % (e_dp, e_dm)) return {} out = {} for name, pth, q0, q1 in (('USB_DP', dp_path, qdp0, qdp1), ('USB_DM', dm_path, qdm0, qdm1)): pth = [(x, y, lay, USB_W) for x, y, lay, _w in pth] va, vb = USB_VIA[name] head = _fan(q0, va, (pth[0][0], pth[0][1]), pth[0][2], USB_W, name, obs) tail = _fan(q1, vb, (pth[-1][0], pth[-1][1]), pth[-1][2], USB_W, name, obs) if head is None or tail is None: print(u' [!!] USB %s 的扇入段过不去(头 %s / 尾 %s)' % (name, u'不通' if head is None else u'ok', u'不通' if tail is None else u'ok')) return {} full = head[:-1] + pth + list(reversed(tail))[1:] out[name + KSEP + 'usb'] = E._dedup(full) ln = {k: sum(math.hypot(q[0] - p[0], q[1] - p[1]) for p, q in zip(v, v[1:])) for k, v in out.items()} skew = abs(ln['USB_DP' + KSEP + 'usb'] - ln['USB_DM' + KSEP + 'usb']) print(u' USB 差分对:束长 %.1f mm,W %.2f / S %.2f;' u'DP %.1f / DM %.1f mm,**对内偏斜 %.2f mm**(容差 %.1f,§26.7)%s' % (sum(math.hypot(b_[0] - a_[0], b_[1] - a_[1]) for a_, b_ in zip(bundle, bundle[1:])), USB_W, USB_S, ln['USB_DP' + KSEP + 'usb'], ln['USB_DM' + KSEP + 'usb'], skew, RU.DIFF['differentailPairLenTolerMax'], u'' if skew <= RU.DIFF['differentailPairLenTolerMax'] else u' [!!] 超了')) return out def _l45(a, b, lay, w, net, obs): u"""a -> b 的 45°/90° 两段折线,挑第一条净距过得去的;都不行返回 None。""" for cand in E._l_shapes(a, b): if all(E.clear_exact(p1[0], p1[1], p2[0], p2[1], lay, w, net, obs) >= CLR - 1e-4 for p1, p2 in zip(cand, cand[1:])): return [(round(p[0], 4), round(p[1], 4), lay, w) for p in cand] return None def _fan(q, via_xy, xy, lay, w, net, obs): u"""焊盘 -> (翻层过孔)-> 束端点。返回从**焊盘**出发的点表。 焊盘在顶层、束在底层,所以中间要翻一次层。过孔位置写死在 `USB_VIA` 里, 两根**错开 1.5 mm 以上**(并排打不下,见那张表上的注释)。 """ if q['lay'] == lay: return _l45((q['x'], q['y']), xy, lay, w, net, obs) other = L_BOT if q['lay'] == L_TOP else L_TOP if other != lay: return None seg1 = _l45((q['x'], q['y']), via_xy, q['lay'], w, net, obs) if seg1 is None: return None # 过孔本身也要够净距(异网铜 + 钻孔间距都在 check() 里再核一遍) if E.clear_exact(via_xy[0], via_xy[1], via_xy[0], via_xy[1], q['lay'], VIA_D, net, obs) < CLR - 1e-4: return None seg2 = _l45(via_xy, xy, lay, w, net, obs) if seg2 is None: return None # 换层:同坐标两个点,层不同 —— `expand()` 就是靠这个认出过孔的 return seg1 + [(round(via_xy[0], 4), round(via_xy[1], 4), lay, w)] + seg2[1:] # ══════════════════ 出线段(escape)══════════════════ def plan_stubs(order, pads_all, obstacles, need_tags): u"""**先把所有要接的焊盘的出线段留出来,再开始布线**(理由见引擎里那一大段)。 只给 `need_tags` 里的焊盘留 —— 板上已经连好的那一千多个焊盘不需要逃逸通道, 给它们留反而会白白占掉走廊。 """ stubs, alts = {}, {} for net in order: dflt, _wmin = wclass(net) for q in pads_all: if q['net'] != net or q['tag'] not in need_tags: continue cand = E.entry_stub(q, pads_all, obstacles, net, dflt, all_cands=True) if not cand: continue alts[q['tag']] = cand end, ws = cand[0] stubs[q['tag']] = (end, ws) obstacles.append((E.sh_seg(q['x'], q['y'], end[0], end[1], ws, q['lay']), net)) return stubs, alts # ══════════════════ 校核 ══════════════════ def hole_check(routes, vias): u"""钻孔到钻孔 0.30,**不分网络**(`check()` 只按铜算净距,不管这一条)。""" mine = [] for key, path in routes.items(): for x, y in E.expand(path)[1]: mine.append((netof(key), x, y)) bad = [] for i, (n1, x1, y1) in enumerate(mine): for n2, x2, y2 in mine[i + 1:]: d = math.hypot(x1 - x2, y1 - y2) - VIA_H if d < HOLE_CLR - 1e-4: bad.append((u'新孔-新孔', n1, n2, d, (x1, y1))) for v in vias: d = math.hypot(x1 - v['x'], y1 - v['y']) - VIA_H if d < HOLE_CLR - 1e-4: bad.append((u'新孔-旧孔', n1, v['net'] or u'空', d, (x1, y1))) return bad def pour_check(routes, pours): u"""线 / 过孔到**外层铺铜**的净距。铺铜按 `pour_rects()` 的矩形条算。 ⚠️ 走线的**圆头端帽比端点还往外探半个线宽**(§26.11 那一条),所以一律按胶囊算。 """ bad = [] for key, path in routes.items(): net = netof(key) segs, vs = E.expand(path) for x0, y0, x1, y1, lay, w in segs: for s, pnet in pours: if pnet == net or lay not in s[-1]: continue d = D.seg_rect((x0, y0), (x1, y1), s[1], s[2], s[3], s[4]) - w / 2.0 if d < CLR - 1e-4: bad.append((u'线-铺铜', net, pnet, lay, d, (x0, y0), (x1, y1))) for x, y in vs: for s, pnet in pours: if pnet == net: continue d = D.seg_rect((x, y), (x, y), s[1], s[2], s[3], s[4]) - VIA_D / 2.0 if d < CLR - 1e-4: bad.append((u'过孔-铺铜', net, pnet, s[-1][0], d, (x, y), (x, y))) return bad def sw_keepaway(routes, lines, want=1.0): u"""§10.3:**开机键那两根线要远离 SW 节点**。这里只报告,不当错误。 SW 节点是全板 dv/dt 最大的地方(12 V / 几十 ns);按键线是高阻输入, 平行走一段就会被容性耦合出假触发。0.2 mm 的 DRC 净距对它没有意义 —— 要的是**别并排走**。判据取 1.0 mm,够不上就列出来人看。 """ sw = set(_AUTO['SW%d' % n] for n in range(1, 6)) | set([_AUTO['SW5V']]) swl = [l for l in lines if l['net'] in sw] out = [] for key, path in routes.items(): net = netof(key) if not (net.startswith('K') and net[1:2].isdigit()) \ and not net.startswith('POGO'): continue best, where = 1e9, None for x0, y0, x1, y1, lay, w in E.expand(path)[0]: for l in swl: if l['lay'] != lay: continue d = D.seg_seg((x0, y0), (x1, y1), (l['x0'], l['y0']), (l['x1'], l['y1'])) \ - w / 2.0 - l['w'] / 2.0 if d < best: best, where = d, (x0, y0) if where is not None and best < want: out.append((net, best, where)) return sorted(out, key=lambda e: e[1]) def conn_check(routes, frags_by_net): u"""本轮画的铜有没有真把每个网络的碎片连成一坨(不看 DRC,自己数)。""" bad = [] for net, frags in frags_by_net.items(): n = len(frags) par = list(range(n)) def find(i): while par[i] != i: par[i] = par[par[i]] i = par[i] return i segs = [] for key, path in routes.items(): if netof(key) != net: continue s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: segs.append(('seg', x0, y0, x1, y1, w, lay)) for x, y in v_: segs.append(('via', x, y, x, y, VIA_D, None)) # 每一段铜碰到哪些碎片,就把那些碎片并起来;段与段之间也要并 hit = [] for s in segs: h = set() for k, f in enumerate(frags): if _touch_frag(s, f): h.add(k) hit.append(h) for a in range(len(segs)): for b in range(a + 1, len(segs)): if _touch_seg(segs[a], segs[b]): hit[a] |= hit[b] hit[b] = hit[a] for h in hit: hs = sorted(h) for k in hs[1:]: pa, pb = find(hs[0]), find(k) if pa != pb: par[pa] = pb ncomp = len(set(find(i) for i in range(n))) if ncomp > 1: bad.append(u'%s 布完还剩 %d 个碎片(原来 %d)' % (net, ncomp, n)) return bad def _layers(s): # ⚠️ 过孔是**通孔**,四层都算。写成 (L_TOP, L_BOT) 的话, # 板上 Inner2 的 I²C 干线(`pcb1_i2c_l3.py`)和它两端的孔会被判成"没碰上", # 碎片分析于是把一条连通的网络数成两片。 if s[0] == 'via': return (L_TOP, L_BOT, 15, 16) return (s[6],) def _touch_seg(a, b): if not (set(_layers(a)) & set(_layers(b))): return False ra = a[5] / 2.0 if a[0] == 'seg' else a[5] / 2.0 rb = b[5] / 2.0 if b[0] == 'seg' else b[5] / 2.0 return D.seg_seg((a[1], a[2]), (a[3], a[4]), (b[1], b[2]), (b[3], b[4])) <= ra + rb + 1e-6 def _touch_frag(s, f): lays = set(_layers(s)) r = s[5] / 2.0 a, b = (s[1], s[2]), (s[3], s[4]) for q in f['pads']: ql = (L_TOP, L_BOT) if q['lay'] == 12 else (q['lay'],) if not (lays & set(ql)): continue if D.seg_rect(a, b, q['x'], q['y'], q['hw'], q['hh']) <= r + 1e-6: return True for v in f['vias']: if D.seg_pt(a[0], a[1], b[0], b[1], v['x'], v['y']) <= r + v['r'] + 1e-6: return True for l in f['lines']: if l['lay'] not in lays: continue if D.seg_seg(a, b, (l['x0'], l['y0']), (l['x1'], l['y1'])) <= r + l['w'] / 2 + 1e-6: return True return False # ══════════════════ 下发 / 回读 / 清理 ══════════════════ JS_PUSH = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1800)); const S = %s, V = %s, MIL = %s, VH = %s, VD = %s; let nl = 0, nv = 0; for (const s of S) { const ln = await eda.pcb_PrimitiveLine.create(s[0], s[1], s[2]*MIL, s[3]*MIL, s[4]*MIL, s[5]*MIL, s[6]*MIL); if (ln) nl++; } for (const v of V) { const vi = await eda.pcb_PrimitiveVia.create(v[0], v[1]*MIL, v[2]*MIL, VH*MIL, VD*MIL); if (vi) nv++; } await eda.pcb_Document.save(); await new Promise(r => setTimeout(r, 2000)); return {lines: nl, vias: nv, totalLine: (await eda.pcb_PrimitiveLine.getAll()).length, totalVia: (await eda.pcb_PrimitiveVia.getAll()).length}; """ def to_wire(routes): segs, vs = [], [] for key, path in sorted(routes.items()): net = netof(key) s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: a, b = (round(x0, 4), round(y0, 4)), (round(x1, 4), round(y1, 4)) if math.hypot(b[0] - a[0], b[1] - a[1]) <= 5e-4: continue # 亚微米的数值毛刺(`_snap_end` 留下的),不是铜 segs.append([net, lay, a[0], a[1], b[0], b[1], round(w, 4)]) for x, y in v_: vs.append([net, round(x, 4), round(y, 4)]) return segs, vs def push(routes): segs, vs = to_wire(routes) r = run(JS_PUSH % (PCB, json.dumps(segs), json.dumps(vs), MIL, VIA_H, VIA_D), timeout=3600) if not r.get('success'): sys.exit(u'下发失败: ' + json.dumps(r, ensure_ascii=False)[:600]) d = r['result'] print(u' 下发:新建 %d 线 / %d 孔;板上共 %d 线 / %d 孔' % (d['lines'], d['vias'], d['totalLine'], d['totalVia'])) io.open(ROUTES, 'w', encoding='utf-8').write( json.dumps({'segs': segs, 'vias': vs}, ensure_ascii=False)) return d JS_DEL = u""" await eda.dmt_EditorControl.openDocument('%s'); await new Promise(r => setTimeout(r, 1800)); const S = new Set(%s), V = new Set(%s), MIL = %s; const q = v => Math.round(+(v / MIL).toFixed(3) * 1000); const dl = [], dv = []; for (const l of await eda.pcb_PrimitiveLine.getAll()) { const a = [l.net||'', l.layer, q(l.startX), q(l.startY), q(l.endX), q(l.endY), q(l.lineWidth)].join('|'); const b = [l.net||'', l.layer, q(l.endX), q(l.endY), q(l.startX), q(l.startY), q(l.lineWidth)].join('|'); if (S.has(a) || S.has(b)) dl.push(l.primitiveId); } for (const v of await eda.pcb_PrimitiveVia.getAll()) if (V.has([v.net||'', q(v.x), q(v.y)].join('|'))) dv.push(v.primitiveId); if (dl.length) await eda.pcb_PrimitiveLine.delete(dl); if (dv.length) await eda.pcb_PrimitiveVia.delete(dv); await eda.pcb_Document.save(); await new Promise(r => setTimeout(r, 1500)); return {dl: dl.length, dv: dv.length, line: (await eda.pcb_PrimitiveLine.getAll()).length, via: (await eda.pcb_PrimitiveVia.getAll()).length}; """ def _q(v): return int(round(round(v, 3) * 1000)) def drop(lines, vias, li, vi, why=u''): u"""把**指定下标**的那些板上线 / 孔删掉。 ⚠️ 键一律用「微米整数」拼,不要用浮点转字符串:板上快照是 `toFixed(3)` 存的, 而 JS 的 `String(107)` 是 `'107'`、Python 的 `str(107.0)` 是 `'107.0'` —— 同一个数在两边拼出来的键不一样,删除会**静默地一条都不匹配**。 """ if not li and not vi: print(u' %s:没有要删的' % (why or u'清理')) return 0 S = [u'|'.join([lines[i]['net'], str(lines[i]['lay']), str(_q(lines[i]['x0'])), str(_q(lines[i]['y0'])), str(_q(lines[i]['x1'])), str(_q(lines[i]['y1'])), str(_q(lines[i]['w']))]) for i in sorted(li)] V = [u'|'.join([vias[i]['net'], str(_q(vias[i]['x'])), str(_q(vias[i]['y']))]) for i in sorted(vi)] r = run(JS_DEL % (PCB, json.dumps(S), json.dumps(V), MIL), timeout=1800) if not r.get('success'): sys.exit(u'删除失败: ' + json.dumps(r, ensure_ascii=False)[:600]) x = r['result'] print(u' %s:删掉 %d 线 / %d 孔(要删 %d / %d),板上还剩 %d 线 / %d 孔' % (why or u'清理', x['dl'], x['dv'], len(S), len(V), x['line'], x['via'])) if x['dl'] < len(set(S)) or x['dv'] < len(set(V)): sys.exit(u'⛔ 删少了 —— 板上还留着本该删掉的铜,**不要继续下发**。' u'先跑一次 --refresh 看看板子是不是被别的脚本动过。') if x['dl'] > len(set(S)) or x['dv'] > len(set(V)): print(u' ⚠️ 删多了(板上有坐标/网络/线宽完全一样的重复图元)—— 记一笔') return x['dl'] def clean(lines, vias, lown, vown): u"""删掉**本脚本画过的全部**线和孔(按 `own_of()` 的归属表,不是按坐标精确比对)。 ⚠️ 不能按网络删:同一个网络上还有扇出的短线和 `pcb1_route_pwr.py` 的功率级。 ⚠️ 也不能按坐标精确比对:EasyEDA 会合并 / 劈开线段,实测那样只认得出 59%。 """ li = set(i for i, s in enumerate(lown) if s) vi = set(i for i, s in enumerate(vown) if s) return drop(lines, vias, li, vi, u'清理') def verify(): u"""按板上**实测**的铜核一遍:本轮每一段是不是都被同网同层同宽的实测线盖住。""" if not os.path.exists(ROUTES): sys.exit(u'没有 %s,本脚本还没下发过' % os.path.basename(ROUTES)) d = json.load(io.open(ROUTES, encoding='utf-8')) _p, vias, lines = board(refresh=True) miss = 0 for net, lay, x0, y0, x1, y1, w in d['segs']: # ⚠️ 同 `pcb1_route_pwr.verify()`:**不能只认同宽的线**。 # 一段窄线整个落在宽线的圆头端帽里时 EasyEDA 会直接吞掉它 —— 铜一点没少。 cand = [l for l in lines if l['net'] == net and l['lay'] == lay and l['w'] >= w - 0.011] L = math.hypot(x1 - x0, y1 - y0) n = max(2, int(L / 0.2) + 1) bad = 0 for k in range(n + 1): t = k / float(n) px, py = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t if not any(D.seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], px, py) <= (l['w'] - w) / 2.0 + 0.02 for l in cand): bad += 1 if bad: miss += 1 vmiss = 0 for net, x, y in d['vias']: if not any(v['net'] == net and abs(v['x'] - x) < 0.02 and abs(v['y'] - y) < 0.02 for v in vias): vmiss += 1 print(u' 回读:板上 %d 线 / %d 孔;本轮 %d 段里没被实测铜盖住的 **%d** 条,' u'%d 个孔里找不到的 **%d** 个' % (len(lines), len(vias), len(d['segs']), miss, len(d['vias']), vmiss)) return miss + vmiss # ══════════════════ 拆线重布(rip-up & reroute)══════════════════ # # ⚠️⚠️ **这个布线器一直是「一遍过、只加不拆」** —— 板上已有的铜一律当成不可动的障碍。 # 那是有意的:扇出的 441 条短线和 `pcb1_route_pwr.py` 的五路功率级绝对不能被抹掉。 # 代价是**先布的把后布的堵死**:§26.14 剩下的 91 条布不通,几乎全是「拥挤」而不是 # 「没路」—— 单独布每一条都通。商业布线器解这一类用的就是拆线重布。 # # 这里的做法:**只有本脚本自己下发过的路径可以拆**(记在 `_pcb1_sig_plan_all.json` # 里),扇出 / 功率级 / 铺铜 / 焊盘一律不动。一条连接布不通时: # # ① 先问一句「把本脚本的铜全当不存在,这条通不通」—— 不通就是真没路,拆了也白拆; # ② 通了就看这条理想路径压到了哪几条**可拆**的路径,那几条就是挡路的; # ③ 把它们从障碍表里摘掉,重算这条连接; # ④ 被拆的那几条**按原来的两个端点、原来的层**重新布 —— 端点不动,连通性就不变; # ⑤ 再用 `pcb1_netlist.analyze()` 按「拆完 + 重布完」的铜实算一遍受影响网络的 # 碎片数,**不许比拆之前多**。这一步是兜底:④ 的不变量在「A 的端点正好压在 # B 的中段、而 A B 这一轮被一起拆」时会破 —— B 重布之后不一定还经过那个点。 # # 任何一步失败就整个回滚(一根铜都不动),这条连接照旧记成「布不通」。 NORIP = ('FORCE:', 'usb') # Kelvin 与 USB 差分对:永远不许拆 RIP_MAX = 10 # 一条连接最多拆几条 # 同一条路径**一轮里只拆一次**:拆完重布出来的那一版不再进可拆池。 # 让它可以被反复拆的话,两条线会互相让来让去(协商式布线器要靠拥塞代价才收敛, # 这里没有代价函数),而每让一次就多算一次全局搜索。一轮拆不动的,下一轮再跑一遍。 RIP_LIMIT = 1 # 先拆便宜的:短的路径重布回去容易、影响面小。一档不行再放宽到下一档。 # (直接上「全部可拆」的话,理想路径会从整块板的最短处穿过去,一口气压掉几十条 —— # 其中总有一条重布不回来,于是整次尝试作废,等于白算。) RIP_TIERS = (8.0, 25.0, 1e9) # 每一档允许拆的路径**总铜长**上限 (mm) OWN_TOL = 0.02 # 判「板上这条线是不是我画的」的贴合公差 (mm) PLANALL = os.path.join(HERE, '_pcb1_sig_plan_all.json') # 本脚本**累计**下发过的路径 def rippable_key(key): tail = key.split(KSEP, 1)[-1] return not any(tail.startswith(p) for p in NORIP) def rip_pool(plan, bytag): u"""这一批路径里**哪些允许拆**。 ⚠️⚠️ **不能只看键名。** 从下发记录反推出来的路径(`plan_from_routes()`)键是 `net\\0pN`,`FORCE:` / `usb` 那两个前缀一个都不剩 —— 照键名判的话, Kelvin 那三条和 USB 差分对**全都变成可拆的**,而它们正是全板最不能动的几条: Kelvin 拆掉重布,取样点会挪到分流电阻外面去,**DRC 和连通性都看不出来**(§10.4)。 ⇒ 再加两道按**几何**的闸:`SKIP` 里的网络(USB 差分对)整网不许拆; 任何一条路径只要有一个点落在 `FORCE` 表点名的焊盘上,就是 Kelvin 的一段,不许拆。 """ guard = [] for a, b, _why in FORCE: for t in (a, b): q = bytag.get(t) if q is not None: guard.append(q) out = set() for k, path in plan.items(): if not rippable_key(k) or netof(k) in SKIP: continue if any(abs(p[0] - q['x']) <= q['hw'] + CLR and abs(p[1] - q['y']) <= q['hh'] + CLR for p in path for q in guard if q['net'] == netof(k)): continue out.add(k) return out def load_plan(path): if not os.path.exists(path): return {} d = json.load(io.open(path, encoding='utf-8')) return {k: [tuple(p) for p in v] for k, v in d.items()} def plan_from_routes(path=ROUTES): u"""从**下发记录**(`_pcb1_sig_routes.json`:一堆线段 + 一堆孔)反推出一条条路径。 ⚠️⚠️ **为什么不直接读计划文件**:`_pcb1_sig_plan.json` 每跑一次就被覆盖一次, 连 `--net XXX` 这种只算两个网络的试水也照覆盖不误 —— 2026-09-06 实测, 一条 `--net EDL_EN2` 的空跑就把 700 条路径的计划清成了 `{}`。 而下发记录只在 `--push` 时写,是板上那些铜**唯一可靠的账本**。 ⇒ 归属表宁可从账本反推:按端点建图(过孔是连接上下两层的一条边), 度 ≠ 2 的点断开,拆成一条条极大链。拆出来的粒度比原来的 700 条还细一点, 对拆线重布只有好处(能单独让开的单元更小)。 最后**逐条回验**:反推出来的路径重新 `expand()` 之后,必须和账本里的线段 / 孔 一模一样,一条都不许多、不许少。 """ if not os.path.exists(path): return {} d = json.load(io.open(path, encoding='utf-8')) segs = [tuple(s) for s in d['segs']] vias = [tuple(v) for v in d['vias']] def nd(x, y, lay): return (round(x, 3), round(y, 3), lay) bynet = {} for net, lay, x0, y0, x1, y1, w in segs: bynet.setdefault(net, [[], []])[0].append( (nd(x0, y0, lay), nd(x1, y1, lay), w)) for net, x, y in vias: bynet.setdefault(net, [[], []])[1].append((nd(x, y, L_TOP), nd(x, y, L_BOT))) out = {} for net, (ss, vv) in sorted(bynet.items()): edges = [(a, b, w) for a, b, w in ss] + [(a, b, None) for a, b in vv] adj = {} for i, (a, b, _w) in enumerate(edges): adj.setdefault(a, []).append(i) adj.setdefault(b, []).append(i) used = [False] * len(edges) chains = [] def walk(start): chain, cur = [start], start while True: nxt = [i for i in adj[cur] if not used[i]] if not nxt: break if len(chain) > 1 and len(adj[cur]) != 2: break # 到了岔口 / 端点,这一条到此为止 i = nxt[0] used[i] = True a, b, _w = edges[i] cur = b if a == cur else a chain.append(cur) return chain for n0 in sorted(adj, key=lambda n: (len(adj[n]) == 2, n)): while any(not used[i] for i in adj[n0]): c = walk(n0) if len(c) > 1: chains.append(c) # 链 -> 路径。⚠️ `expand()` **不给最后一个点发过孔**(见它的注释)—— # 链尾正好是个过孔时就把末点复制一份,让那个换层点不再是最后一个点。 # 复制出来的那一段长度为 0,`expand()` 不会给它发线段,铜一点不多。 ew = {} for a, b, w in edges: ew[(a, b)] = w ew[(b, a)] = w chains = [c + [c[-1]] if ew.get((c[-2], c[-1])) is None else c for c in chains] for ci, c in enumerate(chains): wprev = W_SIG pts = [] for i, n in enumerate(c): w = ew.get((n, c[i + 1])) if i + 1 < len(c) else None if w is None: w = wprev else: wprev = w pts.append((n[0], n[1], n[2], w)) out[net + KSEP + 'p%d' % ci] = pts # 回验:反推的路径 == 账本 gs, gv = set(), set() for k, p in out.items(): s_, v_ = E.expand(p) for x0, y0, x1, y1, lay, w in s_: gs.add((netof(k), lay, round(w, 3), tuple(sorted([(round(x0, 3), round(y0, 3)), (round(x1, 3), round(y1, 3))])))) for x, y in v_: gv.add((netof(k), round(x, 3), round(y, 3))) # ⚠️ 账本里可能有**零长线段**:`expand()` 按 1e-9 判非零,而 `to_wire()` 落盘前 # 落盘到 0.1 µm,端点相邻的两段就并到一起了(实测账本里有 2 条 **0.1 µm** 长的 # `INA_ALERT`,是 `_snap_end` 把端点拽向焊盘中心留下的数值毛刺)。 # 反推时节点按 1 µm 归一,这种边自然变成自环被丢掉 —— 对账时也不该算它。 ws = set((n, l, round(w, 3), tuple(sorted([(round(x0, 3), round(y0, 3)), (round(x1, 3), round(y1, 3))]))) for n, l, x0, y0, x1, y1, w in segs if math.hypot(x1 - x0, y1 - y0) > 5e-4) wv = set((n, round(x, 3), round(y, 3)) for n, x, y in vias) if gs != ws or gv != wv: sys.exit(u'⛔ 从下发记录反推路径对不上:线段 多%d/少%d,过孔 多%d/少%d —— ' u'不敢拿它当归属表' % (len(gs - ws), len(ws - gs), len(gv - wv), len(wv - gv))) return out def split_chains(plan, pads, lines, vias, lown, vown, verbose=True): u"""把每条路径在「别的同网铜搭上来的那一点」切开,并把那一点变成新的端点。 ⚠️⚠️ **这是拆线重布能不能站得住的前提。** 拆掉一条重布时,保住连通性靠的是 「两个端点原样钉死」——可板上的搭接**根本不都在端点上**: 实测 `KEY_RCLK` 三条链里,两条的起点 (83.70,52.30) / (84.70,61.00) 正落在 第三条链**某一段的中间**(当初布线器就是奔着已有的那坨铜去的, 终点落在它的铜里而不是它的端点上)。按端点重布,那两条就当场挂空 —— `_conn_ok()` 抓到过一次(`KEY_RCLK 1->2`),但代价是整次拆线作废。 ⇒ 先把搭接点算出来切开:切完每一处搭接都是某条子链的端点,重布时自然钉住。 切开只是**改分段**,铜一点没动(切点两侧还是原来那条线,共线相接)。 """ def touch_pts(net, skipk): out = [] for k2, p2 in plan.items(): if k2 == skipk or netof(k2) != net: continue for e in (p2[0], p2[-1]): out.append((e[0], e[1], (e[2],), 0.0)) for q in pads: if q['net'] == net: out.append((q['x'], q['y'], pad_layers(q), min(q['hw'], q['hh']))) for i, l in enumerate(lines): if l['net'] == net and not lown[i]: out.append((l['x0'], l['y0'], (l['lay'],), l['w'] / 2.0)) out.append((l['x1'], l['y1'], (l['lay'],), l['w'] / 2.0)) for i, v in enumerate(vias): if v['net'] == net and not vown[i]: out.append((v['x'], v['y'], (L_TOP, L_BOT), v['r'])) return out out, ncut = {}, 0 for k, path in sorted(plan.items()): net = netof(k) cand = touch_pts(net, k) cuts = [] for i in range(len(path) - 1): x0, y0, lay, w = path[i] x1, y1 = path[i + 1][0], path[i + 1][1] L = math.hypot(x1 - x0, y1 - y0) if L < 1e-6 or path[i + 1][2] != lay: continue # 零长(换层处)不切 for cx, cy, clays, cr in cand: if lay not in clays: continue t = ((cx - x0) * (x1 - x0) + (cy - y0) * (y1 - y0)) / (L * L) if not (1e-6 < t < 1 - 1e-6): continue px, py = x0 + (x1 - x0) * t, y0 + (y1 - y0) * t if math.hypot(cx - px, cy - py) > w / 2.0 + cr + 0.05: continue if math.hypot(px - path[0][0], py - path[0][1]) < 1e-6 or math.hypot(px - path[-1][0], py - path[-1][1]) < 1e-6: continue cuts.append((i, t, round(px, 4), round(py, 4))) cuts.sort() if not cuts: out[k] = path continue # 把切点当成新的顶点插进点表,记下它们的下标(从后往前插,下标不串位) pts, idx = list(path), [] for i, t, px, py in reversed(cuts): lay, w = pts[i][2], pts[i][3] pts.insert(i + 1, (px, py, lay, w)) add = 0 for i, t, px, py in cuts: add += 1 idx.append(i + add) pieces, last = [], 0 for j in idx + [len(pts) - 1]: if j > last: pieces.append(pts[last:j + 1]) last = j for n, pc in enumerate(pieces): if len(pc) >= 2: out[k + KSEP + 'c%d' % n] = pc ncut += len(pieces) - 1 if verbose: print(u' 搭接点切分:%d 条路径 -> %d 条(切了 %d 刀)' % (len(plan), len(out), ncut)) return out def plan_all(): u"""本脚本累计下发过的全部路径 + 它是从哪儿来的。 优先级:累计文件 > 上一轮的计划(非空才算)> **从下发记录反推**。 """ if os.path.exists(PLANALL): p = load_plan(PLANALL) if p: return p, os.path.basename(PLANALL) p = load_plan(RAWROUTES) if p: return p, os.path.basename(RAWROUTES) return plan_from_routes(), os.path.basename(ROUTES) + u'(反推)' def own_of(plan, lines, vias, verbose=True): u"""板上每一条线 / 每一个孔是**本脚本哪几条路径**画的:返回两张同长的归属表 (`frozenset()` = 不是本脚本画的,动不得)。 ⚠️⚠️ **不能按端点精确比对。** EasyEDA 会把共线相接的线段并成一条, 也会在 T 形接点上把已有的线劈成两条(§26.13 第 4 条已经踩过一次)。 实测 2305 段计划里只有 1351 段能在板上找到坐标完全一样的线 —— 按精确比对, `clean()` 会**只删掉 59%**,剩下的铜留在板上而记录里已经没有它,从此没人认领。 ⇒ 判据改成**覆盖**:板上这条线的中心线整条都贴在同网同层同宽的计划线上。 实测 2951 条板上的线里 2277 条整条落在计划里、**0 条只落一半** —— 「一半是我的一半不是」这种没法安全删的情况一条都没有。 """ geo = {k: E.expand(p) for k, p in plan.items()} bucket = {} for k, (segs, _v) in geo.items(): for x0, y0, x1, y1, lay, w in segs: bucket.setdefault((netof(k), lay, round(w, 3)), []).append((x0, y0, x1, y1, k)) lown, partial = [], 0 for l in lines: cand = bucket.get((l['net'], l['lay'], round(l['w'], 3))) if not cand: lown.append(frozenset()) continue L = math.hypot(l['x1'] - l['x0'], l['y1'] - l['y0']) n = max(2, int(L / 0.1) + 1) keys, bad = set(), 0 for i in range(n + 1): t = i / float(n) px, py = l['x0'] + (l['x1'] - l['x0']) * t, l['y0'] + (l['y1'] - l['y0']) * t hit = [k for x0, y0, x1, y1, k in cand if D.seg_pt(x0, y0, x1, y1, px, py) <= OWN_TOL] if hit: keys.update(hit) else: bad += 1 if bad == 0 and keys: lown.append(frozenset(keys)) else: partial += 1 if keys else 0 lown.append(frozenset()) # 过孔:按坐标对。**本脚本的孔都是新打的**,坐标不该和扇出的孔重合 —— # 万一重合了,删它就会把扇出的孔一起删掉,所以下面硬拦一道。 pv = {} for k, (_s, vv) in geo.items(): for x, y in vv: pv.setdefault((netof(k), round(x, 3), round(y, 3)), set()).add(k) vown, claim = [], {} for v in vias: keys = set() for kk, ks in pv.items(): if kk[0] == v['net'] and abs(kk[1] - v['x']) < OWN_TOL \ and abs(kk[2] - v['y']) < OWN_TOL: keys |= ks claim[kk] = claim.get(kk, 0) + 1 vown.append(frozenset(keys)) dup = sum(1 for c in claim.values() if c > 1) if dup: sys.exit(u'归属表:%d 个计划过孔在板上配到了 2 个以上的孔 —— ' u'可能和扇出的孔重合了,删它会连扇出一起删。先查清楚再跑。' % dup) if verbose: print(u' 归属表:板上 %d 线里 %d 条是本脚本画的(只落一半的 %d 条);' u'%d 孔里 %d 个是本脚本打的(累计计划 %d 条路径 / %d 孔)' % (len(lines), sum(1 for s in lown if s), partial, len(vias), sum(1 for s in vown if s), len(plan), len(pv))) return lown, vown class Obs(list): u"""障碍表 + 每一项的**归属**(本脚本的哪几条路径画的),好让拆线重布摘得掉。 归属是个 `frozenset`:板上一条线可能同时落在本脚本相邻两条路径上(同网共线相接, 实测 2277 条里有 401 条这样)。**只有归属整个落在「要拆的那一批」里才摘得掉** —— 否则摘掉它等于把没打算拆的那条也拆了一截。 """ def __init__(self, items=(), own=None): list.__init__(self, items) self.own = list(own) if own is not None else [None] * len(self) def extend(self, it, key=None): it = list(it) list.extend(self, it) self.own.extend([key] * len(it)) def append(self, x, key=None): list.append(self, x) self.own.append(key) def _gone(self, i, keys): o = self.own[i] return bool(o) and o <= keys def without(self, keys): u"""摘掉这几条之后的障碍表(普通 list,只给 `connect()` 读)。""" return [s for i, s in enumerate(self) if not self._gone(i, keys)] def rip(self, keys): u"""就地摘掉 —— 这是「真的拆了」,调用方必须把板上对应的铜也删掉。""" keep = [i for i in range(len(self)) if not self._gone(i, keys)] items = [self[i] for i in keep] own = [self.own[i] for i in keep] self[:] = items self.own[:] = own class Rip(object): u"""拆线重布的上下文。整轮共用一个:拆过谁、拆过几次、重布成什么样都记在这里。""" def __init__(self, plan, pool, pads, nl_pads, lines, vias, lown, vown, pgrids, rip_max=RIP_MAX): self.plan = plan self.pool = frozenset(pool) self.clen = {} for k in self.pool: self.clen[k] = sum(math.hypot(x1 - x0, y1 - y0) for x0, y0, x1, y1, _l, _w in E.expand(plan[k])[0]) # 板上有些线同时落在**相邻两条**路径上(同网共线相接,实测 2277 条里 401 条这样)。 # 只拆其中一条的话,那条线既删不掉(归属没落全)、又已经不在计划里 —— # 从此没人认领:下一轮的归属表会把它算成「不是我画的」,永远拆不动也删不掉。 # ⇒ 拆就得连坐:一条路径的铜和谁共享,就跟谁一起拆。 self.share = {} for ks in list(lown) + list(vown): if len(ks) > 1: for k in ks: self.share.setdefault(k, set()).update(ks) self.pads = pads self.nl_pads = nl_pads self.lines, self.vias = lines, vias self.lown, self.vown = lown, vown self.pgrids = pgrids self.rip_max = rip_max self.ripped = {} # key -> 旧路径(已从模型里摘掉,等着从板上删) self.new = {} # key -> 重布出来的新路径 self.count = {} # key -> 被拆过几次 self.tries = 0 self.wins = 0 self.last = frozenset() self.why = {} # 失败原因 -> 次数 def closure(self, keys): u"把「要拆的这一批」补成共享铜的闭包 —— 不补的话删不干净(见 __init__)。" out, todo = set(keys), list(keys) while todo: k = todo.pop() for k2 in self.share.get(k, ()): if k2 not in out: out.add(k2) todo.append(k2) return out def state(self, extra_rip=(), extra_new=None): u"""板上现在(扣掉已拆、加上已重布)的线 / 孔。""" gone = set(self.ripped) | set(extra_rip) lines = [l for i, l in enumerate(self.lines) if not (self.lown[i] and self.lown[i] <= gone)] vias = [v for i, v in enumerate(self.vias) if not (self.vown[i] and self.vown[i] <= gone)] news = dict(self.new) news.update(extra_new or {}) for k, path in news.items(): sg, vv = E.expand(path) for x0, y0, x1, y1, lay, w in sg: lines.append(dict(net=netof(k), lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w)) for x, y in vv: vias.append(dict(net=netof(k), x=x, y=y, r=VIA_D / 2.0)) return lines, vias def gone_ids(self): u"""已经拆掉的那些板上图元的下标。给校核和 `conn_check` 用。""" g = set(self.ripped) li = set(i for i, s in enumerate(self.lown) if s and s <= g) vi = set(i for i, s in enumerate(self.vown) if s and s <= g) return li, vi def _groups(self, net, lines, vias): items, groups = NL.analyze(net, self.nl_pads, vias, lines, self.pgrids) return sum(1 for gp in groups if any(items[i][0] != 'line' for i in gp)) def _conn_ok(self, bl, redo): u"""拆 + 重布之后,受影响网络的碎片数**不许比之前多**。 ⚠️ 「端点钉死 ⇒ 连通性不变」这条不变量有一个破法:A 的端点正好压在 B 的 **中段**上,而 A、B 这一轮被一起拆掉 —— B 重布之后不一定还经过那个点, 于是 A 端点没动却挂空了。栅格 / 净距 / DRC 三样都看不出来。 """ l0, v0 = self.state() l1, v1 = self.state(bl, redo) self.conn_bad = [] for net in sorted(set(netof(k) for k in bl)): g0, g1 = self._groups(net, l0, v0), self._groups(net, l1, v1) if g1 > g0: self.conn_bad.append((net, g0, g1)) return not self.conn_bad def blockers(self, net, segs, vs, pool): u"""这条路径压到了 `pool` 里的哪几条(按**真实几何**,不过格子)。""" out = set() for x0, y0, x1, y1, lay, w in segs: xa, xb = min(x0, x1) - w - CLR, max(x0, x1) + w + CLR ya, yb = min(y0, y1) - w - CLR, max(y0, y1) + w + CLR for i, l in enumerate(self.lines): ks = self.lown[i] if not ks or not (ks <= pool) or l['lay'] != lay or l['net'] == net: continue if (max(l['x0'], l['x1']) < xa or min(l['x0'], l['x1']) > xb or max(l['y0'], l['y1']) < ya or min(l['y0'], l['y1']) > yb): continue if (D.seg_seg((x0, y0), (x1, y1), (l['x0'], l['y0']), (l['x1'], l['y1'])) - w / 2.0 - l['w'] / 2.0 < CLR - 1e-4): out |= ks for i, v in enumerate(self.vias): ks = self.vown[i] if not ks or not (ks <= pool) or v['net'] == net: continue if (D.seg_pt(x0, y0, x1, y1, v['x'], v['y']) - w / 2.0 - v['r'] < CLR - 1e-4): out |= ks for vx, vy in vs: for i, l in enumerate(self.lines): ks = self.lown[i] if not ks or not (ks <= pool) or l['net'] == net: continue if (D.seg_pt(l['x0'], l['y0'], l['x1'], l['y1'], vx, vy) - VIA_D / 2.0 - l['w'] / 2.0 < CLR - 1e-4): out |= ks for i, v in enumerate(self.vias): ks = self.vown[i] if not ks or not (ks <= pool) or v['net'] == net: continue d = math.hypot(v['x'] - vx, v['y'] - vy) # 净距和**钻孔间距**两条都要看:孔到孔 0.30 那条 `check()` 不管 if d - VIA_D < CLR - 1e-4 or d - VIA_H < HOLE_CLR - 1e-4: out |= ks return out def _reroute(self, key, obs): u"""把被拆的这条按**原来的两个端点、原来的层**重新布出来。""" path = self.plan[key] net = netof(key) def pin(p): return E.Comp([E.sh_rect(p[0], p[1], 0.001, 0.001, (p[2],))], (p[0], p[1]), center=(p[0], p[1]), layers=(p[2],)) dflt, wmin = wclass(net) w = max(wmin, min(dflt, path[0][3])) # ⚠️ **两个端点自己也要 relax。** 被拆的这条常常正躺在一条窄缝里 —— # 端点那一格的 `edt` 顶不住 `CLR + w/2 + QERR`,A* 的**种子格**就不是自由格, # 于是「明明本来就有一条合法的线从这里出发」却一步都走不出去 # (实测 `EDL_EN3` 一条 2 点的出线段都布不回去)。 # 放开这一格是有据的:这里原本就合法地走着一条同宽的线。 relax = [E.sh_rect(cx, cy, hw, hh, pad_layers(q)) for q in self.pads if q['net'] == net for cx, cy, hw, hh in pad_rects(q)] relax += [E.sh_rect(p[0], p[1], 0.051, 0.051, (p[2],)) for p in (path[0], path[-1])] return connect(net, pin(path[0]), pin(path[-1]), obs, _wladder(w, wmin), relax=relax) def _no(self, why): self.why[why] = self.why.get(why, 0) + 1 return None def attempt(self, net, a, b, obstacles, widths, relax, verbose=True): u"""这条连接布不通 —— 试试拆线重布。成功返回 `(path, shapes)`; 失败返回 `None`,**且一根铜都没动**。""" self.tries += 1 avail = frozenset(k for k in self.pool if k not in self.ripped and self.count.get(k, 0) < RIP_LIMIT) if not avail: return self._no(u'无可拆') # ① 一档一档放宽「允许拆什么」:先只许拆短的,再许拆长的。 # 每一档都问一句「把这一档的铜当不存在,这条通不通」—— # 一档都不通就是**真没路**(不是拥挤),拆了也白拆。 probe = bl = None prev_pool = None for lim in RIP_TIERS: pool = frozenset(k for k in avail if self.clen.get(k, 0.0) <= lim) if not pool or pool == prev_pool: continue # 这一档和上一档一样,别白算一遍 prev_pool = pool pr = connect(net, a, b, obstacles.without(pool), widths, relax=relax) if pr is None: continue ps, pv = E.expand(pr[0]) cand = self.blockers(net, ps, pv, pool) if len(cand) <= self.rip_max: probe, bl, psegs, pvs = pr, cand, ps, pv break probe, bl, psegs, pvs = pr, cand, ps, pv # 记下来,下一档还不行就报它 if probe is None: return self._no(u'拆光也不通') n0 = len(bl) bl = self.closure(bl) if bl else bl if bl and not (bl <= avail): return self._no(u'连坐拆到了不许拆的') if len(bl) > self.rip_max: if verbose: print(u' 拆线重布:理想路径要压掉 %d 条(连坐后 %d,上限 %d),放弃' % (n0, len(bl), self.rip_max)) return self._no(u'要拆的太多') if not bl: # 拆光才通、却一条也没压到 —— 差别只在栅格精度上。真实几何过得去就收下。 if _geo_ok(net, psegs, pvs, obstacles): self.wins += 1 self.last = frozenset() return probe return self._no(u'栅格假象') # ② 摘掉挡路的那几条,重算这条连接 obs2 = obstacles.without(bl) seg = connect(net, a, b, obs2, widths, relax=relax) if seg is None: return self._no(u'拆完还是不通') path, shp = seg # ③ 被拆的一条一条重布(短的先,长的绕的余地大) obs3 = list(obs2) + [(sp, net) for sp in shp] redo = {} for k in sorted(bl, key=lambda k: len(self.plan[k])): r = self._reroute(k, obs3) if r is None: if verbose: print(u' 拆线重布:%s 的一段(%d 点 %.2f→%.2f mm)布不回去,放弃' % (netof(k), len(self.plan[k]), self.plan[k][0][0], self.plan[k][-1][0])) return self._no(u'重布不回去') redo[k] = r[0] obs3.extend((sp, netof(k)) for sp in r[1]) # ④ 连通性兜底 if not self._conn_ok(bl, redo): if verbose: print(u' 拆线重布:连通性会变差(%s),放弃' % u' '.join(u'%s %d->%d' % t for t in self.conn_bad)) return self._no(u'连通性会变差') # ⑤ 落地:模型里摘掉旧的、加上重布的 obstacles.rip(bl) for k in bl: self.ripped[k] = self.plan[k] self.count[k] = self.count.get(k, 0) + 1 for k, p in redo.items(): self.new[k] = p sg, vv = E.expand(p) obstacles.extend([(E.sh_seg(x0, y0, x1, y1, w, lay), netof(k)) for x0, y0, x1, y1, lay, w in sg] + [(E.sh_via(x, y), netof(k)) for x, y in vv]) self.wins += 1 self.last = frozenset(bl) if verbose: print(u' 拆线重布:让开 %d 条(%s)' % (len(bl), u' '.join(sorted(set(netof(k) for k in bl))))) return path, shp # ══════════════════ 主流程 ══════════════════ def net_order(js): u"""布线顺序:**难的先布**。难度 = 跨度 × 还差几条,宽度只当同分时的次序。 ⚠️ 第一版是「宽的先布」。结果 `+3V3`(0.6 mm)先把 R60x 那一排上拉电阻旁边的缝 占了,轮到 `SDA` / `INA_ALERT`(0.2 mm、要横跨整块板)就一条也出不来 —— 它们**单独布是全通的**。窄线让得起路,跨全板的总线让不起,所以先给总线。 """ def keyf(item): net, frags = item dflt, _ = wclass(net) return (-frag_span(frags) * math.sqrt(len(frags) - 1), -dflt, net) return sorted(js, key=keyf) def main(): argv = sys.argv[1:] refresh = '--refresh' in argv want = None if '--net' in argv: want = set(a for a in argv[argv.index('--net') + 1:] if not a.startswith('--')) limit = None if '--limit' in argv: limit = int(argv[argv.index('--limit') + 1]) # ⛔ `--clean` 在**增量**布线器上是个陷阱:它删的是本脚本**累计**画过的全部铜, # 而这一轮只算「还欠的那几条」—— 删掉的那几百条不会被重画。 # (2026-09-06 之前它只删得掉 59%,靠的是坐标精确比对;现在按归属表删, # 是真的全删,所以必须加一道闸。) if '--clean' in argv and '--force-clean' not in argv: sys.exit(u'⛔ `--clean` 会把本脚本累计画过的铜全删掉,而这一轮只算「还欠的」,' u'删掉的不会被重画。真要整块重来:--clean --force-clean。') do_rip = '--ripup' in argv rip_max = RIP_MAX if '--rip-max' in argv: rip_max = int(argv[argv.index('--rip-max') + 1]) if '--verify' in argv and '--push' not in argv: sys.exit(0 if verify() == 0 else 1) # `--use-plan`:直接吃上一次算好的 `_pcb1_sig_plan.json`,只跑校核 + 下发。 # 改闸门 / 改校核的时候不用再等一小时重算 —— 板子没动过,缓存就还是对的。 use_plan = '--use-plan' in argv if use_plan and not os.path.exists(RAWROUTES): sys.exit(u'没有 %s —— 先不带 --use-plan 跑一遍' % os.path.basename(RAWROUTES)) if use_plan and do_rip: sys.exit(u'--use-plan 和 --ripup 不能一起用:缓存里只有路径,' u'没有「这一轮拆了谁」,照着它下发会把拆掉的铜又画回去。') t0 = time.time() pads, vias, lines = board(refresh) pours = pour_rects() print(u'板上:%d 焊盘 / %d 过孔 / %d 走线;外层铺铜切成 %d 条矩形障碍' % (len(pads), len(vias), len(lines), len(pours))) bytag = {p['tag']: p for p in pads} # ── ⓪ 归属表:板上哪些铜是本脚本自己画的(拆线重布 / --clean 都靠它)── lown = vown = None pall, pall_src = plan_all() if do_rip or '--clean' in argv: if not pall: sys.exit(u'没有累计计划文件(%s / %s),认不出板上哪些铜是自己画的 —— ' u'不敢拆也不敢删' % (os.path.basename(PLANALL), os.path.basename(RAWROUTES))) lown, vown = own_of(pall, lines, vias, verbose=False) # 先按搭接点切开再算最终归属:切完每一处搭接都落在某条子链的端点上, # 拆线重布的「端点钉死 ⇒ 连通性不变」才真的成立(见 `split_chains()`)。 pall = split_chains(pall, pads, lines, vias, lown, vown) lown, vown = own_of(pall, lines, vias) print(u' (累计计划来自 %s)' % pall_src) js = net_order(jobs(pads, want, refresh, skip=SKIP)) if limit: js = js[:limit] need = sum(len(f) - 1 for _n, f in js) print(u'任务:%d 个网络 / %d 条连接(按线宽 → 跨度排序)' % (len(js), need)) obstacles = obstacles_of(pads, vias, lines, pours, lown, vown) rip = None if do_rip: nl_pads, _nv, _nl = NL.geom(False) pool = rip_pool(pall, bytag) rip = Rip(pall, pool, pads, nl_pads, lines, vias, lown, vown, NL.pour_grids(False), rip_max) print(u' 拆线重布:**开**(%d 条路径里 %d 条可拆,一次最多拆 %d 条,' u'同一条最多拆 %d 次)' % (len(pall), len(pool), rip_max, RIP_LIMIT)) # ── ① Kelvin:先于一切,两端钉死在指定焊盘上 ── routes = {} forced_ok = 0 for a, b, why in FORCE: if want and bytag.get(a, {}).get('net') not in want: continue qa, qb = bytag.get(a), bytag.get(b) if not qa or not qb: sys.exit(u'FORCE 表里的 %s / %s 板上找不到 —— 摆位或位号变过了' % (a, b)) if qa['net'] != qb['net']: sys.exit(u'FORCE %s(%s) 和 %s(%s) 不是同一个网' % (a, qa['net'], b, qb['net'])) net = qa['net'] ca = E.Comp([E.sh_rect(qa['x'], qa['y'], qa['hw'], qa['hh'], (qa['lay'],))], (qa['x'], qa['y']), center=(qa['x'], qa['y']), layers=(qa['lay'],)) cb = E.Comp([E.sh_rect(qb['x'], qb['y'], qb['hw'], qb['hh'], (qb['lay'],))], (qb['x'], qb['y']), center=(qb['x'], qb['y']), layers=(qb['lay'],)) w = width_for(net, [qa], [qb]) _d, wmin = wclass(net) seg = connect(net, ca, cb, obstacles, _wladder(w, wmin), relax=[ca.shapes[0], cb.shapes[0]]) if seg is None: print(u' [!!] Kelvin %s -> %s 布不通(%s)' % (a, b, why)) continue path, shp = seg key = 'FORCE:%s:%s' % (a, b) routes[net + KSEP + key] = path obstacles.extend((sp, net) for sp in shp) forced_ok += 1 print(u' Kelvin %-9s -> %-9s w=%.2f %s' % (a, b, path[0][3], why)) print(u' Kelvin 共 %d / %d 条' % (forced_ok, len(FORCE))) # ── ①b USB 差分对:一整束布,再平行偏移 ── skip = set(SKIP) if not want or (want & SKIP): up = usb_pair(pads, obstacles, bytag) if up: skip -= SKIP # 一对已经落地,剩下的 A↔B 短接交给通用布线器 for key, path in up.items(): routes[key] = path s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: obstacles.append((E.sh_seg(x0, y0, x1, y1, w, lay), netof(key))) for x, y in v_: obstacles.append((E.sh_via(x, y), netof(key))) forced_ok += len(up) else: print(u' ⚠️ USB 差分对没布成,`USB_DP` / `USB_DM` 这一轮**整个跳过** —— ' u'宁可空着,也不要两根各走各的最短路。') # 上一步画的铜会改变连通性:重算任务表(不然 U13.9 会被再连一次) if forced_ok or skip != SKIP: ex_l, ex_v = [], [] for key, path in routes.items(): net = netof(key) s_, v_ = E.expand(path) for x0, y0, x1, y1, lay, w in s_: ex_l.append(dict(net=net, lay=lay, x0=x0, y0=y0, x1=x1, y1=y1, w=w)) for x, y in v_: ex_v.append(dict(net=net, x=x, y=y, r=VIA_D / 2.0)) js = net_order(jobs(pads, want, False, ex_l, ex_v, skip)) if limit: js = js[:limit] print(u'任务(扣掉 Kelvin / USB 之后):%d 个网络 / %d 条连接' % (len(js), sum(len(f) - 1 for _n, f in js))) if use_plan: routes = {k: [tuple(p) for p in v] for k, v in json.load(io.open(RAWROUTES, encoding='utf-8')).items()} print(u' 用缓存的计划:%d 条路径(%s)' % (len(routes), os.path.basename(RAWROUTES))) frags_by_net = dict(js) nfail = 0 for net, frags in js: nfail += max(0, len(frags) - 1 - sum(1 for k in routes if netof(k) == net)) globals()['_PLAN_ROUTES'] = routes globals()['_PLAN_FRAGS'] = frags_by_net globals()['_PLAN_NFAIL'] = nfail # ── ② 出线段 ── need_tags = set() for net, frags in js: for f in frags: if len(f['pads']) == 1 and not f['vias'] and not f['lines']: need_tags.add(f['pads'][0]['tag']) order = [n for n, _f in js] stubs, alts = plan_stubs(order, pads, obstacles, need_tags) print(u' 出线段:%d 个孤立焊盘里留了 %d 个' % (len(need_tags), len(stubs))) escapes, stuck = plan_escapes(pads, obstacles, need_tags, stubs) # ── ③ 逐网络布线 ── frags_by_net, nfail, ndone = {}, 0, 0 for k, (net, frags) in enumerate([] if use_plan else js): frags_by_net[net] = frags res = route_net(net, frags, pads, obstacles, stubs, alts, escapes, rip=rip) if not res: continue paths, fails = res nfail += fails for i, (path, _shp) in enumerate(paths): routes[net + KSEP + str(i)] = path ndone += len(frags) - 1 - fails if (k + 1) % 5 == 0 or k == len(js) - 1: print(u' … %d/%d 个网络,已连 %d 条,布不通 %d 条,%.0f s' % (k + 1, len(js), ndone, nfail, time.time() - t0)) # ── ③a 拆线重布的产物并进来 ── # 被拆掉的那几条要按**原来的两个端点**重画,键沿用旧的再加一段 `rr`(网名不变)。 if rip is not None: for k_, p_ in rip.new.items(): routes[k_ + KSEP + 'rr'] = p_ print(u'\n 拆线重布:试了 %d 次,成 %d 次;拆掉 %d 条、重布回来 %d 条' % (rip.tries, rip.wins, len(rip.ripped), len(rip.new))) if rip.why: print(u' 没成的理由:' + u' '.join(u'%s x%d' % (a, b) for a, b in sorted(rip.why.items(), key=lambda e: -e[1]))) # 板上被拆掉的那些铜,从校核用的表里摘掉(不摘的话新铜会和「已经不存在的铜」打架) gl, gv = rip.gone_ids() if gl or gv: lines = [l for i, l in enumerate(lines) if i not in gl] vias = [v for i, v in enumerate(vias) if i not in gv] gone_l = set(id(l) for i, l in enumerate(rip.lines) if i in gl) gone_v = set(id(v) for i, v in enumerate(rip.vias) if i in gv) for _n, fr in frags_by_net.items(): for f in fr: f['lines'] = [l for l in f['lines'] if id(l) not in gone_l] f['vias'] = [v for v in f['vias'] if id(v) not in gone_v] # ── ③b 45° 化:全板不许有直角走线(用户 2026-09-06 拍板)── # ⚠️ **`smooth=True` 会挪动路径(抹微台阶,最多 0.15 mm),不只是拿掉铜。** # 而 `chamfer_routes` 里的 `seg_slack` 拿的是**切之前**的 `mine` # ⇒ 两条本轮新画的线可能被各自抹到对方身上。实测 `$1N227` 与 `POGO1_PWR` # 就这么压成 **−0.199**(2255 段里 2 段)。 # ⇒ 切完再跑一遍 `check()`,**把牵涉到违规的那几条整条退回未切的版本**,再核一次。 if use_plan: routes = _PLAN_ROUTES frags_by_net = _PLAN_FRAGS nfail = _PLAN_NFAIL raw = dict(routes) n_sharp = len(D.sharp_corners(routes)) chk_pads = expand_pads(pads) + pour_pads(pours) if not use_plan: # 缓存里的路径已经切过 45° 了,别再切一遍 routes = D.chamfer_routes(routes, chk_pads, vias, lines, smooth=True) for _round in range(0 if use_plan else 4): bad0 = D.check(routes, chk_pads, vias, lines, verbose=False) hurt = set() for t, n1, n2, lay, d_, a, b in bad0: for key, path in routes.items(): if netof(key) not in (n1, n2) or key not in raw: continue for x0, y0, x1, y1, ll, ww in E.expand(path)[0]: if D.seg_seg((x0, y0), (x1, y1), a, b) < 1e-6: hurt.add(key) break hurt = set(k for k in hurt if routes[k] != raw[k]) if not hurt: break print(u' 45° 化后有 %d 条压线,把 %s 退回未切的版本' % (len(bad0), ' '.join(sorted(netof(k) for k in hurt)))) for k in hurt: routes[k] = raw[k] print(u' 45° 化:直角拐点 %d -> %d' % (n_sharp, len(D.sharp_corners(routes)))) # ⛔ **只有全量跑才许覆盖计划缓存。** 以前是无条件写的 —— 一条 # `--net EDL_EN2` 的空跑就能把 700 条路径的计划清成 `{}`(2026-09-06 实测踩到), # 而归属表 / `--use-plan` 都指着这个文件。 if want is None and limit is None: io.open(RAWROUTES, 'w', encoding='utf-8').write(json.dumps( {k: v for k, v in routes.items()}, ensure_ascii=False)) else: print(u' (--net / --limit 的试水,不覆盖 %s)' % os.path.basename(RAWROUTES)) segs, vs = to_wire(routes) print(u'\n本轮:%d 条路径 / %d 段线 / %d 个过孔,总长 %.1f mm' % (len(routes), len(segs), len(vs), sum(math.hypot(s[4] - s[2], s[5] - s[3]) for s in segs))) # ── ④ 校核 ── print(u'\n---- 校核 ----') bad = D.check(routes, expand_pads(pads) + pour_pads(pours), vias, lines, verbose=True) hb = hole_check(routes, vias) for t, n1, n2, d_, p in sorted(hb, key=lambda e: e[3])[:15]: print(u' [!!] %-9s %-11s vs %-11s 钻孔净距 %+.3f (%.2f,%.2f)' % (t, n1, n2, d_, p[0], p[1])) pb = pour_check(routes, pours) for t, n1, n2, lay, d_, a, b in sorted(pb, key=lambda e: e[4])[:15]: print(u' [!!] %-9s %-11s vs %-11s L%-2s 净距 %+.3f (%.2f,%.2f)' % (t, n1, n2, lay, d_, a[0], a[1])) sc = D.sharp_corners(routes) for key, i, p, t in sc[:10]: print(u' [!!] 直角拐点 %s 第%d点 (%.2f,%.2f) 转向 %.0f°' % (key, i, p[0], p[1], t)) cb = conn_check(routes, frags_by_net) for m in cb[:15]: print(u' [!!] ' + m) ka = sw_keepaway(routes, lines) for net, d_, p in ka[:12]: print(u' [注] %-11s 离 SW 节点只有 %.2f mm((%.1f,%.1f) 附近)—— §10.3 要「远离」' % (net, d_, p[0], p[1])) # ⚠️ **拦下发的只有「铜本身错了」那三样**:净距 / 钻孔 / 铺铜。 # 「直角」和「连通」是**结论**,不是错误: # · 直角 —— `chamfer45` 半径试到 0.05 还切不动的,是这一处通道宽度物理上不允许; # · 连通 —— 有多少条没布通就有多少个网络还是碎的,拦下发不会让它变通。 # 把这两样也当闸门,「布通的那 289 条」就永远落不到板上; # 而这个布线器是**增量**的 —— 下一轮读板上的铜,接着补剩下的。 nbad = len(bad) + len(hb) + len(pb) print(u'\n净距 %d / 钻孔 %d / 铺铜 %d(**只有这三样拦下发**)' u' | 直角 %d / 连通 %d(只报告);布不通 %d 条' % (len(bad), len(hb), len(pb), len(sc), len(cb), nfail)) if nbad == 0 and nfail == 0 and not sc and not cb: print(u'✅ 全部校核通过') elif nbad == 0: print(u'✅ 铜本身没问题(净距 / 钻孔 / 铺铜 全 0);其余是「还没布完」,不拦下发') if '--push' in argv: if nbad: sys.exit(u'有 %d 条**铜的错误**,没有下发' % nbad) if '--clean' in argv: clean(rip.lines if rip else lines, rip.vias if rip else vias, lown, vown) pall = {} elif rip is not None and rip.ripped: # ⚠️ **先删后加,而且删不干净就不许继续。** 被拆的那几条如果留在板上, # 新画的铜正好压在它们身上 —— 而 DRC 会老老实实报出来, # 到那时已经是「板上多了一堆不知道谁的铜」了。 gl, gv = rip.gone_ids() drop(rip.lines, rip.vias, gl, gv, u'拆线(%d 条路径)' % len(rip.ripped)) push(routes) verify() # 累计计划 = 上一版 − 这一轮拆掉的 + 这一轮画的。 # 它是**下一轮认得出「板上哪些铜是自己画的」的唯一依据** —— 漏写一次, # 从此这些铜既拆不掉也删不掉。 keep = {k: v for k, v in pall.items() if not (rip is not None and k in rip.ripped)} keep.update(routes) io.open(PLANALL, 'w', encoding='utf-8').write( json.dumps(keep, ensure_ascii=False)) print(u' 累计计划:%d 条路径 -> %s' % (len(keep), os.path.basename(PLANALL))) print(u'\n⚠️ 新过孔穿过内层平面,那里现在还没有反焊盘 —— ' u'**接着必须跑 `pcb1_pour.py --push`**,再 `drc_read.py`。') else: print(u'(未加 --push,没有下发)') return 1 if (nbad or nfail) else 0 if __name__ == '__main__': sys.exit(main())