文件
phonerail/.claude/pcb1_pour.py
T
zhou yang和Claude Opus 5 efcba30a0c R2 底板改 4 层(D15):规则 / 几何 / 背面摆位 / 4 层布线与铺铜,已下发
- 规则:铜层 2→4,差分 0.28/0.18,间距 0.15,过孔 0.3/0.2(rules_base)
- 几何:98×98 板框 + 4×M3,槽阵居中,L1 12V 大铜 + 指头(pcb_base)
- 摆位:五插槽留 L1,其余 133 件翻到 L4;背面镜像规则实测;Hub 放走廊 2、
  东移 2.5 mm;扇出圈 / 小件间距 1.5(去耦例外)/ 9 A 过孔阵禁区(place_base)
- 叠层按用户决定改为 L1 槽面 / L2 整层 +3V3 / L3 整层 GND / L4 器件面
- 布线:D± 七对按构造布通(USB3 掉头、USB1/2 北去掉头、USB4/5 东绕、
  J23 桥),+3V3 / GND / +12V 平面网每脚一孔,0.125 网格 + PathFinder
  开关(route_base、remote_route.ps1);剩 37 个网待手工补
- 铺铜:4 层重写,入口链自南向北切段 + 断面校核(pour_base);
  已下发布线 / 缝合孔 / 铜面轮廓,填铜未成功(待查)
- 工具:plot_base / plot_routes / shot_base,board_check 通孔两层都算

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_0168Bu6SEx3cXXCJLkdnE6zB
2026-09-10 21:02:10 +08:00

1527 行
82 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""下板 PCB1 的铺铜 —— **事实来源**,改铜面改这里。
/c/Python314/python .claude/pcb1_pour.py # 打印计划 + 全部校核 + ASCII 图
/c/Python314/python .claude/pcb1_pour.py --map # 只打 ASCII 图
/c/Python314/python .claude/pcb1_pour.py --push # 校核 -> 清旧 -> 建边框 -> 重建 -> 存盘 -> 回读复核
/c/Python314/python .claude/pcb1_pour.py --verify # 只回读实测铺出来的铜(不改板)
/c/Python314/python .claude/pcb1_pour.py --verify --cache # 同上,但用上次回读的缓存
⚠️ **先扇出再铺铜,顺序不能反**(PROGRESS 第十五轮那一条):`+3V3` / `VOn` 在板上
一个通孔焊盘都没有,没扇出之前那几块区域里没有本网焊盘 —— 铺铜的孤岛判定
是**按铺铜对象**算的,会把整块删光(边框还在、nPoured 是 0)。
## 叠层(§10.1,层角色和载板是镜像的)
| 设计文档 | EasyEDA 层 | id | 下板上干什么 |
|---|---|---|---|
| L1 | Top Layer | 1 | 功率器件 + 全部 338 个元件 + **8 A 输入链的宽铜** |
| L2 | Inner1 | 15 | **完整地平面,不许割断**(只挖 ESP32 天线区)|
| L3 | Inner2 | 16 | **分割电源面**:+12V(主)+ +3V3(南)+ GND 底填 |
| L4 | Bottom | 2 | 信号(板上元件全在顶层,这一层基本是空的)+ `+5V` 干线 + `VOn_P` 小岛 |
## ⭐ Inner2 到底分几块 —— 算出来的结果,不是上一轮草案里的「八块」
上一轮 PROGRESS 写的是「+12V / +5V / +3V3 / VO1~VO5 八块」。**逐条算下来只剩两块半。**
判据是 IPC-2221 **内层 0.5 oz**:`A=(I/(0.024·ΔT^0.44))^(1/0.725)` mil²,W = A / 0.685 mil,
ΔT = 20 °C:
8 A → 18.1 mm 6.4 A → 13.4 mm 4 A → 6.99 mm
2.8 A → 4.27 mm 2.0 A → 2.69 mm 1.6 A → 1.97 mm 0.5 A → 0.40 mm
**外层 1 oz 的同一条式子常数是 0.048(不是 0.024)、铜厚是 1.37 mil(不是 0.685)**,
所以外层每毫米能带的电流大约是内层的 **4 倍**:2.8 A 在外层只要 **0.45 mm**。
⇒ 「这一路要不要占内层」的门槛,比直觉高得多。逐个网络的结论:
| 网络 | 实际要跑多远 / 多大电流 | 结论 |
|---|---|---|
| `+12V` | 源在北左 Rs6.2 (9.6, 91),负载散布南北五路 + 五个风扇;南边要 5.6 A | ✅ **Inner2 主平面** |
| `+3V3` | 源 L2 (25.5, 104),64 个焊盘遍布全板,但**只有 0.5 A** | ✅ **Inner2 南部两块**(要的不是载流,是「64 个焊盘各打一个孔就完事」)|
| `+5V` | 源 L3 (73.3, 78),去处**全是排针上的 10 个通孔针**,最坏 2.8 A | ❌ 走**底层 1.2 mm 干线**(`PWR5V`)。外层 0.45 mm 就够,占内层是浪费 |
| `VO1`~`VO5` | Buck 输出 → eFuse,**通道体内约 12 mm**,4 A | ❌ 走**顶层 1.5~2.0 mm**(`PWR4A`)。§10.2 本来就要求这一段回路面积最小 |
| `VO1_P`~`VO5_P` | 分流电阻 → 排针,**只有约 9 mm**,4 A | ❌ 走**底层小岛**(排针针脚是通孔,自动连上)|
| 8 A 输入链三段 | J1→F1→Q1→C2/C3→Rs6,全长约 25 mm | ❌ 走**顶层宽铜 ≥3.5 mm**(见下)|
### 为什么 8 A 输入链**不**上 Inner2(和 §10.4 不一样,是有意的)
§10.4 是给 v5(单板时代)写的,那时这条链被摊开到 85 mm。v6 已经按 §10.2b 收进
北左角 (x 3~28, y 69~91),**全长只剩 25 mm**,于是两个新的事实起了作用:
1. **内层扛不动。** 8 A 在 0.5 oz 内层要 **18.1 mm 宽**,而那个角落总共才 22 mm 高,
三段各要 18 mm 根本排不下。
2. **过孔才是真瓶颈。** F1 / Q1 / D1 / C4~C6 全是贴片,要下到 Inner2 只能靠过孔;
Ø0.3 过孔单孔 1~1.5 A,扇出规划器每个焊盘给 3 个 ⇒ 一个转换点最多 3~4.5 A ≪ 8 A。
顶层反而是**零过孔的最短路径**(这条链上每一个元件都在顶层):
1 oz 外层 8 A 只要 **3.49 mm**,而那个角落横向有 25 mm 可用。
⇒ `$1N223` / `$1N227` / `$1N236` 三段一律**顶层灌铜 ≥3.5 mm**,Inner2 上那一片留给 GND 底填
(顺带给这条大电流回路铺了一整块贴身地平面,比原方案还好)。
§10.1 原文给的三个选项里,这就是「顶层灌铜 ≥3.5 mm 宽」那一条。
## 排针把 Inner2 劈成南北两半 —— 只有三条通道
J24 焊盘 x 6.97~57.03、J25 x 102.97~153.03,两排 y 61.2 / 63.8,Ø1.8 焊盘 + 0.2 净距
⇒ 同排相邻反焊盘之间只剩 **0.34 mm**、两排之间 **0.4 mm** 的铜网。数值上没断,电气上当断路看。
| 通道 | x | 净宽 | 内层 0.5 oz 能带 |
|---|---|---|---|
| 左 | 0.5 ~ 6.7 | 6.2 mm | 3.7 A(但北端被 J1 / C2 的通孔掐到 2.8~3.6 mm)|
| **中(主干道)** | 57.3 ~ 102.7 | **45.4 mm** | — |
| 右 | 153.3 ~ 159.5 | 6.2 mm | 3.7 A |
**南边要 5.6 A**(CH1 + CH4 + CH5 各 1.6 A,加左右四个风扇约 0.4+0.4 A)。
分法:**中桥喂 CH1 + CH4 + 左风扇 = 3.6 A,右通道喂 CH5 + 右风扇 = 2.0 A。**
> ⚠️ 2026-09-07 FAN-R2:五个 3 针风扇座删成两个 4 针,而且挪到了**北边两个上角**,
> 供电也从 `+12V` 改成 Q7 开关之后的 `FAN_12V_SW`。所以**南边现在只有 4.8 A**
> (中桥 3.2 / 右通道 1.6),风扇那 1.34 A 全部走北带经 Q7。上面两个 3.6 / 2.0 的
> 判据没改,现在只是更保守,不用重算。
左通道不用 —— 它的北端要从 J1 (6.0,74) 和 C2.1 (4.8,82) 两个通孔旁边挤过去,
只剩 2.8 mm、带 2.06 A,够不上一路通道的量,而且那一片正好在 8 A 输入链的顶层大铜下面,
留成 GND 底填**比塞一条 12V 细带有用得多**。
## ⚠️ 一条绕不过去的结论:`+3V3` 的南部必然被 `+12V` 中桥切成两半
中桥必须从北(y≥88)穿到南(y≤34),也就是**纵向穿过 y 41~58.5 这条带**;
而 `+3V3` 的南部焊盘(CH1/CH4 的 INA226 与上拉、控制块、CH5 的那一组)
正好横向铺满同一条带。**同一层上,两个都要连通的 2-D 区域,交叉是拓扑上避不开的。**
⇒ 让电流小的那个换层:`+3V3` 只有 0.5 A,
**南西块与南东块各自从北边的源单独引一条底层干线**(0.6 mm 就够,底层是空的),
不在 Inner2 上强行连通。这和载板 §25.10 取消两块岛是同一类判断。
## ⚠️ 顶层那三块 8 A 宽铜:和内层不是一套算法(§26.11)
`VINRAW-TOP` / `VINFUSED-TOP` / `VINSW-TOP`。网名是**自动命名网**,
文件开头 `resolve_auto()` 按「位号.引脚」当场反查,**没有写死**。三条要记住:
1. **外层挡路的东西比内层多。** 内层只怕通孔和过孔(贴片焊盘对内层完全透明),
外层连**贴片焊盘和本层走线**一起挡。`Grid` 里 `outer` 那个分支就是干这个的,
障碍来自 `obstacles()`(过孔全层、走线只挡本层)。
`snapshot()` 发现缓存里没有 `via`/`line` 就强制重抓 —— 拿旧缓存等于按
「板上没有任何过孔和走线」去算顶层铜。
2. **栅格校核不够,还要 `check_top_clearance()` 量真实几何。**
0.25 mm 的格子漏得掉 0.1 mm 的侵入(实测栽过:Rs6.2 那条 1 mm 宽的 +12V 出线,
**圆头端帽比端点还往外探 0.5 mm**,真实净距只有 0.101 mm,栅格全绿)。
侵入了 EDA 会自己缩铜,DRC 不报 —— 但板上那块铜就不是这里写的那块了。
3. **`VIN_SW` 是一个铺铜对象,不是几块拼的。** 同层同网重叠时低优先级那块会被裁,
裁的时候留不留净距 API 没说;留了就是一条看不见的缝,而且连接性 DRC 报不出来。
代价是要手写 24 个点 ⇒ 区域里带 `rects=[...]`,`poly_vs_rects()` 逐格对照。
## 已知要靠布线(不进平面)的焊盘,都是有意的
| 焊盘 | 为什么 |
|---|---|
| `U13.8` / `U13.9`(+12V)| **Kelvin 脚**。§10.4:U8~U13 的取样脚一律不进铜面,5 mΩ 分流器满量程才 20 mV |
| `C11.1`(+3V3)| 陪着 U13 一起被挖出去 |
| `C739.1`(+3V3)| 一颗去耦电容,孤零零落在风扇块的 +12V 指里;走 11 mm 顶层线到 U33.10 |
| CH2 / CH3 的 10 个 `+3V3` | 在北边,各 50 mA;沿 y≈84 的空带走顶层线到中间那块 |
| 北左辅助 Buck 那一窝 | U19 / L2 / C7~C10 / R2~R6 / Q3,12V 与 3V3 在 16×24 mm 里交错,整窝走顶层 |
"""
import io
import json
import math
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
PARTS = os.path.join(HERE, 'pcb1-parts.json')
PLACE = os.path.join(HERE, '_pcb1_place_now.json') # 元件位姿快照(--refresh 抓)
# 8 A 输入链三段是**自动命名网**(`$1N223` 一类),网名会随「从原理图导入变更」变。
# ⚠️ 别把网名写死在这里 —— `pcb1_rules.resolve_auto()` 按「位号.引脚」当场反查,
# 并逐条断言两端同网、互不重复;对不上就直接退出,不下发(那份文件头第 4 条)。
from pcb1_rules import resolve_auto
AUTO = resolve_auto()
VIN_RAW, VIN_FUSED, VIN_SW = AUTO['VIN_RAW'], AUTO['VIN_FUSED'], AUTO['VIN_SW']
CHAIN = ((VIN_RAW, 'VIN_RAW'), (VIN_FUSED, 'VIN_FUSED'), (VIN_SW, 'VIN_SW'))
MIL = 39.3700787401575
# ⚠️⚠️ 2026-09-08:这里原来写的是 `BW, BH = 160.0, 125.0`,栅格从 (0,0) 起索引 ——
# 那是**过期的假板框**,和 `pcb1_place.py` 当年那条是同一个病(它已在第三十轮改掉)。
# 真板框是 **220 × 135、左下角 (−30, −5)**(`pcb1_outline.py` 就是这么画的)。
# 后果:**本地校核看不见 x<0 / y<0 那两条边**,落在那里的铜一律算成 0 ——
# 板上的铜不受影响(多边形是原样下发给 EasyEDA 的、由它去填),
# 但**任何摆到负坐标的东西,断面/连通性校核都会静悄悄地给出 0**。
# 第三十四轮把输入首级西移到 x −17~2 之后当场撞上:三块顶层铜全报「碎成 0 块」。
BX0, BY0 = -30.0, -5.0 # 板框左下角
BW, BH = 220.0, 135.0 # 板框尺寸
INSET = 0.5 # 铜面离板边
CLEAR = 0.2 # 安全间距(pcb1_rules.MIN_CLEARANCE)
L_TOP, L_GND, L_PWR, L_BOT = 1, 15, 16, 2
# ESP32 天线禁铺(全层)。和 pcb_fanout.ANT 是同一个框,改一处要改两处 —— 这里直接引用。
try:
from pcb_fanout import ANT
except Exception:
ANT = (78.0, 0.8, 98.0, 8.9)
# ── 载流:IPC-2221 ─────────────────────────────────────────────────────
# 外层 1 oz:k=0.048、t=1.37 mil;内层 0.5 oz:k=0.024、t=0.685 mil
def amps(width_mm, inner=True, dT=20.0):
u"""这么宽的铜能带多少安(ΔT=20 °C)。"""
k, t = (0.024, 0.685) if inner else (0.048, 1.37)
a = width_mm / 0.0254 * t # mil²
return k * (dT ** 0.44) * (a ** 0.725)
def width_for(i_amp, inner=True, dT=20.0):
u"""带这么多安需要多宽(mm)。"""
k, t = (0.024, 0.685) if inner else (0.048, 1.37)
a = (i_amp / (k * dT ** 0.44)) ** (1 / 0.725) # mil²
return a / t * 0.0254
def rect(x0, y0, x1, y1):
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
# ── 毫米 ↔ 栅格:**一律走这四个函数,不要再写 int(x / G)** ──────────────
# 板框左下角不是 (0,0),直接除 G 会把负坐标截成 0(见上面 BX0/BY0 那段)。
def gi(x):
return int((x - BX0) / G)
def gj(y):
return int((y - BY0) / G)
def gx(i):
return BX0 + (i + 0.5) * G
def gy(j):
return BY0 + (j + 0.5) * G
CORNER_R = 5.0
def board_poly(inset=INSET, seg=6):
u"""板框内缩 inset 之后的多边形,**四角是 R5 圆角**。
⚠️ 直接用 rect(0.5, 0.5, 159.5, 124.5) 会让底填在四个角上探出板外
(板框是 R5 圆角,不是直角)。铺铜自己会被板框裁掉,但那样一来
「铺出来的和这里写的不一样」,后面的断面校核就失去意义了。
"""
r = CORNER_R - inset
pts = []
for cx, cy, a0 in ((BX0 + BW - CORNER_R, BY0 + CORNER_R, -90.0),
(BX0 + BW - CORNER_R, BY0 + BH - CORNER_R, 0.0),
(BX0 + CORNER_R, BY0 + BH - CORNER_R, 90.0),
(BX0 + CORNER_R, BY0 + CORNER_R, 180.0)):
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)))
return pts
# ══════════════════ Inner2(L3)分割电源面 ══════════════════
# 顺序 = 铺铜优先级(数字小的先铺、占地盘)。GND 底填必须排最后。
# ⚠️ 这个顺序是 `create()` 的调用顺序,`pourPriority` 参数不生效(载板 §9j 实测)。
REGIONS = [
# ── +12V:三块(不是六块)。**每一块都必须自己含一个本网通孔焊盘** ──
# ⚠️ 第一版按几何写成六个矩形(北带 / 中桥 / 风扇指 / 南西 / 南东 / 右通道),
# 校核当场抓出来:`12V-RC`(右通道)里一个 +12V 焊盘都没有。
# 铺铜的孤岛判定是**按铺铜对象**算的,不是按网络 —— 哪怕它和北带紧挨着,
# 自己那一块里没有本网焊盘/过孔就会被整块删光(边框还在、nPoured = 0,
# 载板 `pcb2_pour.py` 文件头第 4 条)。
# ⇒ 把中桥并进南西、右通道并进南东、风扇指并进北带,凑成三个 L / T 形多边形,
# 每块各自含 J28.1 / J26.1+J27.1 / J29.1+J30.1,**不依赖扇出就活得下来**。
# ⚠️⚠️ **2026-09-07 FAN-R2 之后上面这句不再成立**:J28/J29/J30 删了,J26/J27 挪到两个上角
# 而且 pin2 已经从 `+12V` 改成开关后的 `FAN_12V_SW`(经 Q7)——
# 三块 +12V 里**一个风扇座的通孔焊盘都不剩了**,现在靠 `pcb_fanout` 打的过孔活着
# (实测三块各填出 1 段,没被当孤岛删)。⇒ **重铺之前必须先扇出**,
# 否则会退回「边框还在、nPoured=0」那个状态。整板重布时把这三块各自含一个本网
# 通孔焊盘的条件重新落实一次,别再依赖扇出的运气。
dict(net='+12V', layer=L_PWR, name='12V-N', poly=[
# 北大块 y 88~124.5 通板 + 风扇指下伸到 y 66.5,**挖掉 U13 那一角**(Kelvin 脚,§10.4)
(0.5, 88.0), (96.5, 88.0), (96.5, 66.5), (112.0, 66.5), (112.0, 88.0),
(159.5, 88.0), (159.5, 124.5), (0.5, 124.5),
(0.5, 102.4), (12.5, 102.4), (12.5, 95.6), (0.5, 95.6)],
note=u'北带(CH2/CH3 的 Cin、U19、U29;FAN-R2 之后还含 Q7 源极 = 风扇 1.34 A 的入口)+ 风扇指(现已空载);'
u'**源 Rs6.2 的焊盘是 (8.93~10.21, 91.77~95.23)** —— U13 那个缺口原来写成 y 93.0~101.5,'
u'正好把 Rs6.2 的中心咬掉,扇出于是把这颗 8 A 源判成「不在铜面里」一个过孔都没打(2026-09-06 抓到)。'
u'缺口收成 y 95.6~102.4,只罩住 U13 十个脚(96.08~101.92)与 C11'),
dict(net='+12V', layer=L_PWR, name='12V-SW+BR', poly=[
# 南西块 + 中桥:⌐ 形。中桥西边界 61.5 是为了躲开 R706/R707 的 0402 焊盘 (x 59.35~60.65)
# —— 那两个是**贴片**焊盘,对内层是透明的,所以下面这一段可以放心往西铺过去。
# ⚠️⚠️ **南出口 y 34~40.7 加宽到 x 54**(2026-09-06 第二十一轮)。
# 信号布线之后,中桥在 y=35 那一行被三个异网过孔的反焊盘切成 1.50 / 0.75 / 5.00 三段,
# 最长 5.00 mm → 3.1 A < 需要的 3.6 A(要 6.04 mm)。**这是布线把电源面啃窄了**,
# 不是铺铜算错。往西加宽 7.5 mm 之后那一行连续 8.75 mm。
# 只能加宽到 y 40.7 —— 再往上 y 41 起是 `3V3-SW`(x 0.8~58.5),要留 0.2 净距。
# 实测 x 52~62.5 / y 33~42 里只有 4 个过孔,且没有一个够得到 y 35 那一行。
# ⚠️ 东边界 72.0 -> 72.6:72.0 与 `3V3-SE` 的西边界 73.0 之间那 1.0 mm,
# 两边各让 0.2 之后剩 0.6,GND 底填就在里面挤出一条 **0.3 x 14.5 mm 的浮铜**
# (y 65.5~80,7.3 mm²,里面一个 GND 落脚点都没有)。
# 把 +12V 推到 72.6,缝就没了;离 3V3-SE 还有 0.4,够。
(0.5, 9.0), (72.6, 9.0), (72.6, 88.0), (61.5, 88.0), (61.5, 40.7),
(54.0, 40.7), (54.0, 34.0), (0.5, 34.0)],
note=u'CH1 + CH4 的 Cin(FAN-R2 之后南边没有风扇了);中桥穿中间通道下来,喂 3.2 A,校核仍按 3.6 A 卡'),
dict(net='+12V', layer=L_PWR, name='12V-SE+RC', poly=[
# 南东块 + 右通道:L 形。J22 只有两个无网络的壳体通孔在 x=151.9,不挡 x≥153.5
(116.0, 9.0), (159.5, 9.0), (159.5, 88.0), (153.5, 88.0), (153.5, 34.0), (116.0, 34.0)],
note=u'CH5 的 Cin(FAN-R2 之后南边没有风扇了);右通道接回北带,喂 1.6 A,校核仍按 2.0 A 卡'),
# ── +3V3:南部两块,各自从北边的源单独引底层干线(见文件头「绕不过去的结论」)──
dict(net='+3V3', layer=L_PWR, name='3V3-SW', poly=rect(0.8, 41.0, 58.5, 58.5),
note=u'CH1 / CH4 的 INA226 与上拉;西边一直铺到 x 0.8,给底层干线留落孔点'),
dict(net='+3V3', layer=L_PWR, name='3V3-SE', poly=[
# L 形:控制块(x 73~115,一直到 y 9 接 U20 的 3V3 脚)+ 南东臂(到 CH5)+ 北臂(到 U33)
(73.0, 9.0), (115.0, 9.0), (115.0, 41.0), (148.0, 41.0),
(148.0, 58.5), (96.0, 58.5), (96.0, 86.5), (73.0, 86.5)],
note=u'控制块 P3/P3B + U20 + CH5 那一组 + 北臂上的 U33;北臂顺带占住中间通道东半边'),
# ── GND 底填:**必须排最后**(优先级数字最大)──
dict(net='GND', layer=L_PWR, name='GND-FILL-L3',
poly=board_poly(),
note=u'Inner2 剩余全部。北左角那一片正好垫在 8 A 输入链的顶层大铜下面'),
# ══════════════════ Bottom(L4):五路 VOn_P 小岛 ══════════════════
# 分流电阻 → 排针只有约 9 mm,本来不必上平面;做成小岛纯粹是因为
# **排针那 3 根针是通孔**,小岛一铺就自动连上,省掉三条 4 A 的走线。
# 每块都框住「Rs*01.1 + U* 的 VIN−/VBUS 两脚 + R*19 泄放 + 排针 3 针」。
dict(net='VO1_P', layer=L_BOT, name='VO1P-ISL', poly=rect(6.5, 50.5, 27.5, 65.5),
note=u'Rs101.1 / U8.8,9 / R119.1 / J24 三针 (7.9~12.9, 63.8)'),
dict(net='VO4_P', layer=L_BOT, name='VO4P-ISL', poly=rect(41.5, 50.5, 59.5, 63.0),
note=u'Rs401.1 / U11 / R419.1 / J24 三针 (43.4~48.5, 61.2)'),
dict(net='VO2_P', layer=L_BOT, name='VO2P-ISL', poly=rect(40.5, 62.0, 58.5, 74.5),
note=u'Rs201.1 / U9.8,9 / R219.1 / J24 三针 (51.0~56.1, 63.8)'),
dict(net='VO5_P', layer=L_BOT, name='VO5P-ISL', poly=rect(121.0, 50.5, 139.0, 63.0),
note=u'Rs501.1 / U12 / R519.1 / J25 三针 (124.2~129.3, 61.2)'),
dict(net='VO3_P', layer=L_BOT, name='VO3P-ISL', poly=rect(128.5, 62.0, 146.5, 74.5),
note=u'Rs301.1 / U10.8,9 / R319.1 / J25 三针 (139.4~144.5, 63.8)'),
# ══════════════════ Inner1(L2)完整地平面 ══════════════════
dict(net='GND', layer=L_GND, name='GND-L2',
poly=board_poly(),
note=u'§10.1「不许被割断」。只挖 ESP32 天线区(下面 check_ant 单独核)'),
# ══════════════════ Top(L1):8 A 输入链的宽铜(§26.8)══════════════════
# 这三段**不上 Inner2**,是有意的:8 A 在 0.5 oz 内层要 18.2 mm,北左角只有 22 mm 高;
# 而链上每个元件都在顶层 ⇒ 顶层是**零过孔的最短路径**,外层 1 oz 只要 3.49 mm。
# 网名是自动命名网,上面 resolve_auto() 当场反查出来的,**没有写死**。
#
# ⚠️ 顶层和内层挡路的东西不一样(Grid 的表):这一片里 J1/C2/C3 的通孔、
# D1/C4/C5/C6/R1 的 GND 贴片焊盘、以及扇出给它们打的 9 个 GND 过孔 + 短线,
# **每一样都要躲**。下面每一条边界后面括号里写的就是它躲的那个东西。
# ⚠️⚠️ 2026-09-08 第三十四轮**整段重画**:输入首级(J1/F1/Q1/D1 + 栅极卫星)西移了 30 mm
# (J1 换圆形 DC 座并挪到真西板边)。旧的那一版坐标写在 x 11.9~22.5,
# 那一片现在**一个本网焊盘都没有** —— 铺出来是孤岛、会被整块删掉,
# 而断面校核量到的是空气(报 0.00 mm)。这是本文件第二次踩同一条,
# 第一次是第二十三轮(注释还在下面 CUTS 里)。⇒ **摆位一改就要回来重画这三块。**
#
# 新链路是**单向**的,比旧版顺:J1(−18.8) → F1(−12.5) → Q1(−6) → D1(1) → 东北去 C2/C3/Rs6。
# 旧版是 J1 一路向东到 Q1、再折回西边找 Rs6,多绕了一圈。
dict(net=VIN_RAW, layer=L_TOP, name='VINRAW-TOP',
poly=[(-17.0, 70.7), (-11.5, 70.7), (-11.5, 73.80),
(-13.65, 73.80), (-13.65, 76.5), (-17.0, 76.5)],
note=u'J1.1(−15.80,74.35,中心正极,通孔盘 1.6×3.8) → F1.1(−12.50,73.06)。'
u'南边 70.7 躲 J1.3 的 GND 通孔盘(顶 70.450);'
u'东边 −11.5 刚好盖住 F1.1(东 −11.637),离 Q1 源极三脚(西 −9.786) 还有 1.7;'
u'x −13.65 那级台阶是躲 F1.2(西 −13.364, 南 75.350) —— 台阶以东只铺到 73.80,'
u'盖住 F1.1(顶 73.651) 并给 VIN_FUSED 让 0.30。'
u'**载流断面在 J1.1 与 F1.1 之间**(x≈−14.2),那里是满高 5.8 mm;'
u'台阶以东那 3.10 mm 不走穿过电流,F1.1 就是终点。'
u'⚠️ 两块的**竖直边**也要拉开:VINRAW 东界 −11.5 与 VINFUSED 的台阶 −11.15 '
u'差 0.35 —— 第一版这两条边只隔 0.20(正好压在下限上),是校核报出来的'),
dict(net=VIN_FUSED, layer=L_TOP, name='VINFUSED-TOP',
poly=[(-13.4, 74.10), (-11.15, 74.10), (-11.15, 73.3),
(-7.5, 73.3), (-7.5, 77.8), (-13.4, 77.8)],
note=u'F1.2(−12.50,75.95) → Q1.1~3(源极三脚, x −9.786~−7.751)。'
u'南边两段:西半 74.10 同时躲 F1.1(顶 73.651) 和 VINRAW 的台阶(顶 73.80,让 0.30),'
u'东半 73.3 躲 Q1.4 栅极脚(顶 72.882) 并盖住 Q1.3(底 73.578);'
u'东边 −7.5 离 Q1 漏极四脚(西 −4.250) 还有 3.25。'
u'断面在 F1.2 与源极之间(x≈−10.7)= 4.5 mm。'
u'⚠️ DZ1.1/C1.1 这两个栅极钳位件同属本网但在 y≈68,**不进这块铜** ——'
u'µA 级,交给 pcb1_route_sig 拉一条细线'),
# ⚠ `VIN_SW` 必须是**一个铺铜对象**,不能拆成几块靠互相重叠连起来。
# 同层同网两块铜重叠时,低优先级那块会被高优先级那块裁掉 —— 裁的时候
# 会不会留 0.2 mm 净距,API 文档没说。留了的话干道与漏极块之间就是一条
# 看不见的缝,而**连通性 DRC 也报不出来**(两边都还连在各自的焊盘上)。
# ⇒ 宁可手写 18 个点。下面的 `rects` 是这 18 个点该等于的矩形并集,
# `report()` 里逐格对照,写错一个点当场就报(check_poly_rects)。
dict(net=VIN_SW, layer=L_TOP, name='VINSW-TOP',
poly=[(-4.45, 72.10), (-0.30, 72.10), (-0.30, 73.40), (2.40, 73.40),
(2.40, 78.85), (30.46, 78.85), (30.46, 86.47), (28.6, 86.47),
(28.6, 83.49), (16.72, 83.49), (16.72, 85.5), (12.5, 85.5),
(12.5, 83.49), (6.65, 83.49), (6.65, 87.0), (5.3, 87.0),
(5.3, 95.25), (1.5, 95.25), (1.5, 83.49), (-4.45, 83.49)],
rects=[(-4.45, 72.10, -0.30, 78.85), # Q1 漏极四脚(x −4.250~−2.215)。南边 72.10 躲 R1 两脚(顶 71.772),留 0.328
(-0.30, 73.40, 2.40, 78.85), # D1.1 TVS(−0.096~2.096, 75.838~77.885)。
# 南边抬到 73.40 是躲 D1.2 的 GND 盘(顶 73.163) 和它正下方 6 个扇出孔
(-4.45, 78.85, 28.6, 83.49), # ★ 8 A 干道,y 净宽 4.64 mm。**西端由 1.5 延到 −4.45**:
# 旧版西端卡在 1.5 是因为老 J1 的机械脚 J1.4(顶 78.401) 顶着,那颗件已经挪走
(28.6, 78.85, 30.46, 86.47), # C4.2/C5.2/C6.2 去耦(东边 30.46 躲 C6.1 的 GND 盘 30.670)
(12.5, 83.49, 16.72, 85.5), # C3.1 口袋(东边 16.72 躲 C3.2 GND 通孔 16.950)
(1.5, 83.49, 6.65, 87.0), # C2.1 口袋(净宽 5.15 mm)
(1.5, 87.0, 5.3, 95.25)], # 北柱到 Rs6.1(净宽 **3.8 mm** = 8.5 A)
# ⚠️⚠️ **北柱在 y=87 以上收窄到 x 1.5~5.3(3.8 mm)是给 U13 让路的**(第二十三轮):
# U13 南排五个脚 (x 5.36~7.64, y 96.08) 只能往南出线,而原来的柱子铺到 x 6.65、
# 顶到 y 95.4 —— 离 U13.2 只剩 0.379 mm,`pcb1_escape_audit` 报「被围死」。
# 收窄之后 U13 南边是一整片 x 5.5~14 / y 91.6~96 的空地。
# (Rs6.2 那两根往西甩的 1 mm 扇出短线也一起没了 —— 见 pcb_fanout.FORCE_ARRAY)
note=u'**8 A 主干**:Q1 漏极(−3.2) → 东经 D1 → 北上 → **东行**干道 → C2.1/C3.1 → 北柱 → Rs6.1,'
u'一路无过孔;东端挂 C4/C5/C6 去耦。'
u'⚠️ 第三十四轮之后干道是**东行**不是西行 —— 源头 Q1 从 x 24 挪到了 x −6,'
u'而 C2/C3/Rs6/C4~C6 一颗没动,于是电流方向整个翻过来了。'
u'旧注释「干道比 v10 北提 2.8 mm 躲 XT30 机械脚」已作废:那颗 XT30 已经不在这块板上'),
]
# ── 断面校核:每一条都是「这里必须能过多少安」──────────────────────────
# (名字, 网络, 层, 轴, 坐标, 跨度, 需要的电流)
# axis='y' → 沿 y=坐标 这条横线量,跨度是 x 区间
# axis='x' → 沿 x=坐标 这条竖线量,跨度是 y 区间
# ⚠️ **层必须写对**:同一个宽度,外层 1 oz 能带的电流约是内层 0.5 oz 的 4 倍。
# 把顶层那三段按内层算,8 A 会要 18.2 mm,那个角落根本排不下、结论全错。
CUTS = [
(u'中桥 @排针带', '+12V', L_PWR, 'y', 62.0, (57.3, 102.7), 3.6),
(u'中桥 @R706/R707', '+12V', L_PWR, 'y', 72.5, (57.3, 102.7), 3.6),
# 量测窗口跟着加宽的南出口往西开到 53.0(铜真的铺到了 x 54)
(u'中桥 @南出口', '+12V', L_PWR, 'y', 35.0, (53.0, 102.7), 3.6),
(u'右通道 @排针带', '+12V', L_PWR, 'y', 62.0, (153.0, 159.5), 2.0),
(u'右通道 @J22', '+12V', L_PWR, 'y', 52.0, (150.0, 159.5), 2.0),
(u'南西块 → CH1', '+12V', L_PWR, 'x', 33.0, (9.0, 34.0), 2.0),
(u'南西块 → CH4+CH1', '+12V', L_PWR, 'x', 61.0, (9.0, 34.0), 3.6),
(u'南东块 → CH5', '+12V', L_PWR, 'x', 140.0, (9.0, 34.0), 1.6),
# FAN-R2 之后风扇指下面已经没有 +12V 负载(风扇改吃开关后的 FAN_12V_SW)。
# 这一条留着当**几何回归**:北带往南这一指还在,断面别被后来的布线啃掉。
(u'风扇指 @接北带(现空载)', '+12V', L_PWR, 'y', 87.0, (94.0, 114.0), 1.0),
(u'北大块 @Rs6 东出', '+12V', L_PWR, 'x', 20.0, (86.0, 124.5), 8.0),
(u'3V3 南西块', '+3V3', L_PWR, 'x', 33.0, (41.0, 58.5), 0.5),
(u'3V3 南东块 @控制块', '+3V3', L_PWR, 'x', 95.0, (9.0, 58.5), 0.5),
(u'3V3 南东块 @北臂', '+3V3', L_PWR, 'y', 70.0, (70.0, 100.0), 0.1),
# 8 A 输入链(顶层 1 oz)。J1 → F1 → Q1 → D1/C4~C6 → C3/C2 → Rs6,分叉之前全程 8 A。
# ⚠ 这一排坐标跟着摆位走,**摆位一改就要重定**:2026-09-06 第二十三轮把
# F1 竖过来、D1 与 C4/C5/C6 挪到东列之后,旧的 `x=10` 那一刀量到的是空气(报 0.00 mm)。
# 2026-09-08:输入首级西移 30 mm,下面前四条**全部重定**。
# ⚠️ 断面要卡在「电流真正流过的那一段」,不是随便找个地方量:
# VIN_RAW 的载流段是 J1.1 与 F1.1 **之间**(x≈−14.2);台阶以东那 3.15 mm
# 是 F1.1 的落脚区、不走穿过电流,在那里量会得到一个偏严且没意义的数。
(u'RAW J1.1→F1.1', VIN_RAW, L_TOP, 'x', -14.2, (68.0, 79.0), 8.0),
(u'FUSED F1.2→Q1 源极', VIN_FUSED, L_TOP, 'x', -10.7, (70.0, 80.0), 8.0),
(u'SW Q1 漏极东出', VIN_SW, L_TOP, 'x', -1.5, (69.0, 81.0), 8.0),
(u'SW 过 D1 北上', VIN_SW, L_TOP, 'x', 2.0, (70.0, 86.0), 8.0),
(u'SW 干道 @x24 东端', VIN_SW, L_TOP, 'x', 24.0, (74.0, 86.0), 8.0),
(u'SW 干道 @x12', VIN_SW, L_TOP, 'x', 12.0, (74.0, 86.0), 8.0),
(u'SW 干道拐北 @y84', VIN_SW, L_TOP, 'y', 84.0, (0.5, 12.0), 8.0),
(u'SW 北柱 @C2 上方', VIN_SW, L_TOP, 'y', 86.0, (1.0, 12.0), 8.0),
(u'SW 北柱 @Rs6.1', VIN_SW, L_TOP, 'y', 92.0, (1.0, 12.0), 8.0),
# 支路:体电容与去耦只走纹波,不走 8 A 直流(§10.2)
(u'SW C3 口袋', VIN_SW, L_TOP, 'y', 84.5, (11.0, 18.0), 2.0),
(u'SW C4/C5/C6 那一条', VIN_SW, L_TOP, 'y', 82.0, (27.0, 34.0), 2.0),
]
# 明知故犯:这些焊盘不进自己网络的平面,理由见文件头那张表。
# 校核时它们不算「漏掉」,但会单独列出来提醒去布线。
ROUTED_PADS = {
'U13.8': u'Kelvin(VIN−),§10.4 取样脚不进铜面',
'U13.9': u'Kelvin(VIN−),§10.4 取样脚不进铜面',
# ⭐ 五路 INA226 的 IN−(pin 9):**必须单独引线到 Rs*01 的焊盘,不许进 VOn_P 小岛。**
# 2026-09-06 第十九轮实测:让它走岛的话,取样点在岛上而不是在分流电阻的焊盘上,
# 中间隔着 Rs*01 那三个扇出孔 + 引线(并联约 0.5 mΩ)。Rs 只有 5 mΩ、满载 20 mV,
# 0.5 mΩ x 4 A = 2 mV = **10% 的读数误差**,而 DRC 和连通性检查都看不出来
# (IN− 和 VO*_P 本来就是同一个网,连上了就算过)。
# ⚠️ pin 8 是 VBUS(测输出电压),照旧进岛 —— 它要的就是负载端的电位。
'U8.9': u'Kelvin(IN−):单独引线到 Rs101.1 的焊盘,不进 VO1_P 小岛',
'U9.9': u'Kelvin(IN−):单独引线到 Rs201.1 的焊盘,不进 VO2_P 小岛',
'U10.9': u'Kelvin(IN−):单独引线到 Rs301.1 的焊盘,不进 VO3_P 小岛',
'U11.9': u'Kelvin(IN−):单独引线到 Rs401.1 的焊盘,不进 VO4_P 小岛',
'U12.9': u'Kelvin(IN−):单独引线到 Rs501.1 的焊盘,不进 VO5_P 小岛',
'C11.1': u'陪 U13 一起挖出去,走顶层到 L2 输出',
'U13.6': u'INA226 的 VS+,陪 U13 一起挖出去(C11 就在旁边)',
# U23 西侧那两颗(v11 控制块退火重排之后落到 x 71.9~72.4):
# 那一条 x 是 `12V-SW+BR`(东界 72.6) 与 `3V3-SE`(西界 73.0) 中间的 0.4 mm 缝,
# 两块内层平面谁都够不着。**贴片焊盘对内层透明,所以不是错误**,走顶层短线即可:
# C606.1 挨着 U23.24(0.3 mm,去耦回路本来就该这么短),R608.1 顺着 U23 西缘上去搭 C606.1。
'C606.1': u'U23 的去耦,紧贴 U23.24(本身在 3V3-SE 上),顶层 0.3 mm 短线',
'R608.1': u'IOEXP_INT# 上拉,10 kΩ;沿 U23 西缘走 6 mm 顶层线搭到 C606.1',
# 8 A 输入链上不进宽铜的三个(都不是漏掉)
'U13.10': u'Kelvin(VIN+)。§10.4:Rs6 满量程才 40 mV,取样脚单独引细线到 Rs6.1,'
u'不能从大铜面上随便找个点接',
'DZ1.1': u'Q1 栅极钳位齐纳的上端,µA 级;夹在 F1.1 与 Q1.4 之间只有 3.1 mm,走细线',
'C1.1': u'Q1 栅极 RC 的上端,µA 级;同 DZ1.1',
# 2026-09-08 第三十四轮:两路 TACH 接收跟着风扇座搬到了东西**板边**
# (R753 在 x −25.6、R757 在 x 181,都在 y≈100),离 `3V3-SW`/`3V3-SE` 两块内层
# 平面各有 80~100 mm。**不要为这两颗把平面拉过去** —— 10 kΩ 上拉到 3.3 V
# 只有 **0.33 mA**,外层 0.2 mm 线绰绰有余,而拉一条 100 mm 的内层电源面
# 会把 Inner2 的分割搅乱。它们是「接收电路在信号源头整形」那条设计决定的代价。
'R753.2': u'进风 TACH 的 3.3V 上拉(10k, 0.33 mA),随风扇座在西板边;走顶层细线',
'R757.2': u'排风 TACH 的 3.3V 上拉(10k, 0.33 mA),随风扇座在东板边;走顶层细线',
}
# 整块「有意不进平面」的区域(位号前缀 → 说明)
ROUTED_BLOCKS = [
((14.0, 100.0, 30.0, 124.0), u'北左辅助 Buck 那一窝:12V 与 3V3 在 16×24 mm 里交错,整窝走顶层'),
((36.0, 70.0, 58.0, 84.0), u'CH2 的 +3V3(5 个,50 mA):沿 y≈84 走顶层到中间那块'),
((126.0, 70.0, 146.0, 84.0), u'CH3 的 +3V3(5 个,50 mA):同上'),
# `R518.1`(CH5 的 +3V3):2026-09-06 第二十六轮起不再落在 Inner2 的 +3V3 上 ——
# 它本来就是「短线 + 扇出孔」那一类,只是以前**碰巧**还压着平面所以没被点名。
# 实测通路:R518.1 → 顶层 0.66 mm → +3V3 扇出孔 (134.575, 51.000) → 平面,
# `pcb1_netlist` 里 +3V3 整网连通(不在「还差」名单里)。框只罩这一颗。
((135.0, 50.4, 136.0, 51.6), u'CH5 的 R518.1:顶层 0.66 mm 短线到 (134.575,51.000) 的 +3V3 扇出孔'),
]
# ── 板上元件位姿 + 焊盘 ────────────────────────────────────────────────
SNAP_JS = u"""
await eda.dmt_EditorControl.openDocument('7e9415a16a909c51');
await new Promise(r=>setTimeout(r,1500));
const MIL = 39.3700787401575;
const comp = [], th = [];
for (const c of await eda.pcb_PrimitiveComponent.getAll())
comp.push([c.designator, +(c.x/MIL).toFixed(4), +(c.y/MIL).toFixed(4),
((c.rotation%360)+360)%360, c.layer]);
for (const p of await eda.pcb_PrimitivePad.getAll()) {
const hole = Array.isArray(p.hole) ? p.hole : null;
if (!((p.layer === 12) || (hole && hole.length > 1 && +hole[1] > 0))) continue;
let w = 0, h = 0;
try { w = +p.pad[1]||0; h = +p.pad[2]||0; } catch(e) {}
th.push([p.net||'', +(p.x/MIL).toFixed(3), +(p.y/MIL).toFixed(3),
+(w/MIL).toFixed(3), +(h/MIL).toFixed(3)]);
}
// 扇出留下的过孔与短线。**顶层铺铜要躲它们**(内层只躲过孔,短线在别的层上)。
const via = [], line = [];
for (const v of await eda.pcb_PrimitiveVia.getAll())
via.push([v.net||'', +(v.x/MIL).toFixed(3), +(v.y/MIL).toFixed(3),
+((v.outerDiameter||v.diameter||0)/MIL).toFixed(3)]);
for (const l of await eda.pcb_PrimitiveLine.getAll()) {
if (l.layer === 11) continue; // 11 = 板框
line.push([l.net||'', l.layer,
+(l.startX/MIL).toFixed(3), +(l.startY/MIL).toFixed(3),
+(l.endX/MIL).toFixed(3), +(l.endY/MIL).toFixed(3),
+(l.lineWidth/MIL).toFixed(3)]);
}
return {comp, th, via, line};
"""
def snapshot(refresh=False):
# 缓存里没有 via/line 那一段 = 是加顶层铜面之前抓的,必须重抓,
# 否则顶层铺铜会按「板上没有任何过孔和走线」去算 —— 那正是要防的静默失效。
if not (refresh or not os.path.exists(PLACE)):
try:
if 'via' not in json.load(io.open(PLACE, encoding='utf-8')):
refresh = True
except Exception:
refresh = True
if refresh or not os.path.exists(PLACE):
from eda import run
r = run(SNAP_JS, timeout=300)
if not r.get('success'):
sys.exit(json.dumps(r, ensure_ascii=False)[:400])
io.open(PLACE, 'w', encoding='utf-8').write(
json.dumps(r['result'], ensure_ascii=False))
return json.load(io.open(PLACE, encoding='utf-8'))
def load_pads(refresh=False):
u"""返回 [net, x, y, hx, hy, th, tag],坐标 mm、hx/hy 是焊盘半宽半高。
⚠️ `th` 这一位是整件事的关键:**只有通孔焊盘才在内层挖反焊盘**,
贴片焊盘对内层完全透明。按「所有焊盘都挡内层」去算,
这块板 1200 多个贴片焊盘会把 Inner2 算成一张网,结论全错。
"""
snap = snapshot(refresh)
comp = {c[0]: c for c in snap['comp']}
parts = json.load(io.open(PARTS, encoding='utf-8'))
out = []
for p in parts:
c = comp.get(p['d'])
if not c:
continue
_, cx, cy, rot, lay = c
rad = math.radians(rot)
ct, st = math.cos(rad), math.sin(rad)
for num, lx, ly, w, h, net in p['pads']:
lx, ly, w, h = lx / MIL, ly / MIL, w / MIL, h / MIL
if lay == 2:
ly = -ly
x = cx + lx * ct - ly * st
y = cy + lx * st + ly * ct
if rot in (90, 270):
w, h = h, w
out.append([net or '', x, y, w / 2.0, h / 2.0, False,
'%s.%s' % (p['d'], num), lay])
# 通孔标记:拿板上实测的通孔表最近邻配(坐标各自舍入过,容差 0.05)
n = 0
for net, x, y, w, h in snap['th']:
best, bd = None, 0.05
for q in out:
if q[0] != net or q[5]:
continue
d = abs(q[1] - x) + abs(q[2] - y)
if d < bd:
best, bd = q, d
if best is not None:
best[5] = True
best[3] = max(best[3], w / 2.0)
best[4] = max(best[4], h / 2.0)
n += 1
return out, n, len(snap['th'])
def obstacles(refresh=False):
u"""板上已有的铜:扇出的过孔(全层)+ 每一层的走线。
返回 [(net, layer_or_None, x0, y0, x1, y1)],`layer_or_None is None` = 过孔,全层都挡。
⚠️ 斜线按**包围盒**处理(偏保守,会多挖一点)。扇出的短线全是正交的,
真正的斜线要等信号布线,那时这块顶层铜早就在了。
"""
snap = snapshot(refresh)
obs = []
for net, x, y, od in snap.get('via', ()):
r = (od or 0.61) / 2.0
obs.append((net or '', None, x - r, y - r, x + r, y + r))
for net, lay, x1, y1, x2, y2, w in snap.get('line', ()):
h = (w or 0.0) / 2.0
obs.append((net or '', lay, min(x1, x2) - h, min(y1, y2) - h,
max(x1, x2) + h, max(y1, y2) + h))
return obs
def line_segs(refresh=False):
u"""板上走线的**真实线段**(不折成包围盒):[(net, layer, x0, y0, x1, y1, w)]。"""
snap = snapshot(refresh)
return [(net or '', lay, x1, y1, x2, y2, w or 0.0)
for net, lay, x1, y1, x2, y2, w in snap.get('line', ())]
def inside(poly, x, y):
n, c = len(poly), False
for i in range(n):
x1, y1 = poly[i]
x2, y2 = poly[(i + 1) % n]
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
c = not c
return c
# ══════════════════ 栅格化 + 校核 ══════════════════
G = 0.25 # 栅格(mm)。0.25 下 45 mm 的通道是 180 格,够细
#: 浮铜检查的**最小面积**(mm²)。小于它的当栅格碎屑放过,只打印不判红。
#:
#: ⚠️ 这不是"把检查关小声点",是给它一个说得出理由的下限:布线一多,
#: 过孔的反焊盘之间必然夹出 0.25×0.25(**正好一个栅格**)这种碎屑 ——
#: 2026-09-06 第二十五轮实测两粒:Inner1 (68.9,74.6) 0.06 mm²、Inner2 (138.5,75.2) 0.19 mm²,
#: 都比一个过孔盘还小,电气上没有意义,而且嘉立创铺铜自己也会按死铜阈值清掉。
#: 门槛定在 1.0 mm²(1×1 mm):这一轮真正抓到的两条 —— 南沿那条 **1312.8 mm²** 的
#: GND 底填、`3V3-SW` 里那块 **7.2 mm²** 的孤岛 —— 都远在门槛之上,一条都不会漏。
MIN_FLOAT_AREA = 1.0
class Grid(object):
u"""某一层上某个网络的铜:先按区域填,再挖异网的反焊盘 / 过孔 / 走线。
⚠️ **内层和外层挡路的东西不是同一批**,这是整件事最容易算错的地方:
| | 通孔焊盘 | 贴片焊盘 | 过孔 | 走线 |
|---|---|---|---|---|
| 内层 Inner1/2 | 挡 | **不挡**(对内层完全透明)| 挡 | 挡本层的(内层现在没有走线)|
| 外层 Top/Bottom | 挡 | **挡** | 挡 | 挡本层的 |
按「所有焊盘都挡内层」算,这块板 1200 多个贴片焊盘会把 Inner2 算成一张网;
反过来按「只有通孔挡」去算顶层,8 A 那块宽铜会算得比实际宽得多。
"""
def __init__(self, net, layer, regions, pads, obs=()):
self.net, self.layer = net, layer
outer = layer in (L_TOP, L_BOT)
self.nx, self.ny = int(BW / G), int(BH / G)
self.c = [[False] * self.nx for _ in range(self.ny)]
for r in regions:
xs = [p[0] for p in r['poly']]
ys = [p[1] for p in r['poly']]
i0, i1 = gi(min(xs)), gi(max(xs)) + 1
j0, j1 = gj(min(ys)), gj(max(ys)) + 1
for j in range(max(0, j0), min(self.ny, j1)):
for i in range(max(0, i0), min(self.nx, i1)):
if not self.c[j][i] and inside(r['poly'], gx(i), gy(j)):
self.c[j][i] = True
# 反焊盘:异网焊盘(内层只算通孔,外层连贴片一起算)
self.carved = 0
for pnet, px, py, hx, hy, th, tag, plane in pads:
if pnet == net:
continue
if not (th or (outer and plane == layer)):
continue
if self._carve(px - hx - CLEAR, py - hy - CLEAR,
px + hx + CLEAR, py + hy + CLEAR):
self.carved += 1
# 板上已有的铜:过孔(全层)+ 本层走线
self.carved_obs = 0
for onet, olay, x0, y0, x1, y1 in obs:
if onet == net or (olay is not None and olay != layer):
continue
if self._carve(x0 - CLEAR, y0 - CLEAR, x1 + CLEAR, y1 + CLEAR):
self.carved_obs += 1
# 天线禁铺(全层)
for j in range(self.ny):
for i in range(self.nx):
if self.c[j][i] and ANT[0] <= gx(i) <= ANT[2] \
and ANT[1] <= gy(j) <= ANT[3]:
self.c[j][i] = False
self.label()
def _carve(self, x0, y0, x1, y1):
u"""挖掉一个矩形(已经把净距算进去了)。返回有没有真挖到东西。"""
hit = False
for j in range(max(0, gj(y0)), min(self.ny, gj(y1) + 1)):
yc = gy(j)
if not (y0 <= yc <= y1):
continue
row = self.c[j]
for i in range(max(0, gi(x0)), min(self.nx, gi(x1) + 1)):
if row[i] and x0 <= gx(i) <= x1:
row[i] = False
hit = True
return hit
def label(self):
self.lab = [[0] * self.nx for _ in range(self.ny)]
self.sizes = {}
k = 0
for j in range(self.ny):
for i in range(self.nx):
if not self.c[j][i] or self.lab[j][i]:
continue
k += 1
st = [(j, i)]
self.lab[j][i] = k
n = 0
while st:
a, b = st.pop()
n += 1
for da, db in ((1, 0), (-1, 0), (0, 1), (0, -1)):
p, q = a + da, b + db
if 0 <= p < self.ny and 0 <= q < self.nx \
and self.c[p][q] and not self.lab[p][q]:
self.lab[p][q] = k
st.append((p, q))
self.sizes[k] = n
self.ncomp = k
self.main = max(self.sizes, key=self.sizes.get) if self.sizes else 0
def cut(self, axis, coord, span):
u"""沿一条线量本网铜的总宽与最长连续段(mm)。"""
tot = run_max = run = 0
if axis == 'y':
j = gj(coord)
for i in range(gi(span[0]), gi(span[1]) + 1):
v = 0 <= i < self.nx and 0 <= j < self.ny and self.c[j][i]
tot += 1 if v else 0
run = run + 1 if v else 0
run_max = max(run_max, run)
else:
i = gi(coord)
for j in range(gj(span[0]), gj(span[1]) + 1):
v = 0 <= i < self.nx and 0 <= j < self.ny and self.c[j][i]
tot += 1 if v else 0
run = run + 1 if v else 0
run_max = max(run_max, run)
return tot * G, run_max * G
def hit(self, x, y, hx, hy):
u"""这个焊盘有没有落在本网主连通域上(焊盘框内任一格)。"""
i0, i1 = gi(x - hx), gi(x + hx) + 1
j0, j1 = gj(y - hy), gj(y + hy) + 1
for j in range(max(0, j0), min(self.ny, j1 + 1)):
for i in range(max(0, i0), min(self.nx, i1 + 1)):
if self.lab[j][i]:
return self.lab[j][i]
return 0
# ══════════════════ 报告 ══════════════════
def in_blocks(x, y):
for (bx0, by0, bx1, by1), why in ROUTED_BLOCKS:
if bx0 <= x <= bx1 and by0 <= y <= by1:
return why
return None
def grid_checks(G_, pads, chk):
u"""连通域 / 断面 / 焊盘覆盖 —— **下发前按 REGIONS 算、下发后按实测铜算,走的是同一段代码**。
⚠️ 这三条必须共用一份实现。分成两套写,「预测过了、实测也过了」就只证明
两套代码各自自洽,证明不了板上那块铜真的是计划里那块铜。
"""
# 1) +12V 必须是一整块
g12 = G_[('+12V', L_PWR)]
chk(g12.ncomp == 1, u'+12V 在 Inner2 上是**一个**连通域(六块矩形首尾相接)',
u'+12V 在 Inner2 上碎成 %d 块 —— 六块矩形没接上' % g12.ncomp)
# 2) +3V3 预期 2 块(南西 / 南东),文件头解释过为什么不强行连通
g33 = G_[('+3V3', L_PWR)]
chk(g33.ncomp == 2, u'+3V3 在 Inner2 上是 2 个连通域(南西 / 南东,各自从北边引底层干线)',
u'+3V3 在 Inner2 上是 %d 个连通域,预期 2' % g33.ncomp)
# 2b) 8 A 输入链三段:每一段顶层都必须是**一整块**。
# `VIN_SW` 是四个铺铜对象拼出来的(干道 / 漏极块 / 电容条 / C3 口袋),
# 它们靠互相重叠连成一片 —— 碎成两块就说明某处重叠被反焊盘啃穿了。
for net, nm in CHAIN:
g = G_.get((net, L_TOP))
if g is None:
continue
chk(g.ncomp == 1, u'%s(%s) 顶层是一整块铜' % (nm, net),
u'%s(%s) 顶层碎成 %d 块(碎片 %s mm²)'
% (nm, net, g.ncomp,
' '.join('%.1f' % (c * G * G)
for c in sorted(g.sizes.values(), reverse=True)[1:5])))
# 3) 断面
print()
print(u'── 断面校核(内层 0.5 oz / 顶层 1 oz)──')
for name, net, layer, axis, coord, span, need in CUTS:
g = G_.get((net, layer))
if g is None:
continue
inner = layer not in (L_TOP, L_BOT)
tot, run = g.cut(axis, coord, span)
cap = amps(run, inner=inner) if run > 0 else 0.0
need_w = width_for(need, inner=inner)
ok = run >= need_w
lay = {1: 'Top', 2: 'Bot', 15: 'L2', 16: 'L3'}[layer]
chk(ok, u'%-4s %-18s %s=%-6.1f 连续 %5.2f mm(总 %5.2f)→ %4.1f A ≥ 需要 %.1f A'
% (lay, name, axis, coord, run, tot, cap, need),
u'%-4s %-18s %s=%-6.1f 连续只有 %5.2f mm(总 %5.2f)→ %4.1f A < 需要 %.1f A(要 %.2f mm)'
% (lay, name, axis, coord, run, tot, cap, need, need_w))
# 4) 本网焊盘覆盖
print()
print(u'── 焊盘覆盖(每个电源焊盘是不是落在自己的铜上)──')
for net, layer in ([('+12V', L_PWR), ('+3V3', L_PWR)]
+ [(n, L_TOP) for n, _nm in CHAIN]):
g = G_.get((net, layer))
if g is None:
continue
own = [p for p in pads if p[0] == net]
miss, excused = [], []
for pnet, x, y, hx, hy, th, tag, plane in own:
if g.hit(x, y, hx, hy):
continue
if tag in ROUTED_PADS:
excused.append((tag, ROUTED_PADS[tag]))
else:
why = in_blocks(x, y)
(excused if why else miss).append((tag, why or ''))
print(u' %-4s %-8s %3d 个焊盘:落在铜上 %3d / 有意走线 %2d / **没着落 %d**'
% ({1: 'Top', 2: 'Bot', 15: 'L2', 16: 'L3'}[layer], net, len(own),
len(own) - len(miss) - len(excused), len(excused), len(miss)))
for tag, why in sorted(excused):
print(u' · %-9s 走线:%s' % (tag, why))
if miss:
for tag, _ in sorted(miss)[:20]:
print(u' [!!] %s 既不在平面上,也不在「有意走线」名单里' % tag)
chk(False, '', u'%s 有 %d 个焊盘没着落' % (net, len(miss)))
def poly_vs_rects(poly, rects, g=0.05):
u"""手写的多边形是不是恰好等于那一堆矩形的并集。
手写 24 个点的复杂多边形,写错一个坐标**几何检查全都发现不了**:
断面照样够宽、连通域照样是 1、净距照样过 —— 错的地方在别处,
比如某条边界往里缩了 2 mm,正好没被任何一条断面线量到。
⇒ 用一张 0.05 mm 的细格逐格对照,返回 (只在多边形里, 只在矩形并集里) 的格数。
"""
xs = [p[0] for p in poly] + [r[0] for r in rects] + [r[2] for r in rects]
ys = [p[1] for p in poly] + [r[1] for r in rects] + [r[3] for r in rects]
x0, x1, y0, y1 = min(xs) - g, max(xs) + g, min(ys) - g, max(ys) + g
only_p = only_r = 0
j = 0
y = y0 + g / 2
while y < y1:
x = x0 + g / 2
while x < x1:
a = inside(poly, x, y)
b = any(rx0 <= x <= rx1 and ry0 <= y <= ry1
for rx0, ry0, rx1, ry1 in rects)
if a and not b:
only_p += 1
elif b and not a:
only_r += 1
x += g
y += g
j += 1
return only_p, only_r
def poly_rect_gap(poly, x0, y0, x1, y1):
u"""多边形到一个轴对齐矩形的最小距离;矩形碰到 / 进到多边形里就是 0。"""
box = [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
return poly_gap(poly, box)
def poly_seg_gap(poly, x0, y0, x1, y1, w):
u"""多边形到一条**线段胶囊**(宽 w,圆头端帽)的最小距离。
⚠️⚠️ **斜线不能按包围盒算。** `obstacles()` 为了省事把每条走线折成轴对齐包围盒
(那时板上只有扇出的正交短线,无所谓)。信号布线一上来,板上有两千多条 45° 斜线,
一条长斜线的包围盒必定罩住半块顶层铜 ⇒ `check_top_clearance` 报「侵入 0.000 mm」,
而真实净距其实好几毫米。2026-09-06 第二十一轮实测被这个假阳性拦下过一次重铺。
"""
if inside(poly, x0, y0) or inside(poly, x1, y1):
return 0.0
best = 9e9
for i in range(len(poly)):
best = min(best, _seg_gap(poly[i], poly[(i + 1) % len(poly)],
(x0, y0), (x1, y1)))
return max(0.0, best - w / 2.0)
def check_top_clearance(chk, obs, pads, segs=()):
u"""顶层那三块**手画的**铜,离每一个异网的铜必须都 ≥ 0.2 mm。
⚠️ 这一条和栅格校核**不是一回事,也替代不了**。栅格是 0.25 mm 一格,
一处 0.1 mm 的侵入很可能一个格心都不落进去 —— 栅格全绿,铺出来 EDA 自己
往回缩,于是「板上那块铜」和「这里写的那块铜」不再是同一块,
后面所有断面数字就都不作数了。这里量的是**真实几何**,不过格子。
只对顶层这么严。内层平面本来就是罩住一堆异网通孔、靠反焊盘活着的,
那里的「挖」是设计意图,不是侵入。
"""
tops = [r for r in REGIONS if r['layer'] == L_TOP]
boxes = []
for pnet, x, y, hx, hy, th, tag, plane in pads:
if th or plane == L_TOP:
boxes.append((pnet, tag, x - hx, y - hy, x + hx, y + hy))
for onet, olay, a, b, c, d in obs:
if olay is None: # 过孔:包围盒就是它自己,够准
boxes.append((onet, u'过孔', a, b, c, d))
# 走线一律按**真实胶囊**算(见 `poly_seg_gap` 的注释)
caps = [(l[0], l[2], l[3], l[4], l[5], l[6]) for l in segs if l[1] == L_TOP]
for r in tops:
worst, hits = (9e9, ''), []
for lnet, lx0, ly0, lx1, ly1, lw in caps:
if lnet == r['net']:
continue
g = poly_seg_gap(r['poly'], lx0, ly0, lx1, ly1, lw)
if g < worst[0]:
worst = (g, u'走线(%s)' % lnet)
if g < CLEAR - 1e-6:
hits.append((g, u'走线', lnet))
for bnet, tag, a, b, c, d in boxes:
if bnet == r['net']:
continue
if a > max(p[0] for p in r['poly']) + 2 or c < min(p[0] for p in r['poly']) - 2 \
or b > max(p[1] for p in r['poly']) + 2 or d < min(p[1] for p in r['poly']) - 2:
continue
g = poly_rect_gap(r['poly'], a, b, c, d)
if g < worst[0]:
worst = (g, u'%s(%s)' % (tag, bnet))
if g < CLEAR - 1e-6:
hits.append((g, tag, bnet))
chk(not hits,
u'%-13s 离最近的异网铜 %s 有 %.2f mm(下限 %.2f)'
% (r['name'], worst[1], worst[0], CLEAR),
u'%s 侵入 %d 处,最近的:%s'
% (r['name'], len(hits),
' '.join(u'%s(%s) %.3f mm' % (t, n, g)
for g, t, n in sorted(hits)[:5])))
def anchors_in(r, pads, obs):
u"""这一块铺铜里有没有「本网的落脚点」—— 没有就会被整块当孤岛删光。
⚠️ **判据按层分**:内层上贴片焊盘根本不在那一层,只有通孔和过孔算;
外层上本层的贴片焊盘就是落脚点(8 A 那三段全靠它们活着,一个通孔都没有的
`VINFUSED-TOP` 也不例外)。第一版照抄内层的写法,会把顶层三块全判成「必被删」。
"""
outer = r['layer'] in (L_TOP, L_BOT)
th, smd = [], []
for pnet, x, y, hx, hy, is_th, tag, plane in pads:
if pnet != r['net'] or not inside(r['poly'], x, y):
continue
(th if (is_th or (outer and plane == r['layer'])) else smd).append(tag)
for onet, olay, x0, y0, x1, y1 in obs:
if onet == r['net'] and olay is None \
and inside(r['poly'], (x0 + x1) / 2.0, (y0 + y1) / 2.0):
th.append(u'过孔')
return th, smd
def report(pads, nth, nth_board, obs=()):
bad = []
def chk(ok, good, msg):
print((u' [OK] ' if ok else u' [!!] ') + (good if ok else msg))
if not ok:
bad.append(msg)
print(u'── 载流换算(IPC-2221, ΔT=20 °C)──')
print(u' 内层 0.5 oz: 8A→%.1f 6.4A→%.1f 4A→%.2f 2.8A→%.2f 2A→%.2f 1.6A→%.2f 0.5A→%.2f mm'
% tuple(width_for(i) for i in (8, 6.4, 4, 2.8, 2, 1.6, 0.5)))
print(u' 外层 1 oz : 8A→%.2f 4A→%.2f 2.8A→%.2f 0.5A→%.2f mm (同电流只要内层的约 1/4)'
% tuple(width_for(i, inner=False) for i in (8, 4, 2.8, 0.5)))
print(u' 通孔焊盘 %d / %d 配上(只有通孔在内层挖反焊盘,贴片对内层透明)' % (nth, nth_board))
print()
# 每个 (网络, 层) 一张栅格
keys, grids = [], {}
for r in REGIONS:
k = (r['net'], r['layer'])
if k not in grids:
keys.append(k)
grids[k] = [x for x in REGIONS if (x['net'], x['layer']) == k]
G_ = {}
print(u'── 区域与连通域 ──')
for k in keys:
g = Grid(k[0], k[1], grids[k], pads, obs)
G_[k] = g
area = sum(g.sizes.values()) * G * G
comps = sorted(g.sizes.values(), reverse=True)
lay = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}[k[1]]
print(u' %-6s %-8s %2d 块区域 / 面积 %6.0f mm² / 挖 %3d 焊盘 + %3d 过孔线 / **%d 个连通域**%s'
% (lay, k[0], len(grids[k]), area, g.carved, g.carved_obs, g.ncomp,
u'(碎片 %s mm²)' % ' '.join('%.0f' % (c*G*G) for c in comps[1:6])
if g.ncomp > 1 else u''))
print()
grid_checks(G_, pads, chk)
# 5) 孤岛规则:区域里没有本网通孔焊盘 / 过孔,铺铜会把整块当孤岛删光
print()
print(u'── 孤岛规则(载板 pcb2_pour 文件头第 4 条)──')
print(u' preserveSilos=False + 区域内没有本网焊盘/过孔 = **整块被删光**')
print(u' (边框还在、nPoured 是 0)⇒ 只靠扇出过孔活着的区域,必须先扇出再铺。')
for r in REGIONS:
th_in, smd_in = anchors_in(r, pads, obs)
if th_in:
print(u' [OK] %-13s %d 个本网落脚点,自己就活得下来(%s…)'
% (r['name'], len(th_in), ' '.join(sorted(th_in)[:3])))
elif smd_in:
print(u' [--] %-13s 0 个通孔 / %d 个贴片焊盘 ⇒ **必须先扇出**,否则整块被删'
% (r['name'], len(smd_in)))
else:
chk(False, '', u'%s 里一个本网焊盘都没有 —— 铺出来必被删光' % r['name'])
# 6) 异网区域之间的净距
print()
print(u'── 顶层宽铜到异网铜的真实净距(不过格子)──')
check_top_clearance(chk, obs, pads, line_segs(False))
print()
print(u'── 手写多边形 vs 它该等于的矩形并集 ──')
n = 0
for r in REGIONS:
if not r.get('rects'):
continue
n += 1
op, orr = poly_vs_rects(r['poly'], r['rects'])
chk(op == 0 and orr == 0,
u'%s 的 %d 个点和 %d 个矩形的并集逐格一致'
% (r['name'], len(r['poly']), len(r['rects'])),
u'%s 对不上:多边形多出 %d 格、矩形并集多出 %d 格(0.05 mm/格)'
% (r['name'], op, orr))
if not n:
print(u' (没有带 rects 的区域)')
print()
print(u'── 区域间净距(同层异网)──')
for layer, why in ((L_PWR, u'Inner2 分割电源面'), (L_TOP, u'顶层 8 A 输入链')):
worst = (9e9, '', '')
R = [r for r in REGIONS if r['layer'] == layer and r['net'] != 'GND']
for a in range(len(R)):
for b in range(a + 1, len(R)):
if R[a]['net'] == R[b]['net']:
continue
d = poly_gap(R[a]['poly'], R[b]['poly'])
if d < worst[0]:
worst = (d, R[a]['name'], R[b]['name'])
if worst[1] == '':
continue
chk(worst[0] >= CLEAR - 1e-6,
u'%s:最近的一对异网区域 %s ↔ %s 相距 %.2f mm(下限 %.2f)'
% (why, worst[1], worst[2], worst[0], CLEAR),
u'%s:%s ↔ %s 只相距 %.2f mm,小于 %.2f'
% (why, worst[1], worst[2], worst[0], CLEAR))
return bad
def _seg_gap(p1, p2, q1, q2):
u"""两条线段之间的最小距离。"""
def pt_seg(px, py, ax, ay, bx, by):
dx, dy = bx - ax, by - ay
L = dx * dx + dy * dy
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
# 相交则为 0
d1 = (p2[0]-p1[0]) * (q1[1]-p1[1]) - (p2[1]-p1[1]) * (q1[0]-p1[0])
d2 = (p2[0]-p1[0]) * (q2[1]-p1[1]) - (p2[1]-p1[1]) * (q2[0]-p1[0])
d3 = (q2[0]-q1[0]) * (p1[1]-q1[1]) - (q2[1]-q1[1]) * (p1[0]-q1[0])
d4 = (q2[0]-q1[0]) * (p2[1]-q1[1]) - (q2[1]-q1[1]) * (p2[0]-q1[0])
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
return 0.0
return min(pt_seg(q1[0], q1[1], p1[0], p1[1], p2[0], p2[1]),
pt_seg(q2[0], q2[1], p1[0], p1[1], p2[0], p2[1]),
pt_seg(p1[0], p1[1], q1[0], q1[1], q2[0], q2[1]),
pt_seg(p2[0], p2[1], q1[0], q1[1], q2[0], q2[1]))
def poly_gap(a, b):
u"""两个简单多边形之间的最小间距。
⚠️ **不能用包围盒估**。3V3-SE 是个 L 形,它的包围盒 (73,9)-(148,86.5) 把
12V-FAN (96.5,66.5)-(112,88) 整个罩进去,按包围盒算出来是 0.00 mm ——
看着像一条严重违规,实际两块相距 0.5 mm。第一版就是这么误报的。
"""
if any(inside(b, x, y) for x, y in a) or any(inside(a, x, y) for x, y in b):
return 0.0
best = 9e9
for i in range(len(a)):
for j in range(len(b)):
best = min(best, _seg_gap(a[i], a[(i + 1) % len(a)],
b[j], b[(j + 1) % len(b)]))
return best
def ascii_map_top(pads, obs=()):
u"""0.5 mm 一格,画北左角顶层那三段 8 A 宽铜。
这张图是**给人眼看缩腰在哪**的 —— 断面校核只报一个数字,
看不出「是被 GND 扇出孔啃的还是被自己的边界切的」。
"""
g = 0.5
# 2026-09-08:窗口跟着输入首级西移 —— 旧值 (1,31,65,95) 现在只剩右半条链。
# 链路实际占 x −17~30.5 / y 70~95.3,取 0.5 mm/格 一行 96 字,够宽。
x0, x1, y0, y1 = -18.0, 32.0, 69.0, 96.0
sym = [(VIN_RAW, 'R'), (VIN_FUSED, 'F'), (VIN_SW, 'S')]
grids = [(s, Grid(net, L_TOP,
[r for r in REGIONS if r['layer'] == L_TOP and r['net'] == net],
pads, obs)) for net, s in sym]
# 障碍(异网焊盘 / 过孔 / 走线)单独标一个 'x',好认出缩腰是被谁啃的
block = []
for pnet, px, py, hx, hy, th, tag, plane in pads:
if th or plane == L_TOP:
block.append((pnet, px - hx, py - hy, px + hx, py + hy))
for onet, olay, a, b, c, d in obs:
if olay is None or olay == L_TOP:
block.append((onet, a, b, c, d))
print()
print(u'── 顶层 8 A 输入链,0.5 mm / 格(R=VIN_RAW F=VIN_FUSED S=VIN_SW '
u'x=别的网的铜 .=空)──')
print(' ' + ''.join(str(int(x0 + i * g) % 10) if (i * g) % 5 == 0 else ' '
for i in range(int((x1 - x0) / g))))
for j in range(int((y1 - y0) / g) - 1, -1, -1):
y = y0 + (j + .5) * g
row = ''
for i in range(int((x1 - x0) / g)):
x = x0 + (i + .5) * g
ch = '.'
for s, gr in grids:
ii, jj = gi(x), gj(y)
if 0 <= ii < gr.nx and 0 <= jj < gr.ny and gr.c[jj][ii]:
ch = s
break
if ch == '.':
for _n, a, b, c, d in block:
if a <= x <= c and b <= y <= d:
ch = 'x'
break
row += ch
print('%5.1f %s' % (y0 + j * g, row))
def ascii_map(pads, obs=()):
u"""2 mm 一格,画 Inner2 的分块。"""
g = 2.0
nx, ny = int(BW / g), int(BH / g)
sym = {'+12V': '2', '+3V3': '3', 'GND': '.'}
grids = []
for net in ('+12V', '+3V3', 'GND'):
rs = [r for r in REGIONS if r['layer'] == L_PWR and r['net'] == net]
grids.append((net, Grid(net, L_PWR, rs, pads, obs)))
print()
print(u'── Inner2(L3)分块图,2 mm / 格 ──')
print(' ' + ''.join(str((i * 2 // 10) % 10) for i in range(nx)))
print(' ' + ''.join(str((i * 2) % 10) for i in range(nx)))
for j in range(ny - 1, -1, -1):
row = ''
for i in range(nx):
x, y = BX0 + (i + .5) * g, BY0 + (j + .5) * g
ch = ' '
for net, gr in grids:
ii, jj = gi(x), gj(y)
if 0 <= ii < gr.nx and 0 <= jj < gr.ny and gr.c[jj][ii]:
ch = sym[net]
break
row += ch
print('%3d %s' % (j * 2, row))
print(u' 2=+12V 3=+3V3 .=GND 底填 空=反焊盘/禁铺/板外')
# ══════════════════ 下发 ══════════════════
# 整套流程照抄载板 `pcb2_pour.py`(那边已经跑通两轮),三条硬约定原样保留:
# 1. **顺序即优先级**:`REGIONS` 的次序就是同层内谁先铺、谁占地盘,GND 底填必须最后。
# `create()` 的第 7 个参数 `pourPriority` 传什么都没用,也**不能**事后
# `modify(id, {pourPriority})` 去补 —— 不生效,还会把客户端弄进「后台任务卡在
# 零进度」的状态(skill §9j / §9k)。
# 2. **`create()` 成功了也会抛**,返回值和 try/catch 都不能当判据;只能建完之后
# `getAll()` 回读名字来确认。
# 3. 多边形点列**末尾要把首点再写一遍**,不闭合的话 `create()` 什么也不建。
PCB = '7e9415a16a909c51'
OPEN = ("await eda.dmt_EditorControl.openDocument('%s');"
"await new Promise(r=>setTimeout(r,1800));" % PCB)
def call(js, timeout=120):
from eda import run
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 polysrc(pts):
u"""点列(mm)-> TPCB_PolygonSourceArray(mil,全直边,首点补在末尾)。"""
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
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;
try { await eda.pcb_PrimitivePour.create(p.net, p.layer, poly, 'solid', false, p.name, p.prio, 10); }
catch (e) {}
}
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);
return {fired: true};
"""
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);
}
return {fills, pour: (await eda.pcb_PrimitivePour.getAll())
.map(p => [p.getState_PourName(), p.getState_Net(), p.getState_Layer()])};
"""
# 回读**真正铺出来的铜**(不是边框)。坐标单位是 10 mil/单位,在 JS 侧就换成 mm。
JS_FILLS = u"""
const U = 3.9370078740157477;
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(),
p.getState_Net()];
const out = [];
let arcs = 0;
for (const q of await eda.pcb_PrimitivePoured.getAll()) {
let f = []; try { f = q.getState_PourFills() || []; } catch (e) {}
const info = byId[q.getState_PourPrimitiveId()] || ['?', '?', '?', '?'];
const rings = [];
for (const seg of f) for (const ring of ((seg.path && seg.path.complexPolygon) || [])) {
// 环是 `x1 y1 'L' x2 y2 ... 'ARC' 角度 ex ey ...`
// **'ARC' 后面第一个数是角度,不是坐标** —— 按奇偶分 x/y 会整体错位。
const pts = [];
let i = 0;
while (i < ring.length) {
const v = ring[i];
if (v === 'L') { i++; continue; }
if (v === 'ARC') { arcs++; i += 2; continue; }
pts.push(Math.round(ring[i] / U * 1000) / 1000, Math.round(ring[i+1] / U * 1000) / 1000);
i += 2;
}
if (pts.length >= 6) rings.push(pts);
}
out.push({name: info[0], prio: info[1], layer: info[2], net: info[3],
segs: f.length, rings: rings});
}
const vias = (await eda.pcb_PrimitiveVia.getAll())
.map(v => [v.net || '', Math.round(v.x / 39.3700787401575 * 1000) / 1000,
Math.round(v.y / 39.3700787401575 * 1000) / 1000]);
return {arcs: arcs, pours: out, vias: vias};
"""
def _cells_from_rings(rings):
u"""把一堆环光栅化成布尔格(**奇偶规则**:环套环就是洞)。
用扫描线,不要逐格做 point-in-polygon —— 一整层地平面上百个反焊盘环,
逐格算是 32 万格 x 上百个环,Python 里要跑几分钟。
"""
nx, ny = int(BW / G), int(BH / G)
acc = [[0] * nx for _ in range(ny)]
for pts in rings:
xs_ = pts[0::2]
ys_ = pts[1::2]
n = len(xs_)
j0 = max(0, gj(min(ys_)))
j1 = min(ny - 1, gj(max(ys_)) + 1)
for j in range(j0, j1 + 1):
y = gy(j)
cr = []
for k in range(n):
y1, y2 = ys_[k], ys_[(k + 1) % n]
if (y1 > y) != (y2 > y):
x1, x2 = xs_[k], xs_[(k + 1) % n]
cr.append(x1 + (y - y1) * (x2 - x1) / (y2 - y1))
cr.sort()
row = acc[j]
for a in range(0, len(cr) - 1, 2):
ia = max(0, gi(cr[a]))
ib = min(nx - 1, gi(cr[a + 1]))
for i in range(ia, ib + 1):
xc = gx(i)
if cr[a] <= xc <= cr[a + 1]:
row[i] ^= 1
return [[bool(v) for v in r] for r in acc]
def _grid_from_cells(net, layer, cells):
g = Grid.__new__(Grid)
g.net, g.layer = net, layer
g.ny, g.nx = len(cells), len(cells[0])
g.c = cells
g.carved = 0
g.label()
return g
FILLS = os.path.join(HERE, '_pcb1_poured.json')
def verify(refresh=True):
u"""下发之后,按**实测铺出来的铜**再跑一遍连通域 / 断面 / 焊盘覆盖。
走的是和下发前完全同一段 `grid_checks()` —— 理由见那个函数的注释。
"""
if refresh or not os.path.exists(FILLS):
d = call(JS_FILLS, timeout=900)
io.open(FILLS, 'w', encoding='utf-8').write(json.dumps(d, ensure_ascii=False))
else:
d = json.load(io.open(FILLS, encoding='utf-8'))
pours = d['pours']
print(u'---- 实测铺铜(getState_PourFills 回读)----')
if d.get('arcs'):
print(u' 注意:环里有 %d 段圆弧,这里按**弦**处理(反焊盘会算大一丁点)' % d['arcs'])
cells = {}
for e in sorted(pours, key=lambda e: (e['layer'], e['name'])):
npt = sum(len(r) // 2 for r in e['rings'])
print(u' %-13s net=%-8s layer=%-3s prio=%-3s %3d 段 / %4d 环 / %6d 点'
% (e['name'], e['net'], e['layer'], e['prio'], e['segs'], len(e['rings']), npt))
if not e['rings']:
print(u' [!!] 这一块一个环都没有 —— 铺出来是空的(孤岛被整块删光?)')
k = (e['net'], e['layer'])
cells.setdefault(k, []).extend(e['rings'])
if not pours:
print(u' [!!] 一块铜都没铺出来')
return [u'实测铺铜为空']
print()
G_ = {}
for k, rings in cells.items():
G_[k] = _grid_from_cells(k[0], k[1], _cells_from_rings(rings))
print(u'-- 实测连通域 --')
for k in sorted(G_, key=lambda k: (k[1], k[0])):
g = G_[k]
area = sum(g.sizes.values()) * G * G
comps = sorted(g.sizes.values(), reverse=True)
lay = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}[k[1]]
print(u' %-6s %-8s 面积 %6.0f mm2 / **%d 个连通域**%s'
% (lay, k[0], area, g.ncomp,
u'(碎片 %s mm2)' % ' '.join('%.1f' % (c * G * G) for c in comps[1:6])
if g.ncomp > 1 else u''))
print()
bad = []
def chk(ok, good, msg):
print((u' [OK] ' if ok else u' [!!] ') + (good if ok else msg))
if not ok:
bad.append(msg)
pads, _n, _nb = load_pads(False)
# ⭐ 每一块碎片都要有本网的过孔 / 通孔焊盘,否则那是一块**浮铜**。
# 铺铜自己的孤岛判定只在「重建」那一刻生效;而它认的是**铺铜对象**,
# 一个对象铺出好几块的时候(Inner2 的 GND 底填被三块电源面切成 3 段),
# 单独某一段有没有落脚点它是不管的。浮铜在 DRC 里只会变成几条连接性错误,
# 混在几百条「还没布线」里根本挑不出来。
anchors = {} # (网络, 层) -> 落脚点。**层不同,算不算落脚点也不同**(见 anchors_in)
layers = set(k[1] for k in G_)
for net, x, y in d.get('vias', ()):
for lay in layers:
anchors.setdefault((net, lay), []).append((x, y))
for pnet, px, py, hx, hy, th, tag, plane in pads:
for lay in layers:
if th or (lay in (L_TOP, L_BOT) and plane == lay):
anchors.setdefault((pnet, lay), []).append((px, py))
for k in sorted(G_, key=lambda k: (k[1], k[0])):
g = G_[k]
live = set()
for x, y in anchors.get(k, ()):
i, j = gi(x), gj(y)
if 0 <= i < g.nx and 0 <= j < g.ny and g.lab[j][i]:
live.add(g.lab[j][i])
dead = [(cid, n) for cid, n in g.sizes.items() if cid not in live]
big = [(c, n) for c, n in dead if n * G * G >= MIN_FLOAT_AREA]
dust = [(c, n) for c, n in dead if n * G * G < MIN_FLOAT_AREA]
lay = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}[k[1]]
if dust:
print(u' (%s %s 另有 %d 粒 < %.1f mm² 的碎屑,按 MIN_FLOAT_AREA 放过:%s mm²)'
% (lay, k[0], len(dust), MIN_FLOAT_AREA,
' '.join('%.2f' % (n * G * G) for _c, n in sorted(dust, key=lambda t: -t[1])[:6])))
chk(not big,
u'%s %s 的 %d 块碎片各自都有本网焊盘/过孔落脚' % (lay, k[0], g.ncomp),
u'%s %s 有 %d 块**浮铜**(没有任何本网落脚点):%s mm²'
% (lay, k[0], len(big),
' '.join('%.1f' % (n * G * G) for _c, n in sorted(big, key=lambda t: -t[1])[:6])))
grid_checks(G_, pads, chk)
return bad
def push():
plan = [(r['name'], r['net'], r['layer'], polysrc(r['poly'])) for r in REGIONS]
seen, batch = {}, []
for name, net, layer, src in plan:
seen[layer] = seen.get(layer, 0) + 1
batch.append({'name': name, 'net': net, 'layer': layer,
'src': src, 'prio': seen[layer]})
print(u'清掉旧铜面: %d 块' % call(JS_DEL)['deleted'])
made = call(JS_MAKE % json.dumps(batch, ensure_ascii=False), timeout=300)
print(u'已建: ' + ' '.join(u'%s(prio %s)' % (n, p) for n, p in made))
got = dict((n, p) for n, p in made)
miss = [b['name'] for b in batch if b['name'].upper() not in got]
if miss:
sys.exit(u'!! 这几块没建出来: ' + ' '.join(miss))
# 断言**同层内优先级的相对次序**(数值是 1,3,5,... 不连续,不能断言等于名次)
for layer in sorted(set(b['layer'] for b in batch)):
seq = [(b['name'], got[b['name'].upper()]) for b in batch if b['layer'] == layer]
for (na, pa), (nb, pb) in zip(seq, seq[1:]):
if not pa < pb:
sys.exit(u'!! L%d 优先级次序乱了:%s(%s) 应排在 %s(%s) 前面 —— '
u'重叠的铜面会按错的顺序裁剪,先修好再往下走' % (layer, na, pa, nb, pb))
print(u' 优先级次序 OK:' + ' | '.join(
u'L%d %s' % (layer, ' < '.join(u'%s(%s)' % (b['name'], got[b['name'].upper()])
for b in batch if b['layer'] == layer))
for layer in sorted(set(b['layer'] for b in batch))))
r = call(JS_REBUILD, timeout=300)
if r.get('err'):
sys.exit(u'!! 打不开铺铜管理器: ' + r['err'])
for _i in range(60):
time.sleep(4)
n = call(JS_POLL, timeout=60)['poured']
print(u' 填充 %d / %d' % (n, len(batch)))
if n >= len(batch):
break
else:
sys.exit(u'!! 填充一直上不去。看窗口最底下是不是有条卡在 0% 的进度条 —— '
u'那是客户端卡在后台任务上,location.reload() 之后从 --push 头上重跑'
u'(重载会丢掉已建的边框)。')
d = call(JS_DONE, timeout=300)
print(u'完成:铜面 %d 块,各自填充 %s' % (len(d['pour']), d['fills']))
def main():
refresh = '--refresh' in sys.argv
pads, nth, nthb = load_pads(refresh)
obs = obstacles(False)
print(u'下板 PCB1 铺铜计划(Inner1=整板地 / Inner2=分割电源面 / Top=8 A 输入链宽铜)')
print(u' Inner2 区域 %d 块:%s'
% (len([r for r in REGIONS if r['layer'] == L_PWR]),
' '.join(r['name'] for r in REGIONS if r['layer'] == L_PWR)))
print(u' Top 区域 %d 块:%s'
% (len([r for r in REGIONS if r['layer'] == L_TOP]),
' '.join(r['name'] for r in REGIONS if r['layer'] == L_TOP)))
print(u' 8 A 输入链的网名(当场反查,没写死):%s'
% ' '.join(u'%s=%s' % (nm, net) for net, nm in CHAIN))
print(u' 板上已有的铜:过孔 %d / 走线 %d(顶层 %d)'
% (sum(1 for o in obs if o[1] is None), sum(1 for o in obs if o[1] is not None),
sum(1 for o in obs if o[1] == L_TOP)))
print()
for r in REGIONS:
lay = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}[r['layer']]
print(u' %-6s %-8s %-13s %s' % (lay, r['net'], r['name'], r['note']))
print()
if '--map' in sys.argv:
ascii_map_top(pads, obs)
ascii_map(pads, obs)
return
if '--verify' in sys.argv: # 只回读实测铜,不改板
bad = verify('--cache' not in sys.argv)
_done(bad, u'实测铺铜和计划对得上。')
return
bad = report(pads, nth, nthb, obs)
if '--push' not in sys.argv:
ascii_map_top(pads, obs)
ascii_map(pads, obs)
print()
# ⚠️ 计划没跑绿就不许下发。铺铜下去之后再发现算错,要连扇出一起推倒重来。
_done(bad, u'✅ 计划全部通过。' + (u'' if '--push' in sys.argv else
u'要真的铺到板上加 --push(它自己会先把这一整套再跑一遍)。'
u'⚠️ 动过摆位的话,先跑扇出三步再回来。'))
if '--push' not in sys.argv:
return
print()
push()
print()
_done(verify(), u'实测铺铜和计划对得上。')
def _done(bad, ok_msg):
if bad:
print(u'❌ %d 项没过:' % len(bad))
for b in bad:
print(u' · ' + b)
sys.exit(1)
print(ok_msg)
if __name__ == '__main__':
main()