--- name: easyeda-sch-geometry description: >- 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()` 读回来是 `''`,图上的标签也一起没了(那一段导线于是变成无名网络)。 所以「改一个端口的网络名」这件事**只能删掉重建**,不能改属性。 **规则:旗和端口只能删掉重建,不要移动、也不要改网络名。** ```js await eda.sch_PrimitiveComponent.delete(oldId); await eda.sch_PrimitiveComponent.createNetFlag('Power','+12V', nx, ny, 0, false); ``` 这个错误**几何验证抓不到**——旗还好好地连在导线上,只是名字从 `+12V` 变成了 `+5V`, 于是那一路电源悄悄挂到了一个不存在的网络上。必须靠 §10 的网络名检查。 --- ## 4. 导线必须用扁平数组 **实测。** ```js 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 ─┘ ``` **正确解析** ```python 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、稳压管)放完必须断言一次。** ```python 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 那种)。 **规矩:下发前本地过一遍「横平竖直且非零长度」。** ```python 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()` 里**: ```js 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 个单位再放。 重画整页时**不要**简单地丢掉退化段——先把它们列出来,逐个补成真导线。 ```python # 回读几何时把退化段单独挑出来,别默默丢掉 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): ```bash /c/Python314/python .claude/port_audit.py # 端点 = 连;中段/悬空/压引脚 = 断 ``` ⚠️ 写这个检查时**必须按 §5 的端点对解析导线**。按折线解析会凭空造出斜线段, 572 个旗里有 197 个被误报成「顶点可疑」—— 其中包括一大片工作正常的 GND 旗。 误报率高到这个程度的检查等于没有检查。 **修法:不要动端口**(§3b:移动旗/端口会丢网络名)。改导线,让端点落到端口引脚上: ```js 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) 上面那次是「后画的长线盖过去」。这次是更蠢也更容易复现的一种: ```python 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` 里已经加了): ```python 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) 想画一条「从芯片左边进、穿过本体、右边出」的直通线是做不到的: ```js 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) 改造已有页面的脚本通常长这样:清掉旗/端口/导线/文字 → 读回元件引脚 → 重画。 如果中间夹了一条一致性检查,**它必须在删之前跑**: ```python # ✗ 错的:第二次重跑时连接器已经删过了,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()` 的第一个参数是对象,不是两个字符串 ⚠️ **实测。** 完整签名: ```js 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. 页面切换与截图 ```js await eda.dmt_EditorControl.openDocument(pageUuid); // 返回 "@" 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']`。 **截图**(自查排版的关键手段,不用麻烦用户): ```js 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 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 有三个纯绘图图元,没有任何电气含义: ```js 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)。 **防呆**:放置表里每一条都断言字段齐全再下发。 ```python for it in out: assert len(it) == 6 and it[5], u'placement 少了 libraryUuid: %s' % (it,) ``` 顺带两条同样有用的: 1. **超时的 create 并没有被取消**,它还在客户端里挂着。所以「超时 → 立刻重试」 会叠出一堆永不返回的 promise。先停手,把根因找出来。 2. 真遇到「库件要联网取、确实慢」时,可以用**甩出去 + 轮询**绕开单次请求的 30 秒上限(`globalThis` 在两次 `/execute` 之间是保留的): ```js 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 | 被前面那块同网络的吃得一点不剩 | **怎么量出来的**(这是核对铺铜结果唯一靠得住的办法): ```js 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 里能扫到: ```js 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` 「越框」。**这些假问题看起来和真问题一模一样**, 照着去挪件就会把已经排好的布局改坏。 **规矩:重抓包围盒时把焊盘位置反转回元件本地系。** ```js 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()` 里**永远没有它**。 ```js // ❌ 板框还在的证据不能这么找 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` 只能拿到头几行。 要滚动容器逐屏收集: ```js 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` 也以「布线网络」开头(它是第一节),于是九个单选全都命中。 正确做法:在所有「文本以组名开头且含单选」的元素里取**单选数最少**的那个。 布线图层的复选框是 ``, 勾选状态看 `` 有没有 `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()` 的实现里,打孔的条件是: ```python 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()`): ```python 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.95~2.25)抓成 −2.25~2.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` 的叶子元素; 点**叶子文本元素**(不是 ``)才选中(`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 的第三种成因,比前两种更容易上当,因为**看起来是端点**。 给一排电阻做共地母线时的自然写法:把母线拆成两段,让旗的位置正好是两段的公共端点。 ```python 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`),再写成目标值。 ⚠️ 改的是**元件身份**,`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` 拼接读出来,表头(位号)和明细行会被拼错, 读出来的清单似是而非 —— 同一份数据能读出两种互相矛盾的「错位方向」。 逐 `` 读、每行自带单元格(`.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`](https://github.com/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()` 的返回值不算数。落盘的证据是**重新读一次图元计数**—— ```python 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 上耗时间。