文件
phonerail/.claude/r2/miter.py
T

315 行
14 KiB
Python
原始文件 Blame 文件历史

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