315 行
14 KiB
Python
315 行
14 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""把板上所有**直角走线**改成 45° 斜切(M7 收尾)。
|
||
|
||
/c/Python314/python .claude/r2/miter.py # 只算 + 报告,不下发
|
||
/c/Python314/python .claude/r2/miter.py --push # 下发(自动跑到没有直角为止)
|
||
/c/Python314/python .claude/r2/miter.py --check # 下发后复查还剩几个直角
|
||
|
||
## 为什么可以放心做
|
||
|
||
~~45° 斜切是只去铜、不加铜~~ —— ⛔ **2026-09-11 更正:这句只对拐角的外侧成立。**
|
||
斜边的中心线落在原直角的三角形里,但它有线宽:斜边**凹侧**那条边会越过原来 L 形的内角、
|
||
往凹侧伸进去(切深 d = K×线宽,1.19 宽的 +12V 线伸进去约 1 mm)—— 那是**加铜**。
|
||
刀片 90×68 那一轮就这么压上了凹侧的一个 `$3N30` 过孔(DRC 导线 / 钻孔到过孔 0 mil);
|
||
M7 全是细线,碰巧没出事。⇒ `corners()` 现在对每条新斜边按精确几何校核
|
||
(异网同层线 / 所有过孔 / 异网同层焊盘 ≥ 规则净距 0.15),不够就不切(统计里的「凹侧净距不够」)。
|
||
|
||
## 判据(哪些拐点**不**动)
|
||
|
||
| 情形 | 为什么不动 |
|
||
|---|---|
|
||
| 拐点上坐着**过孔**或**焊盘** | 两段是各自接到孔/盘上的,切角会把它们从孔/盘上拽下来 |
|
||
| 拐点的两条腿有一条**太短** | 切角要各退 `d`,`d` 超过腿长的一半就把腿吃没了 |
|
||
| 拐点连着的段不是**恰好两条** | 三岔口(干线上挂支路)切角会切断其中一支 |
|
||
| 两段的**线宽不一样** | 斜边该用哪个宽度没有唯一答案,宽的那条会被缩窄 |
|
||
|
||
`d` 取 `min(腿长/2, max(MIN_D, K × 线宽))` —— 线越宽切得越多,但不超过腿长的一半。
|
||
|
||
## ⚠ 和布线器的关系
|
||
|
||
`router.py --replan/--push` 会按缓存重画,**斜切会被覆盖**。
|
||
顺序是:布线 → 下发 → 缝合过孔 → **斜切** → 铺铜。重跑布线器就要重跑这一步。
|
||
"""
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
import sys
|
||
|
||
sys.stdout.reconfigure(encoding='utf-8')
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, os.path.dirname(HERE))
|
||
sys.path.insert(0, HERE)
|
||
from eda import run # noqa: E402
|
||
# ⚠ `--base` 切到底板(`pcb_base` + `_base_pads.json`);不带就是刀片,行为一字未变。
|
||
if '--base' in sys.argv:
|
||
import pcb_base as B # noqa: E402
|
||
PADS_JSON = os.path.join(HERE, '_base_pads.json')
|
||
else:
|
||
import pcb_blade as B # noqa: E402
|
||
PADS_JSON = os.path.join(HERE, '_blade_pads.json')
|
||
|
||
MIL = B.MIL
|
||
CU = (1, 2, 15, 16)
|
||
K = 1.5 # 切角深度 = K × 线宽
|
||
MIN_D = 0.25 # 但不小于这个(mm)
|
||
W_MIX_MAX = 0.45 # 宽窄不同的拐角:两边都 <= 这个宽度才切(斜边取窄的)
|
||
EPS = 0.02 # 端点归并容差(mm)
|
||
NEAR_PAD = 0.05 # 拐点离孔/盘多近就算「坐在上面」
|
||
|
||
FETCH = u"""
|
||
await eda.dmt_EditorControl.openDocument('%s');
|
||
await new Promise(r=>setTimeout(r,2500));
|
||
const L=(await eda.pcb_PrimitiveLine.getAll()||[]).filter(l=>[1,2,15,16].includes(l.layer))
|
||
.map(l=>[l.primitiveId,l.net,l.layer,l.startX,l.startY,l.endX,l.endY,l.lineWidth]);
|
||
const V=(await eda.pcb_PrimitiveVia.getAll()||[]).map(v=>[v.x,v.y]);
|
||
return {L, V};
|
||
""" % B.PCB
|
||
|
||
|
||
def fetch():
|
||
r = run(FETCH, timeout=300)
|
||
if not r.get('success'):
|
||
sys.exit(json.dumps(r, ensure_ascii=False)[:400])
|
||
segs = [{'id': a[0], 'net': a[1], 'lay': a[2],
|
||
'a': (a[3] / MIL, a[4] / MIL), 'b': (a[5] / MIL, a[6] / MIL),
|
||
'w': a[7] / MIL} for a in r['result']['L']]
|
||
vias = [(a[0] / MIL, a[1] / MIL) for a in r['result']['V']]
|
||
return segs, vias
|
||
|
||
|
||
_REP = {} # 端点归并:代表点 -> 代表点自己(空间哈希)
|
||
|
||
|
||
def key(p, reset=False):
|
||
u"""端点归并成「顶点」。
|
||
|
||
⛔ **不能写成 `round(x / EPS)`**:那是把坐标切成固定的桶,相差 0.011 的两个点
|
||
会掉进相邻的两个桶里。实测后果:同一个拐角被看成「两个各只连一条线的点」,
|
||
两条腿各自被当成独立的段 —— 于是同一个角**切了两次**,板上留下
|
||
0.125 mm 的小碎块(用户在 `KPAD_VUP` / `$2N139` 那一带一眼看到)。
|
||
⇒ 改成**空间哈希 + 就近归并**:在 3×3 的相邻桶里找已有代表点,
|
||
距离 <= EPS 就用它,否则自己当代表点。
|
||
"""
|
||
global _REP
|
||
if reset:
|
||
_REP = {}
|
||
b = (int(math.floor(p[0] / EPS)), int(math.floor(p[1] / EPS)))
|
||
for dx in (-1, 0, 1):
|
||
for dy in (-1, 0, 1):
|
||
for q in _REP.get((b[0] + dx, b[1] + dy), ()):
|
||
if math.hypot(p[0] - q[0], p[1] - q[1]) <= EPS:
|
||
return q
|
||
q = (round(p[0], 4), round(p[1], 4))
|
||
_REP.setdefault(b, []).append(q)
|
||
return q
|
||
|
||
|
||
def ang(p, q):
|
||
return math.atan2(q[1] - p[1], q[0] - p[0])
|
||
|
||
|
||
CLR_CHECK = 0.15 # 设计规则的安全间距(新斜边的校核判据)
|
||
VIA_R = 0.3
|
||
|
||
|
||
def _step(p, o, d):
|
||
L = math.hypot(o[0] - p[0], o[1] - p[1])
|
||
return (p[0] + (o[0] - p[0]) / L * d, p[1] + (o[1] - p[1]) / L * d)
|
||
|
||
|
||
def _pt_seg(px, py, a, b):
|
||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||
L2 = dx * dx + dy * dy
|
||
t = 0.0 if L2 < 1e-12 else max(0.0, min(1.0, ((px - a[0]) * dx + (py - a[1]) * dy) / L2))
|
||
return math.hypot(px - (a[0] + t * dx), py - (a[1] + t * dy))
|
||
|
||
|
||
def _seg_seg(a, b, c, e):
|
||
def cr(o, p, q):
|
||
return (p[0] - o[0]) * (q[1] - o[1]) - (p[1] - o[1]) * (q[0] - o[0])
|
||
d1, d2, d3, d4 = cr(c, e, a), cr(c, e, b), cr(a, b, c), cr(a, b, e)
|
||
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
|
||
return 0.0
|
||
return min(_pt_seg(a[0], a[1], c, e), _pt_seg(b[0], b[1], c, e),
|
||
_pt_seg(c[0], c[1], a, b), _pt_seg(e[0], e[1], a, b))
|
||
|
||
|
||
def _seg_rect(a, b, r):
|
||
for p in (a, b):
|
||
if r[0] <= p[0] <= r[2] and r[1] <= p[1] <= r[3]:
|
||
return 0.0
|
||
corners4 = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])]
|
||
return min(_seg_seg(a, b, corners4[k], corners4[(k + 1) % 4]) for k in range(4))
|
||
|
||
|
||
def _clear(q1, q2, w, lay, net, i, j, segs, vias, pads):
|
||
u"""新斜边 q1→q2(宽 w)离异网同层的线、所有过孔、异网同层焊盘是否都 ≥ CLR_CHECK。
|
||
|
||
过孔在 fetch() 里不带网名 ⇒ 一律当障碍(保守:本网的孔若恰好在凹侧,这个角就不切)。"""
|
||
for k, s in enumerate(segs):
|
||
if k in (i, j) or s['lay'] != lay or s['net'] == net:
|
||
continue
|
||
if _seg_seg(q1, q2, s['a'], s['b']) < w / 2.0 + s['w'] / 2.0 + CLR_CHECK:
|
||
return False
|
||
for (x, y) in vias:
|
||
if _pt_seg(x, y, q1, q2) < w / 2.0 + VIA_R + CLR_CHECK:
|
||
return False
|
||
for p in pads:
|
||
if p[6] != lay or p[7] == net:
|
||
continue
|
||
r = (p[2] - p[4] / 2.0, p[3] - p[5] / 2.0, p[2] + p[4] / 2.0, p[3] + p[5] / 2.0)
|
||
if _seg_rect(q1, q2, r) < w / 2.0 + CLR_CHECK:
|
||
return False
|
||
return True
|
||
|
||
|
||
def corners(segs, vias, pads):
|
||
u"""找出所有可切的直角拐点 -> [(拐点, 段i, 段j, d)]。"""
|
||
key((0.0, 0.0), reset=True) # 每次重建归并表(同一进程里可能跑两轮)
|
||
inc = {}
|
||
for i, s in enumerate(segs):
|
||
if s['lay'] not in CU:
|
||
continue
|
||
for p in (s['a'], s['b']):
|
||
inc.setdefault((s['lay'], key(p)), []).append(i)
|
||
blocked = set()
|
||
for (x, y) in vias:
|
||
blocked.add(key((x, y)))
|
||
for p in pads:
|
||
blocked.add(key((p[2], p[3])))
|
||
out, stats = [], {'via_pad': 0, 'short': 0, 'branch': 0, 'width': 0, 'not90': 0}
|
||
for (lay, k), idxs in sorted(inc.items()):
|
||
if len(idxs) != 2:
|
||
if len(idxs) > 2:
|
||
stats['branch'] += 1
|
||
continue
|
||
i, j = idxs
|
||
si, sj = segs[i], segs[j]
|
||
if si['net'] != sj['net']:
|
||
continue
|
||
# 拐点坐标(取两段共有的那一端)
|
||
pi = si['a'] if key(si['a']) == k else si['b']
|
||
oi = si['b'] if key(si['a']) == k else si['a']
|
||
oj = sj['b'] if key(sj['a']) == k else sj['a']
|
||
d1 = math.hypot(oi[0] - pi[0], oi[1] - pi[1])
|
||
d2 = math.hypot(oj[0] - pi[0], oj[1] - pi[1])
|
||
if d1 < 1e-6 or d2 < 1e-6:
|
||
continue
|
||
da = (ang(pi, oi) - ang(pi, oj)) % (2 * math.pi)
|
||
deg = min(abs(math.degrees(da)), abs(360 - math.degrees(da)))
|
||
if abs(deg - 90.0) > 1.0: # 只切 90°(135° 已经是斜的)
|
||
stats['not90'] += 1
|
||
continue
|
||
if k in blocked:
|
||
stats['via_pad'] += 1
|
||
continue
|
||
w = min(si['w'], sj['w'])
|
||
if abs(si['w'] - sj['w']) > 1e-6 and max(si['w'], sj['w']) > W_MIX_MAX:
|
||
# ⛔ 宽窄不同的拐角只在**两边都不粗**时才切,斜边取**窄**的那个宽度:
|
||
# 取宽的会在窄腿两侧多出 (W−w)/2 的铜(那是**加铜**,可能破净距);
|
||
# 取窄的等于把拐角缩到窄腿的口径 —— 对 0.8 mm 的 12V 干线就是掐脖子。
|
||
# 实测这一档只剩金手指那 9 个「地指(0.2) 接地母线(0.4)」和一个 +3V3 引出,
|
||
# 都是小电流,缩到 0.2 无所谓。
|
||
stats['width'] += 1
|
||
continue
|
||
d = min(d1 / 2.0, d2 / 2.0, max(MIN_D, K * w))
|
||
if d < 0.1:
|
||
stats['short'] += 1
|
||
continue
|
||
# ⛔ 新斜边的凹侧会加铜(模块头的更正)⇒ 先按精确几何校核,不够就不切
|
||
if not _clear(_step(pi, oi, d), _step(pi, oj, d), w, lay, si['net'], i, j, segs, vias, pads):
|
||
stats['clear'] = stats.get('clear', 0) + 1
|
||
continue
|
||
out.append((pi, i, j, d, oi, oj, w))
|
||
return out, stats
|
||
|
||
|
||
def apply(segs, cuts):
|
||
u"""算出改动:{段 id: 新端点} + 新增的斜边。"""
|
||
newend = {}
|
||
add = []
|
||
for (p, i, j, d, oi, oj, w) in cuts:
|
||
s1, s2 = segs[i], segs[j]
|
||
u1 = ((oi[0] - p[0]) / math.hypot(*(oi[0] - p[0], oi[1] - p[1])),
|
||
(oi[1] - p[1]) / math.hypot(*(oi[0] - p[0], oi[1] - p[1])))
|
||
u2 = ((oj[0] - p[0]) / math.hypot(*(oj[0] - p[0], oj[1] - p[1])),
|
||
(oj[1] - p[1]) / math.hypot(*(oj[0] - p[0], oj[1] - p[1])))
|
||
q1 = (round(p[0] + u1[0] * d, 4), round(p[1] + u1[1] * d, 4))
|
||
q2 = (round(p[0] + u2[0] * d, 4), round(p[1] + u2[1] * d, 4))
|
||
newend.setdefault(i, {})[key(p)] = q1
|
||
newend.setdefault(j, {})[key(p)] = q2
|
||
add.append((s1['net'], s1['lay'], q1, q2, w))
|
||
return newend, add
|
||
|
||
|
||
# ⚠ 不用 `modify`:它的入参形状没实测过;`delete` + `create` 是这个项目里已经用熟的路子。
|
||
# 网名/层/线宽全从 Python 侧带过来,**不回读**(少一次 RPC,也不怕 get() 不存在)。
|
||
PUSH = u"""
|
||
const MIL=%s, MOD=%s, ADD=%s;
|
||
let nd=0, na=0;
|
||
for (const m of MOD) {
|
||
await eda.pcb_PrimitiveLine.delete(m[0]);
|
||
await eda.pcb_PrimitiveLine.create(m[1], m[2], m[3]*MIL, m[4]*MIL, m[5]*MIL, m[6]*MIL, m[7]*MIL);
|
||
nd++;
|
||
}
|
||
for (const a of ADD) { await eda.pcb_PrimitiveLine.create(a[0], a[1], a[2]*MIL, a[3]*MIL, a[4]*MIL, a[5]*MIL, a[6]*MIL); na++; }
|
||
await eda.pcb_Document.save();
|
||
return {mod:nd, add:na};
|
||
"""
|
||
|
||
|
||
def main():
|
||
u"""⚠ `--push` 会**自动跑多轮**:切完一轮之后,新加的两条 45° 斜边之间
|
||
可能又是一个直角(NE 接 NW 就是 90°),实测要 3 轮才收敛到 0。
|
||
"""
|
||
for rnd in range(1, 8):
|
||
if not one_pass(rnd):
|
||
break
|
||
|
||
|
||
def one_pass(rnd=1):
|
||
segs, vias = fetch()
|
||
pads = json.load(io.open(PADS_JSON, encoding='utf-8'))
|
||
cuts, stats = corners(segs, vias, pads)
|
||
print(u'[第 %d 轮] 板上铜 %d 段 / 过孔 %d 个' % (rnd, len(segs), len(vias)))
|
||
print(u'可切的直角拐点:%d 个' % len(cuts))
|
||
print(u'跳过:坐在孔/盘上 %d,三岔口 %d,两段线宽不同 %d,腿太短 %d(非直角拐点 %d 个不算错)'
|
||
% (stats['via_pad'], stats['branch'], stats['width'], stats['short'], stats['not90']))
|
||
if stats.get('clear'):
|
||
print(u'跳过:新斜边凹侧净距不够 %d(< %.2f mm)' % (stats['clear'], CLR_CHECK))
|
||
if '--push' not in sys.argv:
|
||
print(u'(没加 --push,没有下发)')
|
||
return False
|
||
if not cuts:
|
||
return False
|
||
newend, add = apply(segs, cuts)
|
||
MOD = []
|
||
for i, ends in sorted(newend.items()):
|
||
s = segs[i]
|
||
a = list(s['a'])
|
||
b = list(s['b'])
|
||
if key(s['a']) in ends:
|
||
a = list(ends[key(s['a'])])
|
||
if key(s['b']) in ends:
|
||
b = list(ends[key(s['b'])])
|
||
MOD.append([s['id'], s['net'], s['lay'], a[0], a[1], b[0], b[1], s['w']])
|
||
ADD = [[n, l, q1[0], q1[1], q2[0], q2[1], w] for (n, l, q1, q2, w) in add]
|
||
done = {'mod': 0, 'add': 0}
|
||
STEP = 40
|
||
for k in range(0, max(len(MOD), len(ADD)), STEP):
|
||
js = PUSH % (MIL, json.dumps(MOD[k:k + STEP], ensure_ascii=False),
|
||
json.dumps(ADD[k:k + STEP], ensure_ascii=False))
|
||
r = run(js, timeout=300)
|
||
if not r.get('success'):
|
||
sys.exit(json.dumps(r, ensure_ascii=False)[:400])
|
||
done['mod'] += r['result']['mod']
|
||
done['add'] += r['result']['add']
|
||
print(u' 改 %d / 加 %d' % (done['mod'], done['add']))
|
||
print(u'第 %d 轮完成:改了 %d 段、加了 %d 段斜边' % (rnd, done['mod'], done['add']))
|
||
return True
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|