# -*- coding: utf-8 -*- u"""R2 刀片 PCB 的铺铜(M4c-3)。 /c/Python314/python .claude/r2/pcb_pour.py # 只算 + 自检 /c/Python314/python .claude/r2/pcb_pour.py --push # 清旧铜面 + 重建 + 重填 /c/Python314/python .claude/r2/pcb_pour.py --rebuild # 只重填(铜面没改,只是扇出/布线改了) ## 叠层(方案 §7.1) | 设计 | EasyEDA | id | 内容 | |---|---|---|---| | L1 | Top Layer | 1 | 功率 + 信号;**D± 走这层、以 L2 为参考** | | L2 | Inner1 | 15 | **完整地平面,不许割断** | | L3 | Inner2 | 16 | `VO_P` 岛 + `+12V` 岛,其余 GND 底填 | | L4 | Bottom | 2 | 控制信号 + GND 底填 | ## 三个只有这块板才有的约束 1. **金手指区不许铺外层铜**:`J1` 的 64 个指是 L1/L2 上的焊盘,间距只有 0.3 mm。 外层铜面一旦下探到 y < 指顶(5.6)+间距,就会把相邻的指连起来 —— 而且**是不是短路 要看它连到哪两根**,DRC 只报间距不报「这两根本来就该分开」。 ⇒ 外层铜面一律 `y >= TOP_POUR_Y`(11.5,比缺口顶 7.45 还高一截)。 内层(L2/L3)没有指,可以一直铺到板边内缩,只要避开缺口。 2. **缺口要挖掉**:防呆缺口是真的铣穿的,四层都要让开(含 0.5 mm 内缩)。 3. **优先级是「数字小的先铺、先占地盘」**(R1 `pcb_pour.py` 头上那条已经查清的翻案)。 ⇒ `VO_P` / `+12V` 两个岛给 1 / 2,GND 底填给 9。 ## 三个 API 坑(全部照抄 R1 `pcb_pour.py` 的结论,别再踩一遍) · 多边形**必须显式闭合**(末尾把首点再写一遍),否则 `create()` 一块都不建; · `create()` **抛异常不等于失败** —— 判据只能是建完 `getAll()` 按名字(会被转成大写)核对; · `rebuildCopperRegion()` 是坏的,**只能走 GUI**:工具 → 铺铜管理器... → 重建所有 → 确认。 判据是 `pcb_PrimitivePoured` 的数量,不是 `pcb_PrimitivePour`。 """ import json import math import os import sys import time sys.stdout.reconfigure(encoding='utf-8') HERE = os.path.dirname(os.path.abspath(__file__)) # ⚠ 顺序不能反:`.claude` 下有同名的 R1 脚本(pcb_pour / pcb_rules / pcb_place), # 先插 .claude 再插 HERE 的话,`import pcb_pour` 会拿到 R1 那一份, # 而且**不报错**,只在调到 R2 专有的函数时才炸。 sys.path.insert(0, os.path.dirname(HERE)) sys.path.insert(0, HERE) from eda import run # noqa: E402 import pcb_blade as B # noqa: E402 import fp_edge # noqa: E402 PCB = B.PCB MIL = B.MIL L_TOP, L_GND, L_PWR, L_BOT = 1, 15, 16, 2 INSET = 0.5 # 铜面离板边 TOP_POUR_Y = 11.5 # 外层铜面的下沿(金手指区一点外层铜都不许有) NOTCH_KEEP = 0.5 # 缺口周围再让 0.5 # L3 两个岛(x0, y0, x1, y1)—— y 段直接对着 pcb_place 的功能带切,见 --bands # ⛔ 2026-09-11 刀片 90×68:三个岛跟着新板框 / 手机原点 / 功率区西移 8 改(原值见注释)。 VO_ISLAND = (17.5, 11.5, B.BW - 0.5, 28.8) # eFuse/检流带 + 服务带:VO_P 4 A 的主通道(原 20~97.5); # 西沿 17.5 = 泄放支路 DIS 区挪到电池槽正下方(x 17.9 起)之后,R19 的 VO_P 脚要就地扎孔进岛 # 2026-09-09 M6:J5 / 阻尼组挪到电池槽北边之后岛要跟过去 —— 往北伸一个叶(L 形单环, # 不是第二块铜)。东沿让开尾插槽(槽西沿 − 0.58);北沿跟手机走(原 46 − 6.66)。 VO_LOBE = (17.5, 28.8, round(B.flex_slots()['TAIL'][0] - 0.58, 3), round(46.0 + B.PHONE_ORG[1] - 29.0, 3)) V12_ISLAND = (B.BW - 14.0, 55.0, B.BW - 0.5, B.BH - 0.5) # 12V 入口(F1 的 +12V 脚)及以上(原 84~97.5 × 55~74) def mil(v): return round(v * MIL, 4) def rrect(x0, y0, x1, y1, r=0.0, seg=8): u"""圆角矩形点列(逆时针,闭合)。r=0 就是普通矩形。""" if r <= 0: pts = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] else: pts = [] for cx, cy, a0 in ((x1 - r, y0 + r, 270), (x1 - r, y1 - r, 0), (x0 + r, y1 - r, 90), (x0 + r, y0 + r, 180)): for k in range(seg + 1): a = math.radians(a0 + 90.0 * k / seg) pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) out = [pts[0]] for p in pts[1:]: if abs(p[0] - out[-1][0]) > 1e-9 or abs(p[1] - out[-1][1]) > 1e-9: out.append(p) out.append(out[0]) # ⚠ 必须显式闭合 return out def notch_cut(): u"""缺口在铜面里要挖掉的矩形(含让位)。""" ml, mr = B.notch_break() return (ml - NOTCH_KEEP, -1.0, mr + NOTCH_KEEP, fp_edge.NOTCH_D + NOTCH_KEEP) def inner_poly(): u"""内层铜面:板框内缩 INSET,底边在缺口处绕过去(一条闭合折线,不用挖孔)。""" d = INSET r = B.CORNER_R - d x0, y0, x1, y1 = d, d, B.BW - d, B.BH - d nx0, _ny0, nx1, ny1 = notch_cut() pts = [(nx1, y0)] # 缺口右侧起步,沿底边往右 pts.append((x1 - r, y0)) for cx, cy, a0 in ((x1 - r, y0 + r, 270), (x1 - r, y1 - r, 0), (x0 + r, y1 - r, 90), (x0 + r, y0 + r, 180)): for k in range(9): a = math.radians(a0 + 90.0 * k / 8) pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) pts.append((nx0, y0)) # 底边左段走到缺口左沿 pts.append((nx0, ny1)) # 绕缺口 pts.append((nx1, ny1)) out = [pts[0]] for p in pts[1:]: if abs(p[0] - out[-1][0]) > 1e-9 or abs(p[1] - out[-1][1]) > 1e-9: out.append(p) out.append(out[0]) return out def outer_poly(): u"""外层铜面:只在 y >= TOP_POUR_Y 的部分,金手指区一点铜都没有。""" d = INSET r = B.CORNER_R - d x0, x1, y1 = d, B.BW - d, B.BH - d pts = [(x0, TOP_POUR_Y), (x1, TOP_POUR_Y)] for cx, cy, a0 in ((x1 - r, y1 - r, 0), (x0 + r, y1 - r, 90)): for k in range(9): a = math.radians(a0 + 90.0 * k / 8) pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) out = [pts[0]] for p in pts[1:]: if abs(p[0] - out[-1][0]) > 1e-9 or abs(p[1] - out[-1][1]) > 1e-9: out.append(p) out.append(out[0]) return out def src(pts): u"""点列 -> TPCB_PolygonSourceArray(mil): x1 y1 'L' x2 y2 'L' ...""" a = [mil(pts[0][0]), mil(pts[0][1])] for p in pts[1:]: a += ['L', mil(p[0]), mil(p[1])] return a def gnd_tiles(y_lo): u"""地铜的**若干块单环矩形**,绕开铣穿的排线槽。 ⛔ **不能用带洞的复杂多边形**:`pcb_MathPolygon.createComplexPolygon()` 传多个环时 会把客户端挂死 —— 请求 30 s 超时、铺铜边框停在半路、进度恒为 0/0, 和「GUI 按钮失灵」完全无法区分(2026-09-08 在这上面绕了很久)。 ⇒ 一律单环。要挖洞就拆成几块矩形拼,铜面之间同网互相接壤会自然连成一片。 做法:按所有槽的 x 边把板切成竖条,每条里按 y 找槽之间的空隙。 (2026-09-09 改:原来的「上下横带」写法假设各窗 y 区间不重叠,两条并排的排线槽 会把槽本身当成一条带铺进去。) `y_lo` = 这一层铜面的下沿(外层 11.5 / 内层 0.5)。 """ d = INSET x0, x1 = d, B.BW - d y1 = B.BH - d cuts = [(a - INSET, b - INSET, c + INSET, e + INSET) for (a, b, c, e) in B.mill_cuts()] xs = sorted(set([x0, x1] + [v for c in cuts for v in (c[0], c[2])])) tiles = [] for xa, xb in zip(xs, xs[1:]): if xb - xa < 0.6: continue ys = [y_lo] for c in sorted(cuts, key=lambda c: c[1]): if c[0] < xb - 1e-9 and xa < c[2] - 1e-9: ys += [c[1], c[3]] ys.append(y1) for k in range(0, len(ys) - 1, 2): ya, yb = ys[k], ys[k + 1] if yb - ya > 0.6: tiles.append((xa, ya, xb, yb)) # ⛔ 内层的下沿会盖住金手指的**防呆缺口**(铣穿的)。把缺口那一段单独让开: # y 抬到缺口顶之上,再补两块把缺口左右两侧的底部填回来。 nx0, _a, nx1, ny1 = notch_cut() if y_lo < ny1: cut = ny1 out = [] for (a, b, c, e) in tiles: if e <= cut: continue if b < cut and (a < nx1 and c > nx0): out.append((a, cut, c, e)) if a < nx0: out.append((a, b, nx0, cut)) if c > nx1: out.append((nx1, b, c, cut)) else: out.append((a, b, c, e)) tiles = [t for t in out if t[2] - t[0] > 0.6 and t[3] - t[1] > 0.6] return tiles def vo_poly(): u"""VO_P 岛:主体矩形 + 北叶,拼成一个 L 形**单环**(逆时针,显式闭合)。""" a, b = VO_ISLAND, VO_LOBE assert abs(a[3] - b[1]) < 1e-9 and a[0] <= b[0] and b[2] <= a[2], u'北叶必须骑在主体上沿' pts = [(a[0], a[1]), (a[2], a[1]), (a[2], a[3]), (b[2], a[3]), (b[2], b[3]), (b[0], b[3]), (b[0], a[3]), (a[0], a[3])] out = [pts[0]] for q in pts[1:]: if abs(q[0] - out[-1][0]) > 1e-9 or abs(q[1] - out[-1][1]) > 1e-9: out.append(q) out.append(out[0]) return out def plan(): u"""[(name, net, layer, [环...], priority)],priority 小的先铺、先占地盘。""" outs = [] outs.append(('VO-L3', 'VO_P', L_PWR, [src(vo_poly())], 1)) outs.append(('V12-L3', '+12V', L_PWR, [src(rrect(*V12_ISLAND))], 2)) for tag, layer, y_lo in (('L2', L_GND, INSET), ('TOP', L_TOP, TOP_POUR_Y), ('BOT', L_BOT, TOP_POUR_Y)): for k, (a, b, c, d2) in enumerate(gnd_tiles(y_lo)): outs.append(('GND-%s-%d' % (tag, k), 'GND', layer, [src(rrect(a, b, c, d2))], 9)) return outs def selfcheck(ps): bad = [] for name, _net, _lay, rings, _p in ps: for k, s in enumerate(rings): if s[0] != s[-2] or s[1] != s[-1]: bad.append(u'%s 第 %d 个环没闭合' % (name, k)) # 外层铜面不许下探进金手指区 top = fp_edge.EDGE_CLR + fp_edge.LONG_L if TOP_POUR_Y <= top: bad.append(u'外层铜下沿 %.2f 压到指顶 %.2f' % (TOP_POUR_Y, top)) if TOP_POUR_Y <= fp_edge.NOTCH_D: bad.append(u'外层铜下沿 %.2f 低于缺口顶 %.2f' % (TOP_POUR_Y, fp_edge.NOTCH_D)) # 内层铜面必须绕开缺口 nx0, _a, nx1, ny1 = notch_cut() if nx1 - nx0 < 2.9: bad.append(u'缺口挖得比口部还窄') # 两个岛不许互相重叠(同层,优先级只有 1/2 之差,重叠就说不清谁裁谁) b = V12_ISLAND for a in (VO_ISLAND, VO_LOBE): if a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]: bad.append(u'VO 岛和 +12V 岛在 L3 上重叠') # 北叶不许伸进尾插槽/车道 for (x0, y0, x1, y1) in B.mill_cuts(): if x0 > VO_LOBE[0] + 1.0 and VO_LOBE[0] < x1 and x0 < VO_LOBE[2] and y0 < VO_LOBE[3] and VO_LOBE[1] < y1: bad.append(u'VO 北叶压到槽 (%.1f~%.1f)' % (x0, x1)) return bad OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2000));" % PCB) JS_DEL = OPEN + u""" let n = 0; for (const old of await eda.pcb_PrimitivePour.getAll()) { await eda.pcb_PrimitivePour.delete(old.primitiveId); n++; } return {deleted: n}; """ JS_MAKE = OPEN + u""" const PLAN = %s; for (const p of PLAN) { const poly = await eda.pcb_MathPolygon.createComplexPolygon(p.src); if (!poly) continue; try { await eda.pcb_PrimitivePour.create(p.net, p.layer, poly, 'solid', false, p.name, p.prio, 10); } catch (e) {} } return {made: (await eda.pcb_PrimitivePour.getAll()) .map(x => String(x.pourName || '').toUpperCase())}; """ JS_PRIO = OPEN + u""" const P = %s; let k = 0; for (const o of await eda.pcb_PrimitivePour.getAll()) { const want = P[String(o.pourName || '').toUpperCase()]; if (want === undefined) continue; try { await eda.pcb_PrimitivePour.modify(o.primitiveId, {pourPriority: want}); k++; } catch (e) {} } return {set: k, now: (await eda.pcb_PrimitivePour.getAll()) .map(o => [String(o.pourName||''), o.pourPriority])}; """ JS_REBUILD = OPEN + u""" const sleep = ms => new Promise(r => setTimeout(r, ms)); const propsOf = el => { for (const k of Object.keys(el)) if (k.startsWith('__reactProps$')) return el[k]; return null; }; const fire = (el) => { let t = el; for (let i=0;i<8&&t;i++){const q=propsOf(t); if(q&&typeof q.onClick==='function'){q.onClick({target:t,currentTarget:t,stopPropagation(){},preventDefault(){},nativeEvent:{},button:0,detail:1});return true;} t=t.parentElement;} return false; }; const leaf = (txt) => { for (const el of document.querySelectorAll('*')) if (el.children.length===0 && (el.textContent||'').trim()===txt) return el; return null; }; const btn = (txt) => { for (const b of document.querySelectorAll('button')) if ((b.innerText||'').trim()===txt && b.offsetParent!==null) return b; return null; }; if (!fire(leaf('工具'))) return {err: 'no 工具'}; await sleep(900); if (!fire(leaf('铺铜管理器...'))) return {err: 'no 铺铜管理器'}; await sleep(2500); const b = btn('重建所有'); if (!b) return {err: 'no 重建所有'}; return {fired: fire(b)}; """ JS_POLL = u"return {poured: (await eda.pcb_PrimitivePoured.getAll()).length};" JS_DONE = u""" const propsOf = el => { for (const k of Object.keys(el)) if (k.startsWith('__reactProps$')) return el[k]; return null; }; const fire = (el) => { let t = el; for (let i=0;i<8&&t;i++){const q=propsOf(t); if(q&&typeof q.onClick==='function'){q.onClick({target:t,currentTarget:t,stopPropagation(){},preventDefault(){},nativeEvent:{},button:0,detail:1});return true;} t=t.parentElement;} return false; }; let ok = null; for (const b of document.querySelectorAll('button')) if ((b.innerText||'').trim()==='确认' && b.offsetParent!==null) { ok = b; break; } if (ok) fire(ok); await new Promise(r => setTimeout(r, 1500)); await eda.pcb_Document.save(); return {poured: (await eda.pcb_PrimitivePoured.getAll()).length, pour: (await eda.pcb_PrimitivePour.getAll()).length}; """ def call(js, timeout=120): r = run(js, timeout=timeout) if not r.get('success'): print(u'失败: ' + json.dumps(r, ensure_ascii=False)[:600]) sys.exit(1) return r['result'] def main(): ps = plan() print(u'计划建 %d 块铜面:' % len(ps)) for name, net, layer, rings, prio in ps: npt = sum((len(r) + 1) // 3 for r in rings) print(u' %-8s net=%-6s layer=%-3d %d 环 / %3d 点 prio=%d' % (name, net, layer, len(rings), npt, prio)) bad = selfcheck(ps) if bad: print(u'[X] 自检不过:') for b in bad: print(u' ' + b) sys.exit(1) print(u'[OK] 自检通过:多边形全闭合 / 外层铜不进金手指区 / 内层绕开缺口 / 两岛不重叠') if '--push' not in sys.argv and '--rebuild' not in sys.argv: print(u'(未加 --push,没有下发)') return if '--rebuild' not in sys.argv: print(u'清掉旧铜面: %d 块' % call(JS_DEL)['deleted']) for k in range(0, len(ps), 3): # 3 块一批,稳在超时以内 batch = [{'name': n, 'net': net, 'layer': l, 'src': s, 'prio': pr} for n, net, l, s, pr in ps[k:k + 3]] made = call(JS_MAKE % json.dumps(batch), timeout=180)['made'] print(u' 已建 %d 块' % len(made)) want = {n.upper() for n, _, _, _, _ in ps} got = set(call("return (await eda.pcb_PrimitivePour.getAll())" ".map(x=>String(x.pourName||'').toUpperCase());")) if not want <= got: print(u'[X] 没建上: %s' % (want - got)) sys.exit(1) # ⚠ create() 的 pourPriority 参数不生效(R1 skill §9j),建完再逐块设 r = call(JS_PRIO % json.dumps({n.upper(): pr for n, _, _, _, pr in ps}), timeout=180) print(u' 优先级已设 %d 块: %s' % (r['set'], r['now'])) before = len(call("return (await eda.pcb_PrimitivePour.getAll()).map(o=>o.pourName);")) r = call(JS_REBUILD, timeout=120) if r.get('err'): print(u'[X] 打不开铺铜管理器: %s' % r['err']) sys.exit(1) # ⚠ 退出条件是「**数量稳定**」,不是「全部填上」。 # 有的铜面本来就填不出来(`V12-L3` 在 `+12V` 还没有过孔扎进 L3 之前是孤岛, # 会被「移除孤岛」删掉),写成 `n >= len(ps)` 的话每次都要空转满 200 秒, # 而且看上去像是卡死。 prev, same = -1, 0 for _i in range(40): time.sleep(5) n = call(JS_POLL, timeout=60)['poured'] print(u' 填充 %d / %d' % (n, len(ps))) if n >= len(ps): break same = same + 1 if n == prev else 0 prev = n if same >= 2 and n > 0: print(u' (%d 块已稳定,另 %d 块没有可接的铜,属正常)' % (n, len(ps) - n)) break fin = call(JS_DONE, timeout=120) print(u' 收尾: %s' % fin) # ⛔ 铺铜**边框**会莫名其妙变少(实测 6→3、5→2)。哪一步删的还没查清, # 但「静默变少」是最坏的形态:铜面没了,DRC 只报连接性,看上去像布线没做完。 # 这里把它变成硬报错。 if fin['pour'] < before: print(u' [X] 铺铜边框从 %d 变成 %d —— 有东西把它们删了,**不要在这个状态下导出**' % (before, fin['pour'])) sys.exit(1) if __name__ == '__main__': main()