原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。 这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。 纳入: - 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本 - .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、 pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源) - 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾) - firmware/ 的 A1 骨架与 site/ 文档站源码 .gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、 site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。 .gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存, 不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。 Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
465 行
24 KiB
Python
465 行
24 KiB
Python
# -*- coding: utf-8 -*-
|
||
u"""载板 PCB2-CARRIER 的铺铜 —— 事实来源,改铜面改这里再 `--push`。
|
||
|
||
/c/Python314/python .claude/pcb2_pour.py # 只打印计划
|
||
/c/Python314/python .claude/pcb2_pour.py --push # 清旧 -> 建边框 -> 重建 -> 确认 -> 存盘 -> 回读
|
||
/c/Python314/python .claude/pcb2_pour.py --verify # 只回读实测铺出来的铜(不改板)
|
||
|
||
## 叠层(§25.6 ①,**层角色和下板是镜像的**)
|
||
|
||
| 设计文档 | EasyEDA 层 | id | 载板上干什么 |
|
||
|---|---|---|---|
|
||
| L1 | Top Layer | 1 | 信号:服务带、P+/P− 焊盘、U5n、U31、J23 |
|
||
| L2 | Inner1 | 15 | **电源面(分割)** —— 见下,⛔ 还没划 |
|
||
| L3 | Inner2 | 16 | **完整地平面,不许割断** ✅ 已铺 |
|
||
| L4 | Bottom | 2 | 信号:J31/J32、整个 Hub |
|
||
|
||
地平面放 Inner2 而不是 Inner1,是因为 `U32` 在**底层**,所有长距离 D± 都从底层出发,
|
||
底层的参考面是 Inner2 —— 反过来的话底层 D± 就以分割的电源面为参考(§10.1 不许)。
|
||
|
||
## ✅ Inner1 分割电源面已划(2026-09-05 夜)
|
||
|
||
五块 `VOn_P` 岛 + GND 底填,定义在下面的 `ISLANDS`,实测见 §两个校核脚本。
|
||
排母坐标:J31 x 37.869~86.129 / J32 x 133.869~182.129,两行 y = **66.23 / 68.77**
|
||
(`pcb2-header-pads.json`)。
|
||
|
||
### 两个必须都过的校核(**都不看 EDA 的段数**)
|
||
|
||
/c/Python314/python .claude/inner1_check.py # 下发前:按 ISLANDS + 反焊盘预测
|
||
/c/Python314/python .claude/inner1_verify.py # 下发后:按 EDA 实际吐的铜实测
|
||
/c/Python314/python .claude/inner1_diffref.py # §10.1:顶层 5 段 *J 差分下方是不是一整块
|
||
|
||
实测结果(2026-09-05):五块岛各自 **1 个连通域**、5 个本网通孔焊盘全在里面,
|
||
最窄断面 **7.20 / 7.60 / 7.70 / 11.70 / 12.90 mm**(下限 7.0)。
|
||
DRC 499 = 同封装内间距 115(与基线一致)+ 连接性 384(比铺岛前的 396 少 12)+ 间距错误 0。
|
||
|
||
### ⚠️ 焊盘连接方式:五路 `VOn_P` 必须是「全连接」
|
||
|
||
默认的 `copperRegion` 规则是 **十字花**(`connectMode: 0`,辐条 0.254 mm)。
|
||
十字花下铜面多边形会把**自己的**焊盘也挖空、只留 4 条描边辐条,
|
||
于是 `inner1_verify.py` 量到的断面是 **0.3 mm** —— 那是**度量假象**,
|
||
但假象和真断路在数据上分不出来。所以给 VOn_P 挂了 `PLANE-DIRECT`
|
||
(`pcb2_rules.py` 的 `PLANE_RULE`,`connectMode: 1`),铜面才是一整块。
|
||
GND 留默认十字花(~200 个通孔焊盘要手焊)。**电气上两者都够,理由见 pcb2_rules.py。**
|
||
|
||
⚠️ 那 80 个 2.54 间距的通孔在内层会开出两条反焊盘带
|
||
(Ø1.7 焊盘 + 0.2 净距 ⇒ 相邻反焊盘之间只剩 **0.44 mm** 铜网)。
|
||
平面不会被切断,但**电源岛的颈宽别指望从排母底下横穿过去**,要绕。
|
||
|
||
落地成这样(**只做两类,其余全部 GND 底填**):
|
||
|
||
| 岛 | 网络 | 为什么 | 最小颈宽 |
|
||
|---|---|---|---|
|
||
| 5 块 | `VO1_P`~`VO5_P` | 每路 4 A | **≥ 7.0 mm** |
|
||
| 底填 | `GND` | 剩余全部,**优先级数字给最大** | — |
|
||
|
||
> ⚠️ **2026-09-05 夜简化:`+5V` 和 `+3V3_HUB` 两块岛取消了**(§25.10)。
|
||
> 排母逐针分配按几何重排之后,`+5V` 从 3 针变成 **10 针、分散在两颗排母的南北四处**,
|
||
> 每个入口只喂本站的 `U2n` + `C70n`、段电流 ≤0.6 A ⇒ **顶层 0.5~0.8 mm 走线就够**,
|
||
> 内层不用给它留地方。`+3V3_HUB`(420 mA、局部)同理。
|
||
> ⇒ Inner1 只剩「**五块 `VOn_P` + GND 底填**」,五路 4 A 各走各的,互不挤。
|
||
|
||
|
||
颈宽是按 **IPC-2221 内层(0.5 oz)** 算的,不是外层:
|
||
`A = (I/(0.024·ΔT^0.44))^(1/0.725)`,ΔT=20 °C ⇒ 4 A 需 188 mil² / 0.685 mil = **6.97 mm**,
|
||
2.8 A 需 **4.3 mm**。两排手机之间的走廊只有 35.8 mm 高(y 49.66~85.5),
|
||
所以**不能**像原计划那样在 Inner1 上开十二块岛,装不下。
|
||
|
||
`VBUS1~5` 故意不做岛:每口 0.5 A、走线只在本服务带内约 18 mm,`PWR1A`(0.6 mm) 足够。
|
||
额外好处:顶层那 5 段 `*J` 差分(U5n→J7n,5 mm)下方就是**完整的 GND 底填**,
|
||
参考面比分割的电源面干净。
|
||
|
||
走廊里 Inner1 已知的障碍(内层只怕直插焊盘,不怕表贴件)。
|
||
⚠️ **2026-09-05 五个电解整体上移了 2.5 mm**(`pcb2_place.BAND['c1']` 的 v −11.0 → −8.0,
|
||
成因见 §25.9),下面这几个 y 是**改后**的值:
|
||
|
||
| 障碍 | 位置 |
|
||
|---|---|
|
||
| `C801/C811/C821`(上排三站)Ø12.5 罐 | y **70.75~83.25** |
|
||
| `C831/C841`(下排两站)Ø12.5 罐 | y **51.95~64.45** |
|
||
| 两个中跨 M3 的 Ø6 焊盘 | (76.6, 80.1) / (148.1, 80.1) |
|
||
| **J31/J32 的 80 个通孔焊盘** | 两行 y 66.23 / 68.77,x 见上 |
|
||
|
||
## 四条铺铜的硬经验(都在 skill §9j~§9l,这里只留判据)
|
||
|
||
1. **多边形末尾必须把首点再写一遍**,不闭合的话 `create()` 直接不建(下板 `pcb_pour.py` 的坑)。
|
||
`create()` **成功了也会抛异常**,返回值和 try/catch 都不能当判据 —— 只能建完 `getAll()` 按名字核。
|
||
⚠️ 铺铜名字会被转成大写,比对要 `toUpperCase()`。
|
||
2. **优先级是「数字小的先铺、占地盘」**(对话框底部自己写着),
|
||
而 **`create()` 的 `pourPriority` 参数不生效** —— 优先级是**按层、按创建顺序**编的。
|
||
⚠️ **步长是 2 不是 1**:同层建 6 块,回读是 **1,3,5,7,9,11**(2026-09-05 实测)。
|
||
所以只能断言**相对次序**,别断言「第 k 块 == k」—— 第一版就是这么写的,误报了一次。⚠️ 别拿 `modify(id, {pourPriority})` 去补设:不生效,还会把客户端弄卡死。
|
||
|
||
⇒ **所以 `plan()` 的顺序就是优先级**:电源岛写在前面(拿 1,2,3…),
|
||
**GND 底填写在最后**(拿最大的数字)。`--push` 会回读优先级并断言它等于计划里的次序。
|
||
2026-09-05 在本板 Inner1 上做过对照实验证实(skill §9j 有数据)。
|
||
3. **填充只能点 GUI 菜单**(`rebuildCopperRegion()` 必然抛)。点了没反应 =
|
||
客户端卡在后台任务上(窗口底部一条 0% 进度条),`location.reload()` 后一次就通;
|
||
⚠️ **重载会丢掉重载前建的边框,即使 save 过** —— 所以 `--push` 一气跑完,别分两次。
|
||
4. **`preserveSilos=False` + 区域内没有本网焊盘/过孔 = 整块被当孤岛删光**
|
||
(边框还在、`nPoured` 是 0)。**扇出必须排在铺铜之前。**
|
||
"""
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
from eda import run
|
||
|
||
PCB = 'f126ffb29c92f9b2' # PCB2-CARRIER
|
||
MIL = 39.3700787401575
|
||
|
||
L_TOP, L_PWR, L_GND, L_BOT = 1, 15, 16, 2 # ⚠ 载板:Inner1=电源 / Inner2=地
|
||
|
||
BW, BH = 220.0, 135.0
|
||
CORNER_R = 5.0
|
||
INSET = 0.5 # 铜面离板边
|
||
|
||
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
|
||
"await new Promise(r=>setTimeout(r,1800));" % PCB)
|
||
|
||
|
||
def call(js, timeout=120):
|
||
r = run(OPEN + js, timeout=timeout)
|
||
if not r.get('success'):
|
||
sys.exit(u'失败: ' + json.dumps(r, ensure_ascii=False)[:500])
|
||
return r['result']
|
||
|
||
|
||
def board_poly(inset=INSET):
|
||
u"""板框内缩 inset 的圆角矩形 -> TPCB_PolygonSourceArray(mil)。
|
||
|
||
格式:x1 y1 'L' x2 y2 ... 'ARC' 角度 ex ey ...
|
||
ARC 的起点是「当前点」,角度正值逆时针(本工程 y 轴朝上)。
|
||
⚠️ API 枚举不到板框的圆弧(`pcb_PrimitiveArc.getAll()` 返回 0),
|
||
所以这里不去读板框,按几何自己造。打样前用 Gerber 预览复核板框闭合。
|
||
"""
|
||
d, R = inset, CORNER_R
|
||
seq = [('L', BW - R, d), ('A', BW - d, R),
|
||
('L', BW - d, BH - R), ('A', BW - R, BH - d),
|
||
('L', R, BH - d), ('A', d, BH - R),
|
||
('L', d, R), ('A', R, d)] # 最后一段弧回到起点,自然闭合
|
||
src, mode = [R * MIL, d * MIL], None
|
||
for k, x, y in seq:
|
||
if k == 'L':
|
||
if mode != 'L':
|
||
src.append('L')
|
||
mode = 'L'
|
||
src += [x * MIL, y * MIL]
|
||
else:
|
||
src.append('ARC')
|
||
mode = 'ARC'
|
||
src += [90.0, x * MIL, y * MIL]
|
||
return src
|
||
|
||
|
||
def polysrc(pts):
|
||
u"""点列 -> TPCB_PolygonSourceArray(全直边)。
|
||
|
||
⚠️ 末尾把首点再写一遍 —— 不闭合的话 `create()` 什么也不建(下板踩过)。
|
||
"""
|
||
src = [pts[0][0] * MIL, pts[0][1] * MIL, 'L']
|
||
for x, y in list(pts[1:]) + [pts[0]]:
|
||
src += [x * MIL, y * MIL]
|
||
return src
|
||
|
||
|
||
def rect(x0, y0, x1, y1):
|
||
return polysrc([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])
|
||
|
||
|
||
from shapely.geometry import box as _sbox
|
||
from shapely.ops import unary_union as _union
|
||
|
||
|
||
def body_bbox(rects):
|
||
return (min(r[0] for r in rects), min(r[1] for r in rects),
|
||
max(r[2] for r in rects), max(r[3] for r in rects))
|
||
|
||
|
||
def _poly_of_rects(rects):
|
||
u"""若干轴对齐矩形的并 -> 逆时针点列(mm)。必须并成**一个**多边形。"""
|
||
u = _union([_sbox(*r) for r in rects])
|
||
if u.geom_type != 'Polygon':
|
||
raise SystemExit(u'岛的矩形并不成一块: %s' % u.geom_type)
|
||
u = u.simplify(0.001)
|
||
pts = list(u.exterior.coords)[:-1]
|
||
if not u.exterior.is_ccw:
|
||
pts.reverse()
|
||
return [(round(x, 3), round(y, 3)) for x, y in pts]
|
||
|
||
|
||
def island_poly(body, branch):
|
||
u"""整块岛 = 走廊主体(4 A 路径)∪ 分支(穿筋的颈 + 主板投影下方那块)。"""
|
||
return _poly_of_rects(list(body) + list(branch))
|
||
|
||
|
||
def island_body_poly(body):
|
||
return _poly_of_rects(list(body))
|
||
|
||
|
||
# ── v3(2026-09-06,§25.16):槽在主板远端边,岛 = 走廊主体 + 整块主板投影下方的带 ──
|
||
#
|
||
# 4 A 路径:排母针(走廊,y 66~69)→ 主板投影下方一路到 TP8n1(v=42,贴着远端槽)。
|
||
# 中间没有槽挡路(槽在投影外侧 v 45.5~48.5),整块岛就是一条 34 mm 宽的带,
|
||
# 7 mm 颈宽规则对整块岛都判(branch 留空)。
|
||
# 走廊主体的边界沿用 v1 的推导(每条都被具体的异网排母针顶住,见 obsolete 版注释):
|
||
# VO1 右 44.30 <- J31.7 +5V;VO2 左 80.00 <- J31.33 GND + MH5;VO3 颈 168.5~175.9 <- J32.27/35;
|
||
# VO4 颈 71.5~79.9 <- J31.28/36;VO5 颈 152.8~160.6 <- J32.16/24。
|
||
# 主板下方那块取本体 u 12~46(避开 u<8 的顶针过孔),v −1 ~ 14(v3b:TP/电容组都贴着接口边,v 3.5~11;C8n1 已是 SMD)。
|
||
# 电池槽 (u 18.8~28.8, v −4.2~−1.2) 落在走廊主体里,铺铜自己让开,剩余宽度仍 > 20。
|
||
# 定位孔(NPTH)落在带里没关系,铺铜自己让开,带宽 34 远大于 7。
|
||
|
||
|
||
def _site_rect(n, u0, v0, u1, v1):
|
||
import pcb2_place as P
|
||
ax, ay, _ = P.to_board(n, u0, v0)
|
||
bx, by, _ = P.to_board(n, u1, v1)
|
||
return (round(min(ax, bx), 3), round(min(ay, by), 3), round(max(ax, bx), 3), round(max(ay, by), 3))
|
||
|
||
|
||
# 2026-09-06 v5:主板整体转 180°(接口边朝板边)之后,电容组 / J8n / C8n1 都到了远离走廊的那一头(v 5~20 → 板边一侧),
|
||
# 所以主板投影下方那块岛改成**整条竖带 u 8~44 × v −1~47**:从板边一直贯通到走廊(v 44.66 是主板远端边,47 伸进走廊 2.3 让它和主体搭上)。
|
||
# u ≤ 44 避开尾插排线车道 / J7n / U5n(u 44.6~63,D± 在那里要 GND 参考),顶针在 u 2~6 也在带外。
|
||
# VO2 的走廊主体(x 80~87.3)和它的竖带(x 99.6~135.6)不相接,加一条 **Hub 北边的横带 y 75.5~83**(Hub 卫星最北 C742/C749/C751 到 y 74.97),高 7.5。
|
||
# ⚠️ 这条横带把走廊北带 y 75.5~83 变成 VO2_P 平面 —— 那里的差分只能走**底层**(参考 Inner2 GND),别走顶层。
|
||
ISLANDS = [
|
||
('VO1', 'VO1_P', [(12.5, 67.6, 44.3, 83.0), _site_rect(1, 8.0, -1.0, 44.0, 47.0)], []),
|
||
('VO2', 'VO2_P', [(80.0, 67.6, 87.3, 83.0), (87.0, 75.5, 100.5, 83.0), _site_rect(2, 8.0, -1.0, 44.0, 47.0)], []),
|
||
('VO3', 'VO3_P', [(152.0, 70.5, 195.0, 83.0), (168.5, 67.6, 175.9, 70.5), _site_rect(3, 8.0, -1.0, 44.0, 47.0)], []),
|
||
# v5 布线(2026-09-06):VO4 主体东界 89→87,给 site4 短差分 / pair4 / +3V3 干线的顶层段让出 GND 参考(x ≥ 87.3 全是 GND)
|
||
('VO4', 'VO4_P', [(61.0, 44.0, 87.0, 65.15), (71.5, 63.5, 79.9, 67.4), _site_rect(4, 8.0, -1.0, 44.0, 47.0)], []),
|
||
('VO5', 'VO5_P', [(132.5, 44.0, 172.0, 65.15), (152.8, 63.5, 160.6, 67.4), _site_rect(5, 8.0, -1.0, 44.0, 47.0)], []),
|
||
]
|
||
|
||
|
||
# ── 要建的铜面:name, net, layer, 多边形 ──────────────────────────────
|
||
# ⚠️⚠️ **这张表的顺序就是优先级**(同层内按创建顺序从 1 编,数字小的先铺、占地盘)。
|
||
# 所以:**电源岛写在前面,GND 底填写在同层的最后一条。**
|
||
def plan():
|
||
ps = []
|
||
# Inner1(L_PWR=15):**五块 VOn_P 岛写在最前面**,拿优先级 1~5、先占地盘;
|
||
# GND 底填排在同层最后一条,拿 6,只填剩下的。
|
||
# `+5V` / `+3V3_HUB` 不开岛 —— 重排逐针分配之后它们分散取电、走顶层线(§25.10)。
|
||
for name, net, body, branch in ISLANDS:
|
||
ps.append((u'%s-INNER1' % name, net, L_PWR, polysrc(island_poly(body, branch))))
|
||
ps.append((u'GND-INNER1', 'GND', L_PWR, board_poly()))
|
||
# Inner2:整板地平面,一块到底,不许割断(§10.1 / §25.6 ①)。
|
||
# 这一层只有它一块,优先级不参与裁剪。
|
||
ps.append((u'GND-INNER2', 'GND', L_GND, board_poly()))
|
||
return ps
|
||
|
||
|
||
def with_prio(ps):
|
||
u"""按「同层内创建顺序 = 优先级」给每块算出**预期的名次**(不是优先级数值)。
|
||
|
||
⚠️ 2026-09-05 实测:优先级数值**不是 1,2,3…,是 1,3,5,7,9,11**(同层内步长 2)。
|
||
一块的时候是 1,六块就是 1..11 的奇数 —— 所以只能断言**次序**,
|
||
不能断言「第 k 块的优先级 == k」(第一版就是这么写的,误报了一次)。
|
||
"""
|
||
seen, out = {}, []
|
||
for name, net, layer, src in ps:
|
||
seen[layer] = seen.get(layer, 0) + 1
|
||
out.append((name, net, layer, src, seen[layer]))
|
||
return out
|
||
|
||
|
||
JS_DEL = u"""
|
||
let n = 0;
|
||
for (const o of await eda.pcb_PrimitivePour.getAll()) { await eda.pcb_PrimitivePour.delete(o.primitiveId); n++; }
|
||
return {deleted: n};
|
||
"""
|
||
|
||
JS_MAKE = u"""
|
||
const PLAN = %s;
|
||
// ⚠️ 顺序即优先级:PLAN 是按「同层内谁该赢谁就在前面」排好的,**不要重排这个循环**。
|
||
for (const p of PLAN) {
|
||
const poly = await eda.pcb_MathPolygon.createComplexPolygon([p.src]);
|
||
if (!poly) continue;
|
||
// ⚠️ create() **成功了也会抛**,返回值和 try/catch 都不能当判据。
|
||
// 第 7 个参数 pourPriority 传什么都没用,真正决定优先级的是**这个循环的次序**。
|
||
try { await eda.pcb_PrimitivePour.create(p.net, p.layer, poly, 'solid', false, p.name, p.prio, 10); }
|
||
catch (e) {}
|
||
}
|
||
// ⚠️⚠️ 这里**不能**用 `pcb_PrimitivePour.modify(id, {pourPriority})` 去补设优先级:
|
||
// 不生效(回读不变),还会把客户端弄进「后台任务卡在零进度」的状态 ——
|
||
// 之后点「重建所有」永远填 0 块,只能重载,而重载又会丢掉已建的边框。见 skill §9j / §9k。
|
||
return (await eda.pcb_PrimitivePour.getAll())
|
||
.map(x => [String(x.getState_PourName()||'').toUpperCase(), x.getState_PourPriority()]);
|
||
"""
|
||
|
||
# 工具 → 铺铜管理器... → 重建所有(菜单项是 React 元素,按钮派发真事件)
|
||
JS_REBUILD = 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 = t => { for (const el of document.querySelectorAll('*'))
|
||
if (el.children.length===0 && (el.textContent||'').trim()===t) return el; return null; };
|
||
const btn = t => { for (const b of document.querySelectorAll('button'))
|
||
if ((b.innerText||'').trim()===t && b.offsetParent!==null) return b; return null; };
|
||
const nat = b => { const o={bubbles:true,cancelable:true,view:window,button:0};
|
||
for (const t of ['pointerdown','mousedown','pointerup','mouseup','click'])
|
||
b.dispatchEvent(new (t.startsWith('pointer')?PointerEvent:MouseEvent)(t,o)); };
|
||
const cancel = btn('取消'); if (cancel) { nat(cancel); await sleep(1200); }
|
||
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 重建所有'};
|
||
nat(b);
|
||
// 卡住的判据:窗口底部一条不动的 0% 进度条 -> 客户端卡在后台任务上,要 location.reload()
|
||
const pct = [];
|
||
for (const d of document.querySelectorAll('*'))
|
||
if (d.offsetParent!==null && d.children.length===0 && /^\\d+%$/.test((d.textContent||'').trim())) pct.push(d.textContent.trim());
|
||
return {fired: true, pct};
|
||
"""
|
||
|
||
JS_POLL = u"return {poured: (await eda.pcb_PrimitivePoured.getAll()).length};"
|
||
|
||
JS_DONE = u"""
|
||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||
const nat = b => { const o={bubbles:true,cancelable:true,view:window,button:0};
|
||
for (const t of ['pointerdown','mousedown','pointerup','mouseup','click'])
|
||
b.dispatchEvent(new (t.startsWith('pointer')?PointerEvent:MouseEvent)(t,o)); };
|
||
for (const b of document.querySelectorAll('button'))
|
||
if ((b.innerText||'').trim()==='确认' && b.offsetParent!==null) { nat(b); break; }
|
||
await sleep(1500);
|
||
await eda.pcb_Document.save();
|
||
await sleep(2000);
|
||
const fills = [];
|
||
for (const q of await eda.pcb_PrimitivePoured.getAll()) {
|
||
let f = []; try { f = q.getState_PourFills() || []; } catch (e) {}
|
||
fills.push(f.length);
|
||
}
|
||
// ⚠️ 返回铺铜名,用来确认打的是**载板**而不是上一条命令留下的下板(skill §9l)
|
||
return {fills, pour: (await eda.pcb_PrimitivePour.getAll())
|
||
.map(p => [p.getState_PourName(), p.getState_Net(), p.getState_Layer()])};
|
||
"""
|
||
|
||
|
||
JS_VERIFY = u"""
|
||
const byId = {};
|
||
for (const p of await eda.pcb_PrimitivePour.getAll())
|
||
byId[p.getState_PrimitiveId()] = [String(p.getState_PourName()||''),
|
||
p.getState_PourPriority(), p.getState_Layer()];
|
||
// ⚠️ 环是 `x1 y1 'L' x2 y2 ... 'ARC' 角度 ex ey ...` —— **'ARC' 后面第一个数是角度,不是坐标**。
|
||
// 直接把所有数字按奇偶分 x/y 会错位,实测把包围盒算成 -22.9 ~ 219.4(角度混进去了)。
|
||
const walk = (ring, hit) => {
|
||
let i = 0, mode = 'L';
|
||
while (i < ring.length) {
|
||
const v = ring[i];
|
||
if (v === 'L') { mode = 'L'; i++; continue; }
|
||
if (v === 'ARC') { mode = 'ARC'; i++; i++; continue; } // 跳过角度
|
||
hit(ring[i], ring[i + 1]); i += 2;
|
||
}
|
||
};
|
||
const out = [];
|
||
for (const q of await eda.pcb_PrimitivePoured.getAll()) {
|
||
let f = []; try { f = q.getState_PourFills() || []; } catch (e) {}
|
||
const info = byId[q.getState_PourPrimitiveId()] || ['?', '?', '?'];
|
||
let rings = 0, x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9, npt = 0;
|
||
for (const seg of f) {
|
||
const cp = (seg.path && seg.path.complexPolygon) || [];
|
||
rings += cp.length;
|
||
for (const ring of cp) walk(ring, (x, y) => {
|
||
npt++;
|
||
if (x < x0) x0 = x; if (y < y0) y0 = y;
|
||
if (x > x1) x1 = x; if (y > y1) y1 = y;
|
||
});
|
||
}
|
||
out.push({name: info[0], prio: info[1], layer: info[2],
|
||
segs: f.length, rings: rings, npt: npt,
|
||
bb: npt ? [x0, y0, x1, y1] : null});
|
||
}
|
||
return out;
|
||
"""
|
||
|
||
U10MIL = 3.9370078740157477 # getState_PourFills() 的坐标单位:10 mil / 单位
|
||
|
||
|
||
def verify():
|
||
u"""回读**真正铺出来的铜**,不是边框。判据是 `rings > segs` —— 多出来的环就是洞。"""
|
||
out = call(JS_VERIFY)
|
||
print(u'──── 铺铜实测(读回 getState_PourFills)────')
|
||
for e in out:
|
||
bb = e['bb']
|
||
geo = (u'%.1f,%.1f ~ %.1f,%.1f' % (bb[0] / U10MIL, bb[1] / U10MIL,
|
||
bb[2] / U10MIL, bb[3] / U10MIL)) if bb else u'空'
|
||
print(u' %-12s prio=%-3s layer=%-3s %d 段 / %d 环 / %d 点 包围盒 %s'
|
||
% (e['name'], e['prio'], e['layer'], e['segs'], e['rings'], e['npt'], geo))
|
||
if not out:
|
||
print(u' [!!] 一块铜都没铺出来 —— 看 --push 那边的排查提示')
|
||
return out
|
||
|
||
|
||
def main():
|
||
ps = with_prio(plan())
|
||
print(u'计划建 %d 块铜面(**表的顺序就是优先级**,数字小的先铺、占地盘):' % len(ps))
|
||
for name, net, layer, src, prio in ps:
|
||
n_arc = sum(1 for v in src if v == 'ARC')
|
||
n_pt = sum(1 for v in src if isinstance(v, float)) // 2
|
||
print(u' %-12s net=%-9s layer=%-3d prio=%-2d %d 点 / %d 段弧'
|
||
% (name, net, layer, prio, n_pt, n_arc))
|
||
if '--verify' in sys.argv:
|
||
verify()
|
||
return
|
||
if '--push' not in sys.argv:
|
||
print(u'(未加 --push 没有下发;--verify 回读实测铺铜结果)')
|
||
return
|
||
|
||
print(u'')
|
||
print(u'清掉旧铜面: %d 块' % call(JS_DEL)['deleted'])
|
||
batch = [{'name': n, 'net': net, 'layer': l, 'src': src, 'prio': pr}
|
||
for n, net, l, src, pr in ps]
|
||
made = call(JS_MAKE % json.dumps(batch, ensure_ascii=False))
|
||
print(u'已建: ' + ' '.join(u'%s(prio %s)' % (n, p) for n, p in made))
|
||
miss = [n for n, _, _, _, _ in ps if n.upper() not in [m[0] for m in made]]
|
||
if miss:
|
||
sys.exit(u'!! 这几块没建出来: ' + ' '.join(miss))
|
||
# 断言的是**同层内优先级的相对次序**和 plan() 一致(数值是 1,3,5,… 不连续,见 with_prio)
|
||
got = dict((m, p) for m, p in made)
|
||
bad = []
|
||
for layer in sorted(set(l for _, _, l, _, _ in ps)):
|
||
seq = [(n, got[n.upper()]) for n, _, l, _, _ in ps if l == layer]
|
||
for (na, pa), (nb, pb) in zip(seq, seq[1:]):
|
||
if not pa < pb:
|
||
bad.append((layer, na, pa, nb, pb))
|
||
if bad:
|
||
print(u' [!!] 同层内优先级次序和 plan() 不一致(层, 前, 前优先级, 后, 后优先级): %s' % bad)
|
||
print(u' 优先级 = 同层内的创建顺序,对不上说明 plan() 的次序被打乱了,')
|
||
print(u' 重叠的铜面会按错的顺序裁剪。**先修好再往下走。**')
|
||
sys.exit(1)
|
||
print(u' 优先级次序 OK:' + ' | '.join(
|
||
u'L%d %s' % (layer, ' < '.join(u'%s(%s)' % (n, got[n.upper()])
|
||
for n, _, l, _, _ in ps if l == layer))
|
||
for layer in sorted(set(l for _, _, l, _, _ in ps))))
|
||
|
||
r = call(JS_REBUILD)
|
||
if r.get('err'):
|
||
sys.exit(u'!! 打不开铺铜管理器: ' + r['err'])
|
||
for i in range(60):
|
||
time.sleep(4)
|
||
n = call(JS_POLL, timeout=40)['poured']
|
||
print(u' 填充 %d / %d' % (n, len(ps)))
|
||
if n >= len(ps):
|
||
break
|
||
else:
|
||
print(u'!! 填充一直上不去。看看窗口最底下是不是有条卡在 0% 的进度条 ——')
|
||
print(u' 那是客户端卡在后台任务上,跑 location.reload() 重载再来(skill §9k)。')
|
||
print(u' ⚠️ 重载会丢掉已建的边框,重载后要从 --push 头上重跑。')
|
||
sys.exit(1)
|
||
d = call(JS_DONE)
|
||
print(u'完成:铜面 %d 块,各自填充 %s' % (len(d['pour']), d['fills']))
|
||
for name, net, layer in d['pour']:
|
||
print(u' %-12s net=%-9s layer=%d' % (name, net, layer))
|
||
print(u'')
|
||
verify()
|
||
|
||
|
||
if __name__ == '__main__':
|
||
main()
|