# -*- 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()