文件
zhou yang和Claude Opus 5 2bfe308b30 初始导入:PhoneRail 五路手机直供电源板
原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。
这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。

纳入:
- 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本
- .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、
  pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源)
- 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾)
- firmware/ 的 A1 骨架与 site/ 文档站源码

.gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、
site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。

.gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存,
不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
2026-09-06 18:18:14 +08:00

2342 行
113 KiB
Python

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- 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())