文件
phonerail/.claude/pcb2_fanout.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

186 行
9.7 KiB
Python
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# -*- coding: utf-8 -*-
u"""载板 PCB2-CARRIER 的扇出 —— 复用下板 `pcb_fanout.py` 的规划器,只换掉板子相关的常量。
/c/Python314/python .claude/dump_pads.py 942ed1560d81c1e7 pcb2-copper.json # 1. 抓障碍表
/c/Python314/python .claude/pcb2_fanout.py # 2. 只规划
/c/Python314/python .claude/pcb2_fanout.py --push # 3. 下发
⚠️ 顺序同 `pcb_fanout.py` 文件头那条:**布局一动就要 `--clean` → 重新 dump → `--push`**,
三步不能省也不能并(并了会拿旧障碍表规划,或者板上剩两套过孔)。
## 为什么现在只扇 GND
Inner2 是整板地平面,已经铺好了;但载板上 242 个 GND 焊盘里只有 **20 个是直插**
(五路的 C8n1 负极 + TP8n2、J23 的四个屏蔽脚、6 个 M3),
其余两百多个贴片焊盘**根本没连到平面上**。
Inner1 的电源岛还没划(卡在排母选型),所以 `+5V` / `+3V3_HUB` / `VOn_P`
现在扇过去就是一堆悬空过孔 —— 等 Inner1 铺完再一起扇。
## 和下板的四处不同
| | 下板 PCB1 | 载板 PCB2 |
|---|---|---|
| 板框 | 160 × 125 | **220 × 135** |
| 元件面 | 全在顶层 | **两面都是**(J31/J32 + 整个 Hub 在底层)→ `FANOUT_LAYERS = (1, 2)` |
| 天线禁铺区 | ESP32 在下板 | **没有** → 挪到板外,等于不生效 |
| 目标平面层 | GND→Inner1(15) | **GND→Inner2(16)**(层角色是镜像的,§25.6 ①)|
~~⛔ J31/J32 的 33 个 GND 脚故意不扇~~ —— **2026-09-05 这条整个作废**(`SKIP_HDR = False`)。
排母换成直插的 `C5307344`(§22.3f)之后,那 80 个脚**本身就是通孔**,
直接落到 Inner1/Inner2 上,根本不需要扇出 —— 而且 `pcb_fanout.one_net()` 的
`todo` 本来就带 `not p['hole']`,通孔焊盘一律不进扇出清单。
所以这里只要把 `SKIP_HDR` 关掉,`_pour_filter` 返回 None,规划器自己会跳过它们。
`HDR_BOXES` 留着当历史记录(那是**旧的贴片**焊盘区,坐标已经作废)。
"""
import importlib.util
import io
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
_spec = importlib.util.spec_from_file_location(
'pcb_fanout', os.path.join(HERE, 'pcb_fanout.py'))
F = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(F)
# ── 换掉板子相关的常量 ────────────────────────────────────────────────
F.PCB = '942ed1560d81c1e7' # PCB2-CARRIER
F.SRC = os.path.join(HERE, 'pcb2-copper.json')
F.BOARD = (0.0, 0.0, 220.0, 135.0)
F.R_CORNER = 5.0
F.ANT = (-100.0, -100.0, -99.0, -99.0) # 载板没有天线禁铺区,挪到板外
F.FANOUT_LAYERS = (F.L_TOP, F.L_BOT) # 两面都是元件面
# ⚠️ 载板的层角色和下板是**镜像的**:Inner1(15)=电源面 / Inner2(16)=完整地平面。
# 照抄下板的 `TARGET['GND'] = L_GND(15)` 会把地过孔打到电源面上。
# 下板 L_GND=15 是它的地平面,载板的地平面是 **Inner2 = 16**(= 下板的 L_PWR 常量)。
F.TARGET = {'GND': 16}
F.N_VIA = {'GND': 1}
F.TRK_W_MAX = {'GND': F.TRK_W}
F.TRK_W_NET = F.TRK_W_MAX
# ── J31/J32 的焊盘区:现在不扇(排母未定,换件后整体移位)──────────────
SKIP_HDR = False # 2026-09-05 换成直插排母,80 个脚自己就是过孔
HDR_BOXES = [(36.5, 63.5, 87.5, 71.5), # J31,实测焊盘 x 37.87~86.13 / y 65.1~69.9
(132.5, 63.5, 183.5, 71.5)] # J32,实测 x 133.87~182.13
def _pour_filter(net):
u"""借 `pcb_fanout.one_net()` 里现成的钩子来**排除**焊盘(原本是用来筛铜面覆盖的)。
返回的谓词为 True 才扇出。这里对 GND 返回「不在 J31/J32 焊盘区里」。
"""
if not SKIP_HDR or net != 'GND':
return None
def keep(x, y):
return not any(b[0] <= x <= b[2] and b[1] <= y <= b[3] for b in HDR_BOXES)
return keep
F._pour_filter = _pour_filter
# ── v5 差分布完之后加的三条硬约束(PROGRESS「接下来按顺序 · 2」)────────
# ⚠️ 前提:**这一轮不许 `--clean`**。板上那 178 条线(11 对差分 + 3 条 +3V3 车道)
# 是上一轮的成果,`clean2()` 会把信号层的线连同过孔一起删光。
# 规划器现在会把它们当障碍(`pcb_fanout.EXIST_LAYERS`,同层比线 / 过孔不分层)。
# U32(Hub)西缘 pin13 GND —— **不往西挤了,改往 QFN 肚子里打**。
#
# 上一轮留的那条「孔位钉在 (94.6, 71.4)」实测**画不出来**:
# 西边的走廊确实只剩 x ∈ [94.59, 94.625](DP5 车道东沿 94.09 + 0.5,U32 西排焊盘西沿 95.125 − 0.5),
# 但 pin13 的**引线出不去**:pin13(y 70.10~70.40) 与 pin14(70.60~70.90) 之间只有 0.20 mm,
# 0.3 的线要斜着往西北走,一定切进 pin14 的净距圈;而留在 y=70.25 平着往西,
# 过孔又躲不开 USB5_DP 的横段(y 69.75,只差 0.5 < 0.64)。
#
# 正解是往**东**:QFN 的引脚环与散热盘之间有一整圈空地
# (西排东沿 95.975 ←→ 散热盘西沿 97.600,净宽 1.625),
# 自动搜索已经把 pin13 放在 (96.50, 70.25)、pin19 放在 (97.25, 71.50),都在这一圈里。
# 那里到差分车道有 2 mm 以上,比西边的走廊宽裕得多。⇒ 不再钉死孔位。
F.FORCE_VIA = {}
# C747 / C748 的 GND 脚(都是 pin2,在各自 +3V3 脚的正北):
# 南边是留给 +3V3_HUB 的进线,过孔一律往北打。
F.ANG_RANGE = {('C747', '2'): (45, 135),
('C748', '2'): (45, 135)}
# ── J7n(尾插 BM24,五个站各 13 个 GND 脚)整批不扇 ────────────────────
# 这 65 个脚是 `pcb2_route_sig.py` 的 `bm24_pre()` **手写死的两条梳子 + pin52 单独出线**
# (§25.8:0.7 mm 间距里根本打不下 Ø0.61 的孔,只能逃逸出来再并)。
# 让自动扇出去碰它们有两个后果:
# ① 十个脚必然失败(实测 50 个),失败清单被这些噪声塞满,真正的问题看不见;
# ② pin 1 / 2 / 52 又「成功」了 —— 打出来的孔正好落在梳子和车道上,
# route_sig 还得写一个删除框把它们收回去(那个删除框就是这么来的)。
def _skip(p):
return p['net'] == 'GND' and (p['des'] or '').startswith('J7')
F.SKIP_PAD = _skip
# ── 载板专用的 clean ──────────────────────────────────────────────────
# ⚠️⚠️ **不能用 `F.clean()`**:下板那个 CLEAN_JS 删的是「layer !== 11 的所有导线」。
# 下板上除了板框没有别的线,所以没出事;载板上却有
# layer 3 丝印 50 条(5 个手机位外框 + 接口边加粗)
# layer 9 顶层装配 100 条
# layer 13 文档层 20 条
# 照抄会把这 170 条一起删掉,而且 DRC / 几何检查一条都不会报。
# 这里只删**过孔**和**信号层**(1/2/15/16)的导线。
CLEAN_JS2 = u"""
await eda.dmt_EditorControl.openDocument('__PCB__');
await new Promise(r => setTimeout(r, 1500));
const SIG = [1, 2, 15, 16];
const vs = await eda.pcb_PrimitiveVia.getAllPrimitiveId();
if (vs.length) await eda.pcb_PrimitiveVia.delete(vs);
const ls = (await eda.pcb_PrimitiveLine.getAll()).filter(l => SIG.includes(l.layer));
if (ls.length) await eda.pcb_PrimitiveLine.delete(ls.map(l => l.primitiveId));
await eda.pcb_Document.save();
const left = {};
for (const l of await eda.pcb_PrimitiveLine.getAll()) left[l.layer] = (left[l.layer] || 0) + 1;
// ⚠ 板框是 layer 11 上的一条**多段线**(pcb_PrimitivePolyline),不是 Line ——
// 所以「剩余导线」里永远没有 11,别拿它当板框还在的证据。
const outline = (await eda.pcb_PrimitivePolyline.getAll()).filter(p => p.layer === 11).length;
return {vias: vs.length, lines: ls.length, outline,
leftVia: (await eda.pcb_PrimitiveVia.getAll()).length, leftByLayer: left};
"""
def clean2():
r = F.run(CLEAN_JS2.replace('__PCB__', F.PCB), timeout=600)
if not r.get('success'):
sys.exit(u'清理失败: ' + json.dumps(r, ensure_ascii=False)[:400])
x = r['result']
print(u'清掉:过孔 %d / 信号层导线 %d;剩余过孔 %d' % (x['vias'], x['lines'], x['leftVia']))
print(u' 剩余导线按层: %s(3=丝印 / 9=装配 / 13=文档,都必须还在);板框多段线 %d 条'
% (x['leftByLayer'], x['outline']))
for lay in ('3', '9', '13'):
if not x['leftByLayer'].get(lay):
sys.exit(u'!! layer %s 的导线被清光了,说明用错了 clean' % lay)
if x['outline'] != 1:
sys.exit(u'!! layer 11 的板框多段线有 %d 条,应为 1' % x['outline'])
def main():
if not os.path.exists(F.SRC):
sys.exit(u'先抓障碍表:/c/Python314/python .claude/dump_pads.py '
u'942ed1560d81c1e7 pcb2-copper.json')
print(u'载板扇出:板框 %.0f × %.0f,元件面 %s,GND -> layer %d'
% (F.BOARD[2], F.BOARD[3], F.FANOUT_LAYERS, F.TARGET['GND']))
if SKIP_HDR:
print(u'⛔ J31/J32 的焊盘区不扇(排母未定,换件后 80 个焊盘会整体移位)')
if '--clean' in sys.argv:
clean2()
if '--push' not in sys.argv:
return
d = json.load(io.open(F.SRC, encoding='utf-8'))
F.one_net('GND', d)
if __name__ == '__main__':
main()