# -*- coding: utf-8 -*- u"""R2 底板 PCB 铺铜 + 缝合过孔 + 断面校核(M5,**4 层**,D15)。 /c/Python314/python .claude/r2/pour_base.py # 只算 + 自检 + 断面校核 /c/Python314/python .claude/r2/pour_base.py --stitch # 打 GND 缝合过孔 /c/Python314/python .claude/r2/pour_base.py --push # 清旧铜面 + 建 + 重填 /c/Python314/python .claude/r2/pour_base.py --rebuild # 只重填 /c/Python314/python .claude/r2/pour_base.py --clear # 清掉全部铜面 2 层版整份在 `archive_base2L/pour_base.py`。 ⛔ **顺序**:摆位 → 布线(服务器)→ `route_base --push` → **缝合过孔** → **铺铜** → DRC + `board_check`。 重跑布线要把缝合过孔一起重跑。 ## 叠层(D15,2026-09-10 L2 由 GND 改 +3V3) | 设计 | EasyEDA | id | 铺什么 | |---|---|---|---| | L1 | Top | 1 | 12V 大铜(`pcb_base.l1_12v_rects`,硬)+ GND 底填 | | L2 | Inner1 | 15 | **整层 GND**(2026-09-10 深夜由 +3V3 改:双地,L1 信号参考 L2 地;+3V3 改回布线)| | L3 | Inner2 | 16 | **整层 GND**(L4 上全部器件与 D± 的参考)| | L4 | Bottom | 2 | 入口 VIN_RAW / VIN_FUSED / SW12 / VIN_SW(硬,D16 手画矩形 PD_POUR)+ Rs6 西侧 12V 铜块(硬)+ GND 底填 | | L1 | Top | 1 | (D16)U37 正上方 VIN_FUSED 小岛(pcb_base.PD_ISLAND),从 12V 大铜里扣出 | 「硬」= 几何算出来、要过断面校核;GND 底填优先级最低,自然绕开别人的铜。 ## 入口(D16,2026-09-11 起:J1 USB-C → F1 → U37 LM61495 → L4 → C2/C3/D1 → Rs6) 原来的 DC-005 → F1 → Q1 那条链是「每段取本网焊盘外框外扩、段间按空隙中点切开」自动撑出来的(chain_rect / clip_chain, 函数还留着)。D16 的新件东一颗西一颗(U36 / R12 / C12 在东、F1 在中、C5 在西),外框一撑就把 U37 整颗包进 VIN_FUSED ⇒ 改成 **按电流路径手画的矩形** `PD_POUR`:同层不同网互不重叠、串联元件(F1 / U37 / L4)两侧不搭,别的网的焊盘 / 线 / 孔铺铜自己绕。 VIN_SW 北段(C2 / D1 / C3 → Rs6.1)仍按焊盘外框撑(U13 / C4 / R10 不撑:Kelvin / 去耦 / FB 采样)。 ## 断面校核 在铜面上画断面线,数「这条线上属于本网、没被别人的焊盘 / 过孔 / 同层导线占掉」的**最宽一段**。 入口链与 L1 12V 主块的电流都沿 y ⇒ 横切、量 x 方向净宽。串联元件本身占的 y 段跳过(量到的是元件宽度不是铜)。 """ import io import json import math import os import sys import time 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 import pcb_base as B # noqa: E402 import place_base as PL # noqa: E402 PCB = B.PCB MIL = B.MIL L1, L2, L3, L4 = 1, 15, 16, 2 # EasyEDA 层 id:Top / Inner1 / Inner2 / Bottom LNAME = {L1: 'L1', L2: 'L2', L3: 'L3', L4: 'L4'} # ⚠ 打印用设计层名 —— 直接打 id 的话底层是「2」,看着像 L2 INSET = 0.5 # 铜面离板边 CLR = 0.25 # 断面校核里别人的铜要让开多少 VIA_D, VIA_HOLE = 0.6, 0.3 STITCH_PITCH = 4.0 STITCH_CLR = 0.5 # 缝合过孔到任何焊盘 / 导线 / 过孔的净距 # 天线净空周边加密(2026-09-11 第 4 阶段,乐鑫 ESP32-S3 布局指南:天线附近地孔 2~3 mm): # 离净空框 0.9~2.2 mm 的一圈里,按 ANT_PITCH 贪心挑孔。 ANT_PITCH = 2.0 ANT_BAND = (0.9, 3.0) # 上限 2.2 时南北两侧全被缺口的铺铜退让吃掉,只剩西侧 6 颗 # 所有缝合孔都落在这个子网格上(4 mm 主网格的约数);删旧缝合孔 / 布线认缝合孔都按它判。 STITCH_SUB = 1.0 PADS = os.path.join(HERE, '_base_pads.json') ROUTES = os.path.join(HERE, '_base_routes.json') ROUTE_SCHEMA = 8 CHAIN = [('VIN_RAW', 1), ('VIN_FUSED', 2), ('VIN_SW', 3)] # (网, 优先级),自南向北 CHAIN_GROW = 1.2 CHAIN_SKIP = {'VIN_SW': ('U13', 'C4', 'R10')} # 不撑大铜面的焊盘(见文件头;R10 = D16 的 FB 采样) # ── D16 南带功率铜(手画矩形,见文件头;摆位 = place_base.PD_HAND)────────────────────────── PD_VIN_SW_N0 = 30.4 # VIN_SW 北段(按焊盘外框撑的那块)的下沿 = 输出电容一行的上沿 PD_POUR = [ # (名字, 网, 层, 矩形, 优先级) ('PWR-VIN_RAW', 'VIN_RAW', L4, (9.2, 8.2, 15.8, 11.9), 1), # J1 两组 VBUS → F1.1(CC 的孔在里面,铺铜自己绕) ('PWR-VIN_FUSED', 'VIN_FUSED', L4, (11.2, 12.2, 22.1, 14.2), 1), # F1.2 → 东去的母线 ('PWR-VIN_FUSED-1', 'VIN_FUSED', L4, (19.8, 14.2, 20.85, 18.75), 1), # 母线 → C20 / U37.14(南侧 VIN) ('PWR-VIN_FUSED-2', 'VIN_FUSED', L4, (22.1, 12.95, 27.4, 16.0), 1), # 东段:C22 / C12 / U36 ('PWR-VIN_FUSED-L1', 'VIN_FUSED', L1, B.PD_ISLAND, 1), # L1 小岛:连通 U37 两侧 VIN(用户选) ('PWR-SW12', 'SW12', L4, (16.2, 20.2, 19.6, 21.2), 1), # U37.16 ↔ L4.1(夹在 U37 两只 PGND 角脚之间) ('PWR-VIN_SW', 'VIN_SW', L4, (0.5, 17.6, 9.5, PD_VIN_SW_N0), 3), # L4.2 西端 + C5 → 北 ('PWR-VIN_SW-1', 'VIN_SW', L4, (0.5, 27.2, 21.6, PD_VIN_SW_N0), 3), # 输出电容一行(C14~C17) ] GATE_PARTS = ('DZ1', 'C1', 'R1') NOTCH_CLR = 0.3 # (铜面名前缀, 标签, 网, 电流方向, 要求净宽, 是否强制, 层, 是否跳过串联元件) CUTS = [ # D16:20V 侧 ≤5 A,瓶颈是 C 座两组 VBUS 焊盘(各 0.6 mm 宽)本身 ⇒ 只报不强制 ('PWR-VIN_RAW', u'J1→F1', 'VIN_RAW', 'y', 3.0, False, L4, True), ('PWR-VIN_SW', u'L4→储能→Rs6', 'VIN_SW', 'y', 6.5, True, L4, True), # L1 主块上挂满了背面件的 GND / +3V3 / +12V 过孔(反焊盘),串联元件窗口对它没意义 ('PWR-12V-L1', u'L1 12V 主块', '+12V', 'y', 8.0, True, L1, False), ] def mil(v): return round(v * MIL, 4) def call(js, timeout=180): 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'] OPEN = ("await eda.dmt_EditorControl.openDocument('%s');" "await new Promise(r=>setTimeout(r,2500));" % PCB) _PADS = [None] def pads(): u"""板上焊盘(丢掉多边形拆条的零尺寸残留,同 route_base.load_pads)。""" if _PADS[0] is None: _PADS[0] = [p for p in json.load(io.open(PADS, encoding='utf-8')) if p[4] >= 0.05 and p[5] >= 0.05] return _PADS[0] _ROUTES = [None] def routes(): if _ROUTES[0] is None: c = json.load(io.open(ROUTES, encoding='utf-8')) if os.path.exists(ROUTES) else {} if c.get('schema') != ROUTE_SCHEMA: _ROUTES[0] = ([], [], None) else: _ROUTES[0] = (c['segs'], c['vias'], c.get('v12_patch')) return _ROUTES[0] def _layers_of(p): return (L1, L4) if p[6] == 12 else ((L1,) if p[6] == 1 else (L4,)) # ── 铜面几何 ───────────────────────────────────────────────────────────── def keepouts(): u"""所有层都不许铺铜:U20 天线净空 + 天线缺口 + 四个 M3 孔。""" d = json.load(io.open(os.path.join(HERE, '_base_place.json'), encoding='utf-8')) pos = {k: (v[0], v[1], v[2]) for k, v in d.items()} n = B.notch_rect() out = [PL.antenna_keepout(PL.sizes(), pos), (n[0] - INSET, n[1] - INSET, n[2] + INSET, n[3] + INSET)] r = B.HOLE_D / 2.0 + 0.6 for (hx, hy) in B.MOUNT_HOLES.values(): out.append((hx - r, hy - r, hx + r, hy + r)) return out def tiles(x0, y0, x1, y1, cuts): u"""把矩形按 `cuts` 切成若干块**单环**矩形(多环复杂多边形会把客户端挂死,刀片 §24)。""" xs = sorted(set([x0, x1] + [v for c in cuts for v in (c[0], c[2]) if x0 < v < x1])) out = [] for xa, xb in zip(xs, xs[1:]): if xb - xa < 0.6: continue iv = [] for c in cuts: # ⛔ y 方向也要真的相交:禁区整个在矩形上方 / 下方时,夹紧会得到一个倒过来的区间, # 照样切出一块瓦片 —— 实测 VIN_RAW 被栅极缺口「切」出一块伸到 y 22.65 的瓦片(它的上沿本来是 17.62) if c[0] < xb - 1e-9 and xa < c[2] - 1e-9 and c[1] < y1 - 1e-9 and y0 < c[3] - 1e-9: iv.append([max(y0, c[1]), min(y1, c[3])]) iv.sort() merged = [] for a, b in iv: # ⛔ 先合并再配对(嵌套禁区会让奇偶配对错位) if merged and a <= merged[-1][1] + 1e-9: merged[-1][1] = max(merged[-1][1], b) else: merged.append([a, b]) cur = y0 for a, b in merged + [[y1, y1]]: if a - cur > 0.6: out.append((round(xa, 3), round(cur, 3), round(xb, 3), round(a, 3))) cur = max(cur, b) return out def chain_pads(net): z = B.ZONES['A1'] skip = CHAIN_SKIP.get(net, ()) return [p for p in pads() if p[7] == net and p[0] not in GATE_PARTS and p[0] not in skip and z[0] <= p[2] <= z[2] and z[1] <= p[3] <= z[3]] def chain_rect(net): ps = chain_pads(net) if not ps: return None g = CHAIN_GROW z = B.ZONES['A1'] return (max(z[0], min(p[2] - p[4] / 2 for p in ps) - g), max(INSET, min(p[3] - p[5] / 2 for p in ps) - g), min(z[2], max(p[2] + p[4] / 2 for p in ps) + g), min(z[3], max(p[3] + p[5] / 2 for p in ps) + g)) def clip_chain(chain): u"""相邻两段在 y 方向切开:刀口 = 「南段最北的焊盘上沿」与「北段最南的焊盘下沿」的中点(在 x 重叠带里取)。""" out = [r for _n, r in chain] for i in range(len(out) - 1): a, b = out[i], out[i + 1] if a[3] <= b[1]: continue x0, x1 = max(a[0], b[0]), min(a[2], b[2]) pa = [p for p in chain_pads(chain[i][0]) if p[2] + p[4] / 2 > x0 and p[2] - p[4] / 2 < x1] pb = [p for p in chain_pads(chain[i + 1][0]) if p[2] + p[4] / 2 > x0 and p[2] - p[4] / 2 < x1] na = max([p[3] + p[5] / 2 for p in pa] or [None]) sb = min([p[3] - p[5] / 2 for p in pb] or [None]) m = (na + sb) / 2.0 if (na is not None and sb is not None and na < sb) else (a[3] + b[1]) / 2.0 out[i] = (a[0], a[1], a[2], round(min(a[3], m - CLR / 2), 3)) out[i + 1] = (b[0], round(max(b[1], m + CLR / 2), 3), b[2], b[3]) return out def gate_notch(): u"""Q1 栅极网络整团挖掉(GATE 焊盘 + 三颗栅极件外框,外扩 NOTCH_CLR)。""" out = [] for p in pads(): if p[7] == 'GATE' or p[0] in GATE_PARTS: out.append((round(p[2] - p[4] / 2 - NOTCH_CLR, 3), round(p[3] - p[5] / 2 - NOTCH_CLR, 3), round(p[2] + p[4] / 2 + NOTCH_CLR, 3), round(p[3] + p[5] / 2 + NOTCH_CLR, 3))) return out CHAIN_DEBUG = [] # [(网, 原始外框, 裁剪后, 挖缺口后的瓦片)],打印核对用 def plan(): u"""[(name, net, layer, rect, priority)],priority 小的先占地盘。""" outs = [] CHAIN_DEBUG[:] = [] # D16:入口铜块手画(PD_POUR)+ VIN_SW 北段按焊盘外框撑(下沿贴输出电容一行) for (n, net, lay, r, prio) in PD_POUR: outs.append((n, net, lay, tuple(r), prio)) z = B.ZONES['A1'] vn = [p for p in chain_pads('VIN_SW') if p[3] >= PD_VIN_SW_N0] if vn: g = CHAIN_GROW r = (max(z[0], min(p[2] - p[4] / 2 for p in vn) - g), PD_VIN_SW_N0, min(z[2], max(p[2] + p[4] / 2 for p in vn) + g), min(z[3], max(p[3] + p[5] / 2 for p in vn) + g)) outs.append(('PWR-VIN_SW-2', 'VIN_SW', L4, tuple(round(v, 3) for v in r), 3)) CHAIN_DEBUG.append(('VIN_SW', r, r, [r])) pd4 = [r for (_n, _net, lay, r, _p) in outs if lay == L4] island = B.PD_ISLAND ko = keepouts() v12 = routes()[2] if v12: outs.append(('PWR-12V-PATCH', '+12V', L4, tuple(v12), 2)) # L1 12V:主块要绕开两个 M3 孔((4,4) / (4,94) 就在主块里)和 D16 的 VIN_FUSED 小岛,同样切成单环瓦片 isl_cut = (island[0] - 0.5, island[1] - 0.5, island[2] + 0.5, island[3] + 0.5) k = 0 for r in B.l1_12v_rects(): for t in tiles(r[0], r[1], r[2], r[3], ko + [isl_cut]): outs.append(('PWR-12V-L1%s' % ('-%d' % k if k else ''), '+12V', L1, t, 2)) k += 1 # ⛔ GND 底填**不许和同层的功率铜面重叠**(2026-09-10 实测):读回的优先级**不按创建顺序** —— # 整板那块 GND-L1-4 / GND-L4-4 拿到 18,L1 12V 主块与 J37/J38 指头 23~33、VIN_SW 主块 23, # 数字小的先铺,GND 把地盘占完,功率铜面一块铜都没填出来(83 块只填 61,断面校核全白做)。 # 挖掉之后两边不重叠,谁先铺都一样。功率铜块四周各多挖 0.6。 pwr_cut = {L1: list(B.l1_12v_rects()) + [isl_cut], L4: [(r[0] - 0.6, r[1] - 0.6, r[2] + 0.6, r[3] + 0.6) for r in pd4] + ([tuple(v12)] if v12 else [])} d = INSET # 2026-09-10 深夜:L2 由整层 +3V3 改**整层 GND**(双地,用户拍板;+3V3 改回布线网) for tag, net, lay, prio in (('GND-L2', 'GND', L2, 9), ('GND-L3', 'GND', L3, 9), ('GND-L1', 'GND', L1, 9), ('GND-L4', 'GND', L4, 9)): for k, t in enumerate(tiles(d, d, B.BW - d, B.BH - d, ko + pwr_cut.get(lay, []))): outs.append(('%s-%d' % (tag, k), net, lay, t, prio)) return outs def selfcheck(ps): bad = [] ko = keepouts() for name, net, lay, r, _p in ps: if r[2] - r[0] < 0.6 or r[3] - r[1] < 0.6: bad.append(u'%s 太小: %s' % (name, r)) if r[0] < INSET - 1e-6 or r[1] < INSET - 1e-6 or r[2] > B.BW - INSET + 1e-6 or r[3] > B.BH - INSET + 1e-6: bad.append(u'%s 出了内缩线: %s' % (name, r)) for k in ko: if r[0] < k[2] - 1e-6 and k[0] < r[2] - 1e-6 and r[1] < k[3] - 1e-6 and k[1] < r[3] - 1e-6: bad.append(u'%s 压进净空区 %s' % (name, tuple(round(v, 1) for v in k))) hard = [(n, net, l, r) for (n, net, l, r, _p) in ps if n.startswith('PWR-')] for i, (na, neta, la, a) in enumerate(hard): for (nb, netb, lb, b) in hard[i + 1:]: if neta == netb or la != lb: continue if a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]: bad.append(u'%s(%s) 与 %s(%s) 同层重叠 —— 串联的两段搭上就把器件短路了' % (na, neta, nb, netb)) return bad # ── 断面校核 ───────────────────────────────────────────────────────────── def _blocks(net, layer, axis, t): u"""断面线(axis='y' ⇒ 横线 y=t;'x' ⇒ 竖线 x=t)上被别人占掉的区间:(切断, 孔洞)。 ⚠ 两类要分开:同层的**导线**把铺铜切成互不相连的几条(取最宽的一条); **焊盘和过孔**是孤立的岛 / 洞,铜从四周绕过去 —— 扣掉洞宽、不切断。 一排挨着的焊盘(缝 < 2×净距)按洞合并扣除,效果和切断一样,所以这个判据对针排也成立。 第一版把过孔、焊盘都当切断:L1 12V 主块被背面件的孔和 C2/C3 的通孔焊盘「切」成 4.95 / 6.20 mm。 """ out, holes = [], [] for p in pads(): if p[7] == net or layer not in _layers_of(p): continue if axis == 'y' and p[3] - p[5] / 2 - CLR <= t <= p[3] + p[5] / 2 + CLR: holes.append((p[2] - p[4] / 2 - CLR, p[2] + p[4] / 2 + CLR)) if axis == 'x' and p[2] - p[4] / 2 - CLR <= t <= p[2] + p[4] / 2 + CLR: holes.append((p[3] - p[5] / 2 - CLR, p[3] + p[5] / 2 + CLR)) segs, vias, _v = routes() for s in segs: if s[6] == net or s[0] != layer: continue # ⚠ 平面网(GND / +3V3 / +12V)的线都是「焊盘 → 自己的过孔」那一小截引出脚,是孤立的岛, # 铜从四周绕过去 ⇒ 按洞扣。只有信号线会从铜面一边穿到另一边、真的切断。 # (实测:VIN_SW 最窄的那一刀上只有 C6 地脚 0.8 mm 的引出脚,却被当成切断量成 6.07 mm。) dst = holes if s[6] in ('GND', '+3V3', '+12V') else out hw = s[5] / 2.0 + CLR if axis == 'y': y0, y1 = sorted((s[2], s[4])) if y0 - hw <= t <= y1 + hw: x0, x1 = sorted((s[1], s[3])) dst.append((x0 - hw, x1 + hw)) else: x0, x1 = sorted((s[1], s[3])) if x0 - hw <= t <= x1 + hw: y0, y1 = sorted((s[2], s[4])) dst.append((y0 - hw, y1 + hw)) r = VIA_D / 2.0 + CLR for v in vias: # 通孔:每一层都占,但只是洞 if v[2] == net: continue c, o = (v[1], v[0]) if axis == 'y' else (v[0], v[1]) if abs(c - t) <= r: holes.append((o - r, o + r)) return out, holes def cut_width(net, rects, axis, t, layer): u"""`rects` = 本网同层的几块瓦片(同网相邻即连成一片,先在断面线上合并)。返回最宽一条的净宽。""" iv = [] for rect in rects: if (rect[1] <= t <= rect[3]) if axis == 'y' else (rect[0] <= t <= rect[2]): iv.append(((rect[0], rect[2]) if axis == 'y' else (rect[1], rect[3]))) iv.sort() spans = [] for a, b in iv: if spans and a <= spans[-1][1] + 1e-6: spans[-1] = (spans[-1][0], max(spans[-1][1], b)) else: spans.append((a, b)) if not spans: return 0.0 cuts, holes = _blocks(net, layer, axis, t) for (b0, b1) in cuts: nxt = [] for (a0, a1) in spans: if b1 <= a0 or b0 >= a1: nxt.append((a0, a1)) continue if b0 > a0: nxt.append((a0, b0)) if b1 < a1: nxt.append((b1, a1)) spans = nxt best = 0.0 for (a0, a1) in spans: # 洞:合并重叠的反焊盘之后按实际落在这一条里的长度扣 hs = sorted((max(a0, h0), min(a1, h1)) for (h0, h1) in holes if h1 > a0 and h0 < a1) lost, cur = 0.0, a0 for h0, h1 in hs: if h1 > cur: lost += h1 - max(h0, cur) cur = h1 best = max(best, (a1 - a0) - lost) return best def series_windows(net): u"""这个网自己串联元件占的外框(整颗器件,不只本网那只脚)。""" own = set(p[0] for p in pads() if p[7] == net) by = {} for p in pads(): if p[0] not in own: continue b = by.setdefault(p[0], [1e9, 1e9, -1e9, -1e9]) b[0] = min(b[0], p[2] - p[4] / 2); b[2] = max(b[2], p[2] + p[4] / 2) b[1] = min(b[1], p[3] - p[5] / 2); b[3] = max(b[3], p[3] + p[5] / 2) return by def check_cuts(ps): print(u'') print(u'载流断面校核(逐刀切,取最窄一刀):') if not routes()[0]: print(u' ⚠ 没有 4 层布线缓存(schema %d),只按焊盘 / 过孔扣' % ROUTE_SCHEMA) bad = [] byname = {} for (n, _net, _l, r, _p) in ps: byname.setdefault(n, []).append(r) for key, label, net, axis, need, must, layer, skip_series in CUTS: rs = [r for n, v in byname.items() if n == key or n.startswith(key + '-') for r in v] if not rs: print(u' %-14s %-9s [X] 这一段没有铜面' % (label, net)) bad.append(label) continue r = (min(q[0] for q in rs), min(q[1] for q in rs), max(q[2] for q in rs), max(q[3] for q in rs)) skip = [] if skip_series: for ref, b in series_windows(net).items(): if b[0] > r[2] or b[2] < r[0] or b[1] > r[3] or b[3] < r[1]: continue skip.append((ref, (b[1] - CLR, b[3] + CLR) if axis == 'y' else (b[0] - CLR, b[2] + CLR))) lo, hi = (r[1], r[3]) if axis == 'y' else (r[0], r[2]) hs = [] t = lo + 0.25 while t <= hi - 0.25: if not any(a <= t <= b for _ref, (a, b) in skip): hs.append((cut_width(net, rs, axis, t, layer), t)) t += 0.25 worst = min(hs) if hs else (0.0, 0.0) ok = worst[0] >= need print(u' %-14s %-9s 铜面 %5.1f×%5.1f 最窄 %5.2f mm(%s=%.2f,要 %.1f) %s' % (label, net, r[2] - r[0], r[3] - r[1], worst[0], axis, worst[1], need, u'[OK]' if ok else (u'[X] 不够' if must else u'[~] 瓶颈在连接器 / 保险,不强制'))) if skip: print(u' (跳过串联元件 %s)' % ' '.join(sorted(ref for ref, _w in skip))) if not ok and must: bad.append(u'%s %s 最窄只有 %.2f mm' % (label, net, worst[0])) return bad # ── 缝合过孔 ───────────────────────────────────────────────────────────── def _seg_d(px, py, x0, y0, x1, y1): dx, dy = x1 - x0, y1 - y0 L = dx * dx + dy * dy t = 0.0 if L < 1e-12 else max(0.0, min(1.0, ((px - x0) * dx + (py - y0) * dy) / L)) return math.hypot(px - (x0 + t * dx), py - (y0 + t * dy)) def stitch_points(ps): u"""GND 缝合过孔(4 mm 网格):L1 / L4 的 GND 底填会被信号切成岛,每块岛要有孔接到 L3,否则被当孤岛删掉 (`rebuildCopperRegion` / 铺铜管理器 / DRC 三处都不报,刀片 §24.4)。 ⛔ 硬功率铜面(入口链、12V 铜块、L1 12V 大铜)里不打:GND 孔只会在大电流铜上掏洞,断面校核看不到。""" segs, vias, _v = routes() r = VIA_D / 2.0 + STITCH_CLR # 封装自带的 NPTH(插槽定位柱 / J23,`npth_base.py`):按反焊盘半径的方框避开 npth = json.load(io.open(os.path.join(HERE, '_base_npth.json'), encoding='utf-8')) ko = (keepouts() + [rr for (n, _net, _l, rr, _p) in ps if n.startswith('PWR-')] + [(h[0] - h[3], h[1] - h[3], h[0] + h[3], h[1] + h[3]) for h in npth]) pp = pads() def free(x, y): if any(k[0] - r < x < k[2] + r and k[1] - r < y < k[3] + r for k in ko): return False if any(p[2] - p[4] / 2 - r < x < p[2] + p[4] / 2 + r and p[3] - p[5] / 2 - r < y < p[3] + p[5] / 2 + r for p in pp): return False if any(s[6] != 'GND' and _seg_d(x, y, s[1], s[2], s[3], s[4]) < r + s[5] / 2.0 for s in segs): return False return not any(math.hypot(x - v[0], y - v[1]) < VIA_D + STITCH_CLR for v in vias) out = [] y = INSET + 2.0 while y <= B.BH - INSET - 2.0: x = INSET + 2.0 while x <= B.BW - INSET - 2.0: if free(x, y): out.append((round(x, 3), round(y, 3))) x += STITCH_PITCH y += STITCH_PITCH # 天线净空一圈加密(子网格点,离净空框 ANT_BAND,彼此 ≥ ANT_PITCH,和主网格孔也 ≥ ANT_PITCH) d = json.load(io.open(os.path.join(HERE, '_base_place.json'), encoding='utf-8')) ak = PL.antenna_keepout(PL.sizes(), {k: (v[0], v[1], v[2]) for k, v in d.items()}) o0, n0 = INSET + 2.0, len(out) cands = [] for i in range(int((ak[0] - ANT_BAND[1] - o0) // STITCH_SUB), int((ak[2] + ANT_BAND[1] - o0) // STITCH_SUB) + 2): for j in range(int((ak[1] - ANT_BAND[1] - o0) // STITCH_SUB), int((ak[3] + ANT_BAND[1] - o0) // STITCH_SUB) + 2): x, y = round(o0 + i * STITCH_SUB, 3), round(o0 + j * STITCH_SUB, 3) dk = math.hypot(max(ak[0] - x, x - ak[2], 0.0), max(ak[1] - y, y - ak[3], 0.0)) if ANT_BAND[0] <= dk <= ANT_BAND[1] and INSET + 0.5 <= x <= B.BW - INSET - 0.5 \ and INSET + 0.5 <= y <= B.BH - INSET - 0.5: cands.append((dk, y, x)) for _dk, y, x in sorted(cands): if any(math.hypot(x - o[0], y - o[1]) < ANT_PITCH - 1e-6 for o in out): continue if free(x, y): out.append((x, y)) print(u' 天线净空周边加密:%d 个(离净空框 %.1f~%.1f mm、间距 ≥ %.1f)' % (len(out) - n0, ANT_BAND[0], ANT_BAND[1], ANT_PITCH)) return out def stitch(ps): pts = stitch_points(ps) print(u'GND 缝合过孔 %d 个(%g mm 网格)' % (len(pts), STITCH_PITCH)) # ⚠ 只删**旧的缝合孔**:在缝合网格上、且**不在布线缓存里**的 GND 孔。 # 布线的 GND 平面孔在 0.125 网格上,个别会恰好落在缝合网格点上 —— 只按网格删会把它们删掉,焊盘就断了。 keep = [[round(v[0], 3), round(v[1], 3)] for v in routes()[1] if v[2] == 'GND'] JS = OPEN + u""" const MIL = %s, P = %s, D = %s, H = %s, PITCH = %s, OFF = %s, KEEP = %s; const onGrid = v => { const q = (v/MIL - OFF) / PITCH; return Math.abs(q - Math.round(q)) < 1e-3; }; const kept = (x, y) => KEEP.some(k => Math.abs(k[0] - x/MIL) < 0.01 && Math.abs(k[1] - y/MIL) < 0.01); let old = 0; for (const v of (await eda.pcb_PrimitiveVia.getAll()||[])) if (String(v.net) === 'GND' && onGrid(v.x) && onGrid(v.y) && !kept(v.x, v.y)) { await eda.pcb_PrimitiveVia.delete(v.primitiveId); old++; } let n = 0; for (const p of P) { try { await eda.pcb_PrimitiveVia.create('GND', p[0]*MIL, p[1]*MIL, H*MIL, D*MIL); n++; } catch (e) {} } await eda.pcb_Document.save(); return {deleted: old, made: n, now: (await eda.pcb_PrimitiveVia.getAll()||[]).length}; """ % (MIL, json.dumps(pts), VIA_D, VIA_HOLE, STITCH_SUB, INSET + 2.0, json.dumps(keep)) print(call(JS, timeout=900)) # ── 下发铺铜 ───────────────────────────────────────────────────────────── JS_DEL = OPEN + u""" let n = 0; for (const old of await eda.pcb_PrimitivePour.getAll()) { await eda.pcb_PrimitivePour.delete(old.primitiveId); n++; } await eda.pcb_Document.save(); return {deleted: n}; """ JS_DEL_L2 = OPEN + u""" let n = 0; for (const old of await eda.pcb_PrimitivePour.getAll()) if (old.getState_Layer() === %d) { await eda.pcb_PrimitivePour.delete(old.primitiveId); n++; } await eda.pcb_Document.save(); return {deleted: n}; """ % L2 JS_LAYER_NETS = OPEN + u""" const o = {}; for (const p of await eda.pcb_PrimitivePour.getAll()) { const k = p.getState_Layer() + '|' + p.getState_Net(); o[k] = (o[k] || 0) + 1; } return o; """ 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_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 rrect_src(x0, y0, x1, y1): pts = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] # ⚠ 必须显式闭合 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 rebuild(): u"""重建所有铺铜(走 GUI —— `rebuildCopperRegion()` 是坏的,刀片 §24 已查清)。""" print(call(JS_REBUILD, timeout=180)) last, same = -1, 0 for _ in range(40): time.sleep(6) n = call(JS_POLL, timeout=120)['poured'] print(u' 已填 %d 块' % n) same = same + 1 if n == last else 0 last = n if same >= 2 and n > 0: break print(call(JS_DONE, timeout=180)) JS_FILLED = OPEN + u""" const filled = new Set(); for (const q of await eda.pcb_PrimitivePoured.getAll()) filled.add(q.getState_PourPrimitiveId()); const out = []; for (const p of await eda.pcb_PrimitivePour.getAll()) out.push([String(p.getState_PourName()||''), filled.has(p.getState_PrimitiveId()) ? 1 : 0]); return out; """ def verify_fill(): u"""哪些铜面没填出来。GND / +3V3 小瓦片没有本网孔会被当孤岛删掉,属正常; **`PWR-*`(入口链、12V 铜块、L1 12V)一块都不许空** —— 2026-09-10 就是它们被 GND 抢了地盘,DRC / board_check 都不报。""" rows = call(JS_FILLED, timeout=300) empty = [n for n, f in rows if not f] bad = [n for n in empty if n.upper().startswith('PWR-')] print(u'铜面 %d 块,填出 %d 块;空的 %d 块%s' % (len(rows), len(rows) - len(empty), len(empty), (u':' + ' '.join(empty)) if empty else u'')) if bad: print(u'[X] 功率铜面没填出来:%s' % ' '.join(bad)) else: print(u'[OK] 功率铜面全部填出') return not bad def push(ps): print(call(JS_DEL, timeout=300)) arr = [{'name': n, 'net': net, 'layer': l, 'prio': p, 'src': rrect_src(*r)} for (n, net, l, r, p) in ps] made = [] for k in range(0, len(arr), 12): made += call(JS_MAKE % json.dumps(arr[k:k + 12], ensure_ascii=False), timeout=600)['made'] want = set(n.upper() for (n, _net, _l, _r, _p) in ps) got = set(made) print(u'铜面建了 %d / %d 块' % (len(want & got), len(want))) if want - got: print(u' [!] 没建上: %s' % ' '.join(sorted(want - got))) rebuild() if not verify_fill(): sys.exit(u'!! 功率铜面有空的 —— 断面校核作废,别往下走') if __name__ == '__main__': if '--clear' in sys.argv: print(call(JS_DEL, timeout=300)) sys.exit(0) if '--verify-fill' in sys.argv: sys.exit(0 if verify_fill() else 1) ps = plan() bad = selfcheck(ps) if '--swap-l2' in sys.argv: # 第 5 阶段(2026-09-11):只把板上 L2 的铜面轮廓换成计划里的 GND-L2 瓦片,**不重填** # —— 其它层不动;填充留到服务器重布之后的铺铜步骤统一 `--push`。 if bad: sys.exit(u'!! 自检不过,不换 L2') l2 = [p for p in ps if p[2] == L2] assert l2 and all(p[1] == 'GND' for p in l2), u'计划里的 L2 不是整层 GND' print(call(JS_DEL_L2, timeout=300)) arr = [{'name': n, 'net': net, 'layer': l, 'prio': p, 'src': rrect_src(*r)} for (n, net, l, r, p) in l2] call(JS_MAKE % json.dumps(arr, ensure_ascii=False), timeout=600) got = call(JS_LAYER_NETS, timeout=300) on_l2 = {k: v for k, v in got.items() if k.startswith('%d|' % L2)} print(u'板上 L2 铜面:%s(计划 GND ×%d)' % (on_l2, len(l2))) ok = on_l2 == {'%d|GND' % L2: len(l2)} print(u'[OK] L2 已是整层 GND 轮廓(未填充)' if ok else u'[X] L2 读回不对') sys.exit(0 if ok else 1) print(u'VIN_SW 北段(按焊盘外框撑):') for net, raw, r, parts in CHAIN_DEBUG: f = lambda q: u'x %.2f~%.2f y %.2f~%.2f' % (q[0], q[2], q[1], q[3]) # noqa: E731 print(u' %-9s 原始 %s → 裁剪 %s' % (net, f(raw), f(r))) for t in parts: e = 0.01 # 瓦片坐标四舍五入到 0.001,裁剪框没有 out = u' ⚠ 出了裁剪框' if (t[1] < r[1] - e or t[3] > r[3] + e or t[0] < r[0] - e or t[2] > r[2] + e) else u'' print(u' 瓦片 %s%s' % (f(t), out)) if out: bad.append(u'%s 的瓦片 %s 出了裁剪框' % (net, f(t))) print(u'计划建 %d 块铜面:' % len(ps)) for name, net, lay, r, prio in ps: if name.startswith('PWR-') or name.endswith('-0'): print(u' %-15s net=%-9s %s x %6.2f~%6.2f y %6.2f~%6.2f prio=%d' % (name, net, LNAME[lay], r[0], r[2], r[1], r[3], prio)) for tag in ('GND-L2', 'GND-L3', 'GND-L1', 'GND-L4'): print(u' (%s 瓦片 %d 块,绕开天线净空 / 缺口 / 四个 M3 孔)' % (tag, sum(1 for p in ps if p[0].startswith(tag + '-')))) if bad: print(u'[X] 自检 %d 条不过:' % len(bad)) for t in bad: print(u' ' + t) else: print(u'[OK] 自检全过(不出内缩线 / 不压净空 / 入口链各段与 12V 铜块同层互不重叠)') bad += check_cuts(ps) if '--stitch' in sys.argv: stitch(ps) if '--push' in sys.argv: if bad: sys.exit(u'!! 自检 / 断面不过,不下发') push(ps) elif '--rebuild' in sys.argv: rebuild()