原理图/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
2342 行
113 KiB
Python
2342 行
113 KiB
Python
# -*- 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())
|