文件
phonerail/.claude/skills/easyeda-sch-geometry/SKILL.md
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

102 KiB
原始文件 Blame 文件历史

name, description
name description
easyeda-sch-geometry EasyEDA Pro 原理图通过 API 布图时的坐标系、方向与几何约定。放置元件、网络旗(netflag)、 网络端口(netport)、导线之前必读,尤其是判断"朝上还是朝下"、极性元件正负极、 以及连线是否真的连上了。涵盖 Y 轴方向、旋转角语义、坐标吸附、导线数组格式、 getState_Line 的返回结构陷阱,以及一套必做的几何验证流程。 在 D:\DCDC 的 PhoneRail 电源板项目中通过 easyeda-api 桥接实测得出。

EasyEDA Pro 原理图几何与方向约定

适用范围:通过 easyeda-api skill 的 WebSocket 桥接(POST /execute)用 eda.sch_* API 创建原理图。手工在 GUI 里画不受这些约束。

来源:2026-09-03 在 EasyEDA Pro 客户端上实测(PhoneRail-5CH-PSU 工程 P1 页)。 每条都标注了是实测还是推断。


1. Y 轴向上 ⚠️ 最容易踩的一条

实测。 原理图坐标系 Y 值越大,在屏幕上越靠上。与 PCB、与大多数 GUI 的习惯相反。

后果:如果按"Y 向下"排版,整页会上下颠倒——分区顺序反了、地符号跑到元件上方、 电源符号跑到下方、原本预留给标题栏的区域被电路占满。

y = 800  ┌──────────────── 页面顶部
y = 500  │
y = 150  └──────────────── 页面底部(标题栏在这里)

布图规则

  • 电源母线放在大 y,地母线放在小 y。
  • 电源旗(+12V / +3V3)接在元件上方(更大的 y)。
  • 地旗接在元件下方(更小的 y)。
  • 分区标题文字放在该分区上方(更大的 y)。

单位是 0.01 inch(1 单位 = 10 mil)。标准引脚间距 = 10 单位。


2. 网络旗与端口的旋转角

实测。

符号 rotation 朝向 用在
createNetFlag('Ground', ...) 0 符号在下,引脚在上 导线从上方接进来 ✅
createNetFlag('Ground', ...) 180 符号在上,引脚在下 导线从下方接进来 ✅
createNetFlag('Power', ...) 0 符号在上,引脚在下 导线从下方接进来 ✅
createNetFlag('Power', ...) 180 符号在下,引脚在上 导线从上方接进来 ✅

(2026-09-03 在空白页上四种组合一起画出来实测确认;早期只记了 rot=0 两行。 Power rot=180 是"旗挂在导线下面",密集区里想把电源旗塞进导线下方的空隙时全靠它。) | createNetPort(dir, ...) | 0 / 180 | 要看 dir,见 §8b | —— |

旗的引脚永远在"背面",导线必须从旗的引脚那一侧接进来。 rot=0 的 Ground 引脚在上,所以导线要从上方往下引到旗; rot=0 的 Power 引脚在下,导线要从下方往上引到旗。 反了就会出现"符号盖在导线上"的难看画面。P3B 上 DAC 的 AGND/EP/CAP 因为 只能从下往上走线,全部用了 Ground rot=180,很干净。

也就是说 Ground 和 Power 在 rot=0 时朝向相反——这正好符合"两者都用 0"的直觉结果, 但如果你为了"让地朝下"而去设 180,就会全部翻上去。

竖直引出线(向上/向下)用 netport 时,0 或 180 都可以,符号只是横着伸出去。

所有旗和端口的引脚都精确落在传入的 (x, y) 上(实测:getAllPins() 返回的坐标 == 放置坐标)。 所以把它放在导线端点上即可连接,不需要额外对齐。


3. 坐标被吸附到 5 的倍数 ⚠️

实测。 createNetFlag / createNetPort 会把传入坐标吸附到最近的 5 的倍数。

(800,382) -> (800,380)      (740,548) -> (740,550)
(120,162) -> (120,160)      (315,218) -> (315,220)
(620,202) -> (620,200)      (360,258) -> (360,260)

如果导线端点是 382 而旗被吸到 380,两者差 2 个单位就是断开的, 而且在图上看不出来(符号紧挨着导线端点)。

规则:所有旗/端口坐标一律取 5 的倍数,导线端点也对齐到 5 的倍数。 元件坐标不受此限(实测元件可以放在任意值),但也建议统一用 10 的倍数。


3b. 移动 netflag / netport 会丢掉网络名 ⚠️⚠️

实测。 对已存在的旗或端口调用 toAsync().setState_X/Y().done(), 位置会正确移动,但网络名被重置成符号的默认值:

类型 移动后变成
createNetFlag('Power', '+12V', ...) +5V(电源符号的默认网络)
createNetPort('BI', 'SW3V3', ...) 空字符串
createNetFlag('Ground', 'GND', ...) 仍是 GND(正好等于默认值,所以看不出问题)

而且 setState_Net() 修不回来:调用后立刻 getState_Net() 返回新值, 但重新 getAll() 拿到的仍是旧值——网络名跟着符号走,不是可写属性。

⚠️ 2026-09-06 补测:setState_Net() 比「不生效」还糟 —— 它会把网络名清成空字符串。 在没有移动过的端口上直接 toAsync().setState_Net('FAN_TACH').done(), 重新 getAll() 读回来是 '',图上的标签也一起没了(那一段导线于是变成无名网络)。 所以「改一个端口的网络名」这件事只能删掉重建,不能改属性。

规则:旗和端口只能删掉重建,不要移动、也不要改网络名。

await eda.sch_PrimitiveComponent.delete(oldId);
await eda.sch_PrimitiveComponent.createNetFlag('Power','+12V', nx, ny, 0, false);

这个错误几何验证抓不到——旗还好好地连在导线上,只是名字从 +12V 变成了 +5V, 于是那一路电源悄悄挂到了一个不存在的网络上。必须靠 §10 的网络名检查。


4. 导线必须用扁平数组

实测。

await eda.sch_PrimitiveWire.create([x1,y1, x2,y2, x3,y3]);   // ✅ 折线,逐点相连
await eda.sch_PrimitiveWire.create([[x1,y1],[x2,y2]]);        // ❌ 抛 "create failed!"

第二个参数 net 可传可不传;传了也不会阻止 EasyEDA 按几何重新判定网络。


5. getState_Line() 返回的是端点对,不是折线路径 ⚠️⚠️

实测,最坑的一条。 EasyEDA 会把相连的导线合并成一条 wire 图元。 读回来的点列表是每两个点构成一段,而不是首尾相接的折线。

[(370,720),(370,710), (370,700),(370,710), (370,710),(340,710), (340,650),(340,710), ...]
   └─ 段1 ─┘          └─ 段2 ─┘           └─ 段3 ─┘           └─ 段4 ─┘

正确解析

for i in range(0, len(pts) - 1, 2):
    seg = (pts[i], pts[i+1])

错误解析(当成折线 zip(pts, pts[1:]))会凭空造出根本不存在的斜线段, 例如上例会产生 (340,710)→(470,650) 这样一条对角线, 从而误报出一堆假短路。本项目中它一度误报"Q1 栅极与源极短路", 如果照着去"修",反而会把正确的电路改坏。


6. 极性元件:旋转后要复核正负极

实测。 元件符号以放置坐标为中心,引脚按原始定义偏移。 对两脚竖放的元件(rotation: 90 / 270):

rotation pin1 位置 pin2 位置
90 中心 − 20(下方) 中心 + 20(上方)
270 中心 + 20(上方) 中心 − 20(下方)

电解电容(pin1 = 正极)、TVS / 稳压管(pin1 = 阴极 K)在 Y 向上的版面里:

  • 正极 / 阴极要接上方的电源母线 → 用 rotation 270
  • 用 90 会把正极接到地上,图上完全看不出来,DRC 也不一定报

必须逐个读回 getAllPins() 的 pinName(K/A、1/2)来确认,不要靠记忆。

无极性元件(MLCC、电阻)不受影响。

6b. ⚠⚠ create() 的 rotation 与 setState_Rotation() 差 180°

实测(2026-09-04,P6-FAN-EDL 的 D2 = SS34)。 上面那张表只对 create() 成立。

同样传 270 pin1(K) 落在
create(..., 270, ...) 中心下方(−20)
toAsync().setState_Rotation(270).done() 中心上方(+20)

四种角度都量过:setState_Rotation 下 90 → pin1 在下、270 → pin1 在上, 和 create 正好相反。同一批里 create 放好之后再 setState_Rotation 挪位置的 R/C 也全部翻了个个儿(pin1/pin2 互换)。

后果:先 create 一批、再 setState_Rotation 调整方向时, 按 create 时算好的引脚坐标全部作废,二极管 / 电解会反接, 而几何验证一路 PASS(引脚确实落在导线端点上,只是接反了)。

规矩两条

  1. 改过方向的元件必须重新 getAllPins() 读回,再算线。
  2. 非极性的 R/C 一律按坐标连线(「上面那只脚 / 下面那只脚」),不认引脚号; 极性元件按 pinName 认。.claude/p6_nets.py 里的 EXPECT 表就是干这个的 —— 推送前逐个断言引脚坐标,对不上直接终止,别让它画下去。

6c. 连 create() 的 rotation 语义也不是每个型号都一样 ⚠⚠

实测(2026-09-04)。 同一颗 2200µF 电解(C49256788):

页 调用 pin1(正极)落在
P5-HARNESS create(..., 270, ...) 中心上方
C4-SITE1 create(..., 270, ...) 中心下方

同一个料号、同一个参数、相反的结果。原因没查(大概率与符号被放进不同图页时的 默认镜像/子部件状态有关),但结论是确定的:方向表一张都别信。

规矩:极性元件(电解、二极管、TVS、稳压管)放完必须断言一次。

c = pins['C801']            # 读回来的 {引脚号: (x, y)}
assert c['1'][1] > c['2'][1], 'C801 是电解,pin1(正极) 必须在上'

断言失败就把 rotation 翻 180 重放 —— 别去改连线来迁就它, 那样图上画出来的极性符号和实际接法是反的。

6d. 引脚跨距同族也不统一;斜线会被 create() 直接拒掉

实测(2026-09-04,C4-SITE)。

元件 引脚偏移
红色测试点 C9900007423 cx − 20
黑色测试点 C9900007426 cx − 10 ⚠ 同一系列,差 10
0402 电容 ±20
1206 电容 ±15

按名义值算线的后果:算出来的端点和真实引脚差 10,那一段就成了斜线, 而 sch_PrimitiveWire.create() 遇到斜线直接抛 create failed! —— 好在它是硬失败,不是静默画歪。但整批下发会在中途断掉,页面停在半成品状态 (§9c 那种)。

规矩:下发前本地过一遍「横平竖直且非零长度」。

for a, b, c, d in W:
    assert (a == c) != (b == d), '这一段不是横平竖直:%s' % ((a, b, c, d),)

零长度那一半更要紧:create() 不一定拒,但会变成 §8a 那种「看着连上、重画就断」的隐形漏接。


7. 引脚间距 10 → 旗和端口必然打架

实测。 SOT-23-6 / SOP-8 这类器件同侧引脚间距只有 10 个单位, 而旗/端口符号的高度约 15~20 个单位。三个相邻引脚各挂一个符号一定重叠。

解法(择一)

  1. 引出线做成长短不一,让符号在 x 方向错开:
    pin(335,460) -> 引到 x=455   pin(335,470) -> 引到 x=375   pin(335,480) -> 引到 x=415
    
  2. 旋转器件 180°,把需要向下走的引脚(通常是 GND)转到方便的一侧,再竖直引出。
  3. 竖直引出后再挂旗(例如 GND 从引脚直接向下引 30 个单位再放地旗)。

另外注意:元件的位号/型号文字是自动生成的,会占据元件下方约 20~30 个单位。 地旗如果放得太近会压在型号文字上(本项目 J1 的 KF128-5.08-2P-AA 就被压过)。


8. 图框与标题栏

实测(A4 横向,1170 × 825)。 标题栏在右下角,大致占据 x > 520 且 y < 140 的区域。

规则:x > 520 的区域,元件和文字都不要放到 y < 150。 左下角(x < 500)可以用到 y ≈ 150。


8b. 网络端口的符号与文字:rotation 和 dir 两个都管⚠️⚠️

实测(2026-09-03,P5 空白页上把 dir × rotation 六种组合并排画出来量的)。 早期版本写"标签永远在引脚右侧"是错的;后来改成"跟着 rotation 走"也只对了一半—— dir='IN' 的方向和 'BI' / 'OUT' 正好相反:

dir rot=0 符号/文字在 rot=180 符号/文字在
BI(默认) 引脚右侧 引脚左侧
OUT 引脚右侧 引脚左侧
IN 引脚左侧 ⚠️ 引脚右侧 ⚠️

记法:箭头永远指向信号流的方向。IN 是"信号流进本页",箭头指向引脚, 所以符号体在引脚的背面;OUT/BI 箭头背离引脚,符号体在引脚正面。

占据的横向宽度约 40 + 5×字符数(PORTTEST 8 字符实测 80 个单位; 按 40 + 6.7×字符数 估算是偏保守的安全值)。写成范围:

符号在哪侧 占据的横向范围(引脚在 X)
右侧 X ~ X + 40 + 6.7×字符数
左侧 X − 40 − 6.7×字符数 ~ X

(40 是箭头符号本身的宽度,6.7/字符 是混排中英文的经验值;纯中文再宽些。)

两个真实后果(都在 P3B 第一版上出现过):

  1. 把 rot=180 的端口放在 x=60,K1_VDN 的文字跑到 x≈−25,整个漂到图框外面。
  2. 左边器件的 rot=0 端口(占 X..X+100)和右边器件的 rot=180 端口 (占 X'−100..X')在中间对撞——两列端口看着离得很远,文字却叠在一起。

规则:算净空时按上表把符号宽度也算进去,并且认准方向。 rot=180 的端口,引脚 X 至少要留到 图框左边界 + 40 + 6.7×字符数。

✅ dir 是读得回来的(2026-09-06 实测,纠正之前的说法)

重建端口最怕的是不知道原来的 dir —— 传错了标签方向就整个反过来。 以前的结论是「读不回来,所以端口只增不重建」(p1_add.py 当年就是被这一条卡住的)。 实际上它就在 getState_Component() 里:

c.getState_Component()
// -> {libraryUuid:'0819f05c…', uuid:'3eee55ea4a7114aa', name:'Netport-BI'}

name 就是 Netport-BI / Netport-IN / Netport-OUT。 ⇒ 删掉重建之前先读它,原样传回 createNetPort 即可, 整页/整批改端口网络名从此是安全操作(见 .claude/pinmap.py)。

⚠️ 顺带:getState_ComponentType() 对端口返回 'netport'、对旗返回 'netflag'、 对元件返回 'part'、图框返回 'sheet' —— 这是把三者分开的最简判据。

8a. 端口/旗的引脚不要和元件引脚放在同一个坐标 ⚠️⚠️

实测(P1 的 U29、P2 的 R122,2026-09-03)。 把 netport 直接放在元件引脚的坐标上, GUI 里看着"接上了",实际上两者之间是一段零长度导线(create([x,y,x,y]))。

后果分两层:

  1. getState_Line() 会把它当成一段返回,verify_sch.py 判定"引脚落在某条导线段上"因此通过—— 于是这个漏接在几何验证里完全隐身。
  2. 一旦有人重画这一页(读回几何 → 过滤掉 a == b 的退化段 → 重画), 那段零长度线就没了,引脚真的断开。P2 的 K1_VUP(一路音量+)和 P1 的 BLD_REF/BLD_A(TL431 的 REF 和阴极)都是这么暴露出来的。

规则:端口和旗一律放在一段真导线的末端,从元件引脚至少引出 20~40 个单位再放。 重画整页时不要简单地丢掉退化段——先把它们列出来,逐个补成真导线。

# 回读几何时把退化段单独挑出来,别默默丢掉
degenerate = [(a, b) for a, b in segs if a == b]

8a-2. 端口/旗的引脚必须落在导线端点,压在中段不连 ⚠️⚠️

实测(P1-INPUT 的 SCL,2026-09-03)。 这是 §8a 的近亲,比它更隐蔽: 端口引脚确实压在一条真导线上,但压的是那条导线的中段而不是端点, 于是 EasyEDA 判定不相连 —— 而 verify_sch.py 的口径是「引脚落在某条导线段上」, 所以它照样 PASS。

端口 引脚坐标 落在 PCB 里的网络
SDA (910, 380) 导线端点 [910,380 → 910,570] SDA ✅
SCL (920, 340) 导线中段 [920,320 → 920,570] $1N257 ❌

后果:U13(输入侧检流 INA226)的 SCL 没接上 I²C 总线,整片芯片是死的。 画面上两个端口长得一模一样,DRC、ERC、verify_sch --nets、跨页对账四道全过。

怎么发生的:先画了端口下面那一小段引线,之后又画了一条更长的跳线 (A0 → SCL)沿着同一个 x 多走了 20 个单位,端口引脚就成了长线的中间点。

判据只有一条:端口/旗的引脚坐标必须等于某条导线段的首点或末点。 .claude/port_audit.py 全工程扫这一条(572 个旗/端口跑一遍约 30 s):

/c/Python314/python .claude/port_audit.py     # 端点 = 连;中段/悬空/压引脚 = 断

⚠️ 写这个检查时必须按 §5 的端点对解析导线。按折线解析会凭空造出斜线段, 572 个旗里有 197 个被误报成「顶点可疑」—— 其中包括一大片工作正常的 GND 旗。 误报率高到这个程度的检查等于没有检查。

修法:不要动端口(§3b:移动旗/端口会丢网络名)。改导线,让端点落到端口引脚上:

await eda.sch_PrimitiveWire.delete(oldWireId);                     // 整条路径是一个图元
await eda.sch_PrimitiveWire.create([890,570, 890,440, 850,440, 850,340, 920,340]);
await eda.sch_PrimitiveWire.create([920,340, 920,570]);            // 端口引脚成为两段的共同端点

改完在 PCB 里走一次 设计 → 从原理图导入变更(Alt+I)才会生效; 那个对话框本身就是最硬的判据 —— 它把 U13:2 $1N257 → SCL 逐条列出来。

8a-2b. 同一个 bug 的第二种成因:坐标不是 5 的倍数(2026-09-04)

上面那次是「后画的长线盖过去」。这次是更蠢也更容易复现的一种:

w(205, 340, 175, 340, 175, 362)          # 导线端点 y = 362
F.append(['Ground', 'GND', 175, 362, 180])   # 旗也写 362

旗/端口的坐标会被吸附到 5 的倍数(362 → 360),导线端点不会。 两边差了 2 个单位,旗的引脚就落在 340 → 362 这一段的中段, J31 pin1 的地实际是断的。渲染、DRC、verify_sch --nets、跨页对账又是四道全过, 只有 port_audit.py 抓得到。

防呆:下发前断言所有坐标都是 5 的倍数。 只要这一条成立, 旗/端口就永远和导线端点对得齐(c1_conn.py / c4_site.py 里已经加了):

for a, b, c, e in W:
    assert (a == c) != (b == e), u'不是横平竖直或零长度'
    for v in (a, b, c, e):
        assert v % 5 == 0
for it in F + PT:
    assert it[2] % 5 == 0 and it[3] % 5 == 0

⚠️ 这也说明**「上一轮 port_audit 可疑 0」不能当成这一轮还是 0** —— 每次改完都要重跑,Board1 和 Board2 各跑一次(--board Board2)。


8b. create() 的导线会在元件引脚处被截断(2026-09-04)

想画一条「从芯片左边进、穿过本体、右边出」的直通线是做不到的:

await eda.sch_PrimitiveWire.create([620,540, 860,540]);   // 一条横穿 U91 的线
// 读回来变成两条:[620,540 -> 715,540] 和 [860,540 -> 805,540]
// 715 和 805 正好是 U91 的两个引脚 x —— 中间穿过芯片本体那一段直接没了

所以内部短接的直通器件(USBLC6-2SC6 的 1/6 是同一个 I/O1、3/4 是同一个 I/O2; 共模电感、ESD 阵列、磁珠阵列都是这类)在网表里天然是两段, 指望「一条线两个脚都搭上去 = 一个网络」是不成立的。

正确做法:承认它是两段,各起一个名字(本项目用 _P 手机侧 / _C 载板侧)。 这反而是想要的结果 —— 网表里是两个网络,PCB 就必然把走线串在两个焊盘中间, 而不会偷偷从旁边绕过去只搭一个脚。

判据:verify_sch.py --nets 的「网络明细」里,如果出现没有名字的 [ 2] X.a Y.b 分组,就是这种被截断的段落忘了起名。


8b-2. 「先清空再重画」的脚本:守卫必须放在删之前(2026-09-04)

改造已有页面的脚本通常长这样:清掉旗/端口/导线/文字 → 读回元件引脚 → 重画。 如果中间夹了一条一致性检查,它必须在删之前跑:

# ✗ 错的:第二次重跑时连接器已经删过了,conn=0 触发退出,
#         但导线/旗/端口已经在上一句里删光 —— 页面被留在半清空状态
n = call(js_that_deletes_everything)
if n['conn'] != 5:
    raise SystemExit(...)

而且守卫条件本身要对重跑成立:删连接器这种一次性动作, 第二次跑时正确的期望是 conn == 0,不是 5。

判据:脚本必须能连跑两次结果一致(幂等)。跑完 verify_sch.py 立刻验一次, FAIL 就说明页面被留在中间状态了。


8c. xpage.py 对单页板会误报

跨页对账的判据是「全工程只有 1 个实例的 netport = 漏接」。 这条只对多页工程成立:单页板上一个孤立的 netport 只是给这段线起个名字, 它本来就没有对端。Board3(T1-USBTAP,一页)跑出来的 2 个「漏接」是已知误报, 以 verify_sch.py --nets 的网络明细为准。

8b-2. 端口不需要错开,一列 10 单位间距就够

实测。 §7 说"引脚间距 10 → 旗和端口必然打架",那是旗(Power/Ground 符号高约 20)。 netport 的符号只有约 10 单位高,在 10 单位的引脚间距上排成一列完全不重叠—— P3 的 ESP32 21 个端口、P3B 的 74HC595 7 个端口都是一列排下来的,渲染出来很整齐。

早期为 netport 做的"2 级 / 3 级 x 错开"是多余的,还会把页面横向撑爆 (错开后每级都要留 40 + 文字宽,三级就是 300 多单位)。先按一列排,不够再说。

旗(netflag)仍然需要错开或拉开距离。

8c-2. 元件的型号文字画在符号下方,会压住下面的旗

实测。 J22(USB-C)的 TYPE-C-31-M-12 型号文字画在符号下方约 20~30 单位处, 把放在那儿的地旗盖住了。器件符号下方 30 单位内不要放旗和文字。

8b-3. 端口标签压字的通用解法

把端口放在某个元件旁边时,标签常常正好压在那个元件的位号或型号上 (P2 一次踩了 5 处:按键端口压住栅极电阻、FLT 端口压住 MOS、I²C 端口压住 U8 的型号文字)。

解法:把端口挪到一条向上/向下的短支线末端,让标签落在空行里。 P2 的按键三路、P4 的 VBUS_EN1~5 都是这么做的—— 从器件引脚先竖直引出 40~85 个单位,再在末端放端口,标签就落进了器件之间的空白带。

元件自己的文字:位号和型号画在符号右侧(旋转 90/270 时仍然横排在右侧), 地旗的 GND 文字画在符号下方。


8c. A4 标题栏的实际范围比想象的大

实测。 §8 早期写的"x > 520 且 y < 140"偏乐观。实测 A4(1170×825)标题栏 占据大约 x > 455 且 y < 190。

左下角 x < 450 的区域可以一直用到 y ≈ 120,是放大段说明文字的好地方 (本项目 4 行器件参数说明就放在 (60, 185/165/145/125))。


8d. 引脚跨距因符号而异,必须读回

实测。 不要假设两脚器件的引脚在中心 ±20:

器件 跨距
0402 电阻 / 100nF / 10nF ±20
22µF 1206 ±15
33nF +20 / −15(不对称)
2512 分流电阻(竖放) ±20
SOT-23(2N7002/AO3400) G(−20, 0)、D(+10, +20)、S(+10, −20)
KF128 / Micro-Fit 2P 都在左侧,(−20, ±5)
A1251 4P 左侧 (−25, +15/+5/−5/−15),另有两个固定脚 (−25, ±35);符号体比引脚宽得多,rot270 时横向占 (−45, +54),型号文字再往右占到 +121
100pF 0402(C1546) ±15,不是 ±20
2.2µF 0805(C377773) ±20,但传 rot=270 得到横放,传 rot=0 才是竖的
USB-A 母座(C2979057) 一侧 (+40, +15/+5/−5/−15) = VCC/D−/D+/GND,另一侧 (−40, ±15) = 两个 SHLD
TPS22918 SOT-23-6 左 (−60, +10/0/−10) = VIN/GND/ON,右 (+60, +10/0/−10) = VOUT/QOD/CT

而且有的符号默认方向就是竖的:给 2.2µF/0805 传 rotation: 270 反而得到横放。

规则:批量放完元件后一律 getAllPins() 读回真实坐标再算线,不要按公式推。


8e. rotation: 180 的文字仍然是正的

实测。 担心"转 180° 会让引脚名倒过来"是多余的——EasyEDA 会保持文字水平可读。 所以当符号的引脚顺序与信号流方向相反时(本项目 TPS25940 的 OUT 在左、IN 在右), 放心用 rotation: 180 把它转过来,比绕线穿过整个符号干净得多。


9. 已知不可用 / 需绕开的 API

API 现象 替代
sch_PrimitiveAttribute.createNetLabel(x,y,net) 总是返回 undefined,不抛异常。在导线端点、导线中点、空白处都试过,全部失败(文档标注 "ADD since EDA v4",疑似本客户端版本不支持或被权限拦截) 用 createNetFlag('Power'/'Ground', ...) 和 createNetPort('BI'/'IN'/'OUT', ...),功能等价,跨页连接更规范
sch_PrimitivePin.getAll() 对元件实例返回空数组(它只在符号编辑器里有效) 用 component.getAllPins()(ISCH_PrimitiveComponent 的方法)
引脚的网络名 ISCH_PrimitiveComponentPin 没有 getState_Net() 只能靠几何反推,见 §10
wire.getState_Net() 新建的导线一律返回空串(网络在编译/保存时才赋值) 同上

9b. create() 的第一个参数是对象,不是两个字符串 ⚠️

实测。 完整签名:

eda.sch_PrimitiveComponent.create(
  {libraryUuid, uuid},   // ← 一个对象,不是 (libraryUuid, uuid) 两个参数
  x, y,
  subPartName,           // 没有子部件就传 undefined
  rotation, mirror, addIntoBom, addIntoPcb)

写成 create(LIB, UUID, x, y, rot) 会被服务端拒掉,桥接返回 HTTP 500 + {"success":false,"error":"数据不符合规范"}。

真正的坑在于批处理的原子性:整段 JS 不会回滚。 换器件的惯用写法是 delete(old) 紧接 create(new)——create 参数写错时,删除已经生效了, 页面上凭空少一个元件,而错误信息只说"数据不符合规范",不提是哪一步。

规则:换器件时先 create 后 delete,或者先用一次 create 试出参数正确再进循环。 桥接客户端要把 HTTPError 的 body 打出来,否则只能看到一个光秃秃的 500 (.claude/eda.py 已经这么做了)。


9c. 桥接单次请求 30 秒超时 —— 大页面必须分批下发 ⚠️

实测。 一次 POST /execute 里塞 143 段导线 + 31 个旗 + 49 个端口 + 11 条文字, 服务端在 30 s 时返回 HTTP 500 {"success":false,"error":"Request ... timed out after 30000ms"}, 但 EasyEDA 那边其实还在继续执行——结果是页面处于"导线画完了、端口只画了一半"的 中间状态,verify_sch.py 报一堆单引脚网络,看着像布线错误,其实是推送被截断。

规则:导线 ≤40 段 / 旗、端口 ≤25 个 / 文字 ≤20 条,各自分批。 .claude/build.py 已经这么做了。推送脚本一律先全删再重画,所以重跑安全。

9d. 页面切换与截图

await eda.dmt_EditorControl.openDocument(pageUuid);   // 返回 "<pageUuid>@<projectUuid>"
const cur = await eda.dmt_Schematic.getCurrentSchematicPageInfo();  // .name 是显示名
  • 切换是异步的:openDocument 返回后立刻读 getCurrentSchematicPageInfo() 可能还是旧页。脚本里每次都要核对 cur.name 再动手,否则会画到别的图纸上。
  • getCurrentSchematicAllSchematicPagesInfo() 返回的 name 是小写(p1-input), 而 getCurrentSchematicPageInfo().name 是大写(P1-INPUT)。 reorderSchematicPages(schUuid, 页对象数组) 要传前者返回的对象,按小写名去匹配。 排序结果不体现在返回数组的顺序里,要看 titleBlockData['@Page No']。

截图(自查排版的关键手段,不用麻烦用户):

await eda.dmt_EditorControl.openDocument(uuid);
await new Promise(r => setTimeout(r, 1500));      // 不等会截出全白图
await eda.dmt_EditorControl.zoomToAllPrimitives();
await new Promise(r => setTimeout(r, 1500));
const img = await eda.dmt_EditorControl.getCurrentRenderedAreaImage();  // Blob
const bytes = new Uint8Array(await new Response(img).arrayBuffer());
let s=''; for (let i=0;i<bytes.length;i++) s += String.fromCharCode(bytes[i]);
return btoa(s);                                    // base64 传回来存 PNG

Blob 直接 JSON.stringify 会变成 {},必须转成 base64。 脚本见 .claude/shot.py。画完一页先自己截图看一遍,能省掉大部分返工。


10. 验证流程(每次改完必做)

因为拿不到 EasyEDA 自己算的网表,必须自己按几何反推。 scripts/verify_sch.py 已经把整套做好了:

/c/Python314/python .claude/skills/easyeda-sch-geometry/scripts/verify_sch.py --port 49620

它做六项检查(前五项自动判定,第六项需人工扫一眼):

  1. 悬空引脚检查 —— 每个元件引脚、每个旗/端口的引脚,是否落在某条导线段上。 任何非零结果都是真错误(createNetFlag 的 5 单位吸附最常在这里暴露)。
  2. 网表反推 —— 对导线段做并查集(共享端点 + 端点落在另一段内部的 T 型连接), 输出每个局部网络包含哪些引脚。
  3. 单引脚网络 —— 只有一个引脚的网络必定是漏接。
  4. 无网络名的旗/端口 —— 见 §3b,移动过的 netport 会变成空名。
  5. 同一网络出现多个名字 —— 两个不同名字被短在一起。
  6. 网络名清单 —— 列出所有网络名及其引脚数。这一条要人工扫一眼: 意外出现的 +5V(Power 旗的默认名)或拼错的名字, 前五项检查全部通过也发现不了,只有对着清单看才认得出来。

交叉不等于相连:只有"端点落在另一段上"才算 T 型连接, 两条线中间交叉(没有端点)不连。布线时要主动利用这一点, 但也要小心——规划路径时必须检查自己的引出线有没有恰好终止在另一条网络的导线上。

还是要人眼看一次

几何验证只能证明"连对了",证明不了"看着对"。方向、重叠、压字这些 只有截图才能发现(本项目三个方向问题全是靠人眼截图发现的)。 每画完一页,让用户截一张图确认。


10b. 先在本地跑一遍连通性检查,再推给 EasyEDA

经验(P2 页 51 元件 / 251 导线段)。 推一次 + 跑 verify_sch.py 的往返要几十秒, 而密集页面第一遍必然有短路和悬空。把网表定义写成 Python(每段线带一个网络名), 在本地做同样的并查集反推,能在推送前就抓出短路:

  • 端点落在另一条线段上 = 相连;纯交叉 = 不相连(和 EasyEDA 一致)
  • 把几何反推出的网络与声明的网络名比对 → 不一致就是短路
  • 允许 GND / +3V3 / 靠端口连接的网络分成多块,其余网络分块 = 漏连

本项目靠这个在推送前抓到 5 处短路,其中两处(R14 的引脚正好落在 OVP 横线上、 J7 地线端点落在 +3V3 竖线上)光看图完全发现不了。

另外:元件位置一改,pins.json 必须重新读回——本地检查用的是旧坐标就全白做了。

scripts/local_netcheck.py 就是这个检查器(w() 声明导线、gnd()/pwr()/port() 声明旗与端口、check() 返回错误列表、crossings() 列出交叉点)。 用法:先把 getAllPins() 的结果存成 pins.json,再写一个 nets.py 描述整页连线。


11. 推荐作业顺序

1. lib_Device.getByLcscIds([...])   一次性取回所有器件的 uuid / libraryUuid(按 supplierId 建索引)
2. sch_PrimitiveComponent.create()  批量放置 -> toAsync().setState_Designator().done()
3. getAllPins()                     读回真实引脚坐标(不要靠算)
4. 用真实坐标在本地算出导线 / 旗 / 端口的坐标(对齐到 5 的倍数)
5. 批量下发导线 -> 旗 -> 端口 -> 文字
6. 跑 verify_sch.py                 确认 0 悬空、0 单引脚网络
7. 请用户截图                        确认方向与排版

旗和端口要改位置就删掉重建(§3b),元件可以直接移动。

返工时不要做外科手术式的局部删改。 EasyEDA 会合并导线,按坐标精确匹配去删除某几条几乎必然失败 (本项目实测 wiresDeleted: 0)。正确做法是 getAllPrimitiveId() → 全删 → 用更新后的完整列表重画,成本很低。 元件可以单独移动和旋转(toAsync().setState_X/Y/Rotation().done()),不必删; 旗和端口不行,见 §3b。


12. 文档页:非电气图元(矩形 / 折线 / 富文本)

实测(2026-09-03,P5-HARNESS 页)。 线束表、接点说明、对照表这类内容 不要用 sch_PrimitiveWire 画——导线会被判成网络,verify_sch.py 会报一堆 单引脚网络,DRC 也会抱怨。EasyEDA 有三个纯绘图图元,没有任何电气含义:

eda.sch_PrimitiveRectangle.create(topLeftX, topLeftY, width, height,
                                  cornerRadius, rotation, color, fillColor,
                                  lineWidth, lineType, fillStyle);
eda.sch_PrimitivePolygon.create([x1,y1, x2,y2, x3,y3], color, fillColor,
                                lineWidth, lineType);   // 扁平数组,同 Wire
eda.sch_PrimitiveText.create(x, y, content, rotation, textColor, fontName,
                             fontSize, bold, italic, underLine, alignMode);
  • 矩形的 topLeftY 是矩形的上边,在 Y 向上的坐标系里就是 y 更大的那条边, 高度往 y 减小的方向长。create(200, 700, 300, 120, …) 得到的框是 x 200~500 / y 580~700。
  • lineType:0 实线 / 1 短划线 / 2 点线 / 3 点划线。
  • 颜色传十六进制串('#CC0000'),null = 默认色。bold=true 有效,文字仍水平可读。
  • 这三类图元都有自己的 getAll(),整页重画时要一并删掉, 否则会一层层叠上去(build.py 的清空语句只删了 wire / netflag / netport / text)。

文字宽度经验值(实测量出来的,用来算净空、防越界):

字号 中日韩字符 ASCII
10 ≈ 10 单位 ≈ 5.1 单位
11 ≈ 11 单位 ≈ 5.4 单位
14 ≈ 14 单位 ≈ 7 单位

即 宽度 ≈ 字号 × (CJK 数 + 0.5 × ASCII 数)。文字的 y 大致是垂直中心, 左对齐于 x。行距取 17~20 单位在 9~11 号字下都不挤。

A4 可用区实测:x 20~1145、y 10~812;标题栏占 x > 455 且 y < 192。 把这两条写进本地自检(逐条文字算出宽度后检查右边界和标题栏), 比推上去再截图看要快得多。

离板器件:sch_PrimitiveComponent.create(..., addIntoBom, addIntoPcb) 最后两个参数传 false, false,元件就只出现在原理图上,不进 BOM、不上 PCB。 放完用 getState_AddIntoBom() / getState_AddIntoPcb() 读回确认(实测确实是 false)。 本项目 P5 的手机侧电容组 C801~C806 就是这么放的。


9e. 建库(封装 / 器件)的三个坑(2026-09-04 自建 BM24-50DS 时实测)

本 skill 主要讲原理图,但建库这三条是同一批 API 的,放这里免得再踩。

  1. 系统库(立创)的封装既复制不了也打不开。 lib_Footprint.copy(fpUuid, 系统库, 个人库, …) 静默返回 undefined(不抛错, success 还是 true);lib_Footprint.openInEditor(fpUuid, 系统库) 返回 false。 → 「复制立创封装再改焊盘编号」这条路走不通。只能 lib_Footprint.create() 建空封装, 自己把焊盘一颗颗画出来。判据是返回值,不是 success —— 这两个 API 失败时都不抛。

  2. lib_Device.create() 的 property 参数会被吞掉。 建出来的器件 Add into BOM / Convert to PCB 都是 no —— Convert to PCB: no 意味着这个器件根本不会转到 PCB,而原理图侧一切正常, 要到导入 PCB 时才发现少了元件。供应商编号 / 制造商也全丢。 → 建完必须紧跟一次 lib_Device.modify(uuid, lib, undefined, undefined, undefined, undefined, prop), prop 用嵌套结构 {name, addIntoBom:'yes', addIntoPcb:'yes', otherProperty:{…}}, 然后 lib_Device.get() 读回确认。 顺带:create() 里给 association.symbol = {uuid, libraryUuid} 指向系统库符号时, 它会在个人库里复制出一个新符号(新 uuid),器件挂的是副本 —— 这是对的, 但缓存里要记副本的 uuid,不是系统库那个。

  3. PCB / 封装域的坐标单位是 1 mil,和原理图的 0.01 inch 差 10 倍。 接受浮点:0.35 mm = 13.7795 mil 原样传进去,读回来一致。 建封装时统一在 mm 里算、下发前乘 39.3700787,读回时除回去比对 (fp_bm24.py 的 check() 就是这么写的:编号齐全 / 坐标 / 层 / 同排间距 / 奇偶分排 / 电源触点,六项)。

常用图层号(EPCB_LayerId,直接传数字即可): 1 顶层、2 底层、3 顶层丝印、5 顶层阻焊、7 顶层锡膏、9 顶层装配、 11 板框、13 文档层、48 元件外形、49 元件标识。 焊盘外形用字符串:['RECT', w, h, round] / ['ELLIPSE', w, h] / ['OVAL', w, h]。


9c-2. create() 传了 libraryUuid: undefined 会永远挂着(2026-09-04)⚠️⚠️

实测,而且骗了我一个多小时。 sch_PrimitiveComponent.create({libraryUuid, uuid}, ...) 如果 libraryUuid 是 undefined,它既不 resolve 也不 reject —— promise 永远悬着。

外在表现是「桥接 30 秒超时」,于是很自然会往「批量太大」上想。别上当:

  • 一批 5 个超时 → 缩到 2 个还超时 → 缩到 1 个仍然超时。 到这一步就该改判:不是批量问题,是某一颗器件本身卡住了。
  • 逐颗计时:16 颗里只有开头两颗卡满 30 秒,其余 14 颗都是 0.7 秒。 这两颗恰好是我给放置表加 lib 字段时漏掉的那两条(只有 5 项,第 6 项是 undefined)。

防呆:放置表里每一条都断言字段齐全再下发。

for it in out:
    assert len(it) == 6 and it[5], u'placement 少了 libraryUuid: %s' % (it,)

顺带两条同样有用的:

  1. 超时的 create 并没有被取消,它还在客户端里挂着。所以「超时 → 立刻重试」 会叠出一堆永不返回的 promise。先停手,把根因找出来。

  2. 真遇到「库件要联网取、确实慢」时,可以用甩出去 + 轮询绕开单次请求的 30 秒上限(globalThis 在两次 /execute 之间是保留的):

    globalThis.__pl = {done: 0, err: null, n: N};
    (async () => { for (const it of ITEMS) { try { await eda.…create(…); }
                   catch (e) { globalThis.__pl.err = String(e); } globalThis.__pl.done++; } })();
    return 'fired';
    

    轮询 globalThis.__pl 直到 done >= n。轮询请求里不要再 openDocument。 ⚠️ 但这个模式只对「慢」有效,对「永不 resolve」无效 —— done 会一直是 0, 看起来和卡死一模一样,所以先排查参数,再考虑加轮询。

9f. 自建封装不只是为了改编号 —— 立创的焊盘尺寸也会错(2026-09-04)

顶针 C5221288(YTC1P-2510-01)的立创封装 SMD-1_BD2.0-H3.6 焊盘是 Ø2.5, 而顶针法兰只有 Ø2.0。单看没问题,一旦要按实测坐标密排就致命: 小米8 四个侧键触点最近的一对只隔 2.217 mm,Ø2.5 的两个焊盘直接叠在一起短路, 而原理图侧完全看不出来(网表是对的),只有 PCB 摆位时的几何检查能抓到。

教训:用实测坐标钉死位置的器件,摆位前先把焊盘直径和实测最小间距对一遍, 别等 DRC。缩封装的判据是 最小间距 − 焊盘直径 ≥ 设计规则的净间距。

9g. 焊盘归属哪个元件:只能靠 primitiveId 前缀(2026-09-04)⚠️⚠️

写「焊盘级净间距检查」时必须知道每个焊盘属于哪个元件(同封装内的焊盘挨得近 是正常的,跨元件挨得近才是错)。三条路,只有第三条对:

做法 结果
坐标匹配 getAllPinsByPrimitiveId() 回来的 x/y 有浮点尾巴对不上;而且 POLYGON 型焊盘的 x/y 根本不是形心,会漏一大片
pad.getState_ParentComponentPrimitiveId() pcb_PrimitivePad.getAll() 回来的对象上没有这个方法 —— TypeError: not a function
pad.primitiveId 以父元件的 primitiveId 为前缀 ✔ 元件 C701 = 356da09e850d14fa,它 1 脚 = 356da09e850d14fae16

第二条最坑:官方文档里有这个方法,而且别处的代码用 try/catch 包着它也「能跑」 (pcb2_place.py 的 push_outline 就是靠它区分自由焊盘的)—— 但它其实一直在抛异常,catch 之后就把所有焊盘都当成了自由焊盘。 那段代码没出事纯属侥幸:它按焊盘名字 MH* 过滤,正好只有自由焊盘叫这个。

// 长的 id 优先,避免前缀套前缀
const ids = Object.keys(name).sort((a, b) => b.length - a.length);
for (const id of ids)
  if (pid.length > id.length && pid.indexOf(id) === 0) { hit = name[id]; break; }

9h. 焊盘的 rotation 是弧度,元件的 rotation 是角度(2026-09-04)⚠️⚠️

pcb_PrimitivePad.getState_Rotation()        -> 1.5707963267948963   // = π/2
pcb_PrimitiveComponent.getState_Rotation()  -> 90

按角度写 if (rot % 180 === 90) swap(w, h),弧度值永远不等于 90, 于是所有旋转过的矩形焊盘长短边都被认反。

实测后果:J23(Type-C)那 12 个 0.30 × 1.30 的信号焊盘,rot=90°、y 向 0.5 mm 间距。 认反之后按 1.30 宽 × 0.30 高算,判成两两重叠 −0.30 mm; 真实净间距是 +0.20 mm。一口气 13 条假报。

这类错误最阴的地方是只错一半:不旋转的焊盘全部正确,只有旋转过的错。 所以现象看上去像「某个封装画坏了」,而不像「单位换错了」—— 会把人引去查封装,查不出问题来。

判据写成两边都吃得下的形式:

def deg(rot):
    return rot * 180.0 / math.pi if abs(rot) <= 2 * math.pi + 1e-6 else rot

这是 §6b/§6c/§6d 那条「几何量必须读回、不能推」的又一例, 而且更进一步:读回来了还要确认单位。

9i. 摆位过了 ≠ 能打样:包围盒检查有两个系统性盲区(2026-09-04)

pcb2_place.py 那套「元件焊盘包围盒不重叠」的本地检查,漏掉两整类东西:

  1. 自由焊盘不在检查范围里。 载板自己的 4 个 M3 孔、35 个手机定位孔 都是 pcb_PrimitivePad.create() 出来的自由焊盘,不属于任何元件, 所以「元件之间不重叠」检查不到它们。 实测后果:M3 孔 (8,127) 的 Ø6 焊盘和 CH1 的定位孔 P1H2 重叠 0.93 mm, 而且那两个孔干脆就落在手机本体投影里 —— 四角布孔是在手机位拍板之前放的。
  2. 包围盒不重叠 ≠ 净间距达标。 §25.3 的顶针就是这样: 两个 Ø2.5 圆焊盘中心距 2.217 mm,包围盒和净间距都是负的, 但如果焊盘再小一点(比如 Ø2.1),包围盒就「过」了而净间距只剩 0.1 mm。

所以摆完位要再跑一遍按焊盘真实形状算的净间距检查(.claude/pcb2_clear.py), 判据 跨元件不同网络 >= 设计规则净间距。同封装内的另算,只看有没有负值。


9j. 铺铜:优先级是「数字小的先铺、占地盘」,而 create() 的优先级参数被忽略(2026-09-05)⚠️⚠️

分割电源面全靠优先级互相裁剪,而这一位的语义文档里没有 (PCB_PrimitivePour.create() 的 pourPriority 参数只有类型没有说明)。 两条实测:

  1. 语义:铺铜管理器对话框底部自己写着 ——

    提示:重建多个铺铜时,系统将按照优先级数字从小到大来执行铺铜

    先铺的占住地盘,后铺的让开。所以 GND 底填要给最大的数字,电源岛给小数字。 (下板 pcb_pour.py 里 GND=1 / 电源岛=5 正好是反的 —— 那边 19 块铜面 从不重叠,所以这个错误一直没暴露。要在载板上抄它之前先把这一位改过来。)

  2. create() 的第 7 个参数 pourPriority 不生效。 传 9 回读是 1。 实测下板那 14 块铜面回读的优先级是 1,2,3,4,4,5,5,6,7,8,9,10,11,12 —— 基本就是创建顺序,和脚本里写的 1/2/3/4/5 对不上。

  3. ⚠️⚠️ 别用 pcb_PrimitivePour.modify(id, {pourPriority: n}) 补设 —— 它会把客户端弄死。 实测:不报错、优先级也没改(回读仍是 1),但紧接着点「重建所有」就永远填 0 块 —— 客户端进了 §9k 那个「后台任务卡在零进度」的状态,只能重载,而重载又会丢掉已建的边框。

  4. ✅ 正确做法:靠创建顺序。 优先级是按层、按 create() 的调用顺序 从 1 开始编的,所以想让谁赢就先建谁:

    电源岛先建(拿到 1,2,3…),GND 底填最后建(拿到最大的数字)。

    2026-09-05 在载板 Inner1 上做过对照实验(右口袋一块没有焊盘的空地, preserveSilos=true,ZZ-ISLAND 12×12 先建、ZZ-FILL 30×30 后建且完全包住它):

    铺铜 优先级 结果
    ZZ-ISLAND 1 完整活着,11.8 × 11.8 mm @ (191.1, 19.1)
    ZZ-FILL 2 / 4 多边形里多出第二个环(洞),起点正是岛的角
    ZZ-ISLAND1(几何同 ISLAND,但后建) 3 被前面那块同网络的吃得一点不剩

    怎么量出来的(这是核对铺铜结果唯一靠得住的办法):

    const f = poured.getState_PourFills();      // 每段填充一个对象
    f[0].path.complexPolygon                    // [外环, 洞1, 洞2, ...]
    

    每个环是 x1 y1 'L' x2 y2 ... 'ARC' 角度 ex ey ...,解析时两个坑:

    • ⚠️ 单位是 10 mil(3.937 单位/毫米),不是 mil —— 拿 MIL=39.37 去除会差 10 倍。
    • ⚠️ 'ARC' 后面第一个数是角度,不是坐标。 图省事「把所有数字按奇偶分 x/y」 会从第一段圆弧开始整体错位:实测把载板地平面的包围盒算成 -22.9,-22.9 ~ 218.5,219.4(角度 −89.999 混进了 x), 正确解析出来是 0.6,0.6 ~ 219.4,134.4(= 220×135 内缩 0.5,对得上)。

    顺带一个有用的判据:环数 > 段数,多出来的就是洞。 载板地平面 81 段 / 150 环 —— 69 个洞正是那些非 GND 直插焊盘的避让。

  5. ⚠️ 优先级的步长是 2,不是 1。 上面那句「从 1 开始编」只在同层只有一两块时 看着像 1,2。2026-09-05 在载板 Inner1 上一次建 6 块,回读是 1,3,5,7,9,11 (另一层单独那块仍是 1)。所以脚本里只能断言相对次序 (prio[k] < prio[k+1]),不能断言「第 k 块的优先级 == k」—— pcb2_pour.py 第一版就是后者,白报了一次「优先级和计划对不上」。

9j-2. 铺铜的焊盘连接方式:connectMode 藏在 DRC 规则里,0 = 十字花 / 1 = 全连接(2026-09-05)⚠️⚠️

PCB_PrimitivePour 上没有这个属性 —— create() 的 9 个参数里没有, setState_* 的 10 个里也没有。它是一条设计规则,在

(await eda.pcb_Drc.getCurrentRuleConfiguration()).config
  ['Plane']['Copper Zone']['copperRegion'].form
    .singleLayerPadModel.data['1']   // 单层焊盘(贴片)
    .multiLayerPadModel .data['1']   // 多层焊盘(通孔)
      -> {connectMode, lineClearance: 0.254, lineWidth: 0.254, lineAngle: 90}

connectMode 的取值文档里没有,实测(同一块铜面改前改后各铺一次、读回对比):

connectMode getState_PourFills() 回来的样子
0(默认,十字花) VO4 那块吐 18 个 fill = 1 个填充区(fill:true,9 环:外环 + 8 个反焊盘洞)+ 17 条辐条(fill:false,lineWidth:1(=10 mil 单位)、每条 2 个点,从焊盘中心向外 1.107 mm)
1(全连接) 同一块铜面只剩 1 个 fill,自己的焊盘并进铜里,环只剩异网焊盘的洞

⚠️⚠️ 这一条会让铺铜的几何校核给出假的「断路」。 十字花时铜面多边形把自己的 焊盘也挖空,只留 4 条 0.254 mm 的描边辐条 —— 如果校核脚本只把 fill:true 的环画进栅格 (很自然的写法,因为描边不是多边形),量出来的最窄断面会是 0.3 mm。 那是度量假象,但假象和真断路在数据上分不出来。两条出路,二选一:

  • 把 fill:false 的段按 lineWidth 画成粗折线、再并上本网焊盘的铜(麻烦且容易算漏);
  • ✅ 给这条网络挂一条 connectMode: 1 的规则,铜面就是一整块,怎么量都不含糊。

规则可以按网络指派(和线宽一样):在 Plane.Copper Zone 下面复制 copperRegion 建一个新条目(editName / isSetDefault:false),再把 getNetRules() 里对应网络的 'Copper Zone' 字段设成这个名字。载板上就是 PLANE-DIRECT 挂给五路 VOn_P, GND 留默认十字花(~200 个通孔焊盘要手焊)。

⚠️ getNetRules() 回来的是整张表、overwriteNetRules() 整表覆盖 —— Track 和 Copper Zone 必须在同一次读改写里做完,分两次写后一次会拿旧快照冲掉前一次。

📐 顺带纠一个直觉:「0.254 mm 的辐条扛不住 4 A」是错的。 IPC-2221 的线宽表是给 长导体的,辐条长度只有净距那 0.254 mm、两端都焊在大铜面上。 单条 R = ρL/(Wt) = 1.72e-8 × 0.254e-3 / (0.254e-3 × 17.4e-6) ≈ 0.99 mΩ, 4 条并联 0.25 mΩ ⇒ 4 A 上压降 1 mV、发热 16 mW, 两端定温均匀发热棒 ΔT ≈ PL/(8kA) ≈ 0.02 K。 所以改全连接是为了几何可验证,不是因为十字花会烧。

9k. 「重建所有」点了没反应 = 客户端卡在后台任务上(2026-09-05)⚠️⚠️

现象:铺铜边框建出来了(pcb_PrimitivePour.getAll() 数得到), 点「重建所有」——无论用 React 的 onClick 还是派发真事件—— pcb_PrimitivePoured.getAll() 一直是 0,等 30 s、60 s 都不动, 也没有任何报错。rebuildCopperRegion() 照旧抛 Cannot read properties of undefined (reading 'length'), getCopperRegion() 返回 undefined。

判据在窗口最底下:一条卡在 0% 的进度条。 DOM 里能扫到:

for (const d of document.querySelectorAll('*'))
  if (d.offsetParent!==null && d.children.length===0 && /^\d+%$/.test((d.textContent||'').trim()))
    console.log(d.textContent);

健康时这个扫描返回空数组,卡住时返回 ["0%"] —— 判据很干净,可以直接写进脚本。

解法:location.reload() 重载客户端页面(桥接会自己重连,等 30~60 s)。 重载之后同一段代码一次就通。

⚠️ location.reload() 会被「文档有未保存改动」静默挡掉(Electron 的离开确认)。 现象是:调用返回成功,但标签栏没变、windowId 也没变,客户端照旧卡着。 可靠的重载顺序:关掉所有对话框(点「取消」)→ 删掉刚建的铺铜边框(反正重载会丢) → pcb_Document.save() → window.onbeforeunload = null → location.reload(true) → 轮询 windowId 直到它变掉,才算真的重载了。

⚠️ 重载会丢掉重载前建的铺铜边框,即使中间 pcb_Document.save() 过。 实测:save 后 getAll() 还有 1 块,重载回来变 0 块。 所以顺序必须是 先确认客户端不卡 → 再建边框 → 立刻重建 → 确认 → save, 别把「建边框」和「重建」分成两次会话。

另一个会让填充为 0 的正常原因,别和上面这条混起来: preserveSilos = false + 铺铜区域里没有本网络的焊盘/过孔 = 整块被当孤岛删光。 边框还在、getAll() 数得到,但 nPoured 是 0。 所以扇出(fanout)必须在铺铜之前做,否则内层平面只靠零星几个直插焊盘吊着。

9l. 每个下发脚本必须自己 openDocument —— 桥接打的是当前打开的那个文档(2026-09-05)⚠️

.js 片段里省掉 openDocument 的话,它会打到上一条命令留下的文档上。 2026-09-05 实测后果:查了一下下板的 J24 是否存在(那一步开了 PCB1), 紧接着跑「重建所有」,把下板的 14 块铜面重铺了一遍并存了盘 —— 两边的返回值看上去都完全正常,只有铜面名字能看出打错了对象 (回来的是 GND-L2 / 12V-L3 / VO1-L3,而载板上根本没有这些网络)。

所以:凡是会改动文档的片段,第一行就 openDocument + await sleep(1500); 返回值里带上位号或铺铜名,用来事后确认打对了板子。

9m. 元件摆好之后重抓包围盒会得到「位置转了、尺寸没转」的四不像(2026-09-05)⚠️⚠️

实测(载板换排母,从原理图导入变更之后重抓 pcb2-parts.json)。

getAllPinsByPrimitiveId() 回来的焊盘:

跟着元件的 rotation 转吗
焊盘的 x / y 转
pad 数组里的 w / h 不转(方向记在 pad.rotation 上,见 §9h)

对照实验:同一个 0402,C711(元件 rot=0)与 C742(rot=90)的 pad 都是 RECT,19.7,21.3 一模一样,区别只在 pad.rotation 0 / 90, 以及两个焊盘的相对位置一个沿 x、一个沿 y。

后果:本项目的包围盒缓存是「刚导入、全部 rot=0」时抓的,所以它是一个 干净的元件本地包围盒,摆位检查再按元件 rot 翻一次(box() 里那段 if rot % 180 == 90: 交换 x/y)。等元件摆好、转过角度之后再重抓, 拿到的就是半转不转的东西,box() 又翻一次 ⇒ 全错。 实测凭空报出 12 条假问题:手排的 0402(C744×R708、R715×R714 …) 互相「重叠」,J23 「越框」。这些假问题看起来和真问题一模一样, 照着去挪件就会把已经排好的布局改坏。

规矩:重抓包围盒时把焊盘位置反转回元件本地系。

const rot = ((c.rotation % 360) + 360) % 360;
const th = -rot * Math.PI / 180, ct = Math.cos(th), st = Math.sin(th);
// 每个焊盘:
const rx = p.x - c.x, ry = p.y - c.y;
const px = rx * ct - ry * st, py = rx * st + ry * ct;   // 本地系
// w/h 直接用 p.pad[1] / p.pad[2],不要动

这样抓出来的和 rot=0 时抓的等价,下游的旋转语义不用改。 (.claude/pcb2_parts_dump.py 就是这么写的。)

9n. 通用的 clean() 删「layer !== 11 的所有导线」—— 有丝印的板子上是破坏性的(2026-09-05)⚠️⚠️

实测。 pcb_fanout.py 的 CLEAN_JS 是给下板写的,下板除板框外没有别的线, 所以「删掉 layer 11 之外的全部 pcb_PrimitiveLine」一直没出过事。 载板上却有:

layer 名字 条数
3 Top Silkscreen 50(5 个手机位外框 + 接口边加粗)
9 Top Assembly 100
13 Document 20

照抄会把这 170 条一起删光,DRC、几何检查、摆位检查一条都不报 (它们只看铜层和元件)。

规矩:清理只列白名单的信号层([1, 2, 15, 16]), 清完回读一次各层剩余条数并断言丝印/装配/文档还在。

9n-2. 板框是 PrimitivePolyline,不是 PrimitiveLine ⚠️

同一次踩到的第二个:写完上面那条断言,顺手加了「layer 11 必须还有导线」, 结果清理明明是对的却报「板框被清光了」 —— 因为板框(§9j 那条闭合多段线) 是 pcb_PrimitivePolyline,pcb_PrimitiveLine.getAll() 里永远没有它。

// ❌ 板框还在的证据不能这么找
const ok = (await eda.pcb_PrimitiveLine.getAll()).some(l => l.layer === 11);
// ✅
const ok = (await eda.pcb_PrimitivePolyline.getAll()).filter(p => p.layer === 11).length === 1;

9o. 「从原理图导入变更」:整页重画 = 元件全部「移除 + 增加」(2026-09-05)

实测。 用脚本清空并重画一页(本项目所有 *_nets.py / c1_conn.py 都是这么干的), 元件的 UUID 全是新的,所以导入对话框里每个元件都是一对变更: 移除元件 X + 增加元件 X。PCB 上那些元件会被删掉重加,摆位归零 (新元件落在板外某处、layer 1、rot 0)。

本项目无所谓 —— pcb2_place.py 本来就按位号重摆全部 161 个件。 但如果某块板是手工摆的,重画一页就等于把那一页的摆位全丢了。

两条要核的:

  1. 对话框里「包含设计规则」必须不勾,勾上会拿原理图侧的规则覆盖脚本下发的那一套。
  2. 变更列表是虚拟滚动的,直接读 innerText 只能拿到头几行。 要滚动容器逐屏收集:
let sc = null;
for (const el of document.querySelectorAll('div'))
  if (el.scrollHeight > el.clientHeight + 20 && /移除元件/.test(el.innerText || ''))
    if (!sc || el.innerText.length < sc.innerText.length) sc = el;
for (let i = 0; i < 25; i++) { sc.scrollTop += sc.clientHeight - 40; await sleep(220); /* 收集 */ }

导入前把全部变更行读出来核一遍,是这一步唯一的安全带。

9p. 自动布线器:跑完了 ≠ 布了线,判据是重开文档再数一次(2026-09-06)⚠️⚠️

下板那一轮总结的「板框必须是闭合多段线」「结果不到最后不落盘」都还成立, 载板这一轮又添一条它会安静地什么都不做:

配置逐项回读断言过(所选网络 = 22 个差分网络 / 已有导线过孔 = 移除 / 布线拐角 45° / 只勾顶层+底层)、板框是合规的单条闭合多段线、四层、 doCrossProbeSelect 选中了 46 个焊盘 —— 点运行之后 30 秒内"跑完", 进度条从未出现,全板新增 10 条导线 0 个过孔, 而那 10 条全是 J23 连接器自己 A6↔B6 / A7↔B7 的短接线(同层、1 mm)。 console 只有一句 ProgressManager: can not remove progressGroup[win_progress_0] not exist。

⇒ 它根本没在找路,但不报错、也不弹失败。 别按"进度条消失 = 跑完"收工, 唯一判据是 save() → 重新 openDocument → 数导线/过孔并按网络分组。

驱动那个对话框的三个 DOM 坑

  1. 单选按钮身上只有 onChange,没有 onClick。 通用的「往上找 onClick 再调」 对它静默无效:不报错,回读还是老值。要直接 el.checked = true; props.onChange({target: el, currentTarget: el, ...})。
  2. 不能按 DOM 顺序取单选按钮。 doCrossProbeSelect 选中焊盘之后, 右侧属性面板会冒出「形状 / 阻焊扩展 / 焊盘连接方式」三组共 6 个单选, 页面上从 9 个变成 15 个,按下标点会点到焊盘属性上去。
  3. 按组名找容器要取「最紧的那个」。 对话框最外层容器的 innerText 也以「布线网络」开头(它是第一节),于是九个单选全都命中。 正确做法:在所有「文本以组名开头且含单选」的元素里取单选数最少的那个。

布线图层的复选框是 <td data-content-checkboxtdid="1|2|15|16">, 勾选状态看 <i class="checkbox_… active_…"> 有没有 active_ 前缀。 ⚠️ 重载客户端会把四层全勾回来 —— 内层是地平面和分割电源面,被信号线割开就全完了, 所以每次跑之前必须逐层回读断言。

9q. 新加了线和过孔之后必须重铺铜,否则 DRC 刷出一屏假的间距错误(2026-09-06)

载板上手工布完 5 对差分(60 段导线 + 10 个过孔)直接跑 DRC: 多出 40 条间距错误,分类是「到 过孔 (20)」+「到 钻孔 (20)」。 一条真的违规都没有 —— 那是铺铜还不知道这 10 个新过孔的存在、没给它们让位。

pcb2_pour.py --push 重铺一遍之后 40 条全部消失,回到基线; GND-INNER1 的环数 217→227、GND-INNER2 330→340, 正好各多 10 个环 = 10 个新过孔的反焊盘。环数就是最直接的对账。

⇒ 工作顺序固定为:布线 → 重铺铜 → 再 DRC。跳过中间那步会浪费一轮排查。

9r. 路径 → 导线的展开:末端的过孔会被静默丢掉(2026-09-06)⚠️⚠️⚠️

本项目把布线路径写成 [(x, y, layer, width), ...],语义是 「第 i 点的 layer 是从这一点走到下一点那一段的层」,层在第 i+1 点变化就在该点打过孔。 pcb2_route_dp.expand() 的实现里,打孔的条件是:

if i + 1 < n - 1 and l1 != l0:      # ← 显式跳过最后一个点

也就是说最后一个点的层被当成占位符。于是「走到目标焊盘正上方,再换层落下去」 这一类路径 —— 恰恰是布线器最爱走的收尾方式 —— 过孔没了: 两层的铜各画各的,中间断开。

为什么特别贵:三道校核一条都不报

校核 为什么抓不到
净距校核(check()) 它只问"撞没撞"。断开的两段铜当然不撞。
inner1_verify / 铺铜校核 只看铜面,不看信号层的连通
DRC 连接性 程序化建线之后是陈旧的,要 location.reload() 才刷新(见 §9p 那一节的记录)

实测踩了两次,第二次更贵:

  • 布线器自己算出来的路径:$25N40 / SDA / VBUS_DET / XTAL2 / VBUS_EN3 五个网络断开;
  • 手写的固定几何(BM24 那两条 GND 梳子,写法是 [..., (u,v,TOP), (u,v,BOT)]): 五个站 50 个过孔全部丢掉,梳子整个悬空。 DRC 上表现为 J7n 的 pin 1/2/8/12/47/52 全部「与其他相同网络的对象断开了连接」(GND 55 条)。

判据与做法

末点上有换层就补一个重复点(pcb2_route_sig._end_via()):

if _same(path[-1], path[-2]) and path[-1][2] != path[-2][2]:
    path = path + [path[-1]]

起点侧不用补 —— 起点的重合点对 expand() 是「第 0 段长度为 0 + 第 1 点换层」,本来就认得出。

⇒ 所有路径都要过这一道,手写的也不例外。 比对的判据是「算出来的过孔数 == 下发后回读的过孔数」,别只看导线段数。

9s. location.reload() 会被「离开该页面」确认框挡下来(2026-09-06)⚠️⚠️

文档有未保存改动时,location.reload() 会触发 beforeunload, 客户端弹出自己画的「离开该页面 / 留在该页面」两个按钮。 如果按脚本习惯去点了「留在该页面」,页面根本没重载—— 而调用方通常以为重载成功了,于是:

  • §9k 那个「客户端卡在后台任务上」的状态没被清掉, 工具 → 铺铜管理器… 点了没反应、重建所有 按钮压根找不到、 getState_PourFills() 一直是 0 段;
  • DRC 的连接性数字仍然是陈旧的那一份。

⇒ 重载前先 await eda.pcb_Document.save(),等它落盘再 location.reload(), 然后按 document.body.innerText.length 回到正常量级(本项目约 9000)来确认真的重载了。

9t. 窗口在后台时截图只会回一张 88 字节的空 PNG(2026-09-06)⚠️

eda.dmt_EditorControl.getCurrentRenderedAreaImage() 拿的是画布当前渲染出来的内容。 EasyEDA 窗口被最小化 / 被别的窗口盖住时 document.hidden === true, 画布停止渲染,于是返回的 PNG 只有 88 字节(一张空图),而且调用是成功的、不报错。

⇒ 截图前先查 document.visibilityState;是 hidden 就先请用户把 EDA 窗口切到前台。 判据:正常的整板截图是几百 KB。

9u. 封装自带的孔,API 一个都读不出来 —— 只能从铺铜的反焊盘反解(2026-09-06)⚠️⚠️⚠️

USB-C 母座这类封装里常有非金属化定位孔(本项目 J23:两个 Ø0.60)。它们不是焊盘:

试过的读法 结果
pcb_PrimitivePad.getAll() 没有
pcb_PrimitiveRegion.getAll() / pcb_PrimitiveFill.getAll() 空数组
pcb_Primitive.getPrimitiveByPrimitiveId('e55') / getPrimitivesByPrimitiveId 空
pcb_Primitive.getPrimitivesBBox(['e55']) 抛
pcb_Document.getPrimitiveAtPoint(x, y) 只回最上面那一个图元(一般是压在上面的导线)
pcb_Document.getPrimitivesInRegion(...) 签名对不上,怎么传都 a is not iterable
组件的 getState_Footprint() 拿不到内部图元

能看见它们的只有两处:DRC(报「挖槽区域 到 导线」,而且对象 id 是 e55 这种 在别处一律解析不出来的内部号)和内层铺铜给它们让出来的反焊盘。

⇒ 反解办法(.claude/pcb2_npth.py,可直接照搬):

  1. getState_PourFills() 把内层铜面的环读回来(注意 §9j-2:fill:false 的是辐条);
  2. 在每个环上贪心地拟合圆弧(连续 ≥10 个点落在同一个圆上,残差 < 10 µm);
  3. 用「圆心和已知过孔中心重合的那些弧」标定铺铜净距 C = 反焊盘半径 − 过孔半径(本项目实测 0.3583 mm,234 个样本,离散度 0.005);
  4. 圆心对不上任何焊盘/过孔的弧,孔径就是 r − C;
  5. 滤掉两类噪声:铺铜多边形自己的凸角圆角(r ≈ C ⇒ 孔径 ≈ 0)和板框 R5。

⚠️ 顺带两条同源的判据(都是拿 DRC 一条条试出来的):

  • 净距校核只看铜,钻孔是另一条规则。 给同网焊盘旁边打过孔时,铜的判据会 「同网跳过」,于是孔可以正落在同网通孔焊盘的焊盘里 —— 几何校核全过, DRC 报「钻孔 到 钻孔 距离为 0mil,应该 >= 11.8mil」。 ⇒ 同网的通孔焊盘和同网的过孔,都要按钻孔间距(0.2997,取 0.33 留余量)再判一遍。
  • 金属化槽孔焊盘 ≠ 非金属化孔。 线可以压在焊盘自己的孔上(那就是焊盘的一部分), 不可以压在非金属化孔上。混为一谈的话,SLOT 0.80×1.50 的槽孔焊盘 连碰都碰不得 —— 焊盘上任何一点离自己的槽都不足 0.80 mm,等于谁也连不上它。

9v. 「只加不删」的下发脚本必须自己记账(2026-09-06)⚠️⚠️

整网重画(先删该网络所有线和孔,再画)是最省心的下发方式,但在 GND 上不能用: 那会把两百多个别人的扇出孔一起删掉。于是只能「只 create 不 delete」, 而这样一来脚本就不幂等了 —— 改了选点逻辑再推一次,板上留下两套。

实测的现象比「多一对孔」更难看:新画的 0.20 细线和旧的 0.30 粗线共线重叠, EasyEDA 把它并进旧线里,于是新线在 getAll() 里根本不出现, DRC 照样报旧线的错 —— 看上去就是「改了没生效」。

⇒ 把自己下发的坐标(过孔中心 + 每段线的两端)存一个 sidecar JSON, 下次 --push 先按坐标删掉它们再画;算之前也要先把它们从障碍表里摘掉, 否则「要补的对象」已经不悬空了,脚本会以为没活干。

补一条(同日):「本轮新打的」同网过孔之间也要判钻孔间距。 只判板上已有的过孔、 跳过本轮同网的,结果 U34 相邻三个 GND 脚的孔排成一列、中心只隔 0.23 mm —— 净距校核(只看铜、同网跳过)一个字不说。

9w. 底层元件 = 封装本地 y 先翻、再按 rot 转(2026-09-06)⚠️⚠️

实测(U51 SOT23-6 / J71 BM24 / R761 0603 三件用 modify({layer:2}) 翻到底层读焊盘,再翻回):

顶层 rot 180 底层 rot 180 顶层 rot 90 底层 rot 90
U51 pin1/2/3 那一排 北 南 东列 西列
J71 奇数排 北 南 西列 东列
R761 pin1 东 东 南 南

⇒ 模型:p_board = R(rot) · (lx, −ly)。不是「绕元件竖轴镜像」(那样 0603 rot 0 的 pin1 会跑到东边,实测没有)。

后果:

  • 两脚件(焊盘在本地 y=0)pin1 方向和顶层完全一样,不用改 rot;
  • SOT23-6 / BM24 / QFN 这种两排的:rot 0/180 时南北对调、rot 90/270 时东西对调;
  • USB-C 这种口朝一侧的:顶层 rot 90 口朝 +X,底层要 rot 270 才朝 +X(本项目 J23)。
  • 层翻一次,连接器的物理手性也翻一次 —— 顶层推出来的「哪排是奇数排」在底层要重做。

规矩:翻层后回读焊盘坐标核方向(pcb2_place.py --verify),别拿顶层那套 rot 直接改 layer。 另外 modify() 里 layer 和 x/y/rotation 分两次下发更稳(一次下发过,偶见 rot 没生效)。

9w-2. 从已摆好的底层元件反抓包围盒:反转之后还要反镜像(2026-09-06)⚠️

pcb2_parts_dump.py 原来只把焊盘位置按 -rot 转回本地系(§9m),对底层元件这还差一步: 底层 = 本地 y 翻 + 转,所以 p_local = M · R(−rot) · pad,M 那一下不能省。 症状:J6n 这种上下不对称的封装(y −2.952.25)抓成 −2.252.95,box() 再翻一次就跑到另一边, pcb2_place.py 误报 J61 压 J71,而它俩实际差 0.45 mm。对称封装(0402 / SOT23 / BM24)完全看不出来, 所以这个 bug 藏了一整天,直到导入变更后重抓才炸。顺带:c.rotation 对底层件的读数不可信 (J65 实际 rot 180 却读到 0),抓包围盒别信它,信焊盘坐标。

9x. 挖槽 = layer 12 (MULTI) 的填充;批量删除要循环到剩 0(2026-09-06)⚠️

  • 板内开槽:pcb_PrimitiveFill.create(12, await eda.pcb_MathPolygon.createPolygon(src)), 文档原话「填充所属层为 MULTI 时代表挖槽区域」。src 和板框同一种格式,跑道形用两段 90° ARC (逆时针、正角度);一次建 10 条全部成功。pcb_PrimitiveFill.getAll() 能读回(layer 12), 不像封装自带的孔(§9u)。
  • getAll() 逐条 delete() 一遍删不干净:1416 条导线按列表删完、回读还剩 16 条 (都是同一批 GND 梳子)。不是锁定(primitiveLock:false),像是列表快照与删除交错。 ⇒ 删除写成循环:while (剩余 > 0 && pass < 3),收尾断言剩余 == 0。

9y. lib_Device.create() 返回 undefined 但器件已建出来;系统库封装可经 modify 复制进个人库(2026-09-06)⚠️

实测(给 C8n1 换 SMD 封装时):

调用 返回 实际
lib_Device.copy(系统库器件, SYS, ME, …) null 没建
lib_Device.copy(个人库器件, ME, ME, …, 新名) uuid 建了
lib_Device.create(ME, name, undefined, {symbol:{uuid, libraryUuid: SYS}}, desc) undefined 建了(符号复制进个人库,新 uuid)
lib_Device.modify(uuid, ME, …, {footprint:{uuid: 系统库封装, libraryUuid: SYS}}, …, prop) undefined 封装复制进个人库(新 uuid)并挂上

⇒ 建库脚本不能拿返回值当判据:建完立刻 lib_Device.search(name, ME) + get() 读回 association / property 核对。 property 第一次会被吞一部分(§9e),再 modify 一次补全 designator / manufacturer / supplierId / otherProperty。 lib_Footprint.getRenderImage({footprintUuid, libraryUuid}) 能拿到系统库封装的 PNG(3 KB),看极性标记用它, 比放到板上再截图省事。

顺带:把多个库 API 塞进同一个 /execute 里跑,其中一个挂住(本次是 lib_LibrariesList / lib_Classification) 整条请求 30 s 超时,但前面已经执行的 create/copy 仍然生效 —— 超时后先去库里搜一遍有没有留下半成品再重试。

9z. 改库里的器件不会传到原理图实例 —— 换封装要走「封装管理器」的 DOM(2026-09-07)⚠️⚠️

现象:lib_Device.modify() 把器件 1c8e0ea1… 的封装换成 PAD-D1.5_MI8-KEY 之后, 原理图里 20 个 J41~J60 的 footprint 仍是 POGO-SMD1P_D1.9,component.uuid 甚至指向一个 lib_Device.get() 已经查不到的旧 uuid(7904eff7…)—— 实例是放置时的快照,和库脱钩。 于是「从原理图导入变更」一行都不出。

API 改不动:sch_PrimitiveComponent.modify() 的签名里没有 footprint / component, 塞进去被静默忽略(实测读回不变)。更新工程库... 菜单只弹出一个「封装需要关联器件」的提示框。

能用的路:原理图页 工具 → 封装管理器...(lc_modal_dialog_box_* 模态框),全程 DOM:

  1. 左表 input[placeholder="过滤"] 用 React 的 value setter + input 事件填旧封装名,只剩要换的行; 表是虚拟滚动的(lc-table-body_*,一次只渲染 ~12 行),要边 scrollTop += 60 边找 J41…J60 的叶子元素; 点叶子文本元素(不是 <tr>)才选中(lc-table-row--selected_*),ctrlKey: true 累加多选,20 个逐个点。
  2. 右侧点「个人」页签(div.tree-content_*),input[placeholder^="搜索"] 填封装名 + Enter, 结果表里点封装那一格的叶子。
  3. ⚠️ 「更新」按钮要等右下「检查封装尺寸」表出现焊盘尺寸(1|1.5 * 1.5)才会去掉 *_disabled_* 类 —— 之前 onClick 调了也没反应、模态还在、封装没变。等 1~2 s 再点,toast「更新成功!」, 左表按旧名过滤会变空(因为都换掉了)。
  4. 读回:c.footprint.name 全变新名(uuid 是工程库副本的新 uuid,不是个人库那个),component 仍指旧器件 —— 不影响导入。

BOM 字段在实例上:sch_PrimitiveComponent.modify(id, {addIntoBom: false}) 生效; manufacturer / manufacturerId / supplier / supplierId 传 '' 或 null 都被忽略 —— 清不掉,靠 addIntoBom=false 把它挡在 BOM 外。

顺带:lib_Device.modify 的 property(Add into BOM 等)同样被忽略(§上一轮),所以这两处都不要指望 API。

⚠️ 一个卡了半小时的假象:之前留在页面上的「封装需要关联器件使用…/ 关联器件 / 取消」提示框 (lc-tips-modal-*,fp_keypad.py 的 openInEditor 留下的)会挡住后面所有菜单动作, 菜单探测脚本第一件事先把可见的「取消」按钮点一遍。

9aa. PCB 收尾顺序:reload 会丢铺铜填充,API 新建的铜要 reload 之后 DRC 才认(2026-09-07)⚠️⚠️

第三十轮实测(载板):

  • pcb2_reload.py(关对话框 → 存盘 → 页面 reload)之后 getState_PourFills 回读 0 块 —— 填充没了, 只剩铺铜边框。此时跑 DRC 会把所有只靠内层铜面连通的对象(GND 397 条、VOn_P 65 条)全报「断开」。
  • 反过来,用 API 新建了几百段导线之后不 reload 直接 DRC,刚建的那几个网络成片报「与其他相同网络的对象断开」 (171 条),reload 之后同一份数据 DRC 归零 —— 连接性索引不跟着 API 创建更新。
  • ⇒ 固定顺序:pcb2_reload.py → pcb2_pour.py --push → inner1_verify.py → drc_read.py --pcb2。 pour 之后不许再 reload;DRC 之前必须 reload 过一次。

DRC 面板里的图元 ID(e16831 这种)不是 getState_PrimitiveId(),pcb_PrimitiveLine.get('e16831') 返回空数组; 要定位只能按网络 + 坐标自己找(_drc_conn.py 读分类明细,再用几何去对)。

9ab. 「挖槽区域」= 封装自带的 NPTH,不是 layer 12 填充(2026-09-07)

DRC 报「挖槽区域 到 过孔 0mil」的 e56,在 pcb_PrimitiveFill.getAll()(只有 10 条穿线槽)和 pcb_PrimitiveRegion(空)里都找不到 —— 它是 J23 封装自带的 Ø0.6 非金属化孔(§9u 说过 API 读不出来,pcb2_npth.py --write 反解出的 _pcb2_npth.json)。 布线脚本要把这份清单当无网络的通孔盘加进障碍表(pcb2_route_dp.geom() 已加)。

9ac. pcb_PrimitiveLine.create() 建一条穿过本网焊盘的线,会在焊盘中心截断(2026-09-07)⚠️

实测(载板 KEY_RCLK 穿 U22.12):路径 (126.896,66.7)→(126.865,70.521) 从焊盘中心 (126.865,69.236) 旁 0.01 mm 穿过去接出线段末端, 建完板上只剩 (66.7→69.236) 那一半,后半段和同位置的出线段一起没了,DRC 报焊盘 + 一个过孔「断开」。 而两条完全重合的线 create 两次只留一条(后建的替换先建的)。 ⇒ 下发前把离本网焊盘中心 < 0.15 mm 的线段在中心处拆成两段(pcb2_route_sig.push_sig 已做),别让任何一段"穿过"焊盘。

6e. SOT-23 想要「G 左 / S 上 / D 下」只能靠 rot + mirror(2026-09-07)⚠️

画高边 P-MOS(源极接 +12V 在上、漏极接负载在下、栅极朝左)时, 单靠 rotation 做不出来。四种组合实测(AO3401A be56508f…,偏移是相对元件中心):

create 参数 G S D 用在
rot 0, mirror false 左 (−20,0) 下 (+10,−20) 上 (+10,+20) 低边 N-MOS(源极接地)
rot 0, mirror true 右 (+20,0) 下 (−10,−20) 上 (−10,+20) 栅极朝右接收信号(信号从右往左流的接收电路)
rot 180, mirror false 右 (+20,0) 上 (−10,+20) 下 (−10,−20) ——
rot 180, mirror true 左 (−20,0) 上 (+10,+20) 下 (+10,−20) 高边 P-MOS

mirror 是水平镜像(只把 G 换到另一侧),rot 180 才上下翻。两个都要才凑得出第四行。

P6 的两路 TACH 接收就是靠第二行:原始信号从右边的风扇座过来, 栅极朝右接住,漏极向上接 3V3 上拉、源极向下接地 —— 一根绕线都不用。

⚠️ mirror 只影响原理图符号,不镜像 PCB 封装。但放完照样要 getAllPins() 断言(§6c)。

2b. createNetFlag('Power', …) 的网络名可以是任意串(2026-09-07)

不限于 +3V3 / +12V / +5V。实测 createNetFlag('Power', 'FAN_12V_SW', x, y, 0, false) 读回来就是 FAN_12V_SW,rot 0 / 180 都一样。

⇒ 开关后的电源轨(本项目的 FAN_12V_SW)用电源旗最省事: 7 个接点各挂一个旗,不用画母线,也不会像 netport 那样被 xpage.py 当成漏接 (旗有 7 个实例)。真正只有一个实例的局部网络才需要进 xpage.NAMING_ONLY。

⚠️ 但 §3b 那条不变:旗建好之后不能移动、不能改名,只能删了重建。

12a. 地旗的 GND 字样掉在符号下方约 15 个单位(2026-09-07)

createNetFlag('Ground','GND',x,y,0) 的符号占 y−20 ~ y,但文字还在更下面, 大约 y−28 ~ y−15。所以「旗底 y−20 以下就安全」是错的,实际要留到 y−30。

实测踩到:P6 的地旗放在 (250,225),把 y=190 那一行标题压掉了一半。 verify_sch.py / port_audit.py / 本地几何自检四道全过 —— 这类问题只有截图看得见。

⇒ 本地自检里给旗留净空按 30 算,不是 20。同理端口标签的纵向净空按 ±10 算。

8a-3. 旗放在「自己分两段建的母线」中点上 —— 照样不连(2026-09-07)⚠️⚠️

§8a-2 的第三种成因,比前两种更容易上当,因为看起来是端点。

给一排电阻做共地母线时的自然写法:把母线拆成两段,让旗的位置正好是两段的公共端点。

w('GND', (190, 250), (230, 250))     # 以为 (230,250) 是端点
w('GND', (230, 250), (310, 250))
F.append(('Ground', 'GND', 230, 250, 0))

EasyEDA 会把共线的两段合并成一条 (190,250)→(310,250), 于是旗又落回中段,判定不相连。

实测(C2-HUB 的 R903~R906 共地母线):旗放在 x=250(几颗电阻分支点之间的空档)时, PCB 网表里那四颗的地端是 $16N236 而不是 GND; 而右边那条母线的旗恰好放在 x=820 = R907 的分支点上,就正常。

判据:旗/端口的坐标必须是某条「真的从别处接进来」的导线的端点 —— 也就是那里得有一根垂直方向的支线(元件的引出线)落进来,光靠把水平母线切两段没用。

两种可靠写法:

  1. 把旗放在某颗元件的分支点上(本项目采用:LBUS = (190, 310, 230),230 是 R905 的分支 x);
  2. 给旗单独接一根拐弯的短线:w('GND', (250,250), (250,225)) 再把旗放 (250,225)。

⚠️ verify_sch.py 抓不到(它的判据是「落在某条线段上」), port_audit.py 在旗被合并线包住时也可能放过。 真正的判据是 PCB 那边的网表 —— 从原理图导入变更之后, 把关键网络的焊盘网名读回来对一遍(pcb_PrimitivePad.getState_Net()), 共地/共电源的那一组尤其要看。本项目就是靠这一步抓到的。


9o-2. 「从原理图导入变更」按 uniqueId 配对,不是按位号 —— 错位时它会提出一份看着像在修错、实则会改坏板子的清单(2026-09-08)⚠️⚠️⚠️

§9o 说整页重画会让元件 uuid 全新。后果比"摆位归零"严重得多: 重画那一页的元件 uniqueId 会整体重编,而 PCB 侧还是旧的一套。 导入对话框按 uniqueId 把两边配对,于是整块区域错开一格。

下板 2026-09-08 实测(P6-FAN-EDL 重画过):

位号   PCB       原理图
J26    gge418    gge419
J27    gge419    gge420     ← 板上 J27 拿着原理图 J26 的 ID
Q9     gge423    gge424
R753   gge413    gge414

全板 301 件里错了 20 个,全在那一页,整齐地差 +1。 对话框据此提出:J27→J26、Q10→Q9、R753→R752、Q7 换成 4 脚座封装、 R747 换成 DC 座封装…… 一大串「改位号 / 改封装 / 改网络」。

⛔ 最贵的一点:那份清单看起来像在修真错误

它会说 修改网络 J26:3 FAN_OUT_TACH_RAW → FAN_IN_TACH_RAW, 而 J26 确实是进风扇、确实该是 IN —— 于是很容易得出「板子接反了,导入正好修好它」。 实际是反的:板上 J26.3 本来就是 FAN_IN_TACH_RAW, 对话框说的那个「J26」是它配错的那个对象(板上的 J27)。应用下去才真的接反。

判据:不要拿对话框当判据,按四项直接对账

对话框是结果不是原因 —— 它用错位的配对去算差异,算出来的自然也是错位的。

对账项 怎么查 能不能单独定案
位号 pcb1_import.py --verify ❌ 光这个不够
封装名 同上 ❌ 同封装之间的错位它看不见(J26/J27 都是 CONN-TH_4P,Q9/Q10 都是 SOT-23-3)
器件值 两边 getState_Component().name 按位号比 ❌ 同型号之间也看不见
焊盘网络 PCB pad.getState_Net() vs 原理图连通性 ✅ 这一项才有判别力

四项全对 = 板子是对的,对话框在胡说。任何一项对不上 = 先查 uniqueId 再说。

修法:把 PCB 的 uniqueId 按位号写成原理图的那一套

pcb_PrimitiveComponent 上有 setState_UniqueId()(setState_Footprint 没有, 所以换封装只能删了重建,见 §9z 一节的 PCB 版)。

⚠️ 必须两阶段写:这是一个排列(J27 的 ID 要给 J26), 直接按目标值挨个写会在中途出现两个元件同 ID。 先全部写成临时值(zzsync<n>),再写成目标值。

⚠️ 改的是元件身份,DRC / 几何检查 / 连通性一条都不会报 —— 唯一的验收是「重开导入对话框看它还剩几条」。写之前先存快照。

.claude/pcb1_uid_sync.py 就是这件事:对账 → 两阶段写 → 回查逐个一致且无重号。 下板实测:20 处对齐之后,变更清单从一大串塌成一行 (J1:通道ID (空) → $1I219,那是 API 新建的元件本来就没有通道ID), 应用之后再开对话框 0 条变更,而风扇块 20 件的焊盘网络逐条未变。

💡 getState_UniqueId() 返回的 ggeNNN 和对话框里显示的「通道ID」$17I175 不是同一个字段,但配对用的是前者 —— 对齐 ggeNNN 之后错位就消失了。

9o-3. 读导入对话框:别用滚动容器的 innerText(2026-09-08)

那张表是多列分开渲染的,offsetTop 在列之间碰撞。 按 innerText 拼接读出来,表头(位号)和明细行会被拼错, 读出来的清单似是而非 —— 同一份数据能读出两种互相矛盾的「错位方向」。

逐 <tr> 读、每行自带单元格(.claude/p1_import_rows2.py)才是对的。 但即使读对了,结论仍以上面那四项对账为准。

9ah. 「假板框」不止坑摆位 —— 校核脚本的栅格原点同样会把负坐标静默吃掉(2026-09-08)⚠️⚠️

本项目的板框在两板对齐那一轮从 160×125 @(0,0) 改成了 220×135 @(−30,−5)。 pcb1_place.py 当时改了,pcb1_pour.py 没改 —— 它的栅格一直是 nx, ny = int(BW/G), int(BH/G) 加 int(x / G),从 (0,0) 起索引。

平时看不出来:所有铜都在第一象限。直到把元件挪到 x<0 —— 三块顶层铺铜 当场报「碎成 0 块」「断面 0.00 mm」。板上的铜其实没问题 (多边形是原样下发给 EasyEDA 的,由它去填),坏的只是本地校核。

⇒ 判据:校核报 0 而实测回读正常时,先怀疑校核的坐标系,别去改设计。 两边对不上,信实测那一边(--verify 读 getState_PourFills())。

修法是把毫米↔栅格收进四个函数(gi/gj/gx/gy),禁止再直接写 int(x / G)。

9ah-2. ⛔ 但改共享常量会震到别的模块,而且是静默的

pcb1_netlist.py 里写着 G = PO.G,还自己用 int(x / G) 判「焊盘/过孔落没落在铺铜上」。 pcb1_pour.BW/BH 一改,它的栅格尺寸跟着变大、原点却还在 (0,0) ⇒ 坐标整体偏 30 mm。现象是「靠平面连通的网络(+12V / GND / +3V3 / VOn_P) 忽然从 4 个碎片炸成 23 个」,而同一时刻 pcb1_pour --verify 回读的实测铺铜 「+12V 一个连通域、58 个焊盘没着落 0」完全健康。

代价是真的:pcb1_orphan_cu.py 走的就是这套栅格,据此把 12 个过孔判成悬空删掉了 (所幸那些碎片本来就不含焊盘 —— 那一半判据不依赖栅格 —— 所以电气上无损)。

⇒ 两条规矩:

  1. 改共享的几何常量之后,把 import 了它的模块全部跑一遍,不能只验改动的那个;
  2. 「连通性突然大面积变差」和「实测回读健康」同时出现时,先怀疑度量,再怀疑板子。

3c. create() 会把元件坐标也吸附到 5 的倍数(2026-09-08)⚠️⚠️

§3 说「元件坐标不受此限」——那是对移动(setState_X/Y)说的。 sch_PrimitiveComponent.create() 会吸附:R1 页上 679 / 639 / 654 / 589 这些 非 5 倍数的位置(都是当年 create 完再拖出来的),照搬到新工程里放下去会落在 680 / 640 / 655 / 590,差 1~2 个单位。

后果:整页导线是按原坐标算的,差 1 就全断;而「按 (x,y) 精确匹配认领位号」的 惯用写法会一个都认不上(本项目实测 42 件只认到 28 件,另外 14 件停在 U? / C?)。

⇒ 跨工程重放版图的固定写法:就近认领(容差 3)→ 认完立刻 setState_X/Y 拉回精确坐标。

6f. 跨工程重放:27/42 件的 create(rot) 语义与源页相反(2026-09-08)

§6b/§6c 说「方向表一张都别信」,本轮给出了量级:把 R1 一页 40 个件原样搬到另一个工程, 27 件的 rot=270 得到的引脚顺序与源页相反。其中

  • 25 件是两脚对称件(0402 R/C),只有引脚编号互换,坐标集相同 ⇒ 连线不受影响;
  • 2 件致命:33nF(C13594)是 +20 / −15 的不对称跨距,翻过来两个脚的坐标真的差 5, 照源页坐标画线会断在这两颗上。这正是 §8d「跨距同族也不统一」的后果被放大。

⇒ 做法:把源页的引脚坐标连同脚号一起 dump 下来当基准,放完逐脚比对, 对不上的 setState_Rotation(rot+180) 再回读,循环到全对。

⚠️ 第一轮往往「看起来没生效」:create(270) 与 setState_Rotation(270) 差 180°(§6b), 所以要翻两次才落到源页的语义上。别以为脚本卡住了 —— 本项目日志里就是同一批 27 件 连打两行「翻转 27 件重试」,第三轮才空。

6f-2. ✅ 那两圈空转可以直接省掉:放完立刻用同一个角度再写一次(2026-09-08)

受控实验(A2-AUX 空页,0402 电容,create(..., 90)):

pin1 pin2
create(90) 之后第一次读回 (0, +20) (0, −20)
紧接着 setState_Rotation(90)(同一个角度)再读 (0, −20) (0, +20)

两者差 180°,就是 §6b 那条。后者才是最终落盘的语义。 ⇒ place() 里在 create 之后加一遍「按 placement 表把每件的 rotation 用同一个角度再写一次」, 第一次读回就是真的:R2 底板 A1-INPUT 那 14 件 rot 90/270 的件, 从「翻转 14 件重试 ×2 轮」变成一轮直接通过。已并入 .claude/r2/schcheck.py 的 place()。

这不只是省时间。盲目翻转循环有可能替一个真放错的件「碰」出一个假的通过 —— 循环最多转 4 轮,rot+180 / +360 / +540 里总有一个可能与错误的期望值凑上。 先把语义刷正,再让逐脚断言去判,判据才是干净的。

3e. 读回来的旗/端口坐标带浮点尾巴(2026-09-08)

rot=180 的旗和端口,getState_X() 读回来是 789.9999999999999 / 520.0000000000001。 直接拿去做「必须是 5 的倍数」「必须落在导线端点」两道自检 ⇒ 全部误报。 ⇒ dump 出来先 int(round(...))。(导线的 getState_Line() 没有这个问题。)

顺带:旗的符号名读回来是 Ground-GND / Power-5V,而 createNetFlag() 的第一个参数 只认 Ground / Power —— 重建时取 name.split('-')[0]。 (Power-5V 里的 5V 只是符号默认网络名,正是 §3b 里「移动电源旗会掉回 +5V」的那个默认值。)

附录 A:外部来源的四条 PCB 陷阱(2026-09-07 并入)

⚠️ 本节与全文其余部分性质不同:以上每一条都是本项目亲手踩出来的, 下面这四条来自 v0id-byte/easyeda-pro-claude-skill (MIT)§10,是别人在一块密集四层板上的实测记录,本项目尚未复现。 按「可信但待验证」对待:判据照做,但别把它当成本项目已确认的事实。

同一份文档里有一条我们已经证伪的说法:它称 overwriteCurrentRuleConfiguration() 是 BETA、调用会永久 hang、只能走 GUI 改规则。 本项目 .claude/pcb1_rules.py:315 是跑通的 —— 失败原因在参数: 要传 config 本体,不要传 {name, config} 包装(见该文件头第 10 行)。 ⇒ 外部 skill 的结论要逐条对着本项目的实测过一遍,不要整段照抄。

9ad. save() 返回 true 也可能没落盘 —— 判据是回查图元数 ⚠️⚠️

文档上下文过期时,API save() 返回 true 但盘上文件仍是旧状态。 对方因此丢了一整版布线:界面上线都在,重开文件只剩铺铜。

判据:save() 的返回值不算数。落盘的证据是重新读一次图元计数——

before = count_tracks()          # 下发前
...                              # 下发
call('await eda.pcb_Document.save()')
after = count_tracks()           # 必须等于 before + 本次新增

本项目 30 多个脚本(pcb1_place.py / pcb2_route_sig.py / …)目前都只看 save() 的返回值, 没有一个做回查。新写的下发脚本按上面这个形状补。

与 §9p 是同一类判据:「跑完了 ≠ 生效了,重开数一次才算」。 §9p 已经在自动布线上踩过,这条是它在 save() 上的同构版本。

9ae. 关掉再打开 PCB 标签页 → 桥接失去同步(API 读到 0 图元)⚠️⚠️

关闭 PCB 文档标签页再重新打开之后,API 会读回 0 tracks / 陈旧上下文, 而画面上明明有线。此时任何「先读回再增量下发」的脚本都会做出错误决策 (把有线的板子当空板重画)。

恢复:EDA 里 API Gateway → 重新连接。 判据:脚本开头读回的图元数如果是 0,但你知道板子非空,先怀疑桥接而不是怀疑板子—— 这正是 §9v「只加不删的脚本必须自己记账」最怕的输入。

顺带:一个工程里常有多个 PCB 副本(PCB1 / PCB1_1…),重开时确认打开的是对的那一个。

9ae-2. ⚠️⚠️⚠️ 失同步状态下再 save() 一次,会把「空」写回文档(2026-09-07,真丢了一块板)

上面那条说的是「读到 0 会让脚本做出错误决策」。比这严重得多的是写。

实际发生的一次(载板 PCB2,223 元件 / 2649 导线 / 896 过孔):

pcb2_cfgsel.py --push    # 建 6 线 2 孔 + save(),一切正常
pcb2_reload.py           # 删铺铜边框 → save() → location.reload(),报告「已重载」
pcb2_pour.py --push      # 建 7 块铺铜边框 → 填充一直 0/7 → 中途 save()   ← 就是这一下

第二步重载回来之后,铜层已经读回全 0(就是 §9ae 那个陈旧上下文), 但当时的判据只有「windowId 变了 = 重载成功」,没人去数图元,于是直接往下走。 第三步在那个状态里调了 save(),把客户端内存里那个「没有电气对象」的模型写回了文档。

事后的样子非常有迷惑性:完整重启客户端之后,

层 读数
铜层(1/2/15/16):元件 / 导线 / 过孔 / 焊盘 / 铺铜 全 0
板框层 11 1 条 ✅
底层丝印 4 / 底层锡膏 8 / 元件外形 48 / 元件标识 49 1321 / 216 / 185 / 186 ✅
挖槽 12 / 文档层 13 12 / 39 ✅

也就是说丝印、锡膏、装配、板框这些非铜层图形一条不少,只有电气对象没了 —— 第一眼很像「API 又失同步了」,其实是数据真没了。 pcb_PrimitiveComponent.get('U32') 甚至还能返回一个对象,但它所有 getState_* 都是 null (空壳,别把它当成「找到了」)。同一工程里的另一块板(PCB1,301 件)读起来完全正常, 所以「换块板读读看」是区分「客户端坏了」和「这块板坏了」最快的判据。

规矩三条

  1. 「重载成功」的判据不是 windowId 变了,是重载后数一遍图元、数字不为零。 pcb2_reload.py 已经把这条做成硬断言(assert_alive()),不过关直接 sys.exit。
  2. 读回 0 的时候,绝对不许再 save()。 先 API Gateway → 重新连接, 不行就重启客户端,确认数字回来了再动。任何「建完东西顺手 save」的脚本 在这个状态下都是破坏性的。
  3. 真丢了就走 文件 → 工程历史...(另有 版本控制 / 回收站...)恢复到写入之前, 再把这一轮的下发按脚本重放一遍 —— 所以每一步都要能用脚本重放, 这也是本项目坚持「改动写成脚本而不是手工操作」的理由之一。

⚠️ 顺带:这个状态下 getCurrentRenderedAreaImage() / zoomToAllPrimitives() 会挂到超时 (不是 §9t 那种 88 字节空 PNG)。截图挂住 + 另一块板正常 = 这块板的编辑器实例有问题,别再往下写。

9ae-3. 结局与更正:毁数据的是失同步后的 save,不是 reload() 本身(2026-09-08)

上一节读起来容易得出「别再用 location.reload()」的结论。那是错的,同日的收尾证伪了它:

  • 损坏的文档最终连删都删不掉(右键删除,服务端直接回 delete failed !), 只能靠 文件 → 工程历史... 还原到事故前的版本把它移除,再新建一块 PCB 按快照重建。
  • ⚠️ 顺带更正:曾以为「工程历史点不开」,其实是当时的活动文档正是卡死的那块板。 换个健康文档再点就开了。⇒ 某个 GUI 功能点不开时,先怀疑当前活动文档卡住。
  • 在新建的健康文档上,pcb2_reload.py(内含 location.reload())完全正常: 重载后回查 216 元件 / 2644 线 / 896 孔 / 1000 焊盘全对。 而且它是唯一能清掉「卡在 0% 的后台任务」的办法 —— 不清掉,铺铜「重建所有」永远填 0 块。

⇒ 正确的规矩不是「不许重载」,而是:

  1. 重载前先 save() 并回读验证(图元数对得上再重载);
  2. 重载后必须数一遍图元(pcb2_reload.py:assert_alive()),为 0 就停手、绝不 save;
  3. 读到 0 时先 API Gateway → 重新连接,不行就整个重启 EasyEDA; 如果重启之后某块板仍然读 0 而另一块正常,那就是这块板的文档坏了,修不了,只能换。

9ae-4. 铺铜「重建所有」填 0 块的另一个成因:飞线还在算

§9k 说填 0 块 = 客户端卡在后台任务上,判据是底部那条 0% 进度条。 2026-09-08 补一条具体是什么任务:刚导入几百个元件 + 两千段铜之后, pcb_Document.getCalculatingRatlineStatus() 返回 active —— 飞线在算。

⚠️ 但 stopCalculatingRatline() 停掉它(active → inactive)并不能让铺铜通, 0% 进度条也不消失。最后还是要重载。 ⇒ 这个 API 可以用来确认后台确实有任务在跑, 但别指望它能解开死结。

9af. 布线质量的客观判据:绕行比 = 走线长 ÷ 焊盘跨距

不用截图就能给走线打分:

  • 每网络走线总长 L = Σ hypot(endX-startX, endY-startY)(pcb_PrimitiveLine,只数铜层)
  • 焊盘跨距 S = max 两两焊盘距离
  • 绕行比 = L / S

判据(两脚网络):1.0 ~ 1.5 = 直连;> 2 = 绕远路。 对方靠这一个数抓出一条 13 个过孔 / 2.2× 的 PWM 网络,全程没开截图。

⚠️ 长度本身没有判别力(跨距大的网络本来就长),要判的是路径不是长度。

⇒ 这条正好补上本项目 CLAUDE.md 里那句「几何验证证明不了看着对」的一部分: 绕行比能把「连上了但绕了一圈」从人眼截图挪进脚本。建议加进 §10 验证流程的 PCB 侧。

9ag. 自动布线忽略 GND → DRC 连通性开路,而飞线不显示 ⚠️⚠️

把 GND 加进自动布线的「忽略网络」(指望铺铜兜住), 则没有被 GND 铜实际盖住的 GND 焊盘会判为连通性开路——对方那块板 62 处。

最坑的是飞线数远小于 DRC 数:GND 的飞线被部分抑制,看飞线会以为没事。 判据:信 DRC 的连通性计数,不信飞线。

两条出路,二选一:

  1. 把 GND 也交给布线器(代价:顶/底层拥挤 + 几处过孔间距吃紧);
  2. 给每个开路的 GND 焊盘打一个过孔下到内层 GND 平面。

本项目走的是 2(pcb2_strap_gnd.py / pcb2_vo_vias.py), 但开路的判定来源要按这条改:以 DRC 连通性计数为准,不要拿飞线数收工。

顺带:对方在 Freerouting 上的结论(本项目没走这条路,存档备查)

DSN→jar→SES 往返跑得通但结果不可制造,两个死因: ① 铺铜导出成 plane 0(= 没导出),布线器看不见铜,会把 GND/电源当普通线布; ② 逐对象类型的间距矩阵在 DSN 里塌成单一 class 间距 —— track-via 6mil / track-copper 10mil 全丢, 只剩 track-track。导入后 track-过孔间隙落在 0~4 mil,改 DSN 间距重布结果一模一样(属 SES 导入的产物)。 ⇒ 用 EasyEDA 自带的自动布线器(本项目 §9p 走的就是这条),别在 Freerouting 上耗时间。