文件
phonerail/.claude/skills/easyeda-sch-geometry/SKILL.md
T
zhou yang和Claude Opus 5 2bfe308b30 初始导入:PhoneRail 五路手机直供电源板
原理图/PCB 已在 EasyEDA 里做到 12 页跨页 0 漏接,固件刚完成骨架包 A1。
这次是把已有的一切一次性纳入版本控制,没有改动任何设计内容。

纳入:
- 设计与需求文档、PROGRESS.md、小米8 参考原理图 PDF 与追脚脚本
- .claude/ 下的 EasyEDA 桥接工具链与几何/网表缓存(eda-parts.json、
  pcb-parts.json、pcb-pads.json、p2-ch1.json 等,是重画各页的唯一事实来源)
- 117 张验证截图(本项目所有方向问题都是靠人眼看截图发现的,属于证据不是垃圾)
- firmware/ 的 A1 骨架与 site/ 文档站源码

.gitignore 排除:构建产物(firmware/build、host/build、sdkconfig)、
site/node_modules、__pycache__、桥接日志 _bridge.log、脚本自标的 *.stale。

.gitattributes 默认 `* -text`:现有 .py/.json 大多是 CRLF,原样存,
不让 git 重写行尾。唯一例外是 *.sh 强制 LF —— 带 \r 的 shell 脚本 bash 跑不了。

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QYuExfgyYa6DzRRcUso8M8
2026-09-06 18:18:14 +08:00

74 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 仍然生效 —— 超时后先去库里搜一遍有没有留下半成品再重试。