
# EasyEDA Schematic

Use `easyeda-agent` typed actions. Do not write raw EasyEDA JavaScript unless a typed action is missing and the user explicitly accepts a debug path.

> **本文导航**:Workflow · Production preflight gates · library-first 绘图 · netlist 批量实现 ·
> pin-aware autoconnect · module-aware autolayout · zone-less packing · Actions · Bundled Scripts ·
> Guardrails · Layout Conventions · EasyEDA Electrical Rules(load-bearing)· Missing Actions。

## Workflow

1. Run `easyeda health`.
2. Read active project and schematic context.
3. Inspect before mutating.
4. Prefer small additive operations.
5. Verify each mutation by readback, snapshot, or DRC.
6. Ask before destructive operations or a multi-step mutation plan. A save at an already-defined
   passed stage is mandatory and does not need separate confirmation unless the user explicitly
   requested step-by-step approval.
7. Summarize changed primitives, warnings, and artifacts.
8. If an official EasyEDA API is missing, undocumented, or differs from runtime behavior, record the evidence and workaround; when it affects correctness or maintainability, prepare a minimal repro and file an issue with the relevant official EasyEDA repository.

## Production preflight gates

- **Sheet first, default A4.** Before any whole-board placement/routing, run `easyeda doc ls`, switch to the target schematic page, then run `easyeda sch sheet-geometry --json`. If no `componentType:"sheet"` bbox is available, stop and ask the user to select/create the default A4 sheet in EasyEDA; do not place parts, wire nets, or run `sch autolayout --apply` on a sheetless page. **图签 keep-out 只对 A4 横版标定过**(实测真实图签≈右下 60%宽×20%高 → `(468,0)..(1170,165)`,`source:known-template-ratio`;旧 0.22×0.14 只圈右半日期列、漏保护左半是已修的坑)。**非 A4 尺寸**(A3+)图签是定尺寸、占比更小,`sheet-geometry` 会**降级 `source:fallback-ratio` 并 warn「calibrated for A4 landscape only」**——那时 keep-out 是过估近似,别当硬门信,人工核对。所以**默认就用 A4**;要支持别的尺寸需按该尺寸补标定。
- **Page plan before coordinates.** For non-trivial designs, decide the page/module split from the A4 usable area before placing anything. If the modules do not fit with route channels and title-block keep-out, create/rename pages and split modules instead of expanding coordinates outside the sheet.
- **Clear is destructive.** Use `easyeda sch clear --dry-run` first, report the delete counts, and wait for explicit confirmation before `easyeda sch clear`. Preserve the sheet by default; only use `--no-preserve-sheet` when the user explicitly wants the drawing frame removed too. After clearing, read back the page and confirm only the intended sheet/template primitives remain.
- **Honor step confirmations.** If the user asks to confirm each step, stop after every stage report (preflight, clear dry-run, clear apply, page creation, placement dry-run, placement apply, wiring, verification, save) until they approve the next mutation.

## Drawing a schematic — library-first (default)

> **Design conventions live in this skill's references**
> (layout zones, spacing, wire/orientation rules, part-selection criteria, the
> canonical orientation table + standard-parts library). This operational skill
> **links** to it — single source, never copy the rules here.

> ⚠️ **整板 / 非平凡设计 → 先走 [`design-flow.md`](./design-flow.md) 流程脊柱。**
> 那里有分阶段 + 硬门禁(预分析 → 分页 → 模块编组 → 按组摆放 → 通道布线 → DRC + layout-lint → 调整闭环),
> 专治「随手摆导致覆盖、外围乱飞、线压元件」。本 skill 提供它每一步要调用的**具体动作**。
>
> ⚠️ **多器件 / 整板设计:先花几分钟摸底,再动手。** 非平凡板子(>~10 件,或要交付/排 PCB)
> place 前快速读懂设计(器件/电源树/功能分组/幅面)——见
> [`design-pre-analysis.md`](./design-pre-analysis.md)(轻量摸底,不是门禁)。
> 然后照 [`auto-layout-sop.md`](./auto-layout-sop.md)
> 的 CLI 能力 + 硬坑落坐标,布局**用数据 + 截图自调**(放→读回坐标→`sch layout-lint` 判覆盖/间距→挪→再验)。
> 小改 / 几个器件直接按下面放置。
>
> ⚠️ **标准外围先查块(铁律 8):** `easyeda blocks show <id>` 给 `internal_nets`(照抄拓扑,引脚用
> 功能名零改号)+ `ports`(重绑边界网络)+ `schematic_notes`(落线注意);命中就别手接。ESP32
> 自动下载(双三极管交叉耦合时序易接反)这类电路尤其照块抄,别凭记忆手连。
> **块的 `schematic_layout` 有两种形态**(#180)。**关系形态(推荐)**只声明意图 ——
> `flow`(信号流左→右)/ `attach`(角色→目标.引脚,去耦贴电源脚)/ `pair`(等距并列组),
> **一个坐标都不写**;`sch block-apply` 走**两阶段求解**:先落锚件(五级判据自动选:
> 被 attach 指向最多者 = 主芯片)→ 回读它的**实测引脚坐标** → 据此算其余件 → 逐个放。
> 避让是**受约束的**(只沿关系自己的轴推,另一轴钉死),所以 flow 永远共线、pair 躲让
> 也走整数倍 pitch、attach 永远待在目标引脚那一侧 —— 用环形推让会把关系语义当场
> 破坏(实测 flow 两件 y 差 220、pair 完全不成对)。
> **落地后、连线前还有一步「推让」**:数锚件每一侧要挂几个 marker,算出需要多深的通道,
> 通道带里的件不够远就整条链让开(被推的件挤到更外侧的件,那一件跟着让;pair 组整体平移,
> attach 的去耦**永不推**)。它解两遍 —— 落地前按估算尺寸(决定件创建在哪),**落地后按
> 实测 bbox 再补一次**(符号的锚点常常不在 bbox 中心,估算必然差一截),然后才过布线前硬门。
> 所以 **`--at` 给的坐标不是最终坐标**,以 manifest 里的 `AT` 为准。
> 日志把算术写全了,照着判断即可:`relational: left 侧 6 个 marker 需 276,与 D1 只有 120
> —— D1 让 155、J1 让 55(通道 → 274)`;推不动时会说被谁顶住(可用区边界 / 页面上已有图元),
> 那就是**该换更大图纸或拆页**的信号,不是重跑一次能解决的。
> **落完自动按「功能子群」登记虚拟组**(不是整块一个组):有 `flow` 的块按**信号流每一级**
> 一群(CH340C → `/J_USB`(Type-C + CC 下拉)/ `/D_ESD` / `/U`(桥芯片 + 去耦));
> **没有 flow 的块按 `attach` 的目标引脚**分群 —— **贴同一个脚的件就是一个功能单元**,
> 锚件自成一群(群名 = 块短名)、其余按 `ROLE_PIN` 命名(2026-08-20 修复:此前归属只
> 取 `role.pin` 的 role 那一半,`U.3V3`/`U.EN`/`U.IO0` 一律归约成 `U`,于是
> `esp32s3_wroom1_module` 6 件糊成一个 507×712 的区,**独占一整页也放不进 A4**,
> `zone-arrange` 四条边逐条报「被图签挡」;现在拆成
> `esp32s3_wroom1_module`(U)/ `U_3V3`(C_VDD+C_BULK)/ `U_EN`(R_EN+C_EN)/
> `U_IO0`(R_IO0)四群,离线判据实测同页排得下)。**件太少或 attach 全指同一个脚就不拆**
> (小块本来就是一个功能单元,硬拆只是多两个空框)。子群 = `sch group-move --group <id>`
> 的抓手,也是 `zone-plan` 的分区粒度 —— 组名末段就是区名。
> **legacy 形态**(`roles` 绝对偏移)
> 仍受支持但已废弃:块作者写模板时不知道实例会落在页面哪里、图纸多大,手算必踩
> 出界/顶标题带。原点自动避开已有器件真实 bbox 且**不出图纸**(显式 `--at` 优先);
> 螺旋搜索落空时还有一层网格扫描兜底(螺旋步长随块尺寸放大,中等块常常一个候选都
> 落不进可用区,那不等于放不下)。每次 place 都记录平台返回的
> `primitiveId`,落完回读真实 bbox + pins 作**布线前硬门**:读取/解析/几何不完整、bbox overlap
> 或异件引脚重合都会在 autoconnect 前失败。命令只按本次返回的 ID 补偿删除并再次读回;
> 能证明删净报 `failed-rolled-back`,否则报 `failed-partial` + `PARTIAL STATE`,绝不把独立 autosave
> 的多次变异伪装成事务。优先用 `block-apply` 而不是逐件手放。
>
> 🩹 **place 超时收编(假失败定律在 place 上的缺口,2026-08-19 修)**:`place` 报
> `connector did not respond` **不等于没落地** —— 连接器侧 `create` 通常已经建好了件,丢的
> 只是回执。以前 Go 侧拿不到 `primitiveId`,回滚无从下手,那个件就永远留在页上(真机一轮
> 留下 `U2`/`U2`/`U3` 三件,**每次重试再生一个**)。现在 block-apply 在放置前快照本页全部
> 器件 id;place 超时(或成功但没回 id)时做一次 **settle 回读**,把**同时满足「不在快照里」
> 「componentType == part」「落在下发坐标 ±5」**的那个件认回来(`rollback.adoptedPrimitiveIds`),
> 它随后走和正常件一样的逐个删+回读证实。**绝不凭空造 id**;页面上原有的同型同坐标器件
> 天生在快照里,永远不会被收编或误删;快照读不到就**整个关掉收编**并如实报 PARTIAL STATE。
> 命中 ≥2 个则不收编但**逐一点名**,并打印可直接跑的 `sch prim-delete --ids …`。
>
> 🩹 **命中 0 个要先证明「回读是新鲜的」才算数(2026-08-20 修)**:`adopt ✓ …确实没有落地`
> 这句话曾在**它唯一该起作用的场景里系统性说反话** —— 真机上 place C8 超时,收编回读报
> 「(440,535) 附近没有新器件」,而 C8 就在 (440,535)。根因不是判据错,是**回读本身不成立**:
> 让 place 超时的连接器 wedge 同时让这一读没反映当前页面(旧快照 / create 还堵在队列里),
> 两者都只是**读得太早**。现在多一道门⓪:**本命令此前已成功放置并拿到 id 的器件必须一个不缺
> 地出现在这次回读里**,否则这一读什么都不算,判定降级为 `adopt ?`(uncertain)并进
> `PARTIAL STATE`,同时打印可执行处方(`sch save` → 完全退出重启 EasyEDA → `sch list` 查坐标 →
> 有就 `prim-delete`)。**读它的口径**:
>
> | 报文 | 含义 | 你要做什么 |
> |---|---|---|
> | `adopt ✓ …按 id … 收编` | 件已落地,句柄认回来了 | 无 —— 回滚/后续照常 |
> | `adopt ✓ …确实没有落地(强证据:…)` | **顺序可证**:那里确实没有新器件 | 无 —— 可以直接重跑 |
> | `adopt ✓ …确实没有落地(**弱证据**:…)` | 只有探针启发式支撑(连接器旧) | 可以重跑,但先看一眼报文里的升级提示 |
> | `adopt ? …无法判断` | **回读不可信**,落没落地都有可能 | 照处方去页面上查一眼,别盲重跑(会造重复件) |
> | `adopt ✗ …` | 没快照 / 回读失败 | 同上 |
>
> 🔒 **两级判定:算术优先,探针兜底(2026-08-20,连接器 FIFO 上线后)**。上面那道门⓪
> 之所以只能是启发式,根因是**写和随后的读之间原本没有 happens-before 关系**:连接器把
> 每条消息交给各自的回调,`await` 不跨回调排队,两条动作可以同时在飞(用真 transport
> 跑的探针实测:写还没 settle,读的 handler 已经开跑,响应也先发了出去)。连接器现在把
> 动作串成**一条 FIFO 链**,并在每条响应上带 `seq` / `seqAbandoned` / `unordered`,于是:
>
> - **算术档(强证据)** —— 比较「失败那次 place 之前观测到的 `seqAbandoned`」与
>   「收编回读响应上的 `seqAbandoned`」。**没变** = 这中间没有动作被放弃 = FIFO 保证这次
>   回读的 handler 在那次 place 的 handler settle 之后才开跑 → 「那里没有新器件」是真的。
>   **变了** = 有 handler 被丢下且**仍在跑、效果可能稍后才落地** → 一律 uncertain。
>   报文打 `证据档:可证(连接器 FIFO 顺序序号)`。
> - **探针档(弱证据)** —— 连接器比 CLI 旧、响应不带这些字段时,退回原来的探针启发式,
>   报文打 `证据档:弱(探针启发式)` 并给出升级连接器的步骤。**绝不会因为缺字段就默认
>   「新鲜」**。
> - **`unordered: true` 的响应永远不算证据** —— 那是连接器旁路通道(wedge 期仍可观测的
>   纯诊断读)的响应,它的 seq 与 FIFO 无关。
>
> 边界移动了:**第一件(anchor)就超时、没有任何探针**的场景,探针档结构上无解(那一刻
> 「没落地」与「读得太早」在观测上完全等价),但**算术档能出结论** —— 这正是它最大的收益。
>
> ⚠️ **`seq` 证明的是 handler 边界,不是「文档已提交」**。`eda.*` 可能在 handler 返回之后
> 才把改动写进文档模型,那一层没有任何观测点。所以「确实没有落地」的准确含义始终是
> **「在可证的最新一刻,那里没有新器件」**——报文原文就是这么写的,别在转述时把它说满。
>
> ⚠️ **残件删不掉 ≠ 未提交 ≠ 回读 stale**:同一份失败报文里**有回执的**器件也会
> `survived`——那是**连接器 action 队列 wedge**(某个重调用的 promise 永不 resolve)。
> 连接器 FIFO 上线后这件事有两个变化:(1) wedge **会自愈** —— 队首超过它自己的
> `timeoutMs` 就被放弃,队列继续流动(而不是像以前那样静默吞掉接下来几分钟的
> `place`/`delete`/`document.open`);(2) 被放弃过会体现在后续响应的 `seqAbandoned` 上,
> 所以那段时间的写**判定为 uncertain 而不是失败**。收到 `ACTION_ABANDONED` 错误码 =
> 这次写**没有已知的完成时刻**,盲重发就是在造重复件。处方不变:`sch save` → **完全退出
> 并重启 EasyEDA** → 再删。另见 `QUEUE_OVERFLOW`(队列积压到上限):那个动作**根本没有
> 执行**,等积压消化后重发是安全的。
>
> 🔢 **多页工程的位号真相(#144):** EasyEDA **页数据懒加载**——`getAll(_, allPages)` 只返回
> **本会话打开过**的页,没访问过的页对我们隐形,却照样参与平台自己的位号避让。曾因此规划 `C1`
> 落地却成 `C11`,而 wiring 仍拿 `C1` 去解析 → **跨页连到另一页的 C1 上**(netlist 按
> designator.pin 全文档索引),13 条连线全废且报出本页不存在的网络。现已双层兜底:预扫描
> `tagPages` 强制遍历各页把数据加载进来;放置后再**回读平台实际赋予的位号**,不一致就把
> placements / net members / `<INSTANCE>_N<i>` 内部网名一并 remap(manifest 里
> `designatorRenames` + 警告)。**内部网名必须跟着位号走**,否则两个实例的 `C1_N3` 会跨页同名合并。
> ⚠️ 由此推论:**任何按位号引用图元的批量流程,都别信规划值,以放置回读为准**。

Place **real parts from the EasyEDA / 立创(LCSC) library**, then wire them.
Hand-drawing a custom component symbol is the **fallback**, used only when the
part genuinely isn't in the library (a hand-built symbol loses the
footprint/supplier linkage and is error-prone — prefer a library part, even a
near-equivalent, first).

0. **Standard parts first.** Check [`standard-parts.json`](./standard-parts.json)
   (in this skill's `references/`) for the category you need (10k 0402, 100nF,
   ESP32-S3, AMS1117, USB-C, …). If it's there, place straight from its
   `{ libraryUuid, deviceUuid }` — deterministic, BOM-ready, with the real LCSC
   C-number. Only search when the category is missing, and ADD the chosen part back
   to `standard-parts.json` (with its C-number) so the next design is reproducible.
   When you already know the **exact C-number** (from a BOM or standard-parts.json),
   resolve it deterministically with `lib by-lcsc --lcsc C…` (`schematic.library.get_by_lcsc`)
   → `{libraryUuid, uuid}` ready to place, skipping free-text ranking. After a new
   selection, `scripts/parts-add.py` appends the resolved part into `standard-parts.json`
   so the curated cache grows (it reads the JSON `lib by-lcsc` / `lib search` emits).
1. **Search** (fallback) `schematic.library.search` (free-text: an MPN, value+package,
   or a name like `ESP32-S3-WROOM-1`). Results are **reranked by relevance** (best
   category first; each carries a `score`), so the right part usually leads — but
   still sanity-check `value`/`footprintName`/`lcsc` before placing. Each candidate
   carries `uuid`, `libraryUuid`, `name`, `footprintName`, `lcsc`, `manufacturerId`.
2. **Place** `schematic.component.place` with the chosen `{libraryUuid, uuid}` at a
   coordinate → a manufacturable part with correct symbol + footprint + LCSC number.
   ⚠️ **`--uuid` must be a DEVICE-library uuid** (from `lib search` / `standard-parts.json`),
   **never** one of the uuid-looking fields `component`/`symbol`/`footprint`/`uniqueId`
   that `sch list` reports — those are placed-INSTANCE ids and **cannot be replayed**.
   Feeding an instance uuid hangs the EasyEDA API; `sch place` now fails fast (~8s) with
   a hint instead of stalling 20s on `context deadline exceeded`. To re-place an existing
   part, run `lib search` again to get its device uuid.
3. **Read pins** (`schematic.components.list` / pin readback) for exact pin
   coordinates before wiring.
4. **Wire** (reference-validated — see **画线 / flag / 去耦(CLI 级硬规则)** in
   [`auto-layout-sop.md`](./auto-layout-sop.md);
   the 嘉立创 ESP32-S3 standard project is **flags only on power/ground rails; module-local
   signals use real wires and long/cross-module signals use named netports**):
   - **Module-local signals = real orthogonal wires** (pin→wire→pin). Endpoint on a pin coord
     = connected; non-aligned pins → L-route `[x1,y1, x2,y1, x2,y2]`. Use named netport
     stubs for long, cross-module, or cross-page signals.
   - ⚠️ **Never run a wire through another pin** — EasyEDA trims+connects it there.
     Route in pin-free channels.
   - ⚠️ **Multi-pin nets: chain pin→pin** (each segment anchored on a pin), NOT a star
     to a free junction (EasyEDA drops the un-anchored junction on merge).
   - **Flags ONLY for power/ground rails** (`connect_pin direction=`, never blanket rot 0).
5. **Verify each page** with `easyeda sch gate --strict --doc <page>` — one command runs
   layout-lint → check → bridge-check → drc in a fixed order and returns one verdict
   (`pass` / `fail` / `blocked`; `blocked` means a checker could not RUN, so the page was
   never judged — fix `health`/`doc switch` and re-run rather than editing the circuit).
   Then do a `sch read` comparison against the design spec or saved pin→net golden map:
   the gate proves the page is *legal*, only that comparison proves it is *correct*.
   The single checkers stay available for spot re-checks; the data linter
   (`scripts/lint.sh <project>`) is an additional check, not a replacement. ⚠️ After API edits the **EasyEDA canvas may not
   auto-redraw** → `getCurrentRenderedAreaImage`-class viewport captures return a STALE
   frame (even `view fit` framing is stale). **Judge STATE by data (`sch list`/`getAll`),
   use the screenshot for visual layout only**, and touch the page in EasyEDA (scroll/
   click) to force a redraw before trusting a snapshot. Pass the previous frame's `sha256`
   by preferring `sch export-image` (renders document data, viewport-free); a stale frame
   must not be trusted for verification.

## Bulk realization from a netlist (automated)

For a whole board (place ~N parts + wire the full netlist at once), the manual flow
above doesn't scale. Pipeline (proven on box-v2/110 parts):

1. **PLACE-ALL** — for each part, resolve `{libraryUuid, deviceUuid}`
   (standard-parts.json first, `lib.search` fallback), place at coords, then assign
   the designator (`sch modify --patch '{"designator":...}'` — place leaves it `C?`).
2. **READ-PINS** — ONE `sch list` / pin pull AFTER all placement for real pin coords
   (don't trust pre-place maps; map IC functional names → physical pads first).
3. **WIRE** — per net, decide flag vs local wire vs label (see the decision table in
   the SOP); emit flags via `connect_pin direction=` (never blanket rot 0).
4. **DRC + lint**, then a **MANDATORY clustering/zone pass** before "done".

> ⚠️ **Layout is NOT optional.** Naive place-at-synthesis-coords + flag-every-pin is
> electrically valid but **visually scattered** (box-v2: 327 flags, decaps far from
> ICs). **Follow [`auto-layout-sop.md`](./auto-layout-sop.md)**
> (`auto-layout-sop.md`): fit sheet → mains by zone → auxiliaries pin-relative to their
> owner IC → fine-tune. And **write resolved parts back into `standard-parts.json`** in
> the same change (so the next board doesn't re-search non-deterministically).
>
> **Churn-resilience for >~50 mutations** (essential, see the SOP): route by
> `--project` + `--doc`; batch with typed actions, `easyeda apply`,
> `scripts/bulk-place.py`, or `scripts/bulk-connect.py`; incrementally `sch save`;
> re-pull fresh primitive IDs each chunk. `debug.exec_js` remains a user-approved
> temporary fallback only when no typed action exists.
>
> ⚠ **exec_js 建线勿走 create+modify 两步**(#133 Bug 2 实录,Windows 桌面端):批量
> `sch_PrimitiveWire.create()` 后再 `modify(id,{line,net})`、紧跟 `sch save`,触发过**不可逆
> 画布状态损坏**(net 全丢、floatingPinCount 爆表)。`create(line, net)` **一步带 net** 创建,
> 或直接用 typed action(`sch connect`/`sch autoconnect`);批量 exec_js 落线后先 `sch read`
> 逐网验证再 save。另:查 API 真名用 `easyeda api search`——索引已按**运行时可调用名**归属
> (0.15.1 修复:此前 57 个带 implements 的类方法被错归到 `sch_Netlist`/`pcb_Net`,照抄会 undefined)。

## Pin-aware autoconnect — let the planner pick direction/offset

`connect_pin` (`sch connect`) keeps the connection **safe** (pin → short wire →
flag/netport, never a netflag on a bare pin), but it still makes YOU pick
`--direction` and `--offset`, so layout quality depends on judgment. **`sch
autoconnect` removes that judgment**: it pulls the real geometry (part bboxes,
pin coords, existing flag/port/label bboxes, title-block keep-out), scores every
`up/down/left/right × offset` candidate with a deterministic cost function
(flag-collision / through-part penalties, shortest-offset + outward-side +
kind-default bonuses), picks the lowest-cost one, and delegates the mutation to
`connect_pin`. Same schematic state + spec → same selection (deterministic).

**批次内互斥 (issue #138):** 同一批(--spec / 多 --pin)里**已规划的短桩会当作
既存导线注册回 scene**,后续连接对它做同样的异网硬拒——同器件相邻异网引脚
(隔离 DC-DC 的 B0512S 类四域脚)不再出现短桩共线相触被 EasyEDA 合并成隐性
短路;规划器会自动换方向/offset 错开,四向全堵时按 #64 语义响亮报
"no safe candidate" 拒绝落笔。多域脚器件仍建议 power 上/gnd 下方向分治,给
规划器留出错开空间。**标签 stagger 用真实 marker bbox 预测(#148 Phase-2):**
预测框按 family + direction 使用活体 `getPrimitivesBBox` 标定值,并相对连接端点朝
body 所在一侧偏移,不再用旧的端点居中 24×11 框:ground 为 10×21/21×10、
power 为 6×11/11×6;**netport 的长度跟网名走**(`6*len(net)+8`,下限 31)——
写死 31 会让任何长于 3 字符的网名少算,评分器于是算出「刚好不撞」而渲染出来擦在
一起。**预测框 = 符号本体 ∪ 文字带**:`sch check` 的 marker-overlap 判的就是合并
后的框(power/ground 的网名画在符号旁,长 `6*len(net)`、高 12),判定与生成必须
同一把尺,否则评分器挑的「干净」位置在 check 眼里照样重叠。故
**10-unit pitch 平行脚上相邻 marker 会触发 stagger,自动挑不同 offset 错开**;
候选打分与同批后续连接注册回 scene 使用同一预测函数。

**密集区会拉长桩线超出 `--offset-max`。** 常规档位里一个「既可选(未被 #64 硬
拒绝)又不撞 marker」的候选都没有时,规划器把候选范围扩到 **3×offsetMax** 继续
找干净位置 —— 人工画法本就如此(同侧密集旗阶梯 offset 错列)。所以看到某根桩线
明显比同页其他的长,那是**让开标签**的结果,不是失控;扩展候选照样过全部判据,
#64 短路保护不会被绕过。真机 ceshi 单块回归:markerOverlaps 12 → 3。
残留不可避免的密集重叠由 `sch check` 的 marker-overlap 门捕获(见 Actions)。

**Hard rejects (issue #64):** two hazards are never soft penalties — they make a
candidate *unusable* no matter the offset, because EasyEDA would silently merge
nets and the post-hoc DRC can't see it: (1) a stub whose endpoint or path touches
an existing **foreign-net wire** (endpoint-on-wire = junction = net merge), and
(2) a stub **crossing a non-target pin** (EasyEDA trims+connects there, and the
wire-over-pin rule exempts pin endpoints). autoconnect now pulls existing wire
geometry into the scene automatically; a wire already on the target net is fine
(that's the connection point). **Title-block intrusion is now a THIRD hard reject
(#147)**: a label landing in the A4 图签 keep-out steers to a safe direction, or —
when every candidate enters it — fails rather than dropping a netport on the
明细表 (which layout-lint, part-only, and the geometry-blind electrical check both
miss). If EVERY direction/offset is a
hard reject, autoconnect refuses to place the stub and reports the connection as
failed — resolve the layout (move the part / clear the wire / free the title-block
corner) and retry. **Create-after backstop (#147 DoD2):** the plan hard-rejects a
title-block hit using a NOMINAL box, but a marker whose real rendered width (net-name
text) still spills into the 图签 is caught by a post-batch real-bbox re-read — the
intruding wire+marker is DELETED and that pin failed, so the command never returns
success with a marker on the title block. **Partial-run bookkeeping (#146):** a batch interrupted mid-way
returns `partial:true` + `succeeded[]`/`failed[]` pin lists — **retry ONLY the
failed pins, never replay the whole spec** (a blind re-run stacks duplicate markers
on the already-connected pins, which `NetKnown=false` after a connector drop can't
detect). **Always run `sch check` right after a batch autoconnect** — its new
`duplicate-net-marker` rule is the guard that catches those stacked markers.

**带痕候选不再静默入选。** 硬拒之外的碰撞惩罚是软性累加,此前选中候选哪怕
score 上千(真机:score=1737 的长桩扎进邻组标签区)也照连且报告只显示落选项。
现在**选中候选** score 超过软阈值或 reasons 里含碰撞类惩罚时,结果行会带
`⚠ WARN`(默认档照连);**`--strict`** 则把这类连接直接判失败、不落地。
看到 WARN 的处方是**挪件腾位后重连**,不是忽略它。

**平台会随机吞掉一个连接(stuck-at-99%),autoconnect 现在自己救一次。** 实测 2821 次
connect_pin 里 57 次失败,其中 23 次是 netflag 卡在「请求被丢掉但平台不报错」——
它是随机的,同一脚重发通常就成。所以失败后**重试一次,但只在连接器明确声明回滚
之后**(netflag 失败时它会先删掉已建的桩线);`connector did not respond` 这类
**状态未知**的失败绝不重试 —— 那可能只是我们没等到回应而对方已经建好了,重试会得到
第二条桩线和第二面旗。被救回来的连接在报告里标 `retried`,**别忽略这个字段**:
它是平台在变差还是变好的唯一现场证据。

另外 connect_pin 用的是 **35s 专用预算**而非默认 20s(**裸 `sch connect` 也
已对齐**,此前它还吃 20s 默认值,慢速成功被报成失败):连接器内部最坏路径
(wire 7s + 重试 0.25s + wire 重试 7s + netflag 7s = 21.25s)本来就超过 20s,
默认预算会让 daemon 先于连接器放弃 —— 报「connector did not respond」而对方其实
已经把线和旗建完了(实测 57 次失败里 17 次是这么来的)。**`sch connect` 现在对
超时/DISPATCH_FAILED 自动做一次轻读复核**:回读确认 pin 已在目标网,就按
`slowLanded` 成功返回并在 stderr 警告勿重试 —— 「connector did not respond 后
禁止盲重试」不再需要你人工执行,**按命令输出判断即可**;真失败(复核也没看到
落地)照旧非零退出,那时仍以 `sch check` 兜底。

```bash
# single pin by designator:pin (number OR name)
easyeda sch autoconnect --pin U1:41 --kind gnd --net GND
easyeda sch autoconnect --pin U1:3V3 --kind power --net +3V3

# 同名多脚:尾缀 * 一次连全部(连接器的冗余 VBUS/GND/屏蔽脚,#145)。
# 光写功能名会被判歧义并拒绝——autoconnect 不该替你挑一个;* 是你说"全接"。
# 对单脚是恒等,所以电源/地/屏蔽脚可放心加星。
easyeda sch autoconnect --pin J1:VBUS* --kind power --net 5V   # → J1:A4B9 + J1:B4A9

# explicit coordinates (compat with existing flows)
easyeda sch autoconnect --x 720 --y 670 --kind gnd --net GND

# preview the plan + rejected options WITHOUT mutating
easyeda sch autoconnect --pin U1:41 --kind gnd --net GND --dry-run --json

# batch spec — clustered pins auto-stagger so labels don't stack
easyeda sch autoconnect --spec p1-connect.json

# re-run the SAME spec safely — pins already on the target net are skipped
easyeda sch autoconnect --spec p1-connect.json          # idempotent, no growth

# re-route pins currently on the WRONG net (delete old flag+wire, reconnect)
easyeda sch autoconnect --spec p1-connect.json --replace
```

**Idempotent (issue #50):** before connecting, autoconnect reads each pin's
current net (via `sch list --include-pins`, which now carries `net`) and classifies
every connection into three states — `new` (floating → connect), `already-connected`
(already on the target net → **skip**, no duplicate flag+wire), and `conflict` (on a
different net). A conflict is an error by default; pass `--replace` to delete the old
flag+wire (deleted **together**, so no orphan stub — see issue #51) and reconnect.
Re-running the same spec is therefore safe and never stacks duplicates. `--dry-run`
reports the three states without mutating.

Spec JSON (`--spec`): `{"connections":[{"pin":"U1:41","kind":"gnd","net":"GND"},
{"pin":"U1:3V3","kind":"power","net":"+3V3"}], "rules":{"avoidTitleBlock":true,
"avoidPinFanout":true,"staggerLabels":true,"offsetRange":[18,80],"offsetStep":6,
"minLabelGap":12}}`. Each result reports the `selected` candidate (direction /
offset / endPoint / score), the `rejected` alternatives with reasons, and the
`wirePrimitiveId` / `flagPrimitiveId`. The title-block keep-out comes from the
shared `sch sheet-geometry` derivation (issue #26) — when the sheet bbox isn't
exposed it is reported as **provisional** and not geometrically enforced (so a
guessed box can't corrupt scoring). **Prefer `sch autoconnect`
over hand-picking `sch connect --direction/--offset`** for power/ground/netport
stubs; `sch connect` stays for when you deliberately override the geometry.

## 三层布局体系 — Sheet → Zone → Group(tidy + move 各层齐备)

已连线页的布局重构走**三层刚体体系**(契约 `docs/schematic-layout-hierarchy.md`),
每层都有 tidy(布局计算)+ move(刚移,携带下层全部内容:器件+桩线+旗+登记 note):

```bash
easyeda sch zone relayout --zone MCU --apply    # ★首选:placement-first 区级重排——锚 IC 不动,
                                                #   外围电容电阻**全员竖放同行平行对齐**(电上地下,
                                                #   netport 水平朝左引出),sweep 删净旧桩旗后一遍性
                                                #   重连。不搬带线图元,没有组刚移的 merge 撕裂问题
easyeda sch group tidy --group g5 --apply       # 组内:竖放/上电下地/文字朝外;--deep 连残线清扫
easyeda sch zone tidy --zone MCU --deep --apply # 区内增量:组间 pack(保持连线不重生成时用;
                                                #   组刚移的暂态 merge 短路由 move 内核对账抓住并自动恢复)
easyeda sch zone move --zone MCU --dx -510 --dy -95   # 区整体刚移(注册 note 随行,框自动重画)
easyeda sch sheet tidy --apply                  # Sheet 层:全部区当刚体依纸张排布(图签作障碍
                                                #   L 形避让;已达标幂等 no-op;完毕统一重画框)
```

**顺序铁则(用户拍板)**:布局混乱时**先 relayout 后连线语义自动跟上**——先确认
核心器件方向位置,外围电容电阻的方向/间隔纯计算,最后才生成连线;不要在已连线
的东西上打补丁式挪动。

**统一挪动内核(ADR-0004)**:上面所有挪动/重排命令(zone move / zone tidy /
zone relayout,以及 `sch group-move`、`zone-arrange --apply`、`destagger --apply`)
共用同一个安全 move 内核 —— 快照 → 整树删证回读 → 移动 → **合并早检**(删桩线
是共线合并的触发时刻,重连前就查一次全页网表,被合并吞掉的第三方 pin 当场修回)
→ 快照重连 → netlist+bridge 增量对账,**任一步失败自动恢复到快照重连**,输出
结构化 `moveReport`(moved/recovered/stillBroken/partial),**判据是电气对账不是
坐标**。恢复段辖区是**全页**而非移动集合:凡快照里有网名、现在断连或网名不符的
pin(包括共线合并吞掉的**第三方**器件的脚,esp32Mini P2 的 P0 缺陷),一律按
快照网名重连;灌错网的(如地脚被灌进 +3V3)走 replace(带回读验证的 disconnect
后重连)。不再存在「半途失败需手工重连」的形态;仅 `stillBroken` 非空才需要
手修 —— 条目格式 `REF→期望网`,可直接喂 `sch connect --pin REF --net 期望网`;
标注 `sch disconnect` 的(快照浮空却被灌进网)先手工拆。

**内核重连默认 preserve 桩线(2026-08-20)**:第 4 步对「调用方没给显式端子」的
pin,**先按移动前实测的桩方向/长度原样重建**(刚体平移的语义是几何不变),复现
不了的才退回 autoconnect 评分,而且带**桩长硬上限**(封住 `laneStepFor` 的标准
档位 —— netport 一档 ~89、三档 ~285)。此前一律走自由评分,于是 `sch group-move`
一次「挪一下让开」就把短桩换成长桩、把区框撑胖(真机 315×389 → 523×406,+208 ≈
两档),用户/agent 的直觉操作反而破坏收敛成果。

**恢复段也按已知几何重建(2026-08-20)**:桩线快照现在是**全页**的(不只移动集合),
恢复段/合并早检先用「计划端子 ∪ 移动前实测桩几何」把**单纯断连**的 pin 原样连回来,
复现不了的、以及被灌进别的网需要 replace 的才走自由评分。此前恢复段一律自由评分 ——
连接救回来了,几何却换了一套,一次火警就把 phase A 的收敛撤销大半,连**邻区**
(第三方 pin)一起变形。**恢复段仍有意不夹桩长上限**:那是火警现场,把连接接回来
优先于把框收窄。凡是最终仍走了自由落点的 pin,内核逐条点名进 `moveReport.FreeConnected`,
由 `zone-arrange --apply` 的断言③ 报出来 —— **偏差可以有,但必须可见**。

**`--zone`/`--group` 统一命名空间(ADR-0004 Decision 3)**:所有吃
`--zone`/`--group` 的命令(zone move/tidy/relayout、group-move、group tidy、
`sch note --zone`)走同一个解析器 —— 模块认领 + 虚拟组/子组投影成一张带
来源标签的布局对象表,匹配规则**精确名 > 大小写折叠 > 唯一前缀**,组 id(g1)
与块子组末段名(D_ESD)是别名;`sch zones status` 看全表(名字+来源+别名);
解析失败报错自带全量可用名,类型不适配会指路正确命令。「不同命令认不同名」
的老坑已根治(块页 `zone move --zone POWER` 不再隐身、`note --zone` 不再把
格位名当区名)。

硬知识(实测踩坑):
- **顺序**:先 `sheet tidy` 排开各区(给区生长空间),再逐区 `zone tidy --deep`,
  最后 `sheet tidy` 收尾(幂等,已达标不动)。区带装不下 ≠ 无解——常是邻区挡路,
  是 Sheet 层的活。
- **横竖分桶**:zone tidy 自动把竖放组(双电源旗去耦)与横放组(带 netport 的
  信号链——netport 竖排文字必折叠,只能水平)排**不同的行**,竖一排横一排;
  组的移动次序按暂态依赖自动排序(目标位压谁的原位谁先走)——平台会把暂态
  叠位的共点线 merge 成一根,乱序移动会撕出短路。apply 走统一 move 内核:
  自检红自动恢复到快照重连并复检,`moveReport` 如实报 `recovered`/`stillBroken`;
  仅 `stillBroken` 非空才按 findings 手修(multi-net wire → 删线 + 两端
  autoconnect 重连)。
- **组间 hGap 默认 117** = 两个相向水平 netport 标签实测最小距;压到 40 省空间的
  代价是 `marker-overlap` 一片(实测 3 处)。
- **区间 vGap 默认 90** = 两框 pad(24×2)+ 标题带(30)+ 缝(12)——区内容间距
  决定框间距,小于 78 相邻行的分区框必然相叠。
- **方位词**支持跨两列:`left-center` / `center-right` / `any`(超高主控锚+侧排
  外围的宽区,1/3 网格词罩不住)。方位词现在只影响 `sch autolayout` 的**落位目标格**
  —— 分区框的几何一律由活体模块 bbox 反推,与方位词无关。
- **说明文字必须 `sch note --zone <区名>` 登记**成区成员——自动落点才会瞄准该区
  说明带、zone/sheet move 才带它走;裸 `sch note` 放的文字在区移动后原地掉队。
  区名全名/末段短名/组 id/唯一前缀均可;区不在本页分区计划时 stderr 有 warning、
  输出带 `zoneMatched=false`(绝不静默整页兜底)。注意:登记的说明**不反哺分区框
  几何**(框由器件内容反推;说明住在框内说明带)——不会出现"框每重画一次向下
  长一截"的自增长。
  - **落点是"贴着框底"的,而且是一句算式**:`note.y = 分区框.minY + 16`。
    文字图元的锚点 `(x,y)` 是**块的左下角**、块向上生长(2026-08-20
    `getPrimitivesBBox` 实测 5/5 例 `bbox.minY == y`),所以贴底与行数、字号
    **无关** —— 4 行说明和 2 行说明给出同一个 y 偏移,同页所有说明底边齐平。
    (旧行为按"锚点=左上角"算 `y = 带底 + 块高 + 16`,块高整个变成了离框底的
    距离:2 行 42、3 行 55、4 行 68,行数越多飘得越高、下面白空一大截。)
    y **不吸格**(吸格会把固定内缩打散成 ±2.5 的抖动);x 仍吸 5 格。
  - **说明位置的预留是二维的,而且框会为说明扩边**:
    - **高**:带高按已登记说明的实际渲染高度预留(旧版写死单行 26,2~3 行说明
      结构上塞不进带、被踢到框外)。带高 = 底边内缩 16 + 块高,贴底放进去正好
      把带填满 —— 所以"贴底"永远不会顶穿带顶探进器件区。
    - **宽**:说明先按**框宽**折行(按它自己的 `--font-size` 量,与尺寸回读同一把
      尺),折完还比框宽就把框**横向扩边**。**窄框(如区里只有一个 2 脚接线端子,
      框宽 68)一律扩到最小可读宽度 120** —— 而不是"既装不进又永远报警"。
    - **带内占用**:邻区桩线/marker 伸进说明带时,框底**下探**到占用之下,说明
      **仍然贴着(新的)框底**——位置约束不因避让而放弃,代价由框承担(旧行为
      是把说明踢到"区外走廊",落在框外下方)。
    - 扩边/下探不越过纸边 / 图签安全带 / **邻区的基础框**(留一个 gutter),所以
      为说明扩边不会自己撑出 `partitionOverlap` 让 `zone-draw` 拒画。**顶到底线
      仍装不下时如实失败**(stderr 说清是哪一维不够 + 可执行的下一步),
      绝不为了贴底压到器件/marker 上。
  尺寸只从内容+字号推导(**不读落点 bbox**),幂等;规划器与 `sch note` 落点共用
  同一个预留函数,所以"planner 算的框"必然包住"note 落的点"。
  **放完说明要重跑 `sch zone-draw --mode partition`** —— 框可能已为说明扩边/下探,
  画布上的框是旧的;`sch note` 扩了边会在 stderr 明确提示。
  配套判据 **`note-outside-zone`**(`sch check`,WARN,`--strict` 阻塞):登记说明
  的 bbox 不在自己分区框内即报。文案分两档、**都可执行**:带装得下 → 直接给算好的
  贴底 `--x/--y` 坐标(**与自动落点求解器逐字相同**,而且保证落在**带内**;照抄
  即可,不会再落回原处);可扩边界内确实装不下 → 明说"别原样重跑",改为缩短文字/
  减小 `--font-size`,或 `sch group-move` 给这个区腾地方。
  > 处方曾经和带的定义分家过:带 `(36,12)..(204,70)`,处方却给 `--y 80`(80 >
  > 带顶 70),自己就把说明放到了带外。现在带的定义(`zoneNoteBand`)、落点求解、
  > note-outside-zone 的处方**是同一个函数链**,配对测试钉住。
- pin 号 ≠ 坐标序:`disconnect --pin X:2` 按**引脚号**解析(LED1 的 pin1 可能在
  右侧)。删桩前先 `autoconnect --dry-run` 核对该 pin 当前网名,防拆错脚。
- **tidy 流水线跑完必须 `sch export-image` 做一次视觉复查**——机械门(gate/
  score)只护「已建模的判据」;生成侧和校验侧共享同一份真值表时,表错则双双
  失明(实测:竖直旗 rotation 真值反了两个月,connect_pin 放倒挂旗、linter 判
  它正确,gate 全绿,用户肉眼才抓出)。视觉清单:① 同排竖放去耦顶线/底线双齐
  ② 旗向直立(3V3 朝上、GND 朝下,倒挂=旋转真值病,别只调单件)③ 行左对齐、
  无孤行大留白 ④ 说明文字不压器件、分区框完整包裹 ⑤ 各区风格统一 ⑥ 相邻旗
  文字不叠(`reversed-net-flag` / marker-overlap 文字带判据已下沉进 check,
  但新形态的拥挤仍先靠眼)。看出新的「不舒服」→ 翻译成几何判据下沉,别停在肉眼。
- **IC 多个相邻电源/地脚的画法**:同侧相邻 GND pin(如模组 pin1/40 相距 10)
  各自引旗必然文字互叠——合流:两桩引到同一竖线相接,再引出挂**一支**旗;
  EPAD 单独向下引旗。同侧密集异网旗(AMS1117 左侧 GND/3V3/+5V 三连)用阶梯
  offset 错列(20/50/80),`sch connect --offset` 显式给。
- **信号链末端的电源/地旗必须竖直**(power 上/gnd 下):横躺(left/right)的
  power/gnd 旗文字竖排侧向渲染(平台特性,极难看)。`sch group tidy` 的
  signal-row 会自动竖直化;手工 `sch connect` 时 power/gnd 一律 --direction
  up/down。**netport 顺着导线方向摆布**(2026-08-12 用户拍板,取代旧「永不
  竖放」铁则):竖放件的 netport 顺竖直引出(up=90/down=270 真值表)、横链
  netport 水平(left=180/right=0);拥挤由 marker-overlap 文字带判据管,
  不再单独报 folded。

## Module-aware autolayout — place parts by module zone

Where `autoconnect` is pin-level, **`sch autolayout` is module-level placement**:
it reads a `--spec` (page, sheet, modules with `zone`/`core`/`parts`, rules),
pulls the real geometry (anchors + bboxes + core pins + sheet bbox), partitions
the usable canvas into named zones (`left-top` / `left-bottom` / `center` /
`right` / `right-top` / `right-bottom` / …), places each module's **core IC near
its zone center**, fans the **peripherals around the core** with collision retry,
and keeps each core pin's **fanout channel** and the **A4 title-block** clear.
Same pure-scorer style as autoconnect: identical spec + input → identical
coordinates that pass `sch layout-lint`.

```bash
# preview proposed coordinates + warnings, mutate nothing (default)
easyeda sch autolayout --spec p1-layout.json --dry-run

# pin one page, move parts, read back complete geometry, then save
# safety gate: zero wires/buses/net markers + proven bbox/pins
easyeda sch autolayout --spec p1-layout.json --doc MCU_USB_STORAGE --apply

# structured report
easyeda sch autolayout --spec p1-layout.json --json

# platform FALLBACK engine (no spec): the official eda.sch_Document.autoLayout()
# @beta — a LONG op (~2min), rearranges the WHOLE active (foreground) page,
# connectivity-clustered/radial → messier than a template. For un-templated
# pages only; refine with `sch align`/`distribute` afterward.
easyeda sch autolayout --engine official --apply
```

Template `--apply` is deliberately **pre-wiring only**. It moves symbols via
`schematic.component.modify`, which does not carry attached wires or flags with
the symbol. The command resolves one immutable target page from `--doc` or
`spec.page` (both must agree when supplied), and verifies every response's
document UUID. Before planning and again immediately before the first move it
requires a fail-closed active-page inventory of **zero wires, buses, netflags,
netports, netlabels, and short-symbol markers**, complete finite anchors/bboxes, and explicitly
successful pin-array reads. The second snapshot must byte-for-geometry match the
planning input, otherwise the stale plan is refused. `--apply --all-pages` is
also refused because the proofs are active-page scoped (`--all-pages` remains
available for dry-run). There is no force override: `--rewire` is official-engine
only. After moving, every requested primitive ID/anchor is read back and
the complete baseline plus sheet/grid/spacing/overlap/pin/title-block rules are
rechecked before `schematic.save`; only explicit `saved:true` is success.
Any failure restores captured anchors in reverse order, reads them back (only
confirmed coordinates count as restored), and saves the rollback.

**Why the official engine is dangerous — three measured traps** (this is why the
wrapper refuses wired pages by default):

1. **It moves symbols but not wires** — running it on a wired page severs every
   connection (measured: 16 parts → 59 floating pins).
2. **It lands anchors off-grid** — downstream stubs then miss the pin.
3. **Its scatter makes short stubs collide into shorts** — `--replace` cannot
   pull them apart afterward.

`--rewire` is the only way to run it on a wired page: it snapshots the netlist
first, then after the layout it snaps anchors to the 5-unit grid, deletes the
severed wires, and reconnects from that snapshot. Self-check with `sch check`
(dangling/floating), not `layout-lint` alone. The op needs the target page in the
**foreground** and takes ~2 min (300 s timeout). **The official API has no
transactional rollback** — if the post-check fails the page is already mutated.

**Engine priority (iron rule):** block hit → `sch block-apply` template; else a
`--spec` → `--engine template` (default); only when neither exists → `--engine
official` fallback. The official engine graduated `@alpha→@beta` on 3.2.148 and
now runs, but it produces the scattered generic-algorithm layout our research
predicted — never prefer it over a template for a known block.

Spec JSON (`--spec`):

```json
{
  "page": "MCU_USB_STORAGE", "sheet": "A4",
  "modules": [
    {"name":"USB_HUB","zone":"left-top","core":"U10","parts":["J2","U10","X1","C30","R15"]},
    {"name":"MCU","zone":"center","core":"U1","parts":["U1","C18","C19","R6"]},
    {"name":"SD_NAND","zone":"right","core":"U8","parts":["U8","C28","R10"]}
  ],
  "rules": {"avoidTitleBlock":true,"preservePinFanout":true,
            "moduleGap":80,"routeChannelGap":40,
            "preferVerticalPeripheralPlacement":true}
}
```

The result reports each `placement` (designator / x / y / rotation / module), any
`warnings` (e.g. a peripheral forced into a fanout lane, or a spec part not yet
placed), and a `validation` summary (`partOverlaps` / `titleBlockHits` /
`fanoutKeepoutHits`). Notes:

- **v1 moves already-placed parts only** — it does NOT create missing parts; a
  spec part absent from the page is warned + skipped. Place the parts first
  (library-first), then `autolayout` arranges them.
- A **missing core** is a hard error for that module (clear diagnostic).
- When the **sheet bbox isn't exposed**, the title-block keep-out is reported as
  **provisional** and not geometrically enforced.
- `autolayout` solves **module placement, not routing** — follow it with
  `sch autoconnect` (power/ground/netport) + wiring, then the full per-page S5 gate
  (`sch gate --strict --doc <page>` + the `sch read` topology comparison).

### Deterministic zone layout plan — `sch zone-arrange` (A4-only)

排布功能区前先跑 `sch zone-arrange`(纯规划零改动,同一输入唯一输出):

```bash
easyeda sch zone-arrange --project <p> --doc <page>          # 人读
easyeda sch zone-arrange --project <p> --doc <page> --json   # 机器
```

两段流水线:**phase A 区内收敛**(跟随规则 R1-R5:卫星无源件竖放平行跟随锚件、
**端子朝向跟随实测引脚的朝外方向**、netport 恒水平、同件两旗**不得共线** + 同件
端子互不重叠是硬不变式)
→ **phase B 区间求解**(边归属 = 声明 > 质心回退 + 回退链;**每条边可开多层
货架**,回退链整轮走完还放不下才整体往里开第二层/第三层;放不下会**回溯**换上
一个区的候选,不是当场判死;5 格律、无随机)→ 复用 zone-plan 的
validatePartitions(同一把尺)→ 三态 verdict:

- `pass`:每区给出目标框 + 区内成员落位;人读输出里 `(第2层货架)` 表示这个区
  没贴到边、退到了本边第二列/第二行(正常,不是告警)。
- `blocked`:报出**是谁**排不下、**每条边各卡在谁身上**(`S(230)被U挡→
  W(266)被图签挡→…`)—— 出路是进一步收敛或 `sch page-new` 拆页。
  **A4-only:永不建议换纸。**
- `blocked` 且 JSON 里 `arrange.exhausted=true`:搜索预算跑满,**没有证明无解**,
  只是这一轮没搜到 —— 出路一样,但别把它当成「几何上不可能」。

> **phase A 不是优化,是 phase B 的前置条件。**排不下的往往是**形状**不是面积:
> 真机 P3_USB_DL 四区收敛后总面积只占可用面积 46% 却曾报 blocked,根因是老求解器
> 「一条边只能开一列 + 贪心不回头」(2026-08-19 已修)。所以看到 blocked 先回头看
> phase A 那一栏的 `框 A×B → C×D`:收敛没收下来,后面怎么排都白搭。

> **端子挂侧按「边界语义」判,不是「离本体中心哪个分量大」(2026-08-20 根因修复)。**
> phase A 判一支标签挂在器件的哪条边,首版用的是 marker 中心相对**本体中心**的
> 主轴(`|dx| ≥ |dy|` 才算左右)。那条判据隐含假设「本体近似方形」,在**高瘦符号**
> 上系统性翻车:ESP32-S3-WROOM-1 本体 71×421、41 脚**全在左右两条长边**,而标签
> 横向只探出百来个单位 —— 贴在上下两端行的标签 `|dy|` 反而更大,被判成 up/down,
> 于是进了「垂直梯次」(一支竖起来的 netport 就占 63 高,两支摞下来 161.5),
> 框当场从 `449×737` 变成 `244×863`,越过可用高 765,phase B 四条边全报「纸面放不下」。
>
> 现在的口径是 **marker 中心从本体 bbox 的哪条边探出去最多**
> (`outLeft = body.MinX − mcx` … 取四者 argmax;中心落在本体之内时退化成
> 「离哪条边最近」,同一个式子无特例)。它天然把本体长宽比算进去了。同一组几何:
> 首版 `230×897`(排不下)→ 边界语义 `325×556.5`(有落点,phase B 六区全落位)。
> **落地/回退侧的方向来自 `tidyStubDirection`(pin → 标记锚的实测位移),两把尺
> 必须给同一个答案** —— `TestZfSideOf_AgreesWithMeasuredStubDirection` 钉住。

> **计划端子逐 pin 折 + 引脚坐标进计划(2026-08-20 根因修复)。**
> 断言③(落地复判)曾在 `MCU_IO` / `USB_DEBUG` **每一页恒红**,报文点名的
> 「计划未覆盖」清单**全部是 GND 侧引脚**(`C4:2 C5:2 C6:2 LED1:2 SW1:2 SW2:2
> U2:1 U2:40 U2:41` / `C7:2 C8:2 D1:3 J1:8…U3:1`),连跑三轮 apply 区框重叠只从
> 29 收到 19、到不了 0。两条根因都在「规划器手里没有引脚坐标」:
>
> 1. **覆盖面**:端子首版逐 **marker** 折,而 L1 组的「专属 marker」规则不把
>    **共树** marker 算给本组 —— 共树 pin 在计划里根本不存在,而 `--apply` 是
>    逐 **pin** 重建的,漏掉的那几只落地走 autoconnect 自由落点,区框凭空胖一档。
> 2. **朝向**:两脚无源件的引脚位置靠**假定**(本体上下缘中线)+ R3(GND 派到
>    下端)。真机 `C4`/`C6` 是 rot 90 的电容,`+3V3` 脚在本体**下方**、`GND` 脚在
>    **上方**,正好反过来 —— 计划把 GND 端子映到物理上在上面的脚却给 `direction=down`,
>    把 +3V3 映到下面的脚却给 `direction=up`,两根桩线双双钻进本体、共线合并,
>    **GND 整张网并进 +3V3**(日志逐条 `[replaced net "+3V3"]`)。对账当场红 →
>    恢复段把全页地脚自由重连 → 报文那句「计划未覆盖」其实是这条路径的**产物**。
>
> 现在:端子一律**逐 pin** 从活体折出(与 `--apply` 的重建规格 `groupRebuildConnSpecs`
> 同源,计划集合 ⊇ 落地集合),并把引脚坐标折成本体局部坐标带进计划;桩线从**引脚
> 真实所在的地方**出,方向 = 引脚的朝外方向(与挂侧判定同一个函数)。
> 「电源上 / GND 下」回到它本来的身份 —— **推论**(竖放 + 旗顺引脚朝外 + rail 归位),
> 只在**要转竖的件**上仍然可执行(执行侧从 ±90° 两个候选里挑兑现它的那个);
> 已经竖着、不转的件按事实出桩。**硬不变式是「同件两旗异向」**,规划器单独校验
> (`zfCheckPassiveOpposed`,两支 netport 同朝右是 R4 的正常形态,不在此列)。

> **「同件两旗异向」判的是共线,不是同向(2026-08-20 回归修复)。**
> 首版拿「方向相等」当违规,`sch zone-arrange --apply` 因此在页 `POWER` 当场拒绝
> 执行:`J2`(`conn.screw_terminal_2p` / KF301-5.0-2P)两只脚**都在本体左缘外侧**
> (同为 x=50,y 分别 685 / 675),物理上只能都朝 `left` 出桩 —— 「异向」在这个符号上
> 做不到。而两根朝左的桩线一根躺在 y=685、一根躺在 y=675,**平行不共线,永远不会
> 合并**(该端子此前真机 `sch bridge-check` 0 real short、`sch nets` 里 `+5V` 与 `GND`
> 各自独立)。
>
> 真正的短路条件是**两根桩线共线** —— 共线 → 相接 → 平台把导线自动合并成一根 →
> 两张网并成一张。桩只能从 pin 沿 direction 直出、垂直于桩的那个坐标原样留在 pin 上,
> 所以判据是「同向 **且** 同轴」:
>
> | 出桩方向 | 桩线所在的轴 | 共线条件 |
> |---|---|---|
> | `left` / `right` | `y = PinY` | 两脚 **y** 相同 |
> | `up` / `down` | `x = PinX` | 两脚 **x** 相同 |
>
> 同轴容差直接用 `schMarkerOverlapEps=1`(仓库既有的几何噪声地板),**不用 5 网格**:
> 吸附只作用在沿桩方向那个坐标上,桩线所在的轴就是引脚坐标本身。引脚最小节距 10,
> 1 个单位既吃得下浮点噪声又留了 10 倍余量。
>
> 顺带修掉它背后的第二层:两脚同侧时两支标签必然压在一起(节距 10 < 网名带高 12),
> 会被同件端子重叠那条硬不变式判死。**同侧让位**因此从多脚件推广到两脚件 ——
> 两条路径共用同一个函数(`zfPlaceMeasuredTerms`),参与规则一字未改。
>
> **给 agent 的判读法**:看到 `两支旗同向且桩线共线` 别急着换符号,先照报文用
> `sch list --include-pins` 核对两只脚的实测坐标 —— 只有真同轴(符号引脚重合、
> 或同一只脚被折成了两支端子)才需要动;两脚同边但坐标错开是**合法形态**。
>
> 同侧多支标签的**让位**也从「无条件梯次」改成「按需」:撞不撞用的是 `sch check`
> 的 marker-overlap 那把尺(`schMarkerOverlapEps=1`,引脚节距 10 而标签高 11 时
> 必然擦过的那 1 个单位不算撞)。**参与规则沿用首版**:左右侧只有旗让位、且只跟
> 同侧的旗比(port 恒水平、保持短桩);上下侧所有 kind 都参与。
>
> 真机验收(ceshi,两页各一轮 apply):对账**首轮即绿**(无恢复段)、
> `FreeConnected` 为空(报文里那句「计划未覆盖」不再出现)、
> **断言③ 绿** —— 逐区实测框比规划框各小 10(= 落地余量 2×5),区框零重叠;
> 再跑一轮 9/10 件 no-op(收敛)。

> **域感知选形:phase A 选形状时要看空地长什么样(2026-08-20 真机取证)。**
> 收敛不是「把区做方」,是「把区做成这一页塞得进去的样子」。首版两条支路都域盲:
> 「无主导锚件」那条**连候选都没有**(全员单列是硬编码的),锚件那条只会
> `argmin max(w,h)`(求方)。真机 `MCU_IO`(可用域 `1110×765`,图签把它切成
> **左通道 396×765** + **上通道 1110×555**)因此排不下:
>
> - `wroom-passives` 5 个 0402/0805 小无源件被排成 `152×696` 的柱子 —— 高 696 > 555,
>   **只**进得了左通道;
> - `wroom-core` 单件 WROOM 模组 `325×556.5` —— 高 556.5 > 555,**也**只进得了左通道;
> - 两个区抢同一条 396 宽的道:并排 `325+152+12 > 396`、上下叠 `556+696+12 > 765`
>   → phase B `blocked`。而那 5 个小件排成 3+2 的货架只有 `261×352`,上通道轻松吃下。
>
> **注意两个区各自的 `fitRank` 都是 2**(都有落点)—— 「不得变差」门的掉档判据
> 结构上看不见这种病:病在**落点自由度**,不在「有没有落点」。所以选形用两把钥匙
> (都是同一份通道算术的投影,与 phase B 同源):
>
> 1. `fitRank` 三档可排布性;
> 2. `stripFits` = **本页有几条通道装得下这个框**。
>
> 规则:候选里存在装得进通道的形状时**绝不选装不进的**;两把钥匙平局时回到
> **原有紧凑性偏好**(首版会选中的那个形态排最前)—— 所以「本来就排得下、也已经
> 很紧凑」的区一个单位都不会动,不存在「永远选最扁」的退化。`zones[].mode` 尾巴
> 必带这句决策:`域感知选形(5 候选):改选本形态 — 档 2→2、通道 1→2(原偏好
> 「无主导锚件 → 全员单列(位号序)」152×696)`;一个候选都装不进通道时它会直说
> `没有一个装得进任何通道 …… phase B 必然 blocked:拆区或 sch page-new 拆页` ——
> **`blocked` 也是看过域之后的结论**,照着这句去拆,别去调纸张/带高。

> **「不得变差」门:收敛使本区更难排时保留原形(2026-08-20 真机两轮取证)。**
> phase A 对**大符号单件组 / 标签真的挂在上下两侧的宽体连接器**是负优化 ——
> 真机 `MCU_IO` 的 `esp32s3_wroom1_module` 第一轮 `433×541 → 244×767`(宽收 189、
> **高涨 226**,可用高只有 765)。现在 phase A 逐区加一道门:
>
> - 判据是**可排布性**,不是面积/周长 —— 上例宽度和面积都变小了,照样是负优化;
> - 可排布性是**三档阶梯**(`fitRank`),不是一个布尔:
>   `2` 本页有落点(图签也让开了)/ `1` 装得进可用域但被图签挡住(重排、拆页还有救)/
>   `0` 连可用域都装不下(结构上没救)。三档与 phase B 的逐边归因同源:
>   `2` ⟺ 单独放在空页上一定放得下,`0` ⟹ 四条边全报「纸面放不下」,
>   `1` ⟹ 报「被图签挡」。
> - **收敛后掉档 → 保留原形**(不重排、不重生桩,刚体平移到落位框);同档或升档
>   一律放行;两维都没变大时结构上不会掉档。
>   > 首版判据只有 `fits` 一个布尔,于是第二轮真机
>   > `449×737`(1 档:高 737 ≤ 765,只是 449 > 图签左侧通道 396)
>   > `→ 244×863`(0 档:高 863 > 可用高 765)**从门里漏了出去** ——
>   > 它走的是「原形本就排不下 → 收敛是唯一出路」那条放行分支,`retained=false`,
>   > phase B 拿着一个更没救的框去撞墙。别再把这两个「排不下」当成一回事。
> - **逐区独立**:一个区回退不影响其它区照常收敛;
> - **绝不静默**:人读输出该区行首是 `↩`,`zones[].mode` 尾巴与 JSON 的
>   `zones[].retained` / `zones[].retainWhy` 都带这句 ——
>   `收敛回退:高 737→863 后从「装得进可用域但被图签挡住」掉到「连可用域都装不下」(可用 1110×765;图签上方高 555、左侧宽 396)—— 保留原形 449×737`。
>
> 看到 `↩` 时**不要**去调 A4 尺寸/带高绕开它(那会毁掉「框 = L1 全图元并集 + 带」
> 这条不变式);正确的下一步是把这一区拆小(`sch zones set` 重新分组)或
> `sch page-new` 拆页。**两个形状都是 0 档时门不拦**(拦了只是把小框换成大框),
> 这时 phase B 照常 blocked,归因是「纸面放不下」—— 那是真的要拆。

区框口径 = 成员 L1 虚拟组**全图元并集**(标签必在框内)。导线读不到会直接报错
(端子归属靠导线,距离启发式必错)。

> **外框只有一个函数(2026-08-20 用户裁定)**:`frame = f(成员 L1 虚拟组全图元并集,
> 区名带, 说明带)`。`zone-plan` 的框、`zone-arrange` phase A 的现状框与收敛后框
> 走的是**同一个函数本体**。带高由**已登记说明的内容 + 字号**推导(不是常量、更
> 不读 note 的落点坐标)—— 所以 **phase A 收紧时 title/note 就已经在账里**,不再是
> 「按常量带收紧 → 画框 → 再放 note 装不下 → 说明探出框外」。改任一侧,
> `TestRuler_ZoneFrameSingleFunction` 会红。

> **分区归属也只有一个答案(2026-08-20 定案):一个虚拟组 / zone 认领 = 一个分区。**
> `zone-arrange` 一直是这么算的(phase B 每个区一个落位框,断言③ 逐区量实测框、
> 逐对判零重叠);`zone-plan` 此前却先把整页按模块间的自然空隙切成**列带/行带**,
> 再把落在同一格的区**并成一个分区** —— 两把尺。真机 ceshi / MCU_IO:
> `zone-arrange --apply` 断言③ 全绿(区框零重叠),紧接着 `zone-plan` 却把
> `led_indicator_gpio` 与 `tactile_boot_reset`(左列上下叠)并成一框,并集宽到
> x=274,与 229 起的 `wroom-passives` 撞出 45×362 → `partitionOverlap=1` →
> **zone-draw 拒绝画框**,而画分区框是铁律 15,交付被自己卡死。
> 网格带是首版遗留(那时框会被 clamp 到格子里),现已删除。两条推论:
>
> - **`zone-arrange` 断言③ 绿的页面,`zone-draw` 一定画得出来** —— 两边算的是同一批框。
> - **`partitionOverlap` 非 0 现在只有一个含义**:两个区的 L1 体积**真的**互相压。
>   出路是 `sch zone-arrange --apply` 重排或 `sch group-move` 挪件,**不是**调
>   `--gutter` / 也没有「合并成一个大框」这条退路了(合并只是把重叠藏起来)。
>
> 配对由 `TestRuler_ZonePartitionGroupingMatchesArrange` + 真机 fixture 的
> `cmd_sch_zone_partition_test.go` 钉住(含首版归组的常驻变异对照)。

> **桩线伸展只有一把尺(2026-08-20 定案)。** 之前同一件事有三套算法:phase A
> 自己拼端子盒、`--apply` 未被计划覆盖的 pin 走 autoconnect 自由评分、`group-move`
> 的重连也走自由评分。后果是**规划 pass → 落地 overlap 永不收敛**(真机连跑 4 轮,
> 每轮 dry-run 都 `verdict: pass`、validation 四项全 0,落地实测重叠 2/1/2 处;
> 规划 315×351 → 落地 353×382,而 gutter 只有 12)。现在三处共用落地那条真实链
> `connect_pin(direction, offset) → endpointFor(5 网格吸附) → predictedMarkerBBox
> (本体 ∪ 网名带)`,规划框里还含一格落地余量(桩端点的 5 网格吸附)。
>
> **但「规划框 = 落地框的上界」只在模型内成立,别当成真机保证**(2026-08-20 订正):
> 它成立的前提是「同一份 pin 坐标 + 同一份桩长」。真机上三处会打破它 ——
> ① 规划把无源件的 pin 假定在本体 bbox 上下缘中线(真符号未必);
> ② `markerBBoxProfile` 是 2026-06 的实测标定,不是平台契约;
> ③ 计划没覆盖到的 pin 会走 autoconnect **自由方向**落点。
> 真机 MCU_IO 六区实测偏差 `+141 / +126 / +82 / +56 / +26 / +10`,五个区超 gutter。
> 所以断言③ **不是「上界成立」的断言,而是「上界不成立时如实报出来」的机制**;
> 复判只判「落地比规划**胖**」这一边。三条推论:
>
> - **不要靠「多跑几遍」收敛** —— 已实测 4 轮不收敛(第 3 轮落位整体重排,J_USB
>   从 E 边跳到 N 边),那是追尾不是收敛。看 `断言③` 的复判表定位。
> - **`sch group-move` 是刚体平移,不再撑胖区框**:重连按移动前实测的桩方向/长度
>   原样重建(此前一次 `--dx 40` 把 U 组从 315×389 撑到 523×406,重叠从 1 处变 3 处)。
> - **`sch autoconnect` 仍是自由评分**(它的职责就是挑落点),所以在已收敛的区里
>   对单脚补连可能拉出更长的桩 —— 补完看一眼 `sch zone-plan` 的 `partitionOverlap`。

`--apply` 落地执行(断言① 删除集=重建集 → 页级深度清扫 → 逐件落位重连 →
断言② 曾连接 pin 仍连接 → 对账修复循环 → **假失败清创**(自动删同位重复/
同树冗余标记,复用 check 的 suggestDeleteIds 判据)→ bridge-check 红才整体
回滚 → save → **断言③ 落地复判**)。落地执行统一走 ADR-0004 move 内核(失败自动
恢复到快照重连,结构化 `moveReport`,判据是电气对账)。同侧多旗按**垂直梯次**
桩长错开(规划的 offset 直达 connect_pin;pin 再密也不竖叠);计划没覆盖到的 pin
走内核的 **preserve 桩线策略**(原样复现移动前的桩),兜底 autoconnect 带**桩长
硬上限** = 计划里最长的桩。

**断言①的几何形式(retain 刚体不变式,2026-08-20)**:phase A 行首打 `↩ 原形保留`
的区,`--apply` 在**执行前**逐 pin 比对执行指令与移动前快照的 `(方向, 桩长, 类型)`
—— 不一致就**拒绝整页、画布零改动**。「不动的东西真的没动」不依赖任何预测模型,
是本命令最强的可验证不变式;此前它只是一句输出文案:真机 U2 标着「不重排、不重生桩」,
落地后 L1 组却从 391×421 变成 391×562(宽度分毫不差、高度凭空 +141)。报错点名到
pin 与偏差量(`pin4 方向 right→up、桩长 84→20`),那是计划/映射缺陷不是画布问题。

**断言③(落地复判)**:save 之后重读一次真几何,按同一个外框函数算每区**实测框**,
与规划框逐区比。输出形如 `复判 U:实测框 353×382 / 规划框 315×351`。四类红:
① 偏差 > `--gutter`;② 实测区框互相重叠;③ **成员探出图纸可用区**(与
`sch clusters` 的 out-of-sheet 同一个常量,不必再等下一条命令来发现);
④ retain 区落地几何与「原形平移」不符(行尾打 `↩✗ 原形被改动`)。
另有一条独立条目:**走了自由落点的 pin**(计划没覆盖、内核也复现不出原桩,只能
让 autoconnect 挑方向和桩长)—— 它是「规划 pass → 落地胖一档」的唯一结构性来源,
逐条点名。任一条红 → **如实报并以非零退出**(电气与位姿仍已落地保存,不回滚)。
断言①②看电气、内核对账看网表、layout-lint 看器件两两重叠 —— 没有一条看得见
「区框胖了撞邻区」,断言③补的就是这条。**成员读不到时也判红**(unknown 不算过,
不许让一次读故障伪装成完美收敛)。
**真机注意**:连接器在持续变更负载下会停摆,停摆期「报失败的写可能已落地」
(假失败)——apply 已内置重试+对账+清创,但若结束仍报缺口,先用
`sch autoconnect --pin 位号:脚 --kind … --net …` 逐脚补(它幂等,already-connected
会跳过,不会造重复标记),再跑 `sch bridge-check` + `sch nets` 三验。
**不要陷入逐器件手工修补**:apply 报出的问题优先重跑一轮
`zone-arrange --apply`(两遍法,落地实测反哺规划),手写 exec 挪件是最后手段
(且必须 5 的倍数坐标 —— 件是格点公民,脱格 connect_pin 全灭)。

### Functional frames + text labels (multi-page safe)

`easyeda sch zones set --spec <spec.json>` persists `modules[].zone/parts/page`
by resolved schematic **document UUID**. Then draw one page at a time:

```bash
easyeda sch zone-plan --json --doc P1_MCU
easyeda sch zone-draw --mode partition --font-size 22 --doc P1_MCU
easyeda sch zone-plan --json --doc P2_POWER
easyeda sch zone-draw --mode partition --font-size 22 --doc P2_POWER
easyeda sch zone-plan --json --doc P3_PERIPHERAL
easyeda sch zone-draw --mode partition --font-size 22 --doc P3_PERIPHERAL
```

Rectangles are anchored at **`(MinX, MaxY)`** on the y-UP canvas and extend
downward by their height — treating `MinY` as the top-left y shifts the whole
frame down by one height and pushes it past the sheet/title-block edge.

Before drawing a partition, require all five `zone-plan` validation counters
(`sheetOverflow`, `partitionOverlap`, `titleBlockHits`, `moduleOutsideZone`,
`labelCollisions`) to be zero.

> **`moduleOutsideZone` 判的是 L1 虚拟组,而且判定侧独立重算(2026-08-20)。**
> 此前它复用生成侧那份模块 bbox —— 而那份 bbox 已被上游削过,于是「生成漏掉的
> 标签,判定也看不见」,判据结构上恒报 0(真机 POWER 页:8 个 L1 组里 5 个探出
> 框外,六项全绿)。现在判定侧从活体的 `sch clusters` 口径重算每个位号的 L1 组
> 体积再与框做包含判定,并逐条给出**是谁、超了多少、往哪超**。
>
> **降级恒定可见 + fail-closed**:JSON 里 `labelScopeDegraded` 与 `labelScope`
> **永远出现**(不再被 omitempty 抹掉)。归属做不成时(读不到导线 / 某件没有
> 引脚几何 / 某件没有 L1 组记录)`labelScope.degraded=true` 并点名位号,
> `moduleOutsideZone` 按「不可信」计数 —— **验不了就不许报绿**。看到降级先跑
> `easyeda sch clusters --members` 核对,别去调 `--gutter`。

Frames are **always data-driven**: whole-sheet partitions derived from live module
bboxes, 22pt titles by default. The old fixed nine-grid mode (`--mode zones`) is
**retired** — its rectangles had nothing to do with where the parts actually are,
so on a single-module page spanning the sheet the frame missed the circuit entirely.
With frames derived from the parts, `layout-lint`'s old `zone-violation` rule became
a tautology (the frame is drawn *around* those parts), so it is retired too; what
judges a partition now is `sch zone-plan`'s six pre-draw validations. Both modes share one page-scoped frame record, so changing mode replaces
that page's prior annotations without touching another page. Redraw/clear is
fail-closed: exact rectangle/text IDs are re-read after delete, survivors retain
their recovery record, draw counts must match 1:1, and partial creation is
compensated. Every successful draw or clear explicitly requires
`schematic.save` → `saved:true`.

**连接器负载退化下的韧性(2026-08 round2 新 3 修复)**:`zone-draw` 的创建路径
现在是**逐区推进**的:每个区的框线+区名合并成**单次 exec_js**(要么全成要么
全败,失败时 JS 内自清理),单区失败**不回滚**已画成的区。行为要点:

- **幂等重跑**:画前先轻读 survey 画布,已画好且与当前 plan 完全吻合的框
  (标题内容+锚点+配对矩形都在)直接保留 —— 重跑只补缺的区,一页已达标的
  框重跑是**零写操作**;plan 变了(模块挪过)才清旧重画。旧的「先清光旧框、
  再一次画全部」没有了,也就不再有「清旧成功+画新失败=页面从有框变无框」
  的净损失窗口。
- **假失败定律内建**:写报失败后先轻读复核 —— 复核出「其实已落地」就直接
  收编 id、绝不重发;确认没落地才 settle(~400ms)后重发一次;**复核不出来
  (读也失败/落地状态歧义)一律不重发**,可证的半成品 id 记入本页 frame
  record,`--clear` 或下次重画会回收。
- **partial 语义(#151)**:部分区画成时**exit 0**,stdout 报
  `partial: N/M zone(s) not applied` + 每区原因;全部区都失败且画布零变化才
  非零退出。看到 partial 就**重跑同一条命令**补缺,不要手工 exec_js 补框。
- daemon 侧配套:`easyeda health` 的 `writeHealth` 按窗口报最近 20 次转发动作的
  **效果失败率**(不是返回码失败率,见下条)+ 连败数 + 逐 action 分桶,
  `degraded:true` = 连接器在负载下劣化;此时**写**失败(以及「返回 ok 但被证明
  没落地」)的响应会带结构化 `result.degraded` + 「先轻读复核再考虑重试」的告诫。
  daemon 只对幂等导航动作(`document.open`/`schematic.page.open`)自动
  「轻读探测→settle→重发一次」,内容写永不 daemon 级重发。
- **writeHealth 读的是「写的效果」,不是「调用的返回码」**(2026-08-19 口径修订)。
  真机跑完一整场端到端时它曾全程 `failureRate 0.05 / degraded:false`,而同期画布
  上大面积的写根本没生效 —— 因为主要故障形态是**返回成功但画布没变**。现在:
  - 返回成功 + 回读证实没生效 → 计 failure,并记进 `fakeSuccesses`;
  - 返回失败 + 回读证实已落地 → **不**计 failure,单独记 `fakeFailures`
    (同样是不健康信号,但处置相反:假成功要补写,假失败绝不能重发);
  - `verified` = 有回读证据的样本数。**`verified` 很低而 `failureRate` 很绿,
    只能读成「没人核对过」,不是「全都好」**;
  - `actions{}` / `degradedActions[]` 是逐 action 分桶 —— 混合流量里
    「connect_pin 这一批 40% 失败」不会再被 20 样本的均值稀释成 5%,
    哪条路没在工作会被点名。
  证据两个来源:连接器在 result 里自带的回读结论(`partial` / `survivedTotal` /
  `notApplied`)由 daemon 直接内省;命令自己做的回读(block-apply 落地回读、
  `sch connect` 的 slow-landed 复核、zone-draw 的 landed-check)走
  `POST /writeverify` 回传。**新写带回读的命令时,把结论也回传一次**
  (`reportWriteVerified`),否则健康度看不见这条路的真实成色。

## Zone-less packing — `sch autoplace-free`

Where `autolayout` needs you to name zones, **`sch autoplace-free` finds the sheet's
blank space for you** and drops movable parts in, collision-free — the "把这些件塞进
纸面空白" case. Parts only (never wires/flags — that's `sch group-move`), so it's pure
CLI-side (reuses `components.list --include-bbox` + `component.modify`, no connector
handler). Deterministic top-left first-fit, anchors snapped to the 5-grid.

```bash
easyeda sch autoplace-free --dry-run                 # auto-pick messy parts, preview
easyeda sch autoplace-free --designators C1,C2,R4 --apply
easyeda sch autoplace-free --all --apply             # repack the whole page (tidy mode)
```

Move-set: **default** auto-selects parts currently OUTSIDE the usable area or
OVERLAPPING another (clean in-bounds parts stay put); `--designators A,B` targets
explicit parts; `--all` repacks everything. Fixed (non-moved) parts + the
title-block keep-out are obstacles it dodges. `--margin` (sheet-edge inset),
`--gap` (min edge-to-edge), `--grid-step`, `--no-avoid-titleblock`. `--apply`
moves via `component.modify` then self-checks with layout-lint. A big part on an
already-full page honestly reports **"no free slot"** rather than overlapping — use
`--all` so it gets first pick, or free up room. Verified live: 3 stacked parts →
`--apply` → 0 overlap.

## Actions

Run `easyeda actions` for the current machine-readable action list.

### 导航 / Navigation

**自助「发现 + 切换」闭环（首选）** — 不要让用户手动开窗口/切页,Agent 自己发现并切换:

```bash
easyeda daemon health                         # 发现:有哪些已连接窗口 + 各自实时上下文
easyeda doc ls     --project <名字>            # 发现:列出该窗口所有可开文档(原理图页+PCB),★=当前前台
easyeda doc switch <P2|PCB1|uuid> --project <名字>   # 切换:按页名/PCB名/uuid 切到前台,自动回读确认
```

- `easyeda doc ls` 聚合了 `schematic.pages.list` + `pcb.documents.list` + `document.current`,一条命令看全貌;`--json` 给机器读。
- `easyeda doc switch` 按名字解析 → `document.open` → `document.current` 回读确认。**同名页(多个 P1)会报歧义并列出 uuid,改传 uuid**。跨类型也行(PCB ↔ 原理图)。
- **多窗口时必须 `--project`(或 `--window`)**:`doc ls`/`doc switch` 不带目标时,只有「恰好一个窗口」才能自动命中;两个及以上窗口会报 `no EasyEDA connector is available`。同理,某窗口连接器正在重连(churn)的瞬间也可能瞬时报这个,重试即可。
- **`doc reload` 后必须先 `doc switch <uuid>` 重钉 context 再写(2026-08-19 真机实锤)**:reload(saved→closed→reopened)后 exec_js 的 JS context 仍挂在**已关闭的旧 tab**上,紧接的写(`prim-delete`/create/modify)会打进旧文档——**静默 no-op 但回执照样 ok**(同 4 个 id 逐个删、回执全 ok、复检原样;插一条 `doc switch` 后同样命令立即生效)。这是「exec_js context 不跟切页走」的 reload 变体,`--doc` guard 拦不住(guard 核对的是 daemon 视角的 document.current,名义上已是新 tab)。同病还有**读侧 stale**:mutation 后 `bridge-check`/`clusters`/`check` 可能读到旧几何——orphan-tree 判据在 mutation 后不 reload 就跑,会把刚建的合法桩线误判成孤儿树**引导误删**(真机踩过:删掉了 C7:1 刚补的连接)。口诀:**写完要判,先 reload;reload 完要写,先 switch**。

底层 action(需要细控时再用):

- `project.current` — 当前工程信息（uuid / name / teamUuid）
- `document.current` — 当前激活文档信息（uuid / tabId / documentType）—— **实时读取**,不是连接快照
- `document.open` — 按 UUID 打开任意文档（原理图页或 PCB），通用版切换入口
- `schematic.pages.list` — 列出工程内所有原理图及页面
- `schematic.page.open` — 按 UUID 切换到指定原理图页（等同于 `document.open`，保留兼容）

多窗口说明：EasyEDA 每个窗口对应一个独立的 connector（windowId）。`easyeda daemon health` 列出所有已连接窗口;**优先用 `--project <名字>` 路由**(windowId 重连会变),细控时才用 `--window <windowId>`。

> **上下文是实时的,不会卡在 `home`。** 两条刷新路径:① daemon 用每次 action 响应里的实时上下文刷新缓存;② 连接器 **v0.5.7 起,心跳(~3s)会主动重读当前文档,变了就推**——所以用户在 EasyEDA 里**切了 tab、什么命令都没跑**,`daemon health` 也会在 ~3s 内自己跟上。若 health 显示某窗口是 `home`,说明它的前台 tab 停在开始页/欢迎页,或那个窗口跑的是旧连接器(< v0.5.7)没连上。
>
> **UI 切页要双击**:单击只选中 tab、不打开文档;双击才真正打开,`document.current` 读到的是「已打开」的那个文档。
>
> **`connectorVersionOk: false`** = 该窗口加载的连接器版本与 daemon 不符(典型:开着的窗口跑着旧连接器代码,或连接器版本落后 CLI)。处理:**侧载**的 `.eext` 需重导与 CLI 同版的 GitHub Release 包;从[立创插件市场](https://jlc-ext.com/item/zhoushoujian/easyeda-agent-connector)装的可能已原地自动更新(但市场版可能**滞后** CLI,严格同版仍取 Release `.eext`)。无论哪种,都要**完全退出并重启 EasyEDA** 才能把新连接器加载进已开窗口(re-import / 原地更新都不刷新已开窗口)。`null` 表示版本号非 semver(dev 构建)无法判定。

### 原理图编辑

- `schematic.components.list` — `--include-bbox` 附带每个元件渲染范围 `{minX,minY,maxX,maxY}`(供布局推理);`--include-pins` 附带每脚 `{pinName,pinNumber,x,y,noConnected}`，并明确返回 `pinsAvailable:true`；SDK 读脚失败会返回 `pinsAvailable:false + pinsError`，不再伪装成 `pins:[]`。内部布局写门还会请求 `includeConnectivitySummary`，得到 active-page 的 wires/buses/netflags/netports/netlabels/shortSymbols fail-closed 计数。两个常规 flag 可与 `--all-pages` 叠加(输出会显著变大)。
- **`easyeda sch layout-lint`** — **布局自检**(治覆盖的机械真值)。拉 `components.list --include-bbox --include-pins`,Go 侧两两几何检查:**bbox 重叠 / 异件引脚重合 = ERROR**、**间距 < `--min-gap`(默认 2.54mm) / 锚点 off-grid / out-of-sheet = WARN**。生产过门使用 `--strict`，会把这些 WARN、缺失或畸形 bbox/anchor/pin 几何、旧 connector 无法证明 pin 读取成功的状态，以及“无可读 sheet、无法执行 out-of-sheet 判定”的状态一并升级为非零退出，避免 `0 overlap` 冒充布局已完成。
**注意 `layout-lint` 只判器件本体** —— 标签之间、标签压器件、标签探出图纸它结构上看不见,
那一半在 `sch clusters`(已进 `sch gate` 第 2 关)。zone-violation 判据已废弃:分区框从活体
模块 bbox 反推后,「件在不在自己的框里」是同义反复。**`out-of-sheet`(#180)**:器件 bbox 越出图纸边框内缩 12 单位后的可用区。此前**没有任何判据抓这个** —— 出图纸的件照样连线、netlist 照样对得上,只是印不出来(实测 block-apply 曾把件放到 x=-20 / y=880 而图纸 0..825,当时 lint 全绿)。判的是 **bbox 不是锚点**(锚点在框内、body 探出框外一样印不出来)。`sheetCheckStatus` 与 zone 同态诚实披露:读不到图纸 bbox 或 `--all-pages` 下都报 `unavailable` 并附原因,**`--strict` 下 unavailable 本身即阻塞** —— 「没检查」绝不许显得像「查了干净」。`--strict` 必须逐页运行，拒绝与 `--all-pages` 或 `--include-non-parts` 组合：非激活页是浅数据，sheet/marker 也不是器件放置体。`--min-gap` / `--pin-eps` 是毫米参数，CLI 会换算到原理图原生 `0.01inch` 坐标（`2.54mm = 10 raw units`）；JSON schema v2 的距离值带 `measurementUnit: "mm"`，点坐标和 `anchorGridRaw` 使用 `coordinateUnit/anchorGridUnit: "0.01inch"`。注意 v1 曾误把 raw 数值标作 mm，依赖旧实际数值的脚本应按 `schemaVersion` 迁移。诊断模式仍支持 `--all-pages`、`--json`。**默认只检真实器件(`componentType == "part"`)**:图框/标题栏(sheet)铺满整页、netflag/netport/netlabel 等非器件原语都会被自动排除,否则它们会与几乎每个器件误报重叠(见 issue #13)。需要诊断这些 bbox/spacing 时可在非 strict 模式加 `--include-non-parts`;off-grid 与 pin 完整性仍只验真实器件。摆放后跑它判覆盖/间距,比肉眼/截图可靠(截图可能 stale)。是 place→verify→adjust 闭环的输入。
- **`easyeda sch sheet-geometry`** — **图纸边界 + 标题栏 keep-out**(放置/布线规划器的统一几何源,issue #26)。读 `components.list --include-bbox` 里 `componentType == "sheet"` 的实测 bbox,按**长宽比**匹配已知模板(A 系列横/纵向 ≈ √2),在**右下角**按归一化比例切出标题栏(图框/明细表)子矩形;`schematic.titleblock.get` 的 `showTitleBlock` 隐藏时不输出 keep-out。返回 `{sheet, titleBlock, keepouts[], warnings[]}`,每项带 **provenance**(`known-template-ratio` / `fallback-ratio` / `none`),无法确定时只给 warning、不输出虚假精度。`--json`。规划器消费 `keepouts[]`(`{name,bbox,hard}`)即可,**不要再各处硬编码 A4 坐标**。比例表见 `references/sheet-templates.json`。
- `schematic.component.place`
- `schematic.component.modify` — 位置/位号/BOM 标志/自定义属性。`customAttributes` 是 SDK `otherProperty` 的兼容别名(二选一,不能同传);属性补丁与现有值**合并**后写入(不清空其他元数据),未知顶层键**前置拒绝**(SDK 会静默丢弃)。写后用新句柄回读,**分级语义(#151)**:全部生效=ok;**部分生效=ok + `result.{partial,applied,alreadySet,notApplied,addedKeys,propertiesBefore}` + warnings**(已应用子集留画布并照常 autosave;`sch modify` 子命令与 playbook 重放此时**按失败处理**,但裸 `easyeda call` 只有 stderr 警告、退出码仍 0,需自查 `result.partial`)。恢复注意:**重放 `propertiesBefore` 只能恢复被覆盖键的原值,本次新增键(`addedKeys`)merge 语义下删不掉**,要删须编辑器手工操作;`applied` 只计回读可证明的写入,期望值与原值本就相同的键归 `alreadySet`(写入不可证)。纯属性补丁无一可证明写入=报错(画布未变)。回读通道本身失败会降级 `verified:false` + warning 而非报错(报错会让 daemon 跳过 autosave,丢已落画布的变更)。**merge 语义对只含顶层字段的补丁同样成立(#175)**:平台 modify 对 `otherProperty` 是**整体重写**语义,曾导致 `{"supplierId":...}` 这类顶层补丁把全部自定义属性静默清空;连接器现在会回读现有自定义属性并在同一次 modify 里原样写回 —— 全保住时 `result.propertiesPreserved` 列出被连带重写但保留的键(+`propertiesBefore` 快照),平台仍丢的键进 `result.notApplied`(`partial:true`,CLI 非零退出),没有静默面。
- `schematic.component.delete` — **级联清理独占桩线/flag(ADR-0004 Decision 5)**:只挂在被删件引脚上的桩线树连同其上的旗随件删净,**共享树只断不删**(树还触别的器件就留下),结果带 `cascaded` 字段(回读证实的 wire/flag id 明细);payload `cascade:false` 退回旧行为。不再留「删器件不清理桩线」的幽灵连接。⚠️ 级联只针对被删件的附着物——与该件无关的导线/总线/图形不动,要真正清页仍用 `schematic.page.clear`。
- `schematic.page.clear` — **一键清空当前页**:删除所有页级 primitive(组件、网络标志/端口/标签、导线、总线、图形),默认保留图框 sheet(`--no-preserve-sheet` 连图框一起删)。`--dry-run` 只统计不删。返回各类型删除计数 `{deleted:{...}, total, deletedIds}`。**无 undo**,确认门控。生成→检测→清页→重试闭环用这个。生产流程必须先 dry-run、报告、等用户确认;清完再读回确认 sheet 仍在。CLI:`easyeda sch clear [--dry-run] [--no-preserve-sheet]`。
- `schematic.primitives.delete` — 按 id **跨类型**删除(组件/标志/导线/总线/图形都行),省略 `--ids` 则删当前选区(配合 `schematic.select` 做"全选→删除")。无 undo,确认门控。CLI:`easyeda sch prim-delete [--ids id1,id2]`(CSV,重复 id 自动去重——平台对含重复 id 的批次整批静默拒)。**图框守卫(2026-08-17 误删实锤)**:sheet 图元在 `sch list` 里是「无位号 @(0,0)」——与 PARTIAL 残件同脸,曾被残件清理误删,而平台没有重建图框的 API(丢了只能人工 UI 重放)。`prim-delete` 发送前自动比对活画布,命中 sheet 即拒;确认要删图框加 `--allow-sheet`。**清理残件前先看 componentType,别只看「无位号 + 原点坐标」**。**计数是回读验证出来的,不是请求数(#164)**:删完重新枚举各类目,`deleted`/`total` 只计真正消失的;有图元活下来则 `result.partial:true` + `survived`(按类目列 id),CLI **非零退出**。此前它把请求数当删除数上报,于是「删旧+重画」的 zone-draw 标签每轮都报干净、实则只加不减(P5 累积到 56 个)。**批量删不可靠已在工具内兜底(缺陷 3 已修)**:平台对大批量 delete 会静默 no-op 仍返 true(真机:zone-draw 批删旧框 survived=4、block-apply 回滚 deleted=false,**逐个删 100% 成功**)——zone-draw 删旧框/绘制回滚与 block-apply 回滚现已统一为「逐个删+回读证实+幸存者重试一次」,判定只信回读;agent 手工清理大批 id 时也照此办理:分小批或逐个 `prim-delete`,非零退出(partial)就按 survived 列表重试。**删组成员自动级联清组注册(缺陷 2 已修)**:`prim-delete` 删掉的器件若登记在虚拟组里,回读证实后自动从组注册表摘除(组删净则删组),不再留陈旧注册吃掉复用位号。**删除走通用图元类(#164 已修)**:`eda.sch_PrimitiveText.delete()` 只从内存/渲染索引摘除、**从不进持久化模型** —— 删完立即读=0、`sch save` 后=0,`doc reload` 后**原 id 全部复活**(矩形/导线不受影响,只有文本;文本的 `modify` 同样被丢弃,等于一经创建就冻结)。现已统一改走 `eda.sch_PrimitiveObject.delete(ids)`(跨类型、真落盘),`sch prim-delete` 与 `sch zone-draw --clear` 都已真机验过 reload 后归零。**幸存者会先 settle 复核一轮再定案(2026-08-19)**:连接器的存活判定是删完**立刻** `getAll()` 的,那一读可能采到尚未落定的快照 → 误报 `survived`,而 CLI 据此非零退出、人再删一遍空转。现在首轮报 partial 时,CLI 等一拍(400ms)对**幸存 id**重发一次删除,用第二次回执定案:已经没了 → 归 `notFound`、命令绿;真没删掉 → 照样非零退出并给出「`sch save` + 完全重启 EasyEDA」的 wedge 处方(stdout 上留的仍是**首轮**原始回执,最终判定看 stderr 与退出码)。**留下的判据教训**:立即回读**证明不了持久化**,凡是判断"删干净没有",判据是 `doc reload` 后再 `sch text-list`——这条对任何自研的 fail-closed 校验都成立(`zone-draw --clear` 当初就是这么一路报"cleared 6"、实则标签全在的)。
- `schematic.wire.create`
- **`schematic.group.move`**(`easyeda sch group-move --ids id1,id2,... --dx <mil> --dy <mil>`)——把一个器件和它周边的 stub 导线/flag **当一个整体刚性平移**,内部相对布局不变,只挪外框。⚠️ **不对接 EasyEDA 原生"组合"UI 字段**(2026-07-07 查证:该字段在 `ESCH_PrimitiveType` 里没有对应类型、`sch_PrimitiveComponent` 的 47 个方法里没有任何 getter/setter 碰它、也没藏在 `OtherProperty` 里——纯 UI 内部状态,扩展 API 完全读不到写不了)。这是**无状态虚拟分组**:每次调用都要传完整成员 id 列表,不记忆跨调用状态。器件走普通 `x/y` modify(id 不变);导线没有原地 modify,走删除重建(net/color/width/lineType 保留,**id 会变**,后续操作要重新拉 id)。`--ids` 解析走 `getAll()` 本地过滤而非逐个 `.get(id)`——刚创建的图元直接 `.get(id)` 可能瞬时 404(实测踩过),同批次 `getAll()` 能看到。用于「摆放一个模块后想整体挪位置微调」的场景,S3 布局调整阶段可用。**持久编组已可用**:`easyeda sch group create --members R1,C5,U2 [--name mcu-core]` / `list [--all-pages]` / `add` / `remove` / `ungroup` —— 平台无编组 API(真机探测:`eda.*` 零编组面、组件实例零 group/parent 字段),easyeda-agent 按 documentUuid 把组关系存进 workflow state(`~/.easyeda-agent/workflow/<project>.json`,同 zones claims 模式)。成员存**位号**(页内稳定;primitiveId 会 churn),`sch group-move --group g1 --dx 100` 时解析当前 id 并**自动展开附着物**(成员 pin 上的桩线 + 远端 netflag/netport,线树粒度同 disconnect;触碰非成员脚的线树=真连线,留在原地并报告)。**`--groups g1,g2,…` 把同块的多个子组当一个刚体集合、一次内核调用整体移动**——逐子组 move 会撕裂组间共享导线的老坑已根治,同块多子组一律用它。执行走 ADR-0004 统一 move 内核(见「三层布局体系」):失败自动恢复到快照重连,结构化 `moveReport`,判据是电气对账。**边界钳位是可见的部分应用(#151)**:位移撞图纸边/图签 keepout 会被收拢(只减不反号),此时 stderr 逐轴 WARN(撞哪个边、被钳掉多少),stdout 给一行机器可读的 `partial: {"requestedDelta":…,"appliedDelta":…}` 且绿勾行同时印 requestedΔ/appliedΔ;**任一被请求轴被钳到接近 0(|applied| < |requested| 的 10% 且 |applied| ≤ 5)= 未执行**——命令在动画布之前就拒绝、非零退出,出路是先挪走挡路对象或减小位移;位移经 snap 5 网格后 0 件被移动时打「⚠ no-op」而非绿勾。足额位移的输出与旧版逐字节一致。同一位号只属一个组;组空自动删;`list` 标 stale 成员。**删器件自动级联清组注册(缺陷 2 已修)**:`sch prim-delete` / block-apply 回滚在**回读证实删除成功后**,把该位号从组注册表摘除(指向死位号的 role 一并摘,组删净则删组)——位号复用不会再被陈旧组吃掉;级联 fail-soft,失败只警告,可 `sch group list` 审计补清。**块溯源可手工恢复(缺陷 4 已修)**:`group create` 新增 `--block-id <块id> [--instance <实例>] [--roles ROLE=位号,…]`,写入与 block-apply 自动登记相同的溯源字段;组注册损坏(如曾被陈旧组吃掉)后手工重登即可恢复 `sch reconcile` 机械对账——**reconcile 需要 --block-id 加 --roles 两者**,只给 --block-id 记录溯源但暂不可对账(命令会提示);--roles 的位号必须是本组成员。`sch align`/`distribute` 对**部分覆盖**某组的选集硬拒绝(`--break-group` 显式放行);autolayout/autoplace-free 检测到组时警告(不保组内相对几何)。
- `schematic.netflag.create`
- `schematic.power.connect_pin`
- **`easyeda sch destagger`** — **marker-overlap 的修复侧**(#171;检测侧是 `sch check` 的 marker-overlap,#148)。`sch check` 早能检测、一直没法修:直接 `sch modify` 挪 netflag/netport 坐标会把标识从导线端点上**挪脱→断网**,所以实测一块 4 页板报出的 101 条纯视觉重叠长期"修不动",只能手工一个个拆了重连(2026-08-12 真实会话:6 处重叠,AI 临场猜 offset 30/40/50/70 改了三轮才收敛,中途还引入一次 `multi-net-wire` 短路)。四条安全原语:①**只搬两点直线短桩**上的 marker,挂在多段折线/网络主干/斜线上的一律跳过并带原因(`not-a-stub`/`stub-too-long`/`diagonal-stub`);②**带桩线一起挪**——走 `disconnect`(旗+桩一起删)→ `connect_pin`(按新方向/桩长重拉),**宿主端(pin 侧)坐标一字不动**,电气拓扑天然不变;③桩长候选**量出来**(跟着该旗 `flagTextBand` 文字带尺寸递增)并吸附 5 单位连接网格(= 连接器 `SCH_GRID`,不吸附则实际落点与规划差半格),方向按「电上地下」偏好序分配、rotation 走与 `reversed-net-flag` 判据**同一张真值表**;④落地后跑**真实 `sch check` 复验**,电气项任何一项变差就回滚并如实上报回滚是否干净(PARTIAL STATE 绝不谎报复原)。**`--apply` 已解禁(ADR-0004)**——执行统一走安全 move 内核:宿主器件不动,把宿主的桩线/旗**整树删净(删证回读)**后按新方向/桩长一遍性重连,再做网表逐 pin 对账 + bridge 增量检查,任一步失败**自动恢复到快照重连**并如实上报。当年三次三败的死因是逐根 `disconnect` 删桩线触发相邻共线导线自动合并 → 串网(缺安全执行层,非规划错);整树删净后器件身上没有任何导线,重建零合并风险。**dry-run 预览仍推荐**:先看计划,满意再 `--apply`。**挤不下时**宁可不动**(记 `no-free-slot`),不硬塞一个还撞的位置。默认 dry-run;`--json` 出完整计划(每个 skip 带原因);`--max-rounds N` 迭代(marker-overlap 归零提前收敛)。**单页作用域**(桩线只能从激活页读),跨页逐页 `doc switch` 后各跑一次。判据是「电气项不许变差」而非「必须全 0」——真实板本来就带着未 NC 标的 floating IO。
- `schematic.pin.set_no_connect` — 打/清「非连接标识」(NC, X 标记),让 DRC 不再对故意悬空的引脚报"未连接"。按位号+引脚号定位:`easyeda sch no-connect --designator U1 --pin 23,24[,…]`(`--clear` 清除)。实现必须从器件实例 `getAllPins()` 取引脚,`setState_NoConnected(...)` 后逐脚 `await pin.done()` 应用到画布,再重新获取器件实例回读;只调 setter 会得到当前句柄假 `true`、实际画布不变。
- `schematic.select`
- ~~`schematic.snapshot`~~ — 已移除(2026-08-12,出图统一 `sch export-image`)。**产物保存在 CLI 运行目录下的隐藏目录 `<cwd>/.easyeda/artifacts/`,文件名带本地时间戳**(`<YYYYMMDD-HHMMSS>-<kind>-<短id>.png`);响应里的 `artifacts[].path` 是绝对路径。netlist/BOM 等其他产物同此规则。
- **`easyeda sch zone-plan` 的失败分两种,别混**:①**装不下** —— 报「这一页装不下:<模块> 的框要 W×H,而可用区只有 W×H」并直接给出该换的图纸档(A3/A2…)或建议拆页;②**摆得不好** —— 报「容量是够的,是摆放/间距问题」并指向 `sch zone-arrange --apply` 整页重排 / `sch group-move` 挪件 / 拆页(**不再指向 `--gutter`**:归组是「一区一框」之后 gutter 不参与分区怎么分,调它治不了重叠)。此前两者共用一句「adjust margins/gutter or the zone claims」,而对一颗 421 高的 WROOM-1 模组来说那是**做不到的建议**(A4 扣掉图签安全带只剩 541 可用高,框要 605),照着调只会白试一轮然后把整条判据当噪音。判据的价值不在报错,在报出**能执行的下一步**。容量判定刻意保守:只问单个模块自己塞不塞得进可用区,完全不管模块之间怎么排 —— 绝不会把「两个组顶在一起」误判成「该换纸」。

- **`easyeda sch status`** — **原理图侧的进度权威**:S1–S6 每一格**当场从画布算**,`--all-pages` 逐页测(切页读完切回),`--gate` 顺带逐页跑 gate 填上 S5。**不落盘任何状态** —— 立项动机就是记录会撒谎:`workflow status` 把 imported/placement_ready 打成实心圆,而那块 PCB 上一个器件都没有(它记的是「某个动作被调用过」,不是「结果还在画布上」)。原理图的 S1–S6 全部机械可判,所以干脆不存,**没有记录就没有可撒的谎**。四态:`✓ done` / `◐ partial`(部分页满足) / `○ todo` / **`? unknown`——本工具判不了,不是委婉的「没做」,更不会替它打勾**。三条硬规矩:①**有页读不到 → 整张判定降级 unknown** 并指向 `health`/`doc switch`(同 gate 的 `blocked`:检查器没跑完 ≠ 板子没问题;首版正是拿读得到的 1/4 页宣布「已就绪、进 PCB」被真机打脸);②**读取失败绝不合成 0**(导线读不到 ≠ 没有导线,否则故障被渲染成「还没连线」);③S5/S6 是有意留白:S5 要跑 gate,S6 平台不暴露脏标记(只能显式 `sch save` 确认 `saved:true`)。`next` 永远给一条可照抄执行的命令(页名占位时直接带上该页 uuid)。

- **`easyeda sch gate`** — **S5 校验门的唯一入口**:按固定顺序跑 `layout-lint` → `check` → `bridge-check` → `drc`,出一张报告。四个单命令原样保留(局部复查),但**交付门走 gate**。收敛动机见 `docs/design-sch-surface-convergence.md`:四个检查器各自为政时,「跑哪几个、什么顺序、谁的退出码算数」每次都要现场决定,而这个决策没有数据判据 —— audit log 实测 agent 对同一个失败拼过四种不同的下一步。现在顺序、阻塞判据、退出码都固化在代码里。**阻塞判据**:layout-lint `overlap`/`pin-coincidence` · check fatal+error · bridge-check `wire-bridge`(真短路) · drc fatal;tight spacing / orphan stub / 非 fatal DRC 项是告警,`--strict` 提升为阻塞。**顺序不是随意的**:几何最便宜且解释力最强(重叠会连锁出一堆电气误报,先治几何省掉大半来回),DRC 最慢且需前台故垫底。**verdict 三态,`blocked` ≠ `fail`**:`pass` 全过 / `fail` **板子有阻塞问题** / `blocked` **检查器没跑起来**(连接器断、页没打开、返回结构异常)——此时原理图**从未被完整判定**,后续 stage 直接跳过而不是继续撞同一堵墙,报告会指向 `easyeda health` + `doc switch` 而不是让你去改电路(旧行为下 agent 曾在 NO_CONNECTOR 后盲目改调别的命令 146 次)。每个失败 stage 自带**规定的下一步**,不用自己发明。`--json` 带每个 stage 的完整原生报告(`stages[].detail`),是四个单命令 JSON 的超集;`--only`/`--skip` 选子集(拼错 stage 名直接报错,绝不静默少跑一关);`--fail-fast` 第一个阻塞失败就停;窗口不在前台时 `--skip drc` 先过前三关。
- `schematic.drc.check` — 用 `easyeda sch drc` 跑 EasyEDA SDK 的 `sch_Drc.check`。**注意:当前 EasyEDA build 可能只返回布尔/聚合结果,不会暴露 UI DRC 面板里的逐条 warning**(例如网络标识与导线名不一致、悬空脚明细)。所以它只能作为 SDK DRC 门,不能单独宣称“官方 DRC 干净”。
- `schematic.check` — 用 `easyeda sch check` 跑的**重建式逐条设计检查**(补 SDK DRC 暴露不全)。**每条 finding 带 kebab-case 规则类型名 `type`(与 `pcb check` 同约定,可按类型统计/gate),summary 每类一个计数字段**。规则清单(全部 WARN):**floating-pin**(引脚悬空)、**geom-net-mismatch**(导线触碰引脚但网表未归入任何 net——疑漏报)、**net-marker-mismatch**(网络标识/端口/标签名与所连导线 net 名不一致)、**multi-net-wire**(同一导线多个网络名)、**wire-crossing**(导线交叉)、**wire-over-pin**(导线穿过引脚)、**zero-length-wire**(零长度残线)、**dangling-wire**(悬挂导线/孤儿 stub)。**几何 marker 规则(Go 侧,消费 `components.list` 的真实 bbox/锚点,电气引擎看不见的三类,#146/#147/#148)**:**duplicate-net-marker**(同类型+同网+同锚点的重合 netflag/netport ≥2 个——批量 autoconnect 中断重试留下的重复 GND/电源/端口标识,连接器会把同名重合旗合并掉网,故所有电气规则全绿而页面叠着一对;finding 带全部 `primitiveIds` + `suggestKeepId`/`suggestDeleteIds`,直接喂 `sch prim-delete`)、**titleblock-overlap**(part/marker 的 bbox 侵入 A4 标题栏图签 keep-out——autoconnect 会把 netport 落进明细表而 layout-lint 只检 part、电气检查几何盲)、**marker-overlap**(marker body 正面积压住 part 或另一 marker——电气正确但不可读;`--overlap-eps` 默认 0.5 调噪声下限,平行同侧端口的 ~1 unit 天然相交仍会报。**修复走 `easyeda sch destagger`**,别手工挪坐标——直接 modify 会把标识挪脱导线端点导致断网,见下方条目)。`floating-pin` 现在带 `primitiveId` 与 `pinDetails[]`(每个悬空脚的 `number`/`name`/`x`/`y`),文本报告逐脚打印脚名+坐标、designator 为空时回退打印 `primitiveId`,可直接喂给 `sch no-connect`。`wire-over-pin` 会**排除落在导线端点或 netflag/netport/netlabel 锚点上的引脚**——那是 `sch connect` 短 stub 的合法终点(EasyEDA 把共线相邻 stub 自动合并成一条长导线时,内部引脚会落进合并后导线的内部,但官方 DRC 视为合法,故不再误报)。`--json`、`--strict`(有 finding 即非零退出)、`--all-pages`。
- `schematic.bridgeCheck` — 用 `easyeda sch bridge-check` 跑的**树粒度网络-铜皮一致性门**(补 `sch check` 逐 wire 检查的盲区:EasyEDA 把共线相邻异网 stub 合并成一条 wire 树后,单条 wire 不再同时带两个网名)。按共享顶点把 wire 并成树(union-find),聚合树上锚定的 netflag/netport 网名——**锚定按点到线段距离**(0.15.1/#135 修复:合并会把被吞 flag 留在线段**中段**,旧的顶点邻近判定永远锚不上,一树双网真短路曾漏报为 0)。规则类型(kebab-case,同 `sch check`/`pcb check` 约定):**wire-bridge**(一棵 wire 树带 ≥2 个网名 = 真实短路,ERROR,非零退出可 gate)、**orphan-stub**(树触碰引脚但无任何网络标识,WARN)、**orphan-flag**(netflag/netport 不挨任何导线,WARN——删合并线留下的孤儿,新画的线穿过该点会静默继承其网名,发现即 `sch prim-delete` 清掉)、**orphan-tree**(wire 树**不触任何引脚**:挪件残留的 flag+桩线整树、或裸死线,WARN——修法 `sch prim-delete` 整树 wireIds+flagIds 删净;**需连接器 ≥0.26.1**,此前 orphan-stub 要求触脚、orphan-flag 要求 flag 无线,对这种形态**双双结构性盲区**,2026-08-18 真机 P2 两棵 GND 残留树报了全绿,靠渲染图人工数 flag 才抓到 —— 怀疑有悬空标识而 bridge-check 全绿时,可交叉验证「某网 flag 数 vs 该网 pin 数」)。JSON 里每棵问题树带 `type`/`level`(`kind` 大写枚举保留兼容),summary 的 `bridges`/`orphans`/`orphanFlags`/`orphanTrees` 即按类型计数。`--json`、`--all-pages`。**注意:即便 check+bridge-check 双绿,布线后的最终判据仍是 netlist 逐网对账**(`sch read` 对拓扑,`sch block-apply` 已内建此对账门,不一致非零退出)。
- `schematic.read` — **一次拿到整张电路的语义快照**(`easyeda sch read`),省得分别跑 `components.list`+`netlist`+`check` 再自己拼。返回:`components[]`(designator/type/name 值/footprint/supplierId=LCSC/坐标 + 每脚 `{number,name,net}`)、`nets[]`(net→所连 `designator.pin` 列表 + `degree` + `isGlobal` 电源地标志)、`floatingPins[]`(未连脚)、`check`(同 `sch check` 的几何检查)。**脚→net 取自官方网表 `sch_ManufactureData.getNetlistFile()` 的 JSON,权威非几何猜测**,与 `sch check` 同源。`--all-pages`;`--no-check` 跳过设计检查更快。读电路状态/做决策前优先用它。**不要改走 `sch_Netlist.getNetlist()`**:官方 prodocs 已标 obsolete 并要求改用 `SCH_ManufactureData.getNetlistFile()`,且 [easyeda/pro-api-sdk#30](https://github.com/easyeda/pro-api-sdk/issues/30) 记录了它在含悬空引脚原理图上无限卡死。
- `schematic.save`
- `schematic.export.netlist` — 导出网表 artifact,底层同样只走 `sch_ManufactureData.getNetlistFile(fileName, netlistType)`。raw debug 需要网表时用:
  `const f = await eda.sch_ManufactureData.getNetlistFile('netlist.json'); return f && await f.text();`
- `schematic.export.bom`
- `schematic.library.search`
- `schematic.library.get_by_lcsc` — 用 `easyeda lib by-lcsc --lcsc C…`(可重复或逗号分隔多个)把 LCSC C 号**确定性**解析成 `{libraryUuid, uuid}`(免 free-text 排序),返回里带 `notFound` 列出未解析的 C 号。已知确切器件(BOM / standard-parts.json)时优先用它。

### PCB

PCB 操作（切到 PCB、读器件/层/网络/Board、从原理图 `import_changes` 同步、布局摆位
move/rotate/align/distribute/grid_snap/cluster-arrange）在独立的 operational skill
**[`pcb.md`](./pcb.md)** —— 见那里(单一真源,勿在此复制)。

## Bundled Scripts

| 脚本 | 用途 |
|---|---|
| `scripts/sch.py` | **稳定执行器**（import 用）— 把核心 CLI 封成 churn-resilient API:`read()`/`place()`/`move()`/`wire()`(SOP-W 正交避引脚)/`rail_flag()`(SOP-F 定向)/`decouple()`(SOP-D)/`connectivity()`(union-find 真连通)/`snapshot()`(取 .easyeda/artifacts)。AI 数据自调闭环用:放→`read`→判→`move`→`connectivity` 验。 |
| `scripts/lint.sh <project>` | 原理图数据 lint（几何 + 连通性检查，无需截图）。有 baseline 时显示 DIFF |
| `scripts/lint.sh <project> --save` | 全量 lint 并记录 baseline |
| `scripts/bom-enrich.py <bom.tsv>` | 将导出的 BOM 里 `SupplierId` 从 MPN 补全为 LCSC C 号。**`easyeda bom export --type csv` 已默认自动调用它就地补全**（`--enrich=false` 关闭）；本脚本仅在手动后处理已有 BOM 时单独用 |
| `scripts/parts-select.py` | 器件选型辅助工具 |

标准器件库（`standard-parts.json`）、flag 旋转真值表（`orientation.json`）、布局/选型约定都在
**easyeda-agent references** skill（单一真源，勿在此复制）。
`bom-enrich.py` / `parts-select.py` / `orient.py` 会跨 skill 自动读取这些 canonical 文件。

## Guardrails

- Confirm before deleting primitives.
- Save automatically at an already-defined passed stage and verify `saved:true`; pause first
  only when the user explicitly requested step-by-step approval.
- **幂等性**:`sch autoconnect` 幂等(重跑同 spec 安全,已连脚 skip,改网加 `--replace`);`sch connect`
  **非幂等** —— 重发前先 `sch read` 核对,否则在同一脚叠加 flag。
- **持久化:`place`/`wire`/`modify` 只改 EasyEDA 内存,不 `schematic.save` 就不落盘** —— 窗口重载 / daemon 重启 / EasyEDA 崩溃会丢掉未保存的改动(实测踩过)。daemon 默认开**防抖 autosave(3s)** 兜底(`daemon start --autosave-debounce`,`0` 关),但防抖窗口内进程挂掉仍会丢最后几笔,所以多步改动仍**分批显式 `sch save`**,别只靠 autosave。整板流程的存盘节奏见 [`design-flow.md`](./design-flow.md) 的 💾 检查点。
- Confirm before running a generated multi-step mutation plan.
- Do not claim completion after mutation until verification succeeds or the remaining risk is stated.
- Treat `File` and `Blob` outputs as artifacts.
- If DRC fails, report violations and propose the smallest repair step.

## Layout Conventions

### 原理图

When placing components, follow [schematic-layout-conventions.md](./schematic-layout-conventions.md):
- Zone map (power left, MCU center, RF/sensors right, big modules in corners)
- Module spacing rules (80–500 units depending on size + pin count)
- Wire stub lengths (20–40 units for power, 20–60 for signals)
- Right-angle-only routing, decoupling caps within 30 units of VCC pins

> **PCB 布局**约定在 [pcb-layout-conventions.md](./pcb-layout-conventions.md)，操作流程在 [`pcb.md`](./pcb.md) skill。

## EasyEDA Electrical Rules (load-bearing — DRC will fatal if ignored)

EasyEDA's DRC does **not** treat two primitives sharing the same coordinate as electrically connected. Every connection needs a real `schematic.wire.create` between them. Two concrete consequences:

1. **`schematic.netflag.create` MUST NOT be placed on the same point as a pin.** Placing a +3V3/GND/IN/OUT flag at the exact pin coordinate produces a DRC fatal: *"端点重叠且未连接 / endpoints overlap but not connected"*. The flag sits on top of the pin visually but EasyEDA treats them as two disjoint endpoints.

   Correct pattern: pin → short wire → netflag at the wire's far end. Typical offset: 20 grid units (EasyEDA uses 0.01 inch / grid unit on schematics). Example for `+3V3` on `R1.pin1 @(265, 440)`:

   ```text
   schematic.wire.create     points = [265,440, 245,440]   # pin to a free point
   schematic.netflag.create  x = 245, y = 440, kind=power, net="+3V3"
   ```

2. **Wires must have non-zero length.** A wire of `[x,y, x,y]` is silently ignored; a wire of `[x,y, x+0,y+0]` will not register a connection.

3. **NC pins still need explicit marking.** A pin without any wire/flag triggers a "悬空 / floating" warning even if your design intends it unused. Use a Non-Connected flag for those.

Apply this rule when generating any power/ground/port connection — emit the wire first, then place the flag at the wire's free endpoint.

## Missing Actions

When a needed operation has no typed action:

0. **Discover the underlying `eda.*` method first** — `easyeda api search <kw>`
   (offline, no daemon) ranks methods of the official API by name/namespace/中文摘要,
   `easyeda api ls [filter]` lists namespaces, `easyeda api show <ns>` dumps one
   namespace. Index is embedded from `@jlceda/pro-api-types`. This is the front of
   the dev loop `api search → debug.exec_js → typed action → Cobra 子命令`.
1. Decompose it into existing actions if possible.
2. Otherwise state the missing action name and expected inputs/outputs.
3. Use `debug.exec_js` (raw `eda.*` JavaScript) only as a temporary, user-confirmed debug escape hatch. Its result must be JSON-serializable — base64-encode any `Blob`/`File` inside the snippet.
4. Recommend promoting repeated debug code into a typed action.

