# -*- 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。** 左通道不用 —— 它的北端要从 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 BW, BH = 160.0, 125.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)] 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 ((BW - CORNER_R, CORNER_R, -90.0), (BW - CORNER_R, BH - CORNER_R, 0.0), (CORNER_R, BH - CORNER_R, 90.0), (CORNER_R, 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,**不依赖扇出就活得下来**。 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、J28)+ 风扇指(R730/C738/D2);' 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、J26/J27 左风扇;中桥穿中间通道下来,喂 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、J29/J30 右风扇;右通道接回北带,喂 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 过孔 + 短线, # **每一样都要躲**。下面每一条边界后面括号里写的就是它躲的那个东西。 dict(net=VIN_RAW, layer=L_TOP, name='VINRAW-TOP', poly=[(11.9, 65.4), (19.6, 65.4), (19.6, 73.08), (18.58, 73.08), (18.58, 73.78), (15.0, 73.78), (15.0, 73.0), (11.9, 73.0)], note=u'J1.2(13.5,69.5) → F1.1(17.5,73.06)。F1 竖着放之后这一段是一片 7.7×7.7 的大铜;' u'西北那个阶梯是躲 J1.1 的 GND 通孔(Φ2.5, 净距后 r=1.45),' u'东边 19.6 躲 Q1.4 栅极脚(20.215)与 DZ1.1(19.890),' u'北边两段台阶各给 VINFUSED 让 0.2'), dict(net=VIN_FUSED, layer=L_TOP, name='VINFUSED-TOP', poly=[(16.4, 74.0), (18.8, 74.0), (18.8, 73.3), (22.5, 73.3), (22.5, 78.5), (16.4, 78.5)], note=u'F1.2(17.5,75.95) → Q1.1~3(源极三脚, x 20.2~22.2)。' u'南边两段:西半 74.0 躲 F1.1(顶 73.651),东半 73.3 躲 Q1.4(顶 72.882) ' u'并盖住 Q1.3(底 73.578);北边 78.5 给 VIN_SW 西行干道让 0.35'), # ⚠ `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=[(25.6, 72.1), (29.68, 72.1), (29.68, 73.45), (32.3, 73.45), (32.3, 78.85), (30.47, 78.85), (30.47, 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, 78.85), (25.6, 78.85)], rects=[(25.6, 72.1, 29.68, 78.85), # Q1 漏极四脚(南边 72.1 躲 R1 的 GND 过孔 26.675,71.5) (25.6, 73.45, 32.3, 78.85), # D1.1 TVS(南边 73.45 躲 D1.2 的 GND 盘顶 73.163 与它 6 个扇出孔) (28.6, 78.85, 30.47, 86.47), # C4.2/C5.2/C6.2 去耦(东边 30.47 躲 C6.1 的 GND 盘 30.670) (1.5, 78.85, 28.6, 83.49), # ★ 8 A 西行干道,y 净宽 4.64 mm; # 南边 78.85 躲 J1 的机械脚 J1.4(顶 78.401),北边 83.49 躲 C2/C3 的四个通孔(底 83.700) (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 漏极 → 北上 → 西行干道 → C3.1/C2.1 → 北柱 → Rs6.1,一路无过孔;' u'东边挂 D1(TVS) 与 C4/C5/C6 去耦。干道比 v10 北提了 2.8 mm —— XT30 的机械脚占到 y 78.4'), ] # ── 断面校核:每一条都是「这里必须能过多少安」────────────────────────── # (名字, 网络, 层, 轴, 坐标, 跨度, 需要的电流) # 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), (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)。 (u'RAW J1→F1 颈', VIN_RAW, L_TOP, 'y', 73.4, (14.0, 20.0), 8.0), (u'RAW J1.2 东出', VIN_RAW, L_TOP, 'x', 15.5, (64.0, 76.0), 8.0), (u'FUSED F1.2→Q1 源极', VIN_FUSED, L_TOP, 'x', 19.5, (72.0, 80.0), 8.0), (u'SW Q1 漏极北出', VIN_SW, L_TOP, 'y', 77.5, (24.0, 34.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 就在旁边)', 'C739.1': u'孤零零落在风扇指里,走 11 mm 顶层线到 U33.10', # 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', } # 整块「有意不进平面」的区域(位号前缀 → 说明) 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):同上'), ] # ── 板上元件位姿 + 焊盘 ──────────────────────────────────────────────── 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 格,够细 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 = int(min(xs) / G), int(max(xs) / G) + 1 j0, j1 = int(min(ys) / G), int(max(ys) / G) + 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'], (i + .5) * G, (j + .5) * G): 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] <= (i + .5) * G <= ANT[2] \ and ANT[1] <= (j + .5) * G <= 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, int(y0 / G)), min(self.ny, int(y1 / G) + 1)): yc = (j + .5) * G if not (y0 <= yc <= y1): continue row = self.c[j] for i in range(max(0, int(x0 / G)), min(self.nx, int(x1 / G) + 1)): if row[i] and x0 <= (i + .5) * G <= 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 = int(coord / G) for i in range(int(span[0] / G), int(span[1] / G) + 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 = int(coord / G) for j in range(int(span[0] / G), int(span[1] / G) + 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 = int((x - hx) / G), int((x + hx) / G) + 1 j0, j1 = int((y - hy) / G), int((y + hy) / G) + 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 x0, x1, y0, y1 = 1.0, 31.0, 65.0, 95.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 = int(x / G), int(y / G) 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 = (i + .5) * g, (j + .5) * g ch = ' ' for net, gr in grids: ii, jj = int(x / G), int(y / G) 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, int(min(ys_) / G)) j1 = min(ny - 1, int(max(ys_) / G) + 1) for j in range(j0, j1 + 1): y = (j + 0.5) * G 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, int(cr[a] / G)) ib = min(nx - 1, int(cr[a + 1] / G)) for i in range(ia, ib + 1): xc = (i + 0.5) * G 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 = int(x / G), int(y / G) 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] lay = {1: 'Top', 2: 'Bottom', 15: 'Inner1', 16: 'Inner2'}[k[1]] chk(not dead, u'%s %s 的 %d 块碎片各自都有本网焊盘/过孔落脚' % (lay, k[0], g.ncomp), u'%s %s 有 %d 块**浮铜**(没有任何本网落脚点):%s mm²' % (lay, k[0], len(dead), ' '.join('%.1f' % (n * G * G) for _c, n in sorted(dead, 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()