← Commit history

Publish 0.2.3

John Lauer ·6e27c4b647 ·3d ago ·parent 03b0ce8
83 files changed +10467−421
Cargo.lock+10−10Cargo.toml+1−1README.md+1−1SKILL.md+48−4bin/adom-aiflowcrates/adom-aiflow/src/checks.rs+1−3crates/adom-aiflow/src/clipwatch.rs+299crates/adom-aiflow/src/continuation.rs+6−3crates/adom-aiflow/src/copper_carry.rs+456crates/adom-aiflow/src/decoupling_check.rs+352crates/adom-aiflow/src/design_evidence.rs+210crates/adom-aiflow/src/export.rs+22−1crates/adom-aiflow/src/fills.rs+151crates/adom-aiflow/src/main.rs+370−121crates/adom-aiflow/src/mechanical.rs+510crates/adom-aiflow/src/outline.rs+125−22crates/adom-aiflow/src/placement_intent.rs+51crates/adom-aiflow/src/pourverify.rs+587−29crates/adom-aiflow/src/preboard.rs+22−1crates/adom-aiflow/src/project_lib.rs+222crates/adom-aiflow/src/provenance.rs+42−2crates/adom-aiflow/src/rules_card.rs+357crates/adom-aiflow/src/schematic_check.rs+118crates/adom-aiflow/src/schematic_symbols.rs+764crates/adom-aiflow/src/silk_polarity.rs+146crates/adom-aiflow/src/silkproof.rs+135crates/adom-aiflow/src/skill_hints.rs+199crates/adom-aiflow/src/spec_freeze.rs+1−1crates/adom-aiflow/src/spec_nets.rs+35crates/adom-aiflow/src/step_export.rs+182crates/adom-aiflow/src/thermalvias.rs+28−2crates/adom-aiflow/src/verified.rs+124crates/aiflow-analyze/src/board_sink.rs+307crates/aiflow-analyze/src/current_acks.rs+187crates/aiflow-analyze/src/lib.rs+43−5crates/aiflow-board/src/eagle.rs+148−2crates/aiflow-board/src/lib.rs+66−1crates/aiflow-board/src/netname.rs+198crates/aiflow-bridge/src/lib.rs+189−13crates/aiflow-copper/src/lib.rs+7crates/aiflow-place/src/intent.rs+460crates/aiflow-place/src/lib.rs+2crates/aiflow-pours/src/lib.rs+106−9crates/aiflow-router/src/board_copper.rs+31crates/aiflow-router/src/copper_path.rs+401crates/aiflow-router/src/decoupling.rs+373crates/aiflow-router/src/drive.rs+41−10crates/aiflow-router/src/lib.rs+48−2crates/aiflow-router/src/rules.rs+4−1crates/aiflow-router/src/simplify.rs+372crates/aiflow-run/src/lib.rs+28−2docs/board-sink.md+34docs/bridge-boundary.md+45docs/current-peak-acknowledgements.md+23docs/existing-pours.md+29−3docs/fusion-lane.md+8−2docs/plan-schema.md+2flows/board.json+31package.json+1−1page.json+1−1skills/adom-aiflow/SKILL.md+52−4skills/aiflow-circuit-design/SKILL.md+9skills/aiflow-live-clips/SKILL.md+6skills/aiflow-molecule/SKILL.md+5−1skills/aiflow-power-layout/SKILL.md+18skills/aiflow-project-page/SKILL.md+4skills/aiflow-schematic-to-board/SKILL.md+11−1skills/aiflow-silkscreen/SKILL.md+13−1skills/aiflow-simulate/SKILL.md+4skills/aiflow-sourcing/SKILL.md+27−1tests/project-page-library.py+211tests/rules-card.mjs+31tools/fusion-project.py+129−17tools/kicad-cli-remote+304−89tools/laser-etch/adom-aiflow-etch+1−1tools/laser-etch/mark_check.py+48−12tools/laser-etch/mark_etch.py+1−1tools/laser-etch/mark_outline.py+30−3tools/laser-etch/test_outline_lines.py+71tools/project-page.py+408−37tools/test-kicad-cli-remote.py+144tools/test_fusion_project.py+95tools/timeline/index.html+85
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.2"+version = "0.2.3" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.2"+version = "0.2.3" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.2"+version = "0.2.3" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.2"+version = "0.2.3" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
README.md+1−1
@@ -186,7 +186,7 @@ Everything below is what John has asked for, in the order it is likely to land.  A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification. -Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET`, default ConfRoomROG); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.+Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs kicad-cli on a Windows or Linux desktop's KiCad through Adom Bridge (`pcb drc`, `pcb export gerbers|drill|pos|step`, `sch export netlist`, `sch erc`, `version`; `KICAD_REMOTE_TARGET` names the desktop, default this machine's own); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.  Latest maintenance: [0.1.28 lifecycle and native IPC readiness](docs/release-0.1.28.md). 
SKILL.md+48−4
@@ -177,7 +177,7 @@ wrong part at its component page, not just on this board.  The clock starts at the prompt even when there is no board yet. -- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each records a clip of the desktop until a PCB editor is open. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page. - **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.@@ -231,6 +231,16 @@ Deterministic checks that refuse, each an OK/ERROR with hints:  The judgement is in these skills, which this tool executes: kicad-place-route-loop (place for routability, the levers when routing cannot close, go back to placement), kicad-copper-pours (which nets get a pour and which never do, priorities, the Kelvin pair, the preflight lessons), and the tool-neutral eda-engineering skillpack (eda-end-to-end-layout, eda-kelvin-current-sense, eda-copper-ablation, eda-pour-planning-measurement, eda-thermal-bottlenecks, eda-multilayer-current-review). +## Checks from an outside engineer's trial, skill paths in hints, clips that film something (0.2.3)++- **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board.+- **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.+- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say; `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule.+- **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then.+- **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.+- **Clips.** Pre-board steps film Hydrogen's dashboard, the AI's window or the tool's own window, or record a skip with the reason; a take that died is restarted on the step's current window with the gap recorded; a truncated MP4 (no moov atom) is flagged when it stops. Skill aiflow-live-clips.+ ## Companion skills (load the one for the step you are on)  AI Flow's binary is the instrument, the gates and the hands; the judgement for each step lives in a skill. Load it when the step begins:@@ -292,6 +302,20 @@ The gates read the board and the tools' own output, so a file written to look li - **Thermal counts the board's vias (0.1.96).** `analyze thermal` counts the vias on each tab net in the saved board as well as the routing plan, so thermal vias landed after routing count once the board is saved. - **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet`, `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary. +## Spec intent checked by the binary (0.2.3)++### Assigned-pin decoupling (`spec.decoupling`)++Declare each decoupling cap with the supply pin it serves: `"decoupling": [{"cap": "C6", "pin": "U1.39", "returnPins": ["U1.38"], "maxLengthMm": 3.0, "maxVias": 0, "severity": "warning"}]`. Only `cap` and `pin` are required; the cap's supply pad is its pad on the pin's net (`capPad` to name it) and its ground pad the pad on its other net (`gndPad`). `route` lays each pair's direct connection before any rail distribution and plane stubs, keeps it through rip-up, and reports per pair whether it routed directly and why not. After `route` and in `gate`, `decoupling-<stage>.json` measures the copper path cap pad to assigned pin along the net's real copper (length, vias, other pads it passes), the nearest same-net pin of that part and the straight pad distance, and flags a path that detours through the rail. The budgets are this board's own; there is no default. A finding is a warning or, with `"severity": "error"`, fails `route`, `gate` and `finish`. A ground return is `copper` or `fill` only by connectivity; a return joined only by planned pours or by fills that are not a native refill is labelled `unverified` or `fill-unverified`, never measured as a straight line. Decoupling budgets freeze with the other budgets at placement. DRC 0/0 does not prove this intent.++### Placement intent (`spec.placement`)++`placement.proximity: [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}]` (pad edge to pad edge), `placement.cluster: [{"name": "buck", "refs": ["U4", "C7", "L1"], "radiusMm": 6}]` or `"region": "<name in placement.regions>" | [[x, y], ...]`, and `placement.anchor: [{"ref": "U4", "cluster": "buck"}]`. These are intent for you, the placer: the binary verifies, it does not move parts. `place check` and `place verify` fail with the distance and the pin named (`proximity D2 near U4 pin 5: 8.00 mm pad edge to pad edge (D2.1 to U4.5), over maxMm 5`), write `placement-intent.json` and record the outcome `finish` reads. A cluster radius is measured between pad-box centres, from the anchor when there is one. `place pack` honours an anchor only in the simple case: a member with no wish of its own follows the anchor's wish at its present offset (turned with the anchor when the anchor's wish names one rotation); it prints the intent verdicts on the packed spots as advice.++### Design-rule card at `start`++`start` reads the spec's track widths, clearances, via pads, drills, edge clearance, copper thickness and load currents, checks each against the fab lane's range (generic: trace 0.1 to 2 mm, drill 0.2 mm and up, copper 12 to 210 um; raised by the minimums in the `.kicad_dru` beside the board) and prints a `WARNING:` line for each value outside it. The same card, drawn to scale beside the board outline, appears on the run dashboard (`rulesCard` in `<run>/timeline/presentation.json`, also `<run>/rules-card.json`). It is read-only and changes no gate: fix a unit slip in the spec before placement.+ ## Component coverage before placement  The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.@@ -312,7 +336,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched  1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -375,7 +399,7 @@ Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage  A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification. -Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.+Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc`, `sch erc`, `sch export netlist` and `pcb export gerbers|drill|pos` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop; Windows or Linux, detected from the bridge; on Linux the job folder is `~/.adom-aiflow-kicad`, removed after each call; relative output paths are fine); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.  Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone. @@ -444,7 +468,19 @@ When multiple physical lands share one logical pin number, use the reviewed Fiel  ### Existing pours after board adoption -Use `verify-pours --plan <reviewed-plan.json>` for read-only validation of already present saved pours against a reconciled explicit plan. Read `docs/existing-pours.md`. It records input-bound verification, not creation ownership; never replay a native mutation simply to repair dashboard evidence. A stale plan must fail. Native DRC, measurement and electrical/thermal gates remain required.+`verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully.++The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass.++`verify-pours --plan <plan> --adopt --why "<reason>"` takes ownership of the matched zones: their saved IDs join the run's owned zones (so a later `land pours` may replace them), the grant is logged in `zoneAdoptions`, and the outcome is `mode: adopted-existing`. `finish`, `status` and the summary report the pours as adopted, not landed. Native DRC, measurement and current/thermal gates remain required.++### Silk-only adoption keeps copper evidence++`adopt-board` compares the copper fingerprint of the current and adopted boards: tracks, vias, zones with settings, pads with geometry, net and drill, footprint placement, outline, stackup, nets and project rules, but not silkscreen, fab, courtyard, text or 3D models. When it is unchanged, gate, land, measure, the analyses, place verify and the Fields report are kept, with a carry record holding both board hashes and the fingerprint. Every board-hash check accepts the older board only through that record, re-verified from both snapshot files. Any copper change, an unreadable rule file or a non-KiCad board invalidates the evidence as before. The silkscreen and export proofs are always dropped; record them again.++### Holes with no copper++A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it.  ### Artifact evidence is copied without overwriting @@ -479,3 +515,11 @@ After board adoption, `rules check` records a fresh input-bound result when chec ### Rebind native saved files after adoption  Read `docs/native-board-binding.md` when the adopted board differs from the desktop checkpoint. Use `bind-remote-board` with the exact intended desktop path; `models` refuses mismatched board or required rule-sidecar bytes. This does not validate the live editor or waive engineering gates. Preserve older checkpoint files.++### Reviewed current peaks and board-face sinks++`analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited density peak (net, place, peak, reason, ai thread). It covers only that pad or 0.5 mm around the point, keeps the number reported, shows in `finish`, and expires when the copper fingerprint (the one copper carry-over uses) or the spec changes. A whole-net acknowledgement is refused. Read `docs/current-peak-acknowledgements.md`.++`spec.boardSink` declares a pedestal or plate on a board face for named tabless parts: a conservative via, mask, interface and sink series estimate in `analyze thermal`, or the same-input Fields solve with the named conducting block. Both record their assumption in the run summary and `finish`; other parts keep the still-air check. Read `docs/board-sink.md`.++`outline check` reads only Edge.Cuts: board items and footprint Edge.Cuts items placed in board coordinates.
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/checks.rs+1−3
@@ -23,9 +23,7 @@ pub struct SchNetlist { /// `/VIN` and out of a board built from the same netlist as `VIN`. Only the leading `/` goes; a /// sub-sheet path (`/power/VIN`) keeps its hierarchy. pub fn norm_net(n: &str) -> String {-    let t = n.trim();-    let t = t.strip_prefix('/').unwrap_or(t);-    t.to_string()+    aiflow_board::netname::norm_net(n) }  /// KiCad's names for a pin connected to nothing.
crates/adom-aiflow/src/clipwatch.rsadded+299
@@ -0,0 +1,299 @@+//! Clips that film something, and clips that survive (issue #59).+//!+//! 1. The camera for a step before the board. An AI that does the pre-board steps (intake, rules,+//!    sourcing, design, simulation, schematic...) with its own scripts leaves nothing on screen in+//!    the EDA tool, so a clip of the editor window (or of a cached handle) is one flat colour. Those+//!    steps film, in order: the tool's own window when the step really works in it (the schematic+//!    editor for `schematic`, the library tool for `library`), else Hydrogen with the run's+//!    dashboard (the timeline moves at every command), else the AI's terminal or IDE window. When+//!    none is on the test box the clip is skipped and the skip is recorded with its reason.+//! 2. A clip whose recording died (the window closed, the bridge restarted, the take failed). The+//!    command that comes next asks the recorder about the running take (at most once every+//!    WATCH_SECONDS); a dead take is stopped and pulled for what it holds, and a new take starts on+//!    the step's current window. The capture records the gap (from, to, why) so the clip page, the+//!    composed video and `deliver` say so, instead of a truncated file found only at deliver.+//! 3. A truncated MP4 (no moov atom: the recorder never finalised it) is named at the clip's stop+//!    by ffprobe, not later.++use serde_json::{json, Value};++/// Steps whose work has no board window: their camera is chosen by `preboard_camera`.+pub fn preboard_step(step: &str) -> bool {+    crate::preboard::PREBOARD_STEPS.contains(&step) || matches!(step, "parts" | "project-page" | "library")+}++/// Steps where the tool's own window shows the work (when the tool has one open).+pub fn tool_step(step: &str) -> bool { matches!(step, "schematic" | "library") }++/// What a step's clip films.+#[derive(Debug, Clone, PartialEq)]+pub enum Camera {+    Window { hwnd: i64, what: String },+    Skip(String),+}++/// The windows of a `desktop_list_windows` reply (the reply as is, or wrapped in `output`).+pub fn windows_of(reply: &Value) -> Vec<Value> {+    let out = reply.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).unwrap_or(reply.clone());+    out["data"]["windows"].as_array().or(out["windows"].as_array()).cloned().unwrap_or_default()+}++/// Terminal and IDE programs an AI engine runs in on Windows.+const TERMINALS: &[&str] = &["windowsterminal.exe", "code.exe", "cursor.exe", "antigravity.exe", "codex.exe", "claude.exe", "windsurf.exe", "pwsh.exe", "powershell.exe", "cmd.exe", "wezterm-gui.exe", "alacritty.exe", "conemu64.exe", "mintty.exe"];++fn image(w: &Value) -> String { w["image"].as_str().unwrap_or("").to_ascii_lowercase() }+fn visible(w: &Value) -> bool { w["kind"].as_str().map(|k| k == "normal").unwrap_or(true) && w["minimized"] != true && w["hwnd"].as_i64().is_some() }++/// The camera for a pre-board step: the tool window when given, else Hydrogen, else the AI's+/// terminal (one whose title names the engine first), else a skip with its reason.+pub fn preboard_camera(step: &str, windows: &[Value], engine: &str, tool: Option<(i64, String)>) -> Camera {+    if let Some((hwnd, what)) = tool { return Camera::Window { hwnd, what }; }+    if let Some(h) = windows.iter().filter(|w| visible(w)).find(|w| image(w) == "hydrogen.exe").and_then(|w| w["hwnd"].as_i64()) {+        return Camera::Window { hwnd: h, what: "Hydrogen with the run's dashboard".into() };+    }+    let eng = engine.to_ascii_lowercase();+    let first_word = eng.split(|c: char| !c.is_ascii_alphanumeric()).find(|s| s.len() >= 3).unwrap_or("").to_string();+    let terms: Vec<&Value> = windows.iter().filter(|w| visible(w) && TERMINALS.contains(&image(w).as_str())).collect();+    let named = terms.iter().find(|w| !first_word.is_empty() && (w["title"].as_str().unwrap_or("").to_ascii_lowercase().contains(&first_word) || image(w).contains(&first_word)));+    if let Some(w) = named.or(terms.first()) {+        return Camera::Window { hwnd: w["hwnd"].as_i64().unwrap_or(0), what: format!("the AI's window ({})", w["title"].as_str().filter(|t| !t.is_empty()).unwrap_or(w["image"].as_str().unwrap_or("terminal"))) };+    }+    Camera::Skip(format!("nothing meaningful on screen for the {step} step: no Hydrogen, terminal or tool window on the test box (the work is in scripts); the clip is skipped rather than filming an idle window"))+}++/// The schematic or library window of the run's tool for a tool step: KiCad's schematic editor+/// (any editor that is not the PCB editor) from `kicad_window_info`, else the tool's main window.+pub fn tool_window(step: &str, eda_id: &str, kicad_info: &Value, main: Option<i64>) -> Option<(i64, String)> {+    if !tool_step(step) { return None; }+    if eda_id == "kicad" {+        let d = kicad_info.get("data").cloned().unwrap_or(kicad_info.clone());+        let eds = d["editors"].as_array().cloned().unwrap_or_default();+        let want = |w: &Value| { let k = w["kind"].as_str().unwrap_or("").to_ascii_lowercase(); if step == "schematic" { k.contains("sch") } else { k.contains("symbol") || k.contains("footprint") || k.contains("lib") } };+        return eds.iter().find(|w| want(w)).and_then(|w| w["hwnd"].as_i64().map(|h| (h, format!("KiCad's {}", w["kind"].as_str().unwrap_or("editor").replace('_', " ")))));+    }+    main.map(|h| (h, format!("the {eda_id} window")))+}++/// Ask the live desktop: the camera for a pre-board step.+pub fn choose_preboard(br: &aiflow_bridge::Bridge, cam: &dyn aiflow_bridge::eda::Eda, step: &str, engine: &str) -> Camera {+    let tx = |v: &str, a: &Value| br.raw_call(v, a);+    let tool = if tool_step(step) {+        let info = if cam.id() == "kicad" { br.raw_call("kicad_window_info", &json!({})) } else { Value::Null };+        let main = if cam.id() == "kicad" { None } else { cam.main_window(&tx) };+        tool_window(step, cam.id(), &info, main)+    } else { None };+    let windows = windows_of(&br.raw_call("desktop_list_windows", &json!({})));+    preboard_camera(step, &windows, engine, tool)+}++// -- dead takes -------------------------------------------------------------------------------++/// Seconds between two health checks of the running take.+pub const WATCH_SECONDS: u64 = 45;++/// Why a take is dead, from `desktop_record_status {recordingId}`; None when it runs (or when the+/// answer says nothing either way: a transport error is not a dead take).+pub fn dead_reason(status: &Value) -> Option<String> {+    if status.is_null() || status["status"] == "parse_error" || status["status"] == "error" { return None; }+    let code = status["errorCode"].as_str().unwrap_or("").to_ascii_lowercase();+    let text = format!("{} {}", status["error"].as_str().unwrap_or(""), status["message"].as_str().unwrap_or("")).to_ascii_lowercase();+    if status["success"] == false {+        if code.contains("not_found") || code.contains("unknown") || code.contains("no_recording") || text.contains("not found") || text.contains("no such recording") || text.contains("unknown recording") || text.contains("nothing running") {+            return Some("the recorder no longer knows this take (the bridge restarted, or the take was dropped)".into());+        }+        return None;+    }+    let d = status.get("data").filter(|d| d.is_object()).unwrap_or(status);+    let state = d["state"].as_str().unwrap_or("").to_ascii_lowercase();+    if state == "failed" { return Some(format!("the take failed: {}", d["finaliseError"].as_str().or(d["error"].as_str()).unwrap_or("no reason given"))); }+    if d["captureItemClosed"] == true { return Some("the captured window or display went away (window closed, display change or graphics reset)".into()); }+    if d["autoStopped"] == true { return Some("the take hit its maximum duration and stopped".into()); }+    if matches!(state.as_str(), "stopped" | "finalised" | "finalized") && d["stillRunning"] != true { return Some(format!("the take is {state}, though the run never stopped it")); }+    None+}++/// Whether the health check is due (the last check is in `clipWatch.lastCheck`, seconds).+pub fn due(data: &Value, now: u64) -> bool {+    data["clipWatch"]["lastCheck"].as_u64().map(|t| now.saturating_sub(t) >= WATCH_SECONDS).unwrap_or(true)+}++/// The capture entry of a take restarted after `dead` (the capture that died) on `hwnd`.+pub fn resumed_entry(dead: &Value, rid: &str, hwnd: i64, what: &str, died_at: &str, now: &str, why: &str) -> Value {+    json!({"recordingId": rid, "start": now, "kind": "window", "hwnd": hwnd, "camera": what,+        "step": dead["step"], "visit": dead["visit"], "back": dead["back"], "why": dead["why"],+        "part": dead["part"].as_u64().unwrap_or(1) + 1,+        "resumedAfterGap": {"from": died_at, "to": now, "why": why}})+}++/// The running take of the run: (its capture index, recording id).+pub fn running(data: &Value) -> Option<(usize, String)> {+    let caps = data["captures"].as_array()?;+    let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;+    Some((pos, caps[pos]["recordingId"].as_str()?.to_string()))+}++/// Check the running take; when it died, collect what it holds and restart it on the step's+/// current window. Returns a line for the AI, None when nothing happened.+pub fn watch(r: &mut aiflow_run::Run, br: &aiflow_bridge::Bridge, dir: &std::path::Path) -> Option<String> {+    let now_s = aiflow_run::parse(&aiflow_run::now()).unwrap_or(0);+    if !due(&r.data, now_s) { return None; }+    let (pos, rid) = running(&r.data)?;+    r.data["clipWatch"] = json!({"lastCheck": now_s});+    let status = br.raw_call("desktop_record_status", &json!({"recordingId": rid}));+    let Some(why) = dead_reason(&status) else { return None };+    let died_at = aiflow_run::now();+    let cap = r.data["captures"][pos].clone();+    let step = cap["step"].as_str().unwrap_or("").to_string();+    let tag = format!("{}-{}", step, cap["visit"].as_u64().unwrap_or(1));+    // collect what the dead take holds (it may be truncated: the stop's own check says so)+    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+    let stop = br.record_stop(&rid, &save_to);+    let local = crate::settle_clip_stop(r, pos, &stop, dir);+    r.data["captures"][pos]["died"] = json!({"at": died_at, "why": why});+    r.log("clip-stop", crate::clip_stop_event(json!({"step": tag, "recordingId": rid, "by": "clip watch: the take died", "why": why}), &stop));+    if let Some(f) = local.filter(|f| std::path::Path::new(f).is_file()) { crate::clip_artifacts(r, &json!(tag), &f, 10); }+    // the step's current window, looked up live (a cached handle is what died)+    let eda = aiflow_bridge::eda::active();+    let camera = if preboard_step(&step) {+        choose_preboard(br, &*eda, &step, r.data["engine"].as_str().unwrap_or(""))+    } else {+        let tx = |v: &str, a: &Value| br.raw_call(v, a);+        match eda.camera_window(&tx, &step, None) { Some(h) => Camera::Window { hwnd: h, what: format!("{}'s window", eda.name()) }, None => Camera::Skip(format!("no {} window open for {step} any more", eda.name())) }+    };+    let line = match camera {+        Camera::Window { hwnd, what } => match br.record_window_start(hwnd, &format!("aiflow step {step} (resumed after the take died)")) {+            Ok(new_rid) => {+                let t = aiflow_run::now();+                r.data["captures"].as_array_mut().map(|a| a.push(resumed_entry(&cap, &new_rid, hwnd, &what, &died_at, &t, &why)));+                r.log("clip-gap", json!({"step": tag, "from": died_at, "to": t, "why": why, "recordingId": new_rid, "window": what}));+                r.mark(&format!("clip {tag} died ({why}); restarted on {what}"));+                format!("clip {tag} died ({why}); it was stopped and pulled, and a new take started on {what}; the gap is recorded")+            }+            Err(rep) => {+                r.log("clip-gap", json!({"step": tag, "from": died_at, "to": Value::Null, "why": why, "restart": rep["errorCode"]}));+                format!("clip {tag} died ({why}); restarting it failed ({}): `capture start` once the window is back", rep["errorCode"].as_str().unwrap_or("?"))+            }+        },+        Camera::Skip(s) => {+            r.log("clip-gap", json!({"step": tag, "from": died_at, "to": Value::Null, "why": why, "restart": s}));+            format!("clip {tag} died ({why}); not restarted: {s}")+        }+    };+    Some(line)+}++// -- truncated files --------------------------------------------------------------------------++/// The verdict from ffprobe's run on a clip: Some(why) when the MP4 is truncated (no moov atom, or+/// ffprobe cannot read the container at all).+pub fn truncation_verdict(ffprobe_ok: bool, stderr: &str, duration: Option<f64>) -> Option<String> {+    let e = stderr.to_ascii_lowercase();+    if e.contains("moov atom not found") { return Some("truncated: the MP4 has no moov atom (the recording died before it was finalised)".into()); }+    if !ffprobe_ok && (e.contains("invalid data found") || e.contains("end of file")) { return Some(format!("truncated: ffprobe cannot read the container ({})", stderr.lines().next().unwrap_or("").trim())); }+    if ffprobe_ok && duration.is_none() && e.contains("error") { return Some("truncated: no duration in the container".into()); }+    None+}++/// Run ffprobe on a clip: Some(why) when it is truncated. A missing ffprobe is not a verdict.+pub fn truncated(file: &str) -> Option<String> {+    let o = std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", file]).output().ok()?;+    let d = String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok();+    truncation_verdict(o.status.success(), &String::from_utf8_lossy(&o.stderr), d)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn wins() -> Vec<Value> {+        vec![+            json!({"hwnd": 11, "image": "kicad.exe", "kind": "normal", "title": "pcbnew"}),+            json!({"hwnd": 22, "image": "WindowsTerminal.exe", "kind": "normal", "title": "PowerShell"}),+            json!({"hwnd": 33, "image": "Antigravity.exe", "kind": "normal", "title": "board - Antigravity"}),+            json!({"hwnd": 44, "image": "hydrogen.exe", "kind": "normal", "title": "Hydrogen"}),+        ]+    }++    #[test]+    fn preboard_steps_film_hydrogen_then_the_ai_window_never_an_idle_editor() {+        assert!(preboard_step("sourcing") && preboard_step("schematic") && preboard_step("parts") && !preboard_step("placement"));+        assert_eq!(preboard_camera("design", &wins(), "antigravity", None), Camera::Window { hwnd: 44, what: "Hydrogen with the run's dashboard".into() });+        // no Hydrogen: the engine's own window before any other terminal+        let no_h: Vec<Value> = wins().into_iter().filter(|w| w["hwnd"] != 44).collect();+        match preboard_camera("design", &no_h, "Antigravity (Gemini 4 Argon)", None) { Camera::Window { hwnd, .. } => assert_eq!(hwnd, 33), c => panic!("{c:?}") }+        match preboard_camera("design", &no_h, "codex", None) { Camera::Window { hwnd, .. } => assert_eq!(hwnd, 22), c => panic!("{c:?}") }+        // only the PCB editor (nothing of the step's work on it): skipped with the reason+        let only_kicad = vec![wins()[0].clone()];+        match preboard_camera("simulation", &only_kicad, "claude", None) { Camera::Skip(why) => assert!(why.contains("simulation") && why.contains("skipped"), "{why}"), c => panic!("{c:?}") }+        // a tool step with the tool's window open films the tool+        assert_eq!(preboard_camera("schematic", &wins(), "claude", Some((55, "KiCad's schematic editor".into()))), Camera::Window { hwnd: 55, what: "KiCad's schematic editor".into() });+        // minimised windows do not count; the wrapped reply form is read+        let wrapped = json!({"output": json!({"data": {"windows": [{"hwnd": 44, "image": "hydrogen.exe", "kind": "normal", "minimized": true}]}}).to_string()});+        assert!(matches!(preboard_camera("rules", &windows_of(&wrapped), "claude", None), Camera::Skip(_)));+    }++    #[test]+    fn the_schematic_editor_is_the_tool_window_of_the_schematic_step() {+        let info = json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}, {"kind": "schematic_editor", "hwnd": 2}]}});+        assert_eq!(tool_window("schematic", "kicad", &info, None), Some((2, "KiCad's schematic editor".into())));+        assert_eq!(tool_window("schematic", "kicad", &json!({"data": {"editors": [{"kind": "pcb_editor", "hwnd": 1}]}}), None), None);+        assert_eq!(tool_window("design", "kicad", &info, None), None);+        assert_eq!(tool_window("library", "fusion", &Value::Null, Some(9)), Some((9, "the fusion window".into())));+    }++    #[test]+    fn dead_takes_are_told_from_running_and_unknown_answers() {+        assert!(dead_reason(&json!({"success": true, "state": "recording", "stillRunning": true, "lastFrameAgoMs": 30000})).is_none());+        assert!(dead_reason(&json!({"success": true, "data": {"state": "recording", "captureItemClosed": true}})).unwrap().contains("went away"));+        assert!(dead_reason(&json!({"success": false, "errorCode": "recording_not_found"})).unwrap().contains("no longer knows"));+        assert!(dead_reason(&json!({"success": false, "error": "No such recording: abc"})).is_some());+        assert!(dead_reason(&json!({"success": true, "state": "failed", "finaliseError": "sink error"})).unwrap().contains("sink error"));+        assert!(dead_reason(&json!({"success": true, "autoStopped": true})).is_some());+        // a transport failure or an unrelated refusal is not a dead take+        assert!(dead_reason(&json!({"status": "parse_error", "raw": ""})).is_none());+        assert!(dead_reason(&json!({"success": false, "errorCode": "permission_denied"})).is_none());+        assert!(dead_reason(&Value::Null).is_none());+    }++    #[test]+    fn the_watch_is_throttled_and_a_resumed_take_carries_its_gap() {+        assert!(due(&json!({}), 1000));+        assert!(!due(&json!({"clipWatch": {"lastCheck": 990}}), 1000));+        assert!(due(&json!({"clipWatch": {"lastCheck": 900}}), 1000));+        let dead = json!({"recordingId": "a", "step": "silkscreen", "visit": 2, "back": true, "why": "labels", "file": "x.mp4"});+        let e = resumed_entry(&dead, "b", 77, "KiCad's window", "2026-10-15T10:00:00Z", "2026-10-15T10:01:00Z", "the bridge restarted");+        assert_eq!((e["step"].as_str(), e["visit"].as_u64(), e["part"].as_u64()), (Some("silkscreen"), Some(2), Some(2)));+        assert_eq!(e["resumedAfterGap"]["why"], "the bridge restarted");+        assert!(e.get("file").is_none(), "a resumed take is running: no file yet");+        let data = json!({"captures": [dead, e]});+        assert_eq!(running(&data), Some((1, "b".to_string())));+    }++    #[test]+    fn a_missing_moov_atom_is_a_truncated_clip() {+        assert!(truncation_verdict(false, "[mov,mp4,m4a,3gp,3g2,mj2 @ 0x55] moov atom not found\nx.mp4: Invalid data found when processing input", None).unwrap().contains("moov"));+        assert!(truncation_verdict(false, "x.mp4: Invalid data found when processing input", None).unwrap().starts_with("truncated"));+        assert!(truncation_verdict(true, "", Some(12.5)).is_none());+        // a file that is not there is not called truncated (the pull check says so)+        assert!(truncation_verdict(false, "x.mp4: No such file or directory", None).is_none());+    }++    #[cfg(unix)]+    #[test]+    fn ffprobe_reads_a_cut_off_mp4_as_truncated() {+        if std::process::Command::new("ffmpeg").arg("-version").output().is_err() { return; }+        let dir = std::env::temp_dir().join(format!("aiflow-trunc-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let f = dir.join("c.mp4");+        let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc=size=160x90:rate=10", "-t", "2", "-pix_fmt", "yuv420p", f.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+        if !ok { return; }+        assert!(truncated(f.to_str().unwrap()).is_none());+        // cut the file before its moov atom (written at the end by default): the shape of a take that died+        let bytes = std::fs::read(&f).unwrap();+        let cut = dir.join("cut.mp4");+        let moov = bytes.windows(4).position(|w| w == b"moov").unwrap_or(bytes.len() / 2);+        std::fs::write(&cut, &bytes[..moov.saturating_sub(4)]).unwrap();+        assert!(truncated(cut.to_str().unwrap()).is_some());+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/continuation.rs+6−3
@@ -128,17 +128,20 @@ pub fn assess(data: &Value, pending: &[String], stop: Option<Result<Value, Strin             ));         }     }+    if crate::copper_carry::silk_dropped(data) {+        remaining.push("invalidated evidence: silkscreen (adopt-board changed the board file; silkscreen-audit --record again)".into());+    }     if data["outcomes"]["place-land"]["mode"]=="verified-existing" {         let board=data["boards"].as_array().and_then(|a|a.last()).and_then(Value::as_str).unwrap_or("");         let spec=data["spec"].as_str().unwrap_or("");-        if !crate::placement_verify::bound(&data["outcomes"]["place-land"],std::path::Path::new(board),std::path::Path::new(spec)) {+        if crate::copper_carry::placement_bound(data,&data["outcomes"]["place-land"],std::path::Path::new(board),std::path::Path::new(spec)).is_err() {             remaining.push("place verify: board or spec binding changed; verify again".into());         }     }-    if data["outcomes"]["land-pours"]["mode"]=="verified-existing" {+    if crate::pourverify::is_existing(&data["outcomes"]["land-pours"]) {         let board=data["boards"].as_array().and_then(|a|a.last()).and_then(Value::as_str).unwrap_or("");         let spec=data["spec"].as_str().unwrap_or("");-        if !crate::pourverify::bound(&data["outcomes"]["land-pours"],std::path::Path::new(board),std::path::Path::new(spec)) {+        if crate::copper_carry::pours_bound(data,&data["outcomes"]["land-pours"],std::path::Path::new(board),std::path::Path::new(spec)).is_err() {             remaining.push("verify-pours: board, spec or plan binding changed; verify again".into());         }     }
crates/adom-aiflow/src/copper_carry.rsadded+456
@@ -0,0 +1,456 @@+//! Copper fingerprint and carried-over copper evidence (issue #48 item 4).+//!+//! `adopt-board` normally voids the copper evidence (gate, land, measure, analyses, the Fields+//! report) because that evidence is bound to the whole board file. A silkscreen-only rework does+//! not change any copper, so re-solving Fields for it wastes the run's time. The copper fingerprint+//! is a canonical hash of everything electrically or thermally relevant in a `.kicad_pcb` plus its+//! project rule sidecars; text, silkscreen, fab, courtyard and 3D model references are left out.+//!+//! When the adopted board's fingerprint equals the current board's, the evidence is kept and a+//! carry record (both whole-board hashes, both files, the fingerprint) is stored in the run. A check+//! that binds evidence to a whole-board hash accepts an older hash only through `carried_from`,+//! which re-reads both snapshot files and recomputes their hashes and fingerprints; a missing,+//! edited or mismatching file refuses. Anything uncertain (an EAGLE source, an unreadable rule+//! file, a parse error) has no fingerprint, so the evidence is invalidated as before.+use aiflow_board::sx::{self, Item, Node};+use aiflow_run::Run;+use serde_json::{json, Value};+use sha2::{Digest, Sha256};+use std::path::Path;++pub const SCHEME: &str = "copper-fp-v1";++/// Outcomes bound to the board's silkscreen: re-done after any adoption that changes the file.+pub const SILK_OUTCOMES: [&str; 2] = ["silkscreen", "export"];++pub fn sha256_file(p: &Path) -> Result<String, String> {+    std::fs::read(p).map(|b| format!("{:x}", Sha256::digest(b))).map_err(|e| format!("{}: {e}", p.display()))+}++/// Footprint children and top-level items that never carry copper, whatever their layer.+const NEVER_COPPER: [&str; 13] = ["model", "descr", "tags", "path", "sheetname", "sheetfile", "embedded_fonts", "embedded_files", "group", "generator", "generator_version", "paper", "title_block"];+/// Items whose relevance depends on their layer (copper or the board edge count).+const LAYERED: [&str; 7] = ["property", "dimension", "image", "target", "table", "barcode", "text"];++fn atoms(n: &Node) -> impl Iterator<Item = &str> {+    n.items.iter().filter_map(|it| if let Item::Atom { text, .. } = it { Some(text.as_str()) } else { None })+}++/// The layers an item names (`(layer ..)` and `(layers ..)`); None when it names none.+fn item_layers(n: &Node) -> Option<Vec<String>> {+    let mut out: Vec<String> = Vec::new();+    if let Some(l) = n.find("layer") { out.extend(atoms(l).take(1).map(str::to_string)); }+    if let Some(l) = n.find("layers") { out.extend(atoms(l).map(str::to_string)); }+    if out.is_empty() { None } else { Some(out) }+}++fn electrical_layer(l: &str) -> bool { l.ends_with(".Cu") || l == "Edge.Cuts" }++/// Graphic or text-like items count only on copper or the board edge; one with no layer counts.+fn layered_relevant(n: &Node) -> bool {+    match item_layers(n) { Some(ls) => ls.iter().any(|l| electrical_layer(l)), None => true }+}++fn graphic(tag: &str) -> bool { tag.starts_with("gr_") || tag.starts_with("fp_") || LAYERED.contains(&tag) }++fn canon(n: &Node) -> Value {+    let mut v = vec![json!(n.tag)];+    for it in &n.items {+        match it { Item::Atom { text, .. } => v.push(json!(text)), Item::Node(c) => v.push(canon(c)) }+    }+    Value::Array(v)+}++fn canon_without(n: &Node, drop: &[&str]) -> Value {+    let mut v = vec![json!(n.tag)];+    for it in &n.items {+        match it {+            Item::Atom { text, .. } => v.push(json!(text)),+            Item::Node(c) if drop.contains(&c.tag.as_str()) => {}+            Item::Node(c) => v.push(canon(c)),+        }+    }+    Value::Array(v)+}++fn footprint(fp: &Node) -> Value {+    let mut parts: Vec<String> = Vec::new();+    for it in &fp.items {+        let Item::Node(c) = it else { continue };+        let t = c.tag.as_str();+        if NEVER_COPPER.contains(&t) { continue; }+        if graphic(t) && !layered_relevant(c) { continue; }+        parts.push(canon(c).to_string());+    }+    parts.sort();+    let head: Vec<&str> = atoms(fp).collect();+    json!(["footprint", head, aiflow_board::reference_of(fp), parts])+}++/// The project rules that change DRC: the whole `.kicad_dru`, and the `.kicad_pro`'s design+/// settings (less the defaults for new items) and net settings (net classes).+fn rules_part(board: &Path) -> Result<Value, String> {+    let dru = board.with_extension("kicad_dru");+    let dru_v = if dru.is_file() { json!(sha256_file(&dru)?) } else { Value::Null };+    let pro = board.with_extension("kicad_pro");+    let pro_v = if pro.is_file() {+        let t = std::fs::read_to_string(&pro).map_err(|e| format!("{}: {e}", pro.display()))?;+        let v: Value = serde_json::from_str(&t).map_err(|e| format!("{}: {e}", pro.display()))?;+        let mut ds = v["board"]["design_settings"].clone();+        if let Some(o) = ds.as_object_mut() { o.remove("defaults"); }+        json!({"designSettings": ds, "netSettings": v["net_settings"]})+    } else { Value::Null };+    Ok(json!({"kicad_dru": dru_v, "kicad_pro": pro_v}))+}++/// The canonical text the fingerprint hashes (exposed for tests and review).+pub fn canonical_text(text: &str) -> Result<String, String> {+    if aiflow_board::eagle::is_eagle(text) { return Err("an EAGLE source has no copper fingerprint".into()); }+    let root = sx::parse(text)?;+    if root.tag != "kicad_pcb" { return Err("not a kicad_pcb".into()); }+    let mut items: Vec<String> = Vec::new();+    for it in &root.items {+        match it {+            Item::Atom { text, .. } => items.push(json!(["atom", text]).to_string()),+            Item::Node(n) => {+                let t = n.tag.as_str();+                // everything not named here stays in (the format version, layers, nets, tracks, zones)+                if NEVER_COPPER.contains(&t) { continue; }+                match t {+                    "footprint" => items.push(footprint(n).to_string()),+                    "setup" => items.push(canon_without(n, &["pcbplotparams"]).to_string()),+                    // board-level text variables feed text, never copper+                    "property" if item_layers(n).is_none() => {}+                    _ if graphic(t) => { if layered_relevant(n) { items.push(canon(n).to_string()); } }+                    _ => items.push(canon(n).to_string()),+                }+            }+        }+    }+    items.sort();+    Ok(items.join("\n"))+}++/// The copper fingerprint of a saved board and its project rule files.+pub fn fingerprint(board: &Path) -> Result<String, String> {+    let text = std::fs::read_to_string(board).map_err(|e| format!("{}: {e}", board.display()))?;+    let body = canonical_text(&text)?;+    let rules = rules_part(board)?;+    let mut h = Sha256::new();+    h.update(SCHEME.as_bytes());+    h.update(b"\0");+    h.update(body.as_bytes());+    h.update(b"\0");+    h.update(rules.to_string().as_bytes());+    Ok(format!("{:x}", h.finalize()))+}++/// A carry record when `new` has the same copper fingerprint as `old`; otherwise why not.+pub fn carry_record(old: &Path, new: &Path) -> Result<Value, String> {+    let a = fingerprint(old).map_err(|e| format!("current board: {e}"))?;+    let b = fingerprint(new).map_err(|e| format!("adopted board: {e}"))?;+    if a != b { return Err("copper changed (tracks, vias, zones, pads, footprint placement, outline, stackup, nets or rules)".into()); }+    Ok(json!({"scheme": SCHEME, "fromBoard": old.display().to_string(), "fromSha256": sha256_file(old)?,+        "toBoard": new.display().to_string(), "toSha256": sha256_file(new)?, "copperFingerprint": a, "at": aiflow_run::now()}))+}++/// Adopt `dest` as the run's board: keep the copper evidence when the fingerprint is unchanged,+/// otherwise invalidate it as before. Silkscreen and export proofs for another file are always+/// dropped. Returns (carried, a sentence for the user).+pub fn adopt(r: &mut Run, dest: &str) -> (bool, String) {+    let old = r.current_board();+    let dest_sha = sha256_file(Path::new(dest)).ok();+    let decision = if old.is_empty() { Err("no current board".to_string()) } else { carry_record(Path::new(&old), Path::new(dest)) };+    let (carried, note) = match decision {+        Ok(rec) => {+            let fp = rec["copperFingerprint"].as_str().unwrap_or("").to_string();+            r.add_board_carrying(dest, Some(rec));+            (true, format!("copper unchanged (fingerprint {}): gate, land, measure and analyses evidence (Fields included) carried over, bound to both board hashes", &fp[..12.min(fp.len())]))+        }+        Err(why) => {+            r.log("copper-carry-refused", json!({"newBoard": dest, "why": why}));+            r.add_board(dest);+            (false, format!("copper evidence invalidated ({why})"))+        }+    };+    let mut dropped = Vec::new();+    if let Some(o) = r.data["outcomes"].as_object_mut() {+        for k in SILK_OUTCOMES {+            if o.get(k).is_some_and(|v| v["boardSha256"].as_str().is_none() || v["boardSha256"].as_str() != dest_sha.as_deref()) {+                o.remove(k);+                dropped.push(k);+            }+        }+    }+    if !dropped.is_empty() {+        r.log("board-evidence-invalidated", json!({"newBoard": dest, "outcomes": dropped, "reason": "silkscreen and export proofs belong to one board file"}));+        return (carried, format!("{note}; {} must be recorded again on this board", dropped.join(" and ")));+    }+    (carried, note)+}++/// Accept evidence recorded on board hash `recorded` for the board file `current` through the+/// run's carry records: a chain of adoptions, each re-verified from its snapshot files, from the+/// recorded board to the current one with one unchanged copper fingerprint. Ok(chain) or why not.+pub fn carried_from(data: &Value, recorded: &str, current: &Path) -> Result<Vec<Value>, String> {+    let cur = sha256_file(current)?;+    if cur == recorded { return Ok(Vec::new()); }+    let records = data["copperCarry"].as_array().cloned().unwrap_or_default();+    if records.is_empty() { return Err("no copper carry-over is recorded".into()); }+    let fp = fingerprint(current)?;+    let boards: Vec<String> = data["boards"].as_array().map(|a| a.iter().filter_map(|b| b.as_str().map(str::to_string)).collect()).unwrap_or_default();+    let check = |file: &Value, sha: &Value| -> Result<(), String> {+        let f = file.as_str().ok_or("carry record without a board file")?;+        if !boards.iter().any(|b| b == f) { return Err(format!("{f} is not one of the run's board snapshots")); }+        let p = Path::new(f);+        if sha.as_str() != Some(sha256_file(p)?.as_str()) { return Err(format!("{f} changed since it was adopted")); }+        if fingerprint(p)? != fp { return Err(format!("{f} copper fingerprint differs")); }+        Ok(())+    };+    let mut target = cur;+    let mut chain = Vec::new();+    for _ in 0..records.len() {+        let rec = records.iter().rev().find(|r| r["scheme"] == SCHEME && r["toSha256"].as_str() == Some(target.as_str()) && r["copperFingerprint"].as_str() == Some(fp.as_str()))+            .ok_or_else(|| "no carry record with this board's copper fingerprint reaches the recorded board".to_string())?;+        check(&rec["toBoard"], &rec["toSha256"])?;+        check(&rec["fromBoard"], &rec["fromSha256"])?;+        chain.push(rec.clone());+        let from = rec["fromSha256"].as_str().unwrap_or("").to_string();+        if from == recorded { return Ok(chain); }+        target = from;+    }+    Err("the carry records do not reach the recorded board".into())+}++fn chain_note(what: &str, chain: &[Value]) -> String {+    let fp = chain.first().and_then(|c| c["copperFingerprint"].as_str()).unwrap_or("");+    format!("{what}: carried over from board {} (copper fingerprint {} unchanged across {} adoption(s))",+        chain.last().and_then(|c| c["fromBoard"].as_str()).unwrap_or("?"), &fp[..12.min(fp.len())], chain.len())+}++/// `pourverify::bound` that also accepts a verified-existing proof carried over a copper-unchanged+/// adoption. Ok(Some(note)) when it was carried, Ok(None) when bound directly, Err when not bound.+pub fn pours_bound(data: &Value, outcome: &Value, board: &Path, spec: &Path) -> Result<Option<String>, String> {+    if crate::pourverify::bound(outcome, board, spec) { return Ok(None); }+    let recorded = outcome["boardSha256"].as_str().ok_or("verify-pours proof has no board hash")?;+    let chain = carried_from(data, recorded, board)?;+    let mut o = outcome.clone();+    o["boardSha256"] = json!(sha256_file(board)?);+    if crate::pourverify::bound(&o, board, spec) { Ok(Some(chain_note("verify-pours", &chain))) } else { Err("spec or plan changed".into()) }+}++/// `placement_verify::bound` that also accepts a verified-existing placement proof carried over a+/// copper-unchanged adoption (footprint placement is part of the fingerprint).+pub fn placement_bound(data: &Value, proof: &Value, board: &Path, spec: &Path) -> Result<Option<String>, String> {+    if crate::placement_verify::bound(proof, board, spec) { return Ok(None); }+    let recorded = proof["boardSha256"].as_str().ok_or("place verify proof has no board hash")?;+    let chain = carried_from(data, recorded, board)?;+    let mut p = proof.clone();+    p["boardSha256"] = json!(sha256_file(board)?);+    if crate::placement_verify::bound(&p, board, spec) { Ok(Some(chain_note("place verify", &chain))) } else { Err("spec changed or the proof did not pass".into()) }+}++/// `aiflow_analyze::field_screen` on the board file `board`, also accepting a Fields report solved+/// on a board whose copper was carried over. Ok(Some(note)) when carried.+pub fn field_screen(data: &Value, report: &Value, spec: &Value, board: &Path, spec_bytes: &[u8], kind: &str) -> Result<Option<String>, String> {+    field_screen_with_acks(data, report, spec, board, spec_bytes, kind, &[]).map(|(n, _)| n)+}++/// `field_screen` with live current-peak acknowledgements (issue #70); also returns the covered points.+pub fn field_screen_with_acks(data: &Value, report: &Value, spec: &Value, board: &Path, spec_bytes: &[u8], kind: &str, acks: &[Value]) -> Result<(Option<String>, Vec<Value>), String> {+    let board_bytes = std::fs::read(board).unwrap_or_default();+    let cur = format!("{:x}", Sha256::digest(&board_bytes));+    let recorded = report["inputIdentity"]["board"].as_str().unwrap_or("");+    let screen = |r: &Value| aiflow_analyze::field_screen_with_acks(r, spec, &board_bytes, spec_bytes, kind, acks);+    if recorded.is_empty() || recorded == cur { return screen(report).map(|c| (None, c)); }+    let chain = match carried_from(data, recorded, board) {+        Ok(c) => c,+        // not carried: the plain screen reports the stale identity+        Err(why) => return screen(report).map(|c| (None, c)).map_err(|e| format!("{e} (not carried over: {why})")),+    };+    let mut r = report.clone();+    r["inputIdentity"]["board"] = json!(cur);+    screen(&r).map(|c| (Some(chain_note(&format!("Fields {kind} report"), &chain)), c))+}++/// A silkscreen proof was recorded and then dropped by an adoption: finish needs it again.+pub fn silk_dropped(data: &Value) -> bool {+    !data["outcomeAt"]["silkscreen"].is_null() && data["outcomes"].get("silkscreen").is_none()+}++#[cfg(test)]+mod tests {+    use super::*;++    const HEAD: &str = "(kicad_pcb (version 20260206) (generator \"pcbnew\") (general (thickness 1.6))\n (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (5 \"F.SilkS\" user) (25 \"Edge.Cuts\" user))\n (setup (stackup (layer \"F.Cu\" (type \"copper\") (thickness 0.035))) (pcbplotparams (outputdirectory \"a\")))\n (net 0 \"\") (net 1 \"GND\") (net 2 \"VIN\")\n";+    fn fp(silk_at: &str, model: &str) -> String {+        format!(" (footprint \"R:R0603\" (layer \"F.Cu\") (uuid \"f1\") (at 10 10 90)\n  (property \"Reference\" \"R1\" (at {silk_at}) (layer \"F.SilkS\"))\n  (property \"Value\" \"10k\" (at 0 1) (layer \"F.Fab\"))\n  (fp_line (start -1 -1) (end 1 -1) (layer \"F.SilkS\") (stroke (width 0.12)))\n  (fp_rect (start -1.5 -1) (end 1.5 1) (layer \"F.CrtYd\"))\n  (pad \"1\" smd roundrect (at -0.8 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net 1 \"GND\") (uuid \"p1\"))\n  (pad \"2\" smd roundrect (at 0.8 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net 2 \"VIN\") (uuid \"p2\"))\n  (model \"{model}\"))\n")+    }+    const COPPER: &str = " (segment (start 1 1) (end 5 1) (width 0.25) (layer \"F.Cu\") (net 1) (uuid \"s1\"))\n (via (at 5 1) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1) (uuid \"v1\"))\n (zone (net 1) (net_name \"GND\") (layer \"B.Cu\") (uuid \"z1\") (priority 1) (connect_pads (clearance 0.2)) (min_thickness 0.2) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20))))\n (gr_rect (start 0 0) (end 20 20) (layer \"Edge.Cuts\"))\n";+    fn board(silk: &str, fp_silk_at: &str, model: &str, copper: &str) -> String {+        format!("{HEAD}{}{copper}{silk})\n", fp(fp_silk_at, model))+    }+    fn fpr(text: &str) -> String { canonical_text(text).unwrap() }++    #[test]+    fn silk_fab_courtyard_text_and_models_are_outside_the_fingerprint() {+        let a = board("", "0 -1.5", "a.step", COPPER);+        let b = board(" (gr_text \"HELLO\" (at 3 3) (layer \"F.SilkS\"))\n (gr_line (start 0 0) (end 3 3) (layer \"F.SilkS\"))\n", "0 1.5 90", "b.step", COPPER)+            .replace("(outputdirectory \"a\")", "(outputdirectory \"gerbers\")").replace("\"10k\"", "\"10k 1%\"");+        assert_eq!(fpr(&a), fpr(&b));+        // order of items does not matter either+        let swapped = a.replace(COPPER, &COPPER.lines().rev().map(|l| format!("{l}\n")).collect::<String>());+        assert_eq!(fpr(&a), fpr(&swapped));+    }++    #[test]+    fn any_copper_change_changes_the_fingerprint() {+        let a = fpr(&board("", "0 -1.5", "a.step", COPPER));+        let changes = [+            ("(at 5 1) (size 0.6)", "(at 5.1 1) (size 0.6)"),           // a moved via+            ("(end 5 1) (width 0.25)", "(end 5 1.2) (width 0.25)"),    // a moved track+            ("(xy 20 20) (xy 0 20)", "(xy 20 19) (xy 0 20)"),           // a zone outline+            ("(priority 1)", "(priority 2)"),                          // zone priority+            ("(thermal_gap 0.5)", "(thermal_gap 0.3)"),                // zone fill settings+            ("(at -0.8 0 90) (size 0.8 0.9)", "(at -0.9 0 90) (size 0.8 0.9)"), // a moved pad+            ("(size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net 1", "(size 0.8 1.0) (layers \"F.Cu\" \"F.Mask\") (net 1"), // pad size+            ("(net 2 \"VIN\") (uuid \"p2\")", "(net 1 \"GND\") (uuid \"p2\")"), // pad net+            ("(at 10 10 90)", "(at 10 10 0)"),                         // footprint rotation+            ("(footprint \"R:R0603\" (layer \"F.Cu\")", "(footprint \"R:R0603\" (layer \"B.Cu\")"), // side+            ("(end 20 20) (layer \"Edge.Cuts\")", "(end 20 21) (layer \"Edge.Cuts\")"), // outline+            ("(thickness 0.035)", "(thickness 0.07)"),                 // copper weight+            ("(net 2 \"VIN\")\n", "(net 2 \"VBUS\")\n"),               // nets+        ];+        for (from, to) in changes {+            let b = board("", "0 -1.5", "a.step", COPPER);+            assert!(b.contains(from), "{from}");+            assert_ne!(a, fpr(&b.replacen(from, to, 1)), "{from} -> {to} must change the fingerprint");+        }+        // copper text and copper graphics count+        assert_ne!(a, fpr(&board(" (gr_text \"X\" (at 3 3) (layer \"F.Cu\"))\n", "0 -1.5", "a.step", COPPER)));+        assert!(canonical_text("<eagle></eagle>").is_err());+    }++    fn tmp(name: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("aiflow-copper-carry-{name}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn rules_sidecars_are_part_of_the_fingerprint() {+        let d = tmp("rules");+        let (a, b) = (d.join("a.kicad_pcb"), d.join("b.kicad_pcb"));+        let text = board("", "0 -1.5", "a.step", COPPER);+        std::fs::write(&a, &text).unwrap();+        std::fs::write(&b, &text).unwrap();+        assert_eq!(fingerprint(&a).unwrap(), fingerprint(&b).unwrap());+        std::fs::write(b.with_extension("kicad_dru"), "(version 1)").unwrap();+        assert!(carry_record(&a, &b).is_err());+        std::fs::write(a.with_extension("kicad_dru"), "(version 1)").unwrap();+        std::fs::write(a.with_extension("kicad_pro"), r#"{"board":{"design_settings":{"defaults":{"silk_text_size_h":1.0},"rules":{"min_clearance":0.2}}},"net_settings":{}}"#).unwrap();+        std::fs::write(b.with_extension("kicad_pro"), r#"{"board":{"design_settings":{"defaults":{"silk_text_size_h":0.8},"rules":{"min_clearance":0.2}},"viewports":[1]},"net_settings":{}}"#).unwrap();+        assert!(carry_record(&a, &b).is_ok(), "silk defaults and viewports are not copper");+        std::fs::write(b.with_extension("kicad_pro"), r#"{"board":{"design_settings":{"rules":{"min_clearance":0.15}}},"net_settings":{}}"#).unwrap();+        assert!(carry_record(&a, &b).is_err(), "a clearance rule is");+        std::fs::write(b.with_extension("kicad_pro"), "not json").unwrap();+        assert!(carry_record(&a, &b).is_err(), "an unreadable project file is uncertain: invalidate");+        let _ = std::fs::remove_dir_all(d);+    }++    /// A run with board-0, Fields/pours/gate evidence recorded on it, and a spec and plan.+    fn run_with_evidence(d: &Path) -> (Run, std::path::PathBuf, std::path::PathBuf, Value, Value) {+        let b0 = d.join("board-0.kicad_pcb");+        std::fs::write(&b0, board("", "0 -1.5", "a.step", COPPER)).unwrap();+        let spec = d.join("spec.json");+        std::fs::write(&spec, "{}").unwrap();+        let plan = d.join("pours.json");+        std::fs::write(&plan, "{\"zones\":[]}").unwrap();+        let b0sha = sha256_file(&b0).unwrap();+        let pours = json!({"mode":"verified-existing","pass":true,"failed":[],"boardSha256":b0sha,"specSha256":sha256_file(&spec).unwrap(),"plan":plan,"planSha256":sha256_file(&plan).unwrap()});+        let fields = json!({"inputIdentity":{"board":b0sha,"spec":format!("{:x}",Sha256::digest(b"{}"))}});+        let mut outcomes = serde_json::Map::new();+        for k in Run::COPPER_OUTCOMES { outcomes.insert(k.into(), json!({"pass": true})); }+        outcomes.insert("land-pours".into(), pours.clone());+        outcomes.insert("silkscreen".into(), json!({"ok": true, "boardSha256": b0sha}));+        let r = Run { dir: d.to_path_buf(), data: json!({"boards":[b0], "spec": spec, "outcomes": outcomes, "outcomeAt": {"silkscreen": "2026-10-15T00:00:00Z"}}) };+        (r, b0, spec, pours, fields)+    }++    #[test]+    fn silk_only_adoption_keeps_copper_evidence_and_finish_checks_accept_it() {+        let d = tmp("silk");+        let (mut r, _b0, spec, pours, fields) = run_with_evidence(&d);+        let b1 = d.join("board-1.kicad_pcb");+        std::fs::write(&b1, board(" (gr_text \"REV B\" (at 3 3) (layer \"F.SilkS\"))\n", "0 1.5 90", "a.step", COPPER)).unwrap();+        let (carried, note) = adopt(&mut r, &b1.display().to_string());+        assert!(carried, "{note}");+        for k in Run::COPPER_OUTCOMES { assert!(r.data["outcomes"].get(k).is_some(), "{k} kept"); }+        assert!(r.data["outcomes"].get("silkscreen").is_none(), "silk proof must be re-done");+        assert!(silk_dropped(&r.data));+        let rec = &r.data["copperCarry"][0];+        assert_eq!(rec["toSha256"].as_str().unwrap(), sha256_file(&b1).unwrap());+        assert!(rec["fromSha256"].is_string() && rec["copperFingerprint"].is_string());+        // the hash-bound checks accept the carried evidence, and say so+        assert!(pours_bound(&r.data, &pours, &b1, &spec).unwrap().unwrap().contains("carried over"));+        let mut f = fields.clone();+        f["nets"] = json!([]);+        f["issues"] = json!([]);+        f["peakJDefinition"] = json!({"statistic":"unclipped-cell-maximum","rounded":false,"minimumCopperFraction":0.25});+        assert!(field_screen(&r.data, &f, &json!({}), &b1, b"{}", "current").unwrap().unwrap().contains("carried over"));+        // a second silk-only adoption chains back to board-0+        let b2 = d.join("board-2.kicad_pcb");+        std::fs::write(&b2, board(" (gr_text \"REV C\" (at 4 3) (layer \"F.SilkS\"))\n", "0 1.5 90", "c.step", COPPER)).unwrap();+        assert!(adopt(&mut r, &b2.display().to_string()).0);+        assert_eq!(carried_from(&r.data, pours["boardSha256"].as_str().unwrap(), &b2).unwrap().len(), 2);+        assert!(pours_bound(&r.data, &pours, &b2, &spec).unwrap().is_some());+        let placed = json!({"ok": true, "mode": "verified-existing", "boardSha256": pours["boardSha256"], "specSha256": sha256_file(&spec).unwrap()});+        assert!(placement_bound(&r.data, &placed, &b2, &spec).unwrap().unwrap().contains("carried over"));+        let mut not_ok = placed.clone();+        not_ok["ok"] = json!(false);+        assert!(placement_bound(&r.data, &not_ok, &b2, &spec).is_err());+        let _ = std::fs::remove_dir_all(d);+    }++    #[test]+    fn copper_changes_invalidate_and_tampering_is_refused() {+        for (i, (from, to)) in [("(at 5 1) (size 0.6)", "(at 5.2 1) (size 0.6)"), ("(end 5 1) (width 0.25)", "(end 5 1.5) (width 0.25)"), ("(xy 20 20) (xy 0 20)", "(xy 18 20) (xy 0 20)"), ("(at 0.8 0 90) (size", "(at 0.9 0 90) (size")].iter().enumerate() {+            let d = tmp(&format!("moved{i}"));+            let (mut r, _b0, spec, pours, _) = run_with_evidence(&d);+            let b1 = d.join("board-1.kicad_pcb");+            let text = board("", "0 -1.5", "a.step", COPPER);+            assert!(text.contains(from));+            std::fs::write(&b1, text.replacen(from, to, 1)).unwrap();+            let (carried, note) = adopt(&mut r, &b1.display().to_string());+            assert!(!carried && note.contains("invalidated"), "{from}: {note}");+            for k in Run::COPPER_OUTCOMES { assert!(r.data["outcomes"].get(k).is_none(), "{k} voided"); }+            assert!(pours_bound(&r.data, &pours, &b1, &spec).is_err());+            let _ = std::fs::remove_dir_all(d);+        }+        // tampering: a forged record, an edited old snapshot, a forged fingerprint, a report for another board+        let d = tmp("tamper");+        let (mut r, b0, spec, pours, fields) = run_with_evidence(&d);+        let b1 = d.join("board-1.kicad_pcb");+        std::fs::write(&b1, board(" (gr_text \"REV B\" (at 3 3) (layer \"F.SilkS\"))\n", "0 -1.5", "a.step", COPPER)).unwrap();+        assert!(adopt(&mut r, &b1.display().to_string()).0);+        let good = r.data.clone();+        r.data["copperCarry"][0]["copperFingerprint"] = json!("0".repeat(64));+        assert!(pours_bound(&r.data, &pours, &b1, &spec).is_err(), "forged fingerprint");+        r.data = good.clone();+        r.data["copperCarry"][0]["fromSha256"] = json!("f".repeat(64));+        assert!(pours_bound(&r.data, &pours, &b1, &spec).is_err(), "forged hash");+        r.data = good.clone();+        r.data["copperCarry"] = json!([]);+        assert!(pours_bound(&r.data, &pours, &b1, &spec).is_err(), "no record");+        assert!(placement_bound(&r.data, &json!({"ok": true, "boardSha256": pours["boardSha256"], "specSha256": pours["specSha256"]}), &b1, &spec).is_err(), "no record, no placement carry");+        r.data = good.clone();+        let mut f = fields.clone();+        f["inputIdentity"]["board"] = json!("e".repeat(64));+        assert!(field_screen(&r.data, &f, &json!({}), &b1, b"{}", "current").unwrap_err().contains("identity"));+        // the old snapshot is edited after adoption: its copper no longer matches what the evidence saw+        std::fs::write(&b0, board("", "0 -1.5", "a.step", &COPPER.replace("(at 5 1)", "(at 6 1)"))).unwrap();+        assert!(pours_bound(&r.data, &pours, &b1, &spec).is_err(), "edited old snapshot");+        let _ = std::fs::remove_dir_all(d);+    }+}
crates/adom-aiflow/src/decoupling_check.rsadded+352
@@ -0,0 +1,352 @@+//! The decoupling acceptance review (spec "decoupling", issue #32), after `route` and in `gate`.+//!+//! Per declared pair it measures the routed copper path from the cap's supply pad to its assigned+//! pin on the board with the plan applied (this net's planned plus existing copper), apart from the+//! copper distance to the nearest same-net pin of that part and the straight pad distance, and checks+//! the pair's own budgets (`maxLengthMm`, `maxVias`) and whether the path runs through the rail tree.+//! Findings are warnings or errors per the pair's `severity`; without a budget the length is reported+//! only. Ground returns are judged by connectivity: a return joined only by planned or stale pours is+//! labelled unverified, never given a straight line as if it were a path.+//! The measurement itself is `aiflow_router::copper_path`; the router's pre-routing is+//! `aiflow_router::decoupling`.+use std::path::Path;++use aiflow_board::Board;+use aiflow_router::copper_path::{self, FillContext};+use aiflow_router::decoupling::{self, Pair};+use aiflow_run::Run;+use serde_json::{json, Value};++pub struct Review {+    pub value: Value,+    pub errors: u64,+    pub warnings: u64,+}++pub const HINT: &str = "Each pair's budget is this board's own (spec decoupling maxLengthMm / maxVias). Route again (the router lays each pair's direct connection before the rail), move the cap closer to its pin in placement, or change the budget with the datasheet's reason; a warning-severity pair is reported only.";++/// Refuse a spec whose decoupling entries do not parse or do not resolve on this board.+pub fn refuse_bad_spec(spec: &Value, board: &Board) {+    let pairs = match Pair::from_spec(spec) {+        Ok(p) => p,+        Err(e) => crate::err(&format!("spec.decoupling: {e}"), &[shape_hint()]),+    };+    let (_, problems) = decoupling::resolve(board, &pairs);+    if !problems.is_empty() {+        crate::err(&format!("spec.decoupling does not resolve on this board:\n  {}", problems.join("\n  ")), &[shape_hint()]);+    }+}++fn shape_hint() -> String {+    "Use [{\"cap\": \"C6\", \"pin\": \"U1.39\"}] with optional \"capPad\" (default: the cap pad on the pin's net), \"gndPad\", \"returnPins\": [\"U1.38\"], \"maxLengthMm\", \"maxVias\" and \"severity\": \"warning\" | \"error\". There is no default budget.".into()+}++/// Nets with pours planned by `pour` (plan-pours.json), whether or not they are on the board yet.+fn planned_pour_nets(pour_outcome: Option<&Value>) -> Vec<String> {+    let Some(p) = pour_outcome.and_then(|o| o["pours"].as_str()) else { return Vec::new() };+    let Ok(text) = std::fs::read_to_string(p) else { return Vec::new() };+    let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);+    let mut nets: Vec<String> = v["zones"].as_array().map(|a| a.iter().filter_map(|z| z["net"].as_str().map(str::to_string)).collect()).unwrap_or_default();+    nets.sort();+    nets.dedup();+    nets+}++/// Review the pairs on a board text that already carries the plan's copper.+/// `fills_current`: the zone fills in the text are a native refill of this copper.+pub fn review_text(stage: &str, spec: &Value, board_path: &str, text: &str, fills_current: bool, fill_source: &str, plan: &Value, router_pairs: Option<&Value>, pour_outcome: Option<&Value>) -> Option<Review> {+    let pairs = Pair::from_spec(spec).ok()?;+    if pairs.is_empty() {+        return None;+    }+    let board = Board::from_text(board_path, text.to_string()).ok()?;+    let (resolved, problems) = decoupling::resolve(&board, &pairs);+    let ctx = FillContext { fills_current, fill_source: fill_source.to_string(), planned_pour_nets: planned_pour_nets(pour_outcome) };+    let mut v = copper_path::review(&board, &resolved, &ctx);+    // what routed each pair: the router's own report (route), or the plan's labelled entries (gate)+    let in_plan: Vec<String> = plan["nets"].as_array().map(|a| a.iter().filter_map(|e| e["decoupling"].as_str().map(str::to_string)).collect()).unwrap_or_default();+    if let Some(entries) = v["entries"].as_array_mut() {+        for e in entries.iter_mut() {+            let label = e["pair"].as_str().unwrap_or("").to_string();+            e["directInPlan"] = json!(in_plan.contains(&label));+            if let Some(rp) = router_pairs.and_then(|r| r.as_array()).and_then(|a| a.iter().find(|x| x["pair"] == label.as_str())) {+                e["router"] = rp.clone();+            }+        }+    }+    v["stage"] = json!(stage);+    v["unresolved"] = json!(problems);+    let errors = v["errors"].as_u64().unwrap_or(0);+    let warnings = v["warnings"].as_u64().unwrap_or(0);+    Some(Review { value: v, errors, warnings })+}++/// After `route`: the plan applied to the board it was planned on (no zone refill).+pub fn review_route(spec: &Value, board: &Board, plan: &Value, router_report: &Value, pour_outcome: Option<&Value>) -> Option<Review> {+    Pair::from_spec(spec).ok().filter(|p| !p.is_empty())?;+    let empty = plan["nets"].as_array().map(|a| a.is_empty()).unwrap_or(true);+    let (text, _) = match aiflow_copper::apply(board, plan, false) {+        Ok(x) => x,+        Err(e) => {+            eprintln!("Warning: decoupling review skipped, the plan does not apply: {e}");+            return None;+        }+    };+    review_text("route", spec, &board.path, &text, empty, "the zone fills saved in the board file, before this plan's copper", plan, Some(&router_report["decoupling"]), pour_outcome)+}++/// One line per pair, then every finding.+pub fn lines(d: &Review) -> Vec<String> {+    let mut out = Vec::new();+    for e in d.value["entries"].as_array().cloned().unwrap_or_default() {+        let a = &e["assignedPin"];+        let path = if a["connected"] == true {+            format!("path {} mm, {} via(s){}{}", a["pathMm"], a["vias"], if a["detour"] == true { ", detours through the rail" } else { "" }, if a["throughFill"] == true { ", through a zone fill" } else { "" })+        } else {+            "not joined by copper yet".to_string()+        };+        let budget = match (e["budget"]["maxLengthMm"].as_f64(), e["budget"]["maxVias"].as_u64()) {+            (None, None) => "no budget (report only)".to_string(),+            (l, v) => format!("budget {}{}", l.map(|x| format!("{x} mm")).unwrap_or_default(), v.map(|x| format!(" {x} via(s)")).unwrap_or_default()),+        };+        let nearest = e["nearestSameNetPin"]["pin"].as_str().map(|p| format!("; nearest same-net pin {p} at {} mm", e["nearestSameNetPin"]["pathMm"])).unwrap_or_default();+        let routed = match (&e["router"]["direct"], e["directInPlan"].as_bool()) {+            (Value::Bool(true), _) | (_, Some(true)) => "; direct pair route".to_string(),+            (Value::Bool(false), _) => format!("; no direct pair route ({})", e["router"]["note"].as_str().unwrap_or("fell back to normal routing")),+            _ => String::new(),+        };+        let mut l = format!("decoupling {} ({}): {path}, straight {} mm (pad gap {} mm), {budget}{nearest}{routed}", e["pair"].as_str().unwrap_or(""), e["net"].as_str().unwrap_or(""), e["straightMm"], e["padGapMm"]);+        for r in e["groundReturn"]["returns"].as_array().cloned().unwrap_or_default() {+            l.push_str(&format!("; return {} {}{}", r["pin"].as_str().unwrap_or(""), r["status"].as_str().unwrap_or(""), r["pathMm"].as_f64().map(|x| format!(" {x} mm")).unwrap_or_default()));+        }+        out.push(l);+        for f in e["findings"].as_array().cloned().unwrap_or_default() {+            out.push(format!("  {}: {}", f["severity"].as_str().unwrap_or("warning"), f["text"].as_str().unwrap_or("")));+        }+    }+    for p in d.value["unresolved"].as_array().cloned().unwrap_or_default() {+        out.push(format!("  warning: {}", p.as_str().unwrap_or("")));+    }+    out+}++/// Write `<dir>/decoupling-<stage>.json`, record the run outcome `decoupling` and print the lines.+/// A spec without pairs clears an older outcome so it cannot block `finish`.+pub fn record(r: &mut Run, dir: &Path, d: &Option<Review>) {+    match d {+        Some(d) => {+            let stage = d.value["stage"].as_str().unwrap_or("route").to_string();+            let file = dir.join(format!("decoupling-{stage}.json"));+            std::fs::write(&file, serde_json::to_string_pretty(&d.value).unwrap_or_default()).ok();+            let mut o = json!({"stage": stage, "report": file.display().to_string(), "pairs": d.value["pairs"], "warnings": d.warnings, "errors": d.errors});+            o["entries"] = json!(d.value["entries"].as_array().map(|a| a.iter().map(|e| json!({"pair": e["pair"], "pathMm": e["assignedPin"]["pathMm"], "vias": e["assignedPin"]["vias"], "detour": e["assignedPin"]["detour"], "straightMm": e["straightMm"], "nearestSameNetPin": e["nearestSameNetPin"], "findings": e["findings"].as_array().map(|f| f.len()).unwrap_or(0)})).collect::<Vec<_>>()).unwrap_or_default());+            r.outcome("decoupling", o);+            for l in lines(d) {+                eprintln!("{l}");+            }+        }+        None => {+            if r.outcomes().contains_key("decoupling") {+                r.outcome("decoupling", json!({"pairs": 0, "warnings": 0, "errors": 0, "note": "the spec declares no decoupling pairs"}));+            }+        }+    }+}++/// The stage fails on an error-severity finding.+pub fn refuse_errors(stage: &str, d: &Option<Review>) {+    if let Some(d) = d {+        if d.errors > 0 {+            let first: Vec<String> = d.value["entries"].as_array().into_iter().flatten().flat_map(|e| e["findings"].as_array().cloned().unwrap_or_default()).filter(|f| f["severity"] == "error").filter_map(|f| f["text"].as_str().map(str::to_string)).take(6).collect();+            crate::err(&format!("{stage}: {} decoupling error(s):\n  {}", d.errors, first.join("\n  ")), &[HINT.into(), format!("The full review is {}.", d.value["stage"].as_str().map(|s| format!("decoupling-{s}.json in the run folder")).unwrap_or_default())]);+        }+    }+}++/// For `finish`: the last decoupling review has an error-severity finding.+pub fn blocks_finish(o: Option<&Value>) -> Option<String> {+    let o = o?;+    let n = o["errors"].as_u64().unwrap_or(0);+    if n > 0 { Some(format!("decoupling: {n} error-severity finding(s) in the last review ({}); see {}", o["stage"].as_str().unwrap_or("?"), o["report"].as_str().unwrap_or("the run folder"))) } else { None }+}++#[cfg(test)]+mod tests {+    use super::*;+    use aiflow_router::{drive, Router, Rules};++    /// A 24 x 12 mm two-layer board. `parts`: (reference, [(pad name, net, local x, local y)], at x, y).+    fn board(parts: &[(&str, Vec<(&str, &str, f64, f64)>, f64, f64)], extra: &str) -> Board {+        let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");+        for (r, pads, x, y) in parts {+            t.push_str(&format!("\t(footprint \"T:{r}\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n"));+            // a connector's pads are larger: the router starts each net's tree there+            let size = if r.starts_with('J') { 1.5 } else { 0.8 };+            for (name, net, px, py) in pads {+                t.push_str(&format!("\t\t(pad \"{name}\" smd rect\n\t\t\t(at {px} {py})\n\t\t\t(size {size} {size})\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"{net}\")\n\t\t)\n"));+            }+            t.push_str("\t)\n");+        }+        t.push_str(extra);+        t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 24 12)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");+        Board::from_text("test.kicad_pcb", t).unwrap()+    }++    fn seg(net: &str, a: (f64, f64), b: (f64, f64)) -> String {+        format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width 0.25)\n\t\t(layer \"F.Cu\")\n\t\t(net \"{net}\")\n\t)\n", a.0, a.1, b.0, b.1)+    }++    /// GND is a plane net in these specs (via stubs only), so the ground returns stay open unless a+    /// pour or a fill joins them.+    /// The rail: J1.1 (the source) to U1.1 along y = 5 and up, already on the board. The cap C1 sits+    /// 1 mm under that rail and 2.5 mm from its assigned pin U1.2, which sits 2 mm under the rail too:+    /// left to the tree, each lands on the rail and the cap reaches its pin along it.+    fn rail_board() -> Board {+        let parts = vec![+            ("J1", vec![("1", "+3V3", 0.0, 0.0), ("2", "GND", 0.0, 2.0)], 2.0, 5.0),+            ("U1", vec![("1", "+3V3", 0.0, 0.0), ("2", "+3V3", -6.0, 5.0), ("3", "GND", -4.0, 7.0)], 16.0, 2.0),+            ("C1", vec![("1", "+3V3", 0.0, 0.0), ("2", "GND", 0.0, 1.5)], 12.5, 6.0),+        ];+        board(&parts, &(seg("+3V3", (2.0, 5.0), (16.0, 5.0)) + &seg("+3V3", (16.0, 5.0), (16.0, 2.0))))+    }++    fn route(spec: &Value) -> (Router, Value, Value) {+        let b = rail_board();+        let mut router = Router::new(b, Rules::from_spec(spec), false);+        let (plan, report, failed) = drive::route_board(&mut router, 2, 4, 50);+        assert_eq!(failed, 0, "{:?}", router.log_lines);+        (router, json!({"nets": plan}), report)+    }++    fn entry(spec: &Value) -> (Value, Value) {+        let (router, plan, report) = route(spec);+        let d = review_route(spec, &router.board, &plan, &report, None).expect("a review");+        (d.value["entries"][0].clone(), json!({"errors": d.errors, "warnings": d.warnings, "router": report["decoupling"], "plan": plan}))+    }++    #[test]+    fn the_pair_route_is_short_where_the_rail_tree_detours() {+        // a rail tree drawn the long way: the cap and its pin each land on the rail with a stub, so+        // the copper path runs along the rail between two landings; the review sees the detour+        let tree = json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "returnPins": ["U1.3"]}]});+        let stubs = seg("+3V3", (2.0, 5.0), (16.0, 5.0)) + &seg("+3V3", (16.0, 5.0), (16.0, 2.0)) + &seg("+3V3", (12.5, 6.0), (12.5, 5.0)) + &seg("+3V3", (10.0, 7.0), (10.0, 5.0));+        let b = board(&[+            ("J1", vec![("1", "+3V3", 0.0, 0.0), ("2", "GND", 0.0, 2.0)], 2.0, 5.0),+            ("U1", vec![("1", "+3V3", 0.0, 0.0), ("2", "+3V3", -6.0, 5.0), ("3", "GND", -4.0, 7.0)], 16.0, 2.0),+            ("C1", vec![("1", "+3V3", 0.0, 0.0), ("2", "GND", 0.0, 1.5)], 12.5, 6.0),+        ], &stubs);+        let d = review_text("gate", &tree, "t.kicad_pcb", &b.text, false, "the zone fills saved in the board file", &json!({"nets": []}), None, None).unwrap();+        let e = &d.value["entries"][0];+        let detour = e["assignedPin"]["pathMm"].as_f64().unwrap();+        assert_eq!(e["assignedPin"]["detour"], true, "{e:#}");+        assert!(detour > e["straightMm"].as_f64().unwrap() + 0.5, "{e:#}");+        assert_eq!(d.warnings, 1, "no budget: the detour is the one finding");+        // with the pair declared: routed first, direct, short, and the plan names it+        let (e, info) = entry(&tree);+        let direct = e["assignedPin"]["pathMm"].as_f64().unwrap();+        assert_eq!(info["router"][0]["direct"], true, "{info:#}");+        assert_eq!(e["assignedPin"]["detour"], false, "{e:#}");+        assert!(direct < detour - 0.5, "direct {direct} vs tree {detour}");+        assert!(direct < e["straightMm"].as_f64().unwrap() * 1.3, "{e:#}");+        assert_eq!(e["assignedPin"]["vias"], 0);+        assert_eq!(e["directInPlan"], true);+        assert!(info["plan"]["nets"].as_array().unwrap().iter().any(|x| x["decoupling"] == "C1.1->U1.2"));+        assert_eq!(e["capPad"], "C1.1");+        assert_eq!(e["nearestSameNetPin"]["pin"], "U1.2");+        assert_eq!(info["warnings"], 0);+        assert_eq!(info["errors"], 0);+        // ground: C1.2 and U1.3 have no copper and no pour: open, not a straight line+        let r = &e["groundReturn"]["returns"][0];+        assert_eq!(r["status"], "open");+        assert!(r["pathMm"].is_null());+    }++    #[test]+    fn a_budget_violation_warns_or_errors_per_severity() {+        // a 1 mm budget the 2.5 mm spacing cannot meet: the router falls back and the review flags it+        for (sev, errs, warns) in [("warning", 0, 1), ("error", 1, 0)] {+            let spec = json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "maxLengthMm": 1.0, "severity": sev}]});+            let (e, info) = entry(&spec);+            assert_eq!(info["router"][0]["direct"], false, "{info:#}");+            assert!(info["router"][0]["note"].as_str().unwrap().contains("maxLengthMm"), "{info:#}");+            assert!(e["findings"].as_array().unwrap().iter().any(|f| f["text"].as_str().unwrap().contains("over maxLengthMm") && f["severity"] == sev), "{e:#}");+            assert_eq!(info["errors"].as_u64().unwrap() >= errs, true, "{info:#}");+            if errs == 0 { assert_eq!(info["errors"], 0); assert!(info["warnings"].as_u64().unwrap() >= warns); }+        }+        // a generous budget: no finding, the length is still reported+        let (e, info) = entry(&json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "maxLengthMm": 6.0, "maxVias": 0, "severity": "error"}]}));+        assert_eq!(info["errors"], 0, "{e:#}");+        assert!(e["assignedPin"]["pathMm"].as_f64().unwrap() > 0.0);+    }++    #[test]+    fn a_planned_pour_ground_return_is_unverified() {+        let dir = std::env::temp_dir().join(format!("aiflow-dec-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let pours = dir.join("plan-pours.json");+        std::fs::write(&pours, r#"{"zones": [{"net": "GND"}]}"#).unwrap();+        let spec = json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "returnPins": ["U1.3"]}]});+        let (router, plan, report) = route(&spec);+        let d = review_route(&spec, &router.board, &plan, &report, Some(&json!({"pours": pours.display().to_string()}))).unwrap();+        let r = &d.value["entries"][0]["groundReturn"]["returns"][0];+        assert_eq!(r["status"], "unverified", "{r:#}");+        assert_eq!(r["verified"], false);+        assert!(r["pathMm"].is_null());+        std::fs::remove_dir_all(&dir).ok();+    }++    #[test]+    fn a_ground_return_through_a_saved_fill_is_labelled() {+        let zone = "\t(zone\n\t\t(net \"GND\")\n\t\t(layer \"F.Cu\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 11 7) (xy 14 7) (xy 14 9.6) (xy 11 9.6)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon\n\t\t\t(layer \"F.Cu\")\n\t\t\t(pts\n\t\t\t\t(xy 11 7) (xy 14 7) (xy 14 9.6) (xy 11 9.6)\n\t\t\t)\n\t\t)\n\t)\n";+        let parts = vec![+            ("U1", vec![("2", "+3V3", -6.0, 5.0), ("3", "GND", -4.0, 7.0)], 16.0, 2.0),+            ("C1", vec![("1", "+3V3", 0.0, 0.0), ("2", "GND", 0.0, 1.5)], 12.5, 6.0),+        ];+        let b = board(&parts, &(seg("+3V3", (12.5, 6.0), (10.0, 7.0)) + zone));+        let spec = json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "returnPins": ["U1.3"]}]});+        let plan = json!({"nets": []});+        let d = review_text("gate", &spec, "t.kicad_pcb", &b.text, false, "an approximate raster fill", &plan, None, None).unwrap();+        let r = &d.value["entries"][0]["groundReturn"]["returns"][0];+        assert_eq!(r["status"], "fill-unverified", "{r:#}");+        let d = review_text("gate", &spec, "t.kicad_pcb", &b.text, true, "a native refill", &plan, None, None).unwrap();+        assert_eq!(d.value["entries"][0]["groundReturn"]["returns"][0]["status"], "fill");+        // the supply side is the hand-drawn track: direct, no detour+        assert_eq!(d.value["entries"][0]["assignedPin"]["detour"], false);+    }++    #[test]+    fn rip_up_keeps_the_pair_route() {+        let spec = json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2"}]});+        let mut router = Router::new(rail_board(), Rules::from_spec(&spec), false);+        router.route_decoupling();+        let net = router.net_ids["+3V3"];+        let pair_cells = |r: &Router| r.nets[net].routes.iter().filter(|x| x.pair == Some(0)).map(|x| x.cells.clone()).collect::<Vec<_>>();+        let before = pair_cells(&router);+        assert_eq!(before.len(), 1);+        router.route_net(net);+        router.rip(net);+        assert_eq!(pair_cells(&router), before, "rip keeps the pair");+        assert!(router.nets[net].routes.iter().all(|x| x.pair.is_some()));+        router.route_net(net);+        assert_eq!(pair_cells(&router), before, "route_net keeps the pair");+        assert!(router.nets[net].failed.is_empty(), "{:?}", router.nets[net].failed);+        // a rail route landed on the pair's island once it joined the tree, not on a second copy+        assert_eq!(router.nets[net].routes.iter().filter(|x| x.pair.is_some()).count(), 1);+        router.reset();+        assert!(router.nets[net].routes.is_empty(), "reset clears pairs too");+    }++    #[test]+    fn a_bad_entry_is_named() {+        let b = rail_board();+        let (_, p) = decoupling::resolve(&b, &Pair::from_spec(&json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C9", "pin": "U1.2"}, {"cap": "C1", "pin": "U1.9"}, {"cap": "C1", "pin": "U1.2", "returnPins": ["U1.1"]}]})).unwrap());+        assert_eq!(p.len(), 3, "{p:?}");+        assert!(p[0].contains("no part C9"));+        assert!(p[1].contains("pin U1.9 is not a pad"), "{}", p[1]);+        assert!(p[2].contains("return pin U1.1"));+        assert!(Pair::from_spec(&json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "severity": "fatal"}]})).is_err());+        assert!(Pair::from_spec(&json!({"planes": {"GND": "B.Cu"}, "decoupling": [{"cap": "C1", "pin": "U1.2", "maxLengthMm": 0}]})).is_err());+        let (ok, _) = decoupling::resolve(&b, &Pair::from_spec(&json!({"planes": {"GND": "B.Cu"}, "decoupling": {"cap": "C1", "pin": "U1.2"}})).unwrap());+        assert_eq!((ok[0].cap_pad.as_str(), ok[0].gnd_pad.as_deref(), ok[0].gnd_net.as_deref()), ("C1.1", Some("C1.2"), Some("GND")));+    }+}
crates/adom-aiflow/src/design_evidence.rsadded+210
@@ -0,0 +1,210 @@+//! Checks on the design record before the board (issue #39, items 4, 5 and 13):+//!+//! - `sourcing check`: every purchased BOM row cites a manufacturer or distributor page for the exact+//!   MPN (value, tolerance, dielectric, voltage, package), not only the family datasheet. Missing is a+//!   warning, an error when `sourcing.requireExactMpnEvidence` is true in the spec or requirements.+//! - `evidence add --kind calcs`: every datasheet-sourced number is tagged typ, min or max, and a+//!   worst-case use names a guaranteed limit (warnings only).+//! - `evidence add --kind simulation`: when the requirements name a full-scale input, the simulation+//!   has a case at that amplitude (warning only).+//!+//! Pure functions; main.rs wires them to the run.+use serde_json::{json, Value};++/// Header names for the exact-MPN evidence column (normalised: lower case, `_` for spaces and dashes).+const EVIDENCE_COLS: &[&str] = &["mpn_url", "exact_mpn_url", "mpn_evidence", "exact_mpn_evidence", "mpn_page", "product_url", "part_url", "evidence_url", "evidence"];++fn norm_header(s: &str) -> String { s.trim().to_ascii_lowercase().replace([' ', '-', '#'], "_") }+fn alnum(s: &str) -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() }++fn no_part(mpn: &str) -> bool {+    matches!(mpn.trim().to_ascii_lowercase().as_str(), "no part" | "none" | "n/a" | "na" | "dnp" | "-" | "no_part" | "")+}++/// True when the spec or the requirements ask for the exact-MPN column to be an error.+pub fn require_exact(spec: &Value, req: &Value) -> bool {+    [spec, req].iter().any(|v| v["sourcing"]["requireExactMpnEvidence"].as_bool() == Some(true))+}++/// Add the exact-MPN evidence findings to a `sourcing check` outcome: `exactMpnEvidence` counts,+/// warnings, and errors (which fail the check) when `strict`.+pub fn exact_mpn_evidence(bom: &str, strict: bool, out: &mut Value) {+    let rows = crate::preboard::parse_csv(bom);+    let Some(header) = rows.first() else { return };+    let find = |names: &[&str]| header.iter().position(|h| names.contains(&norm_header(h).as_str()));+    let c_mpn = find(&["mpn", "mfr_part", "mfr_pn", "manufacturer_part_number", "part_number", "mfr_part_number"]);+    let c_ref = find(&["ref", "refs", "reference", "references", "designator", "designators", "refdes"]);+    let c_ev = find(EVIDENCE_COLS);+    let Some(c_mpn) = c_mpn else { return };+    let get = |row: &Vec<String>, c: Option<usize>| c.and_then(|i| row.get(i)).map(|s| s.trim().to_string()).unwrap_or_default();+    let (mut cited, mut missing, mut soft) = (0usize, Vec::<String>::new(), Vec::<String>::new());+    for row in rows.iter().skip(1) {+        let mpn = get(row, Some(c_mpn));+        if no_part(&mpn) { continue; }+        let label = format!("{} ({mpn})", get(row, c_ref));+        let url = get(row, c_ev);+        if !(url.starts_with("http://") || url.starts_with("https://")) { missing.push(label); continue; }+        cited += 1;+        let path = url.split(['?', '#']).next().unwrap_or("").to_ascii_lowercase();+        if path.ends_with(".pdf") { soft.push(format!("{label}: the evidence is a PDF, usually the family datasheet; cite the manufacturer or distributor page for this exact MPN")); }+        else if !alnum(&url).contains(&alnum(&mpn)) { soft.push(format!("{label}: the evidence URL does not name the MPN; make sure the page is for this exact part, not its family")); }+    }+    out["exactMpnEvidence"] = json!({"column": c_ev.map(|i| header[i].clone()), "cited": cited, "missing": missing.len(), "required": strict});+    let mut warn: Vec<Value> = out["warnings"].as_array().cloned().unwrap_or_default();+    let mut errs: Vec<Value> = out["errors"].as_array().cloned().unwrap_or_default();+    if !missing.is_empty() {+        let msg = format!("{} row(s) without an exact-MPN page ({}{}): add an mpn_url column with the manufacturer or distributor page for that exact MPN (value, tolerance, dielectric, voltage, package); a family datasheet or a decoded part-number grammar is not proof the part is made", missing.len(), missing.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if missing.len() > 8 { ", ..." } else { "" });+        if strict { errs.push(json!(msg)); } else { warn.push(json!(msg)); }+    }+    warn.extend(soft.into_iter().map(Value::from));+    out["warnings"] = json!(warn);+    if strict && !errs.is_empty() { out["pass"] = json!(false); }+    out["errors"] = json!(errs);+}++// -- calcs: typ/min/max tags --------------------------------------------------------------------------++fn datasheet_sourced(o: &serde_json::Map<String, Value>) -> bool {+    o.contains_key("datasheet") || o.get("source").and_then(|s| s.as_str()).map(|s| { let s = s.to_ascii_lowercase(); s.contains("datasheet") || s.contains(".pdf") }).unwrap_or(false)+}++fn kind_of(o: &serde_json::Map<String, Value>) -> Option<String> {+    let k = o.get("kind").and_then(|k| k.as_str())?.trim().to_ascii_lowercase();+    Some(match k.as_str() { "typical" => "typ".into(), "minimum" => "min".into(), "maximum" => "max".into(), _ => k })+}++fn walk<'a>(v: &'a Value, path: String, out: &mut Vec<(String, &'a serde_json::Map<String, Value>)>) {+    match v {+        Value::Object(o) => {+            out.push((path.clone(), o));+            for (k, c) in o { walk(c, if path.is_empty() { k.clone() } else { format!("{path}.{k}") }, out); }+        }+        Value::Array(a) => for (i, c) in a.iter().enumerate() { walk(c, format!("{path}[{i}]"), out); },+        _ => {}+    }+}++/// Warnings for a calcs JSON. The shape: any object with a numeric `value` and a `source` naming a+/// datasheet (or a `datasheet` field) is a datasheet number, and carries `"kind": "typ" | "min" | "max"`.+/// An object with `"worstCase": true` (or `"use": "worst-case"`) must not rest on a typ number.+pub fn calcs_warnings(v: &Value) -> Vec<String> {+    let mut objs = Vec::new();+    walk(v, String::new(), &mut objs);+    let mut out = Vec::new();+    for (path, o) in objs {+        let name = o.get("name").and_then(|n| n.as_str()).map(str::to_string).unwrap_or_else(|| if path.is_empty() { "(top)".into() } else { path.clone() });+        let numeric = o.get("value").map(|x| x.is_number()).unwrap_or(false);+        let kind = kind_of(o);+        if numeric && datasheet_sourced(o) {+            match kind.as_deref() {+                None => out.push(format!("{name}: a datasheet number with no \"kind\": tag it typ, min or max as the datasheet table does")),+                Some("typ" | "min" | "max") => {}+                Some(k) => out.push(format!("{name}: kind `{k}` is not typ, min or max")),+            }+        }+        let worst = o.get("worstCase").and_then(|w| w.as_bool()) == Some(true) || o.get("use").and_then(|u| u.as_str()).map(|u| u.to_ascii_lowercase().replace([' ', '_'], "-") == "worst-case").unwrap_or(false);+        if worst && kind.as_deref() == Some("typ") {+            out.push(format!("{name}: worst-case math on a typical value; use the guaranteed min or max limit (or derate it and say how)"));+        }+    }+    out+}++// -- simulation: full-scale input ---------------------------------------------------------------------++/// A voltage from a number or a string like "12 Vpp", "12Vpp", "500 mV": (volts, unit text).+fn volts(v: &Value) -> Option<f64> {+    if let Some(x) = v.as_f64() { return Some(x); }+    if let Some(o) = v.as_object() { return o.get("value").and_then(volts).or_else(|| o.get("vpp").and_then(volts)).or_else(|| o.get("amplitude").and_then(volts)); }+    let s = v.as_str()?.trim().to_ascii_lowercase();+    let num: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect();+    let x: f64 = num.parse().ok()?;+    let rest = s[num.len()..].trim_start();+    Some(if rest.starts_with("mv") { x / 1000.0 } else if rest.starts_with("uv") || rest.starts_with("ยตv") { x / 1e6 } else { x })+}++/// The full-scale input the requirements name: any key named like `fullScale`, `fullScaleInput`,+/// `full_scale_vpp` (a number in volts, a string with units, or {"value": ...}).+pub fn full_scale(req: &Value) -> Option<(String, f64)> {+    let mut objs = Vec::new();+    walk(req, String::new(), &mut objs);+    for (path, o) in objs {+        for (k, v) in o {+            let key = k.to_ascii_lowercase().replace(['_', '-', ' '], "");+            if key.contains("fullscale") { if let Some(x) = volts(v) { if x != 0.0 { return Some((if path.is_empty() { k.clone() } else { format!("{path}.{k}") }, x.abs())); } } }+        }+    }+    None+}++/// Warnings for a simulation result when the requirements name a full-scale input: a case is at+/// full scale when it says `"fullScale": true`, or a field named like amplitude / input / vin / vpp /+/// level holds that value within 5 % (or 50 % to 200 % of it for a peak-vs-peak-to-peak reading).+pub fn simulation_warnings(req: &Value, sim: &Value) -> Vec<String> {+    let Some((key, fs)) = full_scale(req) else { return Vec::new() };+    let mut objs = Vec::new();+    walk(sim, String::new(), &mut objs);+    let near = |x: f64| { let x = x.abs(); (x - fs).abs() <= 0.05 * fs || (x - fs / 2.0).abs() <= 0.05 * fs || (x - fs * 2.0).abs() <= 0.1 * fs };+    let hit = objs.iter().any(|(_, o)| o.iter().any(|(k, v)| {+        let key = k.to_ascii_lowercase().replace(['_', '-', ' '], "");+        if key.contains("fullscale") && v.as_bool() == Some(true) { return true; }+        ["amplitude", "input", "vin", "vpp", "level", "vpk", "vpeak"].iter().any(|n| key.contains(n)) && volts(v).map(near).unwrap_or(false)+    }));+    if hit { return Vec::new(); }+    vec![format!("the requirements name a full-scale input ({key} = {fs} V) but this simulation has no case at that amplitude: simulate at full scale too (a case with \"fullScale\": true or \"inputVpp\": {fs}) and say whether the topology only works small-signal (aiflow-simulate)")]+}++#[cfg(test)]+mod tests {+    use super::*;++    fn base() -> Value { json!({"pass": true, "errors": [], "warnings": []}) }++    #[test]+    fn exact_mpn_column_warns_then_errors_when_required() {+        let bom = "ref,mpn,source,stock_checked_2026-10-01,mpn_url\nC1,C0805C104K5RACTU,Mouser,5000,https://www.mouser.com/ProductDetail/KEMET/C0805C104K5RACTU\nC2,C0805C103F5GACTU,Mouser,100,\nR1,RC0805FR-0715K8L,Mouser,900,https://www.yageo.com/upload/media/product/RCseries.pdf\nTP1,no part,,,\n";+        let mut out = base();+        exact_mpn_evidence(bom, false, &mut out);+        assert_eq!(out["pass"], true);+        assert_eq!(out["exactMpnEvidence"]["cited"], 2);+        let w: Vec<String> = out["warnings"].as_array().unwrap().iter().map(|x| x.as_str().unwrap().to_string()).collect();+        assert!(w.iter().any(|x| x.contains("1 row(s) without an exact-MPN page") && x.contains("C2")), "{w:?}");+        assert!(w.iter().any(|x| x.contains("R1") && x.contains("PDF")), "{w:?}");+        let mut out = base();+        exact_mpn_evidence(bom, true, &mut out);+        assert_eq!(out["pass"], false);+        assert!(out["errors"][0].as_str().unwrap().contains("C2"));+        // no column at all: every purchased row is missing+        let mut out = base();+        exact_mpn_evidence("ref,mpn,source\nU1,MCP6001T-I/OT,Mouser\n", false, &mut out);+        assert_eq!(out["exactMpnEvidence"]["missing"], 1);+        assert!(require_exact(&json!({"sourcing": {"requireExactMpnEvidence": true}}), &Value::Null));+    }++    #[test]+    fn calcs_need_typ_min_max_on_datasheet_numbers() {+        let v = json!({"inputs": [+            {"name": "Ib MCP6001 at 85C", "value": 19e-12, "unit": "A", "source": "MCP6001 datasheet table 1-1", "kind": "typ", "worstCase": true},+            {"name": "Vos", "value": 4.5e-3, "source": "datasheet p.3", "kind": "max"},+            {"name": "Cin", "value": 6e-12, "datasheet": "p.4"},+            {"name": "R1", "value": 15800, "source": "chosen"}+        ]});+        let w = calcs_warnings(&v);+        assert_eq!(w.len(), 2, "{w:?}");+        assert!(w.iter().any(|x| x.starts_with("Ib MCP6001") && x.contains("typical")));+        assert!(w.iter().any(|x| x.starts_with("Cin") && x.contains("no \"kind\"")));+    }++    #[test]+    fn full_scale_input_needs_a_simulation_case() {+        let req = json!({"signal": {"fullScaleInput": "12 Vpp", "smallSignal": "100 mVpp"}});+        assert_eq!(full_scale(&req).unwrap().1, 12.0);+        let small = json!({"pass": true, "cases": [{"name": "small", "inputVpp": 0.1, "attenuationDb": 10.5}]});+        assert_eq!(simulation_warnings(&req, &small).len(), 1);+        let full = json!({"pass": true, "cases": [{"name": "small", "inputVpp": 0.1}, {"name": "full", "inputVpp": 12, "attenuationDb": 0.83}]});+        assert!(simulation_warnings(&req, &full).is_empty());+        let flagged = json!({"cases": [{"fullScale": true}]});+        assert!(simulation_warnings(&req, &flagged).is_empty());+        assert!(simulation_warnings(&json!({}), &small).is_empty());+    }+}
crates/adom-aiflow/src/export.rs+22−1
@@ -314,6 +314,9 @@ fn kicad(cli: &str, args: &[String]) -> Result<(), String> { /// Write the set into `out` and check it. Ok: the proof (what was checked); Err: the report and the problems. pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value, Vec<String>)> {     let fail = |m: String| (json!({"board": board_path}), vec![m]);+    // kicad-cli gets absolute outputs (#72): a relative run folder means nothing on a desktop's KiCad+    let out_abs = std::path::absolute(out).unwrap_or_else(|_| out.to_path_buf());+    let out = out_abs.as_path();     let board = Board::load(board_path).map_err(fail)?;     let inv = inventory(&board);     let stem = Path::new(board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();@@ -339,6 +342,11 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     let gerbers = read_dir(&gdir, &["gbr"]);     let drills = read_dir(&gdir, &["drl"]);     let (g, mut problems, mut notes) = check_gerbers(&gerbers, inv.copper_layers.len());+    // issue #39 item 10: the saved board's pours are filled, and the copper Gerbers carry them+    let (saved_fill, p_fill, filled_layers) = crate::fills::saved(&board);+    problems.extend(p_fill);+    let (gerber_fill, p_gfill) = crate::fills::gerbers(&gerbers, &filled_layers, inv.copper_layers.len());+    problems.extend(p_gfill);     let (d, p2, n2) = check_drill(&drills, &inv);     problems.extend(p2); notes.extend(n2);     let (c, p3) = match std::fs::read_to_string(&cpl) { Ok(t) => check_cpl(&t, &inv), Err(e) => (Value::Null, vec![format!("pick and place not written ({}: {e})", cpl.display())]) };@@ -353,7 +361,7 @@ pub fn export(board_path: &str, out: &Path, cli: &str) -> Result<Value, (Value,     let report = json!({         "pass": problems.is_empty(), "dir": out.display().to_string(), "board": board_path,         "boardSha256": crate::sha256_file(board_path), "kicadCli": cli,-        "gerbers": g, "drill": d, "cpl": c, "bom": b, "bomSource": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",+        "gerbers": g, "drill": d, "cpl": c, "bom": b, "fillsSaved": saved_fill, "fillsGerbers": gerber_fill, "bomSource": "the board's footprints (Value, footprint, MPN and Manufacturer properties, DNP and exclude-from-BOM attributes)",         "files": files, "notes": notes, "problems": problems,     });     if problems.is_empty() { Ok(report) } else { Err((report, problems)) }@@ -503,6 +511,19 @@ esac         assert_eq!(rep["drill"]["hits"], 6);         assert_eq!(rep["cpl"]["refs"], 3);         assert!(dir.join("mfg/demo-bom.csv").is_file());+        // issue #39 item 10: a poured zone with no fill in the saved board is refused, and so is a+        // filled zone whose Gerber carries no region+        let zoned = dir.join("zoned.kicad_pcb");+        let zone = "(zone (net 1) (net_name \"GND\") (layer \"B.Cu\") (name \"gnd\") (polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20))))";+        std::fs::write(&zoned, BOARD.replacen("  (via (at 3 3)", &format!("  {zone}\n  (via (at 3 3)"), 1)).unwrap();+        let fake_z = |skip: &str| { let p = fake(skip); let s = std::fs::read_to_string(&p).unwrap().replace("demo-", "zoned-"); std::fs::write(&p, s).unwrap(); p };+        let (rep, problems) = export(zoned.to_str().unwrap(), &dir.join("mfg-z"), &fake_z("none")).unwrap_err();+        assert!(problems.iter().any(|p| p.contains("no filled polygon on B.Cu")), "{problems:?}");+        assert_eq!(rep["fillsSaved"]["pass"], false);+        std::fs::write(&zoned, BOARD.replacen("  (via (at 3 3)", &format!("  {}\n  (via (at 3 3)", zone.replace("))))", "))) (filled_polygon (layer \"B.Cu\") (pts (xy 1 1) (xy 19 1) (xy 19 19))))")), 1)).unwrap();+        let (rep, problems) = export(zoned.to_str().unwrap(), &dir.join("mfg-z"), &fake_z("none")).unwrap_err();+        assert_eq!(rep["fillsSaved"]["pass"], true, "{rep}");+        assert!(problems.iter().any(|p| p.contains("B.Cu") && p.contains("G36")), "{problems:?}");         let cli = fake("In2_Cu.gbr");         let (_, problems) = export(board.to_str().unwrap(), &dir.join("mfg"), &cli).unwrap_err();         assert!(problems.iter().any(|p| p.contains("L3")), "a stale In2 file from the last export must not count: {problems:?}");
crates/adom-aiflow/src/fills.rsadded+151
@@ -0,0 +1,151 @@+//! The board checked is the board that ships (issue #39, item 10). A zone that KiCad never filled+//! (or whose fill was dropped before saving) is outline only: DRC on a refilled copy passes, the+//! saved file and the Gerbers made from it carry no copper there.+//!+//! `saved` reads the saved board: every copper zone that should fill (not a keepout or rule area,+//! with a real outline) has at least one `filled_polygon` on each of its copper layers.+//! `gerbers` reads the exported copper Gerbers: every copper layer that carries a filled zone in+//! the saved board has region data (G36) in its Gerber.++use aiflow_board::{sx, Board};+use serde_json::{json, Value};++pub const HINT: &str = "Refill the zones in KiCad (Edit > Fill All Zones, or `kicad_refill_zones` through the bridge), save the board, then run the check (and `export`) again on the saved file. Never hand over a board or Gerbers whose pours exist only in a DRC copy.";++fn atoms(n: &sx::Node) -> Vec<String> {+    n.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.clone()) } else { None }).collect()+}++fn copper(name: &str) -> bool { name.ends_with(".Cu") }++/// The copper layers a zone is on, expanded against the board's copper layers.+fn zone_layers(z: &sx::Node, board_copper: &[String]) -> Vec<String> {+    let mut names: Vec<String> = Vec::new();+    if let Some(l) = z.find("layer").and_then(|l| l.value()) { names.push(l.to_string()); }+    if let Some(ls) = z.find("layers") { names.extend(atoms(ls)); }+    let mut out = Vec::new();+    for n in names {+        let expanded: Vec<String> = match n.as_str() {+            "*.Cu" => board_copper.to_vec(),+            "F&B.Cu" => vec!["F.Cu".into(), "B.Cu".into()],+            _ if copper(&n) => vec![n],+            _ => vec![],+        };+        for l in expanded { if !out.contains(&l) { out.push(l); } }+    }+    out+}++fn outline_area(z: &sx::Node) -> f64 {+    let pts: Vec<(f64, f64)> = z.find("polygon").and_then(|p| p.find("pts")).map(|p| p.find_all("xy").filter_map(|q| Some((q.number(1)?, q.number(2)?))).collect()).unwrap_or_default();+    aiflow_board::geom::polygon_area(&pts).abs()+}++/// The saved board's zones: (report, problems, copper layers that carry fill).+pub fn saved(b: &Board) -> (Value, Vec<String>, Vec<String>) {+    let mut problems = Vec::new();+    let mut zones = Vec::new();+    let mut filled_layers: Vec<String> = Vec::new();+    let (mut should, mut ok) = (0usize, 0usize);+    for z in b.root.find_all("zone") {+        let net = z.find("net_name").and_then(|n| n.value()).map(str::to_string).unwrap_or_else(|| b.item_net(z));+        let name = z.find("name").and_then(|n| n.value()).map(str::to_string);+        let label = match &name { Some(n) if !n.is_empty() => format!("zone \"{n}\" ({})", if net.is_empty() { "no net" } else { &net }), _ => format!("zone on {}", if net.is_empty() { "no net" } else { &net }) };+        let layers = zone_layers(z, &b.copper_layers);+        let keepout = z.find("keepout").is_some();+        let area = outline_area(z);+        if keepout || layers.is_empty() || area < 1e-6 {+            zones.push(json!({"zone": label, "layers": layers, "skipped": if keepout { "keepout / rule area" } else if layers.is_empty() { "not on copper" } else { "empty outline" }}));+            continue;+        }+        should += 1;+        let mut per_layer = serde_json::Map::new();+        let mut missing = Vec::new();+        for l in &layers {+            let n = z.find_all("filled_polygon").filter(|f| f.find("layer").and_then(|x| x.value()) == Some(l.as_str())).count();+            per_layer.insert(l.clone(), json!(n));+            if n == 0 { missing.push(l.clone()); } else if !filled_layers.contains(l) { filled_layers.push(l.clone()); }+        }+        if missing.is_empty() { ok += 1; } else { problems.push(format!("{label} has no filled polygon on {} in the saved board", missing.join(", "))); }+        zones.push(json!({"zone": label, "layers": layers, "filledPolygons": per_layer, "outlineMm2": (area * 100.0).round() / 100.0}));+    }+    (json!({"zones": zones, "shouldFill": should, "filled": ok, "pass": problems.is_empty(), "filledLayers": filled_layers}), problems, filled_layers)+}++/// KiCad's Gerber copper index of a layer: F.Cu L1, In<k>.Cu L(k+1), B.Cu Ln.+pub fn gerber_index(layer: &str, copper_count: usize) -> Option<usize> {+    match layer {+        "F.Cu" => Some(1),+        "B.Cu" => Some(copper_count.max(2)),+        _ => layer.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()).map(|k| k + 1),+    }+}++/// G36 regions per copper Gerber, against the layers whose saved zones carry fill.+pub fn gerbers(files: &[(String, String)], filled_layers: &[String], copper_count: usize) -> (Value, Vec<String>) {+    let mut by_index: std::collections::BTreeMap<usize, (String, usize)> = std::collections::BTreeMap::new();+    for (name, text) in files {+        let Some(f) = crate::export::gerber_function(text) else { continue };+        let parts: Vec<&str> = f.split(',').collect();+        if parts.first() != Some(&"Copper") { continue; }+        if let Some(n) = parts.get(1).and_then(|l| l.strip_prefix('L')).and_then(|n| n.parse::<usize>().ok()) {+            by_index.insert(n, (name.clone(), text.matches("G36").count()));+        }+    }+    let mut problems = Vec::new();+    let mut layers = serde_json::Map::new();+    for l in filled_layers {+        let Some(i) = gerber_index(l, copper_count) else { continue };+        match by_index.get(&i) {+            Some((name, regions)) => {+                layers.insert(l.clone(), json!({"gerber": name, "regions": regions}));+                if *regions == 0 { problems.push(format!("{l} has poured zones in the saved board but its Gerber {name} has no region (G36) data: the pour did not reach the fab files")); }+            }+            None => problems.push(format!("{l} has poured zones but no copper Gerber L{i} was found")),+        }+    }+    (json!({"layersWithPours": layers, "pass": problems.is_empty()}), problems)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(zones: &str) -> Board {+        Board::from_text("t.kicad_pcb", format!("(kicad_pcb (version 20240108) (generator \"t\")\n (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal) (44 \"Edge.Cuts\" user))\n (net 0 \"\") (net 1 \"GND\")\n{zones}\n (gr_rect (start 0 0) (end 20 20) (layer \"Edge.Cuts\") (width 0.1)))")).unwrap()+    }+    const OUTLINE: &str = "(polygon (pts (xy 0 0) (xy 20 0) (xy 20 20) (xy 0 20)))";+    fn filled(l: &str) -> String { format!("(filled_polygon (layer \"{l}\") (pts (xy 1 1) (xy 19 1) (xy 19 19)))") }++    #[test]+    fn a_zone_without_filled_polygons_fails_and_keepouts_do_not_count() {+        let b = board(&format!("(zone (net 1) (net_name \"GND\") (layer \"B.Cu\") (name \"gnd\") {OUTLINE})\n (zone (net 0) (net_name \"\") (layers \"F.Cu\" \"B.Cu\") (keepout (tracks not_allowed)) {OUTLINE})"));+        let (rep, p, layers) = saved(&b);+        assert_eq!(p.len(), 1, "{p:?}");+        assert!(p[0].contains("\"gnd\"") && p[0].contains("B.Cu"), "{p:?}");+        assert!(layers.is_empty());+        assert_eq!(rep["shouldFill"], 1);+        let b = board(&format!("(zone (net 1) (net_name \"GND\") (layers \"F.Cu\" \"B.Cu\") {OUTLINE} {} {})", filled("F.Cu"), filled("B.Cu")));+        let (_, p, layers) = saved(&b);+        assert!(p.is_empty(), "{p:?}");+        assert_eq!(layers, vec!["F.Cu", "B.Cu"]);+        // a two-layer zone filled on one layer only+        let b = board(&format!("(zone (net 1) (net_name \"GND\") (layers \"F.Cu\" \"B.Cu\") {OUTLINE} {})", filled("F.Cu")));+        assert!(saved(&b).1[0].contains("B.Cu"));+    }++    #[test]+    fn gerbers_need_regions_on_poured_layers() {+        let g = |f: &str, body: &str| format!("%TF.FileFunction,{f}*%\n{body}\nM02*\n");+        let files = vec![("b-F_Cu.gbr".to_string(), g("Copper,L1,Top", "X0Y0D02*\nX1Y1D01*")), ("b-B_Cu.gbr".to_string(), g("Copper,L2,Bot", "X0Y0D02*\nX1Y1D01*"))];+        let (_, p) = gerbers(&files, &["B.Cu".to_string()], 2);+        assert!(p.len() == 1 && p[0].contains("B.Cu") && p[0].contains("G36"), "{p:?}");+        let mut ok = files.clone();+        ok[1].1 = g("Copper,L2,Bot", "G36*\nX0Y0D02*\nX5Y0D01*\nX5Y5D01*\nX0Y0D01*\nG37*");+        let (rep, p) = gerbers(&ok, &["B.Cu".to_string()], 2);+        assert!(p.is_empty(), "{p:?}");+        assert_eq!(rep["layersWithPours"]["B.Cu"]["regions"], 1);+        assert_eq!(gerber_index("In2.Cu", 4), Some(3));+        assert_eq!(gerber_index("B.Cu", 4), Some(4));+    }+}
crates/adom-aiflow/src/main.rs+370−121
@@ -1,6 +1,7 @@ //! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line. //! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run. mod continuation;+mod copper_carry; mod artifact_store; mod pourverify; mod dashboard;@@ -17,14 +18,29 @@ mod remote_binding; mod placement_verify; mod provenance; mod spec_freeze;+mod spec_nets;+mod schematic_check; mod export; mod silkproof;+mod schematic_symbols;+mod design_evidence;+mod silk_polarity;+mod project_lib;  /// The timeline dashboard (tools/timeline/index.html): it receives state over SSE from its run server. const TIMELINE_PAGE: &str = include_str!("../../../tools/timeline/index.html"); mod library_review; mod checks; mod preboard;+mod mechanical;+mod fills;+mod verified;+mod step_export;+mod skill_hints;+mod clipwatch;+mod decoupling_check;+mod placement_intent;+mod rules_card; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -121,12 +137,14 @@ enum Cmd {     BindRemoteBoard { #[arg(long)] remote_board: String },     /// Adopt a native-saved board after AI-owned edits, preserving project rules and checking baseline DRC.     AdoptBoard { #[arg(long)] board: String, #[arg(long)] evidence: String },-    /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page; --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again.-    Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64 },+    /// `parts [--bom <csv>] [--show] [--dwell 4]`: the from-scratch board's parts on the wiki. Every BOM row with an MPN (default: the BOM the sourcing check passed) is matched to its Adom wiki component page; --show walks a Hydrogen tab through each page so the viewer sees the parts being chosen. A part with no page fails: make it (wiki-component skill) and run again, or give it from the board's own project library (--project-lib).+    Parts { #[arg(long)] bom: Option<String>, #[arg(long)] show: bool, #[arg(long, default_value_t = 4)] dwell: u64, /// a project library folder for parts with no public component page: KiCad .kicad_sym, .pretty footprints, STEP models and a provenance.json naming each MPN's source (default: the spec's or requirements' "projectLib"). Each listed MPN needs a symbol, a footprint, a model and provenance; project-page packages them, finish reports them.+    #[arg(long)] project_lib: Option<String> },     /// `project-page --page <owner/slug> [--create]`: revision 1 of the board's project page before any EDA work: a README that says what the board is with the component table, and a KiCad PCM repository of every part (an Adom Cloud Library, exported and repackaged for KiCad's Plugin and Content Manager). Opens the page in Hydrogen.     ProjectPage { #[arg(long)] page: String, /// push every visited step's sub-readme and the README's "How this board was built" section now (each step's is also pushed in the background when the run leaves it)     #[arg(long)] sync: bool, #[arg(long)] create: bool, /// the page already exists and is this same board's own page (an earlier run of it); refused otherwise-    #[arg(long)] existing: bool, #[arg(long)] private: bool, #[arg(long)] title: Option<String>, #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool },+    #[arg(long)] existing: bool, #[arg(long)] private: bool, #[arg(long)] title: Option<String>, /// what the board is: text, or a path to a file (for example design/brief.md) whose contents are read+    #[arg(long)] brief: Option<String>, #[arg(long)] replace_readme: bool, #[arg(long)] no_push: bool },     /// `pcm check`: the project page's PCM package is installed in the test box's KiCad (kicad_pcm_list), so the schematic can use every part. Install it first with kicad_pcm_add_repository + kicad_pcm_install (the URLs project-page printed).     Pcm { #[arg(value_parser = ["check"])] what: String },     /// `schematic check --sch <file.kicad_sch>`: the from-scratch KiCad schematic, drawn in KiCad: kicad-cli ERC with no errors, every BOM reference on the sheet, every part from the project's PCM library (PCM_<lib>). Records the schematic for the netlist check that follows the board.@@ -156,6 +174,12 @@ enum Cmd {     Export { /// the board (default: the run's current board)     #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)     #[arg(long)] out: Option<PathBuf> },+    /// The board twin's native STEP export (kicad-cli pcb export step with models, tracks, pads, zones, mask and silkscreen; never fewer layers). Its output is kept and classified (geometry, model, other) apart from the exit status; geometry warnings block `board-glb copper` and `deliver` until `step-export --ack --why` after a review of the STEP. --log classifies the output of an export run elsewhere (the desktop).+    StepExport { /// the board (default: the run's current board)+    #[arg(long)] board: Option<String>, /// the STEP to write (default: <run>/twin/<board>.step), or with --log the STEP that export wrote+    #[arg(long)] out: Option<PathBuf>, /// classify this saved export output instead of running kicad-cli+    #[arg(long)] log: Option<PathBuf>, /// record that the geometry warnings of the last export were reviewed (needs --why)+    #[arg(long)] ack: bool, #[arg(long)] why: Option<String> },     /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay     Unroute { #[arg(long, required = true)] net: Vec<String> },     /// KiCad DRC offline on the routing plan@@ -163,12 +187,21 @@ enum Cmd {     /// Replay live through the bridge: route, vias, pours, moves     Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64, /// route/vias: start at this trace number (1-based, as the progress lines count); traces already on the live board are skipped anyway     #[arg(long)] from: Option<usize> },-    /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership.-    VerifyPours { #[arg(long)] plan: String },+    /// Verify existing saved filled zones against an explicit reviewed plan, without landing or claiming ownership (--adopt --why takes ownership; --record-plan records a reviewed plan before the zones change)+    VerifyPours {+        /// the zone plan: the one `pour` wrote, or a reviewed plan recorded with --record-plan before the zones changed+        #[arg(long)] plan: String,+        /// take ownership of the matched zones (needs --why); recorded as adopted, not landed+        #[arg(long)] adopt: bool,+        /// reason for --adopt, or for accepting a plan whose provenance is not established; recorded with the proof+        #[arg(long)] why: Option<String>,+        /// only record this reviewed plan's hash now, before the zones it describes change+        #[arg(long)] record_plan: bool,+    },     /// Filled copper per layer from the live board     Measure,-    /// current or thermal-    Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf> },+    /// current or thermal. `analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited peak over the Fields density screen (repeat --ack for more); it covers only that place (the pad or 0.5 mm), keeps the number reported and expires when the copper fingerprint or the spec changes. spec.boardSink declares a board-face heat sink for named parts (thermal).+    Analyze { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] fields: Option<PathBuf>, #[arg(long)] ack: Vec<String>, #[arg(long)] why: Option<String> },     /// The finish line; refuses until every check passes     Finish,     /// Record a change to the spec's budgets (loads, hot, rise limits, switch-node length, net widths, fab) after placement began. Budgets are frozen at placement; this is the only way to move one, and it stays on the record.@@ -274,6 +307,10 @@ fn ok(msg: &str, hints: &[String]) {     } } +/// Where this command's input is described (issue #56: the skill file by absolute path and its+/// section), set once in main and printed under every ERROR of the command.+static INPUT_HINT: std::sync::OnceLock<String> = std::sync::OnceLock::new();+ fn err(msg: &str, hints: &[String]) -> ! {     close_turn(false, msg.lines().next().unwrap_or(""));     println!("ERROR: {msg}");@@ -282,6 +319,7 @@ fn err(msg: &str, hints: &[String]) -> ! {             println!("Hint: {h}");         }     }+    if let Some(h) = INPUT_HINT.get() { println!("Hint: {h}"); }     std::process::exit(2) } @@ -308,11 +346,17 @@ fn read_json(p: &str) -> Value {     serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[])) } +/// The `start` hint for the design-rule card (rules_card.rs).+fn rules_card_hint(flags: &[String]) -> String {+    if flags.is_empty() { "Design-rule card: every spec size is inside the fab lane's range (drawn to scale on the dashboard; rules-card.json). It changes no gate.".into() }+    else { format!("Design-rule card: {} value(s) outside the fab lane's range (the WARNING lines above; rules-card.json and the dashboard). Fix the spec before placement if it is a unit slip; the card changes no gate.", flags.len()) }+}+ fn spec_of(r: &Run) -> Value {     if r.data["spec"].is_null() { err("this run has no spec yet (an intake run before its board)", &["Attach the board and its spec: adom-aiflow start --board B.kicad_pcb --spec spec.json --engine <you> (the clock stays at the prompt).".into()]); }     let spec = read_json(r.data["spec"].as_str().unwrap_or(""));     guard_budgets(r, &spec);-    spec+    spec_nets::align(spec, &r.current_board()) }  /// Once placement has begun the spec's budgets are frozen (`spec_freeze`): freeze them on the first read, and@@ -337,7 +381,8 @@ fn guard_budgets(r: &Run, spec: &Value) {  /// The spec when there is one, Null before the board (an intake run) or when it does not read. fn spec_soft(r: &Run) -> Value {-    r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null)+    let spec = r.data["spec"].as_str().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+    spec_nets::align(spec, &r.current_board()) }  /// requirements.json (aiflow-intake) when the run knows one, else Null.@@ -984,7 +1029,7 @@ fn clip_verdict(file: &str, tools: &[&str]) -> Result<Option<String>, String> {  /// Verdicts that only the blank check gives; a later passing check (recut) clears them. An AI's own rejection stays. fn blank_check_verdict(why: &str) -> bool {-    why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour")+    why == "no frames" || why == "could not read frames" || why.contains("sampled frames are one flat colour") || why.starts_with("truncated") }  /// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a@@ -1064,6 +1109,14 @@ fn clip_artifacts_with(r: &mut Run, step: &Value, file: &str, speed: u32, tools:             return;         }     };+    // issue #59: a take that died before it was finalised has no moov atom; say so now, not at deliver+    let truncated = clipwatch::truncated(file);+    if let Some(t) = &truncated {+        eprintln!("WARNING clip {} is {t}. Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));+        if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["truncated"] = json!(true); }+        r.log("clip-truncated", json!({"step": step, "file": file, "why": t}));+    }+    let verdict = truncated.or(verdict);     let dir = r.dir.clone();     let tag = step.as_str().unwrap_or("step").to_string();     // the clip's offset from the prompt, so the overlay can show the run's own clock@@ -1787,9 +1840,11 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {     if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }     if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }     if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }-    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours ({}): {} groups, failed {}\n", x["mode"].as_str().unwrap_or("landed"), x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }+    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours ({}): {} groups, failed {}\n", pourverify::mode_label(x), x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }     if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }     for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }+    for c in o.get("analyze-current").and_then(|x| x["acknowledgedPeaks"].as_array()).into_iter().flatten() { md.push_str(&format!("- acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) ({}): {} (by {})\n", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }+    for a in o.get("analyze-thermal").and_then(|x| x["boardSinkAssumptions"].as_array()).into_iter().flatten() { md.push_str(&format!("- board-sink assumption: {}\n", a.as_str().unwrap_or(""))); }     md.push_str("\n");     // clips and screenshots per visit     md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");@@ -1991,6 +2046,10 @@ fn live_board(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, Stri /// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the /// narrowest copper of its net, or another net's track cutting a net's patch pour off from its pin. /// Each hint names the spec change that fixes it, so a refine pass is a decision, not a search.+/// The copper fingerprint current-peak acknowledgements bind to (issue #70): copper_carry's, the+/// same one that carries Fields evidence over a copper-unchanged adoption.+fn ack_copper(board: &str) -> Result<String, String> { copper_carry::fingerprint(Path::new(board)) }+ fn refine_hints(fields: &Value, board_text: &str, spec: &Value) -> Vec<String> {     let num = |s: &str| s.parse::<f64>().unwrap_or(0.0);     let via_re = regex_lite::Regex::new(r#"\(via \(at ([-\d.]+) ([-\d.]+)\) \(size ([\d.]+)\) \(drill ([\d.]+)\)[^\n]*?\(net \d+ "([^"]+)"\)"#).unwrap();@@ -2241,7 +2300,17 @@ fn lane_script(lane: &str, script: &str) -> std::path::PathBuf { }  fn main() {-    let cli = Cli::parse();+    let (skill_home, skill_pkg) = skill_hints::here();+    let input_hint = skill_hints::command_hint(&command_line(), &skill_home, skill_pkg.as_deref());+    let cli = match Cli::try_parse() {+        Ok(c) => c,+        Err(e) => {+            // a malformed command line names the skill section that describes the command's input+            if e.use_stderr() { let _ = e.print(); if let Some(h) = &input_hint { eprintln!("Hint: {h}"); } std::process::exit(e.exit_code()); }+            e.exit()+        }+    };+    if let Some(h) = input_hint { let _ = INPUT_HINT.set(h); }     // A host stop hook must not open windows, start captures or append timing events.     if let Cmd::FinishGuard { json: as_json, stop_evidence } = &cli.cmd {         let r = load_run(&cli);@@ -2289,7 +2358,16 @@ fn main() {                     let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);                     let step = caps[pos]["step"].as_str().unwrap_or("").to_string();                     let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, "sourcing" => 20, "design" | "simulation" | "schematic" => 30, _ => 10 } as u64;-                    if age > budget_min * 60 * 2 || age > 3600 {+                    let guard_due = age > budget_min * 60 * 2 || age > 3600;+                    // issue #59: a take that died (window gone, bridge restarted) is restarted on the step's window now+                    if !guard_due && !matches!(cli.cmd, Cmd::Step { .. } | Cmd::Capture { .. } | Cmd::Finish | Cmd::Deliver { .. }) {+                        if let Some(mut r2) = Run::open(&dir) {+                            let br = Bridge { ai_thread: t.clone(), target: target.clone() };+                            if let Some(line) = clipwatch::watch(&mut r2, &br, &dir) { eprintln!("WARNING: {line}"); }+                            let _ = r2.save();+                        }+                    }+                    if guard_due {                         let br = Bridge { ai_thread: t, target };                         if let Some(mut r2) = Run::open(&dir) {                             r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));@@ -2345,6 +2423,11 @@ fn main() {         Cmd::BoardGlb { action, glb, board, out, finish, paste, report } => {             let mut run = Run::open(&dir);             if run.is_some() { thread(&cli); }+            if action == "copper" {+                if let Some(open) = run.as_ref().and_then(|r| step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null))) {+                    err(&format!("board twin not delivered: {open}"), &["The STEP behind this GLB carries exporter geometry warnings nobody has reviewed. Review the STEP at those spots, then `adom-aiflow step-export --ack --why \"...\"`; never drop a layer to silence the exporter.".into()]);+                }+            }             let board_path = board.clone().map(|b| b.display().to_string()).or_else(|| run.as_ref().map(|r| r.current_board())).unwrap_or_else(|| err("board-glb needs --board <file.kicad_pcb> outside a run", &["Pass the .kicad_pcb the STEP was exported from.".into()]));             let spec_finish = run.as_ref().map(spec_soft).and_then(|v| v["fab"]["finish"].as_str().map(String::from));             let finish = finish.clone().or(spec_finish).unwrap_or_else(|| "copper".into());@@ -2404,6 +2487,40 @@ fn main() {             }             ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required. `--record` records the silkscreen step proof from a passing audit that covers the board.".into()]);         }+        Cmd::StepExport { board, out, log, ack, why } => {+            thread(&cli); let mut r = load_run(&cli);+            if *ack {+                let why = why.as_deref().filter(|w| !w.trim().is_empty()).unwrap_or_else(|| err("step-export --ack needs --why \"what you reviewed in the STEP and why it is complete\"", &[]));+                let mut rec = r.outcomes().get("step-export").cloned().unwrap_or_else(|| err("no STEP export recorded in this run", &["Run `adom-aiflow step-export` first.".into()]));+                if rec["geometryWarnings"].as_u64().unwrap_or(0) == 0 { ok("nothing to acknowledge: the last STEP export has no geometry warnings", &[]); return; }+                rec["ack"] = json!({"why": why, "at": now(), "stepSha256": rec["stepSha256"].clone()});+                r.outcome("step-export", rec.clone());+                r.log("step-export-ack", json!({"why": why, "step": rec["step"], "geometryWarnings": rec["geometryWarnings"]}));+                r.save().unwrap();+                ok(&format!("acknowledged {} STEP geometry warning(s) for {} (bound to this STEP file; a new export needs a new review)", rec["geometryWarnings"], rec["step"].as_str().unwrap_or("")), &[]);+                return;+            }+            let board_path = board.clone().unwrap_or_else(|| r.current_board());+            let stem = Path::new(&board_path).file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();+            let step = out.clone().unwrap_or_else(|| dir.join("twin").join(format!("{stem}.step")));+            let rec = match log {+                Some(l) => { let text = std::fs::read_to_string(l).unwrap_or_else(|e| err(&format!("{}: {e}", l.display()), &[])); let mut v = step_export::record(&board_path, Some(&step), Some(0), &text, &format!("output of an export run elsewhere: {}", l.display())); v["exitCode"] = Value::Null; v["log"] = json!(l.display().to_string()); if !v["stepWritten"].as_bool().unwrap_or(false) { err(&format!("--log needs --out <the STEP that export wrote>; {} is not here", step.display()), &[]); } v["exitCode"] = json!(0); v["exitCodeSource"] = json!("assumed 0: the export ran elsewhere and wrote the STEP"); v }+                None => {+                    let _ = std::fs::create_dir_all(step.parent().unwrap_or(Path::new(".")));+                    let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());+                    step_export::run(&board_path, &step, &kicad)+                }+            };+            write_json(&step.with_extension("export.json"), &rec);+            r.outcome("step-export", rec.clone());+            r.save().unwrap();+            let (g, m, o) = (rec["geometryWarnings"].as_u64().unwrap_or(0), rec["modelWarnings"].as_u64().unwrap_or(0), rec["otherWarnings"].as_u64().unwrap_or(0));+            if let Some(open) = step_export::open_issue(&rec) {+                let spots: Vec<String> = rec["diagnostics"].as_array().into_iter().flatten().map(|d| format!("{}: {}{}", d["kind"].as_str().unwrap_or(""), d["message"].as_str().unwrap_or(""), d["location"].as_object().map(|l| format!(" at z {} box ({}, {})-({}, {}) near {}", l["z"], l["x0"], l["y0"], l["x1"], l["y1"], d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).unwrap_or_default())).take(12).collect();+                err(&format!("STEP export recorded with open issues: {open}\n  {}", spots.join("\n  ")), &[format!("Exit status {} and the file are recorded separately from these diagnostics ({}).", rec["exitCode"], step.with_extension("export.json").display()), "Do not re-export without silkscreen or mask to hide them. Review the STEP at each spot; if it is complete, `step-export --ack --why \"...\"`; if not, fix the board geometry and export again.".into()]);+            }+            ok(&format!("STEP export {}: exit {}, {g} geometry, {m} model, {o} other warning(s){}", step.display(), rec["exitCode"], if g > 0 { " (acknowledged)" } else { "" }), &[if m > 0 { "Model warnings: a part may be missing from the twin; check the models step.".into() } else { String::new() }, "Next: step2glb convert, then `board-glb copper` (skill aiflow-molecule).".into()]);+        }         Cmd::Export { board, out } => {             thread(&cli); let mut r = load_run(&cli);             let lane = sequence::lane(&dir, &r.data);@@ -2419,14 +2536,17 @@ fn main() {                     let mut hints: Vec<String> = report["files"].as_array().into_iter().flatten().filter_map(|f| f.as_str()).map(|f| format!("file: {f}")).collect();                     hints.extend(report["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str()).map(str::to_string));                     hints.push("Tell the human where every file is. Bottom-side CPL rotations follow kicad-cli's convention and were counted, not compared; check them against the fab's preview.".into());-                    ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"]), &hints);+                    let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+                    let checked = verified::run_items(&r, facts.as_ref());+                    ok(&format!("manufacturing files in {}, checked against the board: Gerbers ({} copper layers of {}, pours present as regions on {} poured layer(s)), drill ({} hits = vias and drilled pads), pick and place ({} parts), BOM ({} lines)\n{}", out_dir.display(), report["gerbers"]["copperLayers"], report["gerbers"]["boardCopperLayers"], report["fillsGerbers"]["layersWithPours"].as_object().map(|o| o.len()).unwrap_or(0), report["drill"]["hits"], report["cpl"]["refs"], report["bom"]["lines"], verified::message(&checked)), &hints);                 }                 Err((report, problems)) => {                     let _ = std::fs::create_dir_all(&out_dir);                     write_json(&out_dir.join("export-check.json"), &report);                     r.log("export-refused", json!({"board": board_path, "dir": out_dir, "problems": problems}));                     r.save().unwrap();-                    err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &["Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".into()]);+                    let fill_hint = if problems.iter().any(|p| p.contains("filled polygon") || p.contains("G36")) { fills::HINT.to_string() } else { String::new() };+                    err(&format!("manufacturing files do not check out against the board: {}", problems.join("; ")), &[fill_hint, "Fix the board or its footprint attributes (DNP, exclude from BOM/position files) and run `export` again; never hand the human files that do not match the board.".into(), "kicad-cli: ADOM_AIFLOW_KICAD_CLI picks it; in a container with no native KiCad, ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop>.".into()]);                 }             }         }@@ -2503,6 +2623,9 @@ fn main() {                     if let Some(w) = warning { attach_hints.push(format!("Fab rules: {w}.")); }                     if let Err((e, hint)) = checks::rules_gate(&p, &copy.display().to_string(), layers) { attach_hints.push(format!("Fab rules: {e}. {hint}")); }                 }+                let rc_flags = rules_card::at_start(&dir, &read_json(spec), &copy, &r.data["fabRules"]);+                for f in &rc_flags { eprintln!("WARNING: {f}"); }+                attach_hints.push(rules_card_hint(&rc_flags));                 ok(&format!("board attached to the intake run at {} for engine {engine}; the clock still counts from the prompt, {pt} ({:.1} min so far; steps before the board: {})", dir.display(), r.elapsed_minutes(), if steps.is_empty() { "none".into() } else { steps.join(", ") }), &attach_hints);                 print_next(&cli);                 return;@@ -2521,6 +2644,9 @@ fn main() {             else { start_hints.push("No --sch: finish will not ask for schematic-to-board netlist equivalence. When the board came from a schematic, start with --sch <file.kicad_sch> (or run `netlist check --sch <file>` to record it).".into()); }             let spec_v = read_json(spec);             if let Some((p, layers, _)) = checks::rules_target(&spec_v, &Value::Null) { if let Err((e, hint)) = checks::rules_gate(&p, board, layers) { start_hints.push(format!("Fab rules: {e}. {hint}")); } }+            let rc_flags = rules_card::at_start(&dir, &spec_v, &copy, &r.data["fabRules"]);+            for f in &rc_flags { eprintln!("WARNING: {f}"); }+            start_hints.push(rules_card_hint(&rc_flags));             if spec_v.get("molecule").is_some() { start_hints.push("The spec is a molecule: `adom-aiflow molecule check` before placement; place check, gate and finish run it too.".into()); }             start_hints.extend(["Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);             ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &start_hints);@@ -2544,7 +2670,7 @@ fn main() {             let _ = TURN.set((dir.clone(), idx));             let profile = preboard::named_profile(&requirements_of(&r));             ok(&format!("intake run started at {}; the clock started at the prompt, {prompt_time}; step intake is open and there is no board yet", dir.display()), &[-                "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step records a clip of the desktop until a PCB editor is open.".into(),+                "Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step you declared, and with --target each step before the board films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else your own terminal window (nothing meaningful on screen is a recorded skip): work in a visible window.".into(),                 match profile { Some(p) => format!("Sourcing profile {p} (from the requirements): `sourcing check --bom design/bom.csv` gates the BOM; finish requires it."), None => "No sourcing profile named yet: write requirements.json with fab.target `fab` (default) or `jlcpcb` (aiflow-intake), then `sourcing check --bom <csv> --requirements requirements.json`.".into() },                 "Record the design record as it lands: `evidence add --kind calcs --file design/calcs.json`, `evidence add --kind simulation --file sim/<results>.json` (a failing simulation is refused), `evidence add --kind vendor-model --file <result>` for a vendor PSpice/LTspice run (adom/adom-spice-skillpack).".into(),                 "When the board exists: `start --board B.kicad_pcb --spec spec.json --engine <you> [--target <box> --remote-board <path>]` on this same --run attaches it; the clock stays at the prompt.".into(),@@ -2570,6 +2696,7 @@ fn main() {             let board_path = board.clone().or_else(|| (!r.board_pending()).then(|| r.current_board()));             let refs = board_path.as_ref().map(|p| Board::load(p).map(|b| preboard::board_refs(&b)).unwrap_or_else(|e| err(&format!("board {p}: {e}"), &[])));             let mut out = preboard::sourcing_check(&text, refs.as_ref(), &prof, qty);+            design_evidence::exact_mpn_evidence(&text, design_evidence::require_exact(&spec, &req), &mut out);             let bomp = std::fs::canonicalize(bom).map(|p| p.display().to_string()).unwrap_or(bom.clone());             out["bom"] = json!(bomp);             out["bomSha256"] = json!(library_review::hash(Path::new(bom)).unwrap_or_default());@@ -2621,7 +2748,8 @@ fn main() {                 std::fs::copy(&src, &dest).unwrap_or_else(|e| err(&format!("copy into the run: {e}"), &[]));                 std::fs::canonicalize(&dest).unwrap_or(dest)             };-            let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks});+            let review: Vec<String> = match (kind.as_str(), serde_json::from_str::<Value>(&text)) { ("calcs", Ok(v)) => design_evidence::calcs_warnings(&v), ("simulation", Ok(v)) => design_evidence::simulation_warnings(&requirements_of(&r), &v), _ => Vec::new() };+            let entry = json!({"kind": kind, "file": canon.display().to_string(), "copy": copy.display().to_string(), "sha256": sha, "bytes": text.len(), "note": note, "step": step, "at": now(), "pass": pass, "checks": checks, "warnings": review});             if !r.data["evidence"].is_array() { r.data["evidence"] = json!([]); }             r.data["evidence"].as_array_mut().unwrap().push(entry.clone());             r.log("evidence", entry);@@ -2633,7 +2761,9 @@ fn main() {                 "simulation" => if pass.is_none() { "This result states no pass or fail: add a top-level \"pass\" or a \"checks\" list against the requirements so the gate can read it (the file is recorded either way).".to_string() } else { "Next: the vendor model if the brief asks for one (`evidence add --kind vendor-model`), then `step schematic`.".to_string() },                 _ => format!("Vendor PSpice/LTspice runs belong to the adom/adom-spice-skillpack (`adom-wiki pkg install adom/adom-spice-skillpack`: PSpice for TI and LTspice on a Windows desktop through Adom Bridge); this records its result{}. Publish results, never solver speed numbers.", if pass == Some(false) { ", which says it fails: write the design conclusion in sim/<vendor>-validation.md" } else { "" }),             };-            ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &[hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()]);+            let mut hints = vec![hint, "finish requires the kinds the spec lists in \"requireEvidence\" (e.g. [\"calcs\", \"simulation\"]).".into()];+            hints.extend(review.iter().map(|w| format!("warning: {w}")));+            ok(&format!("{kind} evidence recorded ({n} of that kind in this run): {} sha256 {}, {state}", canon.display(), &sha[..12.min(sha.len())]), &hints);         }         Cmd::Clips { page, push } => {             thread(&cli);@@ -2676,13 +2806,14 @@ fn main() {             let dest=dir.join(format!("board-{n}.kicad_pcb"));             std::fs::copy(board,&dest).unwrap_or_else(|e|err(&e.to_string(),&[]));             aiflow_copper::copy_project_rules(board,&dest.display().to_string()).unwrap_or_else(|e|err(&e,&[]));-            r.add_board(&dest.display().to_string());+            let (carried, carry_note) = copper_carry::adopt(&mut r, &dest.display().to_string());             r.data["remoteBinding"]=Value::Null;-            r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary}));+            r.log("adopt-board",json!({"source":board,"board":dest,"evidence":evidence,"drc":summary,"copperCarried":carried}));             r.save().unwrap();-            ok("native-saved board adopted; historical snapshots preserved", &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]);+            if carried { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Silkscreen changed: record the silkscreen proof again (`silkscreen-audit --record`) and export again; finish reruns the full native DRC. Bind the desktop board again (bind-remote-board) before live commands.".into()]); }+            else { ok(&format!("native-saved board adopted; historical snapshots preserved; {carry_note}"), &["Run plan, then route: it keeps the board's own tracks and vias and plans only the connections they leave open (a fully routed board gets an empty plan that gate, land route and finish accept). Re-solve Fields before finish.".into()]); }         }-        Cmd::Parts { bom, show, dwell } => {+        Cmd::Parts { bom, show, dwell, project_lib } => {             let t = thread(&cli);             let mut r = load_run(&cli);             let bom = bom.clone().or_else(|| r.data["outcomes"]["sourcing"]["bom"].as_str().map(str::to_string))@@ -2701,8 +2832,17 @@ fn main() {                 if page.is_none() && !missing.contains(&mpn) { missing.push(mpn.clone()); }                 row["wiki"] = json!(page);             }+            // parts with no public page may come from the board's own project library (issue #66)+            let plib = project_lib.clone().or_else(|| spec_soft(&r)["projectLib"].as_str().or(requirements_of(&r)["projectLib"].as_str()).map(str::to_string));+            let mut why_missing: Vec<(String, String)> = Vec::new();+            if let Some(d) = &plib {+                let lib = project_lib::ProjectLib::load(Path::new(d)).unwrap_or_else(|e| err(&e, &["A project library is a folder with *.kicad_sym, *.pretty/*.kicad_mod, STEP models and provenance.json (see the aiflow-sourcing skill).".into()]));+                why_missing = project_lib::resolve(&mut rows, &lib);+                missing.retain(|m| why_missing.iter().any(|(w, _)| w == m));+            }+            let from_lib: Vec<String> = rows.iter().filter(|x| x["projectLib"].is_object()).filter_map(|x| x["mpn"].as_str().map(str::to_string)).collect();             let pass = missing.is_empty();-            r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing}));+            r.outcome("parts", json!({"pass": pass, "bom": std::fs::canonicalize(&bom).map(|p| p.display().to_string()).unwrap_or(bom.clone()), "parts": rows, "missing": missing, "projectLib": plib, "projectLibParts": from_lib, "projectLibGaps": why_missing.iter().map(|(m, w)| json!({"mpn": m, "why": w})).collect::<Vec<_>>()}));             r.save().unwrap();             if *show {                 // one named tab walks through the pages (never a tab per part)@@ -2713,11 +2853,12 @@ fn main() {                     std::thread::sleep(std::time::Duration::from_secs(*dwell));                 }             }-            let list: Vec<String> = rows.iter().map(|x| format!("  {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().unwrap_or("MISSING"))).collect();+            let list: Vec<String> = rows.iter().map(|x| format!("  {:<22} {:<10} {}", x["mpn"].as_str().unwrap_or(""), x["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default(), x["wiki"].as_str().map(str::to_string).or_else(|| x["projectLib"].is_object().then(|| "project library".to_string())).unwrap_or_else(|| "MISSING".into()))).collect();             if !pass {-                err(&format!("{} of {} parts have no Adom wiki component page:\n{}", missing.len(), rows.len(), list.join("\n")), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into()]);+                let gaps: Vec<String> = why_missing.iter().map(|(m, w)| format!("  {m}: project library: {w}")).collect();+                err(&format!("{} of {} parts have no Adom wiki component page{}:\n{}{}", missing.len(), rows.len(), if plib.is_some() { " and no complete project-library entry" } else { "" }, list.join("\n"), if gaps.is_empty() { String::new() } else { format!("\n{}", gaps.join("\n")) }), &["Make each missing part's page (the wiki-component skill: symbol, footprint and STEP from the datasheet), then run `parts` again. The project page and the KiCad library are built from these pages.".into(), "A part whose page cannot be public yet: `parts --project-lib <dir>` with its symbol, footprint, STEP model and a provenance.json entry (aiflow-sourcing skill).".into()]);             }-            ok(&format!("every part is on the wiki ({} parts):\n{}", rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);+            ok(&format!("every part is on the wiki{} ({} parts):\n{}", if from_lib.is_empty() { String::new() } else { format!(" or in the project library ({} from {})", from_lib.len(), plib.as_deref().unwrap_or("")) }, rows.len(), list.join("\n")), &["Next: `step project-page`, then `project-page --page <owner/slug> --create`: the board's page, revision 1, with the component table and the KiCad PCM repository.".into()]);         }         Cmd::ProjectPage { page, sync, create, existing, private, title, brief, replace_readme, no_push } => {             let t = thread(&cli);@@ -2735,22 +2876,30 @@ fn main() {             if !page.contains('/') { err("--page is <owner>/<slug>", &[]); }             let (spec, req) = (spec_soft(&r), requirements_of(&r));             let title = title.clone().or_else(|| req["title"].as_str().map(str::to_string)).or_else(|| spec["title"].as_str().map(str::to_string)).unwrap_or_else(|| sequence::title(&r.data));-            let brief = brief.clone().or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());-            let cfg = json!({"run": dir.display().to_string(), "page": page, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0"});+            let brief = brief.as_deref().map(preboard::brief_text).or_else(|| req["summary"].as_str().or(req["description"].as_str()).map(str::to_string)).unwrap_or_else(|| title.clone());+            let cfg = json!({"run": dir.display().to_string(), "page": page, "title": title, "brief": brief, "summary": req["description"].as_str().unwrap_or(""), "parts": parts["parts"], "create": create, "push": !no_push, "private": private, "replaceReadme": replace_readme, "existing": existing, "version": "1.0.0",+                "libRef": r.data["cloudLibrary"].as_str().or(r.data["outcomes"]["project-page"]["cloudLibrary"].as_str())});             let cfgp = dir.join("project-page").join("config.json");             let _ = std::fs::create_dir_all(cfgp.parent().unwrap());             std::fs::write(&cfgp, serde_json::to_string_pretty(&cfg).unwrap()).unwrap();             let o = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/project-page.py")).arg(&cfgp).stdin(std::process::Stdio::null()).output().unwrap_or_else(|e| err(&format!("python3: {e}"), &[]));             let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| json!({"ok": false, "error": String::from_utf8_lossy(&o.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()}));-            if v["ok"] != true { err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &[]); }+            if v["ok"] != true {+                if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); r.save().unwrap(); }+                err(&format!("project page: {}", v["error"].as_str().unwrap_or("failed")), &["Nothing was published. Rate-limited adds are retried with backoff; rerun `project-page` after the wait and it reuses the same Cloud Library (never a second one).".into()]);+            }             r.outcome("project-page", v.clone());             r.data["projectPage"] = json!(page);+            if let Some(l) = v["cloudLibrary"].as_str().filter(|l| l.starts_with("lib_")) { r.data["cloudLibrary"] = json!(l); }             r.save().unwrap();             if !no_push {                 let _ = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "Project page", "--url", v["url"].as_str().unwrap_or(""), "--ai-thread", &t, "--reason", "adom-aiflow: the board's project page, revision 1"]).stdin(std::process::Stdio::null()).output();             }-            ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {}", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or("")),-               &[format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]);+            let c = &v["counts"];+            let mut hints: Vec<String> = v["adds"]["notes"].as_array().into_iter().flatten().filter_map(|n| n.as_str().map(str::to_string)).collect();+            if let Some(pl) = v["projectLibParts"].as_array().filter(|a| !a.is_empty()) { hints.push(format!("from the board's project library: {}", pl.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }+            ok(&format!("project page {} ({}): README revision 1 with {} parts, KiCad PCM repository {} ({} symbols, {} footprints, {} models for {} distinct parts; symbol library version {}, KiCad load check: {})", v["url"].as_str().unwrap_or(""), if v["created"] == true { "created" } else { "updated" }, v["parts"], v["repositoryUrl"].as_str().unwrap_or(""), c["symbols"], c["footprints"], c["models"], c["distinctParts"], v["libraryCheck"]["symVersion"], v["libraryCheck"]["kicadCli"].as_str().unwrap_or("?")),+               &[hints, vec![format!("Next: `step libraries`, then install the parts in KiCad: kicad_pcm_add_repository {{\"url\":\"{}\"}} and kicad_pcm_install {{\"repositoryUrl\":\"{}\",\"identifier\":\"{}\"}}, restart KiCad, then `pcm check`.", v["repositoryUrl"].as_str().unwrap_or(""), v["repositoryUrl"].as_str().unwrap_or(""), v["identifier"].as_str().unwrap_or(""))]].concat());         }         Cmd::Pcm { what: _ } => {             thread(&cli);@@ -2773,25 +2922,19 @@ fn main() {             let kc = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());             let sdir = dir.join("schematic"); let _ = std::fs::create_dir_all(&sdir);             let erc = sdir.join("erc.json");-            let _ = std::process::Command::new(&kc).args(["sch", "erc", "--format", "json", "--severity-error", "--output"]).arg(&erc).arg(sch).stdin(std::process::Stdio::null()).output();-            let ercv: Value = std::fs::read_to_string(&erc).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);-            let erc_errors: Vec<String> = ercv["sheets"].as_array().cloned().unwrap_or_default().iter().flat_map(|sh| sh["violations"].as_array().cloned().unwrap_or_default()).filter(|v| v["severity"] == "error").map(|v| v["description"].as_str().unwrap_or("").to_string()).collect();+            let (_, erc_errors) = schematic_check::run_erc(&kc, sch, &erc).unwrap_or_else(|e| err(&format!("schematic check: {e}"), &["ERC must run and its report come back before the schematic can pass. In a container: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote KICAD_REMOTE_TARGET=<desktop> (it runs `sch erc` on the desktop's KiCad).".into()]));             let net = sdir.join("netlist.net");             export_sch_netlist(sch, &net).unwrap_or_else(|e| err(&e, &[]));-            let root = aiflow_board::sx::parse(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));-            let mut on_sheet: BTreeMap<String, (String, String)> = BTreeMap::new();-            for comp in root.find_all("comp") {-                let Some(rf) = comp.find("ref").and_then(|n| n.atom(1)) else { continue };-                let lib = comp.find("libsource").and_then(|l| l.find("lib")).and_then(|n| n.atom(1)).unwrap_or("").to_string();-                let fp = comp.find("footprint").and_then(|n| n.atom(1)).unwrap_or("").to_string();-                on_sheet.insert(rf.to_string(), (lib, fp));-            }+            let on_sheet = schematic_check::sheet_symbols(&std::fs::read_to_string(&net).unwrap_or_default()).unwrap_or_else(|e| err(&format!("netlist: {e}"), &[]));             let lib = r.data["outcomes"]["project-page"]["libName"].as_str().map(|l| format!("PCM_{l}"));             let bom_refs: Vec<String> = r.data["outcomes"]["parts"]["parts"].as_array().cloned().unwrap_or_default().iter().flat_map(|p| p["refs"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();             let missing: Vec<String> = bom_refs.iter().filter(|rf| !on_sheet.contains_key(*rf)).cloned().collect();             let foreign: Vec<String> = match &lib { Some(l) => on_sheet.iter().filter(|(rf, (sl, fp))| bom_refs.contains(rf) && (sl != l || !fp.starts_with(&format!("{l}:")))).map(|(rf, (sl, fp))| format!("{rf} ({sl} / {fp})")).collect(), None => Vec::new() };             let extra: Vec<String> = on_sheet.keys().filter(|rf| !bom_refs.contains(rf)).cloned().collect();-            let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty();+            // issue #39: symbol polarity drawings, active-device pin types and readability (schematic_symbols.rs)+            let symbols = schematic_symbols::check(Path::new(sch), &spec_soft(&r)).unwrap_or_else(|e| json!({"ok": false, "errors": [format!("could not read the hierarchy: {e}")], "warnings": []}));+            schematic_symbols::record(&mut r, sch, &symbols);+            let pass = erc_errors.is_empty() && missing.is_empty() && foreign.is_empty() && symbols["ok"] == true;             let canon = std::fs::canonicalize(sch).map(|p| p.display().to_string()).unwrap_or(sch.clone());             r.outcome("schematic", json!({"pass": pass, "schematic": canon, "sha256": sha256_file(sch), "ercErrors": erc_errors, "symbols": on_sheet.len(), "missingRefs": missing, "notFromProjectLibrary": foreign, "notInBom": extra, "library": lib}));             if pass { r.data["schematic"] = json!(canon); }@@ -2801,10 +2944,14 @@ fn main() {                 if !erc_errors.is_empty() { why.push(format!("ERC: {} error(s): {}", erc_errors.len(), erc_errors.iter().take(6).cloned().collect::<Vec<_>>().join("; "))); }                 if !missing.is_empty() { why.push(format!("not on the sheet: {}", missing.join(", "))); }                 if !foreign.is_empty() { why.push(format!("not from the project library {}: {}", lib.clone().unwrap_or_default(), foreign.join(", "))); }-                err(&format!("schematic check failed:\n  {}", why.join("\n  ")), &["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".into()]);+                why.extend(symbols["errors"].as_array().into_iter().flatten().filter_map(|e| e.as_str().map(str::to_string)));+                let mut hints = vec!["Fix it in KiCad's schematic editor on camera (kicad_sch_place_symbol / kicad_sch_wire / kicad_sch_label / kicad_sch_set_property), save, and check again.".to_string()];+                hints.extend(schematic_symbols::render(&symbols, true).1);+                hints.push(schematic_symbols::warning_hint(&symbols));+                err(&format!("schematic check failed:\n  {}", why.join("\n  ")), &hints);             }             ok(&format!("schematic {canon}: {} symbols, ERC clean, every BOM reference placed from {}{}", on_sheet.len(), lib.unwrap_or_else(|| "the project library".into()), if extra.is_empty() { String::new() } else { format!(" ({} not in the BOM: {})", extra.len(), extra.join(", ")) }),-               &["Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]);+               &[schematic_symbols::warning_hint(&symbols), "Next: make the board from it (KiCad: Update PCB from Schematic), draw the outline, save, bind it (`bind-remote-board` / `start --board`), then `step components`.".into()]);         }         Cmd::Components { etch } => {             thread(&cli);@@ -2924,6 +3071,9 @@ fn main() {                     if let Some(f)=molecule_findings(&spec_of(&r),&b,Some(&r)) {                         if !f.errors.is_empty() { err(&format!("placement violates molecule: {}",f.errors.join("; ")),&[]); }                     }+                    let spec_i=spec_of(&r);+                    let intent_lines=placement_intent::check(&mut r,&dir,&spec_i,&b,"place verify").unwrap_or_else(|e|{r.save().ok();err(&e,&[placement_intent::HINT.into()])});+                    for l in &intent_lines { eprintln!("{l}"); }                     let rb=r.data["remoteBoard"].as_str().filter(|s|!s.is_empty()).unwrap_or_else(||err("place verify requires the named native board",&[])).to_string();                     let br=bridge_of(&r,&cli);                     let before=br.call("kicad_placement_state",&json!({"filePath":rb,"detail":true}));@@ -2945,8 +3095,12 @@ fn main() {                     let spec = spec_of(&r);                     let courtyard_edge=spec["placement"]["courtyardEdgeClearance"].as_f64().unwrap_or(0.0);                     let chains = aiflow_place::chains_from_spec(&spec).unwrap_or_else(|e| err(&format!("spec.chains: {e}"), &[]));+                    let intent = placement_intent::of_spec(&spec);+                    let (wishes, anchor_lines) = aiflow_place::intent::apply_anchors(&b, &wishes, &intent);                     let (wishes, chain_plans, chain_lines) = aiflow_place::apply_chains(&b, &wishes, &chains, 0.05);-                    let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);+                    let (mv, mut lines) = aiflow_place::pack(&b, &wishes, 0.05, courtyard_edge);+                    lines.extend(anchor_lines);+                    lines.extend(placement_intent::pack_lines(&b, &mv, &intent));                     write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());                     let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();                     let verdicts = aiflow_place::chain_report(&b, &mv, &chain_plans);@@ -2978,6 +3132,8 @@ fn main() {                             err(&format!("the moved board is not a conforming molecule:\n{}", f.lines.iter().filter(|l| !l.starts_with("OK")).cloned().collect::<Vec<_>>().join("\n")), &h);                         }                     }+                    let spec_i = spec_of(&r);+                    if let Err(e) = placement_intent::check(&mut r, &dir, &spec_i, &nb, "place check") { r.save().ok(); err(&e, &[placement_intent::HINT.into(), format!("The run's current board is unchanged; the moved board is {}.", outp.display())]); }                     let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));                     let baseline = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&format!("baseline DRC failed: {e}"), &[]));                     let s = aiflow_copper::summarize(&drc, Some(&baseline));@@ -2988,9 +3144,16 @@ fn main() {                     if s["newErrors"].as_u64() != Some(0) {                         err(&format!("the moved board adds {} native DRC violations: {}", s["newErrors"], s["newErrorTypes"]), &[format!("Change the wishes and pack again; inspect {}. Findings are matched by identity, not an inherited-error count.", outp.display())]);                     }+                    // issue #39: courtyards present and clear, part bodies clear, whatever the project severities say+                    let mech_line = if mechanical::applies(&outp.display().to_string()) {+                        let mech = mechanical::check_board(&nb, &outp.display().to_string(), &project_dir_of(&r), &spec_soft(&r));+                        for n in &mech.severity_notes { eprintln!("Warning: {n}"); }+                        if !mech.errors().is_empty() { err(&format!("the moved board fails the courtyard and body checks:\n  {}", mech.errors().join("\n  ")), &[mech.hint(), format!("The run's current board is unchanged; see {}.", outp.display())]); }+                        format!("; courtyards on all {} footprint(s) with pads and clear, {} part bodies clear", mech.with_courtyard, mech.bodies_checked)+                    } else { "; courtyards and bodies not checked (Fusion board: check them in Fusion's DRC)".to_string() };                     r.add_board(&outp.display().to_string());                     r.save().unwrap();-                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited) and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);+                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited){mech_line} and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);                 }                 "land" => {                     let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));@@ -3047,10 +3210,15 @@ fn main() {                 err("spec.planeUnder has an entry that does not parse", &["Use {\"layer\": \"B.Cu\", \"net\": \"GND\", \"around\": [\"U1\", \"C2\"], \"margin\": 1.0} or {\"layer\": ..., \"net\": ..., \"polygon\": [[x, y], ...]}.".into()]);             }             let preexisting = aiflow_router::plane_under::issues(&b, &regions);+            decoupling_check::refuse_bad_spec(&spec, &b);             if spec["stub"].as_f64().map(|s|s<rules.stub).unwrap_or(false){eprintln!("Hint: escape width raised to {} mm to satisfy minTrackWidth (default: track width). Declare a smaller manufacturing minimum only when the board process supports it.",rules.stub);}             r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));             let mut router = aiflow_router::Router::new(b, rules, true);-            let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+            let (mut plan, mut report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+            // issue #39: staircases to clean 45 degree runs before the gate (the router's own clearance check decides)+            let simp = aiflow_router::simplify::simplify_plan(&mut router, &mut plan);+            report["simplify"] = simp.clone();+            let simp_line = format!("simplified: {} staircase(s) replaced, {} kept (no clear 45 degree path), {} collinear point(s) merged; vertices {} -> {} (per net in plan-route-report.json \"simplify\")", simp["staircasesReplaced"], simp["staircasesKept"], simp["collinearMerged"], simp["verticesBefore"], simp["verticesAfter"]);             std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();             let planp = dir.join("plan-route.json");             let bc = report["boardCopper"].clone();@@ -3070,6 +3238,8 @@ fn main() {             }             write_json(&planp, &plan_doc);             write_json(&dir.join("plan-route-report.json"), &report);+            let dec = decoupling_check::review_route(&spec, &router.board, &plan_doc, &report, r.outcomes().get("pour"));+            decoupling_check::record(&mut r, &dir, &dec);             let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();             r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);             r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>(), "boardCopper": if has_copper { bc.clone() } else { Value::Null }, "planeUnder": regions.len()}));@@ -3097,11 +3267,12 @@ fn main() {                 let region_hint = if regions.is_empty() { String::new() } else { "A planeUnder region is a keepout for every other net on its layer: a pin inside it can only leave on the other layer. Shrink the margin, list fewer parts, or give the region a polygon that leaves that pin a way out.".to_string() };                 err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), region_hint, policy_hint, "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);             }+            decoupling_check::refuse_errors("route", &dec);             if has_copper && plan.is_empty() {                 ok(&format!("nothing to route: the board's own copper already joins every net ({} track pieces, {} vias); empty plan {}", bc["trackPieces"], bc["vias"], planp.display()), &["Next: adom-aiflow gate (DRC on the board as it is), then land route (lands nothing and records it), pour, land vias, land pours.".into(), policy_hint]);             } else {                 let copper_line = if has_copper { format!("; the board's own copper was kept and {} connection(s) it left open were planned", bc["openConnections"]) } else { String::new() };-                ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);+                ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}{copper_line}\n{simp_line}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into(), policy_hint]);             }         }         Cmd::Pour { plan } => {@@ -3119,6 +3290,7 @@ fn main() {             let zp = dir.join("plan-pours.json");             let vp = dir.join("plan-vias.json");             write_json(&zp, &json!({"zones": zones}));+            pourverify::record_plan(&mut r.data, &zp, "pour").unwrap_or_else(|e| err(&e, &[]));             write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));             r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);             r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));@@ -3209,13 +3381,21 @@ fn main() {             // the switch node: short, and never under another part (switchnode.rs)             let sw_issues = { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); switchnode::findings(&spec_g, &rb) };             s["switchNodeIssues"] = json!(sw_issues);+            let dec = decoupling_check::review_text("gate", &spec_g, &routed.display().to_string(), &text, !adds_copper, if adds_copper && !aiflow_copper::native_drc_available() { "an approximate raster fill by the gate, not a native refill" } else { "the zone fills saved in the board file" }, &plan_v, None, r.outcomes().get("pour"));+            if let Some(d) = &dec { s["decoupling"] = d.value.clone(); }+            // issue #39: courtyards and bodies (aiflow's own geometry), staircases left in the copper (a warning)+            let mech = mechanical::applies(&r.current_board()).then(|| { let rb = Board::from_text(&routed.display().to_string(), text.clone()).unwrap_or_else(|e| err(&e, &[])); mechanical::check_board(&rb, &routed.display().to_string(), &project_dir_of(&r), &spec_g) });+            let stairs = Board::from_text(&routed.display().to_string(), text.clone()).map(|rb| aiflow_router::simplify::board_staircases(&rb)).unwrap_or_default();+            s["mechanical"] = mech.as_ref().map(|m| m.to_json()).unwrap_or(json!("not checked: Fusion board"));+            s["staircases"] = json!(stairs);+            decoupling_check::record(&mut r, &dir, &dec);             if plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) { s["note"] = plan_v.get("note").cloned().unwrap_or(json!("empty plan: the board is gated as it is")); }             if !plan_notes.is_empty() { s["planNormalised"] = json!(plan_notes); }             write_json(&dir.join("gate.json"), &s);             let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));             let npu = pu_issues.len() as u64;             r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh, "planeUnderIssues": npu})), None);-            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));+            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "planeUnderIssues": npu, "courtyardsOk": mech.as_ref().map(|m| m.courtyards_ok()), "bodiesOk": mech.as_ref().map(|m| m.bodies_ok()), "staircases": stairs.len(), "routedBoard": routed.display().to_string(), "plan": planp, "planNormalised": plan_notes}));             r.save().unwrap();             if !sw_issues.is_empty() {                 r.outcome("switch-node", json!({"issues": sw_issues}));@@ -3249,15 +3429,22 @@ fn main() {                 let pu_hint = if npu > 0 { format!("Also {npu} planeUnder issue(s): {}", pu_issues.iter().take(4).cloned().collect::<Vec<_>>().join("; ")) } else { String::new() };                 err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[pu_hint, format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);             }+            decoupling_check::refuse_errors("gate", &dec);+            if let Some(m) = &mech {+                for n in &m.severity_notes { eprintln!("Warning: {n}"); }+                if !m.errors().is_empty() { err(&format!("gate failed: courtyards or part bodies:\n  {}", m.errors().join("\n  ")), &[m.hint(), "See gate.json \"mechanical\". Courtyard overlap is an error here whatever severity the project gives it.".into()]); }+            } else { eprintln!("Warning: courtyards and part bodies not checked: a Fusion board carries no courtyard geometry aiflow reads; check them in Fusion's DRC"); }+            if !stairs.is_empty() { eprintln!("Warning: {} staircase run(s) left in the copper (tiny alternating 0/90 degree steps; redraw as one 45 degree segment):\n  {}", stairs.len(), stairs.iter().take(8).cloned().collect::<Vec<_>>().join("\n  ")); }+            let checked = match &mech { Some(m) => format!("; courtyards present and clear on {} footprint(s), {} part bodies clear", m.with_courtyard, m.bodies_checked), None => "; courtyards and bodies not checked (Fusion board)".into() };             let empty = plan_v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false);-            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}; copper {}", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);+            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected{}{checked}; copper {}\nThis is the routing gate (DRC, connectivity, courtyards, bodies), not a fabrication check: pours in the saved board, Gerbers and human review come later.", if pu.is_empty() { String::new() } else { format!(", {} planeUnder region(s) clear", pu.len()) }, s["copper"]), &[if empty { "The plan is empty: the board's own copper already joins every net. Next: adom-aiflow land route (records that nothing needs landing), then pour, land vias, land pours.".into() } else { "Next: adom-aiflow land route (then pour, land vias, land pours).".into() }]);         }         Cmd::Land { what, plan, moves, pause_ms, from } => {             let mut r = load_run(&cli);             if what == "route" {                 // a gated empty plan (the board's own copper already joins every net): nothing to land, no bridge call                 let gate = r.outcomes().get("gate").cloned().unwrap_or(Value::Null);-                let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0;+                let gate_ok = gate["newErrors"].as_u64() == Some(0) && gate["unconnected"].as_u64() == Some(0) && gate["planeUnderIssues"].as_u64().unwrap_or(0) == 0 && mechanical::gate_clear(&gate);                 let planp = plan.clone().or_else(|| gate["plan"].as_str().map(str::to_string));                 let empty_after_normalising = |p: &String| { let (v, _) = read_plan_normalised(p, &board_of(&r)); v["nets"].as_array().map(|a| a.is_empty()).unwrap_or(false) };                 if let Some(planp) = planp.filter(|p| gate_ok && plan.is_none() && empty_after_normalising(p)) {@@ -3294,7 +3481,7 @@ fn main() {                 }                 "route" | "vias" => {                     let planp = if what == "route" {-                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0;+                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("planeUnderIssues")).and_then(|v| v.as_u64()).unwrap_or(0) == 0 && r.outcomes().get("gate").map(mechanical::gate_clear).unwrap_or(false);                         if !gate_ok && plan.is_none() {                             err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);                         }@@ -3388,6 +3575,7 @@ fn main() {                     let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));                     let b = board_of(&r);                     let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));+                    pourverify::landable(&zones).unwrap_or_else(|e| err(&e, &["Write that zone natively in KiCad, save and fill it, then accept it with `verify-pours --plan <plan>` (add --adopt --why to own it). Nothing was landed.".into()]));                     r.stage_start("land-pours", Some("binary"), None);                     let before = br.zone_state(&rb, true);                     if !Bridge::ok(&before) { err("cannot read settled zones before landing", &[before.to_string()]); }@@ -3435,25 +3623,10 @@ fn main() {                 other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),             }         }-        Cmd::VerifyPours { plan } => {+        Cmd::VerifyPours { plan, adopt, why, record_plan } => {+            if *adopt || *record_plan { thread(&cli); }             let mut r = load_run(&cli);-            let board_hash = pourverify::hash(Path::new(&r.current_board())).unwrap_or_else(|e|err(&e,&[]));-            let plan_hash = pourverify::hash(Path::new(plan)).unwrap_or_else(|e|err(&e,&[]));-            let b = board_of(&r);-            let mut proof = pourverify::check(&b, &read_json(plan)).unwrap_or_else(|e| err(&e, &[]));-            if pourverify::hash(Path::new(&r.current_board())).ok().as_deref()!=Some(&board_hash)-                || pourverify::hash(Path::new(plan)).ok().as_deref()!=Some(&plan_hash) {-                err("board or plan changed during verification; retry after the writer finishes", &[]);-            }-            proof["boardSha256"] = json!(board_hash);-            proof["specSha256"] = json!(pourverify::hash(Path::new(r.data["spec"].as_str().unwrap_or(""))).unwrap_or_else(|e|err(&e,&[])));-            proof["planSha256"] = json!(plan_hash);-            proof["plan"] = json!(std::fs::canonicalize(plan).unwrap_or_else(|e|err(&e.to_string(),&[])));-            write_json(&dir.join("verify-pours.json"), &proof);-            r.outcome("land-pours", proof.clone());-            r.save().unwrap();-            if proof["pass"] != true { err(&format!("saved pours do not match the plan: {}",proof["failed"]), &["Review the actual geometry and intended plan; never replay pours merely to restore evidence. No board or ownership was changed.".into()]); }-            ok("existing saved pours match the reviewed plan; no copper or ownership changed", &["Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof.".into()]);+            match pourverify::command(&mut r, &dir, plan, *adopt, why.as_deref(), *record_plan) { Ok((m, h)) => ok(&m, &h), Err((m, h)) => err(&m, &h) }         }         Cmd::Measure => {             let mut r = load_run(&cli);@@ -3473,8 +3646,9 @@ fn main() {             r.save().unwrap();             ok(&format!("copper kept per layer ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);         }-        Cmd::Analyze { what, plan, fields } => {-            thread(&cli);+        Cmd::Analyze { what, plan, fields, ack, why } => {+            let t = thread(&cli);+            if !ack.is_empty() && what != "current" { err("--ack acknowledges a current-density peak: `analyze current --ack [email protected] --why ...`", &[]); }             let mut r = load_run(&cli);             let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));             // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot@@ -3487,7 +3661,7 @@ fn main() {             let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };             let name = format!("analyze-{what}");             r.stage_start(&name, Some("binary"), None);-            let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+            let (mut heuristic_passed, mut res) = match what.as_str() { "current" => aiflow_analyze::analyze_current_on_board(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now)), &board_now), "thermal" => aiflow_analyze::analyze_thermal_on_board(&plan_v, &spec, zs.as_ref(), &board_now), other => err(&format!("analyze {other}: use current or thermal"), &[]) };             let mut fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));             // provenance: a fields.json is evidence only when it is the output of a solve aiflow ran or watched;             // any other file (copied, edited, or written to look like one) is solved again here, same inputs@@ -3502,18 +3676,52 @@ fn main() {                     Err(e) => { unobserved = Some(format!("{} was not produced by an Adom Fields solve this run watched, and adom-fields could not start ({e}): install adom-fields, then `adom-aiflow exec -- adom-fields {}`", fields_file.display(), args.join(" "))); }                 }             }-            let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {+            if !ack.is_empty() && (unobserved.is_some() || !fields_file.is_file()) { err("--ack needs a same-input Fields solve this run watched to read the peak from; analyze current without --ack first", &[]); }+            let mut expired_acks: Vec<Value> = Vec::new();+            let mut sink_notes: Vec<String> = Vec::new();+            let screen=if let Some(u)=unobserved.clone() { Err(u) } else if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(Vec::new()) } else if fields_file.is_file() {                 let f=read_json(&fields_file.display().to_string());-                let board_bytes=std::fs::read(&solved_board).unwrap_or_else(|e|err(&e.to_string(),&[]));+                if let Err(e)=std::fs::metadata(&solved_board) { err(&e.to_string(),&[]); }                 let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));-                aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e })+                // issue #70: record reviewed part-limited peaks, then screen with the live ones+                let live_acks = if what == "current" {+                    let copper = ack_copper(live.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board));+                    if !ack.is_empty() {+                        let spec_sha = { use sha2::{Digest, Sha256}; format!("{:x}", Sha256::digest(&spec_bytes)) };+                        if f["inputIdentity"]["spec"].as_str() != Some(spec_sha.as_str()) || copper_carry::carried_from(&r.data, f["inputIdentity"]["board"].as_str().unwrap_or(""), Path::new(&solved_board)).is_err() { err("--ack: the Fields report is not of this saved board (or its carried-over copper) and spec; solve again, then acknowledge", &[]); }+                        let copper = copper.clone().unwrap_or_else(|e| err(&format!("--ack: this board has no copper fingerprint to bind the acknowledgement to ({e})"), &[]));+                        let w = why.clone().unwrap_or_else(|| err("--ack needs --why \"<what limits the peak and why the board cannot change it>\"", &[]));+                        let limit = spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);+                        for a in ack.iter() {+                            let rec = aiflow_analyze::current_acks::record(a, &w, &t, &f, &board_now, &copper, limit, &now()).unwrap_or_else(|e| err(&e, &["The place is a pad of the part that limits the peak (REF.PAD on that net) or the Fields peak location x,y within 0.5 mm.".into()]));+                            let mut list: Vec<Value> = r.data["currentAcks"].as_array().cloned().unwrap_or_default();+                            list.retain(|x| x["target"] != rec["target"]);+                            list.push(rec);+                            r.data["currentAcks"] = json!(list);+                        }+                        r.save().unwrap();+                    }+                    let (live_a, expired) = aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &copper.unwrap_or_else(|_| "unavailable".into()), f["inputIdentity"]["spec"].as_str().unwrap_or(""));+                    expired_acks = expired;+                    live_a+                } else { Vec::new() };+                let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e });+                // issue #67: the same-input solve with a declared board-sink block stands in for the estimate+                if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+                s             } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };             let passed=heuristic_passed && screen.is_ok();+            let covered_acks = screen.as_ref().map(|c| c.clone()).unwrap_or_default();+            let sink_assumptions = aiflow_analyze::board_sink::assumptions(&res);             let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());             r.stage_end(&name, Some(json!({"pass": passed})), None);-            r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res}));+            r.outcome(&name, json!({"pass": passed, "heuristicPass":heuristic_passed, "fieldReport":fields_file, "fieldNote":field_note, "results": res, "acknowledgedPeaks": covered_acks, "expiredAcks": expired_acks, "boardSinkAssumptions": sink_assumptions, "boardSinkNotes": sink_notes}));             r.save().unwrap();-            let lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();+            let mut lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();+            for c in &covered_acks { lines.push(format!("acknowledged peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} by --ack {}: \"{}\" ({}); Fields reports one maximum per net, so check its density map for a second spot", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))); }+            for a in &expired_acks { lines.push(format!("expired acknowledgement (copper or spec changed since the review; acknowledge again if it still holds): {}", aiflow_analyze::current_acks::describe(a, "expired"))); }+            for a in &sink_assumptions { lines.push(format!("board-sink assumption: {a}")); }+            for n in &sink_notes { lines.push(format!("board sink: {n}")); }             // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough             let mut pour_nets: Vec<String> = Vec::new();             for x in &res {@@ -3559,20 +3767,25 @@ fn main() {             let o = r.outcomes().clone();             let mut missing = Vec::new();             let placement=o.get("place-land").cloned().unwrap_or(Value::Null);-            if placement["mode"]=="verified-existing" && !placement_verify::bound(&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))) {+            if placement["mode"]=="verified-existing" && copper_carry::placement_bound(&r.data,&placement,Path::new(&r.current_board()),Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err() {                 missing.push("place verify (saved board or spec changed; verify again)".into());             }             let gate = o.get("gate").cloned().unwrap_or(Value::Null);             if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {                 missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());             }+            if let Some(m) = decoupling_check::blocks_finish(o.get("decoupling")) { missing.push(m); }+            if let Some(m) = placement_intent::blocks_finish(o.get("placement-intent")) { missing.push(m); }+            if !mechanical::gate_clear(&gate) {+                missing.push("gate: courtyards and part bodies (missing or overlapping; see gate.json \"mechanical\")".to_string());+            }             if gate["planeUnderIssues"].as_u64().unwrap_or(0) > 0 {                 missing.push(format!("gate: {} planeUnder issue(s) (other-net copper inside a plane region; see gate.json)", gate["planeUnderIssues"]));             }             if !o.contains_key("land-route") {                 missing.push("land route".into());             }-            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || !pourverify::bound(p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or("")))).unwrap_or(true) {+            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false) || copper_carry::pours_bound(&r.data, p, Path::new(&r.current_board()), Path::new(r.data["spec"].as_str().unwrap_or(""))).map(|n| { if let Some(n) = n { eprintln!("{n}"); } }).is_err()).unwrap_or(true) {                 missing.push("land pours or verify-pours (every planned zone accepted; current inputs required)".into());             }             if !measurement_usable(o.get("measure")) {@@ -3582,22 +3795,27 @@ fn main() {             let live_board = live_board(&r, &cli, &dir);             let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());             let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };+            let mut acked_peaks: Vec<Value> = Vec::new();             for k in ["analyze-current", "analyze-thermal"] {                 if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {                     missing.push(format!("{k} (pass)"));                 } else {                     if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}                     let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());-                    let board_bytes=std::fs::read(&solved_board).unwrap_or_default();                     let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();                     if let Some(report)=report {                         let report_path=o[k]["fieldReport"].as_str().unwrap_or("").to_string();                         if !fields_observed(&r, Path::new(&report_path)) { missing.push(format!("{k}: the field report was not produced by an Adom Fields solve this run watched (analyze {} again; aiflow solves it)", k.trim_start_matches("analyze-"))); }-                        if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }+                        // issue #70: only acknowledgements of this copper fingerprint and this Fields spec identity cover a peak+                        let live_acks = if k == "analyze-current" { aiflow_analyze::current_acks::split_live(&r.data["currentAcks"], &ack_copper(live_board.as_ref().map(|l| l.1.as_str()).unwrap_or(&solved_board)).unwrap_or_else(|_| "unavailable".into()), report["inputIdentity"]["spec"].as_str().unwrap_or("")).0 } else { Vec::new() };+                        match copper_carry::field_screen_with_acks(&r.data,&report,&spec_of(&r),Path::new(&solved_board),&spec_bytes,k.trim_start_matches("analyze-"),&live_acks) { Err(reason) => missing.push(format!("{k}: {reason}")), Ok((n, c)) => { if let Some(n) = n { eprintln!("{n}"); } acked_peaks.extend(c); } }                     } else { missing.push(format!("{k}: same-input field report required; rerun analyze")); }                 }             }             if let Err(m) = netlist_gate(&r) { missing.push(m); }+            if let Err(m) = schematic_symbols::gate(&r) { missing.push(m); }+            let silk_warnings = silk_polarity::warnings(&board_now());+            for w in &silk_warnings { eprintln!("Warning: {w}"); }             let spec_f = spec_of(&r);             if let Some((p, layers, warning)) = checks::rules_target(&spec_f, &r.data["fabRules"]) {                 if let Some(w) = warning { eprintln!("Warning: {w}"); }@@ -3606,20 +3824,25 @@ fn main() {             if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {                 if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }             }+            // issue #39: courtyards, part bodies and the pours in the saved board (the board that ships)+            let facts = (live_board.is_none() && mechanical::applies(&solved_board)).then(|| verified::board_facts(&board_now(), &solved_board, &project_dir_of(&r), &spec_soft(&r)));+            match &facts {+                Some(f) => {+                    for n in &f.mech.severity_notes { eprintln!("Warning: {n}"); }+                    for e in f.mech.errors() { missing.push(format!("mechanical: {e} ({})", f.mech.hint())); }+                    for p in &f.fill_problems { missing.push(format!("pours in the saved board: {p} ({})", fills::HINT)); }+                }+                None => eprintln!("Warning: courtyards, bodies and saved pours not checked here (Fusion board); the summary says so"),+            }             {-                let (mut labels, fps) = provenance::silk_labels(&board_now());+                let (labels, fps) = provenance::silk_labels(&board_now());                 // On the Fusion lane the board checked here is the KiCad copy converted from the Fusion .brd,-                // and the converter carries no silkscreen text (issue #74: 44 texts in the .brd, 0 in the copy),-                // so every Fusion board failed. There the count comes from `fusion silkscreen`'s own proof.+                // which now carries its silkscreen text (issue #74); `fusion silkscreen`'s proof is the second source.                 if aiflow_bridge::eda::active().id() == "fusion" {-                    match std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {-                        Some(p) => {-                            labels = labels.max(p["data"]["labels"].as_u64().unwrap_or(0) as usize);-                            let failed = p["data"]["failed"].as_u64().unwrap_or(0);-                            if failed > 0 { missing.push(format!("silkscreen: {failed} step(s) of `adom-aiflow fusion silkscreen` failed; run it again (skill aiflow-silkscreen)")); }-                        }-                        None => missing.push("silkscreen: not drawn yet on the Fusion board; run `adom-aiflow fusion silkscreen` (skill aiflow-silkscreen)".to_string()),-                    }+                    let proof = std::fs::read_to_string(dir.join("proofs").join("silkscreen.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());+                    let (m, w) = provenance::fusion_silk(labels, fps, proof.as_ref());+                    missing.extend(m);+                    if let Some(w) = w { eprintln!("Warning: {w}"); }                 }                 if fps > 0 && (labels as f64) < provenance::MIN_SILK_PER_FOOTPRINT * fps as f64 {                     missing.push(format!("silkscreen: {labels} visible label(s) for {fps} footprints (finished boards carry at least {:.1} per footprint): references, values and contact labels belong on the silkscreen (skill aiflow-silkscreen)", provenance::MIN_SILK_PER_FOOTPRINT));@@ -3707,6 +3930,7 @@ fn main() {                     }                 }             }+            if copper_carry::silk_dropped(&r.data) { missing.push("silkscreen proof (adopt-board changed the board file; `silkscreen-audit --record` again)".into()); }             // the test plan again, on the finished board: every measurement still has a landing             if let Some(tp) = r.data["testPlan"].as_str().map(str::to_string) {                 let list = testplan::tests_of(&read_json(&tp));@@ -3721,6 +3945,10 @@ fn main() {                 for b in sequence::blockers(&st, "finish") { missing.push(format!("step {b}")); }                 sequence::write_timeline(&dir, &sequence::timeline(&dir, &r.data, &flow_full, &seq, &r.step_report()), TIMELINE_PAGE);             }+            // parts from the board's own project library stay visible as evidence (issue #66)+            let parts_v = o.get("parts").map(|p| p["parts"].clone()).unwrap_or(Value::Null);+            let lib_parts = project_lib::report(&parts_v);+            for g in project_lib::recheck(&parts_v) { missing.push(format!("parts: project library: {g}")); }             if !missing.is_empty() {                 err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);             }@@ -3730,12 +3958,18 @@ fn main() {             }             r.data["clock"]["finishTime"] = json!(now());             record_usage(&mut r, "finish");-            let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});+            let sink_assumptions = o.get("analyze-thermal").map(|x| x["boardSinkAssumptions"].clone()).filter(|v| v.is_array()).unwrap_or(json!([]));+            let mut reviewed: Vec<String> = acked_peaks.iter().map(|c| format!("acknowledged current peak: {} {:.1} A/mm2 at ({:.2}, {:.2}) {} ({}): \"{}\" by {}", c["net"].as_str().unwrap_or(""), c["peakJ"].as_f64().unwrap_or(0.0), c["at"][0].as_f64().unwrap_or(0.0), c["at"][1].as_f64().unwrap_or(0.0), c["layer"].as_str().unwrap_or(""), c["ack"].as_str().unwrap_or(""), c["why"].as_str().unwrap_or(""), c["aiThread"].as_str().unwrap_or(""))).collect();+            reviewed.extend(sink_assumptions.as_array().into_iter().flatten().filter_map(|a| a.as_str()).map(|a| format!("board-sink assumption: {a}")));+            let checked = verified::run_items(&r, facts.as_ref());+            let summary = json!({"verified": verified::to_json(&checked), "mechanical": facts.as_ref().map(|f| f.mech.to_json()), "poursInSavedBoard": facts.as_ref().map(|f| f.fills.clone()), "engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true, "acknowledgedPeaks": acked_peaks, "boardSinkAssumptions": sink_assumptions});             r.data["summary"] = summary;             if let Some(rec) = spec_freeze::read(&r.dir) { r.data["summary"]["specAmendments"] = json!(spec_freeze::amendments(&rec)); }+            r.data["summary"]["projectLibraryParts"] = json!(lib_parts);+            r.data["summary"]["silkscreenWarnings"] = json!(silk_warnings);             r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));             r.save().unwrap();-            ok(&format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+            ok(&(format!("finished: {} from the prompt to a board passing the configured screens, {:.1} min\npours: {}\n{}{}", r.data["engine"], r.elapsed_minutes(), pourverify::mode_label(r.outcomes().get("land-pours").unwrap_or(&Value::Null)), step_table(&r), if lib_parts.is_empty() { String::new() } else { format!("\nparts from a project library (no public component page): {}", lib_parts.join("; ")) }) + &if reviewed.is_empty() { String::new() } else { format!("\nreviewed exceptions and assumptions:\n  {}", reviewed.join("\n  ")) } + "\n" + &verified::message(&checked)), &["Tell the human exactly the verified list above; never call the board ready to fabricate unless every item says yes, and even then advise a human review.".into(), "The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);         }         Cmd::Capture { action, label, reason } => {             let mut r = load_run(&cli);@@ -3799,6 +4033,7 @@ fn main() {                         err(&format!("recording stopped, but {} did not reach the run after {} pulls", p.clone().unwrap_or_default(), stop.attempts), &["The capture is flagged pullFailed with its desktop path; `adom-aiflow recut` pulls it again. Do it soon: the desktop cleans its recordings folder.".into()]);                     }                     let clips_note = auto_clips(&r, &dir);+                    if r.data["captures"][pos]["truncated"] == true { err(&format!("recording stopped, but the clip {} is truncated (no moov atom: the take died before it was finalised)", p.unwrap_or_default()), &["Re-record the step (`step <name> --back --why \"clip truncated\"`) while the work is fresh.".into(), clips_note]); }                     ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into(), clips_note]);                 }                 "reject" => {@@ -3823,7 +4058,7 @@ fn main() {             ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",                 dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],                 o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),-                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { if o["land-pours"]["mode"]=="verified-existing" { "verified existing" } else { "landed" } } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),+                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { pourverify::mode_label(&o["land-pours"]) } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),                 o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);         }         Cmd::Report { page, push, refresh, user_machine, surface } => {@@ -4030,6 +4265,9 @@ fn main() {             for m in rep["mismatches"].as_array().cloned().unwrap_or_default() {                 lines.push(format!("  {:<10} schematic {:<12} board {:<12} {}", m["pin"].as_str().unwrap_or(""), m["schematic"].as_str().unwrap_or("-"), m["board"].as_str().unwrap_or("-"), m["why"].as_str().unwrap_or("")));             }+            let sch_check = schematic_symbols::check(Path::new(&sch), &run.as_ref().map(spec_soft).unwrap_or(Value::Null));+            lines.push(schematic_symbols::summary_line(&sch_check));+            if let (Some(r), Ok(sc)) = (run.as_mut(), &sch_check) { schematic_symbols::record(r, &sch, sc); }             let fpd: Vec<String> = rep["footprintDifferences"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();             if let Some(r) = run.as_mut() {                 let report = dir.join("netlist.json");@@ -4042,7 +4280,7 @@ fn main() {             if !passed {                 err(&format!("the board does not match the schematic\n{}", lines.join("\n")), &["Fix the side that is wrong: regenerate the board from the same netlist, or correct the schematic and export again (skill aiflow-schematic-to-board). Net names compare without the root sheet's leading /; unlabelled Net-(...) nets compare by their pins.".into(), fp_hint]);             }-            ok(&lines.join("\n"), &[if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);+            ok(&lines.join("\n"), &["This is electrical parity between schematic and board only: not ERC, DRC, courtyards, pours or fabrication readiness; never tell the human the board can go to the fab on this result.".into(), if run.is_some() { "Recorded in the run; finish re-checks the stored netlist against the board it finishes on.".into() } else { "No run here: nothing recorded. Start the run with --sch so finish requires this check.".into() }, fp_hint]);         }         Cmd::Molecule { action: _, board, spec } => {             let mut run = Run::open(&dir);@@ -4399,15 +4637,12 @@ fn main() {             let d = v.get("data").cloned().unwrap_or(v.clone());             let counts = d["counts"].clone();             let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);-            let mut missing: Vec<String> = Vec::new();-            for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {-                let fp = f["footprint"].as_str().unwrap_or("").to_string();-                let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();-                let models: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(|s| s.to_string())).collect();-                if !models.is_empty() { missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, models.join(", "))); }-            }+            // complete modelBindings + viewer classes (KiCad Bridge 1.0.22+); file resolution is not render proof+            let mc = aiflow_bridge::model_check_summary(&d);+            let missing = mc.missing.clone();+            let vis = if mc.hidden.is_empty() { String::new() } else { format!("; NOT visible proof, {}: read/set with kicad_3d_viewer_settings (KiCad closed) and confirm in the 3D frame, never by changing BOM/POS/DNP flags", mc.hidden.join("; ")) };             r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);-            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));+            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing, "viewerHidden": mc.hidden, "bindingsComplete": mc.bindings_complete, "evidenceSource": mc.evidence}));             r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));             r.save().unwrap();             if !Bridge::ok(&v) && d["counts"].is_null() {@@ -4423,7 +4658,7 @@ fn main() {             if !unacked.is_empty() {                 err(&format!("every footprint has its 3D model, but {} model(s) may not be right way up:\n{}", unacked.len(), orient_lines.join("\n")), &[orient_hint]);             }-            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);+            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none{vis}); 3D orientation: {} warning(s), all acknowledged; {} not checked here{}", counts["withModel"], counts["noModelExpected"], orient.len(), orient_notes.len(), if orient_lines.is_empty() { String::new() } else { format!("\n{}", orient_lines.join("\n")) }), &["Next: step placement.".into()]);         }         Cmd::Tour { what, nets, port, on } => {             let t = thread(&cli);@@ -4780,6 +5015,7 @@ fn main() {                         clip_artifacts(&mut r, &st, &f, 10);                     }                     clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+                    if r.data["captures"][pos]["truncated"] == true { clip_note.push_str(&format!("WARNING: clip {} is truncated (no moov atom): re-record it with `step {} --back --why \"clip truncated\"` if it matters; ", st.as_str().unwrap_or(""), cs.as_str().unwrap_or(""))); }                     clip_stopped = true;                 }                 // Recording can be delegated to another host without changing the EDA target.@@ -4819,7 +5055,11 @@ fn main() {                     if !HYDROGEN.contains(&name.as_str()) && !matches!(name.as_str(), "library" | "schematic") { br.bring_front(br.pcb_editor_hwnd(), "KiCad"); }                 }                 let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())));-                let hwnd = if name == "fields" { None } else { cam.camera_window(&tx, name, cached) };+                // issue #59: a step before the board films Hydrogen's dashboard, the AI's window or the tool's own+                // window, never an idle editor; nothing meaningful on screen is a recorded skip+                let pre_cam = (name != "fields" && clipwatch::preboard_step(name)).then(|| clipwatch::choose_preboard(&br, &*cam, name, r.data["engine"].as_str().unwrap_or("")));+                if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if what.starts_with("Hydrogen") { ensure_dashboard(&dir, &thread(&cli), true); } }+                let hwnd = match &pre_cam { Some(clipwatch::Camera::Window { hwnd, .. }) => Some(*hwnd), Some(clipwatch::Camera::Skip(_)) => None, None => if name == "fields" { None } else { cam.camera_window(&tx, name, cached) } };                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));@@ -4832,6 +5072,7 @@ fn main() {                             // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back                             let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);                             r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));+                            if let Some(clipwatch::Camera::Window { what, .. }) = &pre_cam { if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.last_mut()) { c["camera"] = json!(what); } clip_note.push_str(&format!("filming {what}; ")); }                             r.mark(&format!("clip-start:{name}-{visit}"));                             let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();                             if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {@@ -4854,16 +5095,14 @@ fn main() {                     },                     None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),                     // before the board there is no editor to film: the desktop is the camera-                    None if preboard::PREBOARD_STEPS.contains(&name.as_str()) => match br.record_start(&format!("aiflow step {name} (before the board)")) {-                        Ok(rid) => {-                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "monitor", "step": name, "visit": visit, "back": *back, "why": why}));-                            r.mark(&format!("clip-start:{name}-{visit}"));-                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid, "window": "monitor"}));-                            clip_note.push_str(&format!("clip {name}-{visit} recording the desktop (no PCB editor before the board)"));-                        }-                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),-                    },+                    None if matches!(pre_cam, Some(clipwatch::Camera::Skip(_))) => {+                        let why_skip = match &pre_cam { Some(clipwatch::Camera::Skip(w)) => w.clone(), _ => String::new() };+                        let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+                        if !r.data["clipSkips"].is_array() { r.data["clipSkips"] = json!([]); }+                        r.data["clipSkips"].as_array_mut().unwrap().push(json!({"step": name, "visit": visit, "at": now(), "why": why_skip}));+                        r.log("clip-skipped", json!({"step": name, "visit": visit, "why": why_skip}));+                        clip_note.push_str(&format!("no clip for {name}-{visit}: {why_skip}"));+                    }                     None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),                 }                 }@@ -4875,10 +5114,11 @@ fn main() {                 let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));                 report_note = format!("Progress page: {url} ({})", notes.join("; "));             }+            let skill_lines = skill_hints::step_hints(&flow_full, name, &skill_home, skill_pkg.as_deref());             let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();             let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };             let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };-            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note, clips_note]);+            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[vec![hint, record_hint], skill_lines, vec![back_hint, report_note, clips_note]].concat());             print_next(&cli);         }         Cmd::ProjectPageCheck { project, live_json } => {@@ -4907,6 +5147,12 @@ fn main() {             if !vp.is_file() {                 err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);             }+            if let Some(open) = step_export::open_issue(r.outcomes().get("step-export").unwrap_or(&Value::Null)) {+                err(&format!("not delivered: the board twin's STEP export: {open}"), &["Never drop a layer (silkscreen, mask, zones) to make an exporter warning go away. Review the STEP at the reported spots in a STEP viewer, then acknowledge with a reason, or fix the geometry and export again.".into()]);+            }+            let board_path = r.current_board();+            let facts = Board::load(&board_path).ok().filter(|_| mechanical::applies(&board_path)).map(|b| verified::board_facts(&b, &board_path, &project_dir_of(&r), &spec_soft(&r)));+            let checked = verified::run_items(&r, facts.as_ref());             let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);             if let Some(target) = r.data["target"].as_str().map(str::to_string) {                 let brs = Bridge { ai_thread: thread(&cli), target };@@ -4919,9 +5165,11 @@ fn main() {             if let Some(a) = agy_report(&r) { r.data["summary"]["antigravity"] = a; }             r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});             r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());+            r.data["summary"]["verifiedAtDelivery"] = verified::to_json(&checked);             r.data["summary"]["steps"] = Value::Object(r.step_report());             r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));-            r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"]})).collect::<Vec<_>>()).unwrap_or_default());+            r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"], "camera": c["camera"], "died": c["died"], "resumedAfterGap": c["resumedAfterGap"], "truncated": c["truncated"]})).collect::<Vec<_>>()).unwrap_or_default());+            r.data["summary"]["clipSkips"] = r.data["clipSkips"].clone();             r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));             r.save().unwrap();             let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);@@ -4930,7 +5178,8 @@ fn main() {                 let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));                 report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));             }-            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+            let verified_text = verified::message(&checked);+            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}\n{verified_text}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["Your message to the human must state the verified list as it stands; no \"ready to fabricate\" unless every item is yes, and still advise a human review.".into(), "This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);         }     } }
crates/adom-aiflow/src/mechanical.rsadded+510
@@ -0,0 +1,510 @@+//! Mechanical checks on a placed board (issue #39, items 7 and 8): every footprint has a courtyard,+//! no two courtyards on one side overlap, and no two part bodies on one side intersect.+//!+//! Courtyards come from the footprint's own F.CrtYd / B.CrtYd graphics (rect, poly, circle, and+//! lines or arcs chained into closed outlines). Bodies come from the F.Fab / B.Fab graphics (their+//! convex hull: fab layers also carry pin-1 marks and chevrons, so the hull is the body envelope),+//! else from the 3D model's bounding box when the caller can supply it.+//!+//! The exemption rule is deliberately narrow: a footprint with no pads at all (a logo, board art,+//! a text block) carries nothing to assemble or solder and needs no courtyard. Every footprint with+//! a pad needs one, test pads, fiducials, mounting holes and machine pins included (KiCad's own+//! libraries draw courtyards for all of them). Footprints excluded from both the BOM and the+//! position files (bare test pads, mounting holes) are not bodies for the body-overlap check: the+//! fab places nothing there, so a fab ring around a bare pad is not a part body.+//!+//! This is aiflow's own geometry, run whatever the project's DRC severities say, so a project that+//! sets `courtyards_overlap` or `missing_courtyard` to ignore cannot hide either finding. The+//! severities are still read and reported so the gate says when KiCad's DRC was told to look away.++use aiflow_board::{geom, sx, Board, Pt};+use serde_json::{json, Value};+use std::path::Path;++/// Smallest overlap depth reported, in mm: below this two outlines only touch.+pub const MIN_OVERLAP_MM: f64 = 0.005;++#[derive(Debug, Clone)]+pub struct Shape {+    pub reference: String,+    pub front: bool,+    pub polys: Vec<Vec<Pt>>,+}++#[derive(Debug, Clone, PartialEq)]+pub struct Overlap {+    pub a: String,+    pub b: String,+    pub front: bool,+    /// The overlap region's smaller axis extent (mm): how far the outlines run into each other.+    pub depth: f64,+    pub area: f64,+}++impl Overlap {+    pub fn line(&self, what: &str) -> String {+        format!("{} and {} {what} overlap by {:.2} mm ({:.2} mm2) on the {}", self.a, self.b, self.depth, self.area, if self.front { "front" } else { "back" })+    }+}++#[derive(Debug, Default)]+pub struct Report {+    pub footprints: usize,+    pub with_courtyard: usize,+    pub missing_courtyards: Vec<String>,+    pub exempt: Vec<(String, String)>,+    pub courtyard_overlaps: Vec<Overlap>,+    pub body_overlaps: Vec<Overlap>,+    pub bodies_checked: usize,+    pub no_body: Vec<String>,+    pub severity_notes: Vec<String>,+    pub notes: Vec<String>,+    /// Item 9: drawn bodies that disagree with the datasheet size in spec.bodies.+    pub body_size_errors: Vec<String>,+    /// Connectors and large parts with no datasheet size in spec.bodies (not verified, a note).+    pub body_size_unverified: Vec<String>,+}++impl Report {+    pub fn courtyards_ok(&self) -> bool { self.missing_courtyards.is_empty() && self.courtyard_overlaps.is_empty() }+    pub fn bodies_ok(&self) -> bool { self.body_overlaps.is_empty() && self.body_size_errors.is_empty() }++    /// Every blocking finding, one line each.+    pub fn errors(&self) -> Vec<String> {+        let mut out = Vec::new();+        if !self.missing_courtyards.is_empty() {+            out.push(format!("{} footprint(s) without a courtyard (F.CrtYd/B.CrtYd): {}", self.missing_courtyards.len(), short(&self.missing_courtyards)));+        }+        out.extend(self.courtyard_overlaps.iter().map(|o| o.line("courtyards")));+        out.extend(self.body_overlaps.iter().map(|o| o.line("bodies")));+        out.extend(self.body_size_errors.iter().cloned());+        out+    }++    pub fn hint(&self) -> String {+        let mut h = Vec::new();+        if !self.missing_courtyards.is_empty() { h.push("Give each listed footprint a closed courtyard on F.CrtYd (B.CrtYd on the back) around its body and pads plus the IPC margin (0.25 mm for chip parts, 0.5 mm for larger parts and connectors), from the datasheet land pattern; KiCad's library footprints already have one."); }+        if !self.courtyard_overlaps.is_empty() || !self.body_overlaps.is_empty() { h.push("Move the overlapping parts apart (place pack / place check again) or fix a body or courtyard drawn larger than the real part; never shrink a courtyard below the part to get a pass."); }+        if !self.body_size_errors.is_empty() { h.push("Redraw the footprint's F.Fab body (and its courtyard) to the datasheet's package drawing, then place check and gate again; a body drawn smaller than the part hides real collisions."); }+        h.join(" ")+    }++    pub fn to_json(&self) -> Value {+        let ov = |v: &[Overlap]| v.iter().map(|o| json!({"a": o.a, "b": o.b, "side": if o.front { "front" } else { "back" }, "overlapMm": round3(o.depth), "areaMm2": round3(o.area)})).collect::<Vec<_>>();+        json!({+            "footprints": self.footprints, "withCourtyard": self.with_courtyard,+            "missingCourtyards": self.missing_courtyards,+            "exempt": self.exempt.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>(),+            "courtyardOverlaps": ov(&self.courtyard_overlaps), "bodyOverlaps": ov(&self.body_overlaps),+            "bodiesChecked": self.bodies_checked, "noBody": self.no_body,+            "bodySizeErrors": self.body_size_errors, "bodySizeNotVerified": self.body_size_unverified,+            "projectSeverities": self.severity_notes, "notes": self.notes,+            "courtyardsOk": self.courtyards_ok(), "bodiesOk": self.bodies_ok(),+            "exemptionRule": "only footprints with no pads (logos, board art) need no courtyard; footprints excluded from both BOM and position files are not bodies",+        })+    }+}++fn round3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }++fn short(v: &[String]) -> String {+    if v.len() > 12 { format!("{} and {} more", v[..12].join(", "), v.len() - 12) } else { v.join(", ") }+}++fn flag(fp: &sx::Node, name: &str) -> bool {+    let in_attr = fp.find("attr").map(|a| a.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == name))).unwrap_or(false);+    in_attr || fp.find(name).map(|n| n.value() != Some("no")).unwrap_or(false)+}++fn xy(n: &sx::Node, tag: &str) -> Option<Pt> {+    let p = n.find(tag)?;+    Some((p.number(1)?, p.number(2)?))+}++/// Closed outlines and loose points of a footprint's graphics on one layer, in its own frame.+fn layer_geometry(fp: &sx::Node, layer: &str) -> (Vec<Vec<Pt>>, Vec<Vec<Pt>>) {+    let (mut closed, mut open) = (Vec::new(), Vec::new());+    for n in fp.nodes() {+        if !matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc") { continue; }+        if n.find("layer").and_then(|l| l.value()) != Some(layer) { continue; }+        match n.tag.as_str() {+            "fp_rect" => if let (Some(a), Some(b)) = (xy(n, "start"), xy(n, "end")) { closed.push(vec![a, (b.0, a.1), b, (a.0, b.1)]); },+            "fp_poly" => {+                let pts: Vec<Pt> = n.find("pts").map(|p| p.find_all("xy").filter_map(|q| Some((q.number(1)?, q.number(2)?))).collect()).unwrap_or_default();+                if pts.len() >= 3 { closed.push(pts); }+            }+            "fp_circle" => if let (Some(c), Some(e)) = (xy(n, "center"), xy(n, "end")) { closed.push(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 32)); },+            "fp_line" => if let (Some(a), Some(b)) = (xy(n, "start"), xy(n, "end")) { open.push(vec![a, b]); },+            "fp_arc" => if let (Some(a), Some(m), Some(b)) = (xy(n, "start"), xy(n, "mid"), xy(n, "end")) { open.push(geom::arc_points(a, m, b, 0.1)); },+            _ => {}+        }+    }+    (closed, open)+}++/// Join open pieces into closed loops; whatever does not close is returned as leftover points.+fn close_loops(mut pieces: Vec<Vec<Pt>>, tol: f64) -> (Vec<Vec<Pt>>, Vec<Pt>) {+    let d = |a: Pt, b: Pt| (a.0 - b.0).hypot(a.1 - b.1);+    let (mut loops, mut left) = (Vec::new(), Vec::new());+    pieces.retain(|p| p.len() >= 2);+    while !pieces.is_empty() {+        let mut cur = pieces.remove(0);+        loop {+            if cur.len() > 2 && d(cur[0], *cur.last().unwrap()) < tol { break; }+            let end = *cur.last().unwrap();+            let Some((i, rev)) = pieces.iter().enumerate().find_map(|(i, p)| if d(end, p[0]) < tol { Some((i, false)) } else if d(end, *p.last().unwrap()) < tol { Some((i, true)) } else { None }) else { break };+            let mut p = pieces.remove(i);+            if rev { p.reverse(); }+            cur.extend(p.into_iter().skip(1));+        }+        if cur.len() > 3 && d(cur[0], *cur.last().unwrap()) < tol { cur.pop(); loops.push(cur); } else { left.extend(cur); }+    }+    (loops, left)+}++/// The convex hull (monotone chain), counter-clockwise.+pub fn hull(pts: &[Pt]) -> Vec<Pt> {+    let mut p: Vec<Pt> = pts.to_vec();+    p.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));+    p.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9);+    if p.len() < 3 { return p; }+    let cross = |o: Pt, a: Pt, b: Pt| (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0);+    let mut lower: Vec<Pt> = Vec::new();+    for &q in &p { while lower.len() >= 2 && cross(lower[lower.len() - 2], lower[lower.len() - 1], q) <= 0.0 { lower.pop(); } lower.push(q); }+    let mut upper: Vec<Pt> = Vec::new();+    for &q in p.iter().rev() { while upper.len() >= 2 && cross(upper[upper.len() - 2], upper[upper.len() - 1], q) <= 0.0 { upper.pop(); } upper.push(q); }+    lower.pop(); upper.pop();+    lower.extend(upper);+    lower+}++fn place(polys: Vec<Vec<Pt>>, x: f64, y: f64, rot: f64) -> Vec<Vec<Pt>> {+    polys.into_iter().map(|poly| poly.into_iter().map(|(dx, dy)| { let (a, b) = geom::rot_kicad(dx, dy, rot); (x + a, y + b) }).collect()).collect()+}++/// Overlap of two polygon sets: (depth, area) when they share area deeper than MIN_OVERLAP_MM.+/// Sampled on a grid fine enough for 0.01 mm over the shared box (capped at 600 x 600 samples).+pub fn overlap(a: &[Vec<Pt>], b: &[Vec<Pt>]) -> Option<(f64, f64)> {+    let all = |s: &[Vec<Pt>]| geom::bbox(&s.iter().flatten().copied().collect::<Vec<_>>());+    let (ba, bb) = (all(a), all(b));+    let (x0, y0, x1, y1) = (ba.0.max(bb.0), ba.1.max(bb.1), ba.2.min(bb.2), ba.3.min(bb.3));+    if x1 - x0 <= 1e-9 || y1 - y0 <= 1e-9 { return None; }+    let res = ((x1 - x0).max(y1 - y0) / 600.0).max(0.005);+    let inside = |s: &[Vec<Pt>], x: f64, y: f64| s.iter().any(|p| geom::point_in_poly(x, y, p));+    let (nx, ny) = (((x1 - x0) / res).ceil() as usize, ((y1 - y0) / res).ceil() as usize);+    let (mut hits, mut hx0, mut hy0, mut hx1, mut hy1) = (0usize, f64::MAX, f64::MAX, f64::MIN, f64::MIN);+    for j in 0..ny {+        let y = y0 + (j as f64 + 0.5) * res;+        if y > y1 { break; }+        for i in 0..nx {+            let x = x0 + (i as f64 + 0.5) * res;+            if x > x1 { break; }+            if inside(a, x, y) && inside(b, x, y) {+                hits += 1;+                hx0 = hx0.min(x); hy0 = hy0.min(y); hx1 = hx1.max(x); hy1 = hy1.max(y);+            }+        }+    }+    if hits == 0 { return None; }+    let depth = ((hx1 - hx0) + res).min((hy1 - hy0) + res).min(x1 - x0).min(y1 - y0);+    (depth > MIN_OVERLAP_MM).then_some((depth, hits as f64 * res * res))+}++/// Same-side overlaps among shapes.+pub fn overlaps(shapes: &[Shape]) -> Vec<Overlap> {+    let mut out = Vec::new();+    for i in 0..shapes.len() {+        for j in i + 1..shapes.len() {+            let (s, t) = (&shapes[i], &shapes[j]);+            if s.front != t.front || s.reference == t.reference { continue; }+            if let Some((depth, area)) = overlap(&s.polys, &t.polys) {+                out.push(Overlap { a: s.reference.clone(), b: t.reference.clone(), front: s.front, depth, area });+            }+        }+    }+    out+}++/// The project's DRC severities for the courtyard rules (from <board>.kicad_pro), as notes for+/// every rule set to ignore or downgraded below error.+pub fn project_severities(board_path: &str) -> Vec<String> {+    let pro = Path::new(board_path).with_extension("kicad_pro");+    let Some(v) = std::fs::read(&pro).ok().and_then(|b| serde_json::from_slice::<Value>(&b).ok()) else { return Vec::new() };+    let sev = &v["board"]["design_settings"]["rule_severities"];+    let mut out = Vec::new();+    for rule in ["courtyards_overlap", "missing_courtyard", "malformed_courtyard"] {+        match sev[rule].as_str() {+            Some("ignore") => out.push(format!("the project sets {rule} to ignore, so KiCad's DRC reports nothing for it; aiflow's own courtyard check below decides")),+            Some("warning") if rule == "courtyards_overlap" => out.push(format!("the project sets {rule} to warning; aiflow treats it as an error (its own courtyard check and the gate's blocking list)")),+            _ => {}+        }+    }+    out+}++/// The whole check. `model_box(reference)` gives a footprint's 3D model box in its own frame+/// (x0, y0, x1, y1, board y down) for parts with no fab outline; pass `|_| None` when unknown.+pub fn check(b: &Board, board_path: &str, model_box: &dyn Fn(&str) -> Option<(f64, f64, f64, f64)>) -> Report {+    let mut rep = Report { severity_notes: project_severities(board_path), ..Default::default() };+    let (mut courts, mut bodies) = (Vec::new(), Vec::new());+    for fp in b.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        rep.footprints += 1;+        let at = fp.find("at");+        let (x, y, rot) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+        let back = fp.find("layer").and_then(|l| l.value()) == Some("B.Cu");+        let pads = fp.find_all("pad").count();+        // courtyards+        let mut found = false;+        for (layer, front) in [("F.CrtYd", true), ("B.CrtYd", false)] {+            let (mut closed, open) = layer_geometry(fp, layer);+            if closed.is_empty() && open.is_empty() { continue; }+            found = true;+            let (loops, left) = close_loops(open, 0.01);+            closed.extend(loops);+            if !left.is_empty() {+                rep.notes.push(format!("{reference}: the {layer} outline is not closed; its convex hull is used"));+                let mut pts = left;+                pts.extend(closed.iter().flatten().copied());+                closed = vec![hull(&pts)];+            }+            courts.push(Shape { reference: reference.clone(), front, polys: place(closed, x, y, rot) });+        }+        if found { rep.with_courtyard += 1; }+        else if pads == 0 { rep.exempt.push((reference.clone(), "no pads (graphics only)".into())); }+        else { rep.missing_courtyards.push(reference.clone()); }+        // bodies+        if pads == 0 || (flag(fp, "exclude_from_bom") && flag(fp, "exclude_from_pos_files")) { continue; }+        let mut got = false;+        for (layer, front) in [("F.Fab", true), ("B.Fab", false)] {+            let (closed, open) = layer_geometry(fp, layer);+            let pts: Vec<Pt> = closed.into_iter().chain(open).flatten().collect();+            if pts.len() < 3 { continue; }+            let h = hull(&pts);+            if h.len() < 3 { continue; }+            got = true;+            bodies.push(Shape { reference: reference.clone(), front, polys: place(vec![h], x, y, rot) });+        }+        if !got {+            if let Some((x0, y0, x1, y1)) = model_box(&reference) {+                got = true;+                bodies.push(Shape { reference: reference.clone(), front: !back, polys: place(vec![vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]], x, y, rot) });+            }+        }+        if got { rep.bodies_checked += 1; } else { rep.no_body.push(reference.clone()); }+    }+    rep.courtyard_overlaps = overlaps(&courts);+    rep.body_overlaps = overlaps(&bodies);+    if !rep.no_body.is_empty() { rep.notes.push(format!("no fab outline and no readable 3D model box, body not checked: {}", short(&rep.no_body))); }+    rep+}++/// The gate outcome's courtyard and body verdicts: false only when the gate checked and found a+/// problem (a Fusion board, or a gate recorded before this check, carries none and is not refused here).+pub fn gate_clear(gate: &Value) -> bool { gate["courtyardsOk"] != false && gate["bodiesOk"] != false }++/// False for a Fusion (EAGLE) board: its KiCad rendering carries no footprint courtyard or fab+/// graphics, so these checks cannot run on it and the stage says so instead of passing.+pub fn applies(board_path: &str) -> bool {+    std::fs::read_to_string(board_path).map(|t| !aiflow_board::eagle::is_eagle(&t)).unwrap_or(false)+}++/// `check` with the 3D model fallback read from the model files that resolve here (their STEP+/// points, no service call), for parts without a fab outline.+pub fn check_board(b: &Board, board_path: &str, project_dir: &Path, spec: &Value) -> Report {+    let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = std::collections::BTreeMap::new();+    let no_fab: std::collections::BTreeSet<String> = b.root.find_all("footprint").filter(|fp| {+        let (c1, o1) = layer_geometry(fp, "F.Fab"); let (c2, o2) = layer_geometry(fp, "B.Fab");+        c1.is_empty() && o1.is_empty() && c2.is_empty() && o2.is_empty()+    }).filter_map(aiflow_board::reference_of).collect();+    for m in crate::checks::model_refs(b) {+        if !no_fab.contains(&m.reference) || boxes.contains_key(&m.reference) { continue; }+        let Some(file) = crate::checks::resolve_model(&m.path, project_dir) else { continue };+        let Some(bb) = std::fs::read(&file).ok().and_then(|t| crate::checks::step_points_bbox(&String::from_utf8_lossy(&t))) else { continue };+        let p = crate::checks::transform_bbox(&bb, m.offset, m.scale, m.rotate);+        // the 3D frame is y up, the board's y down+        boxes.insert(m.reference.clone(), (p[0][0], -p[1][1], p[0][1], -p[1][0]));+    }+    let mut rep = check(b, board_path, &|r: &str| boxes.get(r).copied());+    let (errors, unverified) = declared_bodies(b, spec);+    rep.body_size_errors = errors;+    rep.body_size_unverified = unverified;+    if !rep.body_size_unverified.is_empty() { rep.notes.push(format!("body size not checked against a datasheet (no spec.bodies entry): {}", short(&rep.body_size_unverified))); }+    rep+}++/// A body is "large" for the datasheet note above this many mm on its longer side.+pub const LARGE_BODY_MM: f64 = 8.0;++/// The drawn body's size in the footprint's own frame: the F.Fab/B.Fab extents, (longer, shorter) in mm.+pub fn fab_extent(fp: &sx::Node) -> Option<(f64, f64)> {+    let mut pts: Vec<Pt> = Vec::new();+    for layer in ["F.Fab", "B.Fab"] {+        let (closed, open) = layer_geometry(fp, layer);+        pts.extend(closed.into_iter().chain(open).flatten());+    }+    if pts.len() < 2 { return None; }+    let (x0, y0, x1, y1) = geom::bbox(&pts);+    let (w, h) = (x1 - x0, y1 - y0);+    Some((w.max(h), w.min(h)))+}++fn connector_ref(r: &str) -> bool {+    let p: String = r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase();+    matches!(p.as_str(), "J" | "P" | "CN" | "X" | "CON" | "TB")+}++/// Item 9: every footprint named in `spec.bodies` ({"J1": {"lengthMm": 9.8, "widthMm": 7.6,+/// "tolMm": 0.3, "source": "datasheet page"}}) has a drawn body of that size (longer side against+/// lengthMm, shorter against widthMm, within tolMm, default 0.25 mm). Returns (errors, connectors and+/// parts over LARGE_BODY_MM with no entry).+pub fn declared_bodies(b: &Board, spec: &Value) -> (Vec<String>, Vec<String>) {+    let decl = spec["bodies"].as_object();+    let (mut errors, mut unverified) = (Vec::new(), Vec::new());+    for fp in b.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        let ext = fab_extent(fp);+        match decl.and_then(|d| d.get(&reference)) {+            Some(want) => {+                let tol = want["tolMm"].as_f64().unwrap_or(0.25);+                let Some((long, short)) = ext else { errors.push(format!("{reference}: spec.bodies gives its datasheet size but the footprint draws no F.Fab body to compare")); continue };+                let (wl, ws) = (want["lengthMm"].as_f64(), want["widthMm"].as_f64());+                let (wl, ws) = match (wl, ws) { (Some(a), Some(b)) => (Some(a.max(b)), Some(a.min(b))), (a, b) => (a.or(b), None) };+                let src = want["source"].as_str().map(|s| format!(" ({s})")).unwrap_or_default();+                if let Some(wl) = wl { if (long - wl).abs() > tol { errors.push(format!("{reference}: the drawn body is {long:.2} mm long, the datasheet says {wl:.2} mm{src}")); } }+                if let Some(ws) = ws { if (short - ws).abs() > tol { errors.push(format!("{reference}: the drawn body is {short:.2} mm wide, the datasheet says {ws:.2} mm{src}")); } }+            }+            None => {+                if fp.find_all("pad").count() == 0 { continue; }+                if connector_ref(&reference) || ext.map(|e| e.0 > LARGE_BODY_MM).unwrap_or(false) { unverified.push(reference.clone()); }+            }+        }+    }+    (errors, unverified)+}++#[cfg(test)]+pub(crate) mod tests {+    use super::*;++    /// A footprint at (x, y) with a body of w x h on F.Fab and, when `crt` is set, a courtyard grown by `crt`.+    pub(crate) fn part(r: &str, x: f64, y: f64, w: f64, h: f64, crt: Option<f64>, extra_attr: &str) -> String {+        let (hw, hh) = (w / 2.0, h / 2.0);+        let mut s = format!("  (footprint \"T:Cap\" (layer \"F.Cu\") (at {x} {y})\n    (property \"Reference\" \"{r}\" (at 0 0 0) (layer \"F.Fab\"))\n    (attr smd {extra_attr})\n");+        for (a, b) in [((-hw, -hh), (hw, -hh)), ((hw, -hh), (hw, hh)), ((hw, hh), (-hw, hh)), ((-hw, hh), (-hw, -hh))] {+            s.push_str(&format!("    (fp_line (start {} {}) (end {} {}) (layer \"F.Fab\") (width 0.1))\n", a.0, a.1, b.0, b.1));+        }+        if let Some(g) = crt { s.push_str(&format!("    (fp_rect (start {} {}) (end {} {}) (layer \"F.CrtYd\") (width 0.05))\n", -hw - g, -hh - g, hw + g, hh + g)); }+        s.push_str(&format!("    (pad \"1\" smd rect (at {} 0) (size 0.5 0.5) (layers \"F.Cu\"))\n    (pad \"2\" smd rect (at {} 0) (size 0.5 0.5) (layers \"F.Cu\")))\n", -hw + 0.3, hw - 0.3));+        s+    }++    pub(crate) fn board(parts: &[String]) -> Board {+        let mut t = String::from("(kicad_pcb (version 20240108) (generator \"t\")\n  (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal) (44 \"Edge.Cuts\" user))\n");+        for p in parts { t.push_str(p); }+        t.push_str("  (gr_rect (start 90 60) (end 130 100) (layer \"Edge.Cuts\") (width 0.1))\n)\n");+        Board::from_text("t.kicad_pcb", t).unwrap()+    }++    #[test]+    fn the_issue_example_two_5mm_bodies_4mm_apart_overlap_by_1mm() {+        // C6 at (105, 75.5) and C7 at (105, 79.5), each a 5 mm body: no courtyards, bodies overlap 1 mm+        let b = board(&[part("C6", 105.0, 75.5, 2.5, 5.0, None, ""), part("C7", 105.0, 79.5, 2.5, 5.0, None, "")]);+        let r = check(&b, "/nonexistent/t.kicad_pcb", &|_| None);+        assert_eq!(r.missing_courtyards, vec!["C6", "C7"]);+        assert_eq!(r.body_overlaps.len(), 1);+        let o = &r.body_overlaps[0];+        assert!((o.depth - 1.0).abs() < 0.02, "{o:?}");+        assert!((o.area - 2.5).abs() < 0.05, "{o:?}");+        assert!(r.errors().iter().any(|e| e.contains("C6 and C7 bodies overlap by 1.00 mm")), "{:?}", r.errors());+    }++    #[test]+    fn courtyards_overlap_is_an_error_and_touching_is_not() {+        let b = board(&[part("R1", 100.0, 80.0, 1.0, 0.5, Some(0.25), ""), part("R2", 101.4, 80.0, 1.0, 0.5, Some(0.25), "")]);+        let r = check(&b, "/nonexistent/t.kicad_pcb", &|_| None);+        assert!(r.missing_courtyards.is_empty());+        assert_eq!(r.courtyard_overlaps.len(), 1, "{:?}", r.courtyard_overlaps);+        assert!((r.courtyard_overlaps[0].depth - 0.1).abs() < 0.02);+        assert!(r.body_overlaps.is_empty());+        assert!(!r.courtyards_ok());+        // courtyards that only share an edge (1.5 mm apart, each 0.75 wide half) do not overlap+        let b = board(&[part("R1", 100.0, 80.0, 1.0, 0.5, Some(0.25), ""), part("R2", 101.5, 80.0, 1.0, 0.5, Some(0.25), "")]);+        assert!(check(&b, "/nonexistent/t.kicad_pcb", &|_| None).courtyards_ok());+    }++    #[test]+    fn only_padless_footprints_are_exempt_and_bare_pads_are_not_bodies() {+        let logo = "  (footprint \"T:Logo\" (layer \"F.Cu\") (at 95 65)\n    (property \"Reference\" \"G1\" (at 0 0 0) (layer \"F.Fab\"))\n    (fp_poly (pts (xy -1 -1) (xy 1 -1) (xy 1 1)) (layer \"F.SilkS\")))\n".to_string();+        let tp = part("TP1", 110.0, 80.0, 1.0, 1.0, None, "exclude_from_pos_files exclude_from_bom");+        let r1 = part("R1", 110.2, 80.0, 1.0, 1.0, Some(0.2), "");+        let r = check(&board(&[logo, tp, r1]), "/nonexistent/t.kicad_pcb", &|_| None);+        assert_eq!(r.exempt, vec![("G1".to_string(), "no pads (graphics only)".to_string())]);+        // the test pad still needs a courtyard, but it is not a body+        assert_eq!(r.missing_courtyards, vec!["TP1"]);+        assert!(r.body_overlaps.is_empty());+        assert_eq!(r.bodies_checked, 1);+    }++    #[test]+    fn a_courtyard_drawn_with_lines_is_closed_and_an_l_shape_is_not_hulled() {+        // an L-shaped courtyard: a part tucked into the notch must not count as overlapping+        let l = "  (footprint \"T:L\" (layer \"F.Cu\") (at 100 80)\n    (property \"Reference\" \"U1\" (at 0 0 0) (layer \"F.Fab\"))\n".to_string()+            + &[((0.0, 0.0), (4.0, 0.0)), ((4.0, 0.0), (4.0, 2.0)), ((4.0, 2.0), (2.0, 2.0)), ((2.0, 2.0), (2.0, 4.0)), ((2.0, 4.0), (0.0, 4.0)), ((0.0, 4.0), (0.0, 0.0))]+                .iter().map(|(a, b)| format!("    (fp_line (start {} {}) (end {} {}) (layer \"F.CrtYd\") (width 0.05))\n", a.0, a.1, b.0, b.1)).collect::<String>()+            + "    (pad \"1\" smd rect (at 1 1) (size 0.5 0.5) (layers \"F.Cu\")))\n";+        let r = check(&board(&[l, part("C1", 103.0, 83.0, 1.0, 1.0, Some(0.2), "")]), "/nonexistent/t.kicad_pcb", &|_| None);+        assert!(r.courtyard_overlaps.is_empty(), "{:?} {:?}", r.courtyard_overlaps, r.notes);+        assert!(!r.notes.iter().any(|n| n.contains("not closed")), "{:?}", r.notes);+        assert_eq!(r.no_body, vec!["U1"]);+    }++    #[test]+    fn rotation_and_the_model_box_fallback_are_used() {+        // a part with no fab outline: its 3D model box (3 x 1 mm) stands in, rotated 90 degrees+        let nofab = "  (footprint \"T:J\" (layer \"F.Cu\") (at 100 80 90)\n    (property \"Reference\" \"J1\" (at 0 0 0) (layer \"F.SilkS\"))\n    (fp_rect (start -2 -1) (end 2 1) (layer \"F.CrtYd\") (width 0.05))\n    (pad \"1\" thru_hole circle (at 0 0) (size 1 1) (drill 0.6) (layers \"*.Cu\")))\n".to_string();+        let near = part("C1", 100.0, 81.9, 1.0, 1.0, Some(0.1), "");+        let r = check(&board(&[nofab, near]), "/nonexistent/t.kicad_pcb", &|r| (r == "J1").then_some((-1.5, -0.5, 1.5, 0.5)));+        // J1 rotated: its model box spans y 78.5..81.5, C1's body y 81.4..82.4: 0.1 mm into each other+        assert_eq!(r.body_overlaps.len(), 1, "{:?}", r.body_overlaps);+        assert!((r.body_overlaps[0].depth - 0.1).abs() < 0.02);+        // and the rotated courtyards (J1 y 78..82, C1 81.3..82.5) overlap too+        assert_eq!(r.courtyard_overlaps.len(), 1);+    }++    #[test]+    fn a_body_drawn_smaller_than_the_datasheet_is_an_error() {+        // the trial's Phoenix 1715721: a 7 mm body drawn, the datasheet says 9.8 mm+        let b = board(&[part("J1", 100.0, 80.0, 7.0, 5.0, Some(0.5), ""), part("C1", 120.0, 80.0, 2.0, 1.25, Some(0.25), "")]);+        let (errors, unverified) = declared_bodies(&b, &json!({"bodies": {"J1": {"lengthMm": 9.8, "widthMm": 5.0, "source": "1715721 datasheet"}}}));+        assert_eq!(errors.len(), 1, "{errors:?}");+        assert!(errors[0].contains("7.00 mm long") && errors[0].contains("9.80"), "{errors:?}");+        assert!(unverified.is_empty());+        // with no entry the connector is listed as not verified; the small capacitor is not+        let (errors, unverified) = declared_bodies(&b, &Value::Null);+        assert!(errors.is_empty());+        assert_eq!(unverified, vec!["J1"]);+        let r = check_board(&b, "/nonexistent/t.kicad_pcb", Path::new("/nonexistent"), &json!({"bodies": {"J1": {"lengthMm": 9.8}}}));+        assert!(!r.bodies_ok() && r.errors().iter().any(|e| e.contains("J1")), "{:?}", r.errors());+        assert!(check_board(&b, "/nonexistent/t.kicad_pcb", Path::new("/nonexistent"), &json!({"bodies": {"J1": {"lengthMm": 7.0, "widthMm": 5.0}}})).bodies_ok());+    }++    #[test]+    fn gate_clear_reads_the_recorded_verdicts() {+        assert!(gate_clear(&json!({"newErrors": 0})));+        assert!(gate_clear(&json!({"courtyardsOk": null, "bodiesOk": null})));+        assert!(!gate_clear(&json!({"courtyardsOk": false, "bodiesOk": true})));+        assert!(!gate_clear(&json!({"courtyardsOk": true, "bodiesOk": false})));+    }++    #[test]+    fn ignored_courtyard_severities_are_reported() {+        let dir = std::env::temp_dir().join(format!("aiflow-mech-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        std::fs::write(dir.join("b.kicad_pro"), r#"{"board":{"design_settings":{"rule_severities":{"courtyards_overlap":"ignore","missing_courtyard":"ignore"}}}}"#).unwrap();+        let notes = project_severities(&dir.join("b.kicad_pcb").display().to_string());+        assert_eq!(notes.len(), 2, "{notes:?}");+        assert!(notes[0].contains("courtyards_overlap to ignore"));+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/outline.rs+125−22
@@ -12,10 +12,10 @@ fn near(p: (f64, f64), q: (f64, f64)) -> bool { (p.0 - q.0).hypot(p.1 - q.1) < 0 pub fn sharp_corners(edges: &[Edge]) -> Vec<(f64, f64)> {     let mut out: Vec<(f64, f64)> = Vec::new();     for (i, e) in edges.iter().enumerate() {-        if e.arc { continue; }+        if e.arc || near(e.a, e.b) { continue; }         for p in [e.a, e.b] {             if out.iter().any(|q| near(*q, p)) { continue; }-            let others: Vec<&Edge> = edges.iter().enumerate().filter(|(j, f)| *j != i && (near(f.a, p) || near(f.b, p))).map(|(_, f)| f).collect();+            let others: Vec<&Edge> = edges.iter().enumerate().filter(|(j, f)| *j != i && !near(f.a, f.b) && (near(f.a, p) || near(f.b, p))).map(|(_, f)| f).collect();             let Some(f) = others.first() else { continue };             if f.arc { continue; }             let d1 = if near(e.a, p) { (e.b.0 - p.0, e.b.1 - p.1) } else { (e.a.0 - p.0, e.a.1 - p.1) };@@ -44,34 +44,91 @@ pub fn eagle_edges(src: &str) -> Vec<Edge> {     out } -/// The outline of a KiCad board: Edge.Cuts gr_line / gr_arc / gr_rect (a rect is four sharp corners).+/// The outline of a KiCad board, read with the S-expression parser (issue #71): the board-level+/// Edge.Cuts items (gr_line, gr_arc, gr_rect, gr_poly, gr_curve) plus any footprint item on+/// Edge.Cuts (fp_line, fp_arc, fp_rect, fp_poly, fp_curve), transformed from footprint-local to+/// board coordinates. Graphics on every other layer (silk, fab, courtyard, pad shapes) are not+/// outline and are ignored. KiCad writes a back-side footprint's children already mirrored in its+/// local frame, so the transform is the footprint's position and rotation (the same convention the+/// pads use); the side needs no second mirror. Circles are closed curves with no corner and are+/// skipped. An EAGLE/Fusion board is read with `eagle_edges`. pub fn kicad_edges(text: &str) -> Vec<Edge> {+    if aiflow_board::eagle::is_eagle(text) { return eagle_edges(text); }+    // a fragment (items without the kicad_pcb wrapper) reads as the items of one board+    let root = match aiflow_board::sx::parse(text) {+        Ok(r) if r.tag == "kicad_pcb" => r,+        _ => match aiflow_board::sx::parse(&format!("(kicad_pcb\n{text}\n)")) { Ok(r) => r, Err(_) => return Vec::new() },+    };     let mut out = Vec::new();-    // a gr_poly on Edge.Cuts is a closed chain of straight edges-    for blk in text.split("(gr_poly").skip(1) {-        let end = blk.find("\n  (").unwrap_or(blk.len());-        let blk = &blk[..end];-        if !blk.contains("Edge.Cuts") { continue; }-        let pts: Vec<(f64, f64)> = blk.split("(xy ").skip(1).filter_map(|r| { let v: Vec<f64> = r.split(')').next()?.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some((v[0], v[1])) } else { None } }).collect();-        for k in 0..pts.len() { if pts.len() > 2 { out.push(Edge { a: pts[k], b: pts[(k + 1) % pts.len()], arc: false }); } }+    let ident = |p: (f64, f64)| p;+    for n in root.nodes() {+        if n.tag.starts_with("gr_") { node_edges(n, "gr_", &ident, &mut out); }     }-    for (tag, arc) in [("(gr_line", false), ("(gr_arc", true), ("(gr_rect", false)] {-        for blk in text.split(tag).skip(1) {-            let end = blk.find("\n  (").unwrap_or(blk.len().min(600));-            let blk = &blk[..end];-            if !blk.contains("Edge.Cuts") { continue; }-            let xy = |k: &str| blk.split(&format!("({k} ")).nth(1).and_then(|r| { let v: Vec<f64> = r.split(')').next()?.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some((v[0], v[1])) } else { None } });-            if let (Some(a), Some(b)) = (xy("start"), xy("end")) {-                if tag == "(gr_rect" {-                    let (c, d) = ((b.0, a.1), (a.0, b.1));-                    for (p, q) in [(a, c), (c, b), (b, d), (d, a)] { out.push(Edge { a: p, b: q, arc: false }); }-                } else { out.push(Edge { a, b, arc }); }-            }+    for fp in root.find_all("footprint") {+        let at = fp.find("at");+        let (fx, fy, rot) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+        let to_board = move |p: (f64, f64)| { let (rx, ry) = aiflow_board::geom::rot_kicad(p.0, p.1, rot); (fx + rx, fy + ry) };+        for n in fp.nodes() {+            if n.tag.starts_with("fp_") { node_edges(n, "fp_", &to_board, &mut out); }         }     }     out } +fn on_edge_cuts(n: &aiflow_board::sx::Node) -> bool {+    if n.find("layer").and_then(|l| l.value()) == Some("Edge.Cuts") { return true; }+    n.find("layers").map(|l| l.items.iter().any(|it| matches!(it, aiflow_board::sx::Item::Atom { text, .. } if text == "Edge.Cuts"))).unwrap_or(false)+}++fn node_edges(n: &aiflow_board::sx::Node, prefix: &str, tf: &dyn Fn((f64, f64)) -> (f64, f64), out: &mut Vec<Edge>) {+    if !on_edge_cuts(n) { return; }+    let p2 = |m: Option<&aiflow_board::sx::Node>| -> Option<(f64, f64)> { let m = m?; Some((m.number(1)?, m.number(2)?)) };+    match &n.tag[prefix.len()..] {+        "line" => if let (Some(a), Some(b)) = (p2(n.find("start")), p2(n.find("end"))) { out.push(Edge { a: tf(a), b: tf(b), arc: false }); },+        "arc" => {+            if let (Some(a), Some(_), Some(b)) = (p2(n.find("start")), p2(n.find("mid")), p2(n.find("end"))) {+                out.push(Edge { a: tf(a), b: tf(b), arc: true });+            } else if let (Some(c), Some(s), Some(ang)) = (p2(n.find("start")), p2(n.find("end")), n.find("angle").and_then(|x| x.number(1))) {+                // KiCad 5 arc: start is the centre, end the first point, angle the sweep+                let (dx, dy) = (s.0 - c.0, s.1 - c.1);+                let t = ang.to_radians();+                let e = (c.0 + dx * t.cos() - dy * t.sin(), c.1 + dx * t.sin() + dy * t.cos());+                out.push(Edge { a: tf(s), b: tf(e), arc: true });+            }+        }+        "curve" => {+            let pts: Vec<(f64, f64)> = n.find("pts").map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default();+            if let (Some(a), Some(b)) = (pts.first(), pts.last()) { out.push(Edge { a: tf(*a), b: tf(*b), arc: true }); }+        }+        "rect" => if let (Some(a), Some(b)) = (p2(n.find("start")), p2(n.find("end"))) {+            let c = [a, (b.0, a.1), b, (a.0, b.1)].map(tf);+            for k in 0..4 { out.push(Edge { a: c[k], b: c[(k + 1) % 4], arc: false }); }+        },+        "poly" => {+            // a closed chain: (xy) vertices joined by straight edges, (arc) pieces as arcs+            let mut chain: Vec<((f64, f64), (f64, f64), bool)> = Vec::new();+            let mut cur: Option<(f64, f64)> = None;+            let mut first: Option<(f64, f64)> = None;+            for it in n.find("pts").map(|p| p.nodes().collect::<Vec<_>>()).unwrap_or_default() {+                let (s, e, arc) = match it.tag.as_str() {+                    "xy" => match (it.number(1), it.number(2)) { (Some(x), Some(y)) => ((x, y), (x, y), false), _ => continue },+                    "arc" => match (p2(it.find("start")), p2(it.find("end"))) { (Some(s), Some(e)) => (s, e, true), _ => continue },+                    _ => continue,+                };+                if first.is_none() { first = Some(s); }+                if let Some(c) = cur { if !near(c, s) { chain.push((c, s, false)); } }+                if arc { chain.push((s, e, true)); }+                cur = Some(e);+            }+            if let (Some(c), Some(f)) = (cur, first) { if !near(c, f) { chain.push((c, f, false)); } }+            if chain.len() > 2 || chain.iter().any(|x| x.2) {+                for (a, b, arc) in chain { out.push(Edge { a: tf(a), b: tf(b), arc }); }+            }+        }+        _ => {}+    }+}+ #[cfg(test)] mod tests {     use super::*;@@ -100,4 +157,50 @@ mod tests {         assert_eq!((e.len(), e.iter().filter(|x| x.arc).count()), (8, 4));         assert!(sharp_corners(&e).is_empty());     }+    #[test]+    fn a_rounded_kicad_board_with_busy_footprints_has_no_sharp_corner() {+        // issue #71: tab-indented KiCad 10 text, an R4-rounded Edge.Cuts outline, and footprints+        // full of silk/fab polygons and lines in local coordinates; none of them is outline+        let fp = |r: &str, x: f64, y: f64, rot: f64| {+            let mut s = format!("\t(footprint \"Pkg:SOT-23\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y} {rot})\n\t\t(property \"Reference\" \"{r}\" (at 0 -2.4 0) (layer \"F.SilkS\"))\n");+            for k in 0..40 {+                let d = k as f64 * 0.01;+                s += &format!("\t\t(fp_poly (pts (xy {} -6.4) (xy -9 -6.9) (xy -9.5 -6.9)) (stroke (width 0.12) (type solid)) (fill yes) (layer \"F.SilkS\"))\n", -9.25 + d);+                s += &format!("\t\t(fp_line (start -0.62 {}) (end -0.9 -1.41) (stroke (width 0.1) (type solid)) (layer \"F.Fab\"))\n", -1.69 + d);+            }+            s += "\t\t(pad \"1\" smd roundrect (at -0.95 1.1) (size 0.6 1.0) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\"))\n\t)\n";+            s+        };+        let mut t = String::from("(kicad_pcb\n\t(version 20250114)\n\t(generator \"pcbnew\")\n");+        t += &fp("U1", 40.0, 30.0, 0.0);+        t += &fp("Q1", 60.0, 30.0, 90.0);+        for (a, b) in [((24.0, 20.0), (96.0, 20.0)), ((100.0, 24.0), (100.0, 56.0)), ((96.0, 60.0), (24.0, 60.0)), ((20.0, 56.0), (20.0, 24.0))] {+            t += &format!("\t(gr_line\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(stroke (width 0.05) (type default))\n\t\t(layer \"Edge.Cuts\")\n\t)\n", a.0, a.1, b.0, b.1);+        }+        for (s, m, e) in [((96.0, 20.0), (98.828427, 21.171573), (100.0, 24.0)), ((100.0, 56.0), (98.828427, 58.828427), (96.0, 60.0)), ((24.0, 60.0), (21.171573, 58.828427), (20.0, 56.0)), ((20.0, 24.0), (21.171573, 21.171573), (24.0, 20.0))] {+            t += &format!("\t(gr_arc\n\t\t(start {} {})\n\t\t(mid {} {})\n\t\t(end {} {})\n\t\t(stroke (width 0.05) (type default))\n\t\t(layer \"Edge.Cuts\")\n\t)\n", s.0, s.1, m.0, m.1, e.0, e.1);+        }+        t += "\t(gr_poly (pts (xy 30 30) (xy 35 30) (xy 35 35)) (stroke (width 0.1) (type solid)) (fill no) (layer \"F.SilkS\"))\n)\n";+        let e = kicad_edges(&t);+        assert_eq!((e.len(), e.iter().filter(|x| x.arc).count()), (8, 4));+        assert!(sharp_corners(&e).is_empty());+        // the same board with square corners reads four+        let sq = "(kicad_pcb\n\t(gr_rect (start 20 20) (end 100 60) (stroke (width 0.05) (type default)) (fill no) (layer \"Edge.Cuts\"))\n)";+        let c = sharp_corners(&kicad_edges(sq));+        assert_eq!(c.len(), 4);+        for p in [(20.0, 20.0), (100.0, 20.0), (100.0, 60.0), (20.0, 60.0)] { assert!(c.iter().any(|q| near(*q, p)), "{p:?}"); }+    }+    #[test]+    fn a_footprint_edge_cuts_cutout_is_read_in_board_coordinates() {+        // a slot drawn in a footprint on Edge.Cuts, the footprint at (50, 30) turned 90 degrees+        let t = "(kicad_pcb\n\t(footprint \"Slot\" (layer \"F.Cu\") (at 50 30 90)\n\t\t(property \"Reference\" \"H1\")\n\t\t(fp_rect (start -1 -2) (end 1 2) (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n\t\t(fp_line (start -3 0) (end 3 0) (layer \"F.SilkS\"))\n\t)\n\t(footprint \"Notch\" (layer \"B.Cu\") (at 10 10 180)\n\t\t(property \"Reference\" \"H2\")\n\t\t(fp_line (start 0 0) (end 2 0) (layer \"Edge.Cuts\"))\n\t\t(fp_arc (start 2 0) (mid 2.7071 0.7071) (end 3 1) (layer \"Edge.Cuts\"))\n\t)\n)";+        let e = kicad_edges(t);+        assert_eq!(e.len(), 6);+        let c = sharp_corners(&e);+        assert_eq!(c.len(), 4, "{c:?}");+        // rot_kicad(-1, -2, 90) = (-2, 1): the corner lands at (48, 31), not near the origin+        for p in [(48.0, 31.0), (48.0, 29.0), (52.0, 29.0), (52.0, 31.0)] { assert!(c.iter().any(|q| near(*q, p)), "{p:?} in {c:?}"); }+        // the back-side footprint: position and rotation (180 degrees) map (2, 0) to (8, 10)+        assert!(e.iter().any(|x| x.arc && near(x.a, (8.0, 10.0)) && near(x.b, (7.0, 9.0))));+    } }
crates/adom-aiflow/src/placement_intent.rsadded+51
@@ -0,0 +1,51 @@+//! Placement intent (spec `placement.proximity`, `placement.cluster`, `placement.anchor`; issue #53)+//! in `place check`, `place verify` and `place pack`. The geometry is `aiflow_place::intent`.+use std::path::Path;++use aiflow_board::Board;+use aiflow_place::intent::{self, Intent};+use aiflow_run::Run;+use serde_json::{json, Value};++/// The spec's intent, or the command fails naming the bad entry.+pub fn of_spec(spec: &Value) -> Intent {+    intent::from_spec(spec).unwrap_or_else(|e| crate::err(&format!("spec {e}"), &[format!("Shape: {}", intent::SHAPE)]))+}++/// Check the intent on `board`, write `<dir>/placement-intent.json`, record the run outcome+/// `placement-intent` and return the verdict lines. Err carries the failed verdicts, one per line.+/// A spec without intent records nothing and returns no lines.+pub fn check(r: &mut Run, dir: &Path, spec: &Value, board: &Board, stage: &str) -> Result<Vec<String>, String> {+    let it = of_spec(spec);+    if it.is_empty() {+        return Ok(Vec::new());+    }+    let v = intent::verify(board, &it);+    let mut rep = intent::report(&v);+    rep["stage"] = json!(stage);+    rep["board"] = json!(board.path);+    let file = dir.join("placement-intent.json");+    std::fs::write(&file, serde_json::to_string_pretty(&rep).unwrap_or_default()).ok();+    r.outcome("placement-intent", json!({"stage": stage, "pass": rep["pass"], "failed": rep["failed"], "entries": rep["entries"], "report": file.display().to_string(), "verdicts": rep["verdicts"]}));+    let lines: Vec<String> = v.iter().map(|x| format!("{} {}", if x.pass { "OK" } else { "FAIL" }, x.text)).collect();+    let failed: Vec<String> = v.iter().filter(|x| !x.pass).map(|x| x.text.clone()).collect();+    if failed.is_empty() { Ok(lines) } else { Err(format!("placement intent not met ({} of {}):\n  {}", failed.len(), v.len(), failed.join("\n  "))) }+}++pub const HINT: &str = "The spec's placement intent is the board's own requirement: move the part (a new wish, `place pack`, `place check`), or change the entry in the spec with the reason. Distances are pad edge to pad edge for proximity and pad-box centres for a cluster radius.";++/// For `finish`: the last intent check failed.+pub fn blocks_finish(o: Option<&Value>) -> Option<String> {+    let o = o?;+    (o["pass"] == false).then(|| format!("placement intent: {} entr(ies) not met at {} (see {})", o["failed"], o["stage"].as_str().unwrap_or("?"), o["report"].as_str().unwrap_or("placement-intent.json")))+}++/// `place pack` advisory: the intent on the packed positions (nothing fails here).+pub fn pack_lines(board: &Board, moves: &std::collections::BTreeMap<String, aiflow_place::Move>, it: &Intent) -> Vec<String> {+    if it.is_empty() {+        return Vec::new();+    }+    let Ok((text, _)) = aiflow_place::move_in_text(board, moves) else { return Vec::new() };+    let Ok(nb) = Board::from_text(&board.path, text) else { return Vec::new() };+    intent::verify(&nb, it).iter().map(|x| format!("intent {}: {}", if x.pass { "ok" } else { "NOT MET" }, x.text)).collect()+}
crates/adom-aiflow/src/pourverify.rs+587−29
@@ -1,10 +1,16 @@-//! Read-only comparison of a saved board with an explicit pour plan.-//! This creates no zone ownership and never changes native copper.+//! Read-only comparison of a saved board with an explicit pour plan, plus the two explicit,+//! recorded exceptions around it: adopting matching zones (ownership, `--adopt --why`) and+//! accepting a plan whose provenance is not established (`--why`). Verification alone creates no+//! zone ownership and never changes native copper. use aiflow_board::{sx::Node, Board};-use serde_json::{json, Value};+use aiflow_run::Run;+use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; use std::path::Path; +/// The modes of a `land-pours` outcome that accept zones already on the saved board.+pub const EXISTING_MODES: [&str; 2] = ["verified-existing", "adopted-existing"];+ pub fn hash(path: &Path) -> Result<String, String> {     std::fs::read(path)         .map(|b| format!("{:x}", Sha256::digest(b)))@@ -34,6 +40,7 @@ fn canonical(n: &Node) -> Value {         .collect();     json!([n.tag, atoms, children]) }+/// The geometry and settings of one zone, normalised to the plan's words so both sides compare. fn signature(board: &Board, n: &Node) -> Value {     let mut layers: Vec<String> = n         .find("layer")@@ -56,7 +63,13 @@ fn signature(board: &Board, n: &Node) -> Value {         v["priority"] = json!(number(n, "priority", 0.0));         v["minThickness"] = json!(number(n, "min_thickness", 0.25));         let cp = n.find("connect_pads");-        v["connection"] = json!(cp.and_then(Node::value).unwrap_or("thermal"));+        v["connection"] = json!(match cp.and_then(Node::value) {+            None => "thermal",+            Some("yes") => "solid",+            Some("no") => "none",+            Some("thru_hole_only") => "thru-hole only",+            Some(other) => other,+        });         v["clearance"] = json!(cp.map(|x| number(x, "clearance", 0.0)).unwrap_or(0.0));         let fill = n.find("fill");         for (key, default) in [@@ -68,10 +81,90 @@ fn signature(board: &Board, n: &Node) -> Value {         ] {             v[key] = json!(fill.map(|x| number(x, key, default)).unwrap_or(default));         }+        let mode = v["island_removal_mode"].as_f64().unwrap_or(0.0);+        v["island_removal_mode"] = json!(match mode as i64 { 0 => "always", 1 => "never", 2 => "area", _ => "unknown" });+        // the minimum area means something only for the area mode+        if mode as i64 != 2 {+            v["island_area_min"] = Value::Null;+        }     }     v } +/// The plan's words for each signature field, as the per-zone report names them.+fn field_label(key: &str) -> &str {+    match key {+        "layers" => "layer",+        "polygons" => "geometry",+        "net" => "net",+        "priority" => "priority",+        "clearance" => "clearance",+        "minThickness" => "min thickness",+        "connection" => "pad connection",+        "thermal_gap" => "thermal gap",+        "thermal_bridge_width" => "thermal bridge",+        "island_removal_mode" => "island removal",+        "island_area_min" => "island area min",+        "mode" => "fill mode",+        "keepout" => "keepout rules",+        "placement" => "placement rule area",+        other => other,+    }+}++fn points(n: &Node) -> Vec<(f64, f64)> {+    n.find_all("polygon")+        .filter_map(|p| p.find("pts"))+        .flat_map(|pts| pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect::<Vec<_>>())+        .collect()+}++/// Each field where the board zone differs from the plan zone, with both values.+fn differences(want: &Value, have: &Value, want_node: &Node, have_node: &Node) -> Vec<Value> {+    let empty = Map::new();+    let (w, h) = (want.as_object().unwrap_or(&empty), have.as_object().unwrap_or(&empty));+    let mut keys: Vec<&String> = w.keys().chain(h.keys()).collect();+    keys.sort();+    keys.dedup();+    let mut out = Vec::new();+    for k in keys {+        let (a, b) = (w.get(k).unwrap_or(&Value::Null), h.get(k).unwrap_or(&Value::Null));+        if a == b {+            continue;+        }+        if k == "polygons" {+            let (pa, pb) = (points(want_node), points(have_node));+            let first = pa.iter().zip(pb.iter()).position(|(x, y)| x != y);+            let detail = match first {+                Some(i) => format!("vertex {i}: plan ({}, {}), board ({}, {})", pa[i].0, pa[i].1, pb[i].0, pb[i].1),+                None => format!("plan {} vertices, board {}", pa.len(), pb.len()),+            };+            out.push(json!({"field":"geometry","plan":format!("{} vertices", pa.len()),"board":format!("{} vertices", pb.len()),"detail":detail}));+        } else {+            out.push(json!({"field":field_label(k),"plan":a,"board":b}));+        }+    }+    out+}++fn diff_line(d: &[Value]) -> String {+    d.iter()+        .map(|x| match x["detail"].as_str() {+            Some(detail) => format!("{} ({detail})", x["field"].as_str().unwrap_or("")),+            None => format!("{} (plan {}, board {})", x["field"].as_str().unwrap_or(""), x["plan"], x["board"]),+        })+        .collect::<Vec<_>>()+        .join("; ")+}++fn zone_name(n: &Node) -> &str {+    n.find("name").and_then(Node::value).unwrap_or("unnamed")+}++fn zone_id(n: &Node) -> Option<String> {+    n.find("uuid").or_else(|| n.find("tstamp")).and_then(Node::value).filter(|s| !s.is_empty()).map(str::to_string)+}+ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {     let zones = plan["zones"]         .as_array()@@ -79,9 +172,11 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {         .ok_or("nonempty zones plan required")?;     let resolved = aiflow_pours::resolve_outline(board, zones);     let actual: Vec<_> = board.root.find_all("zone").collect();+    let signatures: Vec<Value> = actual.iter().map(|n| signature(board, n)).collect();     let mut used = std::collections::BTreeSet::new();     let mut groups = Vec::new();     let mut failed = Vec::new();+    let mut report = Vec::new();     for (index, z) in resolved.iter().enumerate() {         let label = z["name"].as_str().unwrap_or("unnamed zone");         let allowed = [@@ -95,6 +190,8 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {             "minThickness",             "thermalGap",             "thermalBridge",+            "islandRemoval",+            "islandAreaMin",             "type",             "keepout",         ];@@ -125,19 +222,10 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {         }) {             return Err(format!("{label}: finite polygon coordinates required"));         }-        for key in ["clearance", "minThickness", "thermalGap", "thermalBridge"] {-            if !z[key].is_null() && !z[key].as_f64().is_some_and(|x| x.is_finite() && x >= 0.0) {-                return Err(format!("{label}: invalid {key}"));-            }-        }+        aiflow_pours::check_settings(z).map_err(|e| format!("{label}: {e}"))?;         if !z["priority"].is_null() && z["priority"].as_u64().is_none() {             return Err(format!("{label}: invalid priority"));         }-        if !z["connection"].is_null()-            && !matches!(z["connection"].as_str(), Some("solid" | "full" | "thermal"))-        {-            return Err(format!("{label}: unsupported connection"));-        }         if !z["type"].is_null() && !matches!(z["type"].as_str(), Some("keepout" | "copper")) {             return Err(format!("{label}: unsupported zone type"));         }@@ -152,19 +240,39 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {         }         let expected = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, z)?)?;         let wanted = signature(board, &expected);-        let matches: Vec<_> = actual-            .iter()-            .enumerate()-            .filter(|(i, n)| !used.contains(i) && signature(board, n) == wanted)+        let matches: Vec<usize> = (0..actual.len())+            .filter(|i| !used.contains(i) && signatures[*i] == wanted)             .collect();         if matches.len() != 1 {-            failed.push(format!(+            // name the closest unused board zone and every field where it differs+            let closest = (0..actual.len())+                .filter(|i| !used.contains(i))+                .map(|i| (i, differences(&wanted, &signatures[i], &expected, actual[i])))+                .min_by_key(|(i, d)| (d.len(), *i));+            let mut line = format!(                 "{index}: {label}: expected one exact geometry/settings match, found {}",                 matches.len()-            ));+            );+            let mut entry = json!({"zone":index,"name":label,"matches":matches.len()});+            if matches.is_empty() {+                if let Some((i, d)) = closest {+                    line.push_str(&format!("; closest board zone {i} ({}) differs in: {}", zone_name(actual[i]), diff_line(&d)));+                    entry["closestBoardZone"] = json!(i);+                    entry["closestBoardZoneName"] = json!(zone_name(actual[i]));+                    entry["fields"] = json!(d);+                } else {+                    line.push_str("; no unmatched board zone is left");+                }+            } else {+                line.push_str(" (ambiguous: several board zones carry the same geometry and settings)");+                entry["boardZones"] = json!(matches);+            }+            failed.push(line);+            report.push(entry);             continue;         }-        let (i, n) = matches[0];+        let i = matches[0];+        let n = actual[i];         used.insert(i);         let supported = [             "net",@@ -172,6 +280,7 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {             "layer",             "layers",             "uuid",+            "tstamp",             "name",             "hatch",             "priority",@@ -221,23 +330,34 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {                 }             }         }-        groups.push(json!({"name":label,"boardZoneIndex":i,"ok":true}));+        groups.push(json!({"name":label,"boardZoneIndex":i,"boardZoneUuid":zone_id(n),"ok":true}));     }     // No hidden extra copper or keepout: every saved zone needs an explicit plan entry.     for (i, n) in actual.iter().enumerate().filter(|(i, _)| !used.contains(i)) {-        failed.push(format!(-            "unplanned board zone {i}: {}",-            n.find("name").and_then(Node::value).unwrap_or("unnamed")-        ));+        failed.push(format!("unplanned board zone {i}: {}", zone_name(n)));     }     Ok(-        json!({"mode":"verified-existing","pass":failed.is_empty(),"failed":failed,"groups":groups,+        json!({"mode":"verified-existing","pass":failed.is_empty(),"failed":failed,"groups":groups,"differences":report,         "ownershipGranted":false,"scope":"saved filled polygons and planned zone geometry/settings; native DRC, measured copper and electrical/thermal checks remain separate"}),     ) } +/// True for a `land-pours` outcome that accepted zones already on the saved board.+pub fn is_existing(outcome: &Value) -> bool {+    outcome["mode"].as_str().is_some_and(|m| EXISTING_MODES.contains(&m))+}++/// How finish and the summary name the pours evidence.+pub fn mode_label(outcome: &Value) -> &'static str {+    match outcome["mode"].as_str() {+        Some("verified-existing") => "verified existing",+        Some("adopted-existing") => "adopted existing (not landed by this run)",+        _ => "landed",+    }+}+ pub fn bound(outcome: &Value, board: &Path, spec: &Path) -> bool {-    if outcome["mode"] != "verified-existing" {+    if !is_existing(outcome) {         return true;     }     outcome["boardSha256"].as_str() == hash(board).ok().as_deref()@@ -250,6 +370,252 @@ pub fn bound(outcome: &Value, board: &Path, spec: &Path) -> bool {         && outcome["planSha256"].is_string() } +/// Settings the KiCad Bridge's `kicad_apply_zone_plan` cannot land faithfully (it lands thermal or+/// solid pad connection with islands always removed). Such a zone is refused before any call, so+/// a landing never silently differs from the plan; write it natively and verify or adopt it.+pub fn landable(zones: &[Value]) -> Result<(), String> {+    for z in zones {+        if z.get("keepout").is_some() || z["type"] == "keepout" {+            continue;+        }+        let name = z["name"].as_str().unwrap_or("unnamed zone");+        if z["connection"] == "none" {+            return Err(format!("{name}: pad connection none cannot be landed through kicad_apply_zone_plan (it lands thermal or solid)"));+        }+        if z.get("islandRemoval").is_some_and(|v| v != "always") {+            return Err(format!("{name}: islandRemoval {} cannot be landed through kicad_apply_zone_plan (it always removes islands)", z["islandRemoval"]));+        }+    }+    Ok(())+}++// -- plan provenance (issue #57) --------------------------------------------------------------++/// A hash of the board's zone definitions (outline, net, layers, settings), leaving out fills and+/// item IDs, so a refill or a re-save does not count as a zone change.+pub fn zone_fingerprint(board: &Board) -> String {+    fn strip(n: &Node) -> Value {+        let mut children: Vec<Value> = n+            .nodes()+            .filter(|c| !matches!(c.tag.as_str(), "filled_polygon" | "fill_segments" | "uuid" | "tstamp"))+            .map(strip)+            .collect();+        if n.tag != "pts" {+            children.sort_by_key(Value::to_string);+        }+        let atoms: Vec<&str> = (1..).map_while(|i| n.atom(i)).collect();+        json!([n.tag, atoms, children])+    }+    let mut zones: Vec<String> = board.root.find_all("zone").map(|z| strip(z).to_string()).collect();+    zones.sort();+    format!("{:x}", Sha256::digest(zones.join("\n").as_bytes()))+}++fn mtime(p: &Path) -> Option<u64> {+    std::fs::metadata(p).ok()?.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok().map(|d| d.as_secs())+}++/// When the current board's zones last changed: the time the run added the oldest snapshot of the+/// latest unbroken series whose zones equal the current ones (`boardAddedAt`, else the snapshot+/// file's modification time). An unreadable older snapshot ends the series (later, stricter).+pub fn zones_changed_at(data: &Value) -> Result<(u64, String), String> {+    let boards: Vec<String> = data["boards"].as_array().map(|a| a.iter().filter_map(|b| b.as_str().map(str::to_string)).collect()).unwrap_or_default();+    let current = boards.last().ok_or("the run has no board")?;+    let fp = zone_fingerprint(&Board::load(current)?);+    let mut first = current.clone();+    for b in boards.iter().rev().skip(1) {+        match Board::load(b) {+            Ok(old) if zone_fingerprint(&old) == fp => first = b.clone(),+            _ => break,+        }+    }+    let t = data["boardAddedAt"][first.as_str()]+        .as_str()+        .and_then(aiflow_run::parse)+        .or_else(|| mtime(Path::new(&first)))+        .ok_or_else(|| format!("{first}: no time for this board snapshot"))?;+    Ok((t, first))+}++/// Record a plan in the run: `pour` for one aiflow wrote, `reviewed` for one recorded before+/// the zones it describes change (`verify-pours --record-plan`).+pub fn record_plan(data: &mut Value, path: &Path, source: &str) -> Result<Value, String> {+    let sha = hash(path).map_err(|e| format!("{}: {e}", path.display()))?;+    let canon = std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))?;+    let mut entry = json!({"source": source, "sha256": sha, "path": canon, "at": aiflow_run::now()});+    if source == "reviewed" {+        if let Some(b) = data["boards"].as_array().and_then(|a| a.last()).and_then(Value::as_str) {+            entry["boardSha256"] = json!(hash(Path::new(b)).ok());+            entry["zoneFingerprint"] = json!(Board::load(b).ok().map(|b| zone_fingerprint(&b)));+        }+    }+    if !data["pourPlans"].is_array() {+        data["pourPlans"] = json!([]);+    }+    data["pourPlans"].as_array_mut().unwrap().push(entry.clone());+    Ok(entry)+}++/// Where the plan came from: Ok with the accepted record, or Err saying why it is not bound.+/// Accepted: a plan `pour` wrote (its hash recorded by the run), or a reviewed plan recorded+/// strictly before the current board's zones changed. Anything else is circular until shown+/// otherwise: a plan written from the board always matches the board.+pub fn provenance(data: &Value, plan_sha: &str, plan: &Path) -> Result<Value, String> {+    let plans = data["pourPlans"].as_array().cloned().unwrap_or_default();+    let same: Vec<&Value> = plans.iter().filter(|p| p["sha256"].as_str() == Some(plan_sha)).collect();+    if let Some(p) = same.iter().rev().find(|p| p["source"] == "pour") {+        return Ok(json!({"kind": "aiflow-pour", "recordedAt": p["at"], "path": p["path"]}));+    }+    let (changed, board) = zones_changed_at(data)?;+    let reviewed = same+        .iter()+        .filter(|p| p["source"] == "reviewed")+        .filter_map(|p| Some((aiflow_run::parse(p["at"].as_str()?)?, *p)))+        .min_by_key(|(t, _)| *t);+    if let Some((t, p)) = reviewed {+        if t < changed {+            return Ok(json!({"kind": "reviewed-before-zone-change", "recordedAt": p["at"], "zonesChangedAt": aiflow_run::iso(changed), "zonesChangedBoard": board}));+        }+    }+    let when = aiflow_run::iso(changed);+    let recorded = match reviewed {+        Some((t, _)) => format!("it was recorded at {} (not before)", aiflow_run::iso(t)),+        None => "it was not recorded with `verify-pours --record-plan` before then".into(),+    };+    let file = match mtime(plan) {+        Some(m) if m >= changed => format!("; the plan file was modified at {}, after the zones changed", aiflow_run::iso(m)),+        Some(m) => format!("; the plan file was modified at {}", aiflow_run::iso(m)),+        None => String::new(),+    };+    Err(format!("plan provenance not established: `pour` did not write this plan, and the board's zones last changed at {when} (snapshot {board}) while {recorded}{file}. A plan written from the board always matches the board."))+}++/// Grant ownership of the zones a passing verification matched: their saved IDs join+/// `ownedZoneIds` (so a later `land pours` may replace them) and the grant is recorded with its+/// why in `zoneAdoptions`. Every matched zone needs a saved ID; nothing is inferred from names.+pub fn adopt(data: &mut Value, proof: &Value, why: &str) -> Result<Vec<String>, String> {+    if proof["pass"] != true {+        return Err("only a passing verification can adopt zones".into());+    }+    if why.trim().is_empty() {+        return Err("--adopt needs --why".into());+    }+    let groups = proof["groups"].as_array().cloned().unwrap_or_default();+    let mut ids = Vec::new();+    for g in &groups {+        let id = g["boardZoneUuid"].as_str().filter(|s| !s.is_empty()).ok_or_else(|| format!("{}: the saved zone has no uuid to own", g["name"]))?;+        ids.push(id.to_string());+    }+    if ids.is_empty() {+        return Err("no zones to adopt".into());+    }+    // Before the first grant, keep the legacy creation receipts the landing used to read.+    if data.get("ownedZoneIds").is_none_or(Value::is_null) {+        let mut legacy: Vec<Value> = Vec::new();+        for g in data["outcomes"]["land-pours"]["groups"].as_array().into_iter().flatten().filter(|g| g["ok"] == true) {+            for id in g["itemIds"].as_array().into_iter().flatten().filter(|v| v.is_string()) {+                if !legacy.contains(id) {+                    legacy.push(id.clone());+                }+            }+        }+        data["ownedZoneIds"] = json!(legacy);+    }+    let owned = data["ownedZoneIds"].as_array_mut().ok_or("ownedZoneIds is not a list")?;+    for id in &ids {+        if !owned.iter().any(|x| x.as_str() == Some(id)) {+            owned.push(json!(id));+        }+    }+    if !data["zoneAdoptions"].is_array() {+        data["zoneAdoptions"] = json!([]);+    }+    data["zoneAdoptions"].as_array_mut().unwrap().push(json!({+        "at": aiflow_run::now(), "why": why, "plan": proof["plan"], "planSha256": proof["planSha256"],+        "boardSha256": proof["boardSha256"], "planProvenance": proof["planProvenance"],+        "zones": groups.iter().map(|g| json!({"name": g["name"], "uuid": g["boardZoneUuid"]})).collect::<Vec<_>>()+    }));+    Ok(ids)+}++type Reply = Result<(String, Vec<String>), (String, Vec<String>)>;++/// `verify-pours`: compare, bind the plan's provenance, and optionally record a reviewed plan or+/// adopt the matched zones. Writes the `land-pours` outcome only for a comparison.+pub fn command(r: &mut Run, dir: &Path, plan: &str, adopt_zones: bool, why: Option<&str>, record: bool) -> Reply {+    let e = |m: String| (m, Vec::<String>::new());+    let why = why.map(str::trim).filter(|w| !w.is_empty());+    let plan_path = Path::new(plan);+    if record {+        if adopt_zones {+            return Err(e("--record-plan only records the plan; verify (and adopt) in a later call".into()));+        }+        let v: Value = serde_json::from_str(&std::fs::read_to_string(plan_path).map_err(|x| e(format!("{plan}: {x}")))?).map_err(|x| e(format!("{plan}: not JSON: {x}")))?;+        if v["zones"].as_array().is_none_or(Vec::is_empty) {+            return Err(e("nonempty zones plan required".into()));+        }+        let entry = record_plan(&mut r.data, plan_path, "reviewed").map_err(e)?;+        r.log("pour-plan-recorded", entry.clone());+        r.save().map_err(e)?;+        return Ok((format!("reviewed pour plan recorded (sha256 {}); verify-pours accepts it once the board's zones change after this moment", &entry["sha256"].as_str().unwrap_or("")[..12]),+            vec!["Record the plan before the zones it describes are drawn or edited. A plan recorded after the zones changed still needs --why.".into()]));+    }+    if adopt_zones && why.is_none() {+        return Err(("--adopt needs --why <reason>: adoption grants this run ownership of zones it did not land".into(), vec![]));+    }+    let board_path = r.current_board();+    let board_hash = hash(Path::new(&board_path)).map_err(e)?;+    let plan_hash = hash(plan_path).map_err(|x| e(format!("{plan}: {x}")))?;+    let board = Board::load(&board_path).map_err(e)?;+    let plan_v: Value = serde_json::from_str(&std::fs::read_to_string(plan_path).map_err(|x| e(format!("{plan}: {x}")))?).map_err(|x| e(format!("{plan}: not JSON: {x}")))?;+    let mut proof = check(&board, &plan_v).map_err(e)?;+    if hash(Path::new(&board_path)).ok().as_deref() != Some(&board_hash) || hash(plan_path).ok().as_deref() != Some(&plan_hash) {+        return Err(e("board or plan changed during verification; retry after the writer finishes".into()));+    }+    let provenance = match provenance(&r.data, &plan_hash, plan_path) {+        Ok(p) => p,+        Err(reason) => match why {+            Some(w) => json!({"kind": "accepted-with-why", "why": w, "refusal": reason}),+            None => {+                r.log("verify-pours-refused", json!({"plan": plan, "planSha256": plan_hash, "reason": reason}));+                return Err((reason, vec![+                    "Verify against the plan `pour` wrote (adom-aiflow pour), or a plan recorded with `verify-pours --plan <file> --record-plan` before the zones changed.".into(),+                    "If this plan is honestly independent of the board, pass --why <reason>; the why is recorded with the proof. No board or ownership was changed.".into(),+                ]));+            }+        },+    };+    proof["planProvenance"] = provenance;+    proof["boardSha256"] = json!(board_hash);+    proof["specSha256"] = json!(hash(Path::new(r.data["spec"].as_str().unwrap_or(""))).map_err(e)?);+    proof["planSha256"] = json!(plan_hash);+    proof["plan"] = json!(std::fs::canonicalize(plan).map_err(|x| e(x.to_string()))?);+    if proof["pass"] == true && adopt_zones {+        let ids = adopt(&mut r.data, &proof, why.unwrap_or("")).map_err(e)?;+        proof["mode"] = json!("adopted-existing");+        proof["ownershipGranted"] = json!(true);+        proof["adoptedZoneIds"] = json!(ids);+        proof["why"] = json!(why);+    }+    let out = dir.join("verify-pours.json");+    std::fs::write(&out, serde_json::to_string_pretty(&proof).unwrap()).map_err(|x| e(format!("{}: {x}", out.display())))?;+    r.outcome("land-pours", proof.clone());+    r.save().map_err(e)?;+    if proof["pass"] != true {+        return Err((format!("saved pours do not match the plan: {}", proof["failed"]), vec![+            "Each failure names the closest board zone and the fields that differ (also in verify-pours.json `differences`). spec.pours can carry connection (thermal, solid, none), thermalGap, thermalBridge, islandRemoval (always, never, area) and islandAreaMin; run `pour` again after a spec change.".into(),+            "Review the actual geometry and intended plan; never replay pours or write a plan from the board merely to restore evidence. No board or ownership was changed.".into(),+        ]));+    }+    let kind = proof["planProvenance"]["kind"].as_str().unwrap_or("").to_string();+    if adopt_zones {+        return Ok((format!("existing saved pours match the reviewed plan ({kind}); {} zone(s) adopted: owned by this run, recorded as adopted, not landed", proof["adoptedZoneIds"].as_array().map(|a| a.len()).unwrap_or(0)),+            vec!["A later `land pours` may now replace these zones through the native bridge. Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof.".into()]));+    }+    Ok((format!("existing saved pours match the reviewed plan ({kind}); no copper or ownership changed"),+        vec!["Native DRC, live measurement, current and thermal checks remain required. Changed board, spec or plan invalidates this proof. Add --adopt --why <reason> to take ownership of these zones.".into()]))+}+ #[cfg(test)] mod tests {     use super::*;@@ -319,7 +685,7 @@ mod tests {     #[test]     fn invalid_connection_refuses() {         let (b, mut p) = fixture();-        p["zones"][0]["connection"] = json!("none");+        p["zones"][0]["connection"] = json!("spokes");         assert!(check(&b, &p).is_err());     }     #[test]@@ -355,4 +721,196 @@ mod tests {         p["zones"][0]["typo"] = json!(1);         assert!(check(&b, &p).is_err());     }++    // -- issue #69: zones written offline, field-by-field report, settings, adoption ----------+    const PREFIX: &str = r#"(kicad_pcb (version 20260101) (net 0 "") (net 1 "5V0") (layers (0 "F.Cu" signal) (31 "B.Cu" signal)) (gr_rect (start 0 0) (end 20 20) (layer "Edge.Cuts")) (footprint "R" (layer "F.Cu") (at 5 5) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "5V0")))"#;+    /// A zone the way the issue's offline script wrote it: solid pads, clearance 0.25,+    /// min thickness 0.2, thermal gap 0.3, bridge 0.4, island removal written out as 0.+    const OFFLINE: &str = r#"(zone (net 1) (net_name "5V0") (layer "F.Cu") (uuid "aaaa-1") (name "P3 5V0 power") (hatch edge 0.5) (priority 2) (connect_pads yes (clearance 0.25)) (min_thickness 0.2) (filled_areas_thickness no) (fill yes (thermal_gap 0.3) (thermal_bridge_width 0.4) (island_removal_mode 0)) (polygon (pts (xy 1 1) (xy 9 1) (xy 9 9) (xy 1 9))) (filled_polygon (layer "F.Cu") (pts (xy 1 1) (xy 9 1) (xy 9 9))))"#;+    fn offline() -> Board {+        Board::from_text("offline", format!("{PREFIX} {OFFLINE})")).unwrap()+    }+    fn offline_plan() -> Value {+        json!({"zones":[{"name":"P3 5V0 power","net":"5V0","layers":["F.Cu"],"polygon":[[1,1],[9,1],[9,9],[1,9]],"priority":2,"clearance":0.25,"minThickness":0.2,"connection":"solid"}]})+    }+    #[test]+    fn offline_zone_report_names_each_differing_field() {+        let r = check(&offline(), &offline_plan()).unwrap();+        assert_eq!(r["pass"], false);+        let line = r["failed"][0].as_str().unwrap();+        assert!(line.contains("closest board zone 0 (P3 5V0 power)"), "{line}");+        assert!(line.contains("thermal gap (plan 0.5, board 0.3)") && line.contains("thermal bridge (plan 0.5, board 0.4)"), "{line}");+        let fields: Vec<&str> = r["differences"][0]["fields"].as_array().unwrap().iter().map(|f| f["field"].as_str().unwrap()).collect();+        assert_eq!(fields, ["thermal bridge", "thermal gap"]);+        // every other field is named too when it differs+        let mut p = offline_plan();+        p["zones"][0]["polygon"][2] = json!([9, 8]);+        p["zones"][0]["priority"] = json!(1);+        p["zones"][0]["clearance"] = json!(0.3);+        p["zones"][0]["minThickness"] = json!(0.25);+        p["zones"][0]["connection"] = json!("thermal");+        p["zones"][0]["islandRemoval"] = json!("never");+        let line = check(&offline(), &p).unwrap()["failed"][0].as_str().unwrap().to_string();+        for want in ["geometry (vertex 2: plan (9, 8), board (9, 9))", "priority", "clearance", "min thickness", "pad connection (plan \"thermal\", board \"solid\")", "island removal (plan \"never\", board \"always\")"] {+            assert!(line.contains(want), "{want} in {line}");+        }+        p["zones"][0]["layers"] = json!(["B.Cu"]);+        assert!(check(&offline(), &p).unwrap()["failed"][0].as_str().unwrap().contains("layer (plan [\"B.Cu\"], board [\"F.Cu\"])"));+    }+    #[test]+    fn plan_settings_describe_offline_zones_exactly() {+        let mut p = offline_plan();+        p["zones"][0]["thermalGap"] = json!(0.3);+        p["zones"][0]["thermalBridge"] = json!(0.4);+        let r = check(&offline(), &p).unwrap();+        assert_eq!(r["pass"], true, "{}", r["failed"]);+        assert_eq!(r["groups"][0]["boardZoneUuid"], "aaaa-1");+        // pad connection none and island removal never / below an area round-trip too+        for (conn, cp, island, fill) in [("none", "(connect_pads no (clearance 0.25))", json!("never"), "(island_removal_mode 1)"), ("thermal", "(connect_pads (clearance 0.25))", json!("area"), "(island_removal_mode 2) (island_area_min 4)")] {+            let zone = OFFLINE.replace("(connect_pads yes (clearance 0.25))", cp).replace("(island_removal_mode 0)", fill);+            let b = Board::from_text("o", format!("{PREFIX} {zone})")).unwrap();+            let mut q = p.clone();+            q["zones"][0]["connection"] = json!(conn);+            q["zones"][0]["islandRemoval"] = island.clone();+            if island == "area" {+                q["zones"][0]["islandAreaMin"] = json!(4);+            }+            assert_eq!(check(&b, &q).unwrap()["pass"], true, "{conn}: {}", check(&b, &q).unwrap()["failed"]);+            q["zones"][0]["connection"] = json!("solid");+            assert_eq!(check(&b, &q).unwrap()["pass"], false);+        }+    }+    #[test]+    fn bridge_limits_refuse_before_landing() {+        assert!(landable(&[json!({"name":"a","connection":"solid"}), json!({"type":"keepout","connection":"none"})]).is_ok());+        assert!(landable(&[json!({"name":"a","connection":"none"})]).unwrap_err().contains("pad connection none"));+        assert!(landable(&[json!({"name":"a","islandRemoval":"never"})]).is_err());+        assert!(landable(&[json!({"name":"a","islandRemoval":"always"})]).is_ok());+    }+    #[test]+    fn adoption_grants_recorded_ownership_only_for_saved_ids() {+        let mut p = offline_plan();+        p["zones"][0]["thermalGap"] = json!(0.3);+        p["zones"][0]["thermalBridge"] = json!(0.4);+        let proof = check(&offline(), &p).unwrap();+        let mut data = json!({"outcomes":{"land-pours":{"groups":[{"ok":true,"itemIds":["landed-1"]},{"ok":false,"itemIds":["failed-1"]}]}}});+        assert!(adopt(&mut data, &proof, " ").is_err(), "a why is required");+        assert_eq!(adopt(&mut data, &proof, "written offline; KiCad 10 SWIG cannot build outlines").unwrap(), ["aaaa-1"]);+        assert_eq!(data["ownedZoneIds"], json!(["landed-1", "aaaa-1"]), "legacy receipts kept, failed ones never owned");+        assert_eq!(data["zoneAdoptions"][0]["zones"][0]["uuid"], "aaaa-1");+        assert!(data["zoneAdoptions"][0]["why"].as_str().unwrap().contains("SWIG"));+        let mut failing = proof.clone();+        failing["pass"] = json!(false);+        assert!(adopt(&mut data, &failing, "x").is_err());+        let mut no_id = proof.clone();+        no_id["groups"][0]["boardZoneUuid"] = Value::Null;+        assert!(adopt(&mut json!({}), &no_id, "x").unwrap_err().contains("no uuid"));+        assert_eq!(mode_label(&json!({"mode":"adopted-existing"})), "adopted existing (not landed by this run)");+        assert!(is_existing(&json!({"mode":"adopted-existing"})) && !is_existing(&json!({"failed":[]})));+    }++    // -- issue #57: plan provenance ---------------------------------------------------------------+    fn tmpdir(name: &str) -> std::path::PathBuf {+        let d = std::env::temp_dir().join(format!("aiflow-pour-prov-{name}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        d+    }+    /// board-0 without zones (added at t0), board-1 with the offline zone (added at t1).+    fn prov_run(d: &Path, t0: u64, t1: u64) -> Run {+        let (b0, b1) = (d.join("board-0.kicad_pcb"), d.join("board-1.kicad_pcb"));+        std::fs::write(&b0, format!("{PREFIX})")).unwrap();+        std::fs::write(&b1, format!("{PREFIX} {OFFLINE})")).unwrap();+        let spec = d.join("spec.json");+        std::fs::write(&spec, "{}").unwrap();+        let (b0s, b1s) = (b0.display().to_string(), b1.display().to_string());+        Run { dir: d.to_path_buf(), data: json!({"boards":[b0s, b1s], "spec": spec, "outcomes": {},+            "boardAddedAt": {b0s.as_str(): aiflow_run::iso(t0), b1s.as_str(): aiflow_run::iso(t1)}}) }+    }+    fn write_plan(d: &Path, name: &str) -> std::path::PathBuf {+        let mut p = offline_plan();+        p["zones"][0]["thermalGap"] = json!(0.3);+        p["zones"][0]["thermalBridge"] = json!(0.4);+        let f = d.join(name);+        std::fs::write(&f, p.to_string()).unwrap();+        f+    }+    #[test]+    fn zone_change_time_is_the_first_snapshot_with_the_current_zones() {+        let d = tmpdir("time");+        let mut r = prov_run(&d, 1000, 2000);+        assert_eq!(zones_changed_at(&r.data).unwrap().0, 2000);+        // a later snapshot with the same zones (silk-only, refilled) keeps the change time+        let b2 = d.join("board-2.kicad_pcb");+        std::fs::write(&b2, format!("{PREFIX} {})", OFFLINE.replace("(xy 9 9))))", "(xy 9 8))))").replace("aaaa-1", "bbbb-2"))).unwrap();+        r.data["boards"].as_array_mut().unwrap().push(json!(b2.display().to_string()));+        r.data["boardAddedAt"][b2.display().to_string().as_str()] = json!(aiflow_run::iso(3000));+        assert_eq!(zones_changed_at(&r.data).unwrap(), (2000, d.join("board-1.kicad_pcb").display().to_string()));+        let _ = std::fs::remove_dir_all(d);+    }+    #[test]+    fn plan_provenance_binds_to_pour_or_to_a_record_before_the_change() {+        let d = tmpdir("prov");+        let now = aiflow_run::parse(&aiflow_run::now()).unwrap();+        let mut r = prov_run(&d, now - 200, now - 100);+        let plan = write_plan(&d, "plan.json");+        let sha = hash(&plan).unwrap();+        // written from the board after the zones changed, never recorded: refused+        let e = provenance(&r.data, &sha, &plan).unwrap_err();+        assert!(e.contains("not recorded") && e.contains("after the zones changed"), "{e}");+        // recorded now, after the change: still refused+        record_plan(&mut r.data, &plan, "reviewed").unwrap();+        assert!(provenance(&r.data, &sha, &plan).unwrap_err().contains("not before"));+        // recorded before the zones changed: accepted+        let mut early = r.data.clone();+        early["pourPlans"][0]["at"] = json!(aiflow_run::iso(now - 150));+        assert_eq!(provenance(&early, &sha, &plan).unwrap()["kind"], "reviewed-before-zone-change");+        // the same second as the change is not before it+        early["pourPlans"][0]["at"] = json!(aiflow_run::iso(now - 100));+        assert!(provenance(&early, &sha, &plan).is_err());+        // a plan `pour` wrote is aiflow's own output+        record_plan(&mut r.data, &plan, "pour").unwrap();+        assert_eq!(provenance(&r.data, &sha, &plan).unwrap()["kind"], "aiflow-pour");+        // an edited plan is a different plan+        std::fs::write(&plan, "{\"zones\":[{}]}").unwrap();+        assert!(provenance(&r.data, &hash(&plan).unwrap(), &plan).is_err());+        let _ = std::fs::remove_dir_all(d);+    }+    #[test]+    fn verify_command_refuses_circular_plans_without_why_and_records_adoption() {+        let d = tmpdir("cmd");+        let now = aiflow_run::parse(&aiflow_run::now()).unwrap();+        let mut r = prov_run(&d, now - 200, now - 100);+        let plan = write_plan(&d, "plan-actual.json");+        let ps = plan.display().to_string();+        let (m, _) = command(&mut r, &d, &ps, false, None, false).unwrap_err();+        assert!(m.contains("provenance"), "{m}");+        assert!(r.data["outcomes"].get("land-pours").is_none(), "a refusal records no proof");+        assert!(command(&mut r, &d, &ps, true, None, false).unwrap_err().0.contains("--why"));+        let (m, _) = command(&mut r, &d, &ps, false, Some("plan typed from the native review, not the board"), false).unwrap();+        assert!(m.contains("accepted-with-why"), "{m}");+        let o = r.data["outcomes"]["land-pours"].clone();+        assert_eq!(o["planProvenance"]["why"], "plan typed from the native review, not the board");+        assert_eq!(o["ownershipGranted"], false);+        let spec = r.data["spec"].as_str().unwrap().to_string();+        assert!(bound(&o, Path::new(&r.current_board()), Path::new(&spec)));+        // adoption: owned, recorded, reported as adopted rather than landed+        let (m, _) = command(&mut r, &d, &ps, true, Some("zones written offline (#69)"), false).unwrap();+        assert!(m.contains("adopted"), "{m}");+        let o = r.data["outcomes"]["land-pours"].clone();+        assert_eq!(o["mode"], "adopted-existing");+        assert_eq!(r.data["ownedZoneIds"], json!(["aaaa-1"]));+        assert!(bound(&o, Path::new(&r.current_board()), Path::new(&spec)));+        std::fs::write(&plan, "{}").unwrap();+        assert!(!bound(&o, Path::new(&r.current_board()), Path::new(&spec)), "a changed plan invalidates the adopted proof");+        // a plan recorded with --record-plan before the zones change needs no why+        let d2 = tmpdir("cmd2");+        let mut r2 = prov_run(&d2, now - 200, now + 100);+        let plan2 = write_plan(&d2, "reviewed.json");+        command(&mut r2, &d2, &plan2.display().to_string(), false, None, true).unwrap();+        let (m, _) = command(&mut r2, &d2, &plan2.display().to_string(), false, None, false).unwrap();+        assert!(m.contains("reviewed-before-zone-change"), "{m}");+        let _ = std::fs::remove_dir_all(d);+        let _ = std::fs::remove_dir_all(d2);+    } }
crates/adom-aiflow/src/preboard.rs+22−1
@@ -284,7 +284,9 @@ pub fn clips_page(data: &Value, dir: &Path, notes: &Value, updated: &str) -> (St         }         rows.push(format!("| {n} | {step} | {visit}{} | {} | {} | {state_cell} |", if c["back"].as_bool() == Some(true) { " (back)" } else { "" }, when(start), links.join(", ")));         let mut b = vec![format!("### Clip {n}: {title}"), String::new(), format!("<a id=\"clip-{n}\"></a>"), String::new()];-        let why = c["why"].as_str().map(|w| format!(" Back because: {w}.")).unwrap_or_default();+        let mut why = c["why"].as_str().map(|w| format!(" Back because: {w}.")).unwrap_or_default();+        if let Some(g) = c["resumedAfterGap"].as_object() { why.push_str(&format!(" Resumed after a gap from {} to {}: {}.", when(g.get("from").and_then(|v| v.as_str()).unwrap_or("")), when(g.get("to").and_then(|v| v.as_str()).unwrap_or("")), g.get("why").and_then(|v| v.as_str()).unwrap_or("the take died"))); }+        if let Some(cam) = c["camera"].as_str() { why.push_str(&format!(" Filmed: {cam}.")); }         b.push(format!("Step `{step}`, visit {visit}. Recorded {}{offset}{secs}.{why}", when(start)));         b.push(String::new());         if let Some(s) = note["shows"].as_str().filter(|s| !s.is_empty()) { b.push(s.to_string()); b.push(String::new()); }@@ -471,6 +473,15 @@ mod tests {  /// The parts a BOM buys, for the parts step: one row per BOM line with an MPN (references, MPN, /// manufacturer and value as written). Rows without an MPN (`no part`, bare pads) are left out.+/// `--brief`: the text itself, or the contents of the file it names (issue #64: a path was published literally).+pub fn brief_text(arg: &str) -> String {+    let p = Path::new(arg.trim());+    if !arg.contains('\n') && p.is_file() {+        if let Ok(t) = std::fs::read_to_string(p) { return t.trim().to_string(); }+    }+    arg.to_string()+}+ pub fn bom_parts(text: &str) -> Vec<Value> {     let rows = parse_csv(text);     let Some(header) = rows.first() else { return Vec::new() };@@ -499,4 +510,14 @@ mod bom_parts_tests {         assert_eq!(p[1]["mpn"], "TPS54202DDCR");         assert_eq!(p[1]["manufacturer"], "TI");     }++    #[test]+    fn a_brief_that_names_a_file_is_read() {+        let f = std::env::temp_dir().join(format!("aiflow-brief-{}.md", std::process::id()));+        std::fs::write(&f, "# Buck\n\nA 12 V to 5 V buck.\n").unwrap();+        assert_eq!(brief_text(f.to_str().unwrap()), "# Buck\n\nA 12 V to 5 V buck.");+        assert_eq!(brief_text("design/brief-that-does-not-exist.md"), "design/brief-that-does-not-exist.md");+        assert_eq!(brief_text("A 12 V buck"), "A 12 V buck");+        let _ = std::fs::remove_file(&f);+    } }
crates/adom-aiflow/src/project_lib.rsadded+222
@@ -0,0 +1,222 @@+//! A board's own project library (issue #66): parts that have no public Adom wiki component page yet+//! (a private page, a reviewed project-only footprint, a page waiting for an owner's sign-off) satisfy+//! the `parts` step from a folder that holds KiCad symbols (*.kicad_sym), footprints (*.pretty/*.kicad_mod),+//! STEP models and a provenance.json. Each listed MPN needs a symbol, a footprint, a STEP model and a+//! provenance source, the same evidence a component page carries. `project-page` packages these parts into+//! the board's PCM repository beside the wiki parts, and `finish` reports which parts came from here.+//!+//! provenance.json: {"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, p. 12", "symbol": "ABC-123",+//! "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]} (or {"parts": {"ABC-123": {...}}}).+//! symbol defaults to the MPN, footprint to the symbol's Footprint property, model to the footprint's model.++use aiflow_board::sx;+use serde_json::{json, Value};+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++struct Symbol { file: PathBuf, footprint: String }+struct Footprint { file: PathBuf, model: Option<String> }++pub struct ProjectLib {+    pub dir: PathBuf,+    entries: BTreeMap<String, Value>,+    symbols: BTreeMap<String, Symbol>,+    footprints: BTreeMap<String, Footprint>,+    models: Vec<PathBuf>,+}++fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {+    let Ok(rd) = std::fs::read_dir(dir) else { return };+    for e in rd.flatten() {+        let p = e.path();+        if p.is_dir() { if depth < 4 { walk(&p, depth + 1, out); } } else { out.push(p); }+    }+}++fn ext(p: &Path) -> String { p.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase() }++fn key(s: &str) -> String { s.trim().to_ascii_uppercase() }++/// The part name after a `Lib:` prefix.+fn bare(s: &str) -> &str { s.rsplit_once(':').map(|(_, n)| n).unwrap_or(s) }++fn is_step(p: &Path) -> bool {+    std::fs::read(p).map(|b| String::from_utf8_lossy(&b[..b.len().min(64)]).trim_start().starts_with("ISO-10303-21")).unwrap_or(false)+}++impl ProjectLib {+    pub fn load(dir: &Path) -> Result<ProjectLib, String> {+        if !dir.is_dir() { return Err(format!("project library {}: no such folder", dir.display())); }+        let prov_path = dir.join("provenance.json");+        let prov: Value = std::fs::read_to_string(&prov_path).map_err(|e| format!("{}: {e} (a project library needs a provenance.json)", prov_path.display()))+            .and_then(|t| serde_json::from_str(&t).map_err(|e| format!("{}: {e}", prov_path.display())))?;+        let mut entries = BTreeMap::new();+        let parts = if prov.is_array() { prov.clone() } else { prov["parts"].clone() };+        match parts {+            Value::Array(a) => for p in a { if let Some(m) = p["mpn"].as_str() { entries.insert(key(m), p.clone()); } },+            Value::Object(o) => for (m, p) in o { let mut p = p.clone(); if p.is_object() && p["mpn"].is_null() { p["mpn"] = json!(m); } entries.insert(key(&m), p); },+            _ => return Err(format!("{}: no \"parts\" list", prov_path.display())),+        }+        let mut files = Vec::new();+        walk(dir, 0, &mut files);+        let mut symbols = BTreeMap::new();+        let mut footprints = BTreeMap::new();+        let mut models = Vec::new();+        for f in &files {+            match ext(f).as_str() {+                "kicad_sym" => {+                    let text = std::fs::read_to_string(f).map_err(|e| format!("{}: {e}", f.display()))?;+                    let root = sx::parse(&text).map_err(|e| format!("{}: {e}", f.display()))?;+                    for s in root.nodes().filter(|n| n.tag == "symbol") {+                        let Some(name) = s.atom(1) else { continue };+                        let fp = s.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Footprint")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+                        symbols.insert(key(name), Symbol { file: f.clone(), footprint: fp });+                    }+                }+                "kicad_mod" if f.parent().map(|p| ext(p) == "pretty").unwrap_or(false) => {+                    let text = std::fs::read_to_string(f).map_err(|e| format!("{}: {e}", f.display()))?;+                    let root = sx::parse(&text).map_err(|e| format!("{}: {e}", f.display()))?;+                    let model = root.find("model").and_then(|m| m.atom(1)).map(str::to_string);+                    let stem = f.file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();+                    footprints.insert(key(&stem), Footprint { file: f.clone(), model });+                }+                "step" | "stp" => models.push(f.clone()),+                _ => {}+            }+        }+        Ok(ProjectLib { dir: dir.to_path_buf(), entries, symbols, footprints, models })+    }++    /// The part's files when the library has all of them, else what is missing.+    pub fn part(&self, mpn: &str) -> Result<Value, Vec<String>> {+        let Some(e) = self.entries.get(&key(mpn)) else { return Err(vec!["not in provenance.json".into()]) };+        let mut why = Vec::new();+        let source = ["source", "datasheet", "origin"].iter().find_map(|k| e[*k].as_str().filter(|s| !s.trim().is_empty())).map(str::to_string);+        if source.is_none() { why.push("no provenance source (provenance.json \"source\": where the symbol, footprint and model come from)".into()); }+        let sym_name = e["symbol"].as_str().map(|s| bare(s).to_string()).unwrap_or_else(|| mpn.to_string());+        let sym = self.symbols.get(&key(&sym_name));+        if sym.is_none() { why.push(format!("no symbol {sym_name} in a .kicad_sym")); }+        let fp_name = e["footprint"].as_str().map(|s| bare(s).to_string()).or_else(|| sym.map(|s| bare(&s.footprint).to_string())).unwrap_or_default();+        let fp = self.footprints.get(&key(&fp_name));+        if fp.is_none() { why.push(if fp_name.is_empty() { "no footprint (name one in provenance.json or the symbol's Footprint property)".into() } else { format!("no footprint {fp_name}.kicad_mod in a .pretty folder") }); }+        let model = match e["model"].as_str() {+            Some(m) => { let p = self.dir.join(m); if p.is_file() { Some(p) } else { self.find_model(m) } }+            None => fp.and_then(|f| f.model.as_deref()).and_then(|m| self.find_model(m)),+        };+        match &model {+            None => why.push("no STEP model (provenance.json \"model\", or the footprint's model path, naming a .step file in the library)".into()),+            Some(p) if !is_step(p) => why.push(format!("{} is not a STEP file (no ISO-10303-21 header)", p.display())),+            _ => {}+        }+        if !why.is_empty() { return Err(why); }+        let abs = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string();+        Ok(json!({"dir": abs(&self.dir), "symbol": sym_name, "symbolFile": abs(&sym.unwrap().file), "footprint": fp_name, "footprintFile": abs(&fp.unwrap().file),+                  "model": abs(model.as_ref().unwrap()), "source": source, "provenance": e}))+    }++    /// A model by path or by file name anywhere in the library (KiCad paths often carry ${KIPRJMOD} or a 3D variable).+    fn find_model(&self, m: &str) -> Option<PathBuf> {+        let name = m.rsplit(['/', '\\']).next().unwrap_or(m).to_ascii_lowercase();+        self.models.iter().find(|p| p.file_name().and_then(|f| f.to_str()).map(|f| f.to_ascii_lowercase() == name).unwrap_or(false)).cloned()+    }+}++/// Every BOM row with no wiki page is looked up in the project library: a complete part gets+/// row["projectLib"]; the rest come back as (mpn, why) for the parts report.+pub fn resolve(rows: &mut [Value], lib: &ProjectLib) -> Vec<(String, String)> {+    let mut missing = Vec::new();+    for row in rows.iter_mut().filter(|r| r["wiki"].is_null()) {+        let mpn = row["mpn"].as_str().unwrap_or("").to_string();+        match lib.part(&mpn) {+            Ok(p) => { row["projectLib"] = p; row["source"] = json!("project-lib"); }+            Err(why) => if !missing.iter().any(|(m, _): &(String, String)| m == &mpn) { missing.push((mpn, why.join("; "))); },+        }+    }+    missing+}++/// The parts that came from a project library (for `finish`): "MPN (refs) from <dir>".+pub fn report(parts: &Value) -> Vec<String> {+    parts.as_array().into_iter().flatten().filter(|p| p["projectLib"].is_object()).map(|p| {+        let refs = p["refs"].as_array().map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();+        format!("{} ({refs}) from project library {}", p["mpn"].as_str().unwrap_or(""), p["projectLib"]["dir"].as_str().unwrap_or(""))+    }).collect()+}++/// Recheck at finish: every project-library part still has its files.+pub fn recheck(parts: &Value) -> Vec<String> {+    let mut gaps = Vec::new();+    let mut libs: BTreeMap<String, Option<ProjectLib>> = BTreeMap::new();+    for p in parts.as_array().into_iter().flatten().filter(|p| p["projectLib"].is_object()) {+        let dir = p["projectLib"]["dir"].as_str().unwrap_or("").to_string();+        let lib = libs.entry(dir.clone()).or_insert_with(|| ProjectLib::load(Path::new(&dir)).ok());+        let mpn = p["mpn"].as_str().unwrap_or("");+        match lib {+            None => gaps.push(format!("project library {dir} no longer loads ({mpn})")),+            Some(l) => if let Err(why) = l.part(mpn) { gaps.push(format!("{mpn}: {}", why.join("; "))); },+        }+    }+    gaps+}++#[cfg(test)]+mod tests {+    use super::*;++    pub fn fixture(dir: &Path, with_model: bool) {+        std::fs::create_dir_all(dir.join("lib.pretty")).unwrap();+        std::fs::create_dir_all(dir.join("3dmodels")).unwrap();+        std::fs::write(dir.join("lib.kicad_sym"), "(kicad_symbol_lib (version 20241209) (generator \"kicad_symbol_editor\")\n  (symbol \"ABC-123\" (property \"Reference\" \"U\") (property \"Footprint\" \"lib:QFN-16\"))\n  (symbol \"XYZ-9\" (property \"Footprint\" \"\"))\n)\n").unwrap();+        std::fs::write(dir.join("lib.pretty/QFN-16.kicad_mod"), "(footprint \"QFN-16\" (layer \"F.Cu\") (model \"${KIPRJMOD}/3dmodels/ABC-123.step\"))\n").unwrap();+        if with_model { std::fs::write(dir.join("3dmodels/ABC-123.step"), "ISO-10303-21;\nHEADER;\nENDSEC;\n").unwrap(); }+        std::fs::write(dir.join("provenance.json"), r#"{"parts": [{"mpn": "abc-123", "source": "datasheet rev C, p. 12"}, {"mpn": "XYZ-9"}]}"#).unwrap();+    }++    fn tmp(name: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("aiflow-projlib-{name}-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        d+    }++    #[test]+    fn a_complete_part_resolves_and_gaps_are_named() {+        let d = tmp("ok");+        fixture(&d, true);+        let lib = ProjectLib::load(&d).unwrap();+        let p = lib.part("ABC-123").unwrap();+        assert_eq!(p["footprint"], "QFN-16");+        assert!(p["model"].as_str().unwrap().ends_with("ABC-123.step"));+        assert_eq!(p["source"], "datasheet rev C, p. 12");+        let why = lib.part("XYZ-9").unwrap_err().join("; ");+        assert!(why.contains("no provenance source") && why.contains("no footprint") && why.contains("no STEP model"), "{why}");+        assert_eq!(lib.part("NOPE").unwrap_err()[0], "not in provenance.json");+        let mut rows = vec![json!({"mpn": "ABC-123", "refs": ["U1"], "wiki": null}), json!({"mpn": "R-1", "refs": ["R1"], "wiki": "adom/r-1"}), json!({"mpn": "NOPE", "refs": ["U2"], "wiki": null})];+        let missing = resolve(&mut rows, &lib);+        assert_eq!(missing.len(), 1);+        assert_eq!(missing[0].0, "NOPE");+        assert_eq!(rows[0]["source"], "project-lib");+        assert!(rows[1]["projectLib"].is_null());+        let rep = report(&json!(rows));+        assert_eq!(rep.len(), 1);+        assert!(rep[0].starts_with("ABC-123 (U1) from project library"), "{rep:?}");+        assert!(recheck(&json!(rows)).is_empty());+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn a_missing_model_fails_the_part() {+        let d = tmp("nomodel");+        fixture(&d, false);+        let lib = ProjectLib::load(&d).unwrap();+        assert!(lib.part("ABC-123").unwrap_err().join("; ").contains("no STEP model"));+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn no_provenance_is_refused() {+        let d = tmp("noprov");+        std::fs::create_dir_all(&d).unwrap();+        assert!(ProjectLib::load(&d).err().unwrap().contains("provenance.json"));+        let _ = std::fs::remove_dir_all(&d);+    }+}
crates/adom-aiflow/src/provenance.rs+42−2
@@ -48,13 +48,13 @@ pub fn proxy_models(b: &Board, project_dir: &Path) -> Vec<(String, String)> {     out } -fn is_hidden(n: &sx::Node) -> bool {+pub(crate) fn is_hidden(n: &sx::Node) -> bool {     let in_effects = n.find("effects").map(|e| e.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || e.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "hide"))).unwrap_or(false);     let direct = n.find("hide").map(|h| h.value() != Some("no")).unwrap_or(false) || n.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == "hide"));     in_effects || direct } -fn on_silk(n: &sx::Node) -> bool {+pub(crate) fn on_silk(n: &sx::Node) -> bool {     n.find("layer").and_then(|l| l.value()).map(|l| l == "F.SilkS" || l == "B.SilkS").unwrap_or(false) } @@ -84,6 +84,31 @@ fn silk_labels_in(root: &sx::Node) -> (usize, usize) { /// The fewest visible silkscreen labels per footprint a finished board may have. pub const MIN_SILK_PER_FOOTPRINT: f64 = 0.5; +/// The Fusion lane's silkscreen verdict for `finish` (issue #74). The count is the converted board's:+/// the Fusion-to-KiCad converter now carries tPlace/tNames text and smashed names, so the board checked is+/// the board as it stands (a pass undone or a version promoted after `fusion silkscreen` shows there).+/// `fusion silkscreen`'s proof (`<run>/proofs/silkscreen.json`) is the second source: failed steps are+/// reported, and a board showing fewer labels than the pass placed is named as a warning (the live+/// copy may be older than the pass), never raised to the proof's number. Returns (missing, warning).+pub fn fusion_silk(converted: usize, fps: usize, proof: Option<&serde_json::Value>) -> (Vec<String>, Option<String>) {+    let mut missing = Vec::new();+    let mut warning = None;+    let short = fps > 0 && (converted as f64) < MIN_SILK_PER_FOOTPRINT * fps as f64;+    match proof {+        Some(p) => {+            let failed = p["data"]["failed"].as_u64().unwrap_or(0);+            if failed > 0 { missing.push(format!("silkscreen: {failed} step(s) of `adom-aiflow fusion silkscreen` failed; run it again (skill aiflow-silkscreen)")); }+            let placed = p["data"]["labels"].as_u64().unwrap_or(0) as usize;+            if converted < placed {+                warning = Some(format!("silkscreen: the board shows {converted} label(s) but `fusion silkscreen` placed {placed}: the live board copy may be older than the pass (analyze again to re-read it), or labels were taken off since"));+            }+        }+        None if short => missing.push("silkscreen: not drawn yet on the Fusion board; run `adom-aiflow fusion silkscreen` (skill aiflow-silkscreen)".to_string()),+        None => {}+    }+    (missing, warning)+}+ /// The KiCad that saved the board file: `generator_version` (for example "10.0"); None when absent. pub fn saved_by(board_text: &str) -> Option<String> {     let head = &board_text[..board_text.len().min(4000)];@@ -176,6 +201,21 @@ mod tests {         assert_eq!(silk_labels_in(&root), (2, 2));     } +    #[test]+    fn fusion_silk_prefers_the_converted_board_and_reports_the_proof() {+        let proof = |labels: u64, failed: u64| serde_json::json!({"data": {"labels": labels, "failed": failed}});+        // the converted board carries the labels: no proof needed (silk drawn by hand counts)+        assert_eq!(fusion_silk(30, 40, None), (vec![], None));+        // nothing on the board and no proof: the pass is the next step+        assert!(fusion_silk(0, 40, None).0[0].contains("not drawn yet"));+        // a proof with failed steps is reported even when the count passes+        assert!(fusion_silk(30, 40, Some(&proof(25, 2))).0[0].contains("2 step(s)"));+        // the board shows fewer labels than the pass placed: a warning, and the count stays the board's+        let (m, w) = fusion_silk(10, 40, Some(&proof(34, 0)));+        assert!(m.is_empty() && w.unwrap().contains("placed 34"));+        assert_eq!(fusion_silk(40, 40, Some(&proof(34, 0))), (vec![], None));+    }+     /// Opt-in regression on real boards: AIFLOW_PROVENANCE_BOARDS="pass:/a.kicad_pcb fail:/b.kicad_pcb"     /// (each board's 3D models resolve beside it). `cargo test -- --ignored provenance_on_real_boards`.     #[test]
crates/adom-aiflow/src/rules_card.rsadded+357
@@ -0,0 +1,357 @@+//! The design-rule sanity card at `start` (issue #54). Read-only: it changes no gate.+//!+//! The spec is written by an AI, which is where a 0.025 that should have been 0.25 slips in. At+//! `start` this reads the spec's track widths, clearances, via pads, drills, copper thickness and+//! load currents, compares each with the fab lane's range (a generic range, tightened by the+//! minimums in the `.kicad_dru` beside the board), writes the card into the dashboard's+//! `timeline/presentation.json` (`rulesCard`, drawn to scale beside the board outline) and returns+//! one line per flag for `start` to print as WARNING.+use std::path::Path;++use aiflow_board::Board;+use serde_json::{json, Map, Value};++/// A fab lane's accepted range per feature, in mm (copper in um).+#[derive(Clone, Debug)]+pub struct Lane {+    pub name: String,+    pub trace: (f64, f64),+    pub clearance: (f64, f64),+    pub via_pad: (f64, f64),+    pub drill: (f64, f64),+    pub annular_min: f64,+    pub edge: (f64, f64),+    pub copper_um: (f64, f64),+    pub amps_max: f64,+    /// Where the lower limits came from.+    pub source: String,+}++impl Lane {+    /// The generic range: what any of the lanes builds without a special order. A trace under+    /// 0.1 mm or over 2 mm, a drill under 0.2 mm, copper outside 12..210 um is almost always a unit slip.+    pub fn generic(name: &str) -> Lane {+        Lane { name: name.into(), trace: (0.1, 2.0), clearance: (0.1, 2.0), via_pad: (0.35, 2.5), drill: (0.2, 1.6), annular_min: 0.075, edge: (0.2, 5.0), copper_um: (12.0, 210.0), amps_max: 60.0, source: "generic fab range".into() }+    }++    /// Tighten the lower limits with a rules pack's minimums (`(constraint track_width (min 0.127mm))`).+    pub fn with_dru(mut self, dru: &str, file: &str) -> Lane {+        let mins = dru_minimums(dru);+        let mut used = Vec::new();+        let mut up = |k: &str, slot: &mut f64| {+            if let Some(v) = mins.get(k).and_then(|v| v.as_f64()) {+                if v > *slot {+                    *slot = v;+                    used.push(format!("{k} {v}"));+                }+            }+        };+        up("track_width", &mut self.trace.0);+        up("clearance", &mut self.clearance.0);+        up("via_diameter", &mut self.via_pad.0);+        up("hole_size", &mut self.drill.0);+        up("annular_width", &mut self.annular_min);+        up("edge_clearance", &mut self.edge.0);+        if !used.is_empty() {+            self.source = format!("generic fab range, minimums from {file} ({})", used.join(", "));+        }+        self+    }+}++/// The least strict `min` of every constraint in a `.kicad_dru`, in mm.+pub fn dru_minimums(text: &str) -> Map<String, Value> {+    let mut out: Map<String, Value> = Map::new();+    let lines: Vec<&str> = text.lines().filter(|l| !l.trim_start().starts_with('#')).collect();+    let text = lines.join("\n");+    let mut rest = text.as_str();+    while let Some(i) = rest.find("(constraint ") {+        rest = &rest[i + 12..];+        let name: String = rest.chars().take_while(|c| c.is_alphanumeric() || *c == '_').collect();+        let end = rest.find("(constraint ").unwrap_or(rest.len());+        let body = &rest[..end];+        if let Some(j) = body.find("(min ") {+            let tok: String = body[j + 5..].chars().take_while(|c| *c != ')' && !c.is_whitespace()).collect();+            let (num, mult) = if let Some(n) = tok.strip_suffix("mm") { (n, 1.0) } else if let Some(n) = tok.strip_suffix("mil") { (n, 0.0254) } else if let Some(n) = tok.strip_suffix("um") { (n, 0.001) } else { (tok.as_str(), 1.0) };+            if let Ok(v) = num.parse::<f64>() {+                let v = ((v * mult) * 1e6).round() / 1e6;+                let cur = out.get(&name).and_then(|x| x.as_f64());+                if cur.map(|c| v < c).unwrap_or(true) {+                    out.insert(name, json!(v));+                }+            }+        }+    }+    out+}++/// One value on the card.+#[derive(Clone, Debug)]+pub struct Item {+    pub key: &'static str,+    pub label: String,+    pub value: f64,+    pub unit: &'static str,+    pub source: String,+    pub range: (f64, f64),+    pub flag: Option<String>,+}++fn item(key: &'static str, label: &str, value: f64, unit: &'static str, source: String, range: (f64, f64), lane: &Lane) -> Item {+    let flag = if !value.is_finite() || value <= 0.0 {+        Some(format!("{label} {value} {unit} ({source}) is not a positive size"))+    } else if value < range.0 - 1e-9 {+        Some(format!("{label} {value} {unit} ({source}) is below the {} lane's {} {unit}; check the unit (a 0.025 that should be 0.25)", lane.name, range.0))+    } else if value > range.1 + 1e-9 {+        Some(format!("{label} {value} {unit} ({source}) is above the {} lane's usual {} {unit}; check the unit", lane.name, range.1))+    } else {+        None+    };+    Item { key, label: label.into(), value, unit, source, range, flag }+}++fn widths(spec: &Value, key: &str) -> Vec<(String, f64)> {+    spec.get(key).and_then(|v| v.as_object()).map(|o| o.iter().flat_map(|(n, a)| a.as_array().cloned().unwrap_or_default().into_iter().filter_map(|w| w.as_f64()).map(|w| (n.clone(), w)).collect::<Vec<_>>()).collect()).unwrap_or_default()+}++/// Every value on the card, with the flags, for a spec against a lane.+pub fn items(spec: &Value, lane: &Lane) -> (Vec<Item>, Vec<Value>, Vec<String>) {+    let rules = aiflow_router::Rules::from_spec(spec);+    let has = |k: &str| spec.get(k).map(|v| !v.is_null()).unwrap_or(false);+    let src = |k: &str| if has(k) { format!("spec {k}") } else { format!("{k} default") };+    let mut out = Vec::new();+    // traces: every declared width is checked; the card draws the smallest+    let mut traces: Vec<(String, f64)> = vec![(src("track"), rules.track)];+    for k in ["minTrackWidth", "stub"] {+        if let Some(v) = spec.get(k).and_then(|v| v.as_f64()) {+            traces.push((format!("spec {k}"), v));+        }+    }+    for k in ["wideNets", "midNets"] {+        for (n, w) in widths(spec, k) {+            traces.push((format!("spec {k} {n}"), w));+        }+    }+    let min_t = traces.iter().cloned().fold((String::new(), f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });+    out.push(item("minTrace", "min trace", min_t.1, "mm", min_t.0.clone(), lane.trace, lane));+    for (s, w) in &traces {+        if s != &min_t.0 || *w != min_t.1 {+            let it = item("trace", "trace", *w, "mm", s.clone(), lane.trace, lane);+            if it.flag.is_some() {+                out.push(it);+            }+        }+    }+    let mut clr: Vec<(String, f64)> = vec![(src("clearance"), rules.clearance)];+    for (n, v) in &rules.net_clearance {+        clr.push((format!("spec netClearance {n}"), *v));+    }+    let min_c = clr.iter().cloned().fold((String::new(), f64::INFINITY), |a, b| if b.1 < a.1 { b } else { a });+    out.push(item("clearance", "clearance", min_c.1, "mm", min_c.0.clone(), lane.clearance, lane));+    for (s, v) in &clr {+        if s != &min_c.0 {+            let it = item("clearanceNet", "clearance", *v, "mm", s.clone(), lane.clearance, lane);+            if it.flag.is_some() {+                out.push(it);+            }+        }+    }+    let vias = [("viaSignal", "drillSignal", rules.via_small, rules.drill_small), ("viaPower", "drillPower", rules.via, rules.drill)];+    let small = if vias[0].2 <= vias[1].2 { 0 } else { 1 };+    for (k, (vk, dk, pad, drill)) in vias.iter().enumerate() {+        let (kp, kd) = if k == small { ("viaPad", "drill") } else { ("viaPadOther", "drillOther") };+        let dsrc = if has(dk) { format!("spec {dk}") } else if spec.get(*vk).and_then(|v| v.as_array()).map(|a| a.len() > 1).unwrap_or(false) { format!("spec {vk}[1]") } else { format!("{dk} default") };+        let p = item(kp, "via pad", *pad, "mm", src(vk), lane.via_pad, lane);+        let d = item(kd, "drill", *drill, "mm", dsrc, lane.drill, lane);+        let ring = (pad - drill) / 2.0;+        let mut r = item(if k == small { "annular" } else { "annularOther" }, "annular ring", (ring * 1e4).round() / 1e4, "mm", format!("({vk} - drill) / 2"), (lane.annular_min, f64::INFINITY), lane);+        if ring <= 0.0 {+            r.flag = Some(format!("{vk} {pad} mm is not larger than its drill {drill} mm"));+        }+        if k == small {+            out.push(p);+            out.push(d);+            out.push(r);+        } else {+            for it in [p, d, r] {+                if it.flag.is_some() {+                    out.push(it);+                }+            }+        }+    }+    out.push(item("edge", "edge clearance", rules.edge, "mm", src("edgeClearance"), lane.edge, lane));+    let cu = spec.get("copperUm").and_then(|v| v.as_f64());+    let mut c = item("copperUm", "copper", cu.unwrap_or(35.0), "um", if cu.is_some() { "spec copperUm".into() } else { "copperUm default (1 oz)".into() }, lane.copper_um, lane);+    if c.flag.is_some() && cu.map(|v| v < 1.0).unwrap_or(false) {+        c.flag = Some(format!("copper {} um (spec copperUm) looks like mm or oz: copperUm is micrometres, 35 for 1 oz", cu.unwrap_or(0.0)));+    }+    out.push(c);+    if let Some(m) = spec.get("copperUmByLayer").and_then(|v| v.as_object()) {+        for (l, v) in m {+            if let Some(x) = v.as_f64() {+                let it = item("copperLayer", &format!("copper {l}"), x, "um", format!("spec copperUmByLayer {l}"), lane.copper_um, lane);+                if it.flag.is_some() {+                    out.push(it);+                }+            }+        }+    }+    // load currents, with the widest width the spec gives the net+    let mut loads = Vec::new();+    let mut flags: Vec<String> = out.iter().filter_map(|i| i.flag.clone()).collect();+    if let Some(m) = spec.get("loads").and_then(|v| v.as_object()) {+        let all: Vec<(String, f64)> = widths(spec, "wideNets").into_iter().chain(widths(spec, "midNets")).collect();+        for (net, l) in m {+            let amps = l.get("amps").and_then(|a| a.as_f64()).or_else(|| l.as_f64());+            let widest = all.iter().filter(|(n, _)| n == net).map(|x| x.1).fold(None, |a: Option<f64>, w| Some(a.map(|x| x.max(w)).unwrap_or(w)));+            let flag = match amps {+                None => Some(format!("load {net}: no amps")),+                Some(a) if a <= 0.0 => Some(format!("load {net}: {a} A is not a positive current")),+                Some(a) if a > lane.amps_max => Some(format!("load {net}: {a} A is above {} A; check the unit (amps, not mA)", lane.amps_max)),+                _ => None,+            };+            if let Some(f) = &flag {+                flags.push(f.clone());+            }+            loads.push(json!({"net": net, "amps": amps, "widestMm": widest.unwrap_or(rules.track), "widthFrom": if widest.is_some() { "wideNets/midNets" } else { "track" }, "flag": flag}));+        }+    }+    (out, loads, flags)+}++fn outline_of(board: &Board) -> Value {+    let (x0, y0, x1, y1) = board.bbox;+    let pts = &board.outline;+    let step = (pts.len() / 400).max(1);+    let rel: Vec<Value> = pts.iter().step_by(step).map(|p| json!([((p.0 - x0) * 100.0).round() / 100.0, ((p.1 - y0) * 100.0).round() / 100.0])).collect();+    json!({"widthMm": ((x1 - x0) * 100.0).round() / 100.0, "heightMm": ((y1 - y0) * 100.0).round() / 100.0, "outline": rel})+}++/// The card for this board and spec.+pub fn card(spec: &Value, board: Option<&Board>, lane: &Lane, rules_file: Option<&str>) -> (Value, Vec<String>) {+    let (items, loads, mut flags) = items(spec, lane);+    let b = board.map(outline_of).unwrap_or(Value::Null);+    if let Some(bd) = board {+        let (w, h) = (bd.bbox.2 - bd.bbox.0, bd.bbox.3 - bd.bbox.1);+        if w.max(h) < 2.0 || w.max(h) > 600.0 {+            flags.push(format!("board outline {w:.2} x {h:.2} mm; check the board's units"));+        }+    }+    let get = |k: &str| items.iter().find(|i| i.key == k).map(|i| i.value);+    let v = json!({+        "title": "Design rules at start",+        "lane": lane.name, "rangeSource": lane.source, "rulesFile": rules_file,+        "board": b,+        "draw": {"traceMm": get("minTrace"), "clearanceMm": get("clearance"), "viaPadMm": get("viaPad"), "drillMm": get("drill")},+        "items": items.iter().map(|i| json!({"key": i.key, "label": i.label, "value": i.value, "unit": i.unit, "source": i.source, "min": i.range.0, "max": if i.range.1.is_finite() { json!(i.range.1) } else { Value::Null }, "flag": i.flag})).collect::<Vec<_>>(),+        "loads": loads,+        "flags": flags,+        "note": "Read-only: this card changes no gate. The gates use the KiCad project rules, the .kicad_dru and the spec as they are.",+    });+    (v, flags)+}++/// Merge `rulesCard` into `<run>/timeline/presentation.json`, keeping every other field.+pub fn present(dir: &Path, card: &Value) -> Result<(), String> {+    let d = dir.join("timeline");+    std::fs::create_dir_all(&d).map_err(|e| e.to_string())?;+    let path = d.join("presentation.json");+    let mut p: Value = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str(&t).ok()).filter(|v: &Value| v.is_object()).unwrap_or(json!({}));+    p["rulesCard"] = card.clone();+    let tmp = d.join("presentation.rules.tmp");+    std::fs::write(&tmp, serde_json::to_string_pretty(&p).unwrap_or_default()).map_err(|e| e.to_string())?;+    std::fs::rename(&tmp, &path).map_err(|e| e.to_string())+}++/// At `start`: build the card for the run's board copy, write it beside the run (`rules-card.json`)+/// and into the dashboard's presentation, and return the flags. Never fails the start.+pub fn at_start(dir: &Path, spec: &Value, board_copy: &Path, fab_rules: &Value) -> Vec<String> {+    let lane_name = crate::checks::rules_target(spec, fab_rules).map(|t| t.0).unwrap_or_else(|| "default".into());+    let dru = board_copy.with_extension("kicad_dru");+    let mut lane = Lane::generic(&lane_name);+    let rules_file = std::fs::read_to_string(&dru).ok().map(|t| {+        lane = lane.clone().with_dru(&t, &dru.display().to_string());+        dru.display().to_string()+    });+    let board = Board::load(&board_copy.display().to_string()).ok();+    let (v, flags) = card(spec, board.as_ref(), &lane, rules_file.as_deref());+    std::fs::write(dir.join("rules-card.json"), serde_json::to_string_pretty(&v).unwrap_or_default()).ok();+    if let Err(e) = present(dir, &v) {+        eprintln!("Hint: the design-rule card could not be written to the dashboard ({e}); rules-card.json has it.");+    }+    flags+}++#[cfg(test)]+mod tests {+    use super::*;++    fn flags_of(spec: Value) -> Vec<String> {+        items(&spec, &Lane::generic("fab")).2+    }++    #[test]+    fn a_unit_slip_in_a_trace_is_flagged() {+        let f = flags_of(json!({"track": 0.025}));+        assert_eq!(f.len(), 1, "{f:?}");+        assert!(f[0].contains("min trace 0.025 mm") && f[0].contains("below") && f[0].contains("0.1"), "{}", f[0]);+        assert!(flags_of(json!({"track": 0.25, "clearance": 0.2, "copperUm": 35})).is_empty());+        let f = flags_of(json!({"track": 0.25, "wideNets": {"+VBAT": [25.0, 0.5]}}));+        assert!(f.iter().any(|x| x.contains("wideNets +VBAT") && x.contains("above")), "{f:?}");+    }++    #[test]+    fn clearance_via_drill_and_copper_ranges() {+        let f = flags_of(json!({"clearance": 0.02}));+        assert!(f.iter().any(|x| x.contains("clearance 0.02")), "{f:?}");+        let f = flags_of(json!({"viaSignal": [0.45, 0.15]}));+        assert!(f.iter().any(|x| x.starts_with("drill 0.15") && x.contains("spec viaSignal[1]")), "{f:?}");+        let f = flags_of(json!({"viaPower": [0.4, 0.5]}));+        assert!(f.iter().any(|x| x.contains("not larger than its drill")), "{f:?}");+        let f = flags_of(json!({"copperUm": 0.035}));+        assert!(f.iter().any(|x| x.contains("micrometres")), "{f:?}");+        let f = flags_of(json!({"netClearance": {"HV": 25}}));+        assert!(f.iter().any(|x| x.contains("netClearance HV")), "{f:?}");+    }++    #[test]+    fn loads_are_checked_and_shown_with_their_width() {+        let (_, loads, f) = items(&json!({"wideNets": {"+VBAT": [1.0, 0.5]}, "loads": {"+VBAT": {"amps": 20}, "+5V": {"amps": 0}, "SENSE": {"amps": 2500}}}), &Lane::generic("fab"));+        assert!(f.iter().any(|x| x.contains("+5V") && x.contains("not a positive")), "{f:?}");+        assert!(f.iter().any(|x| x.contains("SENSE") && x.contains("mA")), "{f:?}");+        let vbat = loads.iter().find(|l| l["net"] == "+VBAT").unwrap();+        assert_eq!(vbat["widestMm"], 1.0);+        assert!(vbat["flag"].is_null());+    }++    #[test]+    fn a_rules_pack_raises_the_lane_minimums() {+        let m = dru_minimums(crate::checks::JLCPCB_2L);+        assert_eq!(m["track_width"], json!(0.127));+        assert_eq!(m["hole_size"], json!(0.3));+        let lane = Lane::generic("jlcpcb").with_dru(crate::checks::JLCPCB_2L, "b.kicad_dru");+        assert_eq!(lane.trace.0, 0.127);+        assert!(lane.source.contains("b.kicad_dru"));+        let f = items(&json!({"track": 0.12, "viaSignal": [0.6, 0.3]}), &lane).2;+        assert!(f.iter().any(|x| x.contains("min trace 0.12") && x.contains("jlcpcb")), "{f:?}");+        assert_eq!(dru_minimums("(rule a (constraint track_width (min 5mil)))")["track_width"], json!(0.127));+    }++    #[test]+    fn the_card_merges_into_the_presentation() {+        let dir = std::env::temp_dir().join(format!("aiflow-rules-card-{}", std::process::id()));+        std::fs::create_dir_all(dir.join("timeline")).unwrap();+        std::fs::write(dir.join("timeline/presentation.json"), r#"{"caption":"kept"}"#).unwrap();+        let (v, flags) = card(&json!({"track": 0.025}), None, &Lane::generic("fab"), None);+        assert_eq!(flags.len(), 1);+        assert_eq!(v["draw"]["traceMm"], 0.025);+        assert_eq!(v["draw"]["drillMm"], 0.3);+        present(&dir, &v).unwrap();+        let p: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("timeline/presentation.json")).unwrap()).unwrap();+        assert_eq!(p["caption"], "kept");+        assert_eq!(p["rulesCard"]["items"][0]["key"], "minTrace");+        assert!(p["rulesCard"]["items"][0]["flag"].is_string());+        std::fs::remove_dir_all(&dir).ok();+    }+}
crates/adom-aiflow/src/schematic_check.rsadded+118
@@ -0,0 +1,118 @@+//! What `schematic check` reads back from KiCad (issues #65, #68): the symbols on the sheet from the+//! exported netlist, and the ERC errors from the ERC report. A report that is missing or does not read+//! is a failure, never a clean ERC.++use serde_json::Value;+use std::collections::BTreeMap;+use std::path::Path;++/// Every symbol in a KiCad `kicadsexpr` netlist: reference -> (libsource lib, footprint). The comps sit+/// in `(export ... (components (comp ...)))`, the same place `netlist check` reads them from; power+/// symbols (`#PWR`) are not parts.+pub fn sheet_symbols(text: &str) -> Result<BTreeMap<String, (String, String)>, String> {+    let root = aiflow_board::sx::parse(text)?;+    if root.tag != "export" {+        return Err(format!("not a KiCad netlist (root is ({}), expected (export ...))", root.tag));+    }+    let mut out = BTreeMap::new();+    let Some(comps) = root.find("components") else { return Ok(out) };+    for comp in comps.find_all("comp") {+        let Some(rf) = comp.find("ref").and_then(|n| n.atom(1)) else { continue };+        if rf.starts_with('#') { continue; }+        let lib = comp.find("libsource").and_then(|l| l.find("lib")).and_then(|n| n.atom(1)).unwrap_or("").to_string();+        let fp = comp.find("footprint").and_then(|n| n.atom(1)).unwrap_or("").to_string();+        out.insert(rf.to_string(), (lib, fp));+    }+    Ok(out)+}++/// The error-severity violations in a KiCad ERC JSON report. Err when the report is missing, does not+/// parse, or is not an ERC report (no `sheets` list): ERC did not run, so it did not pass.+pub fn erc_errors(report: &Path) -> Result<(Value, Vec<String>), String> {+    let text = std::fs::read_to_string(report).map_err(|e| format!("no ERC report at {} ({e}): ERC did not run", report.display()))?;+    let v: Value = serde_json::from_str(&text).map_err(|e| format!("the ERC report {} does not read ({e})", report.display()))?;+    let Some(sheets) = v["sheets"].as_array() else { return Err(format!("{} is not a KiCad ERC report (no sheets)", report.display())) };+    let errors = sheets.iter()+        .flat_map(|sh| sh["violations"].as_array().cloned().unwrap_or_default())+        .filter(|x| x["severity"] == "error")+        .map(|x| x["description"].as_str().unwrap_or("").to_string())+        .collect();+    Ok((v, errors))+}++/// Run `kicad-cli sch erc` (`cli`: ADOM_AIFLOW_KICAD_CLI, local or adom-aiflow-kicad-cli-remote) into+/// `report` and read it. A report from an earlier run is removed first so it cannot stand in for this one.+pub fn run_erc(cli: &str, sch: &str, report: &Path) -> Result<(Value, Vec<String>), String> {+    let _ = std::fs::remove_file(report);+    let o = std::process::Command::new(cli)+        .args(["sch", "erc", "--format", "json", "--severity-all", "--output"]).arg(report).arg(sch)+        .stdin(std::process::Stdio::null()).output()+        .map_err(|e| format!("{cli} is not runnable: {e}"))?;+    erc_errors(report).map_err(|e| {+        let tail = |b: &[u8]| { let t = String::from_utf8_lossy(b).to_string(); t.chars().rev().take(400).collect::<String>().chars().rev().collect::<String>() };+        format!("{e}. {cli} sch erc said: {} {}", tail(&o.stdout), tail(&o.stderr)).trim().to_string()+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    const NESTED: &str = r##"(export (version "E")+  (design (source "/x/demo.kicad_sch") (tool "Eeschema 10.0.3"))+  (components+    (comp (ref "U1") (value "TPS") (footprint "PCM_demo:SOT-23-6")+      (libsource (lib "PCM_demo") (part "TPS") (description ""))+      (sheetpath (names "/") (tstamps "/")))+    (comp (ref "C1") (value "10u") (footprint "PCM_demo:C_0603")+      (libsource (lib "PCM_demo") (part "C") (description "")))+    (comp (ref "#PWR01") (value "GND") (libsource (lib "power") (part "GND"))))+  (libparts) (libraries)+  (nets (net (code "1") (name "/GND") (node (ref "U1") (pin "2")) (node (ref "C1") (pin "2")))))"##;++    #[test]+    fn nested_components_are_all_found() {+        let s = sheet_symbols(NESTED).unwrap();+        assert_eq!(s.keys().cloned().collect::<Vec<_>>(), vec!["C1".to_string(), "U1".to_string()]);+        assert_eq!(s["U1"], ("PCM_demo".to_string(), "PCM_demo:SOT-23-6".to_string()));+        // the old reader (direct children of export) found none of them+        let root = aiflow_board::sx::parse(NESTED).unwrap();+        assert_eq!(root.find_all("comp").count(), 0);+        assert!(sheet_symbols("(kicad_sch)").is_err());+    }++    #[test]+    fn missing_or_unreadable_erc_report_is_refused() {+        let dir = std::env::temp_dir().join(format!("aiflow-erc-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let rep = dir.join("erc.json");+        assert!(erc_errors(&rep).unwrap_err().contains("ERC did not run"));+        std::fs::write(&rep, "not json").unwrap();+        assert!(erc_errors(&rep).is_err());+        std::fs::write(&rep, "{}").unwrap();+        assert!(erc_errors(&rep).unwrap_err().contains("not a KiCad ERC report"));+        std::fs::write(&rep, r#"{"sheets":[{"path":"/","violations":[{"severity":"warning","description":"w"},{"severity":"error","description":"Pin not driven"}]}]}"#).unwrap();+        assert_eq!(erc_errors(&rep).unwrap().1, vec!["Pin not driven".to_string()]);+        std::fs::write(&rep, r#"{"sheets":[]}"#).unwrap();+        assert!(erc_errors(&rep).unwrap().1.is_empty());+        let _ = std::fs::remove_dir_all(&dir);+    }++    /// A kicad-cli that writes no report (the old remote wrapper refusing `sch erc`) must fail the check,+    /// and a stale report from an earlier run must not count.+    #[cfg(unix)]+    #[test]+    fn erc_that_writes_no_report_fails() {+        use std::os::unix::fs::PermissionsExt;+        let dir = std::env::temp_dir().join(format!("aiflow-erc-run-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let cli = dir.join("kicad-cli");+        std::fs::write(&cli, "#!/bin/sh\necho \"only 'pcb drc' is supported\" >&2\nexit 2\n").unwrap();+        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();+        let rep = dir.join("erc.json");+        std::fs::write(&rep, r#"{"sheets":[]}"#).unwrap();+        let e = run_erc(cli.to_str().unwrap(), "x.kicad_sch", &rep).unwrap_err();+        assert!(e.contains("ERC did not run") && e.contains("only 'pcb drc'"), "{e}");+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/schematic_symbols.rsadded+764
@@ -0,0 +1,764 @@+//! The symbol, pin-type and readability half of `schematic check` (and of `netlist check`): what a+//! zero-warning ERC does not prove about a native KiCad schematic. The command itself (ERC, the BOM+//! references, the project library) lives in main.rs and calls `check` and `record` here.+//!+//! Reads the root `.kicad_sch` and every child sheet (resolved exactly as the netlist freshness+//! check resolves them, `schematic_identity::capture`) with the symbols each sheet embeds in+//! `lib_symbols`, and checks three things (issue #39, items 1 to 3):+//!+//! 1. Polarity drawings agree with the pin names: a diode's or LED's cathode bar and triangle+//!    point at the pin named K (cathode), a BJT's emitter arrow sits on the lead of the pin named E,+//!    and an op-amp's drawn `+`/`-` texts sit next to the inputs declared with that sign.+//! 2. Pin electrical types on active devices (U/IC references and op-amp/comparator symbols): an+//!    amplifier input or an output (`OUT`, `VOUT`, `OUTA`) declared `passive` is an error; a passive+//!    power pin is an error on op-amps and comparators and a warning on other ICs (vendor symbols often+//!    do it). Stacked pins (KiCad's convention: one typed pin, the rest passive at the same place) and+//!    crystal pins are skipped. Transistor (Q) pins are left alone: KiCad's own transistor symbols+//!    declare them passive.+//! 3. Readability per sheet: many local labels and almost no wires, the same local label used for a+//!    short hop inside one block, free text over pins or symbol bodies, and parts or text outside the+//!    drawing border.+//!+//! Limits (heuristic geometry, documented in skills/aiflow-schematic-to-board/SKILL.md, section "Schematic check: symbols, pin types, readability"): the polarity check+//! recognises polylines, rectangles, circles and arcs only, a single diode per unit (pins named exactly+//! K/A or cathode/anode), BJTs with pins named E and C, and op-amp inputs named `+`/`-`, `IN+`/`IN-`,+//! `INP`/`INN`. Errors are raised only when a drawing clearly contradicts the pin names, and only for+//! custom symbols (no library nickname, a nickname outside KiCad's standard libraries, or a+//! schematic-local copy with `lib_name`); standard-library symbols can at most warn. Text sizes are+//! estimated from the font size, symbol transforms are approximate (rotation, then mirror), symbol+//! fields (reference/value) are not checked for overlap, and feedback loops are not traced.+use std::collections::{BTreeMap, BTreeSet};+use std::path::Path;++use aiflow_board::sx::{self, Node};+use aiflow_run::Run;+use serde_json::{json, Value};++type Pt = (f64, f64);++/// The run outcome key `schematic check` records and `finish` requires.+pub const OUTCOME: &str = "schematic-check";++/// KiCad standard symbol library nicknames: symbols from these are trusted to be drawn right.+const STD_LIBS: &[&str] = &[+    "Device", "Diode", "LED", "Transistor_BJT", "Transistor_FET", "Transistor_Array", "Transistor_IGBT", "Amplifier_Operational",+    "Amplifier_Instrumentation", "Amplifier_Difference", "Amplifier_Audio", "Amplifier_Buffer", "Amplifier_Current",+    "Amplifier_Video", "Comparator", "power", "Connector", "Connector_Generic", "Connector_Audio", "Regulator_Linear",+    "Regulator_Switching", "Regulator_Controller", "Reference_Voltage", "Diode_Bridge", "Simulation_SPICE", "pspice",+    "Switch", "Mechanical", "Jumper", "Analog_Switch", "Analog_ADC", "Analog_DAC", "Interface_UART", "Interface_USB",+    "MCU_Microchip_ATmega", "MCU_ST_STM32F0", "MCU_RaspberryPi", "Timer", "Logic_74xx", "74xx", "4xxx", "Sensor_Temperature",+    "Power_Management", "Battery_Management", "Optocoupler", "Isolator", "Relay", "Fuse", "Inductor", "Oscillator", "Crystal",+];++#[derive(Clone, Copy, PartialEq, Eq, Debug)]+enum Sev { Error, Warn }++#[derive(Debug, Clone)]+struct Pin { name: String, number: String, etype: String, at: Pt, inner: Pt, unit: u32 }++#[derive(Debug, Clone)]+struct Shape { kind: &'static str, pts: Vec<Pt>, closed: bool, unit: u32 }++#[derive(Debug, Clone)]+struct Txt { text: String, at: Pt, unit: u32 }++#[derive(Debug, Clone, Default)]+struct LibSym { name: String, info: String, power: bool, names_hidden: bool, pins: Vec<Pin>, shapes: Vec<Shape>, texts: Vec<Txt> }++#[derive(Debug, Clone)]+struct Inst { lib: String, custom_copy: bool, reference: String, at: Pt, rot: f64, mirror: Option<String>, unit: u32 }++fn close(a: Pt, b: Pt) -> bool { (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 }+fn dist(a: Pt, b: Pt) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }+fn centroid(p: &[Pt]) -> Pt { let n = p.len().max(1) as f64; (p.iter().map(|q| q.0).sum::<f64>() / n, p.iter().map(|q| q.1).sum::<f64>() / n) }+fn xy(n: &Node) -> Option<Pt> { Some((n.number(1)?, n.number(2)?)) }+fn at_of(n: &Node) -> Option<(Pt, f64)> { let a = n.find("at")?; Some(((a.number(1)?, a.number(2)?), a.number(3).unwrap_or(0.0))) }+fn prop<'a>(n: &'a Node, key: &str) -> Option<&'a str> { n.find_all("property").find(|p| p.atom(1).is_some_and(|k| k.eq_ignore_ascii_case(key))).and_then(|p| p.atom(2)) }+fn flag(n: &Node, tag: &str) -> bool {+    n.find(tag).map(|h| h.value() != Some("no")).unwrap_or(false) || n.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == tag))+}++/// The unit and body style a lib sub-symbol (`NAME_<unit>_<style>`) draws.+fn unit_style(name: &str) -> (u32, u32) {+    let mut it = name.rsplitn(3, '_');+    let style = it.next().and_then(|s| s.parse().ok()).unwrap_or(1);+    let unit = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);+    (unit, style)+}++fn parse_lib(sym: &Node) -> LibSym {+    let name = sym.atom(1).unwrap_or("").to_string();+    let info = [prop(sym, "ki_keywords"), prop(sym, "ki_description"), prop(sym, "Description"), prop(sym, "Value"), Some(name.as_str())]+        .iter().flatten().map(|s| s.to_ascii_lowercase()).collect::<Vec<_>>().join(" ");+    let names_hidden = sym.find("pin_names").map(|p| flag(p, "hide")).unwrap_or(false);+    let mut out = LibSym { name, info, power: flag(sym, "power"), names_hidden, ..Default::default() };+    let mut parts: Vec<(&Node, u32)> = vec![(sym, 0)];+    for sub in sym.find_all("symbol") {+        let (unit, style) = unit_style(sub.atom(1).unwrap_or(""));+        if style <= 1 { parts.push((sub, unit)); }+    }+    for (n, unit) in parts {+        for c in n.nodes() {+            match c.tag.as_str() {+                "pin" => {+                    let Some((at, ang)) = at_of(c) else { continue };+                    let len = c.find("length").and_then(|l| l.number(1)).unwrap_or(0.0);+                    let r = ang.to_radians();+                    out.pins.push(Pin {+                        name: c.find("name").and_then(|x| x.atom(1)).unwrap_or("").to_string(),+                        number: c.find("number").and_then(|x| x.atom(1)).unwrap_or("").to_string(),+                        etype: c.atom(1).unwrap_or("").to_string(), at, inner: (at.0 + len * r.cos(), at.1 + len * r.sin()), unit,+                    });+                }+                "polyline" => {+                    let mut pts: Vec<Pt> = c.find("pts").map(|p| p.find_all("xy").filter_map(xy).collect()).unwrap_or_default();+                    if pts.len() < 2 { continue; }+                    let filled = c.find("fill").and_then(|f| f.find("type")).and_then(|t| t.value()).map(|t| t != "none").unwrap_or(false);+                    let mut closed = filled;+                    if pts.len() > 2 && close(pts[0], *pts.last().unwrap()) { pts.pop(); closed = true; }+                    out.shapes.push(Shape { kind: "polyline", pts, closed, unit });+                }+                "rectangle" => {+                    if let (Some(a), Some(b)) = (c.find("start").and_then(xy), c.find("end").and_then(xy)) {+                        out.shapes.push(Shape { kind: "rectangle", pts: vec![a, (b.0, a.1), b, (a.0, b.1)], closed: true, unit });+                    }+                }+                "circle" => {+                    if let (Some(ce), Some(r)) = (c.find("center").and_then(xy), c.find("radius").and_then(|x| x.number(1))) {+                        let pts = (0..8).map(|i| { let t = i as f64 * std::f64::consts::FRAC_PI_4; (ce.0 + r * t.cos(), ce.1 + r * t.sin()) }).collect();+                        out.shapes.push(Shape { kind: "circle", pts, closed: true, unit });+                    }+                }+                "arc" => {+                    let pts: Vec<Pt> = ["start", "mid", "end"].iter().filter_map(|k| c.find(k).and_then(xy)).collect();+                    if pts.len() >= 2 { out.shapes.push(Shape { kind: "arc", pts, closed: false, unit }); }+                }+                "text" => {+                    if let (Some(t), Some((at, _))) = (c.atom(1), at_of(c)) { out.texts.push(Txt { text: t.to_string(), at, unit }); }+                }+                _ => {}+            }+        }+    }+    out+}++/// A symbol is custom unless it comes, unmodified, from one of KiCad's standard libraries.+fn is_custom(lib: &str, custom_copy: bool) -> bool {+    if custom_copy { return true; }+    match lib.split_once(':') { Some((nick, _)) => !STD_LIBS.contains(&nick), None => true }+}++fn norm(name: &str) -> String {+    name.trim().to_ascii_uppercase().replace(['{', '}', '~', ' ', '_'], "").replace(['\u{2212}', '\u{2013}'], "-")+}++/// `+` or `-` for a pin named as an amplifier input.+fn amp_input(name: &str) -> Option<char> {+    let n = norm(name);+    if n == "+" { return Some('+'); }+    if n == "-" { return Some('-'); }+    if n.contains("IN") && !n.starts_with('V') {+        if n.contains('+') { return Some('+'); }+        if n.contains('-') { return Some('-'); }+        if n.ends_with("INP") || n == "NONINV" { return Some('+'); }+        if n.ends_with("INN") { return Some('-'); }+    }+    None+}++fn is_output(name: &str) -> bool {+    let n = norm(name);+    let s = n.trim_start_matches(|c: char| c.is_ascii_digit());+    s == "OUT" || s == "VOUT" || (s.starts_with("OUT") && s.len() == 4)+}++fn is_power(name: &str) -> bool {+    let n = norm(name);+    ["V+", "V-", "+V", "-V", "VS", "VS+", "VS-", "+VS", "-VS", "AVDD", "DVDD", "AVSS", "DVSS", "AVCC"].contains(&n.as_str())+        || ["VCC", "VDD", "VEE", "VSS"].iter().any(|p| n.starts_with(p))+        || (n.ends_with("GND") && n.len() <= 5)+}++fn amp_like(s: &LibSym) -> bool {+    let i = &s.info;+    ["opamp", "op amp", "op-amp", "operational", "comparator"].iter().any(|k| i.contains(k))+        || (s.pins.iter().any(|p| amp_input(&p.name) == Some('+')) && s.pins.iter().any(|p| amp_input(&p.name) == Some('-')))+}++fn ref_prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++// -- (1) polarity drawings ------------------------------------------------------------------------++fn named<'a>(pins: &[&'a Pin], names: &[&str]) -> Vec<&'a Pin> {+    pins.iter().copied().filter(|p| names.contains(&norm(&p.name).as_str())).collect()+}++fn diode_check(pins: &[&Pin], shapes: &[&Shape]) -> Option<(Sev, String)> {+    let (k, a) = (named(pins, &["K", "CATHODE", "KATHODE"]), named(pins, &["A", "ANODE"]));+    if k.len() != 1 || a.len() != 1 { return None; }+    let (k, a) = (k[0], a[0]);+    let l = dist(a.at, k.at);+    if l < 0.1 { return None; }+    let u = ((k.at.0 - a.at.0) / l, (k.at.1 - a.at.1) / l);+    let t = |p: Pt| ((p.0 - a.at.0) * u.0 + (p.1 - a.at.1) * u.1) / l; // 0 at A, 1 at K+    let s = |p: Pt| -(p.0 - a.at.0) * u.1 + (p.1 - a.at.1) * u.0; // across the axis, mm+    let who = format!("pin {} ({}) is the cathode, pin {} ({}) the anode", k.number, k.name, a.number, a.name);+    // the body triangle: a base across the axis, the apex on it, pointing at the cathode+    let mut tri: Option<(f64, f64, f64)> = None;+    for sh in shapes.iter().filter(|s| s.kind == "polyline" && s.closed && s.pts.len() == 3) {+        for i in 0..3 {+            let (ap, pj, pk) = (sh.pts[i], sh.pts[(i + 1) % 3], sh.pts[(i + 2) % 3]);+            let width = (s(pj) - s(pk)).abs();+            if width < 0.3 || (t(pj) - t(pk)).abs() * l > 0.25 * width || s(ap).abs() > 0.25 * width || ((s(pj) + s(pk)) / 2.0).abs() > 0.25 * width { continue; }+            if tri.map(|x| width > x.2).unwrap_or(true) { tri = Some(((t(pj) + t(pk)) / 2.0, t(ap), width)); }+        }+    }+    // bars: a stroke across the axis (open polyline or a thin filled rectangle)+    let mut bars: Vec<(f64, f64)> = Vec::new(); // (t, across extent)+    for sh in shapes.iter().filter(|s| (s.kind == "polyline" && !s.closed) || (s.kind == "rectangle" || s.kind == "polyline") && s.closed && s.pts.len() == 4) {+        let ts: Vec<f64> = sh.pts.iter().map(|p| t(*p)).collect();+        let ss: Vec<f64> = sh.pts.iter().map(|p| s(*p)).collect();+        let along = (ts.iter().cloned().fold(f64::MIN, f64::max) - ts.iter().cloned().fold(f64::MAX, f64::min)) * l;+        let (smin, smax) = (ss.iter().cloned().fold(f64::MAX, f64::min), ss.iter().cloned().fold(f64::MIN, f64::max));+        let across = smax - smin;+        let ratio = if sh.closed { 3.0 } else { 1.5 };+        if across < 0.5 || across < ratio * along || smin > -0.2 || smax < 0.2 { continue; }+        // where the bar is: the middle of its longest segment+        let n = sh.pts.len();+        let seg = (0..n - if sh.closed { 0 } else { 1 }).max_by(|&i, &j| dist(sh.pts[i], sh.pts[(i + 1) % n]).total_cmp(&dist(sh.pts[j], sh.pts[(j + 1) % n]))).unwrap_or(0);+        let mid = ((sh.pts[seg].0 + sh.pts[(seg + 1) % n].0) / 2.0, (sh.pts[seg].1 + sh.pts[(seg + 1) % n].1) / 2.0);+        bars.push((t(mid), across));+    }+    let bar = match tri {+        Some((_, apex, _)) => bars.iter().min_by(|x, y| (x.0 - apex).abs().total_cmp(&(y.0 - apex).abs())).map(|b| b.0),+        None => bars.iter().max_by(|x, y| x.1.total_cmp(&y.1)).map(|b| b.0),+    };+    match (tri, bar) {+        (Some((base, apex, _)), b) if apex < base => {+            let bar_at_apex = b.map(|b| (b - apex).abs() < (b - base).abs()).unwrap_or(true);+            if bar_at_apex { Some((Sev::Error, format!("the diode is drawn pointing at the anode: the triangle and cathode bar are at the pin {} end, but {who}", a.number))) }+            else { Some((Sev::Warn, format!("the diode triangle points at pin {} (named {}) while the bar is at the other end; {who}", a.number, a.name))) }+        }+        (Some((base, _, _)), Some(b)) if b < base - 0.02 => Some((Sev::Warn, format!("the cathode bar is on the anode side of the triangle; {who}"))),+        (Some(_), _) => None,+        (None, Some(b)) if b < 0.4 => Some((Sev::Error, format!("the cathode bar is drawn at the pin {} end, but {who}", a.number))),+        (None, Some(b)) if b < 0.6 => Some((Sev::Warn, format!("the cathode bar is near the middle, so the drawing does not show which end is the cathode; {who}"))),+        (None, Some(_)) => None,+        (None, None) => Some((Sev::Warn, format!("no cathode bar or triangle recognised in the drawing; check by eye that the bar is at the cathode ({who})"))),+    }+}++fn bjt_check(pins: &[&Pin], shapes: &[&Shape]) -> Option<(Sev, String)> {+    let (e, c) = (named(pins, &["E", "EMITTER"]), named(pins, &["C", "COLLECTOR"]));+    if e.len() != 1 || c.len() != 1 { return None; }+    let (e, c) = (e[0], c[0]);+    let arrows: Vec<Pt> = shapes.iter().filter(|s| s.kind == "polyline" && s.pts.len() >= 3).filter(|s| {+        let (xs, ys): (Vec<f64>, Vec<f64>) = s.pts.iter().cloned().unzip();+        let w = xs.iter().cloned().fold(f64::MIN, f64::max) - xs.iter().cloned().fold(f64::MAX, f64::min);+        let h = ys.iter().cloned().fold(f64::MIN, f64::max) - ys.iter().cloned().fold(f64::MAX, f64::min);+        (w * w + h * h).sqrt() < 2.5+    }).map(|s| centroid(&s.pts)).collect();+    let best = arrows.iter().min_by(|x, y| dist(**x, e.inner).min(dist(**x, c.inner)).total_cmp(&dist(**y, e.inner).min(dist(**y, c.inner))))?;+    let (de, dc) = (dist(*best, e.inner), dist(*best, c.inner));+    let who = format!("pin {} is named {} and pin {} {}", e.number, e.name, c.number, c.name);+    if dc * 1.5 < de { Some((Sev::Error, format!("the emitter arrow is drawn on the collector lead; {who}"))) }+    else if dc < de { Some((Sev::Warn, format!("the emitter arrow is closer to the collector than to the emitter; {who}"))) }+    else { None }+}++fn opamp_check(sym: &LibSym, pins: &[&Pin], texts: &[&Txt], custom: bool) -> Vec<(Sev, String)> {+    let mut out = Vec::new();+    let inputs: Vec<(&Pin, char)> = pins.iter().filter_map(|p| amp_input(&p.name).map(|s| (*p, s))).collect();+    let signs: Vec<(&Txt, char)> = texts.iter().filter_map(|t| match norm(&t.text).as_str() { "+" => Some((*t, '+')), "-" => Some((*t, '-')), _ => None }).collect();+    if signs.is_empty() {+        if !inputs.is_empty() && sym.names_hidden { out.push((Sev::Warn, "the input pins are named +/- but pin names are hidden and no +/- is drawn: the inputs show no polarity".into())); }+        else if inputs.is_empty() && custom && amp_like(sym) { out.push((Sev::Warn, "an amplifier symbol whose inputs are not named +/- (or IN+/IN-) and no +/- is drawn".into())); }+        return out;+    }+    if inputs.is_empty() {+        if custom { out.push((Sev::Warn, "+/- is drawn but no pin is named as an input (+, -, IN+, IN-): name the inputs so the drawing can be checked".into())); }+        return out;+    }+    for (t, sign) in signs {+        let d = |want: bool| inputs.iter().filter(|(_, s)| (*s == sign) == want).map(|(p, _)| (dist(t.at, p.inner), *p)).min_by(|a, b| a.0.total_cmp(&b.0));+        let (Some((same, _)), Some((other, op))) = (d(true), d(false)) else { continue };+        if other * 1.5 < same { out.push((Sev::Error, format!("the drawn `{sign}` sits at pin {} ({}), the opposite input", op.number, op.name))); }+        else if other < same { out.push((Sev::Warn, format!("the drawn `{sign}` is nearer pin {} ({}), the opposite input", op.number, op.name))); }+    }+    out+}++/// The polarity findings of one lib symbol, per unit.+fn polarity(sym: &LibSym, custom: bool) -> Vec<(Sev, String)> {+    let units: BTreeSet<u32> = sym.pins.iter().map(|p| p.unit).filter(|u| *u > 0).collect();+    let units: Vec<u32> = if units.is_empty() { vec![0] } else { units.into_iter().collect() };+    let mut out = Vec::new();+    for u in units {+        let pins: Vec<&Pin> = sym.pins.iter().filter(|p| p.unit == u || p.unit == 0).collect();+        let shapes: Vec<&Shape> = sym.shapes.iter().filter(|s| s.unit == u || s.unit == 0).collect();+        let texts: Vec<&Txt> = sym.texts.iter().filter(|t| t.unit == u || t.unit == 0).collect();+        let tag = if u > 0 && sym.pins.iter().any(|p| p.unit > 1) { format!("unit {u}: ") } else { String::new() };+        let mut found: Vec<(Sev, String)> = Vec::new();+        found.extend(diode_check(&pins, &shapes));+        match bjt_check(&pins, &shapes) {+            Some(f) => found.push(f),+            None if custom && named(&pins, &["E", "EMITTER"]).len() == 1 && named(&pins, &["C", "COLLECTOR"]).len() == 1 && shapes.iter().all(|s| !(s.kind == "polyline" && s.pts.len() >= 3)) => found.push((Sev::Warn, "a transistor with E and C pins but no emitter arrow recognised in the drawing".into())),+            None => {}+        }+        found.extend(opamp_check(sym, &pins, &texts, custom));+        // a custom diode with no recognisable drawing is a warning; a library one is not worth a line+        for (sev, m) in found {+            if !custom && m.starts_with("no cathode bar") { continue; }+            out.push((if custom { sev } else { Sev::Warn }, format!("{tag}{m}")));+        }+    }+    out+}++// -- (2) pin electrical types --------------------------------------------------------------------++fn pin_types(sym: &LibSym) -> Vec<(Sev, Pin, String)> {+    let mut out = Vec::new();+    let mut seen = BTreeSet::new();+    let amp = amp_like(sym);+    for p in &sym.pins {+        if !seen.insert((p.number.clone(), p.name.clone())) { continue; }+        let et = p.etype.as_str();+        // a stacked pin (same unit, same place as another pin) is passive by KiCad's own convention+        if et == "passive" && sym.pins.iter().any(|q| !std::ptr::eq(q, p) && q.unit == p.unit && close(q.at, p.at) && q.etype != "passive") { continue; }+        if let Some(s) = amp_input(&p.name) {+            if et == "passive" { out.push((Sev::Error, p.clone(), format!("amplifier input `{}` ({s}) is passive; declare it input", p.name))); }+            else if et != "input" { out.push((Sev::Warn, p.clone(), format!("amplifier input `{}` is {et}; amplifier inputs are input", p.name))); }+        } else if is_output(&p.name) {+            if et == "passive" { out.push((Sev::Error, p.clone(), format!("output `{}` is passive; declare it output (or power_out for a regulator output)", p.name))); }+            else if !["output", "tri_state", "open_collector", "open_emitter", "power_out", "bidirectional"].contains(&et) { out.push((Sev::Warn, p.clone(), format!("output `{}` is {et}", p.name))); }+        } else if is_power(&p.name) {+            if et == "passive" { out.push((if amp { Sev::Error } else { Sev::Warn }, p.clone(), format!("power pin `{}` is passive; declare it power_in", p.name))); }+            else if !["power_in", "power_out"].contains(&et) { out.push((Sev::Warn, p.clone(), format!("power pin `{}` is {et}; power pins are power_in", p.name))); }+        }+    }+    out+}++/// `spec.schematic.allowPassive`: [{"ref": "U1", "pin": "3" (number or name; omit for every pin), "why": "datasheet ..."}].+fn passive_allowed(spec: &Value, reference: &str, pin: &Pin) -> Option<bool> {+    let list = spec["schematic"]["allowPassive"].as_array()?;+    let hit = list.iter().find(|e| e["ref"].as_str() == Some(reference) && e["pin"].as_str().map(|x| x == pin.number || x == pin.name).unwrap_or(true))?;+    Some(hit["why"].as_str().map(|w| w.trim().len() >= 8).unwrap_or(false))+}++/// `spec.schematic.polarityReviewed`: [{"symbol": "<lib id or reference>", "why": "..."}] turns a polarity error into a warning.+fn polarity_reviewed(spec: &Value, lib: &str, refs: &[String]) -> bool {+    spec["schematic"]["polarityReviewed"].as_array().map(|a| a.iter().any(|e| {+        let s = e["symbol"].as_str().unwrap_or("");+        (s == lib || refs.iter().any(|r| r == s)) && e["why"].as_str().map(|w| w.trim().len() >= 8).unwrap_or(false)+    })).unwrap_or(false)+}++// -- (3) readability ------------------------------------------------------------------------------++/// Paper size (mm) of a sheet: `(paper "A4" [portrait])` or `(paper "User" w h)`.+fn paper(root: &Node) -> Option<Pt> {+    let p = root.find("paper")?;+    let name = p.atom(1)?;+    let (w, h) = match name {+        "A5" => (210.0, 148.0), "A4" => (297.0, 210.0), "A3" => (420.0, 297.0), "A2" => (594.0, 420.0), "A1" => (841.0, 594.0), "A0" => (1189.0, 841.0),+        "A" | "USLetter" => (279.4, 215.9), "B" | "USLedger" => (431.8, 279.4), "USLegal" => (355.6, 215.9), "C" => (558.8, 431.8), "D" => (863.6, 558.8), "E" => (1117.6, 863.6),+        "User" => (p.number(2)?, p.number(3)?),+        _ => return None,+    };+    Some(if flag(p, "portrait") { (h, w) } else { (w, h) })+}++/// The drawing frame's margin from the paper edge in KiCad's default page layout.+const BORDER_MM: f64 = 10.0;+/// Two uses of one local label this close together are a short hop that wants a wire.+const SHORT_HOP_MM: f64 = 25.4;++#[derive(Clone, Copy, Debug)]+struct BBox { x0: f64, y0: f64, x1: f64, y1: f64 }+impl BBox {+    fn of(pts: &[Pt]) -> Option<BBox> {+        if pts.is_empty() { return None; }+        Some(BBox { x0: pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), y0: pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), x1: pts.iter().map(|p| p.0).fold(f64::MIN, f64::max), y1: pts.iter().map(|p| p.1).fold(f64::MIN, f64::max) })+    }+    fn hits(&self, o: &BBox) -> bool { self.x0 < o.x1 && o.x0 < self.x1 && self.y0 < o.y1 && o.y0 < self.y1 }+    fn has(&self, p: Pt) -> bool { p.0 >= self.x0 && p.0 <= self.x1 && p.1 >= self.y0 && p.1 <= self.y1 }+    fn shrink(&self, d: f64) -> BBox { BBox { x0: self.x0 + d, y0: self.y0 + d, x1: self.x1 - d, y1: self.y1 - d } }+}++/// A lib point on the sheet: lib y is up, the sheet's is down; rotation, then mirror (approximate).+fn place(i: &Inst, p: Pt) -> Pt {+    let (x, y) = (p.0, -p.1);+    let r = i.rot.to_radians();+    let (mut x, mut y) = (x * r.cos() + y * r.sin(), -x * r.sin() + y * r.cos());+    match i.mirror.as_deref() { Some("x") => y = -y, Some("y") => x = -x, _ => {} }+    (i.at.0 + x, i.at.1 + y)+}++/// The box free text covers on the sheet, estimated from its font size and justification.+fn text_box(n: &Node) -> Option<BBox> {+    let text = n.atom(1)?;+    let (at, ang) = at_of(n)?;+    let eff = n.find("effects");+    let size = eff.and_then(|e| e.find("font")).and_then(|f| f.find("size"));+    let (h, w) = (size.and_then(|s| s.number(1)).unwrap_or(1.27), size.and_then(|s| s.number(2)).unwrap_or(1.27));+    let lines: Vec<&str> = text.split('\n').collect();+    let (tw, th) = (lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as f64 * w * 0.9, lines.len() as f64 * h * 1.4);+    let j: Vec<String> = eff.and_then(|e| e.find("justify")).map(|j| (1..4).filter_map(|i| j.atom(i).map(str::to_string)).collect()).unwrap_or_default();+    let (u0, u1) = if j.iter().any(|s| s == "left") { (0.0, tw) } else if j.iter().any(|s| s == "right") { (-tw, 0.0) } else { (-tw / 2.0, tw / 2.0) };+    let (v0, v1) = if j.iter().any(|s| s == "bottom") { (-th, 0.0) } else if j.iter().any(|s| s == "top") { (0.0, th) } else { (-th / 2.0, th / 2.0) };+    let r = ang.to_radians();+    let pts: Vec<Pt> = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)].iter().map(|(u, v)| (at.0 + u * r.cos() + v * r.sin(), at.1 - u * r.sin() + v * r.cos())).collect();+    BBox::of(&pts)+}++fn instances(root: &Node) -> Vec<Inst> {+    root.find_all("symbol").filter_map(|s| {+        let lib_id = s.find("lib_id")?.value()?.to_string();+        let lib_name = s.find("lib_name").and_then(|n| n.value()).map(str::to_string);+        let (at, rot) = at_of(s)?;+        let mut reference = prop(s, "Reference").unwrap_or("").to_string();+        if reference.is_empty() || reference.contains('?') {+            if let Some(r) = s.find("instances").and_then(|i| i.find("project")).and_then(|p| p.find("path")).and_then(|p| p.find("reference")).and_then(|r| r.value()) { reference = r.to_string(); }+        }+        Some(Inst { lib: lib_name.clone().unwrap_or(lib_id), custom_copy: lib_name.is_some(), reference, at, rot, mirror: s.find("mirror").and_then(|m| m.value()).map(str::to_string), unit: s.find("unit").and_then(|u| u.number(1)).unwrap_or(1.0) as u32 })+    }).collect()+}++fn sheet_readability(file: &str, root: &Node, libs: &BTreeMap<String, LibSym>, insts: &[Inst], warnings: &mut Vec<String>) -> Value {+    let wires = root.find_all("wire").count();+    let labels: Vec<(String, Pt)> = root.find_all("label").filter_map(|l| Some((l.atom(1)?.to_string(), at_of(l)?.0))).collect();+    let short = std::path::Path::new(file).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();+    if labels.len() >= 8 && wires * 2 < labels.len() {+        warnings.push(format!("{short}: {} local labels and {wires} wire(s): draw the signal path with wires inside each functional block and keep labels for long or cross-sheet nets, so feedback loops can be traced by eye", labels.len()));+    }+    let mut by_name: BTreeMap<&str, Vec<Pt>> = BTreeMap::new();+    for (n, p) in &labels { by_name.entry(n.as_str()).or_default().push(*p); }+    let hops: Vec<String> = by_name.iter().filter(|(_, ps)| ps.len() >= 2 && ps.iter().all(|a| ps.iter().all(|b| dist(*a, *b) <= SHORT_HOP_MM))).map(|(n, ps)| format!("{n} ({} labels)", ps.len())).collect();+    if !hops.is_empty() {+        warnings.push(format!("{short}: {} net(s) joined only by local labels within {SHORT_HOP_MM} mm of each other: {}{}; a short local connection is a drawn wire", hops.len(), hops.iter().take(8).cloned().collect::<Vec<_>>().join(", "), if hops.len() > 8 { ", ..." } else { "" }));+    }+    // where each placed symbol's body and pins land on the sheet+    let mut bodies: Vec<(String, BBox)> = Vec::new();+    let mut pin_segs: Vec<(String, Pt, Pt)> = Vec::new();+    for i in insts {+        let Some(sym) = libs.get(&i.lib) else { continue };+        if sym.power { continue; }+        let pts: Vec<Pt> = sym.shapes.iter().filter(|s| s.unit == 0 || s.unit == i.unit).flat_map(|s| s.pts.iter().map(|p| place(i, *p))).collect();+        let pins: Vec<&Pin> = sym.pins.iter().filter(|p| p.unit == 0 || p.unit == i.unit).collect();+        let body_pts = if pts.is_empty() { pins.iter().map(|p| place(i, p.inner)).collect() } else { pts };+        if let Some(b) = BBox::of(&body_pts) { bodies.push((i.reference.clone(), b)); }+        for p in pins { pin_segs.push((format!("{}.{}", i.reference, p.number), place(i, p.at), place(i, p.inner))); }+    }+    let mut over = Vec::new();+    let texts: Vec<(String, BBox)> = root.find_all("text").filter_map(|t| Some((t.atom(1)?.chars().take(24).collect::<String>(), text_box(t)?))).collect();+    for (t, b) in &texts {+        let on_pin = pin_segs.iter().find(|(_, a, z)| (0..=4).any(|k| { let f = k as f64 / 4.0; b.has((a.0 + (z.0 - a.0) * f, a.1 + (z.1 - a.1) * f)) })).map(|x| format!("pin {}", x.0));+        let on_body = bodies.iter().find(|(_, bb)| bb.shrink(0.2).hits(b)).map(|x| format!("symbol {}", x.0));+        if let Some(w) = on_pin.or(on_body) { over.push(format!("`{t}` over {w}")); }+    }+    if !over.is_empty() { warnings.push(format!("{short}: text over pins or parts: {}{}", over.iter().take(6).cloned().collect::<Vec<_>>().join("; "), if over.len() > 6 { "; ..." } else { "" })); }+    let mut outside = Vec::new();+    if let Some((w, h)) = paper(root) {+        let frame = BBox { x0: BORDER_MM, y0: BORDER_MM, x1: w - BORDER_MM, y1: h - BORDER_MM };+        let inside = |b: &BBox| b.x0 >= frame.x0 && b.y0 >= frame.y0 && b.x1 <= frame.x1 && b.y1 <= frame.y1;+        for (r, b) in &bodies { if !inside(b) { outside.push(format!("symbol {r}")); } }+        for (t, b) in &texts { if !inside(b) { outside.push(format!("text `{t}`")); } }+        for (n, p) in &labels { if !frame.has(*p) { outside.push(format!("label {n}")); } }+        for wire in root.find_all("wire") {+            if wire.find("pts").map(|p| p.find_all("xy").filter_map(xy).any(|q| !frame.has(q))).unwrap_or(false) { outside.push("a wire".into()); break; }+        }+        if !outside.is_empty() { warnings.push(format!("{short}: outside the drawing border: {}{}", outside.iter().take(6).cloned().collect::<Vec<_>>().join(", "), if outside.len() > 6 { ", ..." } else { "" })); }+    }+    json!({"file": file, "symbols": insts.len(), "wires": wires, "localLabels": labels.len(), "shortLabelHops": hops.len(), "textOverlaps": over.len(), "outsideBorder": outside.len()})+}++// -- the check ------------------------------------------------------------------------------------++/// Run the schematic check on a root sheet and its hierarchy. `spec` carries the overrides+/// (`schematic.allowPassive`, `schematic.polarityReviewed`); Null for none.+pub fn check(root: &Path, spec: &Value) -> Result<Value, String> {+    let files = crate::schematic_identity::capture(root)?;+    let (mut errors, mut warnings, mut sheets) = (Vec::<String>::new(), Vec::<String>::new(), Vec::<Value>::new());+    // per lib symbol: the references using it and its findings (deduplicated across sheets)+    let mut seen_lib: BTreeMap<String, (LibSym, bool, BTreeSet<String>)> = BTreeMap::new();+    let mut pin_refs: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();+    let mut symbols = 0usize;+    for file in files.keys() {+        let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;+        let root_node = sx::parse(&text)?;+        let libs: BTreeMap<String, LibSym> = root_node.find("lib_symbols").map(|l| l.find_all("symbol").map(|s| { let p = parse_lib(s); (p.name.clone(), p) }).collect()).unwrap_or_default();+        let insts = instances(&root_node);+        for i in &insts {+            let Some(sym) = libs.get(&i.lib) else { continue };+            if sym.power || i.reference.starts_with('#') { continue; }+            symbols += 1;+            let custom = is_custom(&i.lib, i.custom_copy);+            let e = seen_lib.entry(i.lib.clone()).or_insert_with(|| (sym.clone(), custom, BTreeSet::new()));+            e.2.insert(i.reference.clone());+            let px = ref_prefix(&i.reference);+            if px == "U" || px == "IC" || amp_like(sym) { pin_refs.entry(i.lib.clone()).or_default().insert(i.reference.clone()); }+        }+        sheets.push(sheet_readability(file, &root_node, &libs, &insts, &mut warnings));+    }+    let mut custom_syms = Vec::new();+    for (lib, (sym, custom, refs)) in &seen_lib {+        if *custom { custom_syms.push(lib.clone()); }+        let refs_v: Vec<String> = refs.iter().cloned().collect();+        let reviewed = polarity_reviewed(spec, lib, &refs_v);+        for (sev, m) in polarity(sym, *custom) {+            let line = format!("{} ({lib}{}): {m}", refs_v.join(","), if *custom { ", custom symbol" } else { "" });+            if sev == Sev::Error && !reviewed { errors.push(format!("polarity drawing: {line}")); } else { warnings.push(format!("polarity drawing: {line}{}", if sev == Sev::Error { " [reviewed in spec.schematic.polarityReviewed]" } else { "" })); }+        }+        if let Some(prefs) = pin_refs.get(lib) {+            for (sev, pin, m) in pin_types(sym) {+                let mut blocked: Vec<String> = Vec::new();+                for r in prefs {+                    match (sev, passive_allowed(spec, r, &pin)) {+                        (Sev::Error, Some(true)) => warnings.push(format!("pin type: {r} pin {} ({lib}): {m} [allowed by spec.schematic.allowPassive]", pin.number)),+                        (Sev::Error, Some(false)) => { warnings.push(format!("pin type: spec.schematic.allowPassive names {r} pin {} without a datasheet `why`; it does not count", pin.number)); blocked.push(r.clone()); }+                        (Sev::Error, None) => blocked.push(r.clone()),+                        _ => {}+                    }+                }+                if sev == Sev::Warn { warnings.push(format!("pin type: {} pin {} ({lib}): {m}", prefs.iter().cloned().collect::<Vec<_>>().join(","), pin.number)); }+                else if !blocked.is_empty() { errors.push(format!("pin type: {} pin {} ({lib}): {m}", blocked.join(","), pin.number)); }+            }+        }+    }+    Ok(json!({+        "ok": errors.is_empty(), "errors": errors, "warnings": warnings, "sheets": sheets, "symbols": symbols,+        "librarySymbols": seen_lib.len(), "customSymbols": custom_syms,+        "limits": "heuristic geometry: polylines/rectangles/circles/arcs only; one diode per unit with pins named K/A; BJTs with pins named E/C; op-amp inputs named +/-, IN+/IN-, INP/INN; transistor (Q) pin types not checked; text sizes estimated; symbol fields not checked for overlap; feedback loops not traced",+    }))+}++/// Record a check in the run (`schematic-check` outcome, `<run>/schematic-check.json`), bound to+/// every sheet's hash so `finish` asks again after any edit.+pub fn record(r: &mut Run, sch: &str, rep: &Value) {+    let report = r.dir.join("schematic-check.json");+    let _ = std::fs::write(&report, serde_json::to_string_pretty(rep).unwrap_or_default());+    let files = crate::schematic_identity::capture(Path::new(sch)).unwrap_or_default();+    r.outcome(OUTCOME, json!({"ok": rep["ok"], "schematic": sch, "schematicFiles": files, "errors": rep["errors"].as_array().map(|a| a.len()).unwrap_or(0), "warnings": rep["warnings"].as_array().map(|a| a.len()).unwrap_or(0), "report": report.display().to_string()}));+}++/// `finish`: a run with a schematic needs a passing schematic check on the hierarchy as it is now.+pub fn gate(r: &Run) -> Result<(), String> {+    let Some(sch) = r.data["schematic"].as_str() else { return Ok(()) };+    let Some(o) = r.outcomes().get(OUTCOME) else { return Err("schematic check (the run has a schematic; run `adom-aiflow schematic check --sch <root>.kicad_sch`, or `netlist check`, which runs it too)".into()) };+    let now = crate::schematic_identity::capture(Path::new(sch)).map_err(|e| format!("schematic check (schematic hierarchy: {e})"))?;+    if o["schematicFiles"] != json!(now) { return Err("schematic check (a sheet changed since the last check; run it again)".into()); }+    if o["ok"] != true { return Err(format!("schematic check ({} error(s) in {}; fix the symbols, then run it again)", o["errors"], o["report"].as_str().unwrap_or("schematic-check.json"))); }+    Ok(())+}++/// The reply: (message, hints).+pub fn render(rep: &Value, in_run: bool) -> (String, Vec<String>) {+    let list = |k: &str| rep[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| format!("  {s}"))).collect::<Vec<_>>()).unwrap_or_default();+    let (errs, warns) = (list("errors"), list("warnings"));+    let head = format!("schematic check: {} sheet(s), {} placed symbol(s), {} custom symbol(s); {} error(s), {} warning(s)", rep["sheets"].as_array().map(|a| a.len()).unwrap_or(0), rep["symbols"], rep["customSymbols"].as_array().map(|a| a.len()).unwrap_or(0), errs.len(), warns.len());+    let mut body = vec![head];+    if !errs.is_empty() { body.push("errors:".into()); body.extend(errs); }+    if !warns.is_empty() { body.push("warnings:".into()); body.extend(warns); }+    let hints = vec![+        "Prefer library symbols (one triangle per op-amp section, a conventional transistor symbol) over custom rectangles and circles. A custom symbol's drawn polarity (diode bar, emitter arrow, op-amp +/-) must agree with its pin names (skill aiflow-circuit-design).".into(),+        "Active-device pins are not passive: amplifier inputs input, outputs output, power pins power_in. When the datasheet really says otherwise, add {\"ref\", \"pin\", \"why\"} to spec.schematic.allowPassive; a reviewed polarity drawing goes in spec.schematic.polarityReviewed.".into(),+        format!("Limits: {}.", rep["limits"].as_str().unwrap_or("")),+        if in_run { "Recorded in the run (schematic-check); finish requires a pass on the schematic as it is then.".into() } else { "No run here: nothing recorded.".into() },+    ];+    (body.join("\n"), hints)+}++/// The warnings as one hint line (empty when there are none).+pub fn warning_hint(rep: &Value) -> String {+    let w: Vec<&str> = rep["warnings"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();+    if w.is_empty() { return String::new(); }+    format!("schematic warnings ({}): {}{}", w.len(), w.iter().take(8).cloned().collect::<Vec<_>>().join("; "), if w.len() > 8 { "; ... (all in schematic-check.json)" } else { "" })+}++/// One line for `netlist check`, which runs this check too.+pub fn summary_line(rep: &Result<Value, String>) -> String {+    match rep {+        Ok(v) => format!("schematic check: {} error(s), {} warning(s){}", v["errors"].as_array().map(|a| a.len()).unwrap_or(0), v["warnings"].as_array().map(|a| a.len()).unwrap_or(0), if v["ok"] == true { "" } else { " (finish requires a pass: `adom-aiflow schematic check` lists them)" }),+        Err(e) => format!("schematic check could not read the schematic: {e}"),+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    struct Fixture(std::path::PathBuf);+    impl Fixture {+        fn new() -> Self {+            static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);+            let p = std::env::temp_dir().join(format!("aiflow-schck-{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)));+            std::fs::create_dir_all(&p).unwrap(); Self(p)+        }+        fn write(&self, name: &str, text: &str) -> std::path::PathBuf { let p = self.0.join(name); std::fs::write(&p, text).unwrap(); p }+    }+    impl Drop for Fixture { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } }++    const HEAD: &str = "(kicad_sch (version 20250610) (generator \"eeschema\") (generator_version \"10.0\") (uuid \"6f1d2c3e-0000-4000-8000-000000000001\") (paper \"A4\")";++    fn diode_lib(name: &str, bar_x: f64, apex_x: f64, base_x: f64) -> String {+        format!(r#"(symbol "{name}" (pin_numbers (hide yes)) (pin_names (offset 1.016) (hide yes)) (exclude_from_sim no) (in_bom yes) (on_board yes)+  (property "Reference" "D" (at 0 2.54 0) (effects (font (size 1.27 1.27))))+  (property "Value" "D" (at 0 -2.54 0) (effects (font (size 1.27 1.27))))+  (symbol "{short}_0_1"+    (polyline (pts (xy {bar_x} 1.27) (xy {bar_x} -1.27)) (stroke (width 0.254) (type default)) (fill (type none)))+    (polyline (pts (xy {base_x} 1.27) (xy {base_x} -1.27) (xy {apex_x} 0) (xy {base_x} 1.27)) (stroke (width 0.254) (type default)) (fill (type none))))+  (symbol "{short}_1_1"+    (pin passive line (at -3.81 0 0) (length 2.54) (name "K" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27)))))+    (pin passive line (at 3.81 0 180) (length 2.54) (name "A" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))))+  (embedded_fonts no))"#, short = name.rsplit(':').next().unwrap())+    }++    fn placed(lib: &str, reference: &str, x: f64, y: f64) -> String {+        format!(r#"(symbol (lib_id "{lib}") (at {x} {y} 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) (uuid "6f1d2c3e-0000-4000-8000-{n:012}")+  (property "Reference" "{reference}" (at {x} {y} 0) (effects (font (size 1.27 1.27))))+  (pin "1" (uuid "6f1d2c3e-0000-4000-8001-{n:012}")) (pin "2" (uuid "6f1d2c3e-0000-4000-8002-{n:012}")))"#, n = reference.bytes().map(|b| b as u64).sum::<u64>())+    }++    fn sheet(libs: &[String], body: &str) -> String { format!("{HEAD}\n(lib_symbols {})\n{body}\n(sheet_instances (path \"/\" (page \"1\"))) (embedded_fonts no))", libs.join("\n")) }++    #[test]+    fn standard_diode_passes_and_a_custom_mirrored_one_fails() {+        let f = Fixture::new();+        // KiCad's Device:D: pin K left, bar left, triangle pointing left+        let good = f.write("good.kicad_sch", &sheet(&[diode_lib("Device:D", -1.27, -1.27, 1.27)], &placed("Device:D", "D1", 100.0, 80.0)));+        let rep = check(&good, &Value::Null).unwrap();+        assert_eq!(rep["ok"], true, "{rep}");+        assert!(rep["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("polarity")), "{rep}");+        // the trial's custom diode: pin 1 = K on the left, bar and triangle drawn at the right+        let bad = f.write("bad.kicad_sch", &sheet(&[diode_lib("Trial:DIODE", 1.27, 1.27, -1.27)], &placed("Trial:DIODE", "D1", 100.0, 80.0)));+        let rep = check(&bad, &Value::Null).unwrap();+        assert_eq!(rep["ok"], false);+        assert!(rep["errors"][0].as_str().unwrap().contains("pointing at the anode"), "{rep}");+        // a reviewed drawing becomes a warning+        let spec = json!({"schematic": {"polarityReviewed": [{"symbol": "Trial:DIODE", "why": "reviewed against the datasheet drawing p.1"}]}});+        assert_eq!(check(&bad, &spec).unwrap()["ok"], true);+        // the same wrong drawing from a standard library only warns+        let lib = f.write("lib.kicad_sch", &sheet(&[diode_lib("Device:D", 1.27, 1.27, -1.27)], &placed("Device:D", "D1", 100.0, 80.0)));+        let rep = check(&lib, &Value::Null).unwrap();+        assert_eq!(rep["ok"], true);+        assert!(rep["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("pointing at the anode")));+    }++    #[test]+    fn bar_only_diode_on_the_wrong_side_fails() {+        let f = Fixture::new();+        let lib = r#"(symbol "My:D" (symbol "D_0_1" (rectangle (start -1.27 -1.0) (end 1.27 1.0) (stroke (width 0.2)) (fill (type none))) (polyline (pts (xy 1.0 1.27) (xy 1.0 -1.27)) (stroke (width 0.3)) (fill (type none))))+  (symbol "D_1_1" (pin passive line (at -3.81 0 0) (length 2.54) (name "K") (number "1")) (pin passive line (at 3.81 0 180) (length 2.54) (name "A") (number "2"))))"#;+        let p = f.write("s.kicad_sch", &sheet(&[lib.into()], &placed("My:D", "D3", 100.0, 80.0)));+        let rep = check(&p, &Value::Null).unwrap();+        assert!(rep["errors"].as_array().unwrap().iter().any(|e| e.as_str().unwrap().contains("cathode bar is drawn at the pin 2 end")), "{rep}");+    }++    fn opamp_lib(name: &str, in_type: &str, plus_text_y: f64) -> String {+        format!(r#"(symbol "{name}" (pin_names (offset 0.127)) (property "Reference" "U" (at 0 5 0) (effects (font (size 1.27 1.27)))) (property "ki_keywords" "dual opamp" (at 0 0 0) (effects (font (size 1.27 1.27)) (hide yes)))+  (symbol "OPA_1_1"+    (rectangle (start -5.08 5.08) (end 5.08 -5.08) (stroke (width 0.254)) (fill (type background)))+    (text "+" (at -3.81 {plus_text_y} 0) (effects (font (size 1.27 1.27))))+    (pin {in_type} line (at -7.62 2.54 0) (length 2.54) (name "1" (effects (font (size 1.27 1.27)))) (number "3" (effects (font (size 1.27 1.27)))))+    (pin {in_type} line (at -7.62 -2.54 0) (length 2.54) (name "2" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27)))))+    (pin output line (at 7.62 0 180) (length 2.54) (name "OUT" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27))))))+  (symbol "OPA_2_1"+    (pin passive line (at -7.62 2.54 0) (length 2.54) (name "IN+" (effects (font (size 1.27 1.27)))) (number "5" (effects (font (size 1.27 1.27)))))+    (pin {in_type} line (at -7.62 -2.54 0) (length 2.54) (name "IN-" (effects (font (size 1.27 1.27)))) (number "6" (effects (font (size 1.27 1.27)))))+    (text "+" (at -3.81 {plus_text_y} 0) (effects (font (size 1.27 1.27))))+    (pin output line (at 7.62 0 180) (length 2.54) (name "OUT" (effects (font (size 1.27 1.27)))) (number "7" (effects (font (size 1.27 1.27))))))+  (symbol "OPA_3_1"+    (pin power_in line (at 0 7.62 270) (length 2.54) (name "V+" (effects (font (size 1.27 1.27)))) (number "8" (effects (font (size 1.27 1.27)))))+    (pin passive line (at 0 -7.62 90) (length 2.54) (name "V-" (effects (font (size 1.27 1.27)))) (number "4" (effects (font (size 1.27 1.27)))))))"#)+    }++    #[test]+    fn passive_amplifier_and_power_pins_are_errors_unless_justified() {+        let f = Fixture::new();+        let p = f.write("s.kicad_sch", &sheet(&[opamp_lib("Trial:TL072", "input", 2.54)], &placed("Trial:TL072", "U1", 120.0, 90.0)));+        let rep = check(&p, &Value::Null).unwrap();+        let errs: Vec<String> = rep["errors"].as_array().unwrap().iter().map(|e| e.as_str().unwrap().to_string()).collect();+        assert!(errs.iter().any(|e| e.contains("pin 5") && e.contains("amplifier input")), "{errs:?}");+        assert!(errs.iter().any(|e| e.contains("pin 4") && e.contains("power pin")), "{errs:?}");+        assert_eq!(errs.len(), 2, "{errs:?}");+        let spec = json!({"schematic": {"allowPassive": [{"ref": "U1", "pin": "5", "why": "TL072 datasheet table 6.1"}, {"ref": "U1", "pin": "V-"}]}});+        let rep = check(&p, &spec).unwrap();+        let errs = rep["errors"].as_array().unwrap();+        assert_eq!(errs.len(), 1, "{rep}");+        assert!(errs[0].as_str().unwrap().contains("pin 4"));+        assert!(rep["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("without a datasheet")));+    }++    #[test]+    fn opamp_plus_text_must_sit_at_the_plus_input() {+        let f = Fixture::new();+        // unit 2: pins IN+ (top, y 2.54) and IN- (bottom); the drawn + at the bottom contradicts them+        let p = f.write("s.kicad_sch", &sheet(&[opamp_lib("Trial:OPA", "input", -2.54)], &placed("Trial:OPA", "U2", 120.0, 90.0)));+        let rep = check(&p, &json!({"schematic": {"allowPassive": [{"ref": "U2", "why": "fixture: not testing pin types here"}]}})).unwrap();+        assert!(rep["errors"].as_array().unwrap().iter().any(|e| e.as_str().unwrap().contains("unit 2") && e.as_str().unwrap().contains("opposite input")), "{rep}");+        let p = f.write("t.kicad_sch", &sheet(&[opamp_lib("Trial:OPA", "input", 2.54)], &placed("Trial:OPA", "U2", 120.0, 90.0)));+        let rep = check(&p, &json!({"schematic": {"allowPassive": [{"ref": "U2", "why": "fixture: not testing pin types here"}]}})).unwrap();+        assert_eq!(rep["ok"], true, "{rep}");+    }++    #[test]+    fn emitter_arrow_on_the_collector_fails() {+        let f = Fixture::new();+        let lib = |arrow_y: f64| format!(r#"(symbol "My:NPN" (symbol "NPN_0_1" (circle (center 1.27 0) (radius 2.8) (stroke (width 0.254)) (fill (type none)))+  (polyline (pts (xy 0.635 0.635) (xy 2.54 2.54)) (stroke (width 0)) (fill (type none)))+  (polyline (pts (xy 0.635 -0.635) (xy 2.54 -2.54)) (stroke (width 0)) (fill (type none)))+  (polyline (pts (xy 1.524 {a}) (xy 2.286 {b}) (xy 2.286 {a}) (xy 1.524 {a})) (stroke (width 0)) (fill (type outline))))+  (symbol "NPN_1_1" (pin input line (at -5.08 0 0) (length 5.715) (name "B") (number "1")) (pin passive line (at 2.54 5.08 270) (length 2.54) (name "C") (number "3")) (pin passive line (at 2.54 -5.08 90) (length 2.54) (name "E") (number "2"))))"#, a = arrow_y, b = arrow_y + 0.762 * arrow_y.signum());+        let good = f.write("g.kicad_sch", &sheet(&[lib(-2.0)], &placed("My:NPN", "Q1", 100.0, 80.0)));+        assert_eq!(check(&good, &Value::Null).unwrap()["ok"], true);+        let bad = f.write("b.kicad_sch", &sheet(&[lib(2.0)], &placed("My:NPN", "Q1", 100.0, 80.0)));+        let rep = check(&bad, &Value::Null).unwrap();+        assert!(rep["errors"][0].as_str().unwrap().contains("collector lead"), "{rep}");+    }++    #[test]+    fn label_only_sheets_text_over_parts_and_off_border_warn() {+        let f = Fixture::new();+        let mut body = placed("Device:D", "D1", 100.0, 80.0);+        for i in 0..10 { body.push_str(&format!("\n(label \"N{i}\" (at {} 60 0) (effects (font (size 1.27 1.27)) (justify left bottom)) (uuid \"6f1d2c3e-0000-4000-8003-{i:012}\"))", 60 + 3 * i)); }+        body.push_str("\n(label \"FB\" (at 90 100 0) (effects (font (size 1.27 1.27))) (uuid \"6f1d2c3e-0000-4000-8004-000000000001\"))");+        body.push_str("\n(label \"FB\" (at 100 105 0) (effects (font (size 1.27 1.27))) (uuid \"6f1d2c3e-0000-4000-8004-000000000002\"))");+        body.push_str("\n(text \"servo loop\" (exclude_from_sim no) (at 99 80 0) (effects (font (size 1.27 1.27)) (justify left bottom)) (uuid \"6f1d2c3e-0000-4000-8005-000000000001\"))");+        body.push_str("\n(text \"note\" (exclude_from_sim no) (at 295 205 0) (effects (font (size 1.27 1.27))) (uuid \"6f1d2c3e-0000-4000-8005-000000000002\"))");+        body.push_str("\n(wire (pts (xy 60 60) (xy 70 60)) (stroke (width 0) (type default)) (uuid \"6f1d2c3e-0000-4000-8006-000000000001\"))");+        let p = f.write("s.kicad_sch", &sheet(&[diode_lib("Device:D", -1.27, -1.27, 1.27)], &body));+        let rep = check(&p, &Value::Null).unwrap();+        let w: Vec<String> = rep["warnings"].as_array().unwrap().iter().map(|e| e.as_str().unwrap().to_string()).collect();+        assert!(w.iter().any(|x| x.contains("12 local labels and 1 wire")), "{w:?}");+        assert!(w.iter().any(|x| x.contains("FB (2 labels)")), "{w:?}");+        assert!(w.iter().any(|x| x.contains("`servo loop` over")), "{w:?}");+        assert!(w.iter().any(|x| x.contains("outside the drawing border") && x.contains("note")), "{w:?}");+        assert_eq!(rep["ok"], true);+        assert_eq!(rep["sheets"][0]["localLabels"], 12);+    }++    #[test]+    fn child_sheets_are_checked_and_gate_binds_hashes() {+        let f = Fixture::new();+        f.write("child.kicad_sch", &sheet(&[diode_lib("Trial:DIODE", 1.27, 1.27, -1.27)], &placed("Trial:DIODE", "D9", 100.0, 80.0)));+        let root = f.write("root.kicad_sch", &format!("{HEAD} (lib_symbols) (sheet (at 50 50) (size 20 20) (property \"Sheetname\" \"filter\" (at 50 49 0)) (property \"Sheetfile\" \"child.kicad_sch\" (at 50 71 0))) (embedded_fonts no))"));+        let rep = check(&root, &Value::Null).unwrap();+        assert_eq!(rep["sheets"].as_array().unwrap().len(), 2);+        assert!(rep["errors"][0].as_str().unwrap().starts_with("polarity drawing: D9"), "{rep}");+        let dir = f.0.join("run");+        std::fs::create_dir_all(&dir).unwrap();+        let mut run = Run { dir: dir.clone(), data: json!({"schematic": root, "boards": [], "outcomes": {}}) };+        assert!(gate(&run).unwrap_err().contains("run `adom-aiflow schematic check --sch"));+        record(&mut run, root.to_str().unwrap(), &rep);+        assert!(gate(&run).unwrap_err().contains("error(s)"));+        f.write("child.kicad_sch", &sheet(&[diode_lib("Trial:DIODE", -1.27, -1.27, 1.27)], &placed("Trial:DIODE", "D9", 100.0, 80.0)));+        assert!(gate(&run).unwrap_err().contains("changed"));+        let rep = check(&root, &Value::Null).unwrap();+        assert_eq!(rep["ok"], true, "{rep}");+        record(&mut run, root.to_str().unwrap(), &rep);+        assert!(gate(&run).is_ok());+    }+}
crates/adom-aiflow/src/silk_polarity.rsadded+146
@@ -0,0 +1,146 @@+//! Assembly silkscreen beyond the label count (issue #39, item 11): references that live only on the+//! fabrication layer, and polarized parts (diodes, LEDs, polarized capacitors, batteries, ICs) with+//! no polarity or pin-1 mark on the silkscreen. Warnings only; `finish` prints them.+//!+//! A mark is any silkscreen circle, polygon or arc in the footprint (a pin-1 dot, triangle or notch),+//! a `+`, `1`, `K` or `A` text, footprint silk lines whose length-weighted middle sits clearly on the pad-1+//! side (a diode's extended cathode line), or a board-level silkscreen item within 1.5 mm of pad 1.+use aiflow_board::{sx::Node, Board};++type Pt = (f64, f64);++fn layer(n: &Node) -> Option<&str> { n.find("layer").and_then(|l| l.value()) }+fn on_silk(n: &Node) -> bool { matches!(layer(n), Some("F.SilkS" | "B.SilkS")) }+fn on_fab(n: &Node) -> bool { matches!(layer(n), Some("F.Fab" | "B.Fab")) }+fn hidden(n: &Node) -> bool {+    let h = |m: &Node| m.find("hide").map(|x| x.value() != Some("no")).unwrap_or(false) || m.items.iter().any(|it| matches!(it, aiflow_board::sx::Item::Atom { text, .. } if text == "hide"));+    h(n) || n.find("effects").map(h).unwrap_or(false)+}+fn xy(n: Option<&Node>) -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) }+fn prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase() }++/// References that are not parts to label (holes, fiducials, logos, machine pins).+fn unlabelled(p: &str) -> bool { matches!(p, "H" | "MH" | "FID" | "MP" | "LOGO" | "G" | "REF") || p.is_empty() }++fn polarized(p: &str, lib: &str) -> bool {+    let l = lib.to_ascii_lowercase();+    matches!(p, "D" | "LED" | "CR" | "ZD" | "U" | "IC" | "BT")+        || ((p == "C" || p == "CP") && (l.contains("cp_") || l.contains("elec") || l.contains("tantal") || l.contains("polar") || l.contains("c_pol") || p == "CP"))+}++/// Board-level silkscreen points (texts' anchors, line ends, circle centres, polygon corners) and the+/// words of board-level silkscreen texts.+fn board_silk(b: &Board) -> (Vec<Pt>, Vec<String>) {+    let (mut pts, mut words) = (Vec::new(), Vec::new());+    for n in b.root.nodes().filter(|n| n.tag.starts_with("gr_") && on_silk(n)) {+        for k in ["start", "end", "center", "at", "mid"] { if let Some(p) = xy(n.find(k)) { pts.push(p); } }+        if let Some(ps) = n.find("pts") { pts.extend(ps.find_all("xy").filter_map(|x| xy(Some(x)))); }+        if n.tag == "gr_text" && !hidden(n) { if let Some(t) = n.atom(1) { words.extend(t.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()).map(str::to_string)); } }+    }+    (pts, words)+}++fn ref_on_silk(fp: &Node, reference: &str, words: &[String]) -> (bool, bool) {+    let (mut silk, mut fab) = (false, false);+    for t in fp.nodes().filter(|c| c.tag == "property" || c.tag == "fp_text") {+        let is_ref = (t.tag == "property" && t.atom(1) == Some("Reference")) || (t.tag == "fp_text" && (t.atom(1) == Some("reference") || t.atom(2).map(|s| s.contains("${REFERENCE}") || s == reference).unwrap_or(false)));+        if !is_ref { continue; }+        if on_silk(t) && !hidden(t) { silk = true; }+        if on_fab(t) { fab = true; }+    }+    (silk || words.iter().any(|w| w == reference), fab)+}++fn has_mark(fp: &Node, pad1: Pt, centre: Pt) -> bool {+    let mut weighted = (0.0, 0.0, 0.0);+    for g in fp.nodes().filter(|n| on_silk(n)) {+        match g.tag.as_str() {+            "fp_circle" | "fp_poly" | "fp_arc" => return true,+            "fp_text" | "property" if !hidden(g) => {+                let t = if g.tag == "fp_text" { g.atom(2) } else { g.atom(2).filter(|_| g.atom(1).map(|k| k != "Reference" && k != "Value").unwrap_or(false)) };+                if matches!(t.map(str::trim), Some("+" | "1" | "K" | "A")) { return true; }+            }+            "fp_line" => {+                if let (Some(a), Some(z)) = (xy(g.find("start")), xy(g.find("end"))) {+                    let len = ((a.0 - z.0).powi(2) + (a.1 - z.1).powi(2)).sqrt();+                    weighted.0 += len * (a.0 + z.0) / 2.0; weighted.1 += len * (a.1 + z.1) / 2.0; weighted.2 += len;+                }+            }+            _ => {}+        }+    }+    if weighted.2 <= 0.0 { return false; }+    let mid = (weighted.0 / weighted.2, weighted.1 / weighted.2);+    let d = (pad1.0 - centre.0, pad1.1 - centre.1);+    let dl = (d.0 * d.0 + d.1 * d.1).sqrt();+    if dl < 1e-6 { return false; }+    ((mid.0 - centre.0) * d.0 + (mid.1 - centre.1) * d.1) / dl > 0.15 * dl+}++/// The warnings for a board: references on the fabrication layer only, and polarized parts with no+/// silkscreen polarity or pin-1 mark.+pub fn warnings(b: &Board) -> Vec<String> {+    let (silk_pts, words) = board_silk(b);+    let (mut fab_only, mut unmarked) = (Vec::new(), Vec::new());+    for fp in b.root.find_all("footprint") {+        let Some(reference) = aiflow_board::reference_of(fp) else { continue };+        let p = prefix(&reference);+        if reference.starts_with('#') || unlabelled(&p) { continue; }+        let pads: Vec<(String, Pt)> = fp.find_all("pad").filter_map(|pd| Some((pd.atom(1)?.to_string(), xy(pd.find("at"))?))).collect();+        if pads.is_empty() { continue; }+        let (silk, fab) = ref_on_silk(fp, &reference, &words);+        if !silk && fab { fab_only.push(reference.clone()); }+        if pads.len() < 2 || !polarized(&p, fp.atom(1).unwrap_or("")) { continue; }+        let Some(pad1) = ["1", "K", "A"].iter().find_map(|n| pads.iter().find(|(name, _)| name == n)).map(|x| x.1) else { continue };+        let n = pads.len() as f64;+        let centre = (pads.iter().map(|x| x.1 .0).sum::<f64>() / n, pads.iter().map(|x| x.1 .1).sum::<f64>() / n);+        if has_mark(fp, pad1, centre) { continue; }+        // a board-level mark beside pad 1 (world coordinates)+        let near = b.pads_of(&reference).iter().filter(|q| ["1", "K", "A"].contains(&q.name.as_str())).any(|q| silk_pts.iter().any(|s| ((s.0 - q.x).powi(2) + (s.1 - q.y).powi(2)).sqrt() <= q.half_long + 1.5));+        if !near { unmarked.push(reference.clone()); }+    }+    let list = |v: &[String]| format!("{}{}", v.iter().take(12).cloned().collect::<Vec<_>>().join(", "), if v.len() > 12 { ", ..." } else { "" });+    let mut out = Vec::new();+    if !fab_only.is_empty() { out.push(format!("{} reference(s) only on the fabrication layer, none on the silkscreen: {}. Put readable references on F.SilkS/B.SilkS (skill aiflow-silkscreen)", fab_only.len(), list(&fab_only))); }+    if !unmarked.is_empty() { out.push(format!("{} polarized part(s) with no silkscreen polarity or pin-1 mark: {}. Add a cathode bar, + sign or pin-1 dot beside the pad on the silkscreen (skill aiflow-silkscreen)", unmarked.len(), list(&unmarked))); }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    const BOARD: &str = r#"(kicad_pcb (version 20241229)+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+  (footprint "Diode_SMD:D_SOD-123" (layer "F.Cu") (at 10 10)+    (property "Reference" "D1" (at 0 -2) (layer "F.SilkS") (effects (font (size 1 1))))+    (fp_line (start -2.36 -1) (end 1.65 -1) (layer "F.SilkS")) (fp_line (start -2.36 -1) (end -2.36 1) (layer "F.SilkS")) (fp_line (start -2.36 1) (end 1.65 1) (layer "F.SilkS"))+    (pad "1" smd rect (at -1.65 0) (size 0.9 1.2) (layers "F.Cu")) (pad "2" smd rect (at 1.65 0) (size 0.9 1.2) (layers "F.Cu")))+  (footprint "Custom:DIODE" (layer "F.Cu") (at 20 10)+    (property "Reference" "D2" (at 0 -2) (layer "F.Fab") (effects (font (size 1 1))))+    (fp_rect (start -2 -1) (end 2 1) (layer "F.Fab"))+    (pad "1" smd rect (at -1.65 0) (size 0.9 1.2) (layers "F.Cu")) (pad "2" smd rect (at 1.65 0) (size 0.9 1.2) (layers "F.Cu")))+  (footprint "Resistor_SMD:R_0805" (layer "F.Cu") (at 30 10)+    (property "Reference" "R1" (at 0 -2) (layer "F.Fab") (effects (font (size 1 1))))+    (pad "1" smd rect (at -1 0) (size 1 1.2) (layers "F.Cu")) (pad "2" smd rect (at 1 0) (size 1 1.2) (layers "F.Cu")))+  (footprint "Package_SO:SOIC-8" (layer "F.Cu") (at 40 10)+    (property "Reference" "U1" (at 0 -4) (layer "F.SilkS") (effects (font (size 1 1))))+    (fp_poly (pts (xy -3 -2.5) (xy -3.3 -2.8) (xy -2.7 -2.8)) (layer "F.SilkS"))+    (pad "1" smd rect (at -2.7 -1.9) (size 1.5 0.6) (layers "F.Cu")) (pad "8" smd rect (at 2.7 -1.9) (size 1.5 0.6) (layers "F.Cu")))+  (footprint "MountingHole:MountingHole_3.2mm" (layer "F.Cu") (at 2 2)+    (property "Reference" "H1" (at 0 -4) (layer "F.Fab")) (pad "" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers "*.Cu")))+)"#;++    #[test]+    fn fab_only_refs_and_unmarked_polarized_parts_warn() {+        let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+        let w = warnings(&b);+        assert_eq!(w.len(), 2, "{w:?}");+        assert!(w[0].contains("2 reference(s) only on the fabrication layer") && w[0].contains("D2, R1"), "{w:?}");+        assert!(w[1].contains("1 polarized part(s)") && w[1].contains("D2"), "{w:?}");+        // a board-level + beside pad 1 marks D2; a board text naming the reference labels it+        let fixed = BOARD.replace("\n)", "\n  (gr_text \"+\" (at 18.2 11) (layer \"F.SilkS\") (effects (font (size 1 1))))\n  (gr_text \"D2 R1\" (at 25 14) (layer \"F.SilkS\") (effects (font (size 1 1))))\n)");+        let b = Board::from_text("t.kicad_pcb", fixed).unwrap();+        assert!(warnings(&b).is_empty(), "{:?}", warnings(&b));+    }+}
crates/adom-aiflow/src/silkproof.rs+135
@@ -81,9 +81,116 @@ pub fn geometry_gaps(input: &Value, board: &Board) -> Vec<String> {     if uncovered_vias > 0 {         why.push(format!("{uncovered_vias} of the board's vias have no via/hole obstacle box covering them"));     }+    why.extend(mark_gaps(input, board));     why } +/// A visible silkscreen text on the board, where it is and how big KiCad draws it.+struct SilkText { owner: Option<String>, kind: String, text: String, layer: String, x: f64, y: f64, w: f64, h: f64 }++/// KiCad's stroke font, as a lower bound: about 0.6 x the glyph width per character (0.3 for the narrow+/// ones), lines of the glyph height, plus the stroke. A box smaller than this cannot hold the text.+fn text_extent(text: &str, size_h: f64, size_w: f64, thick: f64, angle: f64) -> (f64, f64) {+    let lines: Vec<&str> = text.split('\n').collect();+    let per = |c: char| if "iljI1.,:;'!|` ".contains(c) { 0.3 } else { 0.6 };+    let wide = lines.iter().map(|l| l.chars().map(per).sum::<f64>()).fold(0.0, f64::max) * size_w + thick;+    let high = lines.len() as f64 * size_h + thick;+    let (s, c) = angle.to_radians().sin_cos();+    (wide * c.abs() + high * s.abs(), wide * s.abs() + high * c.abs())+}++fn silk_texts(board: &Board) -> Vec<SilkText> {+    use crate::provenance::{is_hidden, on_silk};+    let font = |n: &aiflow_board::sx::Node| {+        let f = n.find("effects").and_then(|e| e.find("font"));+        let size = f.and_then(|f| f.find("size"));+        let h = size.and_then(|s| s.number(1)).unwrap_or(1.0);+        let w = size.and_then(|s| s.number(2)).unwrap_or(h);+        let t = f.and_then(|f| f.find("thickness")).and_then(|t| t.number(1)).unwrap_or(0.15 * h);+        (h, w, t)+    };+    let mut out = Vec::new();+    for n in board.root.nodes() {+        match n.tag.as_str() {+            "footprint" => {+                let reference = aiflow_board::reference_of(n);+                let value = n.nodes().find(|p| p.tag == "property" && p.atom(1) == Some("Value")).and_then(|p| p.atom(2)).unwrap_or("").to_string();+                let at = n.find("at");+                let (fx, fy, fr) = (at.and_then(|a| a.number(1)).unwrap_or(0.0), at.and_then(|a| a.number(2)).unwrap_or(0.0), at.and_then(|a| a.number(3)).unwrap_or(0.0));+                for t in n.nodes().filter(|c| (c.tag == "property" || c.tag == "fp_text") && on_silk(c) && !is_hidden(c)) {+                    let raw = t.atom(2).unwrap_or("");+                    let text = raw.replace("${REFERENCE}", reference.as_deref().unwrap_or("")).replace("${VALUE}", &value);+                    if text.trim().is_empty() { continue; }+                    let ta = t.find("at");+                    let (dx, dy) = aiflow_board::geom::rot_kicad(ta.and_then(|a| a.number(1)).unwrap_or(0.0), ta.and_then(|a| a.number(2)).unwrap_or(0.0), fr);+                    let (h, w, th) = font(t);+                    let (ew, eh) = text_extent(&text, h, w, th, ta.and_then(|a| a.number(3)).unwrap_or(0.0));+                    out.push(SilkText { owner: reference.clone(), kind: t.atom(1).unwrap_or("").to_ascii_lowercase(), text, layer: t.find("layer").and_then(|l| l.value()).unwrap_or("").to_string(), x: fx + dx, y: fy + dy, w: ew, h: eh });+                }+            }+            "gr_text" if on_silk(n) && !is_hidden(n) => {+                let text = n.atom(1).unwrap_or("").to_string();+                if text.trim().is_empty() { continue; }+                let a = n.find("at");+                let (h, w, th) = font(n);+                let (ew, eh) = text_extent(&text, h, w, th, a.and_then(|a| a.number(3)).unwrap_or(0.0));+                out.push(SilkText { owner: None, kind: "text".into(), text, layer: n.find("layer").and_then(|l| l.value()).unwrap_or("").to_string(), x: a.and_then(|a| a.number(1)).unwrap_or(0.0), y: a.and_then(|a| a.number(2)).unwrap_or(0.0), w: ew, h: eh });+            }+            _ => {}+        }+    }+    out+}++/// A mark's boxes (`boxes`, else `box`), each normalised to (x0, y0, x1, y1).+fn mark_boxes(m: &Value) -> Vec<(f64, f64, f64, f64)> {+    let one = |b: &Value| { let v: Vec<f64> = b.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); (v.len() == 4).then(|| (v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]))) };+    match m["boxes"].as_array() { Some(a) => a.iter().filter_map(one).collect(), None => one(&m["box"]).into_iter().collect() }+}++/// Label (mark) boxes checked against the board's own silk text (issue #58): a mark is the board text it+/// names (`text`), else the text whose anchor its box holds, else (by id) its footprint's reference or value+/// text on that layer; its box must be at least as large as that text's extent from the board's size,+/// length, rotation and stroke. Marks that match no board text (leaders, frames) are not judged here.+pub fn mark_gaps(input: &Value, board: &Board) -> Vec<String> {+    let marks = input["marks"].as_array().cloned().unwrap_or_default();+    if marks.is_empty() { return Vec::new(); }+    let texts = silk_texts(board);+    let mut short = Vec::new();+    for m in &marks {+        let boxes = mark_boxes(m);+        if boxes.is_empty() { continue; }+        let u = boxes.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, b| (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3)));+        let layer = m["layer"].as_str();+        let id = m["id"].as_str().unwrap_or("");+        let on_layer: Vec<&SilkText> = texts.iter().filter(|t| layer.map(|l| l == t.layer).unwrap_or(true)).collect();+        let owner = board.footprints.keys().filter(|r| covers(id, r)).max_by_key(|r| r.len()).cloned();+        let mut matched: Vec<&SilkText> = match m["text"].as_str() {+            Some(want) => {+                let same: Vec<&SilkText> = on_layer.iter().copied().filter(|t| t.text == want).collect();+                let mine: Vec<&SilkText> = same.iter().copied().filter(|t| owner.is_some() && t.owner == owner).collect();+                if mine.is_empty() { same } else { mine }+            }+            None => on_layer.iter().copied().filter(|t| inside((t.x, t.y), u)).collect(),+        };+        if matched.is_empty() && m["text"].is_null() {+            if let Some(o) = &owner {+                let want = if id.to_ascii_lowercase().contains("val") { "value" } else { "reference" };+                matched = on_layer.iter().copied().filter(|t| t.owner.as_ref() == Some(o) && t.kind == want).collect();+            }+        }+        let Some(t) = matched.into_iter().min_by(|a, b| (a.w * a.h).total_cmp(&(b.w * b.h))) else { continue };+        let (bw, bh) = (u.2 - u.0, u.3 - u.1);+        let (bmin, bmax) = (bw.min(bh), bw.max(bh));+        let (tmin, tmax) = (t.w.min(t.h), t.w.max(t.h));+        if bmin < tmin - TOL || bmax < tmax - TOL {+            short.push(format!("{id} {bw:.2} x {bh:.2} mm for \"{}\" (at least {:.2} x {:.2} mm)", t.text.replace('\n', " "), t.w, t.h));+        }+    }+    if short.is_empty() { return Vec::new(); }+    vec![format!("{} label (mark) box(es) are smaller than the board's own silkscreen text ({}{}): label boxes must be the rendered text extents measured from the board (size, length, rotation and stroke)", short.len(), short.iter().take(8).cloned().collect::<Vec<_>>().join("; "), if short.len() > 8 { "; ..." } else { "" })]+}+ #[cfg(test)] mod tests {     use super::*;@@ -135,6 +242,34 @@ mod tests {         assert!(why.contains("1 of the board's vias"), "{why}");     } +    #[test]+    fn label_boxes_smaller_than_the_board_text_are_refused() {+        // R1's reference is 1 mm text at (2, 1); C1's value "1u" at (6, 3); a 0.6 mm square cannot hold either+        let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+        let base = json!({"inventory": [{"id": "R1", "kind": "body"}, {"id": "C1", "kind": "body"}, {"id": "v1", "kind": "via"}],+            "obstacles": [{"id": "R1", "kind": "body", "box": [1.4, 1.4, 2.6, 2.6]}, {"id": "C1", "kind": "body", "box": [5.4, 1.4, 6.6, 2.6]}, {"id": "v1", "kind": "via", "box": [4.7, 4.7, 5.3, 5.3]}]});+        let with = |marks: Value| { let mut v = base.clone(); v["marks"] = marks; v };+        let tiny = with(json!([{"id": "R1:ref", "layer": "F.SilkS", "box": [1.7, 0.7, 2.3, 1.3]}, {"id": "C1:value", "layer": "F.SilkS", "box": [5.7, 2.7, 6.3, 3.3]}]));+        let why = geometry_gaps(&tiny, &b).join("; ");+        assert!(why.contains("2 label (mark) box(es) are smaller") && why.contains("\"R1\"") && why.contains("\"1u\""), "{why}");+        assert!(proof(&json!({"passed": true}), &tiny, &b).is_err());+        // boxes as large as the text pass; a box placed away from the text is still matched by id+        let real = with(json!([{"id": "R1:ref", "layer": "F.SilkS", "box": [1.2, 0.35, 2.8, 1.65]}, {"id": "C1:value", "layer": "F.SilkS", "boxes": [[5.2, 2.35, 6.0, 3.65], [6.0, 2.35, 6.8, 3.65]]}]));+        assert!(geometry_gaps(&real, &b).is_empty(), "{:?}", geometry_gaps(&real, &b));+        let away = with(json!([{"id": "R1-ref", "layer": "F.SilkS", "box": [8.0, 8.0, 8.5, 8.5]}]));+        assert!(geometry_gaps(&away, &b).join("; ").contains("R1-ref 0.50 x 0.50 mm"));+        // a mark that names its text is judged against that text; one that matches nothing is not judged+        let named = with(json!([{"id": "lbl", "text": "10k", "layer": "F.SilkS", "box": [0.0, 0.0, 0.5, 0.5]}, {"id": "frame", "layer": "F.SilkS", "box": [8.0, 8.0, 8.2, 8.2]}]));+        let why = geometry_gaps(&named, &b).join("; ");+        assert!(why.contains("1 label") && why.contains("\"10k\""), "{why}");+        // rotation: a 90 degree text needs a tall box+        let rot = BOARD.replace("(property \"Reference\" \"R1\" (at 0 -1)", "(property \"Reference\" \"R1\" (at 0 -1 90)");+        let b90 = Board::from_text("t.kicad_pcb", rot).unwrap();+        let tall = with(json!([{"id": "R1:ref", "layer": "F.SilkS", "box": [1.35, 0.2, 2.65, 1.8]}]));+        assert!(geometry_gaps(&tall, &b90).is_empty());+        assert!(text_extent("R1234", 1.0, 1.0, 0.15, 90.0).1 > text_extent("R1234", 1.0, 1.0, 0.15, 90.0).0);+    }+     #[test]     fn a_bare_board_is_not_a_silkscreen() {         let bare = BOARD.replace("(layer \"F.SilkS\")", "(layer \"F.Fab\")");
crates/adom-aiflow/src/skill_hints.rsadded+199
@@ -0,0 +1,199 @@+//! Skill files by absolute path in the Hint lines (issue #56). An engine without a skill loader+//! (Antigravity has no skills directory) never sees ~/.claude/skills, so it reverse-engineered the+//! binary to learn an input format. When a step starts (`step <name>`) and when a command fails,+//! the hints name the SKILL.md to read by absolute path, the installed copy and the package's own+//! copy, and the section that holds the input schema.+//!+//! The step-to-skill map is `skills` in flows/board.json (the Companion skills table of SKILL.md);+//! the command-to-section map is `COMMANDS` below. Both are checked against the shipped skill files+//! by the tests, so a renamed heading fails the build's tests instead of sending an AI to nothing.++use std::path::{Path, PathBuf};++use serde_json::Value;++/// A command (its first word, or its first two words) and where its input is described:+/// (command, skill, section heading in that skill's SKILL.md, a doc in the package or "").+pub const COMMANDS: &[(&str, &str, &str, &str)] = &[+    ("silkscreen-audit", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-layout", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-sections", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-preflight", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-contact-policy", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-mask", "aiflow-silkscreen", "Input schemas", ""),+    ("silkscreen-dashboard", "aiflow-silkscreen", "Inputs and live observation", ""),+    ("analyze current", "aiflow-power-layout", "Input schemas", "docs/plan-schema.md"),+    ("analyze thermal", "aiflow-power-layout", "Input schemas", "docs/plan-schema.md"),+    ("verify-pours", "aiflow-power-layout", "Input schemas", "docs/existing-pours.md"),+    ("gate", "aiflow-power-layout", "Input schemas", "docs/plan-schema.md"),+    ("land route", "aiflow-power-layout", "Input schemas", "docs/plan-schema.md"),+    ("route", "aiflow-power-layout", "3. Routing: keep B.Cu a ground plane under the hot loop", "docs/plan-schema.md"),+    ("pour", "aiflow-power-layout", "4. Pours: small SW, solid heat paths", ""),+    ("thermal-vias", "aiflow-power-layout", "Local thermal-via planning", "docs/thermal-via-acknowledgements.md"),+    ("place", "aiflow-power-layout", "Input schemas", ""),+    ("sourcing", "aiflow-sourcing", "Input schema: the BOM CSV", ""),+    ("parts", "aiflow-sourcing", "Input schema: the BOM CSV", ""),+    ("models", "aiflow-sourcing", "6. Check every model", ""),+    ("evidence", "aiflow-circuit-design", "Input schema: evidence files", ""),+    ("schematic", "aiflow-schematic-to-board", "Schematic check: symbols, pin types, readability", ""),+    ("netlist", "aiflow-schematic-to-board", "6. Verify: ERC and netlist equivalence", ""),+    ("export", "aiflow-schematic-to-board", "5. kicad-cli from the container", ""),+    ("step-export", "aiflow-molecule", "5. Export, convert, publish", ""),+    ("board-glb", "aiflow-molecule", "5. Export, convert, publish", ""),+    ("molecule", "aiflow-molecule", "4. Conformance check before placement", ""),+    ("testplan", "aiflow-test-plan", "2. Give every measurement a spot (before the schematic)", ""),+    ("intake", "aiflow-intake", "5. Write requirements.json", ""),+    ("start", "adom-aiflow", "The spec", ""),+    ("capture", "aiflow-live-clips", "3. Mechanics that bite", ""),+    ("clips", "aiflow-live-clips", "2. After every step", ""),+    ("compose", "aiflow-process-video", "Compose", ""),+    ("recut", "aiflow-process-video", "Review every clip before assembling", ""),+    ("project-page", "aiflow-project-page", "", ""),+];++/// Where the package's own files are: $ADOM_AIFLOW_HOME, else an `adom_modules/adom/adom-aiflow`+/// found walking up from `cwd`, else one under `home` (or `home/project`).+pub fn package_root(home: &Path, cwd: &Path) -> Option<PathBuf> {+    if let Some(p) = std::env::var_os("ADOM_AIFLOW_HOME").map(PathBuf::from).filter(|p| p.join("skills").is_dir()) { return Some(p); }+    let rel = Path::new("adom_modules").join("adom").join("adom-aiflow");+    for d in cwd.ancestors() {+        let c = d.join(&rel);+        if c.join("skills").is_dir() { return Some(c); }+    }+    [home.join(&rel), home.join("project").join(&rel)].into_iter().find(|c| c.join("skills").is_dir())+}++/// The installed SKILL.md (~/.claude/skills, then ~/.codex/skills) and the package's copy, both+/// absolute; the installed path is named even when it is missing, so the line says where it belongs.+pub fn skill_files(skill: &str, home: &Path, pkg: Option<&Path>) -> (PathBuf, Option<PathBuf>) {+    let claude = home.join(".claude").join("skills").join(skill).join("SKILL.md");+    let codex = home.join(".codex").join("skills").join(skill).join("SKILL.md");+    let installed = if claude.is_file() || !codex.is_file() { claude } else { codex };+    let packaged = pkg.map(|p| p.join("skills").join(skill).join("SKILL.md")).filter(|p| p.is_file());+    (installed, packaged)+}++fn where_line(skill: &str, section: &str, doc: &str, home: &Path, pkg: Option<&Path>) -> String {+    let (installed, packaged) = skill_files(skill, home, pkg);+    let mut s = format!("read {}", installed.display());+    if !installed.is_file() { s.push_str(" (not installed here)"); }+    if let Some(p) = packaged { if p != installed { s.push_str(&format!(" or {}", p.display())); } }+    if !section.is_empty() { s.push_str(&format!(", section \"{section}\"")); }+    if !doc.is_empty() {+        match pkg.map(|p| p.join(doc)).filter(|p| p.is_file()) {+            Some(d) => s.push_str(&format!("; also {}", d.display())),+            None => s.push_str(&format!("; also {doc} in the adom-aiflow package")),+        }+    }+    s+}++/// The skills a step needs, from flows/board.json `skills` (step name to a list of skill names).+pub fn step_skills(flow: &Value, step: &str) -> Vec<String> {+    flow["skills"][step].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()+}++/// The `COMMANDS` entry for a command line as typed (flags and the thread already stripped): the+/// two-word form first (`analyze thermal`), then the first word.+pub fn command_entry(cmdline: &str) -> Option<(&'static str, &'static str, &'static str, &'static str)> {+    let words: Vec<&str> = cmdline.split_whitespace().filter(|w| !w.starts_with('-')).collect();+    let first = *words.first()?;+    let two = words.get(1).map(|w| format!("{first} {w}"));+    two.as_deref().and_then(|t| COMMANDS.iter().find(|c| c.0 == t)).or_else(|| COMMANDS.iter().find(|c| c.0 == first)).copied()+}++/// The Hint lines for `step <name>`: the main skill, then each companion skill of the step.+pub fn step_hints(flow: &Value, step: &str, home: &Path, pkg: Option<&Path>) -> Vec<String> {+    let skills = step_skills(flow, step);+    let mut out = Vec::new();+    if skills.is_empty() {+        out.push(format!("Skill for this step: no companion skill; the flow is in the main skill: {}.", where_line("adom-aiflow", "", "", home, pkg)));+        return out;+    }+    for s in &skills {+        out.push(format!("Skill for {step}: {s}: {}. Engines without a skill loader: open that file now, before the step's first command.", where_line(s, "", "", home, pkg)));+    }+    out+}++/// The Hint line for a failed command: where its input is described.+pub fn command_hint(cmdline: &str, home: &Path, pkg: Option<&Path>) -> Option<String> {+    let (cmd, skill, section, doc) = command_entry(cmdline)?;+    Some(format!("Input of `{cmd}` (skill {skill}): {}.", where_line(skill, section, doc, home, pkg)))+}++/// `home` and the package root for this process.+pub fn here() -> (PathBuf, Option<PathBuf>) {+    let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("/"));+    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));+    let pkg = package_root(&home, &cwd);+    (home, pkg)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn repo() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap() }++    fn headings(skill: &str) -> Vec<String> {+        let t = std::fs::read_to_string(repo().join("skills").join(skill).join("SKILL.md")).unwrap_or_else(|e| panic!("{skill}: {e}"));+        t.lines().filter(|l| l.starts_with('#')).map(|l| l.trim_start_matches('#').trim().to_string()).collect()+    }++    #[test]+    fn every_command_section_and_doc_exists_in_the_shipped_skills() {+        for (cmd, skill, section, doc) in COMMANDS {+            let h = headings(skill);+            assert!(section.is_empty() || h.iter().any(|x| x == section), "{cmd}: no heading \"{section}\" in {skill}: {h:?}");+            assert!(doc.is_empty() || repo().join(doc).is_file(), "{cmd}: no {doc}");+        }+    }++    #[test]+    fn every_flow_step_names_shipped_skills() {+        let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+        let map = flow["skills"].as_object().expect("flows/board.json has a skills map");+        let mut steps: Vec<String> = flow["steps"].as_array().unwrap().iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect();+        steps.extend(crate::preboard::PREBOARD_STEPS.iter().map(|s| s.to_string()));+        for st in &steps { assert!(map.contains_key(st), "flows/board.json skills has no entry for step {st}"); }+        for (st, list) in map.iter().filter(|(k, _)| k.as_str() != "_") {+            for s in list.as_array().unwrap() {+                let s = s.as_str().unwrap();+                assert!(repo().join("skills").join(s).join("SKILL.md").is_file(), "step {st}: skill {s} is not shipped");+            }+        }+    }++    #[test]+    fn hints_name_absolute_paths_installed_and_packaged() {+        let tmp = std::env::temp_dir().join(format!("aiflow-skillhints-{}", std::process::id()));+        let home = tmp.join("home");+        let pkg = tmp.join("work").join("adom_modules").join("adom").join("adom-aiflow");+        std::fs::create_dir_all(home.join(".claude/skills/aiflow-silkscreen")).unwrap();+        std::fs::write(home.join(".claude/skills/aiflow-silkscreen/SKILL.md"), "# x").unwrap();+        std::fs::create_dir_all(pkg.join("skills/aiflow-silkscreen")).unwrap();+        std::fs::write(pkg.join("skills/aiflow-silkscreen/SKILL.md"), "# x").unwrap();+        std::fs::create_dir_all(pkg.join("docs")).unwrap();+        std::fs::write(pkg.join("docs/plan-schema.md"), "# x").unwrap();+        // found walking up from a run directory below the project+        let cwd = tmp.join("work").join("runs").join("r1");+        std::fs::create_dir_all(&cwd).unwrap();+        let found = package_root(&home, &cwd);+        assert_eq!(found.as_deref(), Some(pkg.as_path()));+        let h = command_hint("silkscreen-audit --input a.json --out b.json", &home, found.as_deref()).unwrap();+        assert!(h.contains(&home.join(".claude/skills/aiflow-silkscreen/SKILL.md").display().to_string()), "{h}");+        assert!(h.contains(&pkg.join("skills/aiflow-silkscreen/SKILL.md").display().to_string()), "{h}");+        assert!(h.contains("section \"Input schemas\""), "{h}");+        // two-word commands; a doc in the package by absolute path; a skill not installed is said+        let h = command_hint("analyze thermal --plan p.json", &home, found.as_deref()).unwrap();+        assert!(h.contains("aiflow-power-layout") && h.contains("(not installed here)") && h.contains(&pkg.join("docs/plan-schema.md").display().to_string()), "{h}");+        assert!(command_hint("ledger", &home, None).is_none());+        let flow = serde_json::json!({"skills": {"silkscreen": ["aiflow-silkscreen"]}});+        let s = step_hints(&flow, "silkscreen", &home, found.as_deref());+        assert_eq!(s.len(), 1);+        assert!(s[0].starts_with("Skill for silkscreen: aiflow-silkscreen: read /"), "{s:?}");+        assert!(step_hints(&flow, "capture", &home, None)[0].contains("adom-aiflow"));+        let _ = std::fs::remove_dir_all(&tmp);+    }+}
crates/adom-aiflow/src/spec_freeze.rs+1−1
@@ -13,7 +13,7 @@ use std::path::Path; /// Layout keys (pours, thermalVias, fixed coordinates, planeUnder) stay editable: they describe the board. pub const BUDGET_KEYS: &[&str] = &[     "loads", "hot", "hotMaxRiseC", "maxRiseC", "switchNodeMaxLengthMm", "switchNodes", "wideNets", "midNets",-    "fab", "copperUm", "copperUmByLayer", "viaPower", "drillPower", "branchCurrentModel", "packageCooling",+    "fab", "copperUm", "copperUmByLayer", "viaPower", "drillPower", "branchCurrentModel", "packageCooling", "decoupling", ];  pub const FILE: &str = "spec-budgets.json";
crates/adom-aiflow/src/spec_nets.rsadded+35
@@ -0,0 +1,35 @@+//! Spec net names against the run's board (issue #51): a spec that says `GND` on a board whose net is+//! `/GND` (a hierarchical schematic's root-sheet net) means that net, the way `netlist check` already+//! treats `/VIN` and `VIN` as one. Only an unambiguous match moves a name: when the board has both+//! `GND` and `/GND`, the exact name wins. The spec file on disk is never rewritten, so its hash, the+//! frozen budgets and every proof bound to it stay as they were.++use serde_json::Value;++/// `spec` with its net names in the board's spelling (see `aiflow_board::netname::align_spec`).+/// A board that does not read leaves the spec as written.+pub fn align(mut spec: Value, board_path: &str) -> Value {+    if board_path.is_empty() || !spec.is_object() { return spec; }+    let Ok(text) = std::fs::read_to_string(board_path) else { return spec };+    let names = aiflow_board::netname::names_in_text(&text);+    aiflow_board::netname::align_spec(&mut spec, &names);+    spec+}++#[cfg(test)]+mod tests {+    #[test]+    fn spec_gnd_reads_as_the_board_slash_gnd() {+        let dir = std::env::temp_dir().join(format!("aiflow-spec-nets-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let b = dir.join("b.kicad_pcb");+        std::fs::write(&b, r#"(kicad_pcb (net 0 "") (net 1 "/GND") (net 2 "/VIN"))"#).unwrap();+        let s = super::align(serde_json::json!({"pours": [{"net": "GND"}], "hot": {"U1": {"tabNet": "GND"}}, "wideNets": {"VIN": [1.0]}}), b.to_str().unwrap());+        assert_eq!(s["pours"][0]["net"], "/GND");+        assert_eq!(s["hot"]["U1"]["tabNet"], "/GND");+        assert!(s["wideNets"].get("/VIN").is_some());+        // no board yet (an intake run): unchanged+        assert_eq!(super::align(serde_json::json!({"pours": [{"net": "GND"}]}), "")["pours"][0]["net"], "GND");+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/step_export.rsadded+182
@@ -0,0 +1,182 @@+//! The native STEP export of the board twin, with its diagnostics kept (issue #39, reply 2).+//!+//! KiCad's `pcb export step` can exit 0 and write the file while printing geometry warnings such as+//! `Wire self-interference check failed` (followed by a `z: ...; bounding box: ...` line). The+//! exit status says the file exists, not that every face made it. So the output (stdout and+//! stderr) is kept and classified apart from the exit status:+//! - geometry: the exporter could not build a shape cleanly (self-interference, failed outlines);+//! - model: a 3D model was not found or not loaded;+//! - other: any other warning or error line.+//! Geometry warnings block the final twin delivery (`board-glb copper` in a run and `deliver`)+//! until someone reviews the exported STEP and records `step-export --ack --why "..."`, which binds+//! to that STEP file's hash. The export always includes every layer; it never drops silkscreen,+//! mask or zones to make a warning go away.++use aiflow_board::Board;+use serde_json::{json, Value};+use std::path::Path;++/// The full twin: models, tracks, pads, zones, mask and silkscreen. Never trimmed.+pub const LAYER_FLAGS: &[&str] = &["--subst-models", "--include-tracks", "--include-pads", "--include-zones", "--include-soldermask", "--include-silkscreen"];++#[derive(Debug, Clone, PartialEq)]+pub struct Diag { pub kind: &'static str, pub message: String, pub location: Option<[f64; 5]> }++fn kind_of(line: &str) -> Option<&'static str> {+    let l = line.to_ascii_lowercase();+    let geometry = ["self-interference", "self-intersect", "self intersect", "failed to build", "failed to create", "failed to make", "cannot create", "could not create", "unable to create", "invalid shape", "invalid outline", "outline is not closed", "brep", "failed to fix"];+    let model = ["3d model", "model not found", "cannot load model", "could not load", "cannot find", "could not find", "file not found", "no such file", "cannot open"];+    if geometry.iter().any(|k| l.contains(k)) { return Some("geometry"); }+    if model.iter().any(|k| l.contains(k)) { return Some("model"); }+    if l.contains("warning") || l.contains("error") || l.contains("fail") { return Some("other"); }+    None+}++/// `z: 1.585; bounding box: x0: 130.1; y0: 86.5; x1: 130.27; y1: 87.45` as [z, x0, y0, x1, y1].+pub fn location(line: &str) -> Option<[f64; 5]> {+    let l = line.trim();+    if !l.starts_with("z:") || !l.contains("bounding box") { return None; }+    let num = |key: &str| -> Option<f64> {+        let i = l.find(key)? + key.len();+        l[i..].trim_start().split(|c: char| c == ';' || c.is_whitespace()).next()?.parse().ok()+    };+    Some([num("z:")?, num("x0:")?, num("y0:")?, num("x1:")?, num("y1:")?])+}++/// Every warning line of an export's output, with the location line that follows it.+pub fn classify(output: &str) -> Vec<Diag> {+    let mut out: Vec<Diag> = Vec::new();+    for raw in output.lines() {+        let line = raw.trim();+        if line.is_empty() { continue; }+        if let Some(loc) = location(line) {+            if let Some(d) = out.last_mut() { if d.location.is_none() { d.location = Some(loc); continue; } }+            continue;+        }+        if let Some(k) = kind_of(line) { out.push(Diag { kind: k, message: line.to_string(), location: None }); }+    }+    out+}++/// The footprints nearest a warning's box (KiCad prints board coordinates), nearest first.+pub fn nearby(b: &Board, loc: &[f64; 5], n: usize) -> Vec<Value> {+    let (cx, cy) = ((loc[1] + loc[3]) / 2.0, (loc[2] + loc[4]) / 2.0);+    let mut d: Vec<(f64, &String)> = b.footprints.iter().map(|(r, f)| ((f.x - cx).hypot(f.y - cy), r)).collect();+    d.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));+    d.into_iter().take(n).map(|(dist, r)| json!({"ref": r, "distanceMm": (dist * 100.0).round() / 100.0})).collect()+}++/// The run's record of one export.+pub fn record(board_path: &str, step: Option<&Path>, exit_code: Option<i32>, output: &str, command: &str) -> Value {+    let diags = classify(output);+    let board = Board::load(board_path).ok();+    let list: Vec<Value> = diags.iter().map(|d| json!({+        "kind": d.kind, "message": d.message,+        "location": d.location.map(|l| json!({"z": l[0], "x0": l[1], "y0": l[2], "x1": l[3], "y1": l[4]})),+        "nearbyFootprints": match (&board, d.location) { (Some(b), Some(l)) => json!(nearby(b, &l, 3)), _ => Value::Null },+    })).collect();+    let count = |k: &str| diags.iter().filter(|d| d.kind == k).count();+    let step_ok = step.map(|p| p.is_file()).unwrap_or(false);+    json!({+        "board": board_path, "boardSha256": crate::sha256_file(board_path),+        "step": step.map(|p| p.display().to_string()), "stepSha256": if step_ok { json!(crate::sha256_file(&step.unwrap().display().to_string())) } else { Value::Null },+        "exitCode": exit_code, "stepWritten": step_ok, "command": command, "layers": LAYER_FLAGS,+        "diagnostics": list, "geometryWarnings": count("geometry"), "modelWarnings": count("model"), "otherWarnings": count("other"),+        "ack": Value::Null,+        "note": "exit status, file presence and diagnostics are separate facts: exit 0 with geometry warnings still needs a completeness review of the STEP",+    })+}++/// Why the twin may not be delivered yet, if it may not.+pub fn open_issue(rec: &Value) -> Option<String> {+    if rec.is_null() { return None; }+    if rec["exitCode"].as_i64().map(|c| c != 0).unwrap_or(false) || rec["stepWritten"] == false {+        return Some(format!("the native STEP export failed (exit {}, file written {})", rec["exitCode"], rec["stepWritten"]));+    }+    let g = rec["geometryWarnings"].as_u64().unwrap_or(0);+    if g == 0 { return None; }+    let acked = rec["ack"]["stepSha256"].as_str().is_some() && rec["ack"]["stepSha256"] == rec["stepSha256"];+    if acked { return None; }+    Some(format!("{g} STEP geometry warning(s) not reviewed ({}): inspect the exported STEP at those locations, then `step-export --ack --why \"...\"`", rec["diagnostics"].as_array().map(|a| a.iter().filter(|d| d["kind"] == "geometry").take(3).map(|d| format!("{} near {}", d["message"].as_str().unwrap_or(""), d["nearbyFootprints"].as_array().map(|n| n.iter().filter_map(|x| x["ref"].as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default())).collect::<Vec<_>>().join("; ")).unwrap_or_default()))+}++/// Run the native export into `out` and record it.+pub fn run(board_path: &str, out: &Path, cli: &str) -> Value {+    let _ = std::fs::remove_file(out);+    let mut args: Vec<String> = vec!["pcb".into(), "export".into(), "step".into()];+    args.extend(LAYER_FLAGS.iter().map(|s| s.to_string()));+    args.extend(["--output".to_string(), out.display().to_string(), board_path.to_string()]);+    let command = format!("{cli} {}", args.join(" "));+    match std::process::Command::new(cli).args(&args).output() {+        Ok(o) => {+            let text = format!("{}\n{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr));+            let mut rec = record(board_path, Some(out), o.status.code(), &text, &command);+            rec["outputTail"] = json!(text.chars().rev().take(4000).collect::<String>().chars().rev().collect::<String>());+            rec+        }+        Err(e) => { let mut rec = record(board_path, Some(out), None, "", &command); rec["error"] = json!(format!("{cli} is not runnable: {e}")); rec }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    /// The Board32 output from the issue's reply (abridged): exit 0, file written, six geometry warnings.+    const BOARD32: &str = "Wire self-interference check failed\r\nz: 1.585; bounding box: x0: 130.149928; y0: 86.550577; x1: 130.27; y1: 87.450463\r\nWire self-interference check failed\r\nz: 1.585; bounding box: x0: 122.877967; y0: 85.800577; x1: 124.52; y1: 86.92\r\nCould not add 3D model to U9.\r\nSTEP file 'C:/x/board32-mechanical.step' created.\r\n\r\nExport time 17.855 s\r\n";++    #[test]+    fn warnings_are_classified_with_their_locations_apart_from_the_exit_status() {+        let d = classify(BOARD32);+        assert_eq!(d.len(), 3, "{d:?}");+        assert_eq!(d[0].kind, "geometry");+        assert_eq!(d[0].location, Some([1.585, 130.149928, 86.550577, 130.27, 87.450463]));+        assert_eq!(d[2].kind, "model");+        assert!(classify("STEP file 'a.step' created.\nExport time 1 s\n").is_empty());+    }++    #[test]+    fn geometry_warnings_need_an_ack_bound_to_the_step_file() {+        let dir = std::env::temp_dir().join(format!("aiflow-step-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let board = dir.join("b.kicad_pcb");+        std::fs::write(&board, "(kicad_pcb (version 20240108) (generator \"t\") (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal) (44 \"Edge.Cuts\" user))\n (footprint \"T:U\" (layer \"F.Cu\") (at 129.3 87) (property \"Reference\" \"U3\"))\n (gr_rect (start 100 60) (end 150 100) (layer \"Edge.Cuts\")))").unwrap();+        let step = dir.join("b.step");+        std::fs::write(&step, "ISO-10303-21;").unwrap();+        let mut rec = record(board.to_str().unwrap(), Some(&step), Some(0), BOARD32, "kicad-cli pcb export step");+        assert_eq!(rec["geometryWarnings"], 2);+        assert_eq!(rec["diagnostics"][0]["nearbyFootprints"][0]["ref"], "U3");+        let open = open_issue(&rec).unwrap();+        assert!(open.contains("2 STEP geometry warning") && open.contains("U3"), "{open}");+        // an ack for another STEP does not count; one for this STEP does+        rec["ack"] = json!({"why": "looked", "stepSha256": "0000"});+        assert!(open_issue(&rec).is_some());+        rec["ack"] = json!({"why": "silkscreen faces present in the viewer at all six spots", "stepSha256": rec["stepSha256"].clone()});+        assert!(open_issue(&rec).is_none());+        // a failed export is never acked away+        let failed = record(board.to_str().unwrap(), Some(&dir.join("none.step")), Some(1), "", "x");+        assert!(open_issue(&failed).unwrap().contains("failed"));+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[cfg(unix)]+    #[test]+    fn run_keeps_the_full_layer_set_and_reads_both_streams() {+        use std::os::unix::fs::PermissionsExt;+        let dir = std::env::temp_dir().join(format!("aiflow-step-run-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let board = dir.join("b.kicad_pcb");+        std::fs::write(&board, "(kicad_pcb (version 20240108) (generator \"t\") (layers (0 \"F.Cu\" signal)))").unwrap();+        let cli = dir.join("kicad-cli");+        std::fs::write(&cli, "#!/bin/sh\nout=\"\"; prev=\"\"; for a in \"$@\"; do [ \"$prev\" = \"--output\" ] && out=\"$a\"; prev=\"$a\"; done\necho \"$@\" > \"$out.args\"\necho 'Wire self-interference check failed'\necho 'z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4' >&2\necho STEP > \"$out\"\n").unwrap();+        std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();+        let out = dir.join("b.step");+        let rec = run(board.to_str().unwrap(), &out, cli.to_str().unwrap());+        assert_eq!(rec["exitCode"], 0);+        assert_eq!(rec["stepWritten"], true);+        assert_eq!(rec["geometryWarnings"], 1);+        let args = std::fs::read_to_string(dir.join("b.step.args")).unwrap();+        for f in LAYER_FLAGS { assert!(args.contains(f), "{args}"); }+        let _ = std::fs::remove_dir_all(&dir);+    }+}
crates/adom-aiflow/src/thermalvias.rs+28−2
@@ -128,8 +128,11 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a             if on > 0 && (!point_in(&b.outline, c) || (0..on).any(|k| seg_dist(c, b.outline[k], b.outline[(k + 1) % on]) < rr + edge)) { continue; }             // clear of other nets' pads and tracks, of every via, and of every part's pads (no via in a pad)             // a pad reaches its corners, not just half its long side: the half_long circle let a via sit-            // 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused (issue #74)-            if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long.hypot(q.half_short) + rr + clearance) { continue; }+            // 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused (issue #74). The+            // half sizes are the pad's own (unrotated) bounding box, so the circle of its half diagonal holds+            // the pad at any rotation; it is centred on that box (polys_centre), not the pad origin, so an+            // offset pad is held too.+            if b.pads.iter().any(|q| (q.polys_centre.0 - c.0).hypot(q.polys_centre.1 - c.1) < q.half_long.hypot(q.half_short) + rr + clearance) { continue; }             if tracks.iter().any(|t| t.net != net && seg_dist(c, t.a, t.b) < t.width / 2.0 + rr + clearance) { continue; }             if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; }             if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; }@@ -324,6 +327,29 @@ mod tests {         assert_eq!(super::target(2.0, 10.0), 16);     }     #[test]+    fn planned_vias_clear_the_corners_of_turned_and_offset_pads() {+        // issue #74: a via 0.145 mm from a pad corner on a 0.15 mm board. Square pads turned 30 degrees+        // ring U1 (their corners reach past half their side), and Q1's pad sits off its footprint origin+        let ring: String = (0..8).map(|k| { let a = k as f64 * std::f64::consts::FRAC_PI_4; format!(r#"(footprint "y" (layer "F.Cu") (at {} {} 30) (property "Reference" "R{k}") (pad "1" smd rect (at 0 0 30) (size 1.6 1.6) (layers "F.Cu") (net 2 "SW")))"#, 10.0 + 3.3 * a.cos(), 10.0 + 3.3 * a.sin()) }).collect();+        let b = aiflow_board::Board::from_text("fixture", r#"(kicad_pcb (net 0 "") (net 1 "GND") (net 2 "SW") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")))+ {ring}+ (footprint "z" (layer "F.Cu") (at 10 12) (property "Reference" "Q1") (pad "1" smd custom (at 0 0) (size 0.4 0.4) (layers "F.Cu") (net 2 "SW")+   (primitives (gr_poly (pts (xy -0.2 0.3) (xy 0.6 0.3) (xy 0.6 0.9) (xy -0.2 0.9)) (width 0) (fill yes))))))"#.replace("{ring}", &ring)).unwrap();+        let spec = serde_json::json!({"hot": {"U1": {"watts": 2.0, "tabNet": "GND"}}, "clearance": 0.15,+            "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+        let (parts, _) = super::plan(&spec, &b, &[]);+        assert!(!parts[0].planned.is_empty(), "{:?} {:?}", parts[0].planned, parts[0].note);+        for c in &parts[0].planned {+            for q in b.pads.iter().filter(|q| q.net == "SW") {+                for poly in &q.polys {+                    let gap = (0..poly.len()).map(|k| super::seg_dist(*c, poly[k], poly[(k + 1) % poly.len()])).fold(f64::MAX, f64::min);+                    assert!(!super::point_in(poly, *c) && gap + 1e-6 >= 0.3 + 0.15, "via {c:?} is {gap:.3} mm from {}'s copper", q.key);+                }+            }+        }+    }+    #[test]     fn plan_keeps_hole_to_hole_from_existing_holes() {         // U1's GND pad at (10, 10) with a GND via of another part right beside it and a through-hole pad         let b = aiflow_board::Board::from_text("fixture", r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 20 20)(layer "Edge.Cuts"))
crates/adom-aiflow/src/verified.rsadded+124
@@ -0,0 +1,124 @@+//! Honest stage messages (issue #39, item 12). A stage that passes says exactly what was verified,+//! item by item, and never implies the board can go to the fab without a human looking at it.+//! Electrical parity plus DRC is not manufacturing readiness: courtyards, body clearance and the+//! pours in the saved board and in the Gerbers are separate facts, each yes, no or not known.++use serde_json::{json, Map, Value};++#[derive(Debug, Clone, PartialEq)]+pub enum State { Yes, No(String), Unknown(String) }++#[derive(Debug, Clone)]+pub struct Item { pub name: &'static str, pub state: State }++fn yes_no(pass: Option<bool>, no: &str, unknown: &str) -> State {+    match pass { Some(true) => State::Yes, Some(false) => State::No(no.into()), None => State::Unknown(unknown.into()) }+}++/// What the run has verified so far. `mech` (courtyards ok, bodies ok) and `saved_fills` are+/// computed by the caller on the board in hand; the rest comes from the run's recorded outcomes.+pub fn from_run(outcomes: &Map<String, Value>, data: &Value, mech: Option<(bool, bool)>, saved_fills: Option<bool>) -> Vec<Item> {+    let gate = outcomes.get("gate");+    let netlist = outcomes.get("netlist");+    let parity = match (data["schematic"].as_str(), netlist) {+        (_, Some(n)) => yes_no(n["ok"].as_bool(), "the board differs from the schematic", "not recorded"),+        (Some(_), None) => State::Unknown("netlist check not run".into()),+        (None, None) => State::Unknown("no schematic in the run".into()),+    };+    let connectivity = gate.map(|g| yes_no(g["unconnected"].as_u64().map(|u| u == 0), "unconnected items remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into()));+    let erc_rec = outcomes.get("erc").map(|e| e["pass"].as_bool().or(e["ok"].as_bool()))+        .or_else(|| outcomes.get("schematic").filter(|s| s["ercErrors"].is_array()).map(|s| Some(s["ercErrors"].as_array().map(|a| a.is_empty()).unwrap_or(false))))+        .or_else(|| data["evidence"].as_array().and_then(|a| a.iter().rev().find(|e| e["kind"] == "erc")).map(|e| e["pass"].as_bool()));+    let erc = match erc_rec { Some(p) => yes_no(p, "ERC failed", "ERC result states no pass"), None => State::Unknown("no ERC result recorded in the run".into()) };+    let drc = gate.map(|g| yes_no(g["newErrors"].as_u64().map(|n| n == 0), "DRC errors remain", "not recorded")).unwrap_or(State::Unknown("gate not run".into()));+    let gerbers = outcomes.get("export").map(|e| yes_no(e["fillsGerbers"]["pass"].as_bool(), "a poured layer has no Gerber regions", "export predates this check; run export again")).unwrap_or(State::Unknown("not exported yet (run export)".into()));+    vec![+        Item { name: "schematic parity", state: parity },+        Item { name: "connectivity", state: connectivity },+        Item { name: "ERC", state: erc },+        Item { name: "DRC", state: drc },+        Item { name: "schematic symbols and pin types", state: match (outcomes.get(crate::schematic_symbols::OUTCOME), data["schematic"].as_str()) {+            (Some(c), _) => yes_no(c["ok"].as_bool(), "symbol polarity or pin-type errors (schematic-check.json)", "not recorded"),+            (None, Some(_)) => State::Unknown("schematic check not run".into()),+            (None, None) => State::Unknown("no schematic in the run".into()),+        } },+        Item { name: "courtyards", state: mech.map(|m| yes_no(Some(m.0), "missing or overlapping courtyards", "")).unwrap_or(State::Unknown("not checked".into())) },+        Item { name: "body overlap", state: mech.map(|m| yes_no(Some(m.1), "part bodies overlap", "")).unwrap_or(State::Unknown("not checked".into())) },+        Item { name: "fills in saved board", state: yes_no(saved_fills, "a zone has no filled polygons", "not checked") },+        Item { name: "fills in Gerbers", state: gerbers },+    ]+}++pub fn all_passed(items: &[Item]) -> bool { items.iter().all(|i| i.state == State::Yes) }++/// One line: "verified: connectivity yes, ERC not known (...), ...".+pub fn line(items: &[Item]) -> String {+    let parts: Vec<String> = items.iter().map(|i| match &i.state {+        State::Yes => format!("{} yes", i.name),+        State::No(w) => format!("{} NO ({w})", i.name),+        State::Unknown(w) => format!("{} not known ({w})", i.name),+    }).collect();+    format!("verified: {}", parts.join(", "))+}++/// The verdict that follows the line: never a fabrication sign-off.+pub fn verdict(items: &[Item]) -> String {+    if all_passed(items) {+        "Every listed check passed. This is not a fabrication sign-off: a human review of the board, the BOM and the fab files is still advised before ordering.".into()+    } else {+        let open: Vec<&str> = items.iter().filter(|i| i.state != State::Yes).map(|i| i.name).collect();+        format!("Not cleared for fabrication: {} not verified. Do not tell the human the board is ready to fabricate.", open.join(", "))+    }+}++pub fn to_json(items: &[Item]) -> Value {+    let mut m = Map::new();+    for i in items {+        m.insert(i.name.to_string(), match &i.state { State::Yes => json!("yes"), State::No(w) => json!({"state": "no", "why": w}), State::Unknown(w) => json!({"state": "not known", "why": w}) });+    }+    json!({"items": m, "allPassed": all_passed(items), "verdict": verdict(items)})+}++/// The board-side facts a stage computes on the board in hand: courtyards and bodies, and the+/// pours in the saved file.+pub struct BoardFacts { pub mech: crate::mechanical::Report, pub fills: Value, pub fill_problems: Vec<String> }++pub fn board_facts(b: &aiflow_board::Board, board_path: &str, project_dir: &std::path::Path, spec: &Value) -> BoardFacts {+    let mech = crate::mechanical::check_board(b, board_path, project_dir, spec);+    let (fills, fill_problems, _) = crate::fills::saved(b);+    BoardFacts { mech, fills, fill_problems }+}++/// The verified list for a run, with the board facts when the caller has them.+pub fn run_items(r: &aiflow_run::Run, facts: Option<&BoardFacts>) -> Vec<Item> {+    from_run(r.outcomes(), &r.data, facts.map(|f| (f.mech.courtyards_ok(), f.mech.bodies_ok())), facts.map(|f| f.fill_problems.is_empty()))+}++/// "verified: ..." and the verdict, as one message tail.+pub fn message(items: &[Item]) -> String { format!("{}\n{}", line(items), verdict(items)) }++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn unknowns_are_said_and_no_fab_claim_is_made() {+        let o: Map<String, Value> = serde_json::from_value(json!({"gate": {"newErrors": 0, "unconnected": 0}, "netlist": {"ok": true}})).unwrap();+        let items = from_run(&o, &json!({"schematic": "a.kicad_sch"}), Some((true, false)), Some(true));+        let l = line(&items);+        assert!(l.contains("connectivity yes") && l.contains("DRC yes") && l.contains("courtyards yes"), "{l}");+        assert!(l.contains("ERC not known") && l.contains("body overlap NO") && l.contains("fills in Gerbers not known (not exported yet"), "{l}");+        let v = verdict(&items);+        assert!(v.starts_with("Not cleared for fabrication") && v.contains("ERC") && v.contains("body overlap"), "{v}");+    }++    #[test]+    fn all_passed_still_advises_human_review() {+        let o: Map<String, Value> = serde_json::from_value(json!({"gate": {"newErrors": 0, "unconnected": 0}, "netlist": {"ok": true}, "schematic": {"pass": true, "ercErrors": []}, "schematic-check": {"ok": true}, "export": {"fillsGerbers": {"pass": true}}})).unwrap();+        let items = from_run(&o, &json!({"schematic": "a.kicad_sch"}), Some((true, true)), Some(true));+        assert!(all_passed(&items));+        let v = verdict(&items);+        assert!(v.contains("not a fabrication sign-off") && v.contains("human review"), "{v}");+        assert!(!v.to_lowercase().contains("ready to fabricate"));+    }+}
crates/aiflow-analyze/src/board_sink.rsadded+307
@@ -0,0 +1,307 @@+//! Board-bottom heat sinks (issue #67): a pedestal or plate pressed against one face of the board+//! (usually B.Cu through a gap pad) that cools tabless parts (QFN/SON exposed pads) through their+//! thermal vias. `packageCooling` models a sink on the part's case; this models a sink on the board.+//!+//! spec.boardSink: [{name, region: [[x,y],...] | "outline", layer: "B.Cu" | "F.Cu",+//!   kPerW (sink to ambient) | hWm2K (sink area = region area), interfaceKPerW? (gap pad over the+//!   whole region), maskOpen?, parts: [REF...], note, fieldsHeatsink?}]+//!+//! Two ways to credit it, both recorded as assumptions in the analysis and the finish summary:+//!+//! 1. A conservative series estimate in `analyze thermal` for each named part whose heat path+//!    reaches the region: only tab-net vias within `VIA_REACH_MM` of the part's tab-net pads whose+//!    centres lie inside the region count. Each via: plated barrel (k 385 W/mK, 25 um wall, board+//!    thickness) plus a lateral copper strip from the pad edge to the via. Then solder mask (unless+//!    `maskOpen`) and the interface on a contact patch (the vias' box grown 1 mm when the tab net+//!    has copper on the sink face, else the via lands alone); the interface's K/W is spread over the+//!    region area, so a small patch pays proportionally more. Then the sink's own K/W, heated by the+//!    full power of every part declared on it. No board-copper spreading or air credit on this path;+//!    the part passes when this path or the ordinary copper path alone meets its limit.+//! 2. The same-input Adom Fields solve with a conducting block (spec.heatsinks, Fields 0.4+) named by+//!    `fieldsHeatsink`. When the field screen passes (every chip within its budget with the block in+//!    place) and the block carries heat, the named parts' heuristic failures are replaced by the+//!    solve's result, with the block's assumptions recorded.+//!+//! Parts not named on a declared sink keep the still-air copper check unchanged.++use aiflow_board::geom::{point_in_poly, point_poly_distance, polygon_area, Pt};+use aiflow_board::Board;+use serde_json::{json, Value};++/// Tab-net vias farther than this from the part's tab-net pad copper are not on its heat path here.+pub const VIA_REACH_MM: f64 = 1.0;+const K_CU_W_PER_MM_K: f64 = 0.385;+/// Solder mask between the copper and the interface: about 20 um of LPI at 0.25 W/mK.+pub const MASK_KMM2_PER_W: f64 = 80.0;+/// How far the contact patch grows past the vias when the tab net has copper on the sink face.+const PATCH_GROW_MM: f64 = 1.0;++fn num(v: &Value) -> Option<f64> { v.as_f64().filter(|x| x.is_finite()) }++fn region_of(s: &Value, b: Option<&Board>) -> Result<Vec<Pt>, String> {+    if s["region"] == "outline" {+        return b.map(|b| b.outline.clone()).filter(|o| o.len() >= 3).ok_or_else(|| "region \"outline\" needs the board outline".into());+    }+    let pts: Option<Vec<Pt>> = s["region"].as_array().map(|a| a.iter().map(|p| Some((num(&p[0])?, num(&p[1])?))).collect()).unwrap_or(None);+    match pts { Some(p) if p.len() >= 3 && polygon_area(&p).abs() > 1e-6 => Ok(p), _ => Err("region must be \"outline\" or a polygon of at least three [x, y] points (board mm)".into()) }+}++/// Validate spec.boardSink. Returns the entries (empty when absent).+pub fn validate(spec: &Value) -> Result<Vec<Value>, String> {+    let Some(v) = spec.get("boardSink") else { return Ok(Vec::new()) };+    let list = v.as_array().ok_or("boardSink must be a list")?;+    let mut names = Vec::new();+    let mut seen_parts: Vec<String> = Vec::new();+    for s in list {+        let name = s["name"].as_str().filter(|n| !n.trim().is_empty()).ok_or("each boardSink needs a name")?;+        if names.contains(&name) { return Err(format!("boardSink {name} is declared twice")); }+        names.push(name);+        if !matches!(s["layer"].as_str(), Some("F.Cu") | Some("B.Cu")) { return Err(format!("boardSink {name}: layer must be F.Cu or B.Cu (the board face the sink presses on)")); }+        if s["region"] != "outline" { region_of(s, None).map_err(|e| format!("boardSink {name}: {e}"))?; }+        let k = s.get("kPerW").map(|x| num(x).filter(|x| *x > 0.0).ok_or(format!("boardSink {name}: kPerW must be finite and positive")));+        let h = s.get("hWm2K").map(|x| num(x).filter(|x| *x > 0.0).ok_or(format!("boardSink {name}: hWm2K must be finite and positive")));+        match (k, h) {+            (Some(k), None) => { k?; }+            (None, Some(h)) => { h?; }+            _ => return Err(format!("boardSink {name}: give exactly one of kPerW (sink to ambient) or hWm2K (with the region as the sink area)")),+        }+        if let Some(i) = s.get("interfaceKPerW") { num(i).filter(|x| *x >= 0.0).ok_or(format!("boardSink {name}: interfaceKPerW must be finite and not negative"))?; }+        if s["note"].as_str().map(|n| n.trim().len() < 8).unwrap_or(true) { return Err(format!("boardSink {name}: note must say what the sink is (part, gap pad, mounting) and where its numbers come from")); }+        let parts = s["parts"].as_array().filter(|p| !p.is_empty()).ok_or(format!("boardSink {name}: parts must name the hot parts it cools"))?;+        for p in parts {+            let r = p.as_str().ok_or(format!("boardSink {name}: parts are references"))?;+            if spec["hot"].get(r).is_none() { return Err(format!("boardSink {name}: {r} is not in spec.hot")); }+            if super::package_cooling::definition(spec, r).is_some() { return Err(format!("boardSink {name}: {r} already has a packageCooling path; declare one cooling model per part")); }+            if seen_parts.iter().any(|x| x == r) { return Err(format!("boardSink: {r} is named on two sinks")); }+            seen_parts.push(r.to_string());+        }+        if let Some(f) = s.get("fieldsHeatsink") { f.as_str().filter(|x| !x.is_empty()).ok_or(format!("boardSink {name}: fieldsHeatsink names a spec.heatsinks block"))?; }+    }+    Ok(list.clone())+}++fn sink_of<'a>(sinks: &'a [Value], reference: &str) -> Option<&'a Value> {+    sinks.iter().find(|s| s["parts"].as_array().is_some_and(|p| p.iter().any(|x| x.as_str() == Some(reference))))+}++/// Apply the conservative series estimate to the heuristic rows (analyze thermal on the board).+pub fn apply(results: &mut [Value], spec: &Value, board: &Board, zone_state: Option<&Value>) -> Result<(), String> {+    let sinks = validate(spec)?;+    if sinks.is_empty() { return Ok(()); }+    let thick = num(&spec["boardThicknessMm"]).or(num(&spec["branchCurrentModel"]["boardThicknessMm"])).unwrap_or(1.6);+    let plating = num(&spec["branchCurrentModel"]["barrelPlatingUm"]).unwrap_or(25.0) / 1000.0;+    let t_cu = num(&spec["copperUm"]).unwrap_or(35.0) / 1000.0;+    let pours = super::pours_by_net(zone_state);+    let vias = board.vias();+    for row in results.iter_mut() {+        let reference = row["ref"].as_str().unwrap_or("").to_string();+        let Some(s) = sink_of(&sinks, &reference) else { continue };+        let name = s["name"].as_str().unwrap_or("");+        let layer = s["layer"].as_str().unwrap_or("B.Cu");+        let region = region_of(s, Some(board)).map_err(|e| format!("boardSink {name}: {e}"))?;+        let region_area = polygon_area(&region).abs();+        let net = spec["hot"][&reference]["tabNet"].as_str().unwrap_or("").to_string();+        let watts = num(&spec["hot"][&reference]["watts"]).unwrap_or(0.0);+        let sink_watts: f64 = s["parts"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).map(|p| num(&spec["hot"][p]["watts"]).unwrap_or(0.0)).sum();+        let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+        let dist = |x: f64, y: f64| pads.iter().flat_map(|p| p.polys.iter()).filter(|poly| poly.len() >= 3).map(|poly| point_poly_distance(x, y, poly)).fold(f64::INFINITY, f64::min);+        let on_path: Vec<(f64, f64, f64, f64)> = vias.iter().filter(|v| v.net == net && point_in_poly(v.x, v.y, &region))+            .map(|v| (v.x, v.y, v.size, v.drill)).filter(|v| dist(v.0, v.1) <= VIA_REACH_MM).collect();+        let note = s["note"].as_str().unwrap_or("");+        if on_path.is_empty() {+            row["boardSink"] = json!({"name": name, "used": false, "assumption": format!("{name} ({note}): no {net} via within {VIA_REACH_MM} mm of {reference}'s {net} pads lands inside the sink region, so the estimate gives it no sink credit")});+            if row["status"] != "pass" { row["hint"] = json!(format!("{}; board sink {name}: no {net} via within {VIA_REACH_MM} mm of {reference}'s tab lands in its region (add thermal vias in the pad over the sink, or a Fields solve with fieldsHeatsink)", row["hint"].as_str().unwrap_or(""))); }+            continue;+        }+        let r_vias = 1.0 / on_path.iter().map(|v| {+            let barrel = thick / (K_CU_W_PER_MM_K * std::f64::consts::PI * plating * (v.3 + plating));+            let lateral = dist(v.0, v.1) / (K_CU_W_PER_MM_K * t_cu * std::f64::consts::PI * v.2);+            1.0 / (barrel + lateral)+        }).sum::<f64>();+        let face_copper = pours.get(&net).map(|z| super::layers_of(z).iter().any(|l| l == layer)).unwrap_or(false);+        let patch = if face_copper {+            let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+            for v in &on_path { x0 = x0.min(v.0 - v.2 / 2.0); y0 = y0.min(v.1 - v.2 / 2.0); x1 = x1.max(v.0 + v.2 / 2.0); y1 = y1.max(v.1 + v.2 / 2.0); }+            (x1 - x0 + 2.0 * PATCH_GROW_MM) * (y1 - y0 + 2.0 * PATCH_GROW_MM)+        } else { on_path.iter().map(|v| std::f64::consts::PI * (v.2 / 2.0).powi(2)).sum() };+        let patch = patch.min(region_area);+        let r_mask = if s["maskOpen"] == true { 0.0 } else { MASK_KMM2_PER_W / patch };+        let r_if = num(&s["interfaceKPerW"]).unwrap_or(0.0) * region_area / patch;+        let r_sink = num(&s["kPerW"]).unwrap_or_else(|| 1e6 / (num(&s["hWm2K"]).unwrap_or(1.0) * region_area));+        let rise = watts * (r_vias + r_mask + r_if) + r_sink * sink_watts;+        let theta = if watts > 0.0 { rise / watts } else { 0.0 };+        let theta_limit = num(&row["limitCPerW"]);+        let rise_limit = num(&row["riseLimitC"]);+        let limit = theta_limit.unwrap_or(if rise_limit.is_some() { f64::INFINITY } else { 60.0 });+        let sink_pass = theta <= limit && rise_limit.map(|l| rise <= l).unwrap_or(true);+        let r1 = |x: f64| (x * 10.0).round() / 10.0;+        let assumption = format!("{name} on {layer} ({note}): heat leaves {reference} only through its {} {net} via(s) inside the sink region (plated barrel {:.0} um, {thick} mm board, lateral copper from the pad edge), {} and {} on a {:.1} mm2 patch, into a sink of {:.2} K/W carrying all {:.2} W of its parts; no board-spreading or air credit on this path",+            on_path.len(), plating * 1000.0, if s["maskOpen"] == true { "no solder mask (maskOpen)".to_string() } else { format!("solder mask at {MASK_KMM2_PER_W} K mm2/W") },+            match num(&s["interfaceKPerW"]) { Some(i) => format!("the interface at {i} K/W over the {:.0} mm2 region", region_area), None => "no interface resistance declared (direct contact assumed)".to_string() }, patch, r_sink, sink_watts);+        row["boardSink"] = json!({"name": name, "layer": layer, "used": sink_pass, "viasOnPath": on_path.len(), "patchMm2": r1(patch), "rViasKPerW": r1(r_vias), "rMaskKPerW": r1(r_mask), "rInterfaceKPerW": r1(r_if), "rSinkKPerW": (r_sink * 100.0).round() / 100.0, "sinkWatts": sink_watts, "riseEstimateC": r1(rise), "thetaEstimateCPerW": r1(theta), "assumption": assumption});+        if row["status"] == "pass" { continue; }+        if sink_pass {+            row["status"] = json!("pass");+            row["how"] = json!(format!("board sink {name}: about {:.0} C/W, {:.0} C at {watts:.2} W through {} vias into the {layer} sink (still-air copper path alone: {} C/W)", theta, rise, on_path.len(), row["thetaEstimateCPerW"]));+            if let Some(o) = row.as_object_mut() { o.remove("hint"); }+        } else {+            row["hint"] = json!(format!("{}; board sink {name}: about {:.0} C/W ({:.0} C) through {} vias (vias {:.0}, mask {:.0}, interface {:.0}, sink {:.1} K/W): more vias in the pad over the sink, or a Fields solve with fieldsHeatsink", row["hint"].as_str().unwrap_or(""), theta, rise, on_path.len(), r_vias, r_mask, r_if, r_sink));+        }+    }+    Ok(())+}++/// Accept the same-input Fields solve with the named conducting block for the sink's parts whose+/// estimate failed. Call only after the field screen passed on this board and spec. Returns notes.+pub fn accept_solve(results: &mut [Value], spec: &Value, fields: &Value) -> Vec<String> {+    let Ok(sinks) = validate(spec) else { return Vec::new() };+    let mut notes = Vec::new();+    for row in results.iter_mut() {+        if row["status"] == "pass" { continue; }+        let reference = row["ref"].as_str().unwrap_or("").to_string();+        let Some(s) = sink_of(&sinks, &reference) else { continue };+        let Some(block_name) = s["fieldsHeatsink"].as_str() else { continue };+        let name = s["name"].as_str().unwrap_or("");+        let block = fields["heatsinks"].as_array().into_iter().flatten().find(|h| h["definition"]["name"].as_str() == Some(block_name));+        let chip = fields["chips"].as_array().into_iter().flatten().find(|c| c["reference"].as_str() == Some(reference.as_str()));+        let why_not = match (block, chip) {+            (None, _) => Some(format!("the Fields report has no heatsink block {block_name}")),+            (_, None) => Some(format!("the Fields report has no chip {reference}")),+            (Some(b), Some(_)) => {+                let d = &b["definition"];+                let bounds: Option<Vec<f64>> = d["boundsMm"].as_array().map(|a| a.iter().filter_map(num).collect());+                let inside = match (region_of(s, None).ok(), bounds) {+                    (Some(reg), Some(bb)) if bb.len() == 4 => {+                        let (x0, y0, x1, y1) = aiflow_board::geom::bbox(&reg);+                        bb[0] >= x0 - 0.05 && bb[1] >= y0 - 0.05 && bb[2] <= x1 + 0.05 && bb[3] <= y1 + 0.05+                    }+                    (None, Some(bb)) if bb.len() == 4 => s["region"] == "outline",+                    _ => false,+                };+                if d["layer"].as_str() != s["layer"].as_str() { Some(format!("block {block_name} is on {}, the sink on {}", d["layer"], s["layer"])) }+                else if !inside { Some(format!("block {block_name} lies outside the {name} region")) }+                else if !num(&b["contactW"]).is_some_and(|w| w > 0.0) || !num(&b["contactMovingW"]).is_some_and(|w| w > 0.0) { Some(format!("block {block_name} carries no heat in the solve")) }+                else { None }+            }+        };+        if let Some(w) = why_not { notes.push(format!("{reference}: Fields evidence for board sink {name} not accepted: {w}")); continue; }+        let (b, c) = (block.unwrap(), chip.unwrap());+        let d = &b["definition"];+        let assumption = format!("{reference} on board sink {name} ({}) is judged by the same-input Adom Fields solve with conducting block {block_name}: {} mm thick, {} W/mK, contact {} K mm2/W on {}, losing heat to still or moving air on its exposed faces; the sink's declared K/W is not part of that solve. Fields: {} C still, {} C moving at {} W; block takes {} W still",+            s["note"].as_str().unwrap_or(""), d["thicknessMm"], d["conductivityWPerMK"], d["contactResistanceMm2KPerW"], d["layer"].as_str().unwrap_or(""), c["riseC"], c["riseMovingC"], c["watts"], b["contactW"]);+        row["status"] = json!("pass");+        row["how"] = json!(format!("board sink {name}: the same-input Fields solve with block {block_name} keeps {reference} at {} C (still) / {} C (moving); the copper-path estimate alone did not", c["riseC"], c["riseMovingC"]));+        row["boardSink"]["evidence"] = json!({"kind": "fields-heatsink", "block": block_name, "riseC": c["riseC"], "riseMovingC": c["riseMovingC"], "contactW": b["contactW"]});+        row["boardSink"]["used"] = json!(true);+        row["boardSink"]["assumption"] = json!(assumption);+        if let Some(o) = row.as_object_mut() { o.remove("hint"); }+        notes.push(format!("{reference}: accepted on the Fields solve with block {block_name}"));+    }+    notes+}++/// The recorded assumptions of every sink that carried a part, for the run summary and finish.+pub fn assumptions(results: &[Value]) -> Vec<String> {+    results.iter().filter(|r| r["boardSink"]["used"] == true).filter_map(|r| r["boardSink"]["assumption"].as_str().map(str::to_string)).collect()+}++#[cfg(test)]+mod tests {+    use super::*;+    // a 4 mm QFN exposed pad on GND at (20, 15), five in-pad vias, B.Cu poured; a sink under it+    fn board(vias: &[(f64, f64)]) -> Board {+        let mut t = String::from(r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (net 1 "GND")+ (gr_rect (start 0 0) (end 40 30) (layer "Edge.Cuts"))+ (footprint "QFN-24" (layer "F.Cu") (at 20 15) (property "Reference" "U1")+  (pad "25" smd rect (at 0 0) (size 4 4) (layers "F.Cu") (net 1 "GND")))+ (footprint "QFN-24" (layer "F.Cu") (at 5 5) (property "Reference" "U2")+  (pad "25" smd rect (at 0 0) (size 3 3) (layers "F.Cu") (net 1 "GND")))+"#);+        for v in vias { t += &format!(" (via (at {} {}) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1))\n", v.0, v.1); }+        t += ")";+        Board::from_text("t.kicad_pcb", t).unwrap()+    }+    fn zs() -> Value { json!({"zones":[{"type":"copper","net":"GND","layers":["F.Cu"],"fills":[{"areaMm2":20.0}]},{"type":"copper","net":"GND","layers":["B.Cu"],"fills":[{"areaMm2":20.0}]}]}) }+    fn spec(sink: Value) -> Value {+        json!({"hot":{"U1":{"watts":1.5,"tabNet":"GND"},"U2":{"watts":1.5,"tabNet":"GND"}},"hotMaxRiseC":50,"boardSink":[sink]})+    }+    fn sink() -> Value { json!({"name":"pedestal","region":[[15,10],[25,10],[25,20],[15,20]],"layer":"B.Cu","kPerW":2.0,"interfaceKPerW":1.0,"parts":["U1"],"note":"Al pedestal, 1 mm gap pad 3 W/mK, datasheet values"}) }+    fn rows(b: &Board, sp: &Value) -> Vec<Value> {+        let plan = json!({"nets":[]});+        let (_, mut r) = super::super::analyze_thermal_on_board(&plan, sp, Some(&zs()), b);+        apply(&mut r, sp, b, Some(&zs())).unwrap();+        r+    }++    #[test]+    fn a_tabless_part_on_a_declared_sink_passes_through_its_vias() {+        let b = board(&[(19.0, 14.0), (21.0, 14.0), (20.0, 15.0), (19.0, 16.0), (21.0, 16.0), (19.0, 15.0), (21.0, 15.0), (20.0, 14.0), (20.0, 16.0)]);+        let sp = spec(sink());+        let r = rows(&b, &sp);+        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();+        let u2 = r.iter().find(|x| x["ref"] == "U2").unwrap();+        assert_eq!(u1["status"], "pass", "{u1}");+        assert_eq!(u1["boardSink"]["viasOnPath"], 9);+        assert!(u1["boardSink"]["assumption"].as_str().unwrap().contains("no board-spreading"));+        // the part not on the sink keeps its still-air copper check: 40 mm2 of copper fails at 1.5 W+        assert_eq!(u2["status"], "fail", "{u2}");+        assert!(u2.get("boardSink").is_none());+        assert_eq!(assumptions(&r).len(), 1);+    }++    #[test]+    fn no_credit_without_vias_into_the_region_or_with_a_weak_sink() {+        // vias beside the part but outside the region+        let b = board(&[(19.0, 14.0), (21.0, 14.0)]);+        let mut s = sink(); s["region"] = json!([[30,25],[39,25],[39,29],[30,29]]);+        let r = rows(&b, &spec(s));+        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();+        assert_eq!(u1["status"], "fail"); assert_eq!(u1["boardSink"]["used"], false);+        // a sink of 30 K/W cannot carry 1.5 W inside a 50 C budget+        let b = board(&[(19.0, 14.0), (21.0, 14.0), (20.0, 15.0)]);+        let mut s = sink(); s["kPerW"] = json!(30.0);+        let r = rows(&b, &spec(s));+        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");+        // h and area instead of K/W: 10 W/m2K on 100 mm2 is 1000 K/W+        let mut s = sink(); s.as_object_mut().unwrap().remove("kPerW"); s["hWm2K"] = json!(10.0);+        let r = rows(&b, &spec(s));+        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["boardSink"]["rSinkKPerW"], 1000.0);+    }++    #[test]+    fn declarations_are_validated() {+        for (k, v) in [("layer", json!("In1.Cu")), ("kPerW", json!(-1)), ("parts", json!(["U9"])), ("note", json!("")), ("region", json!([[0,0],[1,1]]))] {+            let mut s = sink(); s[k] = v;+            assert!(validate(&spec(s)).is_err(), "{k}");+        }+        let mut both = sink(); both["hWm2K"] = json!(10.0);+        assert!(validate(&spec(both)).is_err());+        let mut sp = spec(sink()); sp["boardSink"].as_array_mut().unwrap().push({ let mut s = sink(); s["name"] = json!("other"); s });+        assert!(validate(&sp).unwrap_err().contains("two sinks"));+    }++    #[test]+    fn a_fields_solve_with_the_named_block_is_accepted_only_when_it_matches() {+        let b = board(&[(20.0, 15.0)]);+        let mut s = sink(); s["kPerW"] = json!(30.0); s["fieldsHeatsink"] = json!("pedestal-block");+        let sp = spec(s);+        let mut r = rows(&b, &sp);+        assert_eq!(r.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");+        let f = json!({"heatsinks":[{"definition":{"name":"pedestal-block","layer":"B.Cu","boundsMm":[16,11,24,19],"thicknessMm":5,"conductivityWPerMK":200,"contactResistanceMm2KPerW":300},"contactW":1.2,"contactMovingW":1.1}],+            "chips":[{"reference":"U1","watts":1.5,"riseC":31.0,"riseMovingC":22.0}]});+        let mut wrong = f.clone(); wrong["heatsinks"][0]["definition"]["layer"] = json!("F.Cu");+        let mut r2 = r.clone();+        accept_solve(&mut r2, &sp, &wrong);+        assert_eq!(r2.iter().find(|x| x["ref"] == "U1").unwrap()["status"], "fail");+        let mut outside = f.clone(); outside["heatsinks"][0]["definition"]["boundsMm"] = json!([0, 0, 8, 8]);+        let mut r3 = r.clone();+        assert!(accept_solve(&mut r3, &sp, &outside)[0].contains("outside"));+        accept_solve(&mut r, &sp, &f);+        let u1 = r.iter().find(|x| x["ref"] == "U1").unwrap();+        assert_eq!(u1["status"], "pass");+        assert_eq!(u1["boardSink"]["evidence"]["block"], "pedestal-block");+        assert!(assumptions(&r)[0].contains("declared K/W is not part of that solve"));+        // U2 is not on the sink: the solve does not touch its row+        assert_eq!(r.iter().find(|x| x["ref"] == "U2").unwrap()["status"], "fail");+    }+}
crates/aiflow-analyze/src/current_acks.rsadded+187
@@ -0,0 +1,187 @@+//! Reviewed acknowledgements of part-limited current-density peaks (issue #70).+//!+//! The Fields screen in `analyze current` refuses any net whose peak density is over the screen+//! (60 A/mm2 by default). Some peaks sit on a part's own land (a SOT-23 drain pad, a connector's+//! last millimetre) that the board cannot change. `analyze current --ack [email protected] --why ...` or+//! `--ack NET@x,y --why ...` records one reviewed point: the net, the place, the peak Fields+//! reported there, the reason and the ai thread. The number keeps being reported.+//!+//! An acknowledgement covers an over-screen point when all of these hold:+//! - same net (never a whole net: a location or a pad is required);+//! - the point lies inside the acknowledged pad's copper or within `TOLERANCE_MM` of it, or within+//!   `TOLERANCE_MM` of the acknowledged x,y;+//! - its density is not above the reviewed peak by more than `PEAK_SLACK` (1 percent);+//! - the board's copper fingerprint (the caller's: adom-aiflow passes copper_carry's, the same one+//!   that carries Fields evidence across a silkscreen-only adoption) and the Fields spec identity+//!   are the ones the review saw. Any copper or rules change, or any spec change, expires it.+//!+//! Fields reports one maximum per net. Once that point is acknowledged, a second over-screen spot on+//! the same net is not visible in the report; the reviewer checks the density map for it.++use aiflow_board::geom::{point_poly_distance, Pt};+use aiflow_board::Board;+use serde_json::{json, Value};++/// How far from the acknowledged pad copper or x,y an over-screen point may lie and still be+/// covered: a few Fields cells and a land's edge.+pub const TOLERANCE_MM: f64 = 0.5;+/// How much the density at an acknowledged point may exceed the reviewed peak (rounding, solver noise).+pub const PEAK_SLACK: f64 = 0.01;++/// An over-screen point Fields reported: a net's peak, or a current-neck finding.+#[derive(Debug, Clone)]+pub struct Point { pub net: String, pub at: Pt, pub layer: String, pub peak: f64 }++/// Every over-screen point in a Fields report: each loaded net whose peak is over `limit`, at its+/// peak location, and every `current-neck` finding with a location.+pub fn over_screen(fields: &Value, limit: f64) -> Vec<Point> {+    let mut out = Vec::new();+    for row in fields["nets"].as_array().into_iter().flatten() {+        let peak = row["peakJ"].as_f64().unwrap_or(0.0);+        if peak <= limit { continue; }+        if let (Some(net), Some(x), Some(y)) = (row["net"].as_str(), row["peakAt"][0].as_f64(), row["peakAt"][1].as_f64()) {+            out.push(Point { net: net.to_string(), at: (x, y), layer: row["peakLayer"].as_str().unwrap_or("").to_string(), peak });+        }+    }+    out+}++/// A parsed `--ack` target: [email protected] or NET@x,y. A bare NET (or NET@, NET@*) is refused.+pub enum Target { Pad { net: String, key: String }, At { net: String, at: Pt } }++pub fn parse_target(s: &str) -> Result<Target, String> {+    let blanket = || format!("--ack {s}: name the place, not the whole net: [email protected] (a pad of the part that limits it) or NET@x,y (board mm). A whole-net acknowledgement is never accepted");+    let Some(i) = s.rfind('@') else { return Err(blanket()) };+    let (net, loc) = (s[..i].trim(), s[i + 1..].trim());+    if net.is_empty() || loc.is_empty() || loc == "*" { return Err(blanket()); }+    let nums: Vec<&str> = loc.split(',').map(str::trim).collect();+    if nums.len() == 2 {+        if let (Ok(x), Ok(y)) = (nums[0].parse::<f64>(), nums[1].parse::<f64>()) {+            if x.is_finite() && y.is_finite() { return Ok(Target::At { net: net.into(), at: (x, y) }); }+        }+    }+    if !loc.contains('.') { return Err(format!("--ack {s}: the place is REF.PAD (for example U1.2) or x,y in board mm")); }+    Ok(Target::Pad { net: net.into(), key: loc.into() })+}++/// The region a stored acknowledgement covers, as polygons (a pad's copper) or one point.+fn region(a: &Value) -> (Vec<Vec<Pt>>, Option<Pt>) {+    let polys: Vec<Vec<Pt>> = a["padCopper"].as_array().into_iter().flatten()+        .map(|p| p.as_array().into_iter().flatten().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).collect();+    let at = match (a["location"][0].as_f64(), a["location"][1].as_f64()) { (Some(x), Some(y)) => Some((x, y)), _ => None };+    (polys, at)+}++fn near(a: &Value, p: Pt) -> bool {+    let (polys, at) = region(a);+    if !polys.is_empty() { return polys.iter().any(|poly| poly.len() >= 3 && point_poly_distance(p.0, p.1, poly) <= TOLERANCE_MM); }+    at.map(|q| (q.0 - p.0).hypot(q.1 - p.1) <= TOLERANCE_MM).unwrap_or(false)+}++/// Does this live acknowledgement cover the point?+pub fn covers(a: &Value, p: &Point) -> bool {+    a["net"].as_str() == Some(p.net.as_str())+        && a["peakJ"].as_f64().is_some_and(|v| p.peak <= v * (1.0 + PEAK_SLACK))+        && near(a, p.at)+}++/// Split stored acknowledgements into live and expired for this copper and spec identity.+pub fn split_live(acks: &Value, copper: &str, spec_identity: &str) -> (Vec<Value>, Vec<Value>) {+    let (mut live, mut expired) = (Vec::new(), Vec::new());+    for a in acks.as_array().into_iter().flatten() {+        if a["copperHash"].as_str() == Some(copper) && a["fieldsSpecIdentity"].as_str() == Some(spec_identity) { live.push(a.clone()); } else { expired.push(a.clone()); }+    }+    (live, expired)+}++/// Build one acknowledgement record from the current Fields report. Refuses when nothing over the+/// screen lies at that place (there is nothing to review), or when the pad is not on the net.+pub fn record(target: &str, why: &str, ai_thread: &str, fields: &Value, board: &Board, copper: &str, limit: f64, recorded_at: &str) -> Result<Value, String> {+    if why.trim().chars().count() < 12 { return Err("--why needs the review: what limits the peak (the part's land, a connector pin) and why the board cannot change it".into()); }+    let t = parse_target(target)?;+    let (net, pad_copper, location, pad_key) = match &t {+        Target::Pad { net, key } => {+            let pad = board.pad_by_key(key).ok_or_else(|| format!("--ack {target}: no pad {key} on the board"))?;+            if pad.net != *net { return Err(format!("--ack {target}: pad {key} is on net {}, not {net}", if pad.net.is_empty() { "(none)" } else { &pad.net })); }+            (net.clone(), pad.polys.clone(), None, Some(key.clone()))+        }+        Target::At { net, at } => (net.clone(), Vec::new(), Some(*at), None),+    };+    let mut a = json!({"net": net, "target": target, "pad": pad_key, "padCopper": pad_copper.iter().map(|p| p.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>()).collect::<Vec<_>>(), "location": location.map(|p| json!([p.0, p.1])), "toleranceMm": TOLERANCE_MM});+    let pts = over_screen(fields, limit);+    let hit = pts.iter().find(|p| p.net == net && near(&a, p.at));+    let Some(p) = hit else {+        let on_net: Vec<String> = pts.iter().filter(|p| p.net == net).map(|p| format!("{:.1} A/mm2 at ({:.2}, {:.2}) on {}", p.peak, p.at.0, p.at.1, p.layer)).collect();+        return Err(if on_net.is_empty() { format!("--ack {target}: {net} has no point over the {limit} A/mm2 screen in this Fields report; nothing to acknowledge") } else { format!("--ack {target}: the over-screen point of {net} is not within {TOLERANCE_MM} mm of that place ({})", on_net.join("; ")) });+    };+    a["peakJ"] = json!(p.peak);+    a["peakAt"] = json!([p.at.0, p.at.1]);+    a["peakLayer"] = json!(p.layer);+    a["screenAperMm2"] = json!(limit);+    a["why"] = json!(why.trim());+    a["aiThread"] = json!(ai_thread);+    a["recordedAt"] = json!(recorded_at);+    a["copperHash"] = json!(copper);+    a["fieldsSpecIdentity"] = json!(fields["inputIdentity"]["spec"].as_str().unwrap_or(""));+    a["fieldsBoardIdentity"] = json!(fields["inputIdentity"]["board"].as_str().unwrap_or(""));+    Ok(a)+}++/// One line per acknowledgement, for analyze and finish.+pub fn describe(a: &Value, state: &str) -> String {+    format!("{} at {} ({:.1} A/mm2 at ({:.2}, {:.2}) {}, screen {} A/mm2), {state}: \"{}\" by {} {}",+        a["net"].as_str().unwrap_or("?"), a["pad"].as_str().map(str::to_string).unwrap_or_else(|| format!("({}, {})", a["location"][0], a["location"][1])),+        a["peakJ"].as_f64().unwrap_or(0.0), a["peakAt"][0].as_f64().unwrap_or(0.0), a["peakAt"][1].as_f64().unwrap_or(0.0), a["peakLayer"].as_str().unwrap_or(""),+        a["screenAperMm2"], a["why"].as_str().unwrap_or(""), a["aiThread"].as_str().unwrap_or("?"), a["recordedAt"].as_str().unwrap_or(""))+}++#[cfg(test)]+mod tests {+    use super::*;+    const PCB: &str = r#"(kicad_pcb (version 20241229) (generator "pcbnew") (net 0 "") (net 1 "VBUS") (net 2 "GND")+ (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts"))+ (footprint "SOT-23" (layer "F.Cu") (at 10 10) (property "Reference" "Q1")+  (pad "3" smd rect (at 0 -1) (size 0.6 0.8) (layers "F.Cu") (net 1 "VBUS"))+  (pad "1" smd rect (at -1 1) (size 0.6 0.8) (layers "F.Cu") (net 2 "GND")))+ (segment (start 10 9) (end 20 9) (width 0.6) (layer "F.Cu") (net 1)))"#;+    fn board(text: &str) -> Board { Board::from_text("t.kicad_pcb", text.to_string()).unwrap() }+    fn fields(peak: f64, at: [f64; 2]) -> Value { json!({"inputIdentity": {"board": "b", "spec": "s"}, "nets": [{"net": "VBUS", "peakJ": peak, "peakAt": at, "peakLayer": "F.Cu"}]}) }++    #[test]+    fn whole_net_acknowledgements_are_refused() {+        for t in ["VBUS", "VBUS@", "VBUS@*", "@Q1.3"] { assert!(parse_target(t).is_err(), "{t}"); }+        assert!(matches!(parse_target("[email protected],9").unwrap(), Target::At { .. }));+        assert!(matches!(parse_target("[email protected]").unwrap(), Target::Pad { .. }));+    }++    #[test]+    fn a_pad_ack_records_the_peak_and_covers_only_its_land() {+        let b = board(PCB);+        let f = fields(67.0, [10.1, 9.1]);+        let a = record("[email protected]", "SOT-23 drain land is 0.6 mm wide; part limited", "astra", &f, &b, "cu", 60.0, "t").unwrap();+        assert_eq!(a["peakJ"], 67.0);+        assert_eq!(a["aiThread"], "astra");+        let p = |x: f64, y: f64, j: f64| Point { net: "VBUS".into(), at: (x, y), layer: "F.Cu".into(), peak: j };+        assert!(covers(&a, &p(10.1, 9.1, 67.0)));+        assert!(covers(&a, &p(10.0, 8.2, 67.0)), "within the tolerance of the land's edge");+        assert!(!covers(&a, &p(15.0, 9.0, 67.0)), "a spot along the track is not the land");+        assert!(!covers(&a, &p(10.1, 9.1, 70.0)), "a higher peak than the one reviewed");+        let mut other = p(10.1, 9.1, 67.0); other.net = "GND".into();+        assert!(!covers(&a, &other));+        // the wrong net's pad, a point with nothing over the screen, a missing reason+        assert!(record("[email protected]", "a reason long enough", "t", &f, &b, "cu", 60.0, "t").unwrap_err().contains("on net GND"));+        assert!(record("VBUS@20,15", "a reason long enough", "t", &f, &b, "cu", 60.0, "t").unwrap_err().contains("not within"));+        assert!(record("[email protected]", "a reason long enough", "t", &fields(50.0, [10.1, 9.1]), &b, "cu", 60.0, "t").unwrap_err().contains("nothing to acknowledge"));+        assert!(record("[email protected]", "ok", "t", &f, &b, "cu", 60.0, "t").is_err());+    }++    #[test]+    fn acknowledgements_expire_when_the_copper_fingerprint_or_the_fields_spec_changes() {+        let b = board(PCB);+        let a = record("[email protected],9.1", "connector shield tab limits the last mm", "t", &fields(67.0, [10.1, 9.1]), &b, "cu", 60.0, "t").unwrap();+        let acks = json!([a]);+        assert_eq!(split_live(&acks, "cu", "s").0.len(), 1);+        assert_eq!(split_live(&acks, "cu", "other spec").1.len(), 1);+        assert_eq!(split_live(&acks, "changed copper", "s").1.len(), 1);+    }+}
crates/aiflow-analyze/src/lib.rs+43−5
@@ -4,7 +4,9 @@ //! curve, pours by presence, connection style and via count, not by a field solve. PASS means "no //! obvious under-sized conductor and every hot tab has copper"; FAIL names the net or the part and //! the fix. 0.2 computes cross-sections through the filled polygons and IR drop per net.+pub mod board_sink; pub mod branches;+pub mod current_acks; pub mod package_cooling; use std::collections::BTreeMap; @@ -161,7 +163,10 @@ pub fn analyze_thermal(plan: &Value, spec: &Value, zone_state: Option<&Value>) - pub fn analyze_thermal_on_board(plan: &Value, spec: &Value, zone_state: Option<&Value>, board: &aiflow_board::Board) -> (bool, Vec<Value>) {     let mut on_board: BTreeMap<String, usize> = BTreeMap::new();     for v in board.vias() { *on_board.entry(v.net).or_default() += 1; }-    thermal_with_vias(plan, spec, zone_state, Some(&on_board))+    let (_, mut results) = thermal_with_vias(plan, spec, zone_state, Some(&on_board));+    // issue #67: a declared board-face sink, for the parts it names only+    if let Err(e) = board_sink::apply(&mut results, spec, board, zone_state) { results.push(json!({"ref": "boardSink", "status": "fail", "hint": e})); }+    (results.iter().all(|r| r["status"] == "pass"), results) }  fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, board_vias: Option<&BTreeMap<String, usize>>) -> (bool, Vec<Value>) {@@ -261,7 +266,15 @@ mod regression_tests {  /// Bind a field screen to the exact saved board and specification, and require coverage. pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes: &[u8], kind: &str) -> Result<(), String> {+    field_screen_with_acks(fields, spec, board_bytes, spec_bytes, kind, &[]).map(|_| ())+}++/// `field_screen` where reviewed current-peak acknowledgements (current_acks; the caller passes+/// only live ones) cover the over-screen points they name. Returns the covered points; any+/// over-screen point no acknowledgement covers still fails, and barrel findings are never covered.+pub fn field_screen_with_acks(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes: &[u8], kind: &str, acks: &[Value]) -> Result<Vec<Value>, String> {     use sha2::{Digest, Sha256};+    let mut covered: Vec<Value> = Vec::new();     for (key,bytes) in [("board",board_bytes),("spec",spec_bytes)] {         let expected=format!("{:x}",Sha256::digest(bytes));         if fields["inputIdentity"][key].as_str()!=Some(expected.as_str()) {@@ -321,8 +334,13 @@ pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes                 return Err(format!("Fields {name} must report a converged, finite solve at the full declared current"));             }             let density_limit=spec["maxCurrentDensityAperMm2"].as_f64().unwrap_or(60.0);-            if !density_limit.is_finite() || density_limit<=0.0 || peak.unwrap()>density_limit {-                return Err(format!("Fields {name} peak current density exceeds the {density_limit} A/mm2 screen"));+            if !density_limit.is_finite() || density_limit<=0.0 { return Err("maxCurrentDensityAperMm2 must be finite and positive".into()); }+            if peak.unwrap()>density_limit {+                let point = current_acks::Point { net: name.clone(), at: (row["peakAt"][0].as_f64().unwrap_or(f64::NAN), row["peakAt"][1].as_f64().unwrap_or(f64::NAN)), layer: row["peakLayer"].as_str().unwrap_or("").to_string(), peak: peak.unwrap() };+                match acks.iter().find(|a| current_acks::covers(a, &point)) {+                    Some(a) => covered.push(json!({"net": name, "peakJ": point.peak, "at": [point.at.0, point.at.1], "layer": point.layer, "ack": a["target"], "why": a["why"], "aiThread": a["aiThread"]})),+                    None => return Err(format!("Fields {name} peak current density exceeds the {density_limit} A/mm2 screen ({:.1} A/mm2 at ({:.2}, {:.2}) on {}; a reviewed part-limited peak takes `analyze current --ack {name}@REF.PAD --why ...`)", point.peak, point.at.0, point.at.1, point.layer)),+                }             }             let coupling=&fields["barrelCoupling"];             if coupling["model"]!="distributed-plated-wall" || coupling["drillVoidsRemoved"]!=true@@ -387,9 +405,12 @@ pub fn field_screen(fields: &Value, spec: &Value, board_bytes: &[u8], spec_bytes         }     }     let issues=fields["issues"].as_array().ok_or("Fields report has no issues array")?;-    let relevant: Vec<_>=issues.iter().filter(|i| if kind=="current" { i["kind"]=="current-neck" || i["kind"]=="current-barrel" } else { i["kind"]=="tab-rise" || i["kind"]=="thermal-vias" }).collect();+    // a neck finding at an acknowledged point is the same reviewed peak; barrel findings never are+    let acked_neck = |i: &Value| i["kind"]=="current-neck" && covered.iter().any(|c| c["net"]==i["net"]+        && i["at"][0].as_f64().zip(i["at"][1].as_f64()).is_some_and(|(x,y)| (x-c["at"][0].as_f64().unwrap_or(f64::NAN)).hypot(y-c["at"][1].as_f64().unwrap_or(f64::NAN)) <= current_acks::TOLERANCE_MM));+    let relevant: Vec<_>=issues.iter().filter(|i| if kind=="current" { (i["kind"]=="current-neck" && !acked_neck(i)) || i["kind"]=="current-barrel" } else { i["kind"]=="tab-rise" || i["kind"]=="thermal-vias" }).collect();     if !relevant.is_empty() { return Err(relevant.iter().map(|i|i["what"].as_str().unwrap_or("unresolved field finding")).collect::<Vec<_>>().join("; ")); }-    Ok(())+    Ok(covered) }  #[cfg(test)]@@ -462,6 +483,23 @@ mod field_screen_tests {         assert!(field_screen(&f,&spec,b"board",b"spec","current").is_err());     }     #[test]+    fn an_acknowledged_peak_is_covered_and_an_unacknowledged_one_still_fails() {+        let spec=json!({"loads":{"VCC":{"amps":2}}});+        let mut f=report(); f["nets"][0]["peakJ"]=json!(67.0); f["nets"][0]["peakAt"]=json!([10.0,9.0]); f["nets"][0]["peakLayer"]=json!("F.Cu");+        f["issues"]=json!([{"kind":"current-neck","net":"VCC","at":[10.0,9.0],"what":"VCC: 67 A/mm2 at its worst point"}]);+        assert!(field_screen(&f,&spec,b"board",b"spec","current").unwrap_err().contains("--ack"));+        let ack=json!({"net":"VCC","target":"VCC@10,9","location":[10.0,9.0],"peakJ":67.0,"why":"part land","aiThread":"t"});+        let covered=field_screen_with_acks(&f,&spec,b"board",b"spec","current",&[ack.clone()]).unwrap();+        assert_eq!(covered.len(),1); assert_eq!(covered[0]["peakJ"],67.0);+        // elsewhere on the net, another net, or a barrel finding: not covered+        let mut moved=f.clone(); moved["nets"][0]["peakAt"]=json!([14.0,9.0]); moved["issues"][0]["at"]=json!([14.0,9.0]);+        assert!(field_screen_with_acks(&moved,&spec,b"board",b"spec","current",&[ack.clone()]).is_err());+        let mut other=ack.clone(); other["net"]=json!("GND");+        assert!(field_screen_with_acks(&f,&spec,b"board",b"spec","current",&[other]).is_err());+        let mut barrel=f.clone(); barrel["issues"]=json!([{"kind":"current-barrel","net":"VCC","at":[10.0,9.0],"what":"barrel over"}]);+        assert!(field_screen_with_acks(&barrel,&spec,b"board",b"spec","current",&[ack]).unwrap_err().contains("barrel"));+    }+    #[test]     fn nonconvergence_underload_and_suppressed_neck_issue_cannot_pass() {         let spec=json!({"loads":{"VCC":{"amps":2}}});         for (key,value) in [("residual",json!(0.01)),("residual",Value::Null),("amps",json!(1)),("peakJ",json!(61))] {
crates/aiflow-board/src/eagle.rs+148−2
@@ -15,6 +15,19 @@ //!   rounded outline stays one closed loop with the board's real extents. //! - signal wires, vias and Fusion pours (`polygonpour`, with their filled islands) come along, so a //!   partly routed or poured board is planned from where it stands.+//! - a rotation string `MRฮธ` is EAGLE's: rotate by ฮธ counterclockwise, then mirror x (x -> -x) about the+//!   element's origin, so a bottom part turns clockwise as seen from the top. This is the order Fusion+//!   uses (the Fusion bridge's routing preflight, KiCad's EAGLE importer, and the wires that end on the+//!   pins of an MR270 part in a real Fusion schematic all agree); R0 and R180 read the same either way.+//! - silkscreen text (issue #74): what Fusion prints on the silkscreen is written as `gr_text`, so+//!   `finish` counts the board's real labels. tPlace (21) and tNames (25) go to F.SilkS, bPlace (22) and+//!   bNames (26) to B.SilkS: the layers Fusion's and EAGLE's silkscreen CAM print (tNames with tPlace).+//!   tValues/bValues (27/28) are not printed (aiflow's library puts >VALUE on 51) and are left out.+//!   Plain board texts, a smashed element's displayed attributes (NAME, VALUE, ...) where they stand,+//!   and an unsmashed element's package texts (>NAME, >VALUE) through the element's transform come+//!   along; a smashed part whose NAME was deleted prints no name. Texts keep EAGLE's alignment and its+//!   readability rule (a text turned past 90 up to 270 degrees is drawn turned back 180 degrees with its+//!   alignment reversed, unless it spins). The footprint's own Reference stays hidden, so no name counts twice. use std::collections::BTreeMap; use std::fmt::Write; @@ -46,6 +59,49 @@ pub fn arc_mid(a: (f64, f64), b: (f64, f64), curve: f64) -> (f64, f64) {  fn layer(l: Option<&str>) -> Option<&'static str> { match l { Some("1") => Some("F.Cu"), Some("16") => Some("B.Cu"), _ => None } } +/// The KiCad silkscreen layer an EAGLE layer prints on (see the module notes); None when it does not print.+pub fn silk_layer(l: &str) -> Option<&'static str> { match l { "21" | "25" => Some("F.SilkS"), "22" | "26" => Some("B.SilkS"), _ => None } }++/// An EAGLE top/bottom layer pair swapped, as a package's layers are on a mirrored (bottom) element.+fn flip_layer(l: &str) -> String {+    match l { "21" => "22", "22" => "21", "25" => "26", "26" => "25", "27" => "28", "28" => "27", "51" => "52", "52" => "51", x => x }.to_string()+}++/// A point of an element's package in board coordinates (EAGLE frame): rotate by the element's angle,+/// then mirror x when it is mirrored, then move to the element's origin.+pub fn place(p: (f64, f64), at: (f64, f64), deg: f64, mirror: bool) -> (f64, f64) {+    let a = deg.to_radians();+    let (x, y) = (p.0 * a.cos() - p.1 * a.sin(), p.0 * a.sin() + p.1 * a.cos());+    (at.0 + if mirror { -x } else { x }, at.1 + y)+}++/// One EAGLE text as a KiCad `gr_text` line: anchor (EAGLE frame), size (height), ratio (stroke, percent+/// of the size), the final rotation (degrees, mirrored, spin) and EAGLE's alignment.+fn silk_text(o: &mut String, text: &str, at: (f64, f64), size: f64, ratio: f64, rot: (f64, bool, bool), align: &str, layer: &str) {+    if text.trim().is_empty() { return; }+    let (deg, mirror, spin) = rot;+    let mut a = ((deg % 360.0) + 360.0) % 360.0;+    let (mut h, mut v) = match align {+        "bottom-left" | "" => ("left", "bottom"), "bottom-center" => ("", "bottom"), "bottom-right" => ("right", "bottom"),+        "center-left" => ("left", ""), "center" => ("", ""), "center-right" => ("right", ""),+        "top-left" => ("left", "top"), "top-center" => ("", "top"), "top-right" => ("right", "top"), _ => ("left", "bottom"),+    };+    // EAGLE keeps an unspun text readable: past 90 up to 270 degrees it is drawn turned back 180 degrees+    // about its anchor with the alignment reversed (the same box on the board)+    if !spin && a > 90.0 + 1e-9 && a <= 270.0 + 1e-9 {+        a -= 180.0;+        h = match h { "left" => "right", "right" => "left", x => x };+        v = match v { "bottom" => "top", "top" => "bottom", x => x };+    }+    // a mirrored EAGLE text (rotate, then mirror x) is KiCad's mirrored text at the opposite angle+    let k = if mirror { (360.0 - a) % 360.0 } else { a };+    let size = if size > 0.0 { size } else { 1.27 };+    let ratio = if ratio > 0.0 { ratio } else { 8.0 };+    let just: Vec<&str> = [h, v, if mirror { "mirror" } else { "" }].into_iter().filter(|s| !s.is_empty()).collect();+    let just = if just.is_empty() { String::new() } else { format!(" (justify {})", just.join(" ")) };+    let _ = writeln!(o, "  (gr_text {} (at {} {} {}) (layer \"{layer}\") (effects (font (size {} {}) (thickness {})){just}))", q(text), n(at.0), n(-at.1), n(k), n(size), n(size), n((size * ratio / 100.0).max(0.01)));+}+ /// The plan board for a Fusion board (EAGLE source text). pub fn to_kicad(text: &str) -> Result<String, String> {     let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not a readable EAGLE board: {e}"))?;@@ -85,6 +141,7 @@ pub fn to_kicad(text: &str) -> Result<String, String> {     let (pad_rv, pad_min, pad_max) = (ratio(param("rvPadTop"), 0.25), len(param("rlMinPadTop"), 0.254), len(param("rlMaxPadTop"), 0.508));     let (via_rv, via_min, via_max) = (ratio(param("rvViaOuter"), 0.25), len(param("rlMinViaOuter"), 0.2032), len(param("rlMaxViaOuter"), 0.508));     let ring = |drill: f64, rv: f64, lo: f64, hi: f64| (drill * rv).max(lo).min(hi);+    let mut silk = String::new();     for e in board.descendants().filter(|x| x.has_tag_name("element")) {         let reference = e.attribute("name").unwrap_or("?").to_string();         let Some(pkg) = packages.get(&(e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string())) else { continue };@@ -99,8 +156,7 @@ pub fn to_kicad(text: &str) -> Result<String, String> {             let (pa, _) = parse_rot(p.attribute("rot"));             // local position: plain y negation; a mirrored element's pads go straight to board space             let (lx, ly, ang) = if mirror {-                let (mx, my) = (-px, py); let a = ea.to_radians();-                let (wx, wy) = (ex + mx * a.cos() - my * a.sin(), ey + mx * a.sin() + my * a.cos());+                let (wx, wy) = place((px, py), (ex, ey), ea, true);                 (wx - ex, yy(wy) - fy, -(ea + pa))             } else { (px, -py, ea + pa) };             let pn = pad_net.get(&(reference.clone(), name.clone())).map(|s| net(s)).unwrap_or_default();@@ -121,6 +177,17 @@ pub fn to_kicad(text: &str) -> Result<String, String> {             }         }         o.push_str("  )\n");+        silk_of_element(&mut silk, &e, pkg, (ex, ey), ea, mirror);+    }+    o.push_str(&silk);+    if let Some(plain) = board.children().find(|x| x.has_tag_name("plain")) {+        for t in plain.children().filter(|x| x.has_tag_name("text")) {+            let Some(l) = silk_layer(t.attribute("layer").unwrap_or("")) else { continue };+            let content = t.text().unwrap_or("");+            if content.starts_with('>') { continue; } // a drawing-frame placeholder, not printed text+            let (d, m) = parse_rot(t.attribute("rot"));+            silk_text(&mut o, content, (f(&t, "x"), f(&t, "y")), f(&t, "size"), f(&t, "ratio"), (d, m, t.attribute("rot").unwrap_or("").contains('S')), t.attribute("align").unwrap_or(""), l);+        }     }      // outline: the Dimension layer@@ -187,6 +254,39 @@ pub fn to_kicad(text: &str) -> Result<String, String> {     Ok(o) } +/// The silkscreen text an element shows: a smashed element its displayed attributes where they stand+/// (their layer is already the board's), an unsmashed one its package texts through its transform.+fn silk_of_element(o: &mut String, e: &roxmltree::Node, pkg: &roxmltree::Node, at: (f64, f64), ea: f64, mirror: bool) {+    let name = e.attribute("name").unwrap_or("");+    let value = e.attribute("value").unwrap_or("");+    let attr = |key: &str| -> Option<String> {+        match key.to_ascii_uppercase().as_str() { "NAME" => Some(name.to_string()), "VALUE" => Some(value.to_string()), k => e.children().find(|a| a.has_tag_name("attribute") && a.attribute("name").map(|n| n.eq_ignore_ascii_case(k)).unwrap_or(false)).and_then(|a| a.attribute("value")).map(str::to_string) }+    };+    if e.attribute("smashed") == Some("yes") {+        for a in e.children().filter(|x| x.has_tag_name("attribute")) {+            let Some(l) = a.attribute("layer").and_then(silk_layer) else { continue };+            let key = a.attribute("name").unwrap_or("");+            let val = attr(key).or_else(|| a.attribute("value").map(str::to_string)).unwrap_or_default();+            let shown = match a.attribute("display").unwrap_or("value") { "off" => continue, "name" => key.to_string(), "both" => format!("{key}={val}"), _ => val };+            let (d, m) = parse_rot(a.attribute("rot"));+            silk_text(o, &shown, (f(&a, "x"), f(&a, "y")), f(&a, "size"), f(&a, "ratio"), (d, m, a.attribute("rot").unwrap_or("").contains('S')), a.attribute("align").unwrap_or(""), l);+        }+        return;+    }+    for t in pkg.children().filter(|x| x.has_tag_name("text")) {+        let pl = t.attribute("layer").unwrap_or("");+        let Some(l) = silk_layer(&if mirror { flip_layer(pl) } else { pl.to_string() }) else { continue };+        let raw = t.text().unwrap_or("");+        let shown = match raw.strip_prefix('>') { Some(k) => match attr(k) { Some(v) => v, None => continue }, None => raw.to_string() };+        let (ta, tm) = parse_rot(t.attribute("rot"));+        // the element's transform on the text's own: mirrored once (either) reads mirrored; a mirrored+        // text's angle runs against the element's+        let deg = if tm { ta - ea } else { ta + ea };+        let p = place((f(&t, "x"), f(&t, "y")), at, ea, mirror);+        silk_text(o, &shown, p, f(&t, "size"), f(&t, "ratio"), (deg, tm != mirror, t.attribute("rot").unwrap_or("").contains('S')), t.attribute("align").unwrap_or(""), l);+    }+}+ /// A footprint position in the plan (KiCad frame) as the Fusion MOVE/ROTATE it needs: (x, y, "R<deg>"). pub fn to_fusion_xy(x: f64, y: f64) -> (f64, f64) { (x, -y) } pub fn to_fusion_rot(deg: f64) -> String { let d = ((deg % 360.0) + 360.0) % 360.0; format!("R{}", n(d)) }@@ -214,6 +314,52 @@ mod tests {         assert_eq!((b.bbox.0, b.bbox.2), (0.0, 20.0));         assert_eq!(super::to_fusion_xy(5.0, -4.5), (5.0, 4.5)); assert_eq!(super::to_fusion_rot(-90.0), "R270");     }+    /// EAGLE's MRฮธ: rotate ฮธ counterclockwise, then mirror x. A pad off both axes tells the order apart+    /// (mirror-then-rotate puts MR90's pad 1 at the opposite corner).+    #[test]+    fn a_bottom_part_turned_90_or_270_has_its_pads_where_fusion_does() {+        let brd = BRD.replace(r#"<smd name="1" x="-0.5" y="0""#, r#"<smd name="1" x="-0.5" y="0.2""#);+        for (rot, want) in [("R0", (9.5, 5.2)), ("R90", (9.8, 4.5)), ("MR0", (10.5, 5.2)), ("MR90", (10.2, 4.5)), ("MR180", (9.5, 4.8)), ("MR270", (9.8, 5.5))] {+            let text = super::to_kicad(&brd.replace(r#"rot="MR0""#, &format!(r#"rot="{rot}""#))).unwrap();+            let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();+            let p = b.pad_by_key("R2.1").unwrap();+            assert!((p.x - want.0).abs() < 1e-6 && (p.y + want.1).abs() < 1e-6, "{rot}: pad 1 at ({}, {}), want ({}, {})", p.x, -p.y, want.0, want.1);+            assert_eq!(p.on_b, rot.starts_with('M'), "{rot}");+        }+    }+    #[test]+    fn silkscreen_text_comes_along_on_the_layers_fusion_prints() {+        let brd = BRD.replace("</plain>", r#"<text x="2" y="3" size="1" layer="21">TOP</text><text x="4" y="5" size="1.2" layer="22" rot="MR0" align="center">BOTTOM</text><text x="1" y="1" size="1" layer="21" rot="R180">TURNED</text><text x="1" y="9" size="1" layer="51">DOC</text></plain>"#)+            .replace(r#"<package name="R0402">"#, r#"<package name="R0402"><text x="0" y="1" size="0.8" layer="25">&gt;NAME</text><text x="0" y="-1" size="0.8" layer="27">&gt;VALUE</text>"#)+            .replace(r#"x="5" y="5" rot="R90"/>"#, r#"x="5" y="5" rot="R90" smashed="yes"><attribute name="NAME" x="6" y="7" size="0.8" layer="25" rot="R90"/><attribute name="VALUE" x="6" y="8" size="0.8" layer="27"/></element><element name="R3" library="L" package="R0402" value="1k" x="15" y="5" smashed="yes"/>"#);+        let text = super::to_kicad(&brd).unwrap();+        let lines: Vec<&str> = text.lines().filter(|l| l.contains("(gr_text")).collect();+        // TOP, BOTTOM, TURNED; R1's smashed NAME (its VALUE is on tValues); R2's package >NAME through its+        // bottom-side transform (its >VALUE is tValues too); R3 is smashed with its NAME deleted: nothing+        assert_eq!(lines.len(), 5, "{lines:#?}");+        let has = |s: &str| lines.iter().any(|l| l.contains(s));+        assert!(has(r#"(gr_text "TOP" (at 2 -3 0) (layer "F.SilkS") (effects (font (size 1 1) (thickness 0.08)) (justify left bottom)))"#), "{lines:#?}");+        assert!(has(r#"(gr_text "BOTTOM" (at 4 -5 0) (layer "B.SilkS")"#) && has("(justify mirror)"), "{lines:#?}");+        assert!(has(r#"(gr_text "TURNED" (at 1 -1 0) (layer "F.SilkS")"#) && has("(justify right top)"), "readable, not upside down: {lines:#?}");+        assert!(has(r#"(gr_text "R1" (at 6 -7 90) (layer "F.SilkS")"#), "{lines:#?}");+        assert!(has(r#"(gr_text "R2" (at 10 -6 0) (layer "B.SilkS")"#) && has("(justify left bottom mirror)"), "{lines:#?}");+        assert!(!has("\"R3\"") && !has("10k") && !has("DOC"), "{lines:#?}");+        // the board still reads, and the footprints' own references stay hidden (no name counted twice)+        let b = crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();+        assert_eq!(b.footprints.len(), 3);+        assert!(text.lines().filter(|l| l.contains("(property \"Reference\"")).all(|l| l.contains("(hide yes)")));+    }+    /// Opt-in on a real Fusion board: AIFLOW_FUSION_BRD=<board.brd> cargo test -p aiflow-board -- --ignored real_fusion_board_silk+    #[test]+    #[ignore]+    fn real_fusion_board_silk() {+        let path = std::env::var("AIFLOW_FUSION_BRD").expect("AIFLOW_FUSION_BRD");+        let text = super::to_kicad(&std::fs::read_to_string(&path).unwrap()).unwrap();+        let b = crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();+        let (f, bk) = (text.lines().filter(|l| l.contains("(gr_text") && l.contains("F.SilkS")).count(), text.lines().filter(|l| l.contains("(gr_text") && l.contains("B.SilkS")).count());+        eprintln!("{path}: {} footprints, {f} top and {bk} bottom silkscreen texts", b.footprints.len());+        assert!(f + bk > 0);+    }     /// A 30 x 20 board with 1 mm corners as MITER leaves it (straight wires shortened, a curve="90" wire     /// at each corner), drawn either way round.     fn rounded(ccw: bool) -> String {
crates/aiflow-board/src/lib.rs+66−1
@@ -4,6 +4,7 @@ pub mod altium; pub mod eagle; pub mod geom;+pub mod netname; pub mod sx;  use std::collections::{BTreeMap, HashMap};@@ -90,6 +91,31 @@ pub struct Zone {     pub fills: Vec<(usize, Vec<Pt>)>, } +/// A drilled hole with no copper layer at all (an NPTH whose pad lists only `*.Mask`, such as a+/// USB-C shell peg). It is not a pad: it has no net and no copper, so pad, net, BOM and netlist+/// consumers never see it. Copper consumers (router, pours, via placement) keep clearance from it.+#[derive(Debug, Clone)]+pub struct DrillHole {+    pub reference: String,+    pub name: String,+    pub x: f64,+    pub y: f64,+    /// the hole's extent (width, height) before rotation; equal for a round hole+    pub size: (f64, f64),+    pub angle: f64,+    pub plated: bool,+    /// the hole's outline in board coordinates+    pub poly: Vec<Pt>,+    pub uuid: String,+}++impl DrillHole {+    /// The largest dimension: a round hole's diameter, an oval's length (conservative for clearance).+    pub fn diameter(&self) -> f64 {+        self.size.0.max(self.size.1)+    }+}+ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetFormat {     Number,@@ -108,6 +134,8 @@ pub struct Board {     pub bbox: (f64, f64, f64, f64),     pub nets: BTreeMap<String, Vec<usize>>,     pub copper_layers: Vec<String>,+    /// drilled holes with no copper (see `DrillHole`); never in `pads` or `nets`+    pub holes: Vec<DrillHole>,     pad_index: HashMap<String, usize>, } @@ -173,6 +201,39 @@ fn pad_local_polys(pn: &Node, shape: &str, sx: f64, sy: f64) -> Vec<Vec<Pt>> {     polys } +/// The hole of a pad with no copper layer, when it has a drill (`(drill d)` or `(drill oval w h)`,+/// optionally with an `(offset x y)`), in board coordinates.+fn drill_hole(pn: &Node, reference: &str, name: &str, ptype: &str, fx: f64, fy: f64, rot: f64) -> Option<DrillHole> {+    if ptype != "np_thru_hole" && ptype != "thru_hole" {+        return None;+    }+    let d = pn.find("drill")?;+    let (w, h) = if d.atom(1) == Some("oval") {+        let w = d.number(2)?;+        (w, d.number(3).unwrap_or(w))+    } else {+        let w = d.number(1)?;+        (w, d.number(2).unwrap_or(w))+    };+    if !(w > 0.0 && h > 0.0 && w.is_finite() && h.is_finite()) {+        return None;+    }+    let (ox, oy) = d.find("offset").map(|o| (o.number(1).unwrap_or(0.0), o.number(2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+    let pat = pn.find("at");+    let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0);+    let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);+    let pang = pat.and_then(|a| a.number(3)).unwrap_or(rot);+    let (rx, ry) = geom::rot_kicad(px, py, rot);+    let (x, y) = (fx + rx, fy + ry);+    let local = if (w - h).abs() < 1e-9 { geom::circle_poly(ox, oy, w / 2.0, 32) } else { geom::stadium_poly(w, h, 10).into_iter().map(|(a, b)| (a + ox, b + oy)).collect() };+    let poly: Vec<Pt> = local.iter().map(|&(a, b)| { let (qx, qy) = geom::rot_kicad(a, b, pang); (x + qx, y + qy) }).collect();+    let (ux, uy) = geom::rot_kicad(ox, oy, pang);+    Some(DrillHole {+        reference: reference.to_string(), name: name.to_string(), x: x + ux, y: y + uy, size: (w, h), angle: pang,+        plated: ptype == "thru_hole", poly, uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(),+    })+}+ pub fn reference_of(fp: &Node) -> Option<String> {     for p in fp.find_all("property") {         if p.atom(1) == Some("Reference") {@@ -204,7 +265,7 @@ impl Board {             }         }         let net_format = if net_table.is_empty() { NetFormat::Name } else { NetFormat::Number };-        let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), pad_index: HashMap::new() };+        let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), holes: Vec::new(), pad_index: HashMap::new() };         b.read_footprints();         b.outline = b.read_outline()?;         b.bbox = geom::bbox(&b.outline);@@ -258,6 +319,10 @@ impl Board {                     }                 }                 if !copper {+                    // a drilled hole with no copper (an NPTH with only mask layers): an obstacle, not a pad+                    if let Some(h) = drill_hole(pn, &reference, &name, ptype, fx, fy, rot) {+                        self.holes.push(h);+                    }                     continue;                 }                 let pat = pn.find("at");
crates/aiflow-board/src/netname.rsadded+198
@@ -0,0 +1,198 @@+//! Net names the way a spec and a board can agree on them. A board built from a hierarchical+//! schematic names root-sheet nets `/GND`, `/VIN`; a spec (or a flat board) says `GND`, `VIN`.+//! `netlist check` already treats the two as one net; the same rule resolves a spec's net names+//! against the board: an exact name always wins, and a name that differs only by the leading `/`+//! matches only when exactly one board net does.++use serde_json::Value;+use std::collections::BTreeSet;++/// A net name without the root sheet's leading `/` (`/VIN` -> `VIN`). A sub-sheet path+/// (`/power/VIN`) keeps its hierarchy.+pub fn norm_net(n: &str) -> String {+    let t = n.trim();+    t.strip_prefix('/').unwrap_or(t).to_string()+}++/// The board net a spec name means: the exact name when the board has it, else the one board net+/// equal to it under `norm_net`. None when there is none, or more than one (ambiguous).+pub fn resolve<'a, I>(name: &str, board_nets: I) -> Option<String>+where+    I: IntoIterator<Item = &'a String>,+{+    let want = norm_net(name);+    let mut hits: Vec<&String> = Vec::new();+    for n in board_nets {+        if n == name {+            return Some(n.clone());+        }+        if norm_net(n) == want {+            hits.push(n);+        }+    }+    (hits.len() == 1).then(|| hits[0].clone())+}++/// Board nets close to a name the board does not have, for a refusal: the same name up to the leading+/// `/`, else the same up to case.+pub fn close<'a, I>(name: &str, board_nets: I) -> Vec<String>+where+    I: IntoIterator<Item = &'a String>,+{+    let all: Vec<&String> = board_nets.into_iter().collect();+    let want = norm_net(name);+    let same: Vec<String> = all.iter().filter(|n| n.as_str() != name && norm_net(n) == want).map(|n| n.to_string()).collect();+    if !same.is_empty() {+        return same;+    }+    all.iter().filter(|n| n.as_str() != name && norm_net(n).eq_ignore_ascii_case(&want)).map(|n| n.to_string()).collect()+}++/// Every net name a `.kicad_pcb` declares or uses (`(net 3 "/GND")`, KiCad 10's `(net "/GND")`),+/// read without parsing the whole board.+pub fn names_in_text(text: &str) -> BTreeSet<String> {+    let mut out = BTreeSet::new();+    let b = text.as_bytes();+    let mut i = 0;+    while let Some(off) = text[i..].find("(net") {+        let mut j = i + off + 4;+        i = j;+        // `(net` followed by whitespace only (not `(net_name`, `(net_class`)+        if j >= b.len() || !b[j].is_ascii_whitespace() { continue; }+        while j < b.len() && b[j].is_ascii_whitespace() { j += 1; }+        while j < b.len() && b[j].is_ascii_digit() { j += 1; }+        while j < b.len() && b[j].is_ascii_whitespace() { j += 1; }+        if j >= b.len() || b[j] != b'"' { continue; }+        j += 1;+        let mut name = String::new();+        let mut chars = text[j..].char_indices();+        let mut end = None;+        while let Some((k, c)) = chars.next() {+            match c {+                '\\' => { if let Some((_, e)) = chars.next() { name.push(e); } }+                '"' => { end = Some(j + k + 1); break; }+                _ => name.push(c),+            }+        }+        if let Some(e) = end {+            if !name.is_empty() { out.insert(name); }+            i = e;+        }+    }+    out+}++/// Rewrite the net names a spec gives (pours[].net, planes, wideNets, midNets, netClearance and loads+/// keys, switchNodes, hot.*.tabNet, thermalVias[].net, stitchVias[].net, planeUnder[].net) to the+/// board's spelling when `resolve` finds exactly one. Returns `(spec name, board name)` per rename.+pub fn align_spec(spec: &mut Value, board_nets: &BTreeSet<String>) -> Vec<(String, String)> {+    let mut renames: Vec<(String, String)> = Vec::new();+    let mut fix = |s: &str| -> Option<String> {+        let r = resolve(s, board_nets.iter())?;+        if r == s { return None; }+        if !renames.iter().any(|(a, _)| a == s) { renames.push((s.to_string(), r.clone())); }+        Some(r)+    };+    let Some(obj) = spec.as_object_mut() else { return renames };+    // a field `key` holding a net name, in each object of an array (or one object)+    let in_items = |v: Option<&mut Value>, key: &str, fix: &mut dyn FnMut(&str) -> Option<String>| {+        let Some(v) = v else { return };+        let items: Vec<&mut Value> = match v { Value::Array(a) => a.iter_mut().collect(), Value::Object(_) => vec![v], _ => vec![] };+        for it in items {+            if let Some(n) = it.get(key).and_then(|n| n.as_str()).and_then(|n| fix(n)) { it[key] = Value::String(n); }+        }+    };+    in_items(obj.get_mut("pours"), "net", &mut fix);+    in_items(obj.get_mut("thermalVias"), "net", &mut fix);+    in_items(obj.get_mut("stitchVias"), "net", &mut fix);+    in_items(obj.get_mut("planeUnder"), "net", &mut fix);+    for key in ["planes", "wideNets", "midNets", "netClearance", "loads"] {+        let Some(m) = obj.get_mut(key).and_then(|v| v.as_object_mut()) else { continue };+        let keys: Vec<String> = m.keys().cloned().collect();+        for k in keys {+            if let Some(n) = fix(&k) {+                // the board's own spelling already in the map wins over the alias+                if m.contains_key(&n) { continue; }+                let v = m.remove(&k).unwrap();+                m.insert(n, v);+            }+        }+    }+    if let Some(a) = obj.get_mut("switchNodes").and_then(|v| v.as_array_mut()) {+        for x in a.iter_mut() {+            if let Some(n) = x.as_str().and_then(|s| fix(s)) { *x = Value::String(n); }+        }+    }+    if let Some(hot) = obj.get_mut("hot").and_then(|v| v.as_object_mut()) {+        for (_, h) in hot.iter_mut() {+            if let Some(n) = h.get("tabNet").and_then(|n| n.as_str()).and_then(|n| fix(n)) { h["tabNet"] = Value::String(n); }+        }+    }+    renames+}++#[cfg(test)]+mod tests {+    use super::*;+    use serde_json::json;++    fn set(a: &[&str]) -> BTreeSet<String> { a.iter().map(|s| s.to_string()).collect() }++    #[test]+    fn hierarchical_name_matches_when_unambiguous() {+        let b = set(&["/GND", "/VIN", "Net-(U1-SW)"]);+        assert_eq!(resolve("GND", b.iter()).as_deref(), Some("/GND"));+        assert_eq!(resolve("/VIN", b.iter()).as_deref(), Some("/VIN"));+        assert_eq!(resolve("VOUT", b.iter()), None);+        // the other way round: spec `/GND`, flat board `GND`+        assert_eq!(resolve("/GND", set(&["GND"]).iter()).as_deref(), Some("GND"));+        // a sub-sheet path keeps its hierarchy+        assert_eq!(resolve("VIN", set(&["/power/VIN"]).iter()), None);+    }++    #[test]+    fn exact_match_wins_over_the_alias() {+        let b = set(&["GND", "/GND"]);+        assert_eq!(resolve("GND", b.iter()).as_deref(), Some("GND"));+        assert_eq!(resolve("/GND", b.iter()).as_deref(), Some("/GND"));+    }++    #[test]+    fn close_names_the_board_spelling() {+        let b = set(&["/GND", "/VIN"]);+        assert_eq!(close("GND", b.iter()), vec!["/GND".to_string()]);+        assert_eq!(close("gnd", b.iter()), vec!["/GND".to_string()]);+        assert!(close("VOUT", b.iter()).is_empty());+    }++    #[test]+    fn names_from_board_text() {+        let t = r#"(kicad_pcb (net 0 "") (net 1 "/GND") (net 2 "/VIN") (footprint "x" (pad "1" smd rect (net "/SW")) (zone (net 1) (net_name "/GND"))))"#;+        assert_eq!(names_in_text(t), set(&["/GND", "/VIN", "/SW"]));+    }++    #[test]+    fn spec_names_follow_the_board() {+        let mut spec = json!({+            "pours": [{"net": "GND", "outline": true}, {"net": "VOUT"}],+            "planes": {"GND": "In1.Cu"}, "wideNets": {"VIN": [1.0]}, "midNets": {"/VIN": [0.5]},+            "hot": {"U1": {"watts": 0.2, "tabNet": "GND"}}, "switchNodes": ["SW"],+            "thermalVias": [{"pad": "U1.7", "net": "GND"}], "planeUnder": {"net": "GND", "layer": "B.Cu"}+        });+        let r = align_spec(&mut spec, &set(&["/GND", "/VIN", "/SW"]));+        assert_eq!(spec["pours"][0]["net"], "/GND");+        assert_eq!(spec["pours"][1]["net"], "VOUT");+        assert_eq!(spec["planes"], json!({"/GND": "In1.Cu"}));+        assert_eq!(spec["wideNets"], json!({"/VIN": [1.0]}));+        assert_eq!(spec["midNets"], json!({"/VIN": [0.5]}));+        assert_eq!(spec["hot"]["U1"]["tabNet"], "/GND");+        assert_eq!(spec["switchNodes"], json!(["/SW"]));+        assert_eq!(spec["thermalVias"][0]["net"], "/GND");+        assert_eq!(spec["planeUnder"]["net"], "/GND");+        assert!(r.contains(&("GND".into(), "/GND".into())));+        // both spellings on the board: nothing moves+        let mut s2 = json!({"pours": [{"net": "GND"}], "hot": {"U1": {"tabNet": "GND"}}});+        assert!(align_spec(&mut s2, &set(&["GND", "/GND"])).is_empty());+        assert_eq!(s2["pours"][0]["net"], "GND");+    }+}
crates/aiflow-bridge/src/lib.rs+189−13
@@ -66,6 +66,89 @@ fn read_zone_state_with(     json!({"success":false,"errorCode":"zone_readback_unsettled","lastReply":reply,"message":"No usable stable zone snapshot; inspect native state. Refill was not replayed."}) } +/// A reply's fields, also when ab wraps them as a JSON string in `output` or under `data`.+fn reply_fields(reply: &Value) -> Value {+    if let Some(out) = reply.get("output").and_then(|o| o.as_str()) {+        if let Ok(v) = serde_json::from_str::<Value>(out) { if v.is_object() { return v; } }+    }+    reply.get("data").filter(|d| d.is_object()).cloned().unwrap_or_else(|| reply.clone())+}++/// Megabytes the caller can still write, from a `desktop_disk_free` reply (availableBytes is+/// quota-aware; freeBytes is the volume's raw free space and is used only when it is absent).+pub fn disk_free_mb(reply: &Value) -> Option<u64> {+    let read = |v: &Value| v.get("availableBytes").or_else(|| v.get("freeBytes")).and_then(|b| b.as_u64());+    read(reply).or_else(|| read(&reply_fields(reply))).map(|b| b / 1_048_576)+}++/// The recording drive's free space through the native verb. LEGACY FALLBACK: only when the box's+/// Adom Bridge is older than 2.1.127 and answers `unknown_verb` does this ask the drive through a+/// human-gated `shell_execute` PowerShell query (a refusal or no answer gives None, and the disk+/// check falls back to the run drive). Remove the fallback once no supported box runs ab < 2.1.127.+fn remote_free_mb_with(mut call: impl FnMut(&str, &Value) -> Value) -> Option<u64> {+    let r = call("desktop_disk_free", &json!({}));+    if let Some(mb) = disk_free_mb(&r) { return Some(mb); }+    if r["errorCode"].as_str() != Some("unknown_verb") { return None; }+    let r = call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip (legacy Adom Bridge without desktop_disk_free)"}));+    let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");+    out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)+}++/// Whether a box's Adom Bridge predates pull_file's configurable whole-pull budget (2.1.175), the+/// only case where the PowerShell part splitter is still used for a clip pull_file could not land.+/// Since then pull_file streams 1 MiB binary frames, verifies sha256 and takes timeoutSeconds, so+/// splitting duplicates it. An unknown version is not treated as legacy.+pub fn legacy_pull_needs_split(ab_version: Option<&str>) -> bool {+    let Some(v) = ab_version else { return false };+    let parts: Vec<u64> = v.trim().trim_start_matches('v').split(['.', '-', '+']).take(3).map_while(|p| p.parse().ok()).collect();+    if parts.len() < 3 { return false; }+    (parts[0], parts[1], parts[2]) < (2, 1, 175)+}++/// What `kicad_model_check` says beyond resolution counts. `missing` lists model references whose+/// file does not exist, read from the complete `modelBindings` (KiCad Bridge 1.0.22+), else from the+/// capped `footprintsWithWarnings` of older bridges. `hidden` names the 3D viewer classes that hide+/// bodies on this board (viewer flag false with footprints of that class carrying a model) and every+/// model the bridge marks hidden. File resolution is not render proof; `evidence` says what was read.+#[derive(Debug, Default, PartialEq)]+pub struct ModelCheck {+    pub missing: Vec<String>,+    pub hidden: Vec<String>,+    pub bindings_complete: bool,+    pub evidence: String,+}++pub fn model_check_summary(d: &Value) -> ModelCheck {+    let d = if d.get("counts").is_some() || d.get("modelBindings").is_some() { d.clone() } else { reply_fields(d) };+    let mut out = ModelCheck { bindings_complete: d["bindingsComplete"].as_bool() == Some(true), evidence: d["evidenceSource"].as_str().unwrap_or("unknown").to_string(), ..Default::default() };+    if let Some(bindings) = d["modelBindings"].as_array() {+        for b in bindings {+            let r = b["reference"].as_str().unwrap_or("");+            let fp = b["footprint"].as_str().unwrap_or("");+            let models = b["models"].as_array().cloned().unwrap_or_default();+            let gone: Vec<String> = models.iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();+            if !gone.is_empty() { out.missing.push(format!("{fp} ({r}): {}", gone.join(", "))); }+            if models.iter().any(|m| m["hidden"].as_bool() == Some(true)) { out.hidden.push(format!("{r}: model marked hidden in the footprint")); }+        }+    } else {+        for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {+            let fp = f["footprint"].as_str().unwrap_or("");+            let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();+            let gone: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(str::to_string)).collect();+            if !gone.is_empty() { out.missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, gone.join(", "))); }+        }+    }+    if let (Some(flags), Some(by_class)) = (d["viewer"]["flags"].as_object(), d["footprintsWithModelByViewerClass"].as_object()) {+        for (class, n) in by_class {+            let n = n.as_u64().unwrap_or(0);+            if n > 0 && flags.get(class).and_then(|f| f.as_bool()) == Some(false) {+                out.hidden.push(format!("3D viewer hides class {class} ({n} footprint(s) with a model)"));+            }+        }+    }+    out+}+ /// What a clip stop brought back: the desktop path, where the pull puts it, and whether it landed. #[derive(Clone, Debug)] pub struct ClipStop {@@ -252,17 +335,21 @@ impl Bridge {     /// at most `attempts` pulls. Returns (landed, pulls made).     pub fn pull_verified(&self, remote: &str, save_to: &str, attempts: usize) -> (bool, usize) {         let local = landed_path(remote, save_to);+        // pull_file streams and sha256-verifies any size; a multi-GB clip over the mesh relay needs+        // the whole-pull budget (default 3600 s since ab 2.1.175, passed explicitly here)         let (landed, made) = pull_until_landed(&local, attempts,-            || { self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to})); },+            || { self.call("pull_file", &json!({"filePaths": [remote], "saveTo": save_to, "timeoutSeconds": 3600})); },             || std::thread::sleep(std::time::Duration::from_secs(3)));         if landed { return (true, made); }-        // a long clip can be gigabytes, more than one pull_file carries: split it on the desktop-        // into 200 MB parts with PowerShell (no extra software), pull each, join and check the size+        // LEGACY FALLBACK for ab < 2.1.175 only (fixed pull budget): split on the desktop+        let ab_version = self.raw_call("status", &json!({}))["version"].as_str().map(str::to_string);+        if !legacy_pull_needs_split(ab_version.as_deref()) { return (false, made); }         (self.pull_in_parts(remote, save_to), made + 1)     } -    /// Split a Windows file into 200 MB parts beside it, pull the parts, join them into the landed-    /// path and keep it only when its size matches the original. The parts are removed on both sides.+    /// LEGACY (ab < 2.1.175): split a Windows file into 200 MB parts beside it with a human-gated+    /// PowerShell shell_execute, pull the parts, join them into the landed path and keep it only+    /// when its size matches the original. The parts are removed on both sides.     pub fn pull_in_parts(&self, remote: &str, save_to: &str) -> bool {         let win = remote.replace('/', "\\");         let dir = format!("{win}.parts");@@ -371,17 +458,14 @@ impl Bridge {         vec![("native-zone-plan".into(), reply)]     } -    /// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait-    /// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.-    /// Free megabytes on the test box's recording drive (the temp folder's drive), via a shell query.+    /// Free megabytes on the test box's recording drive: Adom Bridge's native, read-only,+    /// ungated `desktop_disk_free` (ab 2.1.127+), whose default path is the recorder's output folder.     pub fn remote_free_mb(&self) -> Option<u64> {-        // shell_execute is human-gated on ab (Tier 2: it asks the person at the box and needs a reason);-        // when it is refused or unanswered this returns None and the check falls back to the run drive-        let r = self.call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip, so a run never fills the disk"}));-        let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");-        out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)+        remote_free_mb_with(|verb, args| self.raw_call(verb, args))     } +    /// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait+    /// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.     pub fn zone_state(&self, board: &str, refill: bool) -> Value {         read_zone_state_with(refill,             |fill| self.call("kicad_zone_state", &json!({"filePath":board,"refill":fill})),@@ -575,3 +659,95 @@ mod read_socket_tests {         assert_eq!(read_socket_args("kicad_placement_state",&Value::Null,Some("x")),Value::Null);     } }++#[cfg(test)]+mod native_preflight_tests {+    use serde_json::{json, Value};++    #[test]+    fn disk_free_uses_the_native_verb_and_prefers_available_bytes() {+        // the shape ab 2.1.182 returned on AdomLapper: fields at top level and again in `output`+        let reply = json!({"success":true,"availableBytes":5_856_219_136u64,"freeBytes":6_000_000_000u64,"output":"{\"availableBytes\":5856219136}"});+        assert_eq!(super::disk_free_mb(&reply), Some(5584));+        assert_eq!(super::disk_free_mb(&json!({"output":"{\"freeBytes\":2097152}"})), Some(2));+        let mut verbs = Vec::new();+        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); reply.clone() });+        assert_eq!((mb, verbs), (Some(5584), vec!["desktop_disk_free".to_string()]));+    }++    #[test]+    fn shell_fallback_only_on_a_bridge_without_the_verb() {+        let mut verbs = Vec::new();+        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); if v == "desktop_disk_free" { json!({"errorCode":"unknown_verb"}) } else { json!({"stdout":"3145728\r\n"}) } });+        assert_eq!((mb, verbs.len()), (Some(3), 2));+        assert_eq!(verbs[1], "shell_execute");+        // any other refusal of the native verb is not a reason to shell out+        let mut verbs = Vec::new();+        let mb = super::remote_free_mb_with(|v, _| { verbs.push(v.to_string()); json!({"errorCode":"target_not_found"}) });+        assert_eq!((mb, verbs.len()), (None, 1));+    }++    #[test]+    fn part_splitter_is_legacy_only() {+        assert!(super::legacy_pull_needs_split(Some("2.1.174")));+        assert!(super::legacy_pull_needs_split(Some("1.8.172")));+        assert!(!super::legacy_pull_needs_split(Some("2.1.175")));+        assert!(!super::legacy_pull_needs_split(Some("2.1.182")));+        assert!(!super::legacy_pull_needs_split(Some("3.0.0-beta")));+        assert!(!super::legacy_pull_needs_split(None));+        assert!(!super::legacy_pull_needs_split(Some("garbage")));+    }++    fn binding(r: &str, exists: bool, hidden: bool) -> Value {+        json!({"reference": r, "footprint": "Lib:Fp", "models": [{"reference": format!("${{X}}/{r}.step"), "exists": exists, "hidden": hidden}]})+    }++    #[test]+    fn model_check_reads_complete_bindings_and_viewer_classes() {+        // J1 of kicad-bridge#101: the file resolves, but the viewer hides not-in-POS bodies+        let d = json!({"counts":{"footprints":3},"bindingsComplete":true,"evidenceSource":"saved-board-file",+            "modelBindings":[binding("J1", true, false), binding("U1", false, false), binding("R1", true, true), {"reference":"TP1","models":[],"noModelExpected":true}],+            "footprintsWithWarnings":[],+            "viewer":{"flags":{"smd":true,"notInPosFile":false,"dnp":false}},+            "footprintsWithModelByViewerClass":{"smd":3,"notInPosFile":1,"dnp":0}});+        let s = super::model_check_summary(&d);+        assert_eq!(s.missing, vec!["Lib:Fp (U1): ${X}/U1.step".to_string()]);+        assert!(s.hidden.iter().any(|h| h.contains("notInPosFile (1")));+        assert!(!s.hidden.iter().any(|h| h.contains("dnp")), "a hidden class with no modelled footprints is not reported");+        assert!(s.hidden.iter().any(|h| h.starts_with("R1:")));+        assert!(s.bindings_complete);+        assert_eq!(s.evidence, "saved-board-file");+    }++    #[test]+    fn model_check_falls_back_to_warnings_on_older_bridges() {+        let d = json!({"data":{"counts":{},"footprintsWithWarnings":[{"footprint":"Lib:Fp","references":["U2","U3"],"models":[{"reference":"a.step","exists":false},{"reference":"b.step","exists":true}]}]}});+        let s = super::model_check_summary(&d);+        assert_eq!(s.missing, vec!["Lib:Fp (U2,U3): a.step".to_string()]);+        assert!(s.hidden.is_empty());+        assert!(!s.bindings_complete);+        assert_eq!(s.evidence, "unknown");+    }+}++#[cfg(test)]+mod native_preflight_live {+    /// Read-only acceptance on a real box: AIFLOW_LIVE_TARGET=AdomLapper cargo test -p aiflow-bridge -- --ignored native_preflight_live+    #[test]+    #[ignore = "needs a live Adom Bridge target (AIFLOW_LIVE_TARGET) and optionally AIFLOW_LIVE_BOARD"]+    fn native_preflight_live() {+        let target = std::env::var("AIFLOW_LIVE_TARGET").expect("AIFLOW_LIVE_TARGET");+        let br = super::Bridge { ai_thread: "aiflow".into(), target };+        let mb = br.remote_free_mb().expect("desktop_disk_free answered");+        eprintln!("recording drive free: {mb} MB");+        assert!(mb > 0);+        let ver = br.raw_call("status", &serde_json::json!({}))["version"].as_str().map(str::to_string);+        eprintln!("ab {ver:?}, legacy split: {}", super::legacy_pull_needs_split(ver.as_deref()));+        if let Ok(board) = std::env::var("AIFLOW_LIVE_BOARD") {+            let v = br.raw_call("kicad_model_check", &serde_json::json!({"filePath": board}));+            let s = super::model_check_summary(&v);+            eprintln!("{s:?}");+            assert!(s.bindings_complete);+        }+    }+}
crates/aiflow-copper/src/lib.rs+7
@@ -366,6 +366,13 @@ pub fn zone_fill_polygons(board: &Board, grid: &Grid, zone_net: &str, vias_by_ne             }         }     }+    // drilled holes with no copper (mask-only NPTH): the pour keeps its clearance from the hole+    for h in &board.holes {+        grid.poly_mask(&h.poly, &mut block);+        for c in grid.disk_cells((h.x, h.y), h.size.0.min(h.size.1) / 2.0, None) {+            block.bits[c] = true;+        }+    }     for (net, vias) in vias_by_net {         if net == zone_net {             continue;
crates/aiflow-place/src/intent.rsadded+460
@@ -0,0 +1,460 @@+//! Placement intent from the spec (issue #53): `placement.proximity`, `placement.cluster` and+//! `placement.anchor`.+//!+//! The AI keeps its freedom to place. These entries say what the placement must honour, and+//! `place check` / `place verify` fail when it does not, naming the parts, the pin and the distance.+//! `place pack` honours an anchor only in the simple case (members without their own wish follow+//! the anchor's wish, offsets kept); every other entry is checked, never acted on.+//!+//! ```json+//! "placement": {+//!   "proximity": [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}],+//!   "cluster":   [{"name": "buck", "refs": ["U4", "C7", "L1"], "radiusMm": 6, "anchor": "U4"},+//!                 {"refs": ["J1", "D1"], "region": "connector-edge"}],+//!   "regions":   {"connector-edge": [[0, 0], [8, 0], [8, 30], [0, 30]]},+//!   "anchor":    [{"ref": "U4", "cluster": "buck"}]+//! }+//! ```+//!+//! - proximity: pad edge to pad edge, the closest copper of `ref` to `nearRef`'s pad `pin` (or to+//!   any pad of `nearRef` when `pin` is absent).+//! - cluster with `radiusMm`: every member's pad-box centre within the radius of the anchor's+//!   pad-box centre, or of the members' mean centre when there is no anchor.+//! - cluster with `region`: every pad of every member inside the region (a name in+//!   `placement.regions`, or a polygon `[[x, y], ...]` in board mm).+use aiflow_board::{geom, Board, Pt};+use serde_json::{json, Value};++use crate::Wish;++#[derive(Clone, Debug, PartialEq)]+pub struct Proximity {+    pub reference: String,+    pub near_ref: String,+    pub pin: Option<String>,+    pub max_mm: f64,+}++#[derive(Clone, Debug, PartialEq)]+pub struct Region {+    pub name: String,+    pub poly: Vec<Pt>,+}++#[derive(Clone, Debug, PartialEq)]+pub struct Cluster {+    pub name: String,+    pub refs: Vec<String>,+    pub radius_mm: Option<f64>,+    pub region: Option<Region>,+    pub anchor: Option<String>,+}++#[derive(Clone, Debug, Default, PartialEq)]+pub struct Intent {+    pub proximity: Vec<Proximity>,+    pub clusters: Vec<Cluster>,+}++impl Intent {+    pub fn is_empty(&self) -> bool {+        self.proximity.is_empty() && self.clusters.is_empty()+    }+}++/// One checked entry.+#[derive(Clone, Debug)]+pub struct Verdict {+    pub kind: &'static str,+    pub name: String,+    pub pass: bool,+    pub measured_mm: Option<f64>,+    pub limit_mm: Option<f64>,+    pub text: String,+}++pub const SHAPE: &str = "placement.proximity: [{\"ref\": \"D2\", \"nearRef\": \"U4\", \"pin\": \"5\", \"maxMm\": 5}] (pad edge to pad edge); placement.cluster: [{\"name\": \"buck\", \"refs\": [\"U4\", \"C7\"], \"radiusMm\": 6 | \"region\": \"name\" or [[x, y], ...], \"anchor\": \"U4\"}]; placement.regions: {\"name\": [[x, y], ...]}; placement.anchor: [{\"ref\": \"U4\", \"cluster\": \"buck\"}].";++fn str_list(v: &Value) -> Option<Vec<String>> {+    v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())+}++fn as_items(v: Option<&Value>) -> Vec<Value> {+    match v {+        Some(Value::Array(a)) => a.clone(),+        Some(o @ Value::Object(_)) => vec![o.clone()],+        _ => Vec::new(),+    }+}++fn poly_of(v: &Value) -> Option<Vec<Pt>> {+    let a = v.as_array()?;+    let pts: Vec<Pt> = a.iter().filter_map(|p| Some((p.get(0)?.as_f64()?, p.get(1)?.as_f64()?))).collect();+    (pts.len() >= 3 && pts.len() == a.len()).then_some(pts)+}++/// The placement intent of a spec. An entry that does not parse, or names a region or cluster that+/// does not exist, is an error naming it.+pub fn from_spec(spec: &Value) -> Result<Intent, String> {+    let p = spec.get("placement").cloned().unwrap_or(Value::Null);+    let mut out = Intent::default();+    for (k, e) in as_items(p.get("proximity")).iter().enumerate() {+        let s = |key: &str| e.get(key).and_then(|v| v.as_str()).map(str::to_string);+        let reference = s("ref").ok_or(format!("placement.proximity[{k}]: ref is required"))?;+        let near_ref = s("nearRef").ok_or(format!("placement.proximity[{k}]: nearRef is required"))?;+        let pin = match e.get("pin") {+            None | Some(Value::Null) => None,+            Some(Value::String(x)) => Some(x.clone()),+            Some(Value::Number(n)) => Some(n.to_string()),+            Some(_) => return Err(format!("placement.proximity[{k}]: pin is a pad name such as \"5\"")),+        };+        let max_mm = e.get("maxMm").and_then(|v| v.as_f64()).filter(|m| *m > 0.0).ok_or(format!("placement.proximity[{k}]: maxMm must be a positive distance in mm"))?;+        out.proximity.push(Proximity { reference, near_ref, pin, max_mm });+    }+    let regions = p.get("regions").and_then(|r| r.as_object()).cloned().unwrap_or_default();+    let cluster_items = as_items(p.get("cluster").or_else(|| p.get("clusters")));+    for (k, e) in cluster_items.iter().enumerate() {+        let refs = e.get("refs").and_then(str_list).filter(|r| r.len() >= 2).ok_or(format!("placement.cluster[{k}]: refs needs two or more parts"))?;+        let name = e.get("name").and_then(|v| v.as_str()).map(str::to_string).unwrap_or_else(|| refs.join("+"));+        let radius_mm = match e.get("radiusMm") {+            None | Some(Value::Null) => None,+            Some(v) => Some(v.as_f64().filter(|r| *r > 0.0).ok_or(format!("placement.cluster[{k}] {name}: radiusMm must be a positive distance in mm"))?),+        };+        let region = match e.get("region") {+            None | Some(Value::Null) => None,+            Some(Value::String(rn)) => {+                let poly = regions.get(rn).and_then(poly_of).ok_or(format!("placement.cluster[{k}] {name}: region {rn:?} is not a polygon in placement.regions"))?;+                Some(Region { name: rn.clone(), poly })+            }+            Some(v) => Some(Region { name: format!("{name} region"), poly: poly_of(v).ok_or(format!("placement.cluster[{k}] {name}: region is a name in placement.regions or a polygon [[x, y], ...]"))? }),+        };+        if radius_mm.is_none() && region.is_none() && e.get("anchor").is_none() {+            return Err(format!("placement.cluster[{k}] {name}: give radiusMm or region (or an anchor)"));+        }+        let anchor = e.get("anchor").and_then(|v| v.as_str()).map(str::to_string);+        if let Some(a) = &anchor {+            if !refs.contains(a) {+                return Err(format!("placement.cluster[{k}] {name}: anchor {a} is not one of its refs"));+            }+        }+        out.clusters.push(Cluster { name, refs, radius_mm, region, anchor });+    }+    // placement.anchor: [{"ref", "cluster"}] or {"U4": "buck"}; {"ref", "refs": [...]} declares a+    // group that follows the anchor in `place pack` without a radius to verify+    let anchors: Vec<Value> = match p.get("anchor") {+        Some(Value::Object(m)) if !m.contains_key("ref") => m.iter().map(|(r, c)| json!({"ref": r, "cluster": c})).collect(),+        other => as_items(other),+    };+    for (k, e) in anchors.iter().enumerate() {+        let a = e.get("ref").and_then(|v| v.as_str()).ok_or(format!("placement.anchor[{k}]: ref is required"))?.to_string();+        if let Some(refs) = e.get("refs").and_then(str_list) {+            let mut refs = refs;+            if !refs.contains(&a) {+                refs.insert(0, a.clone());+            }+            out.clusters.push(Cluster { name: format!("{a} group"), refs, radius_mm: None, region: None, anchor: Some(a) });+            continue;+        }+        let c = e.get("cluster").and_then(|v| v.as_str()).ok_or(format!("placement.anchor[{k}] {a}: name its cluster (\"cluster\": \"buck\") or list \"refs\""))?;+        let cl = out.clusters.iter_mut().find(|x| x.name == c).ok_or(format!("placement.anchor[{k}] {a}: no placement.cluster named {c:?}"))?;+        if !cl.refs.contains(&a) {+            return Err(format!("placement.anchor[{k}]: {a} is not in cluster {c}"));+        }+        if cl.anchor.as_deref().map(|x| x != a).unwrap_or(false) {+            return Err(format!("placement.anchor[{k}]: cluster {c} already has anchor {}", cl.anchor.as_deref().unwrap_or("")));+        }+        cl.anchor = Some(a);+    }+    Ok(out)+}++fn r2(x: f64) -> f64 {+    (x * 100.0).round() / 100.0+}++fn segs_cross(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {+    let o = |p: Pt, q: Pt, r: Pt| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+    ((d1 > 0.0) != (d2 > 0.0)) && ((d3 > 0.0) != (d4 > 0.0))+}++/// Edge-to-edge distance of two polygons (0 when they touch or overlap).+pub fn poly_gap(pa: &[Pt], pb: &[Pt]) -> f64 {+    let mut best = f64::INFINITY;+    for v in pa {+        best = best.min(geom::point_poly_distance(v.0, v.1, pb));+    }+    for v in pb {+        best = best.min(geom::point_poly_distance(v.0, v.1, pa));+    }+    if best > 0.0 {+        for i in 0..pa.len() {+            for j in 0..pb.len() {+                if segs_cross(pa[i], pa[(i + 1) % pa.len()], pb[j], pb[(j + 1) % pb.len()]) {+                    return 0.0;+                }+            }+        }+    }+    best+}++fn centre(board: &Board, r: &str) -> Option<Pt> {+    if let Some((x0, y0, x1, y1)) = board.pad_box(r) {+        return Some(((x0 + x1) / 2.0, (y0 + y1) / 2.0));+    }+    board.footprints.get(r).map(|f| (f.x, f.y))+}++/// Check every intent entry on a board.+pub fn verify(board: &Board, intent: &Intent) -> Vec<Verdict> {+    let mut out = Vec::new();+    for p in &intent.proximity {+        let target = match &p.pin { Some(pin) => format!("{} pin {pin}", p.near_ref), None => p.near_ref.clone() };+        let name = format!("{} near {target}", p.reference);+        let fail = |text: String| Verdict { kind: "proximity", name: name.clone(), pass: false, measured_mm: None, limit_mm: Some(p.max_mm), text };+        let mine = board.pads_of(&p.reference);+        if mine.is_empty() {+            out.push(fail(format!("proximity {name}: {} is not on the board (or has no pads)", p.reference)));+            continue;+        }+        let near: Vec<_> = board.pads_of(&p.near_ref).into_iter().filter(|q| p.pin.as_ref().map(|n| &q.name == n).unwrap_or(true)).collect();+        if near.is_empty() {+            out.push(fail(if board.pads_of(&p.near_ref).is_empty() { format!("proximity {name}: {} is not on the board", p.near_ref) } else { format!("proximity {name}: {} has no pad {}", p.near_ref, p.pin.as_deref().unwrap_or("")) }));+            continue;+        }+        let mut best: Option<(f64, String, String)> = None;+        for a in &mine {+            for b in &near {+                for pa in &a.polys {+                    for pb in &b.polys {+                        let d = poly_gap(pa, pb);+                        if best.as_ref().map(|x| d < x.0).unwrap_or(true) {+                            best = Some((d, a.key.clone(), b.key.clone()));+                        }+                    }+                }+            }+        }+        let (d, ka, kb) = best.unwrap_or((f64::INFINITY, String::new(), String::new()));+        let pass = d <= p.max_mm + 1e-6;+        let text = format!("proximity {name}: {:.2} mm pad edge to pad edge ({ka} to {kb}), {} maxMm {}", d, if pass { "within" } else { "over" }, p.max_mm);+        out.push(Verdict { kind: "proximity", name, pass, measured_mm: Some(r2(d)), limit_mm: Some(p.max_mm), text });+    }+    for c in &intent.clusters {+        let missing: Vec<&String> = c.refs.iter().filter(|r| centre(board, r).is_none()).collect();+        if !missing.is_empty() {+            out.push(Verdict { kind: "cluster", name: c.name.clone(), pass: false, measured_mm: None, limit_mm: c.radius_mm, text: format!("cluster {}: {} not on the board", c.name, missing.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")) });+            continue;+        }+        if let Some(rad) = c.radius_mm {+            let pts: Vec<(String, Pt)> = c.refs.iter().map(|r| (r.clone(), centre(board, r).unwrap())).collect();+            let (ctr, from) = match &c.anchor {+                Some(a) => (centre(board, a).unwrap(), format!("anchor {a}")),+                None => ((pts.iter().map(|p| p.1 .0).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.1 .1).sum::<f64>() / pts.len() as f64), "the members' mean centre".to_string()),+            };+            let dist: Vec<(String, f64)> = pts.iter().map(|(r, p)| (r.clone(), (p.0 - ctr.0).hypot(p.1 - ctr.1))).collect();+            let worst = dist.iter().cloned().fold((String::new(), 0.0), |w, x| if x.1 > w.1 { x } else { w });+            let over: Vec<String> = dist.iter().filter(|x| x.1 > rad + 1e-6).map(|x| format!("{} at {:.2} mm", x.0, x.1)).collect();+            let pass = over.is_empty();+            let text = if pass {+                format!("cluster {}: every member within radiusMm {rad} of {from} (farthest {} at {:.2} mm)", c.name, worst.0, worst.1)+            } else {+                format!("cluster {}: {} outside radiusMm {rad} of {from} (pad-box centres)", c.name, over.join(", "))+            };+            out.push(Verdict { kind: "cluster", name: c.name.clone(), pass, measured_mm: Some(r2(worst.1)), limit_mm: Some(rad), text });+        }+        if let Some(reg) = &c.region {+            let mut outside = Vec::new();+            for r in &c.refs {+                let bad: Vec<String> = board.pads_of(r).iter().filter(|p| p.polys.iter().flatten().any(|v| !geom::point_in_poly(v.0, v.1, &reg.poly) && geom::point_poly_distance(v.0, v.1, &reg.poly) > 1e-6)).map(|p| p.key.clone()).collect();+                if !bad.is_empty() {+                    outside.push(format!("{r} ({})", bad.join(", ")));+                }+            }+            let pass = outside.is_empty();+            let text = if pass { format!("cluster {}: every member inside region {}", c.name, reg.name) } else { format!("cluster {}: pads outside region {}: {}", c.name, reg.name, outside.join("; ")) };+            out.push(Verdict { kind: "region", name: c.name.clone(), pass, measured_mm: None, limit_mm: None, text });+        }+        if c.radius_mm.is_none() && c.region.is_none() {+            out.push(Verdict { kind: "anchor", name: c.name.clone(), pass: true, measured_mm: None, limit_mm: None, text: format!("anchor group {}: intent only (no radius or region to verify)", c.name) });+        }+    }+    out+}++/// The verdicts as a JSON report.+pub fn report(v: &[Verdict]) -> Value {+    let failed = v.iter().filter(|x| !x.pass).count();+    json!({"pass": failed == 0, "entries": v.len(), "failed": failed,+        "verdicts": v.iter().map(|x| json!({"kind": x.kind, "name": x.name, "pass": x.pass, "measuredMm": x.measured_mm, "limitMm": x.limit_mm, "text": x.text})).collect::<Vec<_>>(),+        "method": "proximity: closest pad copper edge to edge; cluster radius: pad-box centres from the anchor (or the members' mean centre); region: every pad corner inside the polygon"})+}++/// `place pack`: a member of an anchored cluster that has no wish of its own follows the anchor's+/// wish, keeping its present offset from the anchor (turned with the anchor when the anchor's wish+/// names one rotation). Members with their own wish keep it. Anything else is left to the AI.+pub fn apply_anchors(board: &Board, wishes: &[(String, Wish)], intent: &Intent) -> (Vec<(String, Wish)>, Vec<String>) {+    let mut out = wishes.to_vec();+    let mut lines = Vec::new();+    for c in &intent.clusters {+        let Some(a) = &c.anchor else { continue };+        let Some(pos) = out.iter().position(|(k, _)| k == a) else {+            lines.push(format!("anchor {a} ({}): not applied, the anchor has no wish", c.name));+            continue;+        };+        let aw = out[pos].1.clone();+        let Some(afp) = board.footprints.get(a) else { continue };+        if aw.rot.len() != 1 {+            lines.push(format!("anchor {a} ({}): not applied, its wish has {} rotations; give it one so the group can turn with it", c.name, aw.rot.len()));+            continue;+        }+        let turn = aw.rot[0] - afp.rot;+        let mut dragged = Vec::new();+        let mut insert_at = pos + 1;+        for m in &c.refs {+            if m == a || out.iter().any(|(k, _)| k == m) {+                continue;+            }+            let Some(fp) = board.footprints.get(m) else { continue };+            let (dx, dy) = geom::rot_kicad(fp.x - afp.x, fp.y - afp.y, turn);+            let rot = ((fp.rot + turn) % 360.0 + 360.0) % 360.0;+            let w = Wish { x: r2(aw.x + dx), y: r2(aw.y + dy), rot: vec![rot], radius: 2.0, keepout: Vec::new(), gap: aw.gap };+            out.insert(insert_at, (m.clone(), w));+            insert_at += 1;+            dragged.push(m.clone());+        }+        if dragged.is_empty() {+            lines.push(format!("anchor {a} ({}): every member has its own wish; nothing dragged", c.name));+        } else {+            lines.push(format!("anchor {a} ({}): drags {} with it (offsets kept{})", c.name, dragged.join(", "), if turn.abs() > 1e-9 { format!(", turned {turn}") } else { String::new() }));+        }+    }+    (out, lines)+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A 40 x 30 mm board. U4: a 6-pad part at (10, 10), pads 1..6 at x = -1.25 / 1.25, y = -1, 0, 1.+    /// Other parts: two pads 1 mm apart on x.+    fn board(parts: &[(&str, f64, f64)]) -> Board {+        let mut t = String::from("(kicad_pcb\n\t(version 20260206)\n\t(generator \"test\")\n\t(layers\n\t\t(0 \"F.Cu\" signal)\n\t\t(2 \"B.Cu\" signal)\n\t\t(25 \"Edge.Cuts\" user)\n\t)\n");+        let pad = |name: &str, dx: f64, dy: f64| format!("\t\t(pad \"{name}\" smd rect\n\t\t\t(at {dx} {dy})\n\t\t\t(size 0.6 0.6)\n\t\t\t(layers \"F.Cu\")\n\t\t\t(net \"N{name}\")\n\t\t)\n");+        t.push_str("\t(footprint \"T:U\"\n\t\t(layer \"F.Cu\")\n\t\t(at 10 10)\n\t\t(property \"Reference\" \"U4\")\n");+        for (k, (dx, dy)) in [(-1.25, -1.0), (-1.25, 0.0), (-1.25, 1.0), (1.25, 1.0), (1.25, 0.0), (1.25, -1.0)].iter().enumerate() {+            t.push_str(&pad(&(k + 1).to_string(), *dx, *dy));+        }+        t.push_str("\t)\n");+        for (r, x, y) in parts {+            t.push_str(&format!("\t(footprint \"T:R\"\n\t\t(layer \"F.Cu\")\n\t\t(at {x} {y})\n\t\t(property \"Reference\" \"{r}\")\n{}{}\t)\n", pad("1", -0.5, 0.0), pad("2", 0.5, 0.0)));+        }+        t.push_str("\t(gr_rect\n\t\t(start 0 0)\n\t\t(end 40 30)\n\t\t(layer \"Edge.Cuts\")\n\t)\n)\n");+        Board::from_text("test.kicad_pcb", t).unwrap()+    }++    fn spec_d2() -> Value {+        json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}]}})+    }++    #[test]+    fn proximity_fails_with_the_distance_and_the_pin_named() {+        let intent = from_spec(&spec_d2()).unwrap();+        // U4 pin 5 is at (11.25, 10), its right edge at 11.55. D2 pad 1 left edge at x - 0.8.+        // D2 at x = 20.35: pad 1 left edge 19.55, 8.00 mm from pin 5's edge+        let v = verify(&board(&[("D2", 20.35, 10.0)]), &intent);+        assert_eq!(v.len(), 1);+        assert!(!v[0].pass, "{:?}", v[0]);+        assert_eq!(v[0].measured_mm, Some(8.0));+        assert!(v[0].text.contains("8.00 mm") && v[0].text.contains("U4 pin 5") && v[0].text.contains("U4.5") && v[0].text.contains("over maxMm 5"), "{}", v[0].text);+        // at 4 mm it passes+        let v = verify(&board(&[("D2", 16.35, 10.0)]), &intent);+        assert!(v[0].pass, "{}", v[0].text);+        assert_eq!(v[0].measured_mm, Some(4.0));+        // the pin matters: pin 2 is on U4's far side+        let far = from_spec(&json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": 2, "maxMm": 5}]}})).unwrap();+        let v = verify(&board(&[("D2", 16.35, 10.0)]), &far);+        assert!(!v[0].pass && v[0].text.contains("U4 pin 2"), "{}", v[0].text);+        // a missing pin or part is a failure that says so+        let bad = from_spec(&json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "pin": "9", "maxMm": 5}, {"ref": "D9", "nearRef": "U4", "maxMm": 5}]}})).unwrap();+        let v = verify(&board(&[("D2", 16.35, 10.0)]), &bad);+        assert!(v[0].text.contains("has no pad 9") && !v[0].pass);+        assert!(v[1].text.contains("D9 is not on the board") && !v[1].pass);+    }++    #[test]+    fn overlapping_pads_are_zero_apart() {+        let a = vec![(0.0, -1.0), (1.0, -1.0), (1.0, 1.0), (0.0, 1.0)];+        let b = vec![(-1.0, 0.0), (2.0, 0.0), (2.0, 0.5), (-1.0, 0.5)];+        assert_eq!(poly_gap(&a, &b), 0.0);+        let c = vec![(3.0, 0.0), (4.0, 0.0), (4.0, 1.0), (3.0, 1.0)];+        assert!((poly_gap(&a, &c) - 2.0).abs() < 1e-9);+    }++    #[test]+    fn clusters_by_radius_and_region() {+        let spec = json!({"placement": {+            "cluster": [{"name": "buck", "refs": ["U4", "C1", "C2"], "radiusMm": 4, "anchor": "U4"},+                        {"name": "edge", "refs": ["C1", "C2"], "region": "left"}],+            "regions": {"left": [[0, 0], [15, 0], [15, 30], [0, 30]]}}});+        let intent = from_spec(&spec).unwrap();+        let v = verify(&board(&[("C1", 12.0, 13.0), ("C2", 14.0, 10.0)]), &intent);+        assert!(v.iter().all(|x| x.pass), "{v:?}");+        let v = verify(&board(&[("C1", 12.0, 13.0), ("C2", 16.0, 10.0)]), &intent);+        let rad = v.iter().find(|x| x.kind == "cluster").unwrap();+        assert!(!rad.pass && rad.text.contains("C2 at 6.00 mm") && rad.text.contains("anchor U4"), "{}", rad.text);+        let reg = v.iter().find(|x| x.kind == "region").unwrap();+        assert!(!reg.pass && reg.text.contains("C2 (C2.1, C2.2)") && !reg.text.contains("C1"), "{}", reg.text);+        // without an anchor the centre is the members' mean+        let mean = from_spec(&json!({"placement": {"cluster": [{"refs": ["C1", "C2"], "radiusMm": 2}]}})).unwrap();+        let v = verify(&board(&[("C1", 10.0, 20.0), ("C2", 13.0, 20.0)]), &mean);+        assert!(v[0].pass, "{}", v[0].text);+        let v = verify(&board(&[("C1", 10.0, 20.0), ("C2", 15.0, 20.0)]), &mean);+        assert!(!v[0].pass && v[0].text.contains("mean centre"), "{}", v[0].text);+    }++    #[test]+    fn bad_entries_are_named() {+        for (s, want) in [+            (json!({"placement": {"proximity": [{"ref": "D2", "nearRef": "U4", "maxMm": 0}]}}), "maxMm"),+            (json!({"placement": {"proximity": [{"nearRef": "U4", "maxMm": 2}]}}), "ref is required"),+            (json!({"placement": {"cluster": [{"refs": ["A"], "radiusMm": 2}]}}), "two or more"),+            (json!({"placement": {"cluster": [{"refs": ["A", "B"]}]}}), "radiusMm or region"),+            (json!({"placement": {"cluster": [{"refs": ["A", "B"], "region": "nowhere"}]}}), "nowhere"),+            (json!({"placement": {"cluster": [{"refs": ["A", "B"], "radiusMm": 2, "anchor": "C"}]}}), "anchor C"),+            (json!({"placement": {"anchor": [{"ref": "U4", "cluster": "buck"}]}}), "no placement.cluster named"),+        ] {+            let e = from_spec(&s).unwrap_err();+            assert!(e.contains(want), "{e}");+        }+        assert!(from_spec(&json!({})).unwrap().is_empty());+        assert!(from_spec(&json!({"placement": {"edgeConnectors": {}}})).unwrap().is_empty());+        // placement.anchor names the cluster's anchor; the map form works too+        let i = from_spec(&json!({"placement": {"cluster": [{"name": "buck", "refs": ["U4", "C1"], "radiusMm": 3}], "anchor": {"U4": "buck"}}})).unwrap();+        assert_eq!(i.clusters[0].anchor.as_deref(), Some("U4"));+    }++    #[test]+    fn pack_drags_unwished_members_with_the_anchor() {+        let b = board(&[("C1", 12.0, 13.0), ("C2", 14.0, 10.0), ("C3", 30.0, 20.0)]);+        let intent = from_spec(&json!({"placement": {"anchor": [{"ref": "U4", "refs": ["C1", "C2", "C3"]}]}})).unwrap();+        let wish = |x, y, r: f64| Wish { x, y, rot: vec![r], radius: 3.0, keepout: Vec::new(), gap: None };+        let wishes = vec![("U4".to_string(), wish(20.0, 15.0, 0.0)), ("C3".to_string(), wish(5.0, 5.0, 0.0))];+        let (out, lines) = apply_anchors(&b, &wishes, &intent);+        let get = |r: &str| out.iter().find(|(k, _)| k == r).map(|(_, w)| w.clone()).unwrap();+        assert_eq!((get("C1").x, get("C1").y), (22.0, 18.0));+        assert_eq!((get("C2").x, get("C2").y), (24.0, 15.0));+        assert_eq!((get("C3").x, get("C3").y), (5.0, 5.0), "a member with its own wish keeps it");+        assert_eq!(out[1].0, "C1", "dragged members pack right after their anchor");+        assert!(lines[0].contains("drags C1, C2"), "{lines:?}");+        // the anchor turned 90: the offsets turn with it (KiCad rotation, y down)+        let (out, _) = apply_anchors(&b, &[("U4".to_string(), wish(20.0, 15.0, 90.0))], &intent);+        let c2 = out.iter().find(|(k, _)| k == "C2").unwrap().1.clone();+        assert_eq!((c2.x, c2.y, c2.rot[0]), (20.0, 11.0, 90.0));+        // several rotations on the anchor: not applied, said+        let (out, lines) = apply_anchors(&b, &[("U4".to_string(), Wish { x: 20.0, y: 15.0, rot: vec![0.0, 90.0], radius: 3.0, keepout: Vec::new(), gap: None })], &intent);+        assert_eq!(out.len(), 1);+        assert!(lines[0].contains("not applied"));+    }+}
crates/aiflow-place/src/lib.rs+2
@@ -3,6 +3,8 @@ //! //! The AI decides where parts go; these are the mechanics that keep a decision legal before it //! reaches the live board (the bridge's `kicad_move_footprint` checks courtyards exactly when it lands).+pub mod intent;+ use std::collections::BTreeMap;  use aiflow_board::{geom, num, Board, Pt};
crates/aiflow-pours/src/lib.rs+106−9
@@ -87,10 +87,13 @@ pub fn hole_to_hole(spec: &Value) -> f64 {     spec.get("holeToHoleClearance").or_else(|| spec.get("holeToHole")).and_then(|v| v.as_f64()).unwrap_or(0.2) } -/// Every hole already on the board: its vias, and its through-hole pads (copper to the pad's long half).+/// Every hole already on the board: its vias, its through-hole pads (copper to the pad's long half),+/// and its drilled holes with no copper. pub fn board_holes(board: &Board) -> Vec<Hole> {     let mut out: Vec<Hole> = board.vias().into_iter().map(|v| (v.x, v.y, v.size, v.drill, v.net)).collect();     out.extend(board.pads.iter().filter(|p| p.thru).filter_map(|p| Some((p.x, p.y, 2.0 * p.half_long, p.drill?, p.net.clone()))));+    // drilled holes with no copper (mask-only NPTH): the hole is its own extent, no net+    out.extend(board.holes.iter().map(|h| (h.x, h.y, h.diameter(), h.diameter(), String::new())));     out } @@ -120,6 +123,27 @@ pub fn hole_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hol     }) } +/// The pad connection and island settings a pour may carry, so a reviewed plan can describe a zone+/// exactly: `connection` thermal (default), solid (or full) or none; `thermalGap` and+/// `thermalBridge` in mm; `islandRemoval` always (default), never or area, with `islandAreaMin` in mm2.+pub fn check_settings(z: &Value) -> Result<(), String> {+    if !matches!(z.get("connection").map(|c| c.as_str()), None | Some(Some("thermal" | "solid" | "full" | "none"))) {+        return Err(format!("connection {} is not thermal, solid or none", z["connection"]));+    }+    if !matches!(z.get("islandRemoval").map(|c| c.as_str()), None | Some(Some("always" | "never" | "area"))) {+        return Err(format!("islandRemoval {} is not always, never or area", z["islandRemoval"]));+    }+    for key in ["thermalGap", "thermalBridge", "islandAreaMin", "clearance", "minThickness"] {+        if z.get(key).is_some_and(|v| !v.as_f64().is_some_and(|x| x.is_finite() && x >= 0.0)) {+            return Err(format!("{key} must be a number of mm (mm2 for islandAreaMin), zero or more"));+        }+    }+    if z.get("islandAreaMin").is_some() && z.get("islandRemoval").and_then(|v| v.as_str()) != Some("area") {+        return Err("islandAreaMin applies only with islandRemoval area".into());+    }+    Ok(())+}+ /// (zones in the kicad_add_zone shape, via plan entries, keepout count) from the spec and the routing plan. pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> { build_with(board, spec, plan, &[]) } @@ -132,16 +156,19 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[     let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);     let mut zones: Vec<Value> = Vec::new();     for e in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() {-        let net = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string();-        if !board.nets.contains_key(&net) {-            return Err(format!("pour {net}: the board has no pads on that net"));-        }+        let named = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string();+        // `GND` in the spec is the board's `/GND` when that is the only match (exact names win)+        let Some(net) = aiflow_board::netname::resolve(&named, board.nets.keys()) else {+            let near = aiflow_board::netname::close(&named, board.nets.keys());+            return Err(if near.is_empty() { format!("pour {named}: the board has no pads on that net") } else { format!("pour {named}: the board has no pads on that net; the board has {} (name that in the spec)", near.join(", ")) });+        };         let mut z = serde_json::Map::new();         for (k, v) in e.as_object().unwrap() {             if !matches!(k.as_str(), "around" | "margin" | "exclude" | "polygon" | "outline" | "inset") {                 z.insert(k.clone(), v.clone());             }         }+        z.insert("net".into(), json!(net));         z.entry("clearance").or_insert(json!(clearance));         z.entry("minThickness").or_insert(json!(0.25));         if e.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {@@ -169,6 +196,7 @@ pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[             return Err(format!("pour {net} needs outline, polygon or around"));         }         z.entry("name").or_insert(json!(format!("{net} pour")));+        check_settings(&Value::Object(z.clone())).map_err(|e| format!("pour {net}: {e}"))?;         zones.push(Value::Object(z));     }     for key in strs(spec.get("solidAt")) {@@ -422,16 +450,28 @@ pub fn zone_sexpr(board: &Board, z: &Value) -> Result<String, String> {         NetFormat::Number => format!("{}\t\t(net_name {})\n", board.net_ref_sexpr(net)?, quote(net)),         NetFormat::Name => board.net_ref_sexpr(net)?,     };-    let solid = matches!(z.get("connection").and_then(|c| c.as_str()), Some("solid") | Some("full"));+    let connect = match z.get("connection").and_then(|c| c.as_str()) {+        None | Some("thermal") => "",+        Some("solid") | Some("full") => " yes",+        Some("none") => " no",+        Some(other) => return Err(format!("zone connection {other:?}: use thermal, solid or none")),+    };+    // KiCad's island removal: 0 always (the default, not written), 1 never, 2 below an area+    let island = match z.get("islandRemoval").and_then(|c| c.as_str()) {+        None | Some("always") => String::new(),+        Some("never") => "\t\t\t(island_removal_mode 1)\n".to_string(),+        Some("area") => format!("\t\t\t(island_removal_mode 2)\n\t\t\t(island_area_min {})\n", num(z.get("islandAreaMin").and_then(|v| v.as_f64()).unwrap_or(10.0))),+        Some(other) => return Err(format!("zone islandRemoval {other:?}: use always, never or area")),+    };     let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);     let clearance = z.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);     let min_t = z.get("minThickness").and_then(|v| v.as_f64()).unwrap_or(0.25);     let gap = z.get("thermalGap").and_then(|v| v.as_f64()).unwrap_or(0.5);     let bridge = z.get("thermalBridge").and_then(|v| v.as_f64()).unwrap_or(0.5);-    Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n{}{}\t\t(min_thickness {})\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",+    Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n{}{}\t\t(min_thickness {})\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n{}\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",         net_lines, layer_line, uuid, quote(name), if prio > 0 { format!("\t\t(priority {prio})\n") } else { String::new() },-        if solid { format!("\t\t(connect_pads yes\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)) } else { format!("\t\t(connect_pads\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)) },-        num(min_t), num(gap), num(bridge), pts.join(" ")))+        format!("\t\t(connect_pads{connect}\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)),+        num(min_t), num(gap), num(bridge), island, pts.join(" "))) }  /// Summary line per zone for the human.@@ -493,6 +533,41 @@ mod hole_tests {         assert!(hole_clear(10.6, 10.0, 0.8, 0.4, "GND", &planned, 0.2, 0.2));         assert!(!hole_clear(10.6, 10.0, 0.8, 0.4, "VIN", &planned, 0.2, 0.2), "another net also needs copper clearance");     }+    #[test]+    fn pour_settings_reach_the_zone_block() {+        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"R\" (layer \"F.Cu\") (at 5 5) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();+        let spec = json!({"pours": [{"net": "GND", "layers": ["F.Cu"], "polygon": [[0, 0], [10, 0], [10, 10]], "connection": "none", "thermalGap": 0.3, "thermalBridge": 0.4, "islandRemoval": "area", "islandAreaMin": 2.5}]});+        let (zones, _, _) = build(&b, &spec, None).unwrap();+        assert_eq!(zones[0]["islandRemoval"], "area");+        let text = zone_sexpr(&b, &zones[0]).unwrap();+        for want in ["(connect_pads no", "(thermal_gap 0.3)", "(thermal_bridge_width 0.4)", "(island_removal_mode 2)", "(island_area_min 2.5)"] {+            assert!(text.contains(want), "{want} in {text}");+        }+        let mut z = zones[0].clone();+        z["islandRemoval"] = json!("never");+        z.as_object_mut().unwrap().remove("islandAreaMin");+        assert!(zone_sexpr(&b, &z).unwrap().contains("(island_removal_mode 1)"));+        z["connection"] = json!("solid");+        assert!(zone_sexpr(&b, &z).unwrap().contains("(connect_pads yes"));+        for bad in [json!({"connection": "spokes"}), json!({"islandRemoval": "sometimes"}), json!({"islandAreaMin": 3}), json!({"thermalGap": -1})] {+            let mut spec = spec.clone();+            spec["pours"][0]["islandRemoval"] = json!("always");+            spec["pours"][0].as_object_mut().unwrap().remove("islandAreaMin");+            for (k, v) in bad.as_object().unwrap() { spec["pours"][0][k] = v.clone(); }+            assert!(build(&b, &spec, None).is_err(), "{bad}");+        }+    }+    #[test]+    fn mask_only_npth_is_a_board_hole_for_stitch_vias() {+        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"T:USBC\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"J9\") (pad \"\" np_thru_hole circle (at 0 0) (size 0.65 0.65) (drill 0.65) (layers \"*.Mask\"))))".into()).unwrap();+        assert!(b.pads.is_empty() && b.holes.len() == 1);+        let holes = board_holes(&b);+        assert_eq!(holes.len(), 1);+        assert!(!hole_clear(10.4, 10.0, 0.6, 0.3, "GND", &holes, 0.25, 0.2), "a via next to the peg keeps hole clearance");+        let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});+        let (_, vias, _) = build_with(&b, &spec, None, &[]).unwrap();+        assert_eq!(vias.len(), 1, "the via on the peg is left out");+    } }  #[cfg(test)]@@ -509,3 +584,25 @@ mod plane_tests {   assert_eq!(missing_planes(&make(&keepout),&spec).len(),1);  } }++#[cfg(test)]+mod net_name_tests {+    use super::*;+    fn board() -> Board {+        Board::from_text("fixture", r#"(kicad_pcb (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (net 0 "") (net 1 "/GND")+ (gr_rect (start 0 0)(end 20 20)(layer "Edge.Cuts"))+ (footprint "R" (layer "F.Cu") (at 10 10) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "/GND"))))"#.to_string()).unwrap()+    }+    #[test]+    fn spec_gnd_pours_the_board_hierarchical_gnd() {+        let (zones, _, _) = build(&board(), &json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]}]}), None).unwrap();+        assert_eq!(zones[0]["net"], "/GND");+    }+    #[test]+    fn refusal_names_the_close_board_net() {+        let e = build(&board(), &json!({"pours": [{"net": "gnd", "outline": true}]}), None).unwrap_err();+        assert!(e.contains("no pads on that net") && e.contains("/GND"), "{e}");+        let e = build(&board(), &json!({"pours": [{"net": "VOUT", "outline": true}]}), None).unwrap_err();+        assert_eq!(e, "pour VOUT: the board has no pads on that net");+    }+}\ No newline at end of file
crates/aiflow-router/src/board_copper.rs+31
@@ -240,6 +240,37 @@ pub(crate) mod tests {         assert!(r.via_clear_exact(net, 13.0, 5.0));     } +    #[test]+    fn mask_only_npth_blocks_vias_and_tracks_without_becoming_a_pad() {+        // a USB-C receptacle: one signal pad and two shell pegs drilled with no copper layer at all+        let extra = r#"(footprint "T:USBC"+          (layer "F.Cu") (at 10 5)+          (property "Reference" "J9")+          (pad "A5" smd rect (at -3 -3) (size 0.3 1) (layers "F.Cu" "F.Mask") (net "A"))+          (pad "" np_thru_hole circle (at 0 0) (size 0.65 0.65)+            (drill 0.65) (layers "*.Mask"))+          (pad "" np_thru_hole oval (at 3 0 90) (size 0.6 1.2)+            (drill oval 0.6 1.2) (layers "F.Mask" "B.Mask")))"#;+        let b = board(&[("J1", "A", 2.0, 5.0, true)], extra);+        assert_eq!(b.holes.len(), 2, "both mask-only drills are holes");+        assert_eq!(b.pads_of("J9").len(), 1, "a hole is not a pad");+        assert_eq!(b.nets["A"].len(), 2);+        assert!(b.nets.keys().all(|n| !n.is_empty()));+        assert!((b.holes[0].x - 10.0).abs() < 1e-9 && (b.holes[0].y - 5.0).abs() < 1e-9);+        assert!((b.holes[1].diameter() - 1.2).abs() < 1e-9);+        let mut r = Router::new(b, Rules::default(), false);+        let net = r.net_ids["A"];+        let (i, j) = r.grid.to_cell(10.0, 5.0);+        assert!(r.thru_cells.get(i as usize, j as usize));+        assert_eq!(r.pad_raw[F_CU][j as usize * r.grid.w + i as usize], -2, "physical, no net");+        assert_eq!(r.pad_raw[B_CU][j as usize * r.grid.w + i as usize], -2);+        // a via on the peg, or within hole clearance of it, is refused; one well clear is not+        assert!(!r.via_clear_exact(net, 10.0, 5.0));+        assert!(!r.via_clear_exact(net, 10.0, 5.0 + 0.325 + 0.35));+        assert!(!r.via_clear_exact(net, 13.0, 5.0 + 0.7), "the oval peg's long axis");+        assert!(r.via_clear_exact(net, 10.0, 8.0));+    }+     const PADS: [(&str, &str, f64, f64, bool); 4] = [("J1", "A", 2.0, 5.0, true), ("J2", "A", 18.0, 5.0, true), ("J3", "B", 10.0, 2.0, true), ("J4", "B", 10.0, 8.0, true)];      fn route(b: Board) -> (Router, Vec<serde_json::Value>, serde_json::Value, usize) {
crates/aiflow-router/src/copper_path.rsadded+401
@@ -0,0 +1,401 @@+//! Paths along one net's copper on a board: pads, track pieces, vias and filled zone islands as a+//! graph, and the shortest path between two pads on it. This is how the decoupling review measures+//! the routed path from a cap's supply pad to its assigned pin (the plan applied to the board, plus+//! the copper already there), never a straight line, and never across zone polygons that no fill+//! joins. Lengths are along track centrelines; a stretch through a filled island counts the straight+//! distance across it and is flagged, since that copper has no single path.+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap};++use aiflow_board::{geom, Board, Pt};+use serde_json::{json, Value};++use crate::decoupling::Resolved;++#[derive(Clone, Copy, PartialEq, Debug)]+enum Kind {+    Pad,+    Point,+    Via,+}++/// One net's copper as a graph.+pub struct NetGraph {+    pos: Vec<Pt>,+    /// None: both layers (a via, a through-hole pad).+    layer: Vec<Option<usize>>,+    kind: Vec<Kind>,+    label: Vec<String>,+    /// (to, mm, through a zone fill)+    adj: Vec<Vec<(usize, f64, bool)>>,+    pad_node: HashMap<String, usize>,+    /// The net has zones, and whether any is filled in this board.+    pub zones: usize,+    pub filled_islands: usize,+}++/// A path between two pads.+#[derive(Clone, Debug)]+pub struct Path {+    pub length_mm: f64,+    pub vias: usize,+    /// Other pads of the net the path passes through (the rail tree).+    pub through_pads: Vec<String>,+    pub through_fill: bool,+    /// Points on the path where other copper of the net leaves it (a T, a rail landing). A path+    /// with two or more is a stretch of the rail tree between two landings, not a local connection.+    pub branch_points: usize,+}++/// Shortest distances from one pad.+pub struct Dist {+    dist: Vec<f64>,+    prev: Vec<usize>,+    prev_fill: Vec<bool>,+    src: usize,+}++const TOL: f64 = 1e-3;++impl NetGraph {+    pub fn build(board: &Board, net: &str) -> NetGraph {+        let mut g = NetGraph { pos: Vec::new(), layer: Vec::new(), kind: Vec::new(), label: Vec::new(), adj: Vec::new(), pad_node: HashMap::new(), zones: 0, filled_islands: 0 };+        let add = |g: &mut NetGraph, p: Pt, l: Option<usize>, k: Kind, s: String| -> usize {+            g.pos.push(p);+            g.layer.push(l);+            g.kind.push(k);+            g.label.push(s);+            g.adj.push(Vec::new());+            g.pos.len() - 1+        };+        let pads: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|p| p.net == net).collect();+        let mut pad_ids = Vec::new();+        for p in &pads {+            let l = if p.thru || (p.on_f && p.on_b) { None } else if p.on_f { Some(aiflow_board::F_CU) } else { Some(aiflow_board::B_CU) };+            let id = add(&mut g, (p.x, p.y), l, Kind::Pad, p.key.clone());+            g.pad_node.insert(p.key.clone(), id);+            pad_ids.push(id);+        }+        let tracks: Vec<aiflow_board::Track> = board.tracks().into_iter().filter(|t| t.net == net).collect();+        let mut ends = Vec::new(); // (node a, node b, half width)+        let mut point_hw: HashMap<usize, f64> = HashMap::new();+        // track ends at one spot on one layer are one node (a corner, a joint)+        let mut at: HashMap<(i64, i64, usize), usize> = HashMap::new();+        for t in &tracks {+            let mut node = |g: &mut NetGraph, p: Pt| -> usize {+                let key = ((p.0 * 1e4).round() as i64, (p.1 * 1e4).round() as i64, t.layer);+                let id = *at.entry(key).or_insert_with(|| add(g, p, Some(t.layer), Kind::Point, String::new()));+                let hw = point_hw.entry(id).or_insert(0.0);+                *hw = hw.max(t.width / 2.0);+                id+            };+            let a = node(&mut g, t.a);+            let b = node(&mut g, t.b);+            ends.push((a, b, t.width / 2.0));+        }+        let vias: Vec<aiflow_board::Via> = board.vias().into_iter().filter(|v| v.net == net).collect();+        let mut via_ids = Vec::new();+        for v in &vias {+            let id = add(&mut g, (v.x, v.y), None, Kind::Via, format!("via ({:.2}, {:.2})", v.x, v.y));+            point_hw.insert(id, v.size / 2.0);+            via_ids.push(id);+        }+        // each track piece: its own ends plus every point or via that touches it, chained along it+        let feature_nodes: Vec<usize> = point_hw.keys().copied().collect();+        for (ti, t) in tracks.iter().enumerate() {+            let (a, b, hw) = ends[ti];+            let len = (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1);+            let (ux, uy) = if len > 1e-9 { ((t.b.0 - t.a.0) / len, (t.b.1 - t.a.1) / len) } else { (0.0, 0.0) };+            let mut on: Vec<(f64, usize)> = vec![(0.0, a), (len, b)];+            let (x0, x1) = (t.a.0.min(t.b.0) - hw - 1.0, t.a.0.max(t.b.0) + hw + 1.0);+            let (y0, y1) = (t.a.1.min(t.b.1) - hw - 1.0, t.a.1.max(t.b.1) + hw + 1.0);+            for &n in &feature_nodes {+                if n == a || n == b {+                    continue;+                }+                if let Some(l) = g.layer[n] {+                    if l != t.layer {+                        continue;+                    }+                }+                let (x, y) = g.pos[n];+                if x < x0 || x > x1 || y < y0 || y > y1 {+                    continue;+                }+                let reach = hw + point_hw[&n] + TOL;+                if geom::seg_distance(x, y, t.a.0, t.a.1, t.b.0, t.b.1) <= reach {+                    let s = ((x - t.a.0) * ux + (y - t.a.1) * uy).clamp(0.0, len);+                    on.push((s, n));+                }+            }+            on.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());+            for w in on.windows(2) {+                g.link(w[0].1, w[1].1, w[1].0 - w[0].0, false);+            }+        }+        // pads: a track end or a via on the pad's copper joins it at its centre+        for (pi, p) in pads.iter().enumerate() {+            let pn = pad_ids[pi];+            for &n in &feature_nodes {+                let on_layer = match g.layer[n] { Some(l) => p.thru || p.on_layer(l), None => true };+                if !on_layer {+                    continue;+                }+                let (x, y) = g.pos[n];+                if (x - p.x).abs() > p.half_long + 2.0 || (y - p.y).abs() > p.half_long + 2.0 {+                    continue;+                }+                let r = if g.kind[n] == Kind::Via { TOL } else { point_hw[&n] + TOL };+                if p.polys.iter().any(|poly| geom::point_poly_distance(x, y, poly) <= r) {+                    let d = (x - p.x).hypot(y - p.y);+                    g.link(pn, n, d, false);+                }+            }+        }+        // filled zone islands: everything on one island is joined, straight across it+        for z in board.zones().iter().filter(|z| z.net == net) {+            g.zones += 1;+            for (l, poly) in &z.fills {+                g.filled_islands += 1;+                let mut members = Vec::new();+                for n in 0..g.pos.len() {+                    let on_layer = match g.layer[n] { Some(x) => x == *l, None => true };+                    if !on_layer {+                        continue;+                    }+                    let (x, y) = g.pos[n];+                    let inside = if g.kind[n] == Kind::Pad {+                        let p = pads[pad_ids.iter().position(|&q| q == n).unwrap()];+                        geom::point_in_poly(x, y, poly) || p.polys.iter().flatten().any(|v| geom::point_in_poly(v.0, v.1, poly))+                    } else {+                        geom::point_poly_distance(x, y, poly) <= point_hw.get(&n).copied().unwrap_or(0.0) + TOL+                    };+                    if inside {+                        members.push(n);+                    }+                }+                for i in 0..members.len() {+                    for j in i + 1..members.len() {+                        let (a, b) = (members[i], members[j]);+                        let d = (g.pos[a].0 - g.pos[b].0).hypot(g.pos[a].1 - g.pos[b].1);+                        g.link(a, b, d, true);+                    }+                }+            }+        }+        g+    }++    fn link(&mut self, a: usize, b: usize, d: f64, fill: bool) {+        if a == b {+            return;+        }+        self.adj[a].push((b, d, fill));+        self.adj[b].push((a, d, fill));+    }++    pub fn has_pad(&self, key: &str) -> bool {+        self.pad_node.contains_key(key)+    }++    /// Shortest copper distances from a pad of the net.+    pub fn from_pad(&self, key: &str) -> Option<Dist> {+        let src = *self.pad_node.get(key)?;+        let n = self.pos.len();+        let mut dist = vec![f64::INFINITY; n];+        let mut prev = vec![usize::MAX; n];+        let mut prev_fill = vec![false; n];+        let mut heap = BinaryHeap::new();+        dist[src] = 0.0;+        heap.push(Reverse((0i64, src)));+        while let Some(Reverse((dk, u))) = heap.pop() {+            if dk > (dist[u] * 1e6) as i64 {+                continue;+            }+            for &(v, w, fill) in &self.adj[u] {+                let nd = dist[u] + w;+                if nd + 1e-9 < dist[v] {+                    dist[v] = nd;+                    prev[v] = u;+                    prev_fill[v] = fill;+                    heap.push(Reverse(((nd * 1e6) as i64, v)));+                }+            }+        }+        Some(Dist { dist, prev, prev_fill, src })+    }++    /// The path from the Dist's source pad to this pad, if copper joins them.+    pub fn path_to(&self, d: &Dist, key: &str) -> Option<Path> {+        let t = *self.pad_node.get(key)?;+        if !d.dist[t].is_finite() {+            return None;+        }+        let mut nodes = vec![t];+        let mut fill = false;+        let mut c = t;+        while c != d.src {+            fill |= d.prev_fill[c];+            c = d.prev[c];+            nodes.push(c);+        }+        nodes.reverse();+        let mut vias = 0;+        let mut branches = 0;+        let mut through = Vec::new();+        for k in 1..nodes.len().saturating_sub(1) {+            let n = nodes[k];+            match self.kind[n] {+                Kind::Via => {+                    // a layer change: the copper before and after the via is on different layers+                    let before = (0..k).rev().find_map(|q| self.layer[nodes[q]]);+                    let after = (k + 1..nodes.len()).find_map(|q| self.layer[nodes[q]]);+                    if let (Some(a), Some(b)) = (before, after) {+                        if a != b && self.layer[nodes[k - 1]].is_some() && self.layer[nodes[k + 1]].is_some() {+                            vias += 1;+                        }+                    }+                }+                Kind::Pad => through.push(self.label[n].clone()),+                Kind::Point => {}+            }+            // a landing on the end pads themselves is not a rail branch; other pads' links are not copper runs+            let at_end = self.adj[n].iter().any(|e| !e.2 && (e.0 == t || e.0 == d.src));+            if self.kind[n] != Kind::Pad && !at_end {+                let mut nb: Vec<usize> = self.adj[n].iter().filter(|e| !e.2 && e.0 != n && self.kind[e.0] != Kind::Pad).map(|e| e.0).collect();+                nb.sort_unstable();+                nb.dedup();+                if nb.len() >= 3 {+                    branches += 1;+                }+            }+        }+        Some(Path { length_mm: d.dist[t], vias, through_pads: through, through_fill: fill, branch_points: branches })+    }++    /// Every pad of the net with its copper distance from the Dist's source.+    pub fn pad_distances(&self, d: &Dist) -> Vec<(String, f64)> {+        let mut v: Vec<(String, f64)> = self.pad_node.iter().filter(|(_, &n)| n != d.src && d.dist[n].is_finite()).map(|(k, &n)| (k.clone(), d.dist[n])).collect();+        v.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));+        v+    }+}++impl Path {+    /// Through other pads, or along a stretch of the rail between two landings.+    pub fn detour(&self) -> bool {+        !self.through_pads.is_empty() || self.branch_points >= 2+    }+}++/// Where the zone fills in the measured board came from, and whether they are current.+#[derive(Clone, Debug, Default)]+pub struct FillContext {+    /// The fills were refilled natively after the last copper change: a path through them is verified.+    pub fills_current: bool,+    /// Words for where the fills came from (the saved board file, an approximate raster fill, ...).+    pub fill_source: String,+    /// Nets with pours planned but not on the board (plan-pours.json not landed and refilled yet).+    pub planned_pour_nets: Vec<String>,+}++fn r2(x: f64) -> f64 {+    (x * 100.0).round() / 100.0+}++fn pad_gap(a: &aiflow_board::Pad, b: &aiflow_board::Pad) -> f64 {+    let mut best = f64::INFINITY;+    for pa in &a.polys {+        for pb in &b.polys {+            for v in pa {+                best = best.min(geom::point_poly_distance(v.0, v.1, pb));+            }+            for v in pb {+                best = best.min(geom::point_poly_distance(v.0, v.1, pa));+            }+        }+    }+    if best.is_finite() { best } else { 0.0 }+}++/// The acceptance review of every pair on a board (the plan applied plus the copper already there).+/// Per pair: the routed copper path from the cap supply pad to the assigned pin (length, vias, the+/// other pads it passes), the nearest same-net pin of the same part by copper, the straight pad+/// distance, the ground return by connectivity, and findings against the pair's own budget.+pub fn review(board: &Board, pairs: &[Resolved], ctx: &FillContext) -> Value {+    let mut entries = Vec::new();+    let (mut warnings, mut errors) = (0usize, 0usize);+    let mut graphs: HashMap<String, NetGraph> = HashMap::new();+    for p in pairs {+        let sev = if p.spec.is_error() { "error" } else { "warning" };+        let mut findings: Vec<Value> = Vec::new();+        let cap = board.pad_by_key(&p.cap_pad).unwrap();+        let pin = board.pad_by_key(&p.pin).unwrap();+        let straight = (cap.x - pin.x).hypot(cap.y - pin.y);+        let g = graphs.entry(p.net.clone()).or_insert_with(|| NetGraph::build(board, &p.net));+        let d = g.from_pad(&p.cap_pad).unwrap();+        let path = g.path_to(&d, &p.pin);+        let nearest = g.pad_distances(&d).into_iter().find(|(k, _)| board.pad_by_key(k).map(|q| q.reference == pin.reference).unwrap_or(false));+        let assigned = match &path {+            Some(x) => json!({"connected": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "branchPoints": x.branch_points, "detour": x.detour(), "throughFill": x.through_fill}),+            None => json!({"connected": false, "pathMm": Value::Null, "vias": Value::Null, "throughPads": [], "branchPoints": Value::Null, "detour": Value::Null, "throughFill": false}),+        };+        match &path {+            None => findings.push(json!({"severity": sev, "text": format!("{}: no copper joins {} to {} yet", p.label, p.cap_pad, p.pin)})),+            Some(x) => {+                if !x.through_pads.is_empty() {+                    findings.push(json!({"severity": sev, "text": format!("{}: the copper path detours through the rail ({}), {:.2} mm against {:.2} mm straight; the cap reaches its pin through other pads", p.label, x.through_pads.join(", "), x.length_mm, straight)}));+                } else if x.branch_points >= 2 {+                    findings.push(json!({"severity": sev, "text": format!("{}: the copper path runs along the rail tree ({} rail branch points on it), {:.2} mm against {:.2} mm straight; the cap and the pin each land on the rail instead of on each other", p.label, x.branch_points, x.length_mm, straight)}));+                }+                if let Some(ml) = p.spec.max_length_mm {+                    if x.length_mm > ml + 1e-6 {+                        findings.push(json!({"severity": sev, "text": format!("{}: copper path {:.2} mm, over maxLengthMm {ml}", p.label, x.length_mm)}));+                    }+                }+                if let Some(mv) = p.spec.max_vias {+                    if x.vias > mv {+                        findings.push(json!({"severity": sev, "text": format!("{}: {} via(s) on the path, over maxVias {mv}", p.label, x.vias)}));+                    }+                }+            }+        }+        // ground return, by connectivity only+        let ground = match (&p.gnd_pad, &p.gnd_net) {+            (Some(gp), Some(gn)) => {+                let gg = graphs.entry(gn.clone()).or_insert_with(|| NetGraph::build(board, gn));+                let gd = gg.from_pad(gp);+                let planned = ctx.planned_pour_nets.iter().any(|n| n == gn);+                let mut returns = Vec::new();+                for rp in &p.return_pins {+                    let q = board.pad_by_key(rp).unwrap();+                    let s = r2((q.x - board.pad_by_key(gp).unwrap().x).hypot(q.y - board.pad_by_key(gp).unwrap().y));+                    let found = gd.as_ref().and_then(|d| gg.path_to(d, rp));+                    let v = match found {+                        Some(x) if !x.through_fill => json!({"pin": rp, "status": "copper", "verified": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "straightMm": s}),+                        Some(x) if ctx.fills_current => json!({"pin": rp, "status": "fill", "verified": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "straightMm": s, "note": format!("through filled zone islands ({}); the length is straight across the fill", ctx.fill_source)}),+                        Some(x) => json!({"pin": rp, "status": "fill-unverified", "verified": false, "pathMm": r2(x.length_mm), "vias": x.vias, "straightMm": s, "note": format!("joined only through zone fills that are not a native refill of this copper ({}); refill natively (land pours, measure) and review again", ctx.fill_source)}),+                        None if gg.zones > 0 || planned => json!({"pin": rp, "status": "unverified", "verified": false, "pathMm": Value::Null, "straightMm": s, "note": "only planned or unfilled pours could join these pads; no refilled native fill data, so the return is unverified (the straight distance is not a path)"}),+                        None => json!({"pin": rp, "status": "open", "verified": false, "pathMm": Value::Null, "straightMm": s, "note": "no copper joins these pads"}),+                    };+                    returns.push(v);+                }+                json!({"gndPad": gp, "net": gn, "returns": returns})+            }+            _ => Value::Null,+        };+        for f in &findings {+            if f["severity"] == "error" { errors += 1 } else { warnings += 1 }+        }+        entries.push(json!({+            "pair": p.label, "net": p.net, "capPad": p.cap_pad, "pin": p.pin, "severity": sev,+            "budget": {"maxLengthMm": p.spec.max_length_mm, "maxVias": p.spec.max_vias},+            "assignedPin": assigned,+            "nearestSameNetPin": nearest.map(|(k, mm)| json!({"pin": k, "pathMm": r2(mm)})),+            "straightMm": r2(straight), "padGapMm": r2(pad_gap(cap, pin)),+            "groundReturn": ground, "findings": findings,+        }));+    }+    json!({"pairs": entries.len(), "warnings": warnings, "errors": errors, "entries": entries,+        "method": "shortest path along this net's copper (tracks, vias, pads, and filled zone islands straight across) from the cap supply pad to the pin; budgets are the spec's own"})+}
crates/aiflow-router/src/decoupling.rsadded+373
@@ -0,0 +1,373 @@+//! Assigned-pin decoupling (spec "decoupling", issue #32).+//!+//! A decoupling capacitor serves one supply pin. A rail tree that is DRC-clean can still reach that+//! pin the long way round (cap to another pin, along the rail, back to the assigned pin). A spec entry+//!+//! ```json+//! {"cap": "C6", "pin": "U1.39", "capPad": "C6.1", "gndPad": "C6.2", "returnPins": ["U1.38"],+//!  "maxLengthMm": 3.0, "maxVias": 0, "severity": "warning"}+//! ```+//!+//! names the pair. Only `cap` and `pin` are required: the cap's supply pad is its pad on the pin's net,+//! its ground pad the pad on its other net. The router routes the direct connection (cap supply pad to+//! the pin) before any rail distribution, with the net's own widths, clearances and vias, and keeps it+//! through rip-up; the rest of the net then joins that copper. A pair it cannot route directly is said+//! in the router log and the report and falls back to normal routing. There is no default budget:+//! `maxLengthMm` and `maxVias` are this board's, and without them the length is reported only.+use std::collections::HashSet;++use aiflow_board::Board;+use aiflow_grid::RES;+use serde::{Deserialize, Serialize};+use serde_json::{json, Value};++use crate::drive::Search;+use crate::{End, Route, Router, Term};++/// One spec `decoupling` entry, as written.+#[derive(Clone, Debug, Default, Serialize, Deserialize)]+#[serde(default, rename_all = "camelCase")]+pub struct Pair {+    /// The capacitor's reference (`C6`), or its supply pad key (`C6.1`).+    pub cap: String,+    /// The supply pin the cap serves, as a pad key (`U1.39`).+    pub pin: String,+    /// The cap's supply pad; inferred as the cap pad on the pin's net when absent.+    pub cap_pad: Option<String>,+    /// The cap's ground pad; inferred as the cap pad on its other net when absent.+    pub gnd_pad: Option<String>,+    /// The IC's return pins for this pair (on the ground pad's net), reported by connectivity.+    pub return_pins: Vec<String>,+    /// This board's budget for the routed path, cap supply pad to pin. None: report only.+    pub max_length_mm: Option<f64>,+    /// This board's budget for layer changes on that path. None: report only; 0 also keeps the+    /// router's direct connection on one layer.+    pub max_vias: Option<usize>,+    /// "warning" (default) or "error" for a finding on this pair.+    pub severity: Option<String>,+    pub note: Option<String>,+}++impl Pair {+    /// Every `decoupling` entry of a spec (an array, or one object). An entry that does not parse,+    /// or has a bad severity or budget, is an error naming it.+    pub fn from_spec(spec: &Value) -> Result<Vec<Pair>, String> {+        let v = spec.get("decoupling").cloned().unwrap_or(Value::Null);+        let items = match v {+            Value::Array(a) => a,+            Value::Object(_) => vec![v],+            Value::Null => Vec::new(),+            _ => return Err("decoupling must be an array of {cap, pin, ...} entries".into()),+        };+        let mut out = Vec::new();+        for (k, e) in items.into_iter().enumerate() {+            let p: Pair = serde_json::from_value(e).map_err(|x| format!("decoupling[{k}]: {x}"))?;+            if p.cap.is_empty() || p.pin.is_empty() {+                return Err(format!("decoupling[{k}]: cap and pin are required"));+            }+            if let Some(s) = &p.severity {+                if s != "warning" && s != "error" {+                    return Err(format!("decoupling[{k}]: severity is \"warning\" or \"error\", not {s:?}"));+                }+            }+            if p.max_length_mm.map(|m| !(m > 0.0)).unwrap_or(false) {+                return Err(format!("decoupling[{k}]: maxLengthMm must be a positive length in mm"));+            }+            out.push(p);+        }+        Ok(out)+    }++    pub fn is_error(&self) -> bool {+        self.severity.as_deref() == Some("error")+    }+}++/// A pair resolved on a board: pad keys and nets.+#[derive(Clone, Debug)]+pub struct Resolved {+    pub label: String,+    pub net: String,+    pub cap_pad: String,+    pub pin: String,+    pub gnd_pad: Option<String>,+    pub gnd_net: Option<String>,+    pub return_pins: Vec<String>,+    pub spec: Pair,+}++/// Resolve the pairs on the board. Returns the pairs that resolve and one problem per pair that does not.+pub fn resolve(board: &Board, pairs: &[Pair]) -> (Vec<Resolved>, Vec<String>) {+    let mut ok = Vec::new();+    let mut problems = Vec::new();+    for p in pairs {+        match resolve_one(board, p) {+            Ok(r) => ok.push(r),+            Err(e) => problems.push(format!("decoupling {} -> {}: {e}", p.cap, p.pin)),+        }+    }+    (ok, problems)+}++fn resolve_one(board: &Board, p: &Pair) -> Result<Resolved, String> {+    let pin = board.pad_by_key(&p.pin).ok_or_else(|| format!("pin {} is not a pad on this board (use REF.PAD, e.g. U1.39)", p.pin))?;+    let net = pin.net.clone();+    if net.is_empty() {+        return Err(format!("pin {} has no net", p.pin));+    }+    // the cap: a reference, or a pad key standing for its supply pad+    let (cap_ref, given_pad) = match board.pad_by_key(&p.cap) {+        Some(cp) if board.footprints.get(&p.cap).is_none() => (cp.reference.clone(), Some(p.cap.clone())),+        _ => (p.cap.clone(), p.cap_pad.clone()),+    };+    let pads = board.pads_of(&cap_ref);+    if pads.is_empty() {+        return Err(format!("no part {cap_ref} on this board"));+    }+    let cap_pad = match given_pad {+        Some(k) => {+            let cp = board.pad_by_key(&k).ok_or_else(|| format!("capPad {k} is not a pad on this board"))?;+            if cp.reference != cap_ref {+                return Err(format!("capPad {k} is not a pad of {cap_ref}"));+            }+            if cp.net != net {+                return Err(format!("capPad {k} is on {:?}, the pin {} is on {net:?}", cp.net, p.pin));+            }+            k+        }+        None => {+            let on: Vec<&&aiflow_board::Pad> = pads.iter().filter(|q| q.net == net).collect();+            match on.len() {+                0 => return Err(format!("{cap_ref} has no pad on {net:?} (the net of {})", p.pin)),+                1 => on[0].key.clone(),+                _ => return Err(format!("{cap_ref} has {} pads on {net:?}; name the supply pad with capPad", on.len())),+            }+        }+    };+    if cap_pad == p.pin {+        return Err("the cap pad and the pin are the same pad".into());+    }+    let gnd_pad = match &p.gnd_pad {+        Some(k) => {+            let g = board.pad_by_key(k).ok_or_else(|| format!("gndPad {k} is not a pad on this board"))?;+            if g.reference != cap_ref {+                return Err(format!("gndPad {k} is not a pad of {cap_ref}"));+            }+            if g.net.is_empty() || g.net == net {+                return Err(format!("gndPad {k} is on {:?}; it must be the cap's other net", g.net));+            }+            Some(k.clone())+        }+        None => {+            let others: Vec<&&aiflow_board::Pad> = pads.iter().filter(|q| !q.net.is_empty() && q.net != net).collect();+            let nets: HashSet<&str> = others.iter().map(|q| q.net.as_str()).collect();+            if nets.len() == 1 { Some(others[0].key.clone()) } else { None }+        }+    };+    let gnd_net = gnd_pad.as_ref().and_then(|k| board.pad_by_key(k)).map(|g| g.net.clone());+    for rp in &p.return_pins {+        let Some(q) = board.pad_by_key(rp) else { return Err(format!("return pin {rp} is not a pad on this board")) };+        match &gnd_net {+            None => return Err(format!("returnPins given but {cap_ref} has no single ground pad; name it with gndPad")),+            Some(g) if &q.net != g => return Err(format!("return pin {rp} is on {:?}, the cap's ground pad is on {g:?}", q.net)),+            _ => {}+        }+    }+    Ok(Resolved { label: format!("{cap_pad}->{}", p.pin), net, cap_pad, pin: p.pin.clone(), gnd_pad, gnd_net, return_pins: p.return_pins.clone(), spec: p.clone() })+}++/// A pair in the router: its net and clusters, and what the last attempt said.+#[derive(Clone, Debug)]+pub struct PairState {+    pub r: Resolved,+    pub net: Option<usize>,+    pub cap_cluster: Option<usize>,+    pub pin_cluster: Option<usize>,+    pub note: String,+}++/// Grid length (mm) and layer changes of a cell path.+pub fn cells_length(cells: &[Term]) -> (f64, usize) {+    let mut mm = 0.0;+    let mut vias = 0;+    for w in cells.windows(2) {+        if w[0].layer() != w[1].layer() {+            vias += 1;+        } else {+            mm += ((w[1].i - w[0].i) as f64).hypot((w[1].j - w[0].j) as f64) * RES;+        }+    }+    (mm, vias)+}++impl Router {+    /// Resolve the rules' decoupling pairs on this board and find their clusters.+    pub(crate) fn init_decoupling(&mut self) {+        let (resolved, problems) = resolve(&self.board, &self.rules.decoupling);+        for p in problems {+            self.log(format!("  {p}; pair skipped"));+        }+        for r in resolved {+            let net = self.net_ids.get(&r.net).copied();+            let find = |me: &Router, key: &str| -> Option<usize> {+                let pi = me.board.pad_idx(key)?;+                me.nets[net?].clusters.iter().position(|c| c.pads.contains(&pi))+            };+            let (a, b) = (find(self, &r.cap_pad), find(self, &r.pin));+            self.pairs.push(PairState { r, net, cap_cluster: a, pin_cluster: b, note: String::new() });+        }+    }++    /// Is this route tree copper? A decoupling pair route is, once its clusters joined the tree.+    pub fn pair_route_joined(&self, net: usize, r: &Route) -> bool {+        if r.pair.is_none() {+            return true;+        }+        r.start.cluster().map(|c| self.nets[net].clusters[c].connected).unwrap_or(true)+    }++    /// `connect_group` plus every cluster a kept decoupling pair route joins to them (transitively).+    pub fn join_with_pairs(&mut self, net: usize, c: usize) -> Vec<usize> {+        let mut out = self.connect_group(net, c);+        let mut k = 0;+        while k < out.len() {+            let me = out[k];+            let partners: Vec<usize> = self.nets[net].routes.iter().filter(|r| r.pair.is_some()).filter_map(|r| {+                let (s, e) = (r.start.cluster(), r.end.cluster());+                if s == Some(me) { e } else if e == Some(me) { s } else { None }+            }).collect();+            for p in partners {+                if !out.contains(&p) {+                    for q in self.connect_group(net, p) {+                        if !out.contains(&q) {+                            out.push(q);+                        }+                    }+                }+            }+            k += 1;+        }+        out+    }++    /// After a rip that kept the pair routes: their escapes stay used, the pins' other escapes released.+    pub(crate) fn keep_pair_escapes(&mut self, net: usize) {+        let ends: Vec<End> = self.nets[net].routes.iter().filter(|r| r.pair.is_some()).flat_map(|r| [r.start.clone(), r.end.clone()]).collect();+        for e in ends {+            match e {+                End::Escape(c, t) => {+                    self.nets[net].used_escapes.insert((c, t));+                    self.stamp_channel_pub(net, c, t, -1);+                    self.release_escapes(net, c);+                }+                End::Pad(c, _) => self.release_escapes(net, c),+                _ => {}+            }+        }+    }++    /// Route every decoupling pair's direct connection that is not already down. Called at the start+    /// of each pass, before plane stubs and signal nets.+    pub fn route_decoupling(&mut self) {+        for k in 0..self.pairs.len() {+            let ps = self.pairs[k].clone();+            let Some(net) = ps.net else { continue };+            if self.nets[net].routes.iter().any(|r| r.pair == Some(k)) {+                continue;+            }+            let label = format!("decoupling {} ({})", ps.r.label, ps.r.net);+            let (Some(a), Some(b)) = (ps.cap_cluster, ps.pin_cluster) else {+                self.pairs[k].note = "the pads are not routing terminals of their net".into();+                self.log(format!("  {label}: {}; normal routing", self.pairs[k].note));+                continue;+            };+            if a == b {+                self.pairs[k].note = "the cap pad and the pin overlap: joined by their pads".into();+                continue;+            }+            if self.nets[net].done_by_board || (self.comp_of(net, a) == self.comp_of(net, b) && self.joined_by_board(net, a)) {+                self.pairs[k].note = "already joined by the board's own copper".into();+                self.log(format!("  {label}: {}", self.pairs[k].note));+                continue;+            }+            if self.kelvin_clusters.contains(&(net, a)) || self.kelvin_clusters.contains(&(net, b)) || self.rules.kelvin.contains_key(&ps.r.cap_pad) || self.rules.kelvin.contains_key(&ps.r.pin) {+                self.pairs[k].note = "a Kelvin tap owns one of the pads; not routed as a pair".into();+                self.log(format!("  {label}: {}", self.pairs[k].note));+                continue;+            }+            match self.route_pair(k, net, a, b) {+                Ok((mm, vias, w)) => {+                    self.pairs[k].note = String::new();+                    self.stat("decouplingDirect", 1);+                    self.log(format!("  {label}: direct, {mm:.2} mm on the grid, {vias} via(s), {w} mm wide, routed before the rail and kept through rip-up"));+                }+                Err(why) => {+                    self.pairs[k].note = why.clone();+                    self.stat("decouplingFallback", 1);+                    self.log(format!("  {label}: no direct connection ({why}); falls back to normal routing of {}", ps.r.net));+                }+            }+        }+    }++    fn route_pair(&mut self, k: usize, net: usize, a: usize, b: usize) -> Result<(f64, usize, f64), String> {+        let spec = self.pairs[k].r.spec.clone();+        let (ca, cb) = (self.nets[net].clusters[a].clone(), self.nets[net].clusters[b].clone());+        let allow = ca.allow.min(cb.allow);+        let mut widths: Vec<f64> = self.nets[net].widths.iter().copied().filter(|w| *w <= allow).collect();+        if widths.is_empty() {+            widths = vec![self.nets[net].widths.iter().cloned().fold(f64::INFINITY, f64::min)];+        }+        widths.sort_by(|x, y| y.partial_cmp(x).unwrap());+        let sources: Vec<(Term, i64)> = ca.terminals.iter().map(|t| (*t, 0)).collect();+        let targets: HashSet<Term> = cb.terminals.iter().copied().collect();+        let mut pts: Vec<(i64, i64)> = ca.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+        let tpts: Vec<(i64, i64)> = cb.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+        pts.extend_from_slice(&tpts);+        let allow_via = spec.max_vias != Some(0);+        let mut why = "no path clear of other nets within 4 mm of the pads".to_string();+        for width in widths {+            for margin in [2.0, 4.0] {+                let win = self.window_for(&pts, margin);+                let (cells, _) = self.search(net, width, Search { sources: sources.clone(), targets: Some(&targets), heur: tpts.clone(), win, allow_via, soft: false, via_target: false, max_nodes: Some(200_000), vip: Vec::new() });+                let Some(cells) = cells else { continue };+                let (mm, vias) = cells_length(&cells);+                if let Some(mv) = spec.max_vias {+                    if vias > mv {+                        why = format!("the shortest clear path needs {vias} via(s), maxVias {mv}");+                        continue;+                    }+                }+                if let Some(ml) = spec.max_length_mm {+                    if mm > ml {+                        why = format!("the shortest clear path is {mm:.2} mm, maxLengthMm {ml}");+                        continue;+                    }+                }+                let start = if ca.escapes.contains_key(&cells[0]) { End::Escape(a, cells[0]) } else { End::Pad(a, cells[0]) };+                let last = *cells.last().unwrap();+                let end = if cb.escapes.contains_key(&last) { End::Escape(b, last) } else { End::Pad(b, last) };+                let mut r = Route::new(cells, width, start, end);+                r.pair = Some(k);+                self.commit(net, r);+                return Ok((mm, vias, width));+            }+        }+        Err(why)+    }++    /// Per pair: routed directly or not, the grid length and vias of the direct route, and why not.+    pub fn decoupling_report(&self) -> Value {+        let mut out = Vec::new();+        for (k, p) in self.pairs.iter().enumerate() {+            let route = p.net.and_then(|n| self.nets[n].routes.iter().find(|r| r.pair == Some(k)));+            let (mm, vias) = route.map(|r| cells_length(&r.cells)).unwrap_or((0.0, 0));+            out.push(json!({+                "pair": p.r.label, "net": p.r.net, "capPad": p.r.cap_pad, "pin": p.r.pin, "direct": route.is_some(),+                "gridMm": route.map(|_| (mm * 100.0).round() / 100.0), "vias": route.map(|_| vias), "widthMm": route.map(|r| r.width),+                "note": if route.is_some() { Value::Null } else { json!(if p.note.is_empty() { "not routed as a pair" } else { p.note.as_str() }) },+            }));+        }+        Value::Array(out)+    }+}
crates/aiflow-router/src/drive.rs+41−10
@@ -258,6 +258,9 @@ impl Router {             if r.kelvin {                 continue;   // a Kelvin tap is not copper the rest of its net may land on             }+            if !self.pair_route_joined(net, r) {+                continue;   // a decoupling pair not yet joined to the tree is an island, not tree copper+            }             t.extend(r.cells.iter().copied());         }         // the board's own copper of every group already joined to the tree@@ -355,7 +358,7 @@ impl Router {         for c in 0..n {             self.nets[net].clusters[c].connected = false;         }-        self.nets[net].routes.clear();+        self.nets[net].routes.retain(|r| r.pair.is_some());   // decoupling pairs stay (decoupling.rs)         self.nets[net].failed.clear();         self.nets[net].last_soft.clear();         let start = (0..n).max_by(|&a, &b| {@@ -366,7 +369,7 @@ impl Router {             (ca.allow, pa.half_long * pa.half_short).partial_cmp(&(cb.allow, pb.half_long * pb.half_short)).unwrap()         }).unwrap();         // the start and every cluster the board's own copper already joins to it-        let mut connected = self.connect_group(net, start);+        let mut connected = self.join_with_pairs(net, start);         let mut remaining: Vec<usize> = (0..n).filter(|c| !connected.contains(c)).collect();         let mut blockers_all = HashSet::new();         // Kelvin taps first: sense pad to shunt pad as a dedicated trace@@ -427,7 +430,7 @@ impl Router {                 }                 Some(r) => {                     self.commit(net, r);-                    for k in self.connect_group(net, c) {+                    for k in self.join_with_pairs(net, c) {                         if !connected.contains(&k) {                             connected.push(k);                         }@@ -557,7 +560,7 @@ impl Router {         let routes = std::mem::take(&mut self.nets[net].routes);         let mut keep = Vec::new();         for r in routes {-            if r.start.cluster() == Some(cluster) {+            if r.start.cluster() == Some(cluster) && r.pair.is_none() {                 self.stamp_route(net, &r, -1);                 for c in &r.cells {                     self.bump_history(c);@@ -588,20 +591,32 @@ impl Router {         }     } +    /// Rip the net's routes, keeping its decoupling pair routes (decoupling.rs).     pub fn rip(&mut self, net: usize) {+        self.rip_keeping(net, true);+    }++    pub fn rip_keeping(&mut self, net: usize, keep_pairs: bool) {         let routes = std::mem::take(&mut self.nets[net].routes);-        for r in &routes {-            self.stamp_route(net, r, -1);+        let mut kept = Vec::new();+        for r in routes {+            if keep_pairs && r.pair.is_some() {+                kept.push(r);+                continue;+            }+            self.stamp_route(net, &r, -1);             for c in &r.cells {                 self.bump_history(c);             }         }+        self.nets[net].routes = kept;         self.nets[net].used_escapes.clear();         let n = self.nets[net].clusters.len();         for c in 0..n {             self.nets[net].clusters[c].connected = false;             self.reserve_escapes(net, c);         }+        self.keep_pair_escapes(net);         self.nets[net].rips += 1;         self.rip_total += 1;         self.stat("rips", 1);@@ -762,6 +777,16 @@ impl Router {                 }             }         }+        // drilled holes with no copper: hole to hole for the drill, clearance for the via's copper+        for h in &self.board.holes {+            if (h.x - x).abs() > reach + h.diameter() || (h.y - y).abs() > reach + h.diameter() {+                continue;+            }+            let need = (drill / 2.0 + self.rules.hole_to_hole).max(rv + self.rules.clearance) + slop();+            if geom::point_poly_distance(x, y, &h.poly) < need {+                return false;+            }+        }         if self.fixed_blocks(&name, None, x, y, rv + self.rules.clearance) {             return false;         }@@ -827,6 +852,8 @@ impl Router {         }         let plane_nets: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_some()).map(|n| n.idx).collect();         let kelvin = self.rules.kelvin.clone();+        // assigned-pin decoupling pairs go down before any rail distribution (spec "decoupling")+        self.route_decoupling();         if planes {             for &net in &plane_nets {                 if self.nets[net].done_by_board {@@ -867,7 +894,7 @@ impl Router {         let mut best_state: Option<(usize, Snapshot)> = None;         while !queue.is_empty() {             let net = queue.remove(0);-            if !self.nets[net].routes.is_empty() {+            if self.nets[net].routes.iter().any(|r| r.pair.is_none()) {                 self.rip(net);             }             let t1 = std::time::Instant::now();@@ -885,7 +912,7 @@ impl Router {                 best_state = Some((score, self.snapshot()));             }             if !self.nets[net].failed.is_empty() && !blockers.is_empty() && self.nets[net].rips < rip_per_net && self.rip_total < rip_global {-                let mut cands: Vec<usize> = blockers.iter().copied().filter(|b| !self.kelvin_owner.contains_key(b)).filter_map(|b| self.nets.iter().position(|n| n.idx == b)).filter(|&n| !self.nets[n].routes.is_empty() && self.nets[n].plane.is_none()).collect();+                let mut cands: Vec<usize> = blockers.iter().copied().filter(|b| !self.kelvin_owner.contains_key(b)).filter_map(|b| self.nets.iter().position(|n| n.idx == b)).filter(|&n| self.nets[n].routes.iter().any(|r| r.pair.is_none()) && self.nets[n].plane.is_none()).collect();                 cands.sort_by_key(|&n| std::cmp::Reverse(self.nets[n].routed_order.unwrap_or(0)));                 cands.truncate(4);                 let mut stubs: Vec<(usize, usize)> = Vec::new();@@ -969,7 +996,7 @@ impl Router {         let n = self.nets.len();         for net in 0..n {             if !self.nets[net].routes.is_empty() {-                self.rip(net);+                self.rip_keeping(net, false);             }             self.nets[net].failed.clear();             self.nets[net].via_in_pad.clear();@@ -1091,6 +1118,10 @@ impl Router {                     entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": true}));                     continue;                 }+                if let Some(k) = r.pair {+                    entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "decoupling": self.pairs.get(k).map(|p| p.r.label.clone())}));+                    continue;+                }                 if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {                     e.1.push(Value::Array(pts));                 } else {@@ -1153,7 +1184,7 @@ impl Router {                 unrouted.push(json!([net.name, [k, why]]));             }         }-        json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(),+        json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted, "boardCopper": self.board_copper_summary(), "decoupling": self.decoupling_report(),             "planeUnder": self.keep.iter().map(|k| json!({"label": k.label, "net": k.net, "layer": aiflow_board::LAYER_NAMES[k.layer], "polygon": k.poly})).collect::<Vec<_>>()})     } 
crates/aiflow-router/src/lib.rs+48−2
@@ -17,9 +17,12 @@ //! //! Output: a plan, a list of `kicad_route_net` calls ({net, width, viaSize, viaDrill, paths}). pub mod board_copper;+pub mod copper_path;+pub mod decoupling; pub mod drive; pub mod plane_under; pub mod rules;+pub mod simplify;  use std::collections::{HashMap, HashSet}; @@ -119,11 +122,14 @@ pub struct Route {     pub via_end: bool,     pub via_end_layer: usize,     pub kelvin: bool,+    /// The spec decoupling pair (index into `Router::pairs`) this route is the direct connection of:+    /// routed before the rail and kept through rip-up (decoupling.rs).+    pub pair: Option<usize>, }  impl Route {     pub fn new(cells: Vec<Term>, width: f64, start: End, end: End) -> Route {-        Route { cells, width, start, end, breaks: HashSet::new(), via_end: false, via_end_layer: B_CU, kelvin: false }+        Route { cells, width, start, end, breaks: HashSet::new(), via_end: false, via_end_layer: B_CU, kelvin: false, pair: None }     } } @@ -197,6 +203,8 @@ pub struct Router {     /// planeUnder regions and, per layer, the region index owning each cell (-1 none).     pub keep: Vec<plane_under::Region>,     pub keep_raw: [Vec<i16>; 2],+    /// Spec decoupling pairs resolved on this board (decoupling.rs).+    pub pairs: Vec<decoupling::PairState>, }  impl Router {@@ -238,15 +246,17 @@ impl Router {             pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(),             body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(),             fixed_raw: [vec![-1; n], vec![-1; n]], fixed_tracks: Vec::new(), fixed_vias: Vec::new(), pad_comp: Vec::new(), comp_copper: HashMap::new(), fixed_targets: HashMap::new(),-            keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], board,+            keep: Vec::new(), keep_raw: [vec![-1; n], vec![-1; n]], pairs: Vec::new(), board,         };         r.big_clr_val = r.rules.clearance;         let t0 = std::time::Instant::now();         r.stamp_pads();+        r.stamp_holes();         r.stamp_board_copper();         r.stamp_plane_under();         r.build_clusters();         r.build_ic_zones();+        r.init_decoupling();         let count = r.nets.iter().filter(|n| (n.clusters.len() >= 2 || n.plane.is_some()) && !n.done_by_board).count();         r.log(format!("router: {} x {} cells, {} pads stamped, {} nets to route, init {:.1}s", r.grid.w, r.grid.h, r.board.pads.len(), count, t0.elapsed().as_secs_f64()));         r@@ -297,6 +307,31 @@ impl Router {         }     } +    /// Drilled holes with no copper (mask-only NPTH): physical obstacles on both layers, like an+    /// unnumbered copper hole, so tracks keep clearance and vias keep hole clearance. They are not+    /// pads, so they never join a net or a cluster.+    fn stamp_holes(&mut self) {+        let g = self.grid.clone();+        let holes = self.board.holes.clone();+        for h in &holes {+            let mut m = g.mask();+            g.poly_mask(&h.poly, &mut m);+            for c in g.disk_cells((h.x, h.y), h.size.0.min(h.size.1) / 2.0, None) {+                m.bits[c] = true;+            }+            let fpi = self.fp_index.get(&h.reference).map(|&k| k as i16).unwrap_or(-1);+            for c in m.bits.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i) {+                for l in [F_CU, B_CU] {+                    if self.pad_raw[l][c] == -1 {+                        self.pad_raw[l][c] = -2;+                        self.pad_owner[l][c] = fpi;+                    }+                }+                self.thru_cells.bits[c] = true;+            }+        }+    }+     /// Resolve the planeUnder regions and mark their cells. A region the board cannot resolve is     /// logged and skipped here; `route` refuses such a spec before it builds a router.     fn stamp_plane_under(&mut self) {@@ -735,6 +770,17 @@ impl Router {                 }             }         }+        // a drilled hole with no copper blocks escapes of its own footprint too (a USB-C shell peg)+        for h in &self.board.holes {+            if (h.x - b.0).abs() > reach + h.diameter() || (h.y - b.1).abs() > reach + h.diameter() {+                continue;+            }+            for &(x, y, hw) in &samples {+                if geom::point_poly_distance(x, y, &h.poly) < hw + self.rules.clearance {+                    return false;+                }+            }+        }         true     } 
crates/aiflow-router/src/rules.rs+4−1
@@ -31,6 +31,8 @@ pub struct Rules {     /// Layer policy (spec "planeUnder"): regions of a layer that belong to one net's plane; every     /// other net treats them as a keepout on that layer (vias included, they pierce both layers).     pub plane_under: Vec<PlaneUnder>,+    /// Assigned-pin decoupling pairs (spec "decoupling"), routed before the rail (decoupling.rs).+    pub decoupling: Vec<crate::decoupling::Pair>, }  /// One `planeUnder` entry: `{"layer": "B.Cu", "net": "GND", "around": ["U1", "C2.1"], "margin": 1.0}`@@ -51,7 +53,7 @@ impl Default for Rules {         Rules {             track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5,             stub: 0.25, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,-            planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(),+            planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(), plane_under: Vec::new(), decoupling: Vec::new(),         }     } }@@ -71,6 +73,7 @@ impl Rules {         r.wide = get_widths("wideNets");         r.mid = get_widths("midNets");         r.plane_under = PlaneUnder::from_spec(spec);+        r.decoupling = crate::decoupling::Pair::from_spec(spec).unwrap_or_default();         let num = |k: &str| spec.get(k).and_then(|v| v.as_f64());         if let Some(v) = num("clearance") { r.clearance = v; }         if let Some(m) = spec.get("netClearance").and_then(|v| v.as_object()) {
crates/aiflow-router/src/simplify.rsadded+372
@@ -0,0 +1,372 @@+//! Post-route clean-up of a plan (issue #39, reply 1): staircase traces become clean 45 degree runs.+//!+//! A grid search, or an AI writing a path point by point, can leave a diagonal run as a staircase+//! of tiny alternating horizontal and vertical segments. They are electrically fine but add+//! vertices and acid traps and look machine-made. For every path of a plan:+//! 1. collinear consecutive segments are merged into one;+//! 2. a staircase (three or more consecutive axis-aligned segments, each no longer than+//!    STAIR_FACTOR x the track width, alternating between horizontal and vertical) is replaced by+//!    one 45 degree segment plus at most one orthogonal leg between the same end points, only when+//!    the replacement keeps clearance by the router's own obstacle data (other nets' pads, routes,+//!    fixed copper, planeUnder regions and the board edge, dilated exactly as the search dilates+//!    them); otherwise the staircase is left as it is;+//! 3. collinear segments are merged again.+//! Pads, layer changes and the path's ends are never moved. The gate's DRC then runs on the+//! changed copper as on any plan.+//!+//! `staircases` and `board_staircases` find what is left (a warning in the gate).++use std::collections::{BTreeMap, HashMap};++use aiflow_board::{Board, Pt, B_CU, F_CU};+use serde_json::{json, Value};++use crate::{slop, Router};++/// A staircase step is at most this many track widths long.+pub const STAIR_FACTOR: f64 = 2.0;+/// A staircase has at least this many steps.+pub const STAIR_MIN_STEPS: usize = 3;++const EPS: f64 = 1e-6;++fn axis(a: Pt, b: Pt) -> Option<bool> {+    let (dx, dy) = ((b.0 - a.0).abs(), (b.1 - a.1).abs());+    if dx < EPS && dy < EPS { None } else if dy < EPS { Some(true) } else if dx < EPS { Some(false) } else { None }+}++fn len(a: Pt, b: Pt) -> f64 { (b.0 - a.0).hypot(b.1 - a.1) }++/// Staircase runs in a polyline: (first segment index, number of segments).+pub fn staircases(pts: &[Pt], width: f64) -> Vec<(usize, usize)> {+    let max = STAIR_FACTOR * width + EPS;+    let mut out = Vec::new();+    let n = pts.len();+    let mut j = 0;+    while j + 1 < n {+        let mut m = j;+        let mut last: Option<bool> = None;+        while m + 1 < n {+            let Some(h) = axis(pts[m], pts[m + 1]) else { break };+            if len(pts[m], pts[m + 1]) > max || last == Some(h) { break; }+            last = Some(h);+            m += 1;+        }+        let steps = m - j;+        if steps >= STAIR_MIN_STEPS { out.push((j, steps)); j = m; } else { j += 1; }+    }+    out+}++/// Drop free interior points that sit on a straight run (same direction in and out).+fn merge_collinear(pts: &mut Vec<Pt>, free: &mut Vec<bool>) -> usize {+    let mut removed = 0;+    let mut i = 1;+    while i + 1 < pts.len() {+        let (a, b, c) = (pts[i - 1], pts[i], pts[i + 1]);+        let (ux, uy, vx, vy) = (b.0 - a.0, b.1 - a.1, c.0 - b.0, c.1 - b.1);+        let cross = ux * vy - uy * vx;+        let dot = ux * vx + uy * vy;+        let scale = (ux.hypot(uy) * vx.hypot(vy)).max(1e-12);+        if free[i] && (cross / scale).abs() < 1e-6 && dot > 0.0 {+            pts.remove(i); free.remove(i); removed += 1;+        } else { i += 1; }+    }+    removed+}++/// The two 45 degree candidates between a and b: diagonal first, or leg first. A pure diagonal or+/// a straight line has one.+pub fn replacements(a: Pt, b: Pt) -> Vec<Vec<Pt>> {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let d = dx.abs().min(dy.abs());+    if d < EPS || (dx.abs() - dy.abs()).abs() < EPS { return vec![vec![a, b]]; }+    let (sx, sy) = (dx.signum() * d, dy.signum() * d);+    vec![vec![a, (a.0 + sx, a.1 + sy), b], vec![a, (b.0 - sx, b.1 - sy), b]]+}++#[derive(Debug, Default, Clone, Copy)]+pub struct Counts { pub merged: usize, pub replaced: usize, pub kept: usize }++/// Simplify one chain (its first and last points and every non-free point stay where they are).+/// `clear(a, b)` says whether a new segment keeps clearance.+pub fn simplify_chain(pts: &[Pt], free: &[bool], width: f64, clear: &mut dyn FnMut(Pt, Pt) -> bool) -> (Vec<Pt>, Vec<bool>, Counts) {+    let (mut p, mut f) = (pts.to_vec(), free.to_vec());+    let mut c = Counts { merged: merge_collinear(&mut p, &mut f), ..Default::default() };+    let mut j = 0;+    loop {+        let runs = staircases(&p, width);+        let Some(&(s, k)) = runs.iter().find(|(s, _)| *s >= j) else { break };+        let (a, b) = (p[s], p[s + k]);+        // only free points inside the run may go+        if !(s + 1..s + k).all(|i| f[i]) { c.kept += 1; j = s + 1; continue; }+        match replacements(a, b).into_iter().find(|cand| cand.windows(2).all(|w| clear(w[0], w[1]))) {+            Some(cand) => {+                let mid: Vec<Pt> = cand[1..cand.len() - 1].to_vec();+                let nm = mid.len();+                p.splice(s + 1..s + k, mid);+                f.splice(s + 1..s + k, std::iter::repeat(true).take(nm));+                c.replaced += 1;+                j = s + 1;+            }+            None => { c.kept += 1; j = s + k; }+        }+    }+    c.merged += merge_collinear(&mut p, &mut f);+    (p, f, c)+}++impl Router {+    /// The router's own obstacle check for a straight copper segment of `width` on layer `l`:+    /// every other net's pad, stub, route, via and fixed copper, a foreign planeUnder region, and+    /// the board edge, dilated by the clearance plus half the width plus the grid slop.+    pub fn segment_clear(&mut self, net_name: &str, l: usize, a: Pt, b: Pt, width: f64) -> bool {+        let g = self.grid.clone();+        let nid = self.net_ids.get(net_name).map(|&k| k as i16).unwrap_or(-3);+        let clr = self.rules.clearance.max(self.rules.net_clearance.get(net_name).copied().unwrap_or(0.0));+        let r = clr + width / 2.0 + slop();+        let r_big = self.big_clr_val + width / 2.0 + slop();+        let inside = self.inside_for(width).clone();+        for c in g.segment_cells(a, b, 0.0, Some(1e-6)) {+            if !inside.bits[c] { return false; }+        }+        for c in g.segment_cells(a, b, r, Some(0.0)) {+            let other = |v: i16| v != -1 && v != nid;+            if other(self.pad_raw[l][c]) || other(self.route_raw[l][c]) || other(self.fixed_raw[l][c]) || self.kept_from(net_name, l, c) { return false; }+        }+        if r_big > r {+            for c in g.segment_cells(a, b, r_big, Some(0.0)) {+                let p = self.pad_raw[l][c];+                if self.big_clr[l].bits[c] && p != -1 && p != nid { return false; }+            }+        }+        true+    }++    fn stamp_segment(&mut self, net_name: &str, l: usize, a: Pt, b: Pt, width: f64) {+        let Some(&k) = self.net_ids.get(net_name) else { return };+        let g = self.grid.clone();+        for c in g.segment_cells(a, b, width / 2.0, Some(1e-6)) { self.route_raw[l][c] = k as i16; }+    }+}++/// A plan waypoint resolved: where it is, the layer it sets, and whether it may be removed.+struct Wp { xy: Pt, layer: Option<String>, free: bool }++fn resolve(board: &Board, v: &Value) -> Option<Wp> {+    if let Some(k) = v.as_str() { let p = board.pad_by_key(k)?; return Some(Wp { xy: (p.x, p.y), layer: None, free: false }); }+    if let Some(a) = v.as_array() { return Some(Wp { xy: (a.first()?.as_f64()?, a.get(1)?.as_f64()?), layer: None, free: true }); }+    let o = v.as_object()?;+    let layer = o.get("layer").and_then(|l| l.as_str()).map(str::to_string);+    if let Some(k) = o.get("pad").and_then(|p| p.as_str()) { let p = board.pad_by_key(k)?; return Some(Wp { xy: (p.x, p.y), layer, free: false }); }+    Some(Wp { xy: (o.get("x")?.as_f64()?, o.get("y")?.as_f64()?), free: layer.is_none(), layer })+}++fn round(v: f64) -> f64 { (v * 1e6).round() / 1e6 }++/// Simplify every path of a plan in place; returns the report (vertex count per net before and+/// after, staircases replaced and kept, collinear points merged).+pub fn simplify_plan(router: &mut Router, plan: &mut [Value]) -> Value {+    let mut per_net: BTreeMap<String, (usize, usize)> = BTreeMap::new();+    let mut total = Counts::default();+    for e in plan.iter_mut() {+        let net = e["net"].as_str().unwrap_or("").to_string();+        let width = e["width"].as_f64().unwrap_or(0.25);+        let entry_layer = e["layer"].as_str().map(str::to_string);+        let key = if e["paths"].is_array() { "paths" } else if e["points"].is_array() { "points" } else { continue };+        let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };+        let mut out_paths = Vec::new();+        for path in paths {+            let Some(raw) = path.as_array().cloned() else { out_paths.push(path); continue };+            let rs: Vec<Option<Wp>> = raw.iter().map(|v| resolve(&router.board, v)).collect();+            let entry = per_net.entry(net.clone()).or_insert((0, 0));+            entry.0 += raw.len();+            if rs.iter().any(|w| w.is_none()) || raw.len() < 3 { entry.1 += raw.len(); out_paths.push(Value::Array(raw)); continue; }+            let rs: Vec<Wp> = rs.into_iter().map(Option::unwrap).collect();+            // the layer of the segment leaving each waypoint, as plan_to_copper reads it+            let mut layer = entry_layer.clone().or_else(|| rs[0].layer.clone()).unwrap_or_else(|| "F.Cu".into());+            let mut seg_layer = Vec::with_capacity(rs.len());+            let mut anchor = vec![false; rs.len()];+            for (i, w) in rs.iter().enumerate() {+                let nxt = w.layer.clone().unwrap_or_else(|| layer.clone());+                if nxt != layer { anchor[i] = true; layer = nxt; }+                seg_layer.push(layer.clone());+            }+            let mut new_path: Vec<Value> = vec![raw[0].clone()];+            let mut i = 0;+            while i + 1 < rs.len() {+                // a chain: from i to the next anchor or layer change (exclusive of what follows it)+                let mut k = i + 1;+                while k + 1 < rs.len() && rs[k].free && !anchor[k] && seg_layer[k] == seg_layer[i] { k += 1; }+                let l = match seg_layer[i].as_str() { "F.Cu" => Some(F_CU), "B.Cu" => Some(B_CU), _ => None };+                let pts: Vec<Pt> = rs[i..=k].iter().map(|w| w.xy).collect();+                let free: Vec<bool> = (i..=k).map(|q| q != i && q != k).collect();+                if let (Some(l), true) = (l, k - i >= 2) {+                    let mut accepted: Vec<(Pt, Pt)> = Vec::new();+                    let (np, _, c) = {+                        let r = &mut *router;+                        let net_s = net.clone();+                        simplify_chain(&pts, &free, width, &mut |a, b| { let ok = r.segment_clear(&net_s, l, a, b, width); if ok { accepted.push((a, b)); } ok })+                    };+                    for (a, b) in accepted { router.stamp_segment(&net, l, a, b, width); }+                    total.merged += c.merged; total.replaced += c.replaced; total.kept += c.kept;+                    // keep original values where a point survived unchanged, new points as [x, y]+                    let orig: HashMap<(i64, i64), Value> = (i..=k).map(|q| (((rs[q].xy.0 * 1e6).round() as i64, (rs[q].xy.1 * 1e6).round() as i64), raw[q].clone())).collect();+                    for p in &np[1..] {+                        let key = ((p.0 * 1e6).round() as i64, (p.1 * 1e6).round() as i64);+                        new_path.push(orig.get(&key).cloned().unwrap_or_else(|| json!([round(p.0), round(p.1)])));+                    }+                } else {+                    new_path.extend(raw[i + 1..=k].iter().cloned());+                }+                i = k;+            }+            entry.1 += new_path.len();+            out_paths.push(Value::Array(new_path));+        }+        if key == "paths" { e["paths"] = Value::Array(out_paths); } else if let Some(p) = out_paths.into_iter().next() { e["points"] = p; }+    }+    let nets: serde_json::Map<String, Value> = per_net.iter().map(|(n, (b, a))| (n.clone(), json!({"before": b, "after": a}))).collect();+    let (b, a): (usize, usize) = per_net.values().fold((0, 0), |s, v| (s.0 + v.0, s.1 + v.1));+    json!({"verticesBefore": b, "verticesAfter": a, "staircasesReplaced": total.replaced, "staircasesKept": total.kept, "collinearMerged": total.merged, "nets": nets,+        "rule": format!("staircase = {STAIR_MIN_STEPS}+ alternating 0/90 degree segments each <= {STAIR_FACTOR} x track width; replaced by a 45 degree segment plus at most one orthogonal leg only where the router's obstacle check keeps clearance")})+}++/// Staircases left in a board's copper: track chains per net and layer joined end to end through+/// points where exactly two tracks meet. One line per staircase.+pub fn board_staircases(b: &Board) -> Vec<String> {+    let tracks = b.tracks();+    let key = |p: Pt| ((p.0 * 1e4).round() as i64, (p.1 * 1e4).round() as i64);+    let mut groups: BTreeMap<(String, usize), Vec<usize>> = BTreeMap::new();+    for (i, t) in tracks.iter().enumerate() { groups.entry((t.net.clone(), t.layer)).or_default().push(i); }+    let mut out = Vec::new();+    for ((net, layer), ids) in groups {+        let mut at: HashMap<(i64, i64), Vec<usize>> = HashMap::new();+        for &i in &ids { at.entry(key(tracks[i].a)).or_default().push(i); at.entry(key(tracks[i].b)).or_default().push(i); }+        let mut used = std::collections::HashSet::new();+        for &start in &ids {+            if used.contains(&start) { continue; }+            // walk to one end of the chain, then collect forward+            let (mut cur, mut from) = (start, key(tracks[start].a));+            let mut guard = 0;+            loop {+                let next = at.get(&from).filter(|v| v.len() == 2).and_then(|v| v.iter().find(|&&t| t != cur).copied());+                match next { Some(n) if n != start && guard < ids.len() => { let t = &tracks[n]; from = if key(t.a) == from { key(t.b) } else { key(t.a) }; cur = n; guard += 1; } _ => break }+            }+            // cur is an end track, `from` its outer end; walk the other way+            let mut pts: Vec<Pt> = Vec::new();+            let mut width = f64::INFINITY;+            let (mut t_id, mut end) = (cur, from);+            loop {+                if !used.insert(t_id) { break; }+                let t = &tracks[t_id];+                let (p, q) = if key(t.a) == end { (t.a, t.b) } else { (t.b, t.a) };+                if pts.is_empty() { pts.push(p); }+                pts.push(q);+                width = width.min(t.width);+                end = key(q);+                match at.get(&end).filter(|v| v.len() == 2).and_then(|v| v.iter().find(|&&x| x != t_id).copied()) { Some(n) => t_id = n, None => break }+            }+            for (s, k) in staircases(&pts, width) {+                out.push(format!("{net} on {}: a {k}-step staircase from ({:.3}, {:.3}) to ({:.3}, {:.3})", aiflow_board::LAYER_NAMES[layer], pts[s].0, pts[s].1, pts[s + k].0, pts[s + k].1));+            }+        }+    }+    out+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::Rules;++    fn stair(n: usize, step: f64, from: Pt) -> Vec<Pt> {+        let mut p = vec![from];+        for k in 0..n { let l = *p.last().unwrap(); p.push(if k % 2 == 0 { (l.0 + step, l.1) } else { (l.0, l.1 + step) }); }+        p+    }++    #[test]+    fn detects_staircases_and_not_long_jogs() {+        let s = stair(6, 0.3, (0.0, 0.0));+        assert_eq!(staircases(&s, 0.25), vec![(0, 6)]);+        // steps longer than 2 x width are a real jog, not a staircase+        assert!(staircases(&stair(6, 0.6, (0.0, 0.0)), 0.25).is_empty());+        // two alternating steps are a corner pair, not a staircase+        assert!(staircases(&stair(2, 0.3, (0.0, 0.0)), 0.25).is_empty());+    }++    #[test]+    fn a_staircase_becomes_a_diagonal_and_a_leg_and_collinear_points_merge() {+        // 6 steps of 0.3 after a straight lead-in with a redundant point: the first step merges+        // into the lead-in, the 5 left span dx 0.6, dy 0.9+        let mut pts = vec![(-2.0, 0.0), (-1.0, 0.0)];+        pts.extend(stair(6, 0.3, (0.0, 0.0)));+        let free: Vec<bool> = (0..pts.len()).map(|i| i != 0 && i != pts.len() - 1).collect();+        let (p, _, c) = simplify_chain(&pts, &free, 0.25, &mut |_, _| true);+        assert_eq!(c.replaced, 1);+        assert!(c.merged >= 1);+        // lead-in, a 45 degree segment, one orthogonal leg: 4 points+        assert_eq!(p.len(), 4, "{p:?}");+        assert!(staircases(&p, 0.25).is_empty());+        let diag = |a: Pt, b: Pt| ((b.0 - a.0).abs() - (b.1 - a.1).abs()).abs() < 1e-9;+        assert!((diag(p[1], p[2]) && axis(p[2], p[3]).is_some()) || (axis(p[1], p[2]).is_some() && diag(p[2], p[3])), "{p:?}");+        assert_eq!(*p.last().unwrap(), *pts.last().unwrap());+    }++    #[test]+    fn the_leg_first_candidate_is_used_when_the_diagonal_first_one_is_blocked() {+        let pts = stair(5, 0.3, (0.0, 0.0)); // ends at (0.9, 0.6)+        let free: Vec<bool> = (0..pts.len()).map(|i| i != 0 && i != pts.len() - 1).collect();+        // diagonal first would turn at (0.6, 0.6); refuse any segment that ends there+        let (p, _, c) = simplify_chain(&pts, &free, 0.25, &mut |a, b| (a.0 - 0.6).abs() + (a.1 - 0.6).abs() > 1e-9 && (b.0 - 0.6).abs() + (b.1 - 0.6).abs() > 1e-9);+        assert_eq!(c.replaced, 1);+        let want = [(0.0, 0.0), (0.3, 0.0), (0.9, 0.6)];+        assert!(p.len() == 3 && p.iter().zip(want).all(|(a, b)| len(*a, b) < 1e-9), "{p:?}");+    }++    #[test]+    fn a_staircase_is_kept_when_no_replacement_clears() {+        let pts = stair(4, 0.3, (0.0, 0.0));+        let free: Vec<bool> = (0..pts.len()).map(|i| i != 0 && i != pts.len() - 1).collect();+        let (p, _, c) = simplify_chain(&pts, &free, 0.25, &mut |_, _| false);+        assert_eq!((c.replaced, c.kept), (0, 1));+        assert_eq!(p, pts);+    }++    #[test]+    fn plan_simplifier_respects_other_nets_by_the_router_obstacle_data() {+        // net A from J1 (2, 5) to J2 (8, 7.4) with a staircase between, net B's pad elsewhere+        let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true), ("J2", "A", 8.0, 7.4, true), ("K1", "B", 15.0, 2.0, true)], "");+        let mut r = Router::new(b, Rules::default(), false);+        let mut path = vec![json!("J1.1"), json!([4.0, 5.0])];+        for p in stair(8, 0.3, (4.0, 5.0)).into_iter().skip(1) { path.push(json!([p.0, p.1])); }+        path.push(json!([6.0, 6.2]));+        path.push(json!("J2.1"));+        let mut plan = vec![json!({"net": "A", "width": 0.25, "paths": [path.clone()]})];+        let rep = simplify_plan(&mut r, &mut plan);+        assert_eq!(rep["staircasesReplaced"], 1, "{rep}");+        let after = plan[0]["paths"][0].as_array().unwrap().len();+        assert!(after < path.len(), "{rep}");+        assert_eq!(rep["nets"]["A"]["before"], path.len());+        assert_eq!(rep["nets"]["A"]["after"], after);+        assert_eq!(plan[0]["paths"][0][0], "J1.1");+        assert_eq!(plan[0]["paths"][0][after - 1], "J2.1");+        // the router's check refuses copper over another net's pad and off the board+        let k = (15.0, 2.0);+        assert!(!r.segment_clear("A", F_CU, (14.0, 2.0), (16.0, 2.0), 0.25));+        assert!(!r.segment_clear("A", F_CU, (0.1, 1.0), (0.1, 3.0), 0.25));+        assert!(r.segment_clear("B", F_CU, (k.0 - 1.0, k.1), (k.0 + 1.0, k.1), 0.25));+    }++    #[test]+    fn board_staircases_are_found_in_track_chains() {+        let mut extra = String::new();+        let p = stair(5, 0.3, (4.0, 5.0));+        for w in p.windows(2) { extra.push_str(&crate::board_copper::tests::seg("A", "F.Cu", w[0], w[1])); }+        let b = crate::board_copper::tests::board(&[("J1", "A", 2.0, 5.0, true)], &extra);+        let found = board_staircases(&b);+        assert_eq!(found.len(), 1, "{found:?}");+        assert!(found[0].contains("5-step staircase"), "{found:?}");+    }+}
crates/aiflow-run/src/lib.rs+28−2
@@ -86,7 +86,7 @@ impl Run {             "target": target, "remoteBoard": remote_board,             "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},             "plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [board],-            "outcomes": {}, "summary": null,+            "boardAddedAt": {board: now()}, "outcomes": {}, "summary": null,             "currentStep": null, "steps": {}, "turns": [], "delivery": null,         }) };         r.save()?;@@ -124,6 +124,7 @@ impl Run {     pub fn attach_board(&mut self, board: &str, spec: &str, engine: &str, target: Option<&str>, remote_board: Option<&str>) {         self.data["board"] = json!(board);         self.data["boards"] = json!([board]);+        self.note_board_added(board);         self.data["spec"] = json!(spec);         self.data["engine"] = json!(engine);         if target.is_some() { self.data["target"] = json!(target); }@@ -242,12 +243,37 @@ impl Run {     pub fn add_board(&mut self, path: &str) {         let mut invalidated=Vec::new();         if let Some(outcomes)=self.data["outcomes"].as_object_mut() {-            for key in ["route","gate","land-route","land-vias","land-pours","measure","analyze-current","analyze-thermal"] {+            for key in Self::COPPER_OUTCOMES {                 if outcomes.remove(key).is_some(){invalidated.push(key);}             }         }         if !invalidated.is_empty(){self.log("board-evidence-invalidated",json!({"newBoard":path,"outcomes":invalidated,"reason":"board snapshot changed; historical ledger preserved"}));}         self.data["boards"].as_array_mut().unwrap().push(json!(path));+        self.note_board_added(path);+    }++    /// When a board snapshot joined the run (`boardAddedAt`), so a check can tell what was+    /// recorded before the board changed (verify-pours' plan provenance).+    pub fn note_board_added(&mut self, path: &str) {+        if !self.data["boardAddedAt"].is_object() { self.data["boardAddedAt"] = json!({}); }+        self.data["boardAddedAt"][path] = json!(now());+    }++    /// The copper evidence a new board snapshot normally voids (`add_board`).+    pub const COPPER_OUTCOMES: [&'static str; 8] = ["route","gate","land-route","land-vias","land-pours","measure","analyze-current","analyze-thermal"];++    /// `add_board`, except that with a carry record (the caller proved the new board's copper+    /// fingerprint equals the current one's) the copper evidence is kept and the record is stored+    /// in `copperCarry`, where every check that binds evidence to a board hash can verify it again.+    pub fn add_board_carrying(&mut self, path: &str, carry: Option<Value>) {+        let Some(mut rec) = carry else { return self.add_board(path) };+        let kept: Vec<&str> = Self::COPPER_OUTCOMES.iter().copied().filter(|k| self.data["outcomes"].get(*k).is_some()).collect();+        rec["kept"] = json!(kept);+        self.log("board-evidence-carried", json!({"newBoard": path, "carry": rec, "reason": "copper fingerprint unchanged; copper evidence kept, bound to this record"}));+        if !self.data["copperCarry"].is_array() { self.data["copperCarry"] = json!([]); }+        self.data["copperCarry"].as_array_mut().unwrap().push(rec);+        self.data["boards"].as_array_mut().unwrap().push(json!(path));+        self.note_board_added(path);     }      pub fn outcome(&mut self, key: &str, v: Value) {
docs/board-sink.mdadded+34
@@ -0,0 +1,34 @@+# Board-face heat sinks++Tabless parts (QFN/SON exposed pads) on some boards are cooled through thermal vias into a pedestal or plate pressed on the board's bottom, usually through a gap pad. `packageCooling` models a sink on a part's case and must not be used for this. Declare the sink on the board:++```json+"boardSink": [{+  "name": "pedestal",+  "region": [[15, 10], [25, 10], [25, 20], [15, 20]],+  "layer": "B.Cu",+  "kPerW": 2.0,+  "interfaceKPerW": 1.0,+  "parts": ["U1", "U3"],+  "note": "6061 pedestal 10x10 mm, 1 mm gap pad 3 W/mK (datasheet), enclosure wall to ambient 2 K/W measured",+  "fieldsHeatsink": "pedestal-block"+}]+```++- `region`: a polygon in board mm, or `"outline"` for the whole board.+- `layer`: the board face the sink presses on.+- Exactly one of `kPerW` (sink to ambient) or `hWm2K` (the region area is the sink area).+- `interfaceKPerW`: the gap pad over the whole region (optional; without it direct contact is assumed and recorded). `maskOpen: true` when the face has no solder mask there.+- `parts`: the hot parts it cools. A part can be on one sink only and not also in `packageCooling`.+- `note`: what the hardware is and where the numbers come from.+- `fieldsHeatsink`: optional, the name of a `heatsinks` block (Adom Fields 0.4 or later) in the same spec.++## The estimate++For each named part, `analyze thermal` counts the tab-net vias within 1 mm of the part's tab-net pads whose centres lie in the region. The series path is: each via's plated barrel (copper 385 W/mK, 25 um wall, `boardThicknessMm` or 1.6 mm) plus a lateral copper strip from the pad edge, in parallel; solder mask at 80 K mm2/W unless `maskOpen`; the interface on a contact patch (the vias' box grown 1 mm when the tab net has copper on that face, else the via lands alone), with `interfaceKPerW` spread over the region area so a small patch pays more; then the sink's K/W heated by the full power of every part on it. No board spreading and no air credit are given on this path. The part passes when this path or its ordinary copper path alone meets its limit. Parts not named on a sink keep the still-air copper check unchanged.++## The Fields solve++With `fieldsHeatsink`, the same-input Fields solve models the sink as a conducting block. When the field screen passes (every chip within its budget with the block in place), the block lies on the sink's face inside its region, and it carries heat in both air conditions, the named parts' failing estimates are replaced by the solve's rise. The block loses heat to still or moving air on its exposed faces; the declared `kPerW` is not part of that solve, and the recorded assumption says so.++Both routes record their assumption in the analyze outcome (`boardSinkAssumptions`), the run report and `finish`. They are design screens under supplied parameters, not thermal qualification.
docs/bridge-boundary.mdadded+45
@@ -0,0 +1,45 @@+# Bridge boundary++AI Flow owns the plan, the pacing, the run ledger, the evidence and the final gates. Native EDA and desktop operations belong in the bridges. This page records which side owns what as of 0.2.3 (issue #21, kicad-bridge#101). The desktop preflight verbs were checked read-only against Adom Bridge 2.1.184 on AdomLapper on 2026-10-18 (`desktop_disk_free`, `status`); the KiCad verbs were last checked against KiCad Bridge 1.0.55 on 2026-10-15.++Board verbs keep their historical `kicad_*` names in the flow. `Bridge::call` sends each one to the run's EDA backend (`crates/aiflow-bridge/src/eda.rs`, `docs/eda-backends.md`), so KiCad, Fusion and Altium answer them in their own bridge. Desktop verbs (`desktop_*`, `pull_file`, `status`, `shell_execute`) go to Adom Bridge as they are (`Bridge::raw_call`).++## What AI Flow owns++- Plans: placement moves, routing entries, the zone plan, rule pack choice, silkscreen layout.+- Pacing: one footprint or one trace per native call so the recording shows each change.+- Ownership bookkeeping: which zone item IDs this run created (`owned_zones_to_replace` refuses a planned name that collides with an unowned live zone).+- Measurement policy: one refill request, then two delayed matching read-only snapshots before a zone area counts.+- Run ledger, clips, screenshots, narration, video composition and the `finish` gates.+- Offline checks on the run's own board snapshot: 3D orientation from STEP bounding boxes (the shared step2glb engine), proxy-model detection, netlist comparison, DRC report reading. These read a local copy and never write the desktop board.++## Bridge verbs AI Flow calls++| Need | Verb | Notes |+|---|---|---|+| Move parts | `kicad_move_footprint` | expectedRevision per part |+| Route | `kicad_route_net`, `kicad_apply_tracks`, `kicad_remove_route` | one trace per call |+| Zones | `kicad_apply_zone_plan` (KiCad Bridge 1.0.23+) | native reconcile, DRC, one Undo; no remove/create fallback |+| Zone measurement | `kicad_zone_state` | refill once, then read-only snapshots |+| Validate | `kicad_routing_validate`, `kicad_placement_validate`, `kicad_run_drc` | |+| Models | `kicad_model_check` | complete `modelBindings` (1.0.22+), viewer classes and hidden models |+| 3D viewer visibility | `kicad_3d_viewer_settings` | named in the `models` hint; AI Flow does not change it itself |+| 3D tour | `kicad_open_3d_viewer`, `kicad_3d_view`, `kicad_close_3d_viewer` | |+| Recording | `desktop_record_window_start`, `desktop_record_start`, `desktop_record_stop` | |+| Recorder disk preflight | `desktop_disk_free` (ab 2.1.127+) | read-only, ungated; default path is the recorder folder |+| Clip transfer | `pull_file` with `timeoutSeconds: 3600` | streamed, sha256 verified |+| Windows | `kicad_window_info`, `desktop_ui_window`, `desktop_bring_to_front`, `desktop_screenshot_window` | |++## Labelled legacy fallbacks++- `remote_free_mb`: a `shell_execute` PowerShell drive query runs only when `desktop_disk_free` answers `unknown_verb` (Adom Bridge older than 2.1.127). Any other refusal returns no value and the disk check uses the run drive.+- `pull_in_parts`: the PowerShell 200 MB splitter runs only when a clip did not land and the box reports Adom Bridge older than 2.1.175. Newer bridges stream any size through `pull_file`, so a failed pull is reported as `pullFailed` instead.++Remove both once no supported test box runs an older Adom Bridge.++## Still outside the bridges++- Model binding edits. KiCad Bridge has no verb to set a footprint's model path, offset, rotation or visibility. AI Flow does not rewrite `(model ...)` entries. The schematic-to-board skill still describes the board build script doing it before the board is opened; follow it with `kicad_model_check` and a 3D frame. Handoff: `kicad-bridge` (see the handoff drafts kept outside the package).+- `tools/kicad-cli-remote`: runs the desktop's `kicad-cli` through `send_files`, `shell_execute` and `pull_file`. The shared `kicad-cli` shim from service-kicad does the same job but carries only a file's same-stem siblings and pulls single-file outputs. AI Flow still needs hierarchical schematic sheets, library tables, `${KIPRJMOD}` libraries and directory outputs (Gerbers, drill), so the wrapper stays until the shim covers them. Handoff: `service-kicad`.+- `tools/fusion-project.py` reads `%USERPROFILE%` and lists `.lbr` files with `shell_execute`. `desktop_list_files` can replace the listing; left as is in this release because it is the Fusion lane and outside issue #21.+- `rules use` and `rules install` write the `.kicad_dru` beside the run's local board copy. That is a project file beside the run's board copy, not a live edit of the desktop board, so it stays.
docs/current-peak-acknowledgements.mdadded+23
@@ -0,0 +1,23 @@+# Reviewed current-density peaks++The Fields screen in `analyze current` refuses a net whose peak current density is over `maxCurrentDensityAperMm2` (60 A/mm2 by default). Some peaks are set by a part's own geometry: a SOT-23 drain land 0.6 mm wide, the last millimetre into a connector's pins or shield tab. A reviewer can accept such a point:++```sh+adom-aiflow analyze current --ack [email protected] --why "USB-C VBUS pin land limits the last 1 mm; copper beside it is full width"+adom-aiflow analyze current --ack [email protected],18.35 --why "SOT-23 drain land, 0.6 mm wide, about 1.4 A"+```++`--ack` takes `[email protected]` (a pad on that net) or `NET@x,y` (board mm), and may be repeated. A whole net (`NET`, `NET@`, `NET@*`) is refused.++What is recorded (run.json `currentAcks`): the net, the pad and its copper or the x,y, the peak value, location and layer Fields reported there, the screen, the reason, the ai thread, the time, a fingerprint of the board's copper and the Fields input identities.++When it covers an over-screen point:++- same net;+- the point lies inside the pad's copper or within 0.5 mm of it, or within 0.5 mm of the x,y;+- the density there is not more than 1 percent above the reviewed peak;+- the copper fingerprint and the Fields spec identity are the ones the review saw. The fingerprint is the one copper carry-over uses (`copper_carry.rs`: tracks, vias, zones, pads, footprint placement, outline, stackup, nets and the project rule files). Any copper, rules or spec change expires it; silkscreen, fab and text edits do not, and an acknowledgement survives exactly the adoptions that carry the Fields report over.++The number keeps being reported in `analyze current` and in `finish`, which lists every acknowledged peak with its reason. Expired acknowledgements are listed too and cover nothing. Barrel-density findings are never covered.++Limit: Fields reports one maximum per net. After that point is acknowledged, a second over-screen spot on the same net is not in the report. Check the net's density map before acknowledging, and fix copper you can change.
docs/existing-pours.md+29−3
@@ -1,9 +1,35 @@ # Verify existing pours without landing them again -Adopting a board invalidates prior landing evidence. If reviewed manual edits changed the copper, first reconcile an explicit zone plan with the intended design. Use `adom-aiflow --run <run> verify-pours --plan <reviewed-plan.json>` to compare that plan with the current saved board. This does not alter the PCB, refill zones, create an undo step, or grant zone ownership.+Adopting a board invalidates prior landing evidence unless its copper fingerprint is unchanged (a silk-only adoption keeps it; see `adopt-board`). If reviewed manual edits changed the copper, first reconcile an explicit zone plan with the intended design. Use `adom-aiflow --run <run> verify-pours --plan <plan.json>` to compare that plan with the current saved board. This does not alter the PCB, refill zones, create an undo step, or grant zone ownership. -The verifier requires a nonempty plan, one-to-one matching of every board zone, polygon ordering, copper net/layers, connection/clearance/priority/thickness, thermal and island settings, keepout flags and saved fill polygons per copper layer. It refuses unknown plan properties and unsupported native fill settings. It is deliberately conservative: equivalent reordered polygon vertices can fail. Review differences; do not blindly regenerate a plan from the board to conceal unintended edits.+The verifier requires a nonempty plan, one-to-one matching of every board zone, polygon ordering, copper net/layers, connection/clearance/priority/thickness, thermal and island settings, keepout flags and saved fill polygons per copper layer. It refuses unknown plan properties and unsupported native fill settings. It is deliberately conservative: equivalent reordered polygon vertices can fail. When a plan zone has no exact match, the failure names the closest unmatched board zone and each field that differs (geometry with the first differing vertex, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge, island removal, fill mode, keepout rules); `verify-pours.json` lists them under `differences`. Review differences; do not regenerate a plan from the board to conceal unintended edits. -The existing landing-evidence slot records `mode: verified-existing`, without item IDs or ownership changes. Finish checks SHA-256 bindings to the board, spec and plan. Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance.+## Describing zones written outside the flow++A plan zone (and a `spec.pours` entry, which `pour` copies into its plan) can carry:++- `connection`: `thermal` (default), `solid` (or `full`), or `none`+- `thermalGap`, `thermalBridge`: mm (default 0.5 each)+- `islandRemoval`: `always` (default), `never`, or `area` with `islandAreaMin` in mm2 (default 10)+- `priority`, `clearance`, `minThickness` as before++`land pours` refuses `connection: none` and any `islandRemoval` other than `always` before calling the bridge: `kicad_apply_zone_plan` lands thermal or solid pads with islands always removed, so such a landing would silently differ from the plan. Write those zones natively, then verify or adopt them.++## Plan provenance++A plan written from the board always matches the board, so it proves nothing. `verify-pours` accepts a plan only when:++1. `pour` wrote it (the run records the hash of every plan `pour` writes), or+2. it was recorded with `verify-pours --plan <file> --record-plan` strictly before the board's zones last changed.++The zone change time is when the run added the oldest board snapshot in the latest unbroken series whose zone definitions (outline, net, layers, settings; not fills or item IDs) equal the current board's (`boardAddedAt`, or the snapshot file's modification time for older runs). Any other plan, including one created, edited or recorded after that time, is refused with the times involved. `--why "<reason>"` accepts it anyway; the proof records `planProvenance: {kind: accepted-with-why, why, refusal}`. The check binds hashes and recorded times; it cannot prove how a human wrote a reviewed plan, and `spec.pours` remains the reviewable source of what `pour` writes.++## Adopting matching zones++`verify-pours --plan <plan> --adopt --why "<reason>"` runs the same verification and, only on a full pass, takes ownership of the matched zones. Each matched zone must have a saved `uuid` (or `tstamp`); nothing is inferred from names or geometry. The IDs join `ownedZoneIds` (legacy creation receipts are kept), the grant is appended to `zoneAdoptions` with the why, plan, plan hash, board hash and provenance, and the outcome records `mode: adopted-existing`, `ownershipGranted: true`. A later `land pours` may replace these zones through the native bridge, which still compares definitions, runs candidate DRC and refuses conflicts with unowned zones. If the live KiCad item IDs differ from the saved IDs, the bridge treats the zones as unowned and refuses: the safe outcome.++## Evidence++The landing-evidence slot records `mode: verified-existing` or `mode: adopted-existing`; neither claims the run landed the copper, and `finish`, `status` and the summary name the mode. Finish checks SHA-256 bindings to the board, spec and plan (a silk-only adoption is accepted only through its recorded copper carry). Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance.  Do not replay `land pours` merely to restore dashboard evidence. That command is a mutation with native ownership rules. Preserve successful creation receipts and never infer ownership from names or geometry.
docs/fusion-lane.md+8−2
@@ -18,6 +18,12 @@ adom-aiflow start --board lanetest.brd --spec spec.json --engine <you> \ - A `.brd` is converted to `board-0.kicad_pcb` in the run (parts, pads, nets, outline, tracks, vias,   pours with Fusion's own fill). Fusion's Y axis points up and KiCad's points down, so every point is   written as (x, -y); rotations keep their number; a mirrored part (bottom side) lands on B.Cu.+  EAGLE's `MRฮธ` is rotate ฮธ, then mirror x, so a bottom part at MR90 turns clockwise as seen from the top.+- Silkscreen text comes along as `gr_text` (issue #74): plain text on tPlace/tNames (21, 25) on F.SilkS,+  on bPlace/bNames (22, 26) on B.SilkS, each smashed part's displayed NAME where it stands, an unsmashed+  part's package NAME through the part's transform. tValues/bValues (27, 28) are not printed and are+  left out. `finish` counts these labels; `fusion silkscreen`'s proof is the second source (failed steps+  are reported, and a board showing fewer labels than the pass placed is warned about). - `--remote-board fusion:<doc>` is required for a `.brd`. From then on every live verb the flow   sends (`kicad_*`) is answered by the Fusion bridge for that document instead (see the table). - The Fusion board must be the active document on the box (`fusion_show_2d_board`).@@ -45,9 +51,9 @@ behind the flow's back is refused (`stale_board`), never edited blind.  | step | what it does | |---|---|-| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. |+| `fusion library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE library for the project: each part from its wiki component page, Mouser, DigiKey and LCSC numbers (the page's own, then the distributor lookups, each checked against the exact MPN), the chip outline as symbol for ICs, inductors and big capacitors, standard symbols for passives, the etched or coloured 3D bound. Run again with new parts and they join the same library; parts already bound are kept. `--project`/`--folder` set the run's cloud home, asked of the bridge for the 3D models too (a bridge that does not take it yet puts them under the project active in Fusion's data panel, and the step says so). | | `fusion reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Fusion board's frame: `moves.json`, `fixed.json`, `spec.json`. |-| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. |+| `fusion schematic --netlist <netlist.json> --spec <spec.json> --fixed <fixed.json> --name <Design>` | The design started with the fab's rules and the fixed parts (`fusion_board_seed`), the schematic drawn live in labelled groups (`fusion_schematic_plan`, `fusion_schematic_run`), checked pin by pin, saved, and the board written for `start`. The save is the new design's first save into `--project`/`--folder` (the run's, else Main / Adom AI Flow); it is refused when the design is already in the cloud (the bridge's save is Save As, which forks an already-saved design's board into an unlinked .fbrd) or when the folder already holds a design of the same name. | | `fusion view3d` | The board in Fusion's 3D PCB: a still from Fusion's own renderer saved in the run to check every part sits flat on its pads, then a slow live orbit for the recording. | | `fusion reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. | | `fusion export` | Gerbers (the JLCPCB CAM job), pick and place, and the BOM with every distributor number, in `<run>/manufacturing/`. Only when the human asks; answer with each file's path. |
docs/plan-schema.md+2
@@ -16,6 +16,8 @@ Either an object with a `nets` array, or a bare array of the same entries:  Other top-level keys (`engine`, `note`, `planeUnder`, ...) are kept as notes and ignored by the copper. `{"nets": []}` is an empty plan: the board is gated and landed as it is. +An entry may carry a label the copper ignores: `"kelvin": true` for a Kelvin tap, `"decoupling": "C6.1->U1.39"` for the direct connection of a `spec.decoupling` pair. The decoupling review in `gate` reads that label to say the pair was routed directly.+ ## Entry  ```json
flows/board.json+31
@@ -588,6 +588,37 @@     "finish": 20,     "export": 20   },+  "skills": {+    "_": "the companion skills to read for each step (the Companion skills table in SKILL.md); `step <name>` names each SKILL.md by absolute path",+    "intake": ["aiflow-intake"],+    "rules": ["aiflow-sourcing", "aiflow-molecule"],+    "design": ["aiflow-circuit-design"],+    "test-plan": ["aiflow-test-plan"],+    "sourcing": ["aiflow-sourcing"],+    "parts": ["aiflow-sourcing"],+    "project-page": ["aiflow-project-page"],+    "library": ["aiflow-sourcing"],+    "components": ["aiflow-sourcing"],+    "libraries": ["aiflow-sourcing"],+    "models": ["aiflow-sourcing"],+    "library-tour": ["aiflow-sourcing", "aiflow-live-clips"],+    "schematic": ["aiflow-schematic-to-board", "aiflow-circuit-design"],+    "simulation": ["aiflow-simulate"],+    "placement": ["aiflow-power-layout", "aiflow-molecule"],+    "routing": ["aiflow-power-layout"],+    "thermal-vias": ["aiflow-power-layout"],+    "pours": ["aiflow-power-layout"],+    "outline": ["aiflow-molecule"],+    "current": ["aiflow-power-layout"],+    "thermal": ["aiflow-power-layout"],+    "fields": ["aiflow-power-layout"],+    "nets": ["aiflow-power-layout", "aiflow-live-clips"],+    "silkscreen": ["aiflow-silkscreen"],+    "3d": ["aiflow-molecule"],+    "capture": ["aiflow-process-video", "aiflow-live-clips"],+    "finish": ["aiflow-measurement", "aiflow-process-video"],+    "export": ["aiflow-schematic-to-board", "aiflow-molecule"]+  },   "phases": {     "Plan": [       "intake",
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.2",+  "version": "0.2.3",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.2",+  "version": "0.2.3",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+52−4
@@ -206,7 +206,7 @@ everything drawn on the design. The lane's steps are the Fusion lane's, with `al  The clock starts at the prompt even when there is no board yet. -- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each records a clip of the desktop until a PCB editor is open. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page.+- **`intake --prompt-time <UTC of the prompt> [--brief <file>] [--requirements requirements.json] [--target <box>] [--page <owner/slug>]`** opens the run with the `intake` step and no board. Pre-board steps are steps like any other: `step sourcing`, `step design`, `step simulation`, `step schematic`; every command is a turn charged to the step, and with `--target` each films the tool's own window when the step works in it, else Hydrogen with the run's dashboard, else the AI's terminal window, or records a skip when nothing meaningful is on screen. `start --board ... --spec ...` on the same `--run` attaches the board: same clock, same ledger, the steps before the board stay on the page. - **`sourcing check --bom design/bom.csv [--profile fab|jlcpcb] [--qty N] [--requirements requirements.json]`** gates the BOM. The profile comes from the spec or the requirements (`sourcing.profile` or `fab.target`), `fab` by default. Every electrical reference on the board needs a row with an MPN (contacts and pads the footprint does not exclude from the BOM take a `no part` row; the diff lists missing and extra references). On `fab` every row is from Mouser or Adom stock and no row is an LCSC/JLCPCB part; on `jlcpcb` every row carries its LCSC number. The stock column carries its check date (`stock_checked_2026-09-29`, or a `stock_date` column); under twice the build quantity is a thin-stock warning. Before the board it checks the BOM alone; run it again after `start --board`, because `finish` wants the diff. - **`evidence add --kind calcs|simulation|vendor-model --file <json|md> [--note ...]`** records the design record in the run and the ledger with its sha256 (a copy lands in `<run>/evidence/`). A simulation result that says it fails (a top-level `"pass": false`, or a `"checks"` row `{"name","value","limit","pass"}` that fails) is refused. Vendor PSpice/LTspice runs belong to the separately published adom/adom-spice-skillpack; `--kind vendor-model` records their result. - **`finish`** also refuses without a passed sourcing check against the board when the spec or the requirements name a sourcing profile, and without the evidence kinds the spec lists in `"requireEvidence": ["calcs", "simulation"]`.@@ -219,7 +219,7 @@ A KiCad run that starts with no board (`intake` with no `start --board`) follows 2. **`step parts`, `parts --show`**: every BOM row with an MPN is matched to its Adom wiki component page; one Hydrogen tab ("Parts ยท <run>") steps through the pages so the viewer sees the parts picked. A part with no page: make it with the wiki-component skill (datasheet symbol, footprint, STEP; verified), run `parts --show` again. Never swap in a similar part to dodge a missing page. 3. **`step project-page`, `project-page --page <owner>/<slug> --create`**: revision 1 of the board's one project page before any EDA work: a README saying what the board is, the component table (references, value, MPN, manufacturer, each part's wiki page) and a KiCad PCM repository of every part (an Adom Cloud Library of the parts, exported and repackaged for KiCad's Plugin and Content Manager, served from the page at `/api/pages/<owner>/<slug>/files/pcm/repository.json`). A new board always gets a new, standalone page (a new slug, `--create`); `project-page` refuses an existing page unless `--existing` says it is this same board's own page. Later revisions update this same page (aiflow-project-page skill); never a second page for the board. 4. **`step library`**: install the parts in KiCad on the test box: `kicad_pcm_add_repository {"url": <repositoryUrl>}`, `kicad_pcm_install {"repositoryUrl": ..., "identifier": ...}` (both printed by `project-page`), restart KiCad, then **`pcm check`** (the package is in `kicad_pcm_list`). The library is `PCM_<slug>`.-5. **`step schematic`**: draw the schematic in KiCad's schematic editor, on camera: `kicad_open_schematic`, `kicad_sch_place_symbol` (symbols from `PCM_<slug>`), `kicad_sch_wire`, `kicad_sch_label`, `kicad_sch_set_property`; save; **`schematic check --sch <project>.kicad_sch`** passes when kicad-cli ERC has no errors, every BOM reference is on the sheet and every part comes from the project library. Never write the `.kicad_sch` with a script of your own: the viewer is meant to see it drawn.+5. **`step schematic`**: draw the schematic in KiCad's schematic editor, on camera: `kicad_open_schematic`, `kicad_sch_place_symbol` (symbols from `PCM_<slug>`), `kicad_sch_wire`, `kicad_sch_label`, `kicad_sch_set_property`; save; **`schematic check --sch <project>.kicad_sch`** passes when kicad-cli ERC ran and has no errors (no ERC report is a failure, never a clean ERC), every BOM reference is on the sheet and every part comes from the project library. Never write the `.kicad_sch` with a script of your own: the viewer is meant to see it drawn. 6. Simulate (as in the Fusion lane), then make the board in KiCad (Update PCB from Schematic, the outline), save it and attach it (`start --board ... --spec ...` on the same `--run`), `step components` and on.  **The project page grows with the run.** Once `project-page` names the page, every step the run leaves is pushed as its own sub-readme, `steps/<nn>-<step>/README.md` (what the step does, each visit and why a return came back, the proof the binary recorded, the screenshots and your registered drawings, the AI usage), and the main README keeps a "How this board was built" section between `<!-- aiflow:steps -->` markers: one intro line per step linking to its page. Keep the main README for people: what the board is, the hero, the component table, then those intros; the evidence lives in the sub-readmes. Register your drawings with `artifact` so they land on their step's page. `project-page --page <owner/slug> --sync` pushes every step at once (do it at finish).@@ -270,6 +270,16 @@ Deterministic checks that refuse, each an OK/ERROR with hints:  The judgement is in these skills, which this tool executes: kicad-place-route-loop (place for routability, the levers when routing cannot close, go back to placement), kicad-copper-pours (which nets get a pour and which never do, priorities, the Kelvin pair, the preflight lessons), and the tool-neutral eda-engineering skillpack (eda-end-to-end-layout, eda-kelvin-current-sense, eda-copper-ablation, eda-pour-planning-measurement, eda-thermal-bottlenecks, eda-multilayer-current-review). +## Checks from an outside engineer's trial, skill paths in hints, clips that film something (0.2.3)++- **Schematic.** `schematic check` (and `netlist check`) also checks custom symbols' polarity drawings against their pin names, active-device pin types (no `passive` amplifier inputs, outputs or power pins) and readability (labels instead of wires, text over pins, items outside the border). `finish` requires the pass (outcome `schematic-check`). Skill aiflow-schematic-to-board.+- **Parts and datasheet numbers.** `sourcing check` wants an `mpn_url` per purchased row: the exact MPN's own page (an error with `sourcing.requireExactMpnEvidence`). `evidence add --kind calcs` warns on datasheet numbers without `kind` typ/min/max and on worst-case math resting on a typical value; `--kind simulation` warns when the requirements name a full-scale input and no case is at full scale. Skills aiflow-sourcing, aiflow-circuit-design, aiflow-simulate.+- **Courtyards and bodies are errors.** `place check`, `gate` and `finish` require a courtyard on every footprint with a pad and refuse overlapping courtyards or part bodies whatever the project's DRC severities say; `spec.bodies` gives connectors and large parts their datasheet size. `route` replaces staircases with 45 degree runs and reports vertices per net; `gate` warns about staircases left in the copper. Skill aiflow-power-layout.+- **The board that ships.** `export` and `finish` check that every copper zone of the saved board has filled polygons and that each poured copper layer's Gerber carries region (G36) data. `step-export` keeps the native STEP export's warnings apart from its exit status; geometry warnings block `board-glb copper` and `deliver` until reviewed (`step-export --ack --why`). Skill aiflow-molecule.+- **Honest messages.** `gate`, `export`, `finish` and `deliver` list what was verified (schematic parity, connectivity, ERC, DRC, symbols, courtyards, bodies, fills in the saved board and in the Gerbers) as yes, no or not known. Never tell the human the board is ready to fabricate unless every item is yes, and advise a human review even then.+- **Skill files by path.** `step <name>` names each companion skill of the step by absolute path (`~/.claude/skills/<skill>/SKILL.md` and the package's own copy under `adom_modules/adom/adom-aiflow/skills/`), and every ERROR names the skill file and section that describe the failing command's input. An engine without a skill loader opens those files instead of reading the binary. The step map is `skills` in `flows/board.json`.+- **Clips.** Pre-board steps film Hydrogen's dashboard, the AI's window or the tool's own window, or record a skip with the reason; a take that died is restarted on the step's current window with the gap recorded; a truncated MP4 (no moov atom) is flagged when it stops. Skill aiflow-live-clips.+ ## Companion skills (load the one for the step you are on)  AI Flow's binary is the instrument, the gates and the hands; the judgement for each step lives in a skill. Load it when the step begins:@@ -318,6 +328,20 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - KiCad drops pour islands that touch nothing: a heat spreader drawn across dense routing on the other layer fills as fragments and reads small in `measure`. Put the copper where the layer is actually free (the analysis numbers say when it is not). - 0.1's analyses are conservative heuristics (IPC-2221 for tracks, presence, connection style and vias for pours and tabs); they say so in their output. 0.2 computes cross-sections through the filled copper. +## Spec intent checked by the binary (0.2.3)++### Assigned-pin decoupling (`spec.decoupling`)++Declare each decoupling cap with the supply pin it serves: `"decoupling": [{"cap": "C6", "pin": "U1.39", "returnPins": ["U1.38"], "maxLengthMm": 3.0, "maxVias": 0, "severity": "warning"}]`. Only `cap` and `pin` are required; the cap's supply pad is its pad on the pin's net (`capPad` to name it) and its ground pad the pad on its other net (`gndPad`). `route` lays each pair's direct connection before any rail distribution and plane stubs, keeps it through rip-up, and reports per pair whether it routed directly and why not. After `route` and in `gate`, `decoupling-<stage>.json` measures the copper path cap pad to assigned pin along the net's real copper (length, vias, other pads it passes), the nearest same-net pin of that part and the straight pad distance, and flags a path that detours through the rail. The budgets are this board's own; there is no default. A finding is a warning or, with `"severity": "error"`, fails `route`, `gate` and `finish`. A ground return is `copper` or `fill` only by connectivity; a return joined only by planned pours or by fills that are not a native refill is labelled `unverified` or `fill-unverified`, never measured as a straight line. Decoupling budgets freeze with the other budgets at placement. DRC 0/0 does not prove this intent.++### Placement intent (`spec.placement`)++`placement.proximity: [{"ref": "D2", "nearRef": "U4", "pin": "5", "maxMm": 5}]` (pad edge to pad edge), `placement.cluster: [{"name": "buck", "refs": ["U4", "C7", "L1"], "radiusMm": 6}]` or `"region": "<name in placement.regions>" | [[x, y], ...]`, and `placement.anchor: [{"ref": "U4", "cluster": "buck"}]`. These are intent for you, the placer: the binary verifies, it does not move parts. `place check` and `place verify` fail with the distance and the pin named (`proximity D2 near U4 pin 5: 8.00 mm pad edge to pad edge (D2.1 to U4.5), over maxMm 5`), write `placement-intent.json` and record the outcome `finish` reads. A cluster radius is measured between pad-box centres, from the anchor when there is one. `place pack` honours an anchor only in the simple case: a member with no wish of its own follows the anchor's wish at its present offset (turned with the anchor when the anchor's wish names one rotation); it prints the intent verdicts on the packed spots as advice.++### Design-rule card at `start`++`start` reads the spec's track widths, clearances, via pads, drills, edge clearance, copper thickness and load currents, checks each against the fab lane's range (generic: trace 0.1 to 2 mm, drill 0.2 mm and up, copper 12 to 210 um; raised by the minimums in the `.kicad_dru` beside the board) and prints a `WARNING:` line for each value outside it. The same card, drawn to scale beside the board outline, appears on the run dashboard (`rulesCard` in `<run>/timeline/presentation.json`, also `<run>/rules-card.json`). It is read-only and changes no gate: fix a unit slip in the spec before placement.+ ## Component coverage before placement  The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.@@ -338,7 +362,7 @@ MPN marking is on by default for ICs: every IC's model carries its laser-etched  1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -401,7 +425,7 @@ Custom manual stages default to AI. `take custom-stage=ai` or `take custom-stage  A pour's area or presence is not proof of current capacity or acceptable temperature. `analyze current` and `analyze thermal` now require both the heuristic checks and a matching Adom Fields solve. Run `adom-fields analyze --board <current saved board> --spec <run spec> --out <run>/fields`, then `analyze current --fields <run>/fields/fields.json` and `analyze thermal --fields <run>/fields/fields.json`. Fields records SHA-256 identities for the exact inputs. Changed boards or specs invalidate the result; unsolved loaded nets or hot parts are not a pass. `finish` rechecks the evidence. This is an engineering screen under the solver's documented assumptions, not junction-temperature certification. -Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.+Placement checks compare individual native DRC findings with the baseline, not just their counts. Conservative courtyard bounding boxes guide packing but do not override native courtyard checks. Project `.kicad_pro` and `.kicad_dru` constraints follow board snapshots. When present, they require a native `kicad-cli` (optionally set `ADOM_AIFLOW_KICAD_CLI`); the single-file headless service must not silently discard them. In a container without native KiCad, `ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote` (installed with AI Flow) runs `pcb drc`, `sch erc`, `sch export netlist` and `pcb export gerbers|drill|pos` on a desktop's KiCad through Adom Bridge (`KICAD_REMOTE_TARGET` names the desktop; Windows or Linux, detected from the bridge; on Linux the job folder is `~/.adom-aiflow-kicad`, removed after each call; relative output paths are fine); a stock distro kicad-cli that lacks `pcb drc --refill-zones` is ignored. The gate refills zones natively when it has a native kicad-cli, so zone fills are KiCad's own, not the raster estimate, and the current and thermal analyses read the board's own copper when the routing plan leaves nets out (an adopted, already-routed board). Manufacturing-rule warnings, including hole spacing, remain blocking.  Component library footage: keep the full `library-tour` separate. A reviewed five-second `library-overview` artifact may enter compose, with `--review` binding the exact selected STEP/GLB variants, current board, clip and native readback/screenshot. See [the review contract](https://wiki.adom.inc/adom/adom-aiflow/files/docs/library-overview-review.md). Never claim native model matching from a tour alone. @@ -488,3 +512,27 @@ A USB or other edge connector may intentionally project its mechanical courtyard  ### Reviewed via counts do not add optional holes `thermal-vias plan` honors explicit via-count acknowledgements for named references, retaining reported targets and actual counts while omitting their new optional holes. Read `docs/thermal-via-acknowledgements.md`; refresh the plan after a review. Record exact-board heat-path and barrel assumptions. This does not pass current or thermal analysis or bypass native DRC or finish.++### Existing pours after board adoption++`verify-pours --plan <plan.json>` compares saved, filled zones with an explicit plan without landing them. Read `docs/existing-pours.md`. A failure names, per zone, the closest board zone and every field that differs: geometry, net, layer, priority, clearance, min thickness, pad connection, thermal gap, thermal bridge and island removal. `spec.pours` (and so the plan `pour` writes) can carry `connection` (thermal, solid or none), `thermalGap`, `thermalBridge`, `islandRemoval` (always, never or area) and `islandAreaMin`, so zones written outside the flow can be described exactly. `land pours` refuses pad connection none and island removal other than always before any bridge call, because the KiCad bridge cannot land them faithfully.++The plan needs provenance: the plan `pour` wrote (its hash is recorded in the run), or a reviewed plan recorded with `verify-pours --plan <file> --record-plan` before the board's zones changed. Any other plan, such as one written from the board after the zones changed, is refused unless `--why "<reason>"` explains why it is independent of the board; the why is stored with the proof. Never write a plan from the board to make the check pass.++`verify-pours --plan <plan> --adopt --why "<reason>"` takes ownership of the matched zones: their saved IDs join the run's owned zones (so a later `land pours` may replace them), the grant is logged in `zoneAdoptions`, and the outcome is `mode: adopted-existing`. `finish`, `status` and the summary report the pours as adopted, not landed. Native DRC, measurement and current/thermal gates remain required.++### Silk-only adoption keeps copper evidence++`adopt-board` compares the copper fingerprint of the current and adopted boards: tracks, vias, zones with settings, pads with geometry, net and drill, footprint placement, outline, stackup, nets and project rules, but not silkscreen, fab, courtyard, text or 3D models. When it is unchanged, gate, land, measure, the analyses, place verify and the Fields report are kept, with a carry record holding both board hashes and the fingerprint. Every board-hash check accepts the older board only through that record, re-verified from both snapshot files. Any copper change, an unreadable rule file or a non-KiCad board invalidates the evidence as before. The silkscreen and export proofs are always dropped; record them again.++### Holes with no copper++A drilled pad with no copper layer (an NPTH that lists only `*.Mask`, such as a USB-C shell peg) is read as a board hole, not a pad. The router, the stitching and heatsink via planners and the pour masks keep clearance from it; pad, net, BOM and netlist consumers never see it.++### Reviewed current peaks and board-face sinks++`analyze current --ack [email protected]|NET@x,y --why "..."` records a reviewed part-limited density peak (net, place, peak, reason, ai thread). It covers only that pad or 0.5 mm around the point, keeps the number reported, shows in `finish`, and expires when the copper fingerprint (the one copper carry-over uses) or the spec changes. A whole-net acknowledgement is refused. Read `docs/current-peak-acknowledgements.md`.++`spec.boardSink` declares a pedestal or plate on a board face for named tabless parts: a conservative via, mask, interface and sink series estimate in `analyze thermal`, or the same-input Fields solve with the named conducting block. Both record their assumption in the run summary and `finish`; other parts keep the still-air check. Read `docs/board-sink.md`.++`outline check` reads only Edge.Cuts: board items and footprint Edge.Cuts items placed in board coordinates.
skills/aiflow-circuit-design/SKILL.md+9
@@ -91,3 +91,12 @@ Each `note` in calcs.json says what the value does, why this one, and what would - EN UVLO: 1M over 150k, start 8.58 V, stop 6.87 V. - Losses: 0.25 W in U1 (0.15 W conduction hot, about 0.09 W switching, gate drive), 0.12 W in L1 at the maximum DCR, about 93 % efficiency estimated. These became the spec's `hot` watts. - The 3 % load-step budget in calcs.py was self-set; the vendor model later showed 3.6 %, inside the usual 5 % for a 5 V rail, so COUT stayed. Labelling it self-set is what made that an easy call.++## Input schema: evidence files++`evidence add --kind calcs|simulation|vendor-model --file <json|md>` records the file; JSON is read as follows.++- **calcs.json**: any object with a numeric `value` and a `source` naming a datasheet (or a `datasheet` field) is a datasheet number and carries `"kind": "typ" | "min" | "max"`, exactly as the datasheet table gives it. An object used for worst-case math says `"worstCase": true` (or `"use": "worst-case"`) and must not rest on a `typ` number. Example: `{"name": "Ib MCP6001 at 85 C", "value": 1.9e-11, "unit": "A", "source": "MCP6001 datasheet table 1-1", "kind": "typ"}`. A missing kind or a typical value used as a worst case is a warning in the reply and in the run record.+- **simulation results**: a top-level `"pass": true|false`, or `"checks": [{"name", "value", "limit", "pass"}]`; a failing result is refused. When the requirements name a full-scale input (any key like `fullScaleInput`, `full_scale_vpp`, a number in volts or a string such as `"12 Vpp"`), the result needs a case at that amplitude (`"fullScale": true`, or a field like `inputVpp` holding it); otherwise the reply warns (aiflow-simulate).++Typical is not maximum: an op-amp's input bias current at 85 C listed as typical cannot be quoted as a maximum. Worst-case math uses the guaranteed min or max (or a stated derating).
skills/aiflow-live-clips/SKILL.md+6
@@ -62,3 +62,9 @@ When a clip stops (`step <next>`, `capture stop`, `finish`, `deliver`, or the cl Honor the user's recording/storage target. An EDA bound to a low-disk laptop does not authorize automatic capture there. Set `AIFLOW_AUTO_CAPTURE=off` to prevent `step` from starting a new window/monitor recording or bringing a recording window forward. A running take is still settled before the next step. This setting does not redirect capture to another host: use the named remote Bridge target explicitly and retain/render raw media there. Explicit `capture start` and tours still require choosing the correct host.  Set `AIFLOW_AUTO_PUBLISH_CLIPS=off` when raw clips require editorial/narration review before wiki publication. This disables automatic raw-clip publication,not explicit reviewed publication or engineering gates. For the electronic-load workflow,use remote footage with Adom TTS,no dead air,and separate player captions;never burn captions into reusable footage. Verify the rendered wiki player and caption control. If automatic capture ignored the storage preference,stop it,mark the take rejected,withdraw its unreviewed player from the clip index,and archive it on the requested host before removing local raw copies.++## Clips before the board, and clips that die (0.2.3)++- **Steps before the board film something.** intake, rules, sourcing, design, simulation, test-plan, parts, project-page, library and schematic do not film an idle editor. Each films the tool's own window when the step works in it (KiCad's schematic editor for `schematic`), else Hydrogen with the run's dashboard (the timeline moves at every command), else the AI's own terminal or IDE window. With none of them on the test box the clip is skipped and the skip is recorded (`clipSkips` in run.json, `clip-skipped` in the ledger). Work in a visible window when you can: a script run off screen films nothing.+- **A take that dies is restarted.** The command after a take died (the window closed, the bridge restarted, the take failed) stops and pulls what it holds and starts a new take of the same step visit on the step's current window. The capture records `died` and the new one `resumedAfterGap` (from, to, why); the clips page and the deliver summary say so.+- **A truncated clip is named at its stop.** A clip whose MP4 has no moov atom (the take died before it was finalised) is flagged `truncated` and suspect when it stops: `step` and `capture stop` say so at once. Re-record the step (`step <name> --back --why "clip truncated"`) while the work is fresh, instead of finding it at `deliver`.
skills/aiflow-molecule/SKILL.md+5−1
@@ -122,7 +122,7 @@ Whenever a component page is created or its model changes (here, or through mole  1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -137,3 +137,7 @@ Read base-scaffold-system and run adom-aiflow-probing stack for the real fixture An explicit `spec.molecule.fixed` entry is the required position in millimetres from MP1, Y up. It takes precedence over that reference's original XY position, so a reviewed correction to an initially invalid scaffold span can be represented without rewriting history. MP1 remains at its original absolute position; every fixed reference retains its original rotation. References without explicit entries remain locked to their original positions. Malformed entries and missing references fail. Scaffold pitch, rectangle, pad/drill and edge-margin checks still apply.  Do not populate this map merely to silence a moved-pin error. First retain a before/after interface review, explain why the old layout fails the intended scaffold, verify the proposed coordinates against the actual scaffold and control-panel wiring, and record the evidence path and hashes with the spec change. Keep the original board history. Rerun native DRC, connectivity, model/assembly alignment, copper/current/thermal checks and affected probing tests; coordinate conformance is not physical assembly qualification.++## The STEP export's own warnings (0.2.3)++`adom-aiflow step-export` runs the native `kicad-cli pcb export step` with every layer (models, tracks, pads, zones, mask, silkscreen) and keeps its output, classified apart from the exit status: geometry warnings (`Wire self-interference check failed` with its location and the nearest footprints), model warnings and others. An export that exits 0 with geometry warnings is not a complete twin: `board-glb copper` in a run and `deliver` refuse until you review the STEP at those spots and record `step-export --ack --why "..."` (bound to that STEP file's hash). Never drop silkscreen, mask or zones to make a warning go away. `--log <saved output> --out <the STEP>` classifies an export run elsewhere.
skills/aiflow-power-layout/SKILL.md+18
@@ -18,6 +18,9 @@ Write `aiflow/spec.json` from the schematic before `start`, with a note on every - `hot`: watts per part from calcs.json (aiflow-circuit-design) with the arithmetic in `hotNote`, and `tabNet` (the pin the heat leaves through; a SOT-23 has no pad, so GND and VIN pins are the heatsink). - `pours`: GND on both layers from the outline; VIN and VOUT as `around` pours on F.Cu; SW as an explicit small `polygon`; `notPoured` for BOOT, FB and dividers. - `solidAt` for the IC GND pin and the inductor pads; thermal and stitch vias.+- `decoupling` for each local cap and the supply pin it serves (`{"cap": "C2", "pin": "U1.2", "returnPins": ["U1.1"], "maxLengthMm": 3}`): `route` lays that connection before the rail and `gate` reports its copper path against the budget (adom-aiflow SKILL, Spec intent checked by the binary).+- `placement.proximity` / `placement.cluster` for the hot loop: the input cap within a few mm of VIN and GND, the hot-loop parts in one cluster. `place check` and `place verify` fail with the distance and pin named.+- Read the `start` WARNING lines and the design-rule card on the dashboard before placement: a 0.025 that should be 0.25 shows there.  ## 2. Placement: the hot loop first, chains in one rotation @@ -88,3 +91,18 @@ Returns in the ledger tell the story: routing (VOUT sense slicing the plane), po ## Local thermal-via planning  Thermal-via candidates and existing-via counts must be within 3 mm of the specified heat pads and fit completely inside a planned same-net copper region on each outer layer. A remote same-net region does not satisfy this requirement. Missing polygon coordinates use the board outline; malformed or missing geometry refuses coverage. This is a conservative geometric prerequisite, not proof of native filled connectivity or thermal capacity: preflight with native refill/DRC and verify the actual heat path before landing. A TO-220 case-mounted heatsink requires an explicit package/interface/sink/airflow analysis; adding board vias cannot replace that path. Shortfalls remain visible and do not justify changing temperature limits or acknowledging unverified cooling.++## Input schemas++- `place pack --wish wishes.json`: an array `[{"ref", "x", "y", "rot": [0, 90], "radius": 3.0, "gap": 0.2, "keepout": [[x0, y0, x1, y1, margin]]}]` packed in order, or an object `{"U1": {...}}`. `place check|land --moves moves.json`: `{"U1": {"x", "y", "rotation"}}`.+- `gate --plan`, `land route --plan`, `analyze current|thermal --plan`: the route plan, `docs/plan-schema.md` in the package (`{"nets": [{"net", "width", "viaSize", "viaDrill", "paths": [["U1.3", [x, y], {"x", "y", "layer"}, {"pad": "J1.1"}]]}]}`). Without `--plan` they read the plan `route` recorded.+- `analyze current|thermal` read the run's current board (`<run>/board-N.kicad_pcb`, the one `status` names; on the Fusion lane a fresh snapshot of the live board, `<run>/board-live.kicad_pcb`), plus the board's own tracks and vias for every net the plan does not carry, the spec's `loads`, `hot`, `wideNets` and rise limits, and with `--fields` (default `<run>/fields/fields.json`) the Adom Fields report of a solve aiflow watched.+- `verify-pours --plan <reviewed-plan.json>`: the zone plan `pour` writes (`{"zones": [{"net", "layers", "polygon" | "polygons", "priority", "clearance", "minThickness", "connection", "thermal", "fill", "keepout"}]}`; unknown keys are refused), matched one to one with the saved board's zones; `docs/existing-pours.md`.++## Courtyards, bodies and staircases (0.2.3)++- `place check`, `gate` and `finish` require a closed courtyard (F.CrtYd or B.CrtYd) on every footprint with a pad, and fail when two courtyards on one side overlap, whatever severity the project gives `courtyards_overlap` or `missing_courtyard` (the reply says when the project set them to ignore). Only pad-less footprints (logos, board art) are exempt.+- Part bodies (the F.Fab/B.Fab outline, else the 3D model's box) must not intersect: two 5 mm capacitors 4 mm apart fail by 1 mm even with no courtyards.+- Bodies against the datasheet: give connectors and large parts their package size in the spec, `"bodies": {"J1": {"lengthMm": 9.8, "widthMm": 7.6, "tolMm": 0.3, "source": "datasheet page"}}`; a drawn body off by more than the tolerance is an error. Parts with no entry are listed as not verified.+- `route` simplifies its own plan: collinear points merge, and a staircase (3 or more alternating 0/90 degree steps, each no longer than 2 x the track width) becomes one 45 degree segment plus at most one leg where the router's clearance check allows it; vertices per net before and after are in `plan-route-report.json` (`simplify`). `gate` warns about staircases left in a plan you wrote yourself: draw straight runs joined by 45 degree corners, simplify a grid path before writing it, and look at the rendered copper once.+- The gate reply lists what it checked (DRC, connectivity, courtyards, bodies); it is not a fabrication check.
skills/aiflow-project-page/SKILL.md+4
@@ -5,6 +5,10 @@ user-invocable: true --- # Deliver the project page +## Revision 1: `project-page`++`adom-aiflow project-page --page <owner/slug> --create [--brief <text or file>]` makes one Cloud Library per board and keeps its `lib_` id in the run; a rerun reuses it and never makes a second library of the same name (when duplicates already exist it uses the fullest and says which to delete). Rate-limited adds (HTTP 429, `retry_after`) are retried with backoff (`ADOM_AIFLOW_WIKI_ATTEMPTS`, `ADOM_AIFLOW_WIKI_MAX_WAIT`). The step is an ERROR, and nothing is published, when the exported library is short of any BOM part (symbols, footprints and models counted against the distinct parts) or when the packaged symbol library does not load: the header is written as version 20241209 (KiCad 9 format, read by KiCad 10) and the file is parse-checked, with `kicad-cli sym upgrade` through `ADOM_AIFLOW_KICAD_CLI` when available. `--brief` takes text or a path to a file whose contents are used. Parts from the board's project library (`parts --project-lib`) are packaged beside the wiki parts.+ A board project must showcase its board assembly, not one of its component assets. Never rely on recursive repository asset discovery: embedded component GLBs, STEP files and PDFs can silently win.  1. Read the live page and current immutable source HEAD before editing; preserve concurrent work. Confirm which README variant renders. Read wiki-hero-glb and the wiki publishing contract.
skills/aiflow-schematic-to-board/SKILL.md+11−1
@@ -68,7 +68,7 @@ It creates the nets, loads each footprint (`pcbnew.FootprintLoad(dir, name)`, pr ### KiCad 10 traps  - `GetFieldByName` is gone. `fp.SetField(name, value)` to set; iterate `fp.GetFields()` and match `f.GetName()` to hide or restyle.-- Model paths changed through `fp.Models()` (`m.m_Filename = ...`) did not persist in the saved board. Rewrite the `(model ...)` entries in the saved file instead (`kicad/tools/set_models.py`: path, offset, and the -90 degree X rotation for Y-up models).+- Model paths changed through `fp.Models()` (`m.m_Filename = ...`) did not persist in the saved board. Rewrite the `(model ...)` entries in the saved file instead (`kicad/tools/set_models.py`: path, offset, and the -90 degree X rotation for Y-up models). Do this only in the build, before the board is opened in KiCad: KiCad Bridge has no model-binding verb yet (kicad-bridge#101), and a saved-file rewrite is not a live edit. Prove the result with `kicad_model_check` (its `modelBindings`) and a 3D frame; a body hidden by a viewer class is a `kicad_3d_viewer_settings` matter, never a reason to change BOM, position-file or DNP flags. - The netclass API moves between versions: wrap it in try/except and let the `.kicad_dru` carry the limits. - **One IPC socket.** KiCad's IPC API server belongs to the first KiCad process that opened it. A second editor (another board, another user's session) answers IPC for the wrong board or not at all; `capture open` reports `board_mismatch`. Save and close only your own conflicting editors, reopen the intended board first, and never close someone else's work. - **Footprint fields are refused over IPC** in 10.0.5 (`native_field_update_unsupported`): references, values and their text size or position cannot be edited live. Do them in build_board.py, or offline: close the clean editor, edit the saved board, reopen, then refresh both 2D and 3D views.@@ -128,3 +128,13 @@ Then `step components` / `components`, `models`, and placement. - The first saved ERC report held 21 library-configuration warnings because the lib tables were written 22 seconds after it. Tables first, then ERC. - `set_models.py` rewrote the model entries in the saved board, including -90 X for the three Y-up models. - A distro KiCad 7 kicad-cli, installed mid-run by another session, broke the gate with a usage error; the remote kicad-cli fixed it.++## Schematic check: symbols, pin types, readability++`schematic check --sch <root>.kicad_sch` (and `netlist check`, which runs the same part) reads the root sheet and every child sheet with the symbols they embed, beyond ERC:++1. **Polarity drawings agree with the pin names.** A diode's cathode bar and triangle point at the pin named K, a transistor's emitter arrow sits on the E lead, an op-amp's drawn `+`/`-` sit at the inputs named that way. A custom symbol that contradicts its pins is an error (a standard KiCad library symbol only warns). A drawing you reviewed against the datasheet goes in the spec: `"schematic": {"polarityReviewed": [{"symbol": "<lib id or reference>", "why": "..."}]}`.+2. **Pin electrical types are real.** Amplifier inputs are `input`, outputs `output`, power pins `power_in`; `passive` on an op-amp or comparator input, output or power pin is an error (a warning on other ICs). When the datasheet truly says otherwise: `"schematic": {"allowPassive": [{"ref": "U1", "pin": "3", "why": "datasheet ..."}]}`. Prefer library symbols (one triangle per op-amp section, a conventional transistor) over custom rectangles.+3. **Readable sheets** (warnings): many local labels and almost no wires, the same local label used for a short hop inside one block, free text over pins or symbol bodies, and parts or text outside the drawing border. Draw the signal path with wires inside each functional block; keep labels for long or cross-sheet nets, so feedback loops can be traced by eye.++The result is `<run>/schematic-check.json` and the run outcome `schematic-check`, bound to every sheet's hash; `finish` requires a pass on the hierarchy as it is then. Limits: heuristic geometry (polylines, rectangles, circles, arcs), one diode per unit with pins named K/A, transistors with pins named E/C, op-amp inputs named `+`/`-`, `IN+`/`IN-`, `INP`/`INN`.
skills/aiflow-silkscreen/SKILL.md+13−1
@@ -26,7 +26,7 @@ Record replay exports a WebM of the 2D canvas with a persistent REPLAY label and  ## Acceptance -Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list. On the KiCad lane `silkscreen-audit --record` records the step proof only from a passing audit whose inventory names every footprint by its reference (`R1`, `R1:body`) and has a via or hole item for every via; `fusion silkscreen` records it on the Fusion lane.+Audit the whole board on both faces: fitted body projections including the area between pads, every via/hole, actual mask openings, labels and leader paths. Missing Fab geometry is not empty space. Keep each label associated with its own component. Unresolved labels remain visible and block acceptance. The independent inventory in `silkscreen-audit` must be derived from the whole board, not from the same potentially incomplete obstacle list. On the KiCad lane `silkscreen-audit --record` records the step proof only from a passing audit whose inventory names every footprint by its reference (`R1`, `R1:body`) and has a via or hole item for every via; `fusion silkscreen` records it on the Fusion lane. Label (mark) boxes are checked against the board's own silk text the same way: a mark is matched to its text (`text`, else the text anchored inside its box, else the reference or value of the footprint its id names) and a box smaller than that text's extent (KiCad stroke font, about 0.6 x the glyph width per character plus the stroke, from the board's size, length and rotation) is refused at `--record` and again at `finish`. Supply the rendered text bounds, never a placeholder square.  ## Automated recording @@ -129,3 +129,15 @@ Read the current `silkscreenPolicy` from `adom/pcb-design-rules`, file `rules/re Mechanical documentation belongs on the correct face: bracket boundaries on its mounting face; screw thread/length and head/washer envelopes on the bearing face; screwdriver clearance on the access face. Keep physical copper/component exclusion zones separate from printed outlines. Preserve reference/value association when rotating crowded groups. Record labels that require removing a heatsink or bracket to read them.  When using native SVG as a 3D silkscreen texture, export with `--drill-shape-opt 0`. Filled drill plot markers are not silk and must never become white discs covering the actual holes. Verify both alpha at hole centers and the rendered view with silk enabled. A successful model reload is not enough; stale texture caches may require refreshing the existing page.++## Input schemas++Every silkscreen command reads a JSON file you write; lengths are mm in board coordinates, boxes are `[x0, y0, x1, y1]`.++- `silkscreen-audit --input audit.json --out result.json [--record]`: `{"obstacles": [{"id", "kind", "layer", "box" | "boxes": [[...]]}], "inventory": [{"id", "kind", "layers": ["F.SilkS", ...]}], "marks": [{"id", "layer", "box" | "boxes"}]}`. The inventory is independent of the obstacles and covers the whole board: every footprint by its reference (`R1`, `R1:body`, `R1:pad:1`) and a via or hole item per via. Every inventory item needs an obstacle with the same `id`, `kind` and `layer`; every mark is checked against every obstacle on its layer. `--record` also checks the boxes against the saved board (each pad inside one of its footprint's boxes, each via inside a via box as large as the via). Source: `tools/silkscreen-obstacle-check.py` in the package.+- `silkscreen-layout --input candidates.json --out layout.json [--events ...]`: `{"bounds": [x0, y0, x1, y1], "obstacles": [{"box"}], "clearance": 0.12, "labels": [{"id", "anchor": [x, y], "maxDistance", "orderGroup", "order", "requiredRoles", "candidates": [{"position": [x, y], "box", "boxes", "font", "children": [{"role", "size"}], "qualityPenalty"}]}]}`. Source: `tools/silkscreen-layout.py`.+- `silkscreen-sections`, `silkscreen-preflight` (`{"placements": [{"index", "box", "polygon"}], "obstacles": [...], "requiredIndices": [...]}`), `silkscreen-contact-policy` (`--geometry` carries the board `outline`) and `silkscreen-mask --board <file.kicad_pcb>`: see the scripts of the same names under `tools/` in the package for every key.++## References and polarity on the silkscreen (0.2.3)++`finish` warns when a reference lives only on F.Fab/B.Fab (none on the silkscreen), and when a polarized part (a diode, LED, polarized capacitor, battery or IC) has no silkscreen polarity or pin-1 mark (a cathode bar, a `+`, a pin-1 dot or triangle beside pad 1). Put readable references on F.SilkS or B.SilkS, mark polarity and pin 1, and label connectors by function. The warnings are in the finish summary (`silkscreenWarnings`).
skills/aiflow-simulate/SKILL.md+4
@@ -83,3 +83,7 @@ Then write the design conclusion in engineering terms: what matched, what the ve - Eq 16 gave 99.4 pF, and 100 pF had the best phase margin in the model, but it pushed crossover to about 57 kHz, past the datasheet's 40 kHz limit that the equivalent cannot see. 68 pF was chosen: about 29 kHz, about 110 degrees at 12 V, 1 A. - The switching model: output ripple 3.2 mV at 12 V, 4.0 mV at 16 V; 95 % of VOUT at 4.76 ms (5 ms soft start). - TI's model confirmed startup, ripple and inductor current and showed about 15 % more load-step deviation. Conclusion: with internal compensation fo x COUT is constant, so the step deviation (about 180 mV, 3.6 %, for a full 1 A step) does not shrink with more COUT; only a larger Cff would help, and the 40 kHz limit rules that out. COUT unchanged; confirm with a 0 to 1 A step on the workcell.++## Simulate at full scale too (0.2.3)++A design that meets its spec at 100 mVpp can fail at the full-scale input (an attenuator measured 10 dB at 100 mVpp and 0.83 dB at 12 Vpp). When the requirements name a full-scale input, simulate a case at that amplitude, record it with `"fullScale": true` or the amplitude in the case (`"inputVpp": 12`), and say whether the topology only works small-signal; `evidence add --kind simulation` warns when no case is at full scale. A topology that scales the signal down before a nonlinear stage and restores the gain afterwards is the usual fix.
skills/aiflow-sourcing/SKILL.md+27−1
@@ -115,7 +115,7 @@ Whenever a component page is created or its model changes (here, or through mole  1. **Let the planner place it.** `adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step --footprint part.kicad_mod` (`adom-aiflow-etch plan part.step --footprint ... --mpn ...` shows the plan JSON first). Never hand-pick an axis, a rotation or an `/outline` rot. 2. **The rule it enforces.** The mark runs along the longest usable dimension of the marking face: chip R/C/L along the body, in the band between the metal end caps; ICs parallel to the pin rows (a SOT-23-6 along its 2.9 mm length; two pin rows are read from the footprint pads, and a pin-1 dimple only shortens the band, it never turns the mark across the rows); LEDs on the front long side wall, window clear. As large as fits the band minus 12 % / 15 % margins, one line, two equal lines only when both are larger. Upright and left to right in the standard pose: pin 1 / pad 1 lower left seen from the top (two-terminal: pad 1 on the left; polarised parts follow pin 1 / cathode); the iso outline shows pin 1 (pins 1 to 3) toward the viewer. A flat light mark, coplanar with the face, in `Adom.Chip` / `Adom.LaserEtch`; never raised or recessed lettering. The top face is found by position (the flat face at the top of the part), so models that store it with a reversed normal, like KiCad's stock SOIC and TSSOP, work as they are.-3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place.+3. **The outline's name is the same mark.** `adom-aiflow-etch outline part.step --etched part-etched.step --footprint part.kicad_mod` (adom-chip-thumbnailer does the same): same face, same direction, same place, same line split. The outline reads the split from the etch's own `<etched>.mark-plan.json` (so a forced `--lines "A|B"` carries over); pass `--lines` or `--plan` when that file is elsewhere. `check --outline` FAILs an outline whose text lines differ from the mark. 4. **Run the gate and look before you report.** `adom-aiflow-etch check part-etched.step --outline <named.svg>` must say PASS (it runs after every etch too). Open `<etched>.check.png` (top view in the standard pose, band in green, reading arrow) and the outline at 320 px. A FAIL's `Hint:` lines say exactly what to rerun. The verdict is judged in the normalized pose (footprint frame after the stand-up turns), not the mounted pose on a board; `<etched>.check.json` `frame` records the turns from the source and to the outputs, and whether model up (`--up`) and the face (`--face`) were supplied or inferred. 5. **Overrides are exceptions.** `--along x|y` only keeps a polarity stripe or printed band clear; the tool warns when it puts text across the short side and the gate still judges against the planner (text across an IC's pin rows always FAILs). A vendor model that already prints its marking stays as it is (the tool refuses unless `--over-vendor-mark`). Everything runs on the shared STEP service with plain python3: nothing to install (`--legacy` is the old OpenCascade path). @@ -137,6 +137,16 @@ After Pup denial, read adom-native-browser and check `nbrowser_readiness` then `  Verified example, 2026-10-08: Pup showed Mouser Access Denied for MF40200V1-1000U-G99. Native Chrome reached a normal missing-page response for the same incomplete URL. Mouser's normal exact-MPN search then opened the actual product page and its manufacturer-hosted 3D ZIP. The recovered model is a separate provenance/dimensional check, not proof of a qualified cooling design. Record short clips of the observed states when the user requested a process video; label a later walkthrough as such rather than presenting it as the original discovery. +## Parts with no public component page yet++When a part's page must stay private, waits for an owner's sign-off, or the footprint is a reviewed project-only one, give it from the board's project library: `adom-aiflow parts --project-lib <dir>` (or `"projectLib": "<dir>"` in the spec or requirements). The folder holds the KiCad symbols (`*.kicad_sym`), footprints (`*.pretty/*.kicad_mod`), STEP models and a `provenance.json`:++```json+{"parts": [{"mpn": "ABC-123", "source": "datasheet rev C, fig. 12 (AI-created footprint)", "symbol": "ABC-123", "footprint": "QFN-16_3x3", "model": "3dmodels/ABC-123.step"}]}+```++`symbol` defaults to the MPN, `footprint` to the symbol's Footprint property, `model` to the footprint's model file. `parts` checks every listed MPN has all four (symbol, footprint, STEP, source) and still prefers a wiki page when one exists. `project-page` packages these parts into the board's PCM library beside the wiki parts, and `finish` lists them (and refuses if their files went missing). Publish the pages when they can be public; the project library is the evidence until then.+ ## Required component pages include off-board hardware  Before calling sourcing complete, reconcile the full assembly BOM (not only PCB references) against exact component wiki pages. Fans, heatsinks, cables, sensors and connectors count. Duplicate-check first and improve the exact existing page; create a missing reusable part page. Link every selected MPN in the assembly register. Bare copper, unidentified placeholders and project-only assemblies remain in the project rather than becoming false catalog parts.@@ -150,3 +160,19 @@ Publish permitted assets with portable paths, an asset manifest and source/deriv For a part whose appearance or motion changes in operation, create/reuse `states.json` and set `component.states` on its wiki page. Read wiki-component and component-widgets: LEDs use off/lit models; fans use off/on plus a typed RPM variable and the proposed `adom/component-motion@1` GLB binding. Name stationary and moving scene branches, animation clips, axes/pivots, cycle duration and revolutions per cycle; preserve static STEP and fallback GLB. One revolution/second maps to RPM/60 playback rate, with any slow-motion preview separately labeled.  Check actual GLB binding and playback before marking motion ready. Keep simulated and measured sources distinct; stale readings are UNKNOWN. Write a part-specific skill when necessary, and pass states.json, GLB hashes, instance transform and required capabilities to adom-aiflow-probing. An unsupported consumer must report that limitation rather than silently portraying a stopped fan as operating. See the [fan motion example](https://wiki.adom.inc/adom/mf40200v1-1000u-g99/files/docs/component-motion.md). This is a proposed extension until each owning app releases support.++## Input schema: the BOM CSV++`sourcing check --bom <csv>` (and `parts --bom`) read a CSV with a header row. Column names are matched without case, with spaces, dashes and `#` read as `_`:++- references: `ref`, `refs`, `reference`, `designator(s)`, `refdes` (several references in one cell, space or comma separated);+- the part: `mpn`, `mfr_part`, `mfr_pn`, `manufacturer_part_number`, `part_number`; `no part` for a contact or test pad the footprint does not exclude;+- where it comes from: `source` (`Mouser`, `Adom stock`, `LCSC`...), `vendor_pn` / `mouser_pn`, `lcsc` on the jlcpcb profile;+- the stock check: `stock_checked_<YYYY-MM-DD>` (the count on that date), or `stock` with `stock_date`;+- the exact part (0.2.3): `mpn_url` (also `exact_mpn_url`, `product_url`, `part_url`, `evidence_url`): the manufacturer or distributor page for that exact MPN (value, tolerance, dielectric, voltage, package).++### Prove the exact part exists (0.2.3)++A part number decoded from a family's part-number grammar is not proof the part is made: the family table may not list that combination. Cite the exact MPN's page in `mpn_url` for every purchased row. A missing page, a PDF (usually the family datasheet) or a URL that does not name the MPN is a warning; with `"sourcing": {"requireExactMpnEvidence": true}` in the spec or requirements a missing page fails the check.++When a part is unavailable, re-derive the circuit around what is stocked instead of freezing a value: an RC pair of 15.8 kOhm and 10 nF meets the same corner as 1.58 kOhm and 100 nF. Keep the spec, not the first values.
tests/project-page-library.pyadded+211
@@ -0,0 +1,211 @@+"""project-page's Cloud Library and PCM package against a fake adom-wiki (and a fake kicad-cli) on PATH:+HTTP 429 retries, one library per board (recorded lib_ id, no duplicates), an incomplete export refused,+the symbol header normalised and parse-checked, project-library parts packaged (issues #64, #66).++  python3 tests/project-page-library.py+"""+import json, os, subprocess, sys, tempfile, textwrap, unittest, zipfile+from pathlib import Path++ROOT = Path(__file__).resolve().parents[1]+SCRIPT = ROOT / "tools" / "project-page.py"++FAKE_WIKI = r'''#!/usr/bin/env python3+import json, os, sys, zipfile+st_path = os.environ["FAKE_WIKI_STATE"]+st = json.load(open(st_path))+a = [x for x in sys.argv[1:] if x != "--json"]+st.setdefault("calls", []).append(a)+def save(): json.dump(st, open(st_path, "w"))+def out(v, rc=0):+    save(); print(json.dumps(v)); sys.exit(rc)+if a[:2] == ["library", "list"]:+    libs = [{k: v for k, v in l.items() if k != "items"} for l in st["libraries"]]+    out({"status": "ok", "data": {"status": "ok", "data": {"libraries": libs}}})+if a[:2] == ["library", "create"]:+    ref = "lib_%03d" % (len(st["libraries"]) + 1)+    st["libraries"].append({"ref": ref, "name": a[2], "item_count": 0, "active": True, "created_at": "2026-10-18 10:00:0%d" % len(st["libraries"]), "items": []})+    out({"status": "ok", "data": {"ref": ref, "name": a[2]}})+if a[:2] == ["library", "activate"]:+    out({"status": "ok"})+lib = lambda ref: next(l for l in st["libraries"] if l["ref"] == ref)+if a[:2] == ["library", "add"]:+    if st.get("add429", 0) > 0:+        st["add429"] -= 1+        save()+        print(json.dumps({"status": "error", "error": {"code": "RATE_LIMITED", "http": 429, "message": "Too many requests", "retry_after": 0}}))+        sys.exit(1)+    l = lib(a[2])+    for p in a[3:]:+        if p not in l["items"]: l["items"].append(p)+    l["item_count"] = len(l["items"])+    out({"status": "ok", "data": {"added": a[3:]}})+if a[:2] == ["library", "export"]:+    l = lib(a[2]); dest = a[a.index("-o") + 1]+    name = l["name"].replace("/", "_")+    parts = [p for p in l["items"] if p not in st.get("exportDrop", [])]+    syms, files = [], {}+    for i, p in enumerate(parts):+        mpn = st["pages"][p]+        # one KiCad 9 style symbol (hide yes, unquoted pin_numbers) under the export's old header+        syms.append('  (symbol "%s" (pin_numbers (hide yes)) (property "Reference" "U") (property "Footprint" "%s:%s") (property "Value" "%s" (effects (hide yes))))' % (mpn, name, mpn, mpn))+        files["%s/%s.pretty/%s.kicad_mod" % (name, name, mpn)] = '(footprint "%s" (layer "F.Cu") (model "3dmodels/%s.step"))\n' % (mpn, mpn)+        files["%s/3dmodels/%s.step" % (name, mpn)] = "ISO-10303-21;\nEND-ISO-10303-21;\n"+    files["%s/%s.kicad_sym" % (name, name)] = "(kicad_symbol_lib (version 20211014) (generator adom_wiki_cloud_library)\n" + "\n".join(syms) + "\n)\n"+    with zipfile.ZipFile(dest, "w") as z:+        for k, v in files.items(): z.writestr(k, v)+    out({"status": "ok", "data": {"report": {"status": "ok", "data": {"included": len(parts), "skipped": [], "parts": [{"ref": "pg_%d" % i, "name": st["pages"][p], "mpn": st["pages"][p]} for i, p in enumerate(parts)]}}}})+out({"status": "error", "error": "fake adom-wiki: unknown " + " ".join(a)}, 2)+'''++# KiCad 10 as issue #64 found it: a library whose header predates its symbols' syntax does not load+FAKE_KICAD = r'''#!/usr/bin/env python3+import re, sys+src = sys.argv[-1]; dst = sys.argv[sys.argv.index("-o") + 1]+t = open(src).read()+v = int(re.search(r"\(version\s+(\d+)\)", t).group(1))+if v < 20241209 and "(hide yes)" in t:+    print("Unable to load library " + src); sys.exit(1)+open(dst, "w").write(t); print("Saving symbol library in updated format")+'''++PAGES = {"adom/u1-page": "TPS54202DDCR", "adom/l1-page": "AMPLH5030S-150MT", "adom/c1-page": "CL21A226MPQNNNE"}+++class ProjectPageLibrary(unittest.TestCase):+    def setUp(self):+        self.tmp = tempfile.TemporaryDirectory(); self.d = Path(self.tmp.name)+        b = self.d / "bin"; b.mkdir()+        (b / "adom-wiki").write_text(FAKE_WIKI); (b / "adom-wiki").chmod(0o755)+        (b / "kicad-cli").write_text(FAKE_KICAD); (b / "kicad-cli").chmod(0o755)+        self.state = self.d / "state.json"+        self.write_state({"libraries": [], "pages": PAGES})+        self.run_dir = self.d / "run"; self.run_dir.mkdir()+        self.env = dict(os.environ, PATH=f"{b}:{os.environ['PATH']}", FAKE_WIKI_STATE=str(self.state), ADOM_AIFLOW_KICAD_CLI=str(b / "kicad-cli"),+                        ADOM_AIFLOW_WIKI_BACKOFF="0", ADOM_AIFLOW_WIKI_ATTEMPTS="4")++    def tearDown(self):+        self.tmp.cleanup()++    def write_state(self, v):+        self.state.write_text(json.dumps(v))++    def read_state(self):+        return json.loads(self.state.read_text())++    def parts(self, pages=PAGES):+        return [{"refs": [f"X{i}"], "mpn": mpn, "manufacturer": "", "value": "", "wiki": p} for i, (p, mpn) in enumerate(pages.items())]++    def run_page(self, parts=None, **cfg):+        c = {"run": str(self.run_dir), "page": "adom/test-board", "title": "Test board", "brief": "A test board.", "summary": "",+             "parts": self.parts() if parts is None else parts, "create": True, "push": False, "version": "1.0.0"}+        c.update(cfg)+        p = self.d / "config.json"; p.write_text(json.dumps(c))+        r = subprocess.run([sys.executable, str(SCRIPT), str(p)], capture_output=True, text=True, env=self.env, stdin=subprocess.DEVNULL)+        try:+            return json.loads(r.stdout or r.stderr)+        except Exception:+            raise AssertionError(f"no JSON: {r.stdout!r} {r.stderr!r}")++    def creates(self):+        return [c for c in self.read_state()["calls"] if c[:2] == ["library", "create"]]++    def test_rate_limited_adds_are_retried(self):+        s = self.read_state(); s["add429"] = 2; self.write_state(s)+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        self.assertEqual(len(v["adds"]["rateLimitWaits"]), 2)+        self.assertEqual(v["counts"], {"distinctParts": 3, "symbols": 3, "footprints": 3, "models": 3})+        self.assertEqual(self.read_state()["libraries"][0]["item_count"], 3)++    def test_persistent_rate_limit_is_an_error(self):+        s = self.read_state(); s["add429"] = 100; self.write_state(s)+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("could not be added", v["error"])+        self.assertIn("adom/u1-page", v["error"])+        self.assertTrue(v["cloudLibrary"].startswith("lib_"))++    def test_incomplete_export_is_an_error(self):+        s = self.read_state(); s["exportDrop"] = ["adom/l1-page"]; self.write_state(s)+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("AMPLH5030S-150MT", v["error"])+        self.assertIn("2 symbols for 3 distinct parts", v["error"])++    def test_second_run_reuses_the_recorded_library(self):+        self.assertTrue(self.run_page()["ok"])+        rec = json.loads((self.run_dir / "project-page" / "cloud-library.json").read_text())+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["cloudLibrary"], rec["ref"])+        self.assertEqual(len(self.creates()), 1)+        self.assertEqual(len(self.read_state()["libraries"]), 1)+        # the run's own libRef wins even when the work folder is gone+        (self.run_dir / "project-page" / "cloud-library.json").unlink()+        v = self.run_page(libRef=rec["ref"])+        self.assertEqual(v["cloudLibrary"], rec["ref"]); self.assertEqual(len(self.creates()), 1)++    def test_duplicate_libraries_are_never_added_to(self):+        libs = [{"ref": "lib_a", "name": "adom/test-board", "item_count": 0, "active": False, "created_at": "2026-10-17 01:00:00", "items": []},+                {"ref": "lib_b", "name": "adom/test-board", "item_count": 2, "active": False, "created_at": "2026-10-17 02:00:00", "items": ["adom/u1-page", "adom/l1-page"]}]+        self.write_state({"libraries": libs, "pages": PAGES})+        v = self.run_page()+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["cloudLibrary"], "lib_b")+        self.assertEqual(self.creates(), [])+        self.assertTrue(any("2 libraries are named" in n for n in v["adds"]["notes"]))++    def test_header_is_normalised_and_kicad_loads_it(self):+        v = self.run_page()+        self.assertEqual(v["libraryCheck"]["symVersion"], 20241209)+        self.assertEqual(v["libraryCheck"]["kicadCli"], "pass")+        with zipfile.ZipFile(v["zip"]) as z:+            sym = z.read("symbols/test-board.kicad_sym").decode()+        self.assertTrue(sym.startswith("(kicad_symbol_lib (version 20241209)"))+        self.assertIn('"Footprint" "PCM_test-board:TPS54202DDCR"', sym)++    def test_a_library_kicad_cannot_load_is_not_published(self):+        (self.d / "bin" / "kicad-cli").write_text("#!/bin/sh\necho 'Unable to load library'\nexit 1\n")+        v = self.run_page()+        self.assertFalse(v["ok"])+        self.assertIn("KiCad cannot load", v["error"])++    def project_lib(self):+        d = self.d / "plib"; (d / "lib.pretty").mkdir(parents=True); (d / "3dmodels").mkdir()+        (d / "lib.kicad_sym").write_text('(kicad_symbol_lib (version 20241209) (generator "kicad_symbol_editor")\n  (symbol "ABC-123" (property "Reference" "U") (property "Footprint" "lib:QFN-16"))\n)\n')+        (d / "lib.pretty" / "QFN-16.kicad_mod").write_text('(footprint "QFN-16" (layer "F.Cu") (model "${KIPRJMOD}/3dmodels/ABC-123.step"))\n')+        (d / "3dmodels" / "ABC-123.step").write_text("ISO-10303-21;\nEND-ISO-10303-21;\n")+        return {"refs": ["U9"], "mpn": "ABC-123", "manufacturer": "", "value": "", "wiki": None, "source": "project-lib",+                "projectLib": {"dir": str(d), "symbol": "ABC-123", "symbolFile": str(d / "lib.kicad_sym"), "footprint": "QFN-16",+                               "footprintFile": str(d / "lib.pretty" / "QFN-16.kicad_mod"), "model": str(d / "3dmodels" / "ABC-123.step"), "source": "datasheet"}}++    def test_project_library_parts_are_packaged(self):+        v = self.run_page(parts=self.parts() + [self.project_lib()])+        self.assertTrue(v["ok"], v)+        self.assertEqual(v["projectLibParts"], ["ABC-123"])+        self.assertEqual(v["counts"]["distinctParts"], 4)+        with zipfile.ZipFile(v["zip"]) as z:+            names = z.namelist()+            sym = z.read("symbols/test-board.kicad_sym").decode()+            fp = z.read("footprints/test-board.pretty/QFN-16.kicad_mod").decode()+        self.assertIn("3dmodels/test-board.3dshapes/ABC-123.step", names)+        self.assertIn('(property "Footprint" "PCM_test-board:QFN-16")', sym)+        self.assertIn("/test-board.3dshapes/ABC-123.step", fp)+        readme = (self.run_dir / "project-page" / "README.md").read_text()+        self.assertIn("project library (no public page yet)", readme)++    def test_only_project_library_parts_need_no_cloud_library(self):+        v = self.run_page(parts=[self.project_lib()])+        self.assertTrue(v["ok"], v)+        self.assertIsNone(v["cloudLibrary"])+        self.assertEqual(self.read_state().get("calls", []), [])++    def test_brief_short_description_skips_the_heading(self):+        v = self.run_page(brief="# Test board\n\nA 12 V to 5 V buck.\n")+        meta = json.loads(zipfile.ZipFile(v["zip"]).read("metadata.json"))+        self.assertEqual(meta["description"], "A 12 V to 5 V buck.")+++if __name__ == "__main__":+    unittest.main()
tests/rules-card.mjsadded+31
@@ -0,0 +1,31 @@+// Render the dashboard's design-rule card (presentation.json rulesCard) with stub elements: no browser, no dashboard.+import fs from 'node:fs';+import vm from 'node:vm';+import assert from 'node:assert/strict';+const html=fs.readFileSync(new URL('../tools/timeline/index.html',import.meta.url),'utf8');+const start=html.indexOf('  // Design-rule sanity card');+const end=html.indexOf('  let presentation = null;',start);+assert(start>0 && end>start);+assert(html.includes('id="rules-card"'),'the card section is in the page');+const elements=new Map();+const node=()=>({hidden:true,children:[],attrs:{},textContent:'',innerHTML:'',replaceChildren(){this.children=[]},appendChild(c){this.children.push(c)},setAttribute(k,v){this.attrs[k]=v}});+const $=id=>{if(!elements.has(id))elements.set(id,node());return elements.get(id)};+const svgEl=(tag,attrs)=>{const n=node();n.tag=tag;n.attrs={...attrs};return n};+const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));+const ctx={$,svgEl,esc};+vm.createContext(ctx);vm.runInContext(html.slice(start,end)+'\nglobalThis.renderCard=renderRulesCard;',ctx);+ctx.renderCard(undefined);assert.equal($('rules-card').hidden,true,'no card, no section');+const card={lane:'fab',board:{widthMm:40,heightMm:30,outline:[[0,0],[40,0],[40,30],[0,30]]},draw:{traceMm:0.025,clearanceMm:0.2,viaPadMm:0.6,drillMm:0.3},+  items:[{key:'minTrace',label:'min trace',value:0.025,unit:'mm',source:'spec track',flag:'min trace 0.025 mm (spec track) is below the fab lane\'s 0.1 mm'}],+  loads:[{net:'+5V',amps:2,widestMm:0.5}],flags:['min trace 0.025 mm (spec track) is below the fab lane\'s 0.1 mm <b>'],note:'Read-only: this card changes no gate.'};+ctx.renderCard(card);+assert.equal($('rules-card').hidden,false);+assert($('rc-board').children.some(c=>c.tag==='polygon'),'board outline drawn');+assert.equal($('rc-board').attrs.viewBox.split(' ').length,4);+const traces=$('rc-detail').children.filter(c=>c.tag==='rect'&&c.attrs.width===0.025);+assert.equal(traces.length,2,'two min traces at their own width, to scale');+assert($('rc-detail').children.some(c=>c.tag==='circle'&&c.attrs.r===0.3),'via pad radius to scale');+assert($('rc-flags').innerHTML.includes('&lt;b&gt;'),'flag text is escaped');+assert($('rc-facts').innerHTML.includes('class="flag"'));+assert.match($('rc-note').textContent,/changes no gate/);+console.log('PASS: rules card hidden without data, outline and features to scale, flags escaped, read-only note');
tools/fusion-project.py+129−17
@@ -163,6 +163,66 @@ def prove(key, data=None):     (d / (key + '.json')).write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'data': data or {}}, indent=1))  +# ---------------------------------------------------------------- cloud home++def cloud_place(a, st):+    """The run's Fusion cloud home (project, folder): --project/--folder, else what this run used before,+    else Main / Adom AI Flow. Kept in the run so the library's 3D models and the design land together."""+    c = st.get('cloud') or {}+    return (getattr(a, 'project', None) or c.get('project') or 'Main', getattr(a, 'folder', None) or c.get('folder') or 'Adom AI Flow')+++def save_refusal(app_state, name):+    """Why a Save As by name must not run now, or None. fusion_save_to_cloud is Save As on the ACTIVE+    document: the first save of a new design writes the whole design (fprj, fsch, fbrd, linked), but on a+    design already in the cloud with its 2D board in front it writes the board as a NEW unlinked .fbrd of+    the same name, and the design keeps linking the old board (issue #74 item 7: the routing, pours and+    silkscreen ended up in a board the design did not open)."""+    d = app_state.get('data') or app_state+    for x in d.get('documents') or []:+        df = x.get('dataFile') or {}+        if not df: continue+        if x.get('isActive') or (d.get('activeDocument') and x.get('name') == d.get('activeDocument')):+            return 'the open document %s is already saved in the cloud (as %s): a Save As by name would fork its board into a new, unlinked .fbrd' % (x.get('name'), df.get('name'))+        if df.get('name') == name:+            return 'a design named %s is already open from the cloud (%s): a Save As would make a second, unlinked copy of it' % (name, x.get('name'))+    return None+++def cloud_copies(listing, name):+    """The files of a fusion_list_cloud_files reply named exactly `name`."""+    return [f for f in ((listing.get('data') or listing).get('files') or []) if (f.get('name') or '') == name]+++def save_design(name, project, folder):+    """The new design's first save into <project>/<folder>, made only when it is safe: never over a design+    already in the cloud (see save_refusal) and never beside another design of the same name (the data panel+    cannot tell them apart). Read back after. Returns the hints worth passing on."""+    why = save_refusal(ab('fusion_get_app_state', {}), name)+    if why:+        err('not saving %s: %s' % (name, why),+            ['The Fusion bridge saves only by Save As today. Save an already-saved design in place in Fusion itself (File > Save with the design\'s own tab in front), then check its board still has the routing (open the design, not the .fbrd).',+             'Or close it without saving (fusion_close_document {name, save:false}) and run this again on a fresh import.'])+    ls = ab('fusion_list_cloud_files', {'projectName': project, 'folderPath': folder})+    if ls.get('success') is False:+        ab('fusion_create_cloud_folder', {'folderName': folder, 'projectName': project})+    elif cloud_copies(ls, name):+        have = ', '.join(sorted('%s.%s' % (f.get('name'), f.get('fileExtension') or '?') for f in cloud_copies(ls, name)))+        err('%s/%s already holds a design named %s (%s); a second one of the same name cannot be told apart in the data panel' % (project, folder, name, have),+            ['Use a new --name for this design, or open the existing one (fusion_schematic {action:"open", fileName, projectName}) and carry on with it.',+             'If the old copies are stale, remove them yourself in Fusion\'s data panel first; aiflow does not delete cloud files.'])+    s = ab('fusion_save_to_cloud', {'name': name, 'projectName': project, 'folderPath': folder})+    if not s.get('success'): err('could not save %s to %s/%s: %s' % (name, project, folder, s.get('error')), [s.get('_hint')])+    hints = []+    after = ab('fusion_list_cloud_files', {'projectName': project, 'folderPath': folder})+    if after.get('success') is not False:+        kinds = [(f.get('fileExtension') or '?') for f in cloud_copies(after, name)]+        twice = sorted({k for k in kinds if kinds.count(k) > 1})+        if not kinds: hints.append('Saved, but %s does not list %s yet; check the data panel before going on.' % (folder, name))+        elif twice: hints.append('%s/%s now holds more than one %s named %s: open the design (the .fprj) and check its board is the one you work on.' % (project, folder, '/'.join(twice), name))+    return hints++ # ---------------------------------------------------------------- library  def lookup_numbers(mpn, page_links, cache):@@ -704,13 +764,17 @@ def cmd_library(a):     # 3D: build only the parts that have no binding yet, then carry every binding into the library     todo = [m for m, p in st['parts'].items() if p.get('step') and not p.get('urn')]     bdir = box_dir() + '/library'+    models_home = ''     if todo:         stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); (stage / 'lib').mkdir(parents=True); (stage / 'models').mkdir()         for m in todo:             shutil.copy(st['parts'][m]['lbr'], stage / 'lib' / (m + '.lbr')); shutil.copy(st['parts'][m]['step'], stage / 'models' / (m + '.step'))         send(sorted(stage.glob('lib/*')), bdir + '/lib'); send(sorted(stage.glob('models/*')), bdir + '/models'); send([combined], bdir)         say('binding real 3D to %d part(s) in Fusion (about a minute each)' % len(todo))-        r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True,+        # the models' cloud home: the run's project and folder (a bridge that does not take them yet puts+        # them under the project open in Fusion's data panel; the reply says which)+        project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}; save_state(st)+        r = ab('fusion_build_library_3d', {'lbrPath': bdir + '/' + combined.name, 'outLbrPath': bdir + '/' + name + '_3d.lbr', 'capture': True, 'projectName': project, 'folderPath': folder,                                            'parts': [{'package': m.upper(), 'lbrPath': bdir + '/lib/' + m + '.lbr', 'modelPath': bdir + '/models/' + m + '.step'} for m in todo]}, timeout=300)         t0 = time.time(); res = {}         while time.time() - t0 < 60 * (5 + 3 * len(todo)):@@ -720,6 +784,8 @@ def cmd_library(a):                 res = pr; break             if pr.get('stage'): say('  3D: %s' % pr.get('stage'))         if not res: err('the 3D build did not finish in time', ['Read fusion_prove_result; run `adom-aiflow fusion library` again: parts already bound are kept and only the rest are built.'])+        if (res.get('projectName') or r.get('projectName')) != project:+            models_home = 'The 3D models went to the project open in Fusion\'s data panel (Adom AI Workspace/%s there), not %s/%s: this Fusion bridge does not take a destination for them yet. Make %s the active project in the data panel before `fusion library` to keep them with the design.' % (name, project, folder, project)         back = {m.upper(): m for m in st['parts']}         for x in res.get('parts', []):             if x.get('success') and x.get('wip_urn'): st['parts'][back.get(x['package'].upper(), x['package'])]['urn'] = x['wip_urn']@@ -747,6 +813,7 @@ def cmd_library(a):         'This step already applied each footprint\'s own model transform and checked every model against its footprint, but look anyway: once the board exists, '         '`adom-aiflow fusion view3d` (a still from Fusion\'s own renderer, then a live orbit), and every part must sit flat on its pads the way its datasheet drawing shows. '         'A part that does not: say which, and fix its model or its footprint\'s (model ... (rotate ...)) entry, then run `fusion library` again.',+        models_home,         'Next: adom-aiflow fusion schematic --netlist <netlist.json> --spec <spec.json> --name <Design> (the design starts from this library). New parts later: run `fusion library` again with them; they join this same library.'])  @@ -934,11 +1001,11 @@ def cmd_schematic(a):                       if ((re.search(r'package="([^"]+)"', e) or [None, ''])[1] or '').upper() in want3d and 'package3d_urn' not in e)     lost = missing3d(); restored = []     if lost:-        restored = restore_3d(st, name, a.project, a.folder)+        restored = restore_3d(st, name, *cloud_place(a, st))         ab('fusion_show_2d_board', {}); time.sleep(3)         lost = missing3d()-    s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})-    if not s.get('success'): ab('fusion_create_cloud_folder', {'folderName': a.folder, 'projectName': a.project}); s = ab('fusion_save_to_cloud', {'name': name, 'projectName': a.project, 'folderPath': a.folder})+    project, folder = cloud_place(a, st); st['cloud'] = {'project': project, 'folder': folder}+    save_hints = save_design(name, project, folder)     src = ab('fusion_board_source', {'expectDocument': name})     if not src.get('source'): err('could not read the board back: %s' % src.get('error'))     out = Path(a.board_out); out.write_text(src['source'])@@ -946,8 +1013,8 @@ def cmd_schematic(a):     if dru and dru.is_file(): shutil.copy(dru, out.with_suffix('.kicad_dru'))     st['design'] = name; save_state(st)     prove('schematic', {'name': name, 'groups': len(plan['groups'])})-    ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s; board written to %s' % (len(plan['groups']), n, name, out),-       [('3D: Fusion dropped the 3D models of %d part(s) when they were added; they were put back (the design was imported again with every binding on it): %s.' % (len(restored), ', '.join(restored))) if restored else '',+    ok('schematic drawn live in %d group(s), every pin on its net; the board followed with %d parts; saved as %s in %s/%s; board written to %s' % (len(plan['groups']), n, name, project, folder, out),+       save_hints + [('3D: Fusion dropped the 3D models of %d part(s) when they were added; they were put back (the design was imported again with every binding on it): %s.' % (len(restored), ', '.join(restored))) if restored else '',         ('3D: %d part(s) still show as flat placeholder boxes: %s. Read <run>/fusion/restore3d/ and fusion_check_dialogs.' % (len(lost), ', '.join(lost))) if lost else '',         'Look at the board in 3D before going on: `adom-aiflow fusion view3d` saves a still from Fusion\'s own renderer and orbits the live view, and every part must sit on its pads the way its datasheet shows.',         'Next: simulate it before the board: the Adom SPICE dashboard (adom-spice start / study / run ngspice / show, full screen in a Hydrogen webview); change values in the schematic if a measurement misses; record it with `evidence add --kind simulation`.',@@ -1061,6 +1128,11 @@ def _num(tag, k, d=None):     try: return float(m.group(1))     except ValueError: return m.group(1) +def _attr(tag, k, d=''):+    """An attribute as written (a name, package or value of digits stays a string: 0603, 10)."""+    m = re.search(r'\s%s="([^"]*)"' % k, ' ' + tag)+    return m.group(1) if m else d+ def _rot_of(r):     r = r or 'R0'; mir = r.startswith('M') or 'M' in r[:2]     try: ang = float(re.sub(r'[^0-9.\-]', '', r) or 0)@@ -1068,9 +1140,38 @@ def _rot_of(r):     return ang, mir  def _xf(x, y, ex, ey, ang, mir):-    if mir: x = -x+    """A package point on the board for an element at (ex, ey) with EAGLE rotation (ang, mir): MRฮธ is+    rotate ฮธ counterclockwise, then mirror x (Fusion's order; see aiflow-board eagle.rs)."""     c, s_ = math.cos(math.radians(ang)), math.sin(math.radians(ang))-    return ex + x * c - y * s_, ey + x * s_ + y * c+    x, y = x * c - y * s_, x * s_ + y * c+    return ex + (-x if mir else x), ey + y+++def board_elements(text):+    """{ref: {'smashed': bool, 'name': (x, y) or None}} from the board XML: each element's own NAME+    attribute (where a smashed name stands), read with a real parse so a self-closing <element .../> is+    never confused with its neighbour. {} when the text is not readable XML."""+    try: root = ET.fromstring(text)+    except ET.ParseError: return {}+    out = {}+    for e in root.iter('element'):+        na = next((x for x in e.findall('attribute') if x.get('name') == 'NAME'), None)+        xy = (float(na.get('x')), float(na.get('y'))) if na is not None and na.get('x') is not None and na.get('y') is not None else None+        out[e.get('name')] = {'smashed': e.get('smashed') == 'yes', 'name': xy, 'hasName': na is not None}+    return out+++def nameless_parts(text):+    """Smashed parts whose NAME text is gone (an earlier, interrupted pass deleted it): there is no name to+    move or remove, so the reference is printed as plain text instead."""+    return {r for r, e in board_elements(text).items() if e['smashed'] and not e['hasName']}+++def plain_texts(text, layer='21'):+    """[(text, x, y)] of the board's plain texts on one layer."""+    try: root = ET.fromstring(text)+    except ET.ParseError: return []+    return [(t.text or '', float(t.get('x', 0)), float(t.get('y', 0))) for pl in root.iter('plain') for t in pl.findall('text') if t.get('layer') == layer]  def board_model(text):     """The board as the label search sees it: copper and mask boxes, part bodies, the outline, and each@@ -1095,9 +1196,11 @@ def board_model(text):         pk[m.group(1)] = {'pads': pads, 'body': body, 'nameAlign': (re.search(r'align="([^"]+)"', nt.group(0)) or [None, 'bottom-left'])[1] if nt else 'bottom-left',                           'nameXY': (_num(nt.group(0), 'x', 0), _num(nt.group(0), 'y', 0)) if nt else (0, 0)}     els = []-    for m in re.finditer(r'<element ([^>]*)>(.*?)</element>|<element ([^>]*)/>', text, re.S):-        tag = m.group(1) or m.group(3); inner = m.group(2) or ''-        name, pkg = _num(tag, 'name'), _num(tag, 'package')+    # the self-closing form first: `<element ...>(.*?)</element>` also matches `<element .../>` and then runs+    # on through the next element (its NAME read as this one's, the next part lost)+    for m in re.finditer(r'<element ([^>]*?)/>|<element ([^>]*)>(.*?)</element>', text, re.S):+        tag = m.group(1) or m.group(2); inner = m.group(3) or ''+        name, pkg = _attr(tag, 'name', None), _attr(tag, 'package', None)         ex, ey = _num(tag, 'x', 0), _num(tag, 'y', 0)         ang, mir = _rot_of(_num(tag, 'rot', 'R0') if isinstance(_num(tag, 'rot', 'R0'), str) else 'R0')         p = pk.get(pkg, {'pads': [], 'body': []})@@ -1111,7 +1214,7 @@ def board_model(text):         na = re.search(r'<attribute name="NAME"[^>]*>', inner)         align = (re.search(r'align="([^"]+)"', na.group(0)) or [None, None])[1] if na else None         hx_, hy_ = p.get('nameXY', (0, 0))-        els.append({'name': name, 'package': pkg, 'value': _num(tag, 'value', ''), 'x': ex, 'y': ey, 'bottom': mir, 'pads': pads, 'body': body, 'rot': ang,+        els.append({'name': name, 'package': pkg, 'value': _attr(tag, 'value'), 'x': ex, 'y': ey, 'bottom': mir, 'pads': pads, 'body': body, 'rot': ang,                     'homeName': _xf(hx_, hy_, ex, ey, ang, mir),                     'nameAttr': na.group(0) if na else None, 'nameAlign': align or p.get('nameAlign', 'bottom-left')})     # each element's nets from the board's own connections (a value field can say anything); a pin with@@ -1161,7 +1264,7 @@ def cmd_silkscreen(a):         src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''         if '<element ' not in text: err('could not read the board %s (%s)' % (name, src.get('error')), ['Show the 2D board (fusion_show_2d_board) and run this again.'])     els, outline, vias, texts = board_model(text)-    nameless = {m.group(1) for m in re.finditer(r'<element name="([^"]+)"[^>]*smashed="yes"[^>]*>(.*?)</element>', text, re.S) if 'name="NAME"' not in m.group(2)}+    nameless = nameless_parts(text)     contacts = [e for e in els if is_contact(e)]     fresh_cmds = []     if a.fresh:@@ -1433,10 +1536,18 @@ def cmd_silkscreen(a):         err('the silkscreen pass removed parts from the board: %s' % ', '.join(lost),             ['Do not save. In Fusion press Undo (Ctrl+Z) until those parts are back, or close the document without saving (fusion_close_document {name, save:false}) and reopen it; if it was already saved, promote the previous cloud version (fusion_aps_versions). Then report it: a silkscreen command picked a part.'])     moved, off = 0, []+    after = board_elements(t2); printed = plain_texts(t2)     for L in labels:-        if L['kind'] != 'name' or abs(L['size'] - 0.8) > 1e-6: continue  # smaller names are printed as plain text-        m = re.search(r'<element name="%s"[^>]*>.*?<attribute name="NAME"([^>]*)>' % re.escape(L['ref']), t2, re.S)-        if m and abs((_num(m.group(1), 'x', 0) or 0) - L['anchor'][0]) < 0.05 and abs((_num(m.group(1), 'y', 0) or 0) - L['anchor'][1]) < 0.05: moved += 1+        if L['kind'] != 'name': continue+        if L['ref'] in nameless:+            # printed as plain text at its planned spot (a nameless part has no NAME to read back)+            tx, ty = L['origin'] if L.get('rot', 'R0') == 'R0' else (L['box'][2], L['box'][1])+            if any(t == L['text'] and abs(x - tx) < 0.05 and abs(y - ty) < 0.05 for (t, x, y) in printed): moved += 1+            else: off.append(L['ref'])+            continue+        if abs(L['size'] - 0.8) > 1e-6: continue  # smaller names are printed as plain text+        xy = (after.get(L['ref']) or {}).get('name')+        if xy and abs(xy[0] - L['anchor'][0]) < 0.05 and abs(xy[1] - L['anchor'][1]) < 0.05: moved += 1         else: off.append(L['ref'])     nets_written = sum(1 for L in labels if L['kind'] == 'net')     nets_kept = sum(1 for t in texts if t['layer'] == 21)@@ -1608,9 +1719,10 @@ def main():     ap = argparse.ArgumentParser(prog='adom-aiflow fusion')     sub = ap.add_subparsers(dest='cmd', required=True)     p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json, parts.json [{mpn, page?, footprint?}] or a BOM .csv'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} of numbers already checked')+    p.add_argument('--project', help='Fusion cloud project for the run (the 3D models and the design); default: the run\'s, else Main'); p.add_argument('--folder', help='folder in that project; default: the run\'s, else "Adom AI Flow"')     p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'fusion'))     p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--spec'); p.add_argument('--fixed'); p.add_argument('--outline', nargs=2, type=float)-    p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', default='Main'); p.add_argument('--folder', default='Adom AI Flow')+    p.add_argument('--mpns'); p.add_argument('--name', required=True); p.add_argument('--project', help='Fusion cloud project (default: the run\'s, else Main)'); p.add_argument('--folder', help='folder in that project (default: the run\'s, else "Adom AI Flow")')     p.add_argument('--board-out', default='board.brd'); p.add_argument('--pause-ms', type=int, default=60)     p.add_argument('--layout', help='wired.json: parts placed by hand and every net drawn as wires pin to pin (a sheet an engineer can read), instead of the planner\'s labelled stubs')     p = sub.add_parser('reset'); p.add_argument('--design')
tools/kicad-cli-remote+304−89
@@ -1,63 +1,111 @@ #!/usr/bin/env bash # kicad-cli-remote: a native KiCad 10 `kicad-cli` for a container that has none, run on a desktop # through Adom Bridge. Implements what adom-aiflow needs (ADOM_AIFLOW_KICAD_CLI):-#   kicad-cli-remote --version-#   kicad-cli-remote pcb drc --format json --output <report.json> <board.kicad_pcb>+#   kicad-cli-remote --version                (this wrapper)+#   kicad-cli-remote version                  (the desktop's kicad-cli answers)+#   kicad-cli-remote pcb drc --format json --output <report.json> [--refill-zones] <board.kicad_pcb>+#   kicad-cli-remote sch erc --format json --output <report.json> [--severity-all] <design.kicad_sch> #   kicad-cli-remote sch export netlist --format kicadsexpr --output <out.net> <design.kicad_sch> #   kicad-cli-remote pcb export gerbers|drill --output <dir>/ [flags] <board.kicad_pcb> #   kicad-cli-remote pcb export pos --output <file.csv> [flags] <board.kicad_pcb>+#   kicad-cli-remote pcb export step --output <file.step> [flags] <board.kicad_pcb>  (native output passed through) # The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The # schematic travels with its sub-sheets, .kicad_pro, sym-lib-table and fp-lib-table, and every # ${KIPRJMOD} library the sym-lib-table names, in the same folders relative to the project.-# Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop), KICAD_REMOTE_CLI (native kicad-cli path on it),-#      KICAD_REMOTE_THREAD (ai-thread name for the bridge; default kicad-cli-remote).+# Local outputs may be relative: they are resolved against the current folder before anything is pulled.+# Windows desktops run cmd through shell_execute in %TEMP%. Linux desktops (kicad-bridge's Linux build, a+# Flatpak KiCad too) run bash through run_script in ~/.adom-aiflow-kicad (home, not /tmp: a Flatpak has its own /tmp).+# Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop),+#      KICAD_REMOTE_CLI (native kicad-cli path on it), KICAD_REMOTE_THREAD (ai-thread name for the bridge;+#      default kicad-cli-remote), KICAD_REMOTE_OS (windows|linux; default: detected),+#      KICAD_REMOTE_TMP (job folder parent on the desktop). set -euo pipefail T="${KICAD_REMOTE_TARGET:-}" TH="${KICAD_REMOTE_THREAD:-kicad-cli-remote}" # The desktop's native kicad-cli: KICAD_REMOTE_CLI, else what the KiCad bridge reports (install.kicadCli). CLI="${KICAD_REMOTE_CLI:-}"+ROS="${KICAD_REMOTE_OS:-}"; ROS="${ROS,,}"; KSTATUS_OS=""; RTMP=""; RTMP_OURS="" -if [[ "${1:-}" == "--version" ]]; then echo "kicad-cli-remote -> $T (native KiCad via Adom Bridge)"; exit 0; fi+if [[ "${1:-}" == "--version" ]]; then echo "kicad-cli-remote -> ${T:-this machine} (native KiCad via Adom Bridge)"; exit 0; fi++ab() { adom-bridge ${T:+--target "$T"} "$1" "$2" --ai-thread "$TH"; }+jstdout() { python3 -c 'import sys,json+try: print(json.load(sys.stdin).get("stdout",""))+except Exception: pass'; }+# A local path made absolute (pull_file's saveTo and the existence checks must agree on it).+absp() { realpath -m -- "$1"; }  find_cli() {   if [[ -z "$CLI" ]]; then-    CLI=$(adom-bridge ${T:+--target "$T"} kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' --ai-thread "$TH" | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""))')-    [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on $T (set KICAD_REMOTE_CLI)" >&2; exit 2; }+    local found+    found=$(ab kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' | python3 -c 'import sys,json+d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""));print(str((d.get("platform") or {}).get("os") or ""))')+    CLI="$(sed -n 1p <<<"$found")"; KSTATUS_OS="$(sed -n 2p <<<"$found")"+    [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on ${T:-this machine} (set KICAD_REMOTE_CLI)" >&2; exit 2; }+  fi+  find_os+}+# The desktop's OS: KICAD_REMOTE_OS, else kicad_status platform.os (kicad-bridge 1.0.56+), else+# `adom-bridge targets` os, else the shape of the kicad-cli path.+find_os() {+  [[ -n "$ROS" ]] && return 0+  local o="${KSTATUS_OS,,}"+  if [[ -z "$o" && -n "$T" ]]; then+    o=$(adom-bridge targets --ai-thread "$TH" 2>/dev/null | python3 -c 'import sys,json+t=sys.argv[1].lower()+try: d=json.load(sys.stdin)+except Exception: d={}+for x in d.get("targets") or []:+    if t in (str(x.get("name") or "").lower(), str(x.get("hostname") or "").lower()) and x.get("os"): print(str(x["os"]).lower()); break' "$T" || true)   fi+  case "$o" in+    *linux*) ROS=linux;;+    *windows*|win*) ROS=windows;;+    *) if [[ "$CLI" == /* ]]; then ROS=linux; else ROS=windows; fi;;+  esac+}+# Linux: run a bash script on the desktop (run_script; no shell-string quoting), print the bridge reply.+lx_script() {+  ab run_script "$(python3 -c 'import json,sys,base64;print(json.dumps({"interpreter":"bash","scriptB64":base64.b64encode(sys.argv[1].encode()).decode(),"timeoutSeconds":int(sys.argv[2]),"reason":sys.argv[3]}))' "$1" "$2" "$3")" }+# Linux: one command line, every argument quoted for bash.+lx_cmd() { printf '%q ' "$@"; } find_tmp() {-  # A temp folder on the desktop: KICAD_REMOTE_TMP, else the desktop's %TEMP%.+  # The job folder parent: KICAD_REMOTE_TMP, else %TEMP% (Windows) or ~/.adom-aiflow-kicad (Linux).   RTMP="${KICAD_REMOTE_TMP:-}"-  if [[ -z "$RTMP" ]]; then-    RTMP=$(adom-bridge ${T:+--target "$T"} shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' --ai-thread "$TH" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("stdout","").strip().replace(chr(92),"/"))')+  [[ -n "$RTMP" ]] && return 0+  if [[ "$ROS" == linux ]]; then+    RTMP_OURS=1+    RTMP=$(lx_script 'mkdir -p "$HOME/.adom-aiflow-kicad" && printf %s "$HOME/.adom-aiflow-kicad"' 30 "job folder for an adom-aiflow kicad-cli job" | jstdout | tr -d '\r\n')+    [[ "$RTMP" == /* ]] || { echo "kicad-cli-remote: could not make a job folder in the home folder on ${T:-this machine}" >&2; exit 2; }+  else+    RTMP=$(ab shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' | jstdout | tr -d '\r\n' | tr '\\' /)+    [[ -n "$RTMP" ]] || { echo "kicad-cli-remote: no temp folder on ${T:-this machine}" >&2; exit 2; }   fi } cleanup_remote() {-  adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" --ai-thread "$TH" >/dev/null || true-}--if [[ "${1:-}" == "sch" && "${2:-}" == "export" && "${3:-}" == "netlist" ]]; then-  shift 3-  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then-    echo "Usage: kicad-cli-remote sch export netlist [--format kicadsexpr] --output FILE INPUT_FILE"-    echo "  --format  kicadsexpr (default), kicadxml, orcadpcb2, ... (passed to the desktop's native KiCad)"-    exit 0+  if [[ "$ROS" == linux ]]; then+    # only a job folder, and only inside the job parent+    [[ "$1" == "$RTMP"/aiflow-* ]] && lx_script "rm -rf -- $(printf '%q' "$1")${RTMP_OURS:+; rmdir -- $(printf '%q' "$RTMP") 2>/dev/null}; true" 60 "clean up the kicad-cli job folder" >/dev/null || true+    return 0   fi-  fmt=kicadsexpr; out=""; sch=""-  while [[ $# -gt 0 ]]; do-    case "$1" in-      --format) fmt="$2"; shift 2;;-      --output|-o) out="$2"; shift 2;;-      --*) shift;;-      *) sch="$1"; shift;;-    esac-  done-  [[ -f "$sch" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing schematic" >&2; exit 2; }-  find_cli; find_tmp-  job="aiflow-net-$(date +%s)-$$"-  dest="$RTMP/$job"-  base="$(basename "$sch" .kicad_sch)"-  # groups of files by folder relative to the project: {"": [...], "lib": [...]}+  ab shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" >/dev/null || true+}+# Send local files to one remote folder: <dest> <files...>+send_to() {+  local d="$1"; shift+  ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$@")" >/dev/null+}+# The board and its .kicad_pro / .kicad_dru into <dest>.+send_board() {+  local board="$1" dest="$2" ext f files=("$(readlink -f "$1")")+  for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done+  send_to "$dest" "${files[@]}"+}+# The schematic hierarchy, project, lib tables and ${KIPRJMOD} symbol libraries into <dest>, in the same+# folders relative to the project.+send_schematic() {+  local sch="$1" dest="$2" groups line sub list d   groups=$(python3 - "$(readlink -f "$sch")" <<'PY' import json, os, re, sys root_sch = sys.argv[1]; proj = os.path.dirname(root_sch)@@ -83,31 +131,216 @@ for f in sorted(files):     rel = os.path.relpath(os.path.dirname(f), proj)     if rel.startswith(".."): rel = ""     groups.setdefault("" if rel == "." else rel.replace(os.sep, "/"), []).append(f)-print(json.dumps(groups))+for k, v in groups.items(): print(k + "\t" + json.dumps(v)) PY )   while IFS= read -r line; do     sub="${line%%$'\t'*}"; list="${line#*$'\t'}"     d="$dest"; [[ -n "$sub" ]] && d="$dest/$sub"-    args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":json.loads(sys.argv[2]),"dest":sys.argv[1],"reason":"schematic netlist export for adom-aiflow (native kicad-cli on the desktop)"}))' "$d" "$list")-    adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null-  done < <(python3 -c 'import json,sys;[print(k+"\t"+json.dumps(v)) for k,v in json.loads(sys.argv[1]).items()]' "$groups")-  winsch="$dest/$base.kicad_sch"; winnet="$dest/$base.aiflow.net"-  cmd=$(python3 -c 'import json,sys;cli,s,o,f=sys.argv[1:5];c=f"\"{cli}\" sch export netlist --format {f} --output \"{o}\" \"{s}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":180,"reason":"schematic netlist export for adom-aiflow"}))' "$CLI" "$winsch" "$winnet" "$fmt")-  res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH")-  tmp="$(mktemp -d)"-  adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the netlist back"}))' "$winnet" "$tmp")" --ai-thread "$TH" >/dev/null-  if [[ -f "$tmp/$base.aiflow.net" ]]; then-    mv "$tmp/$base.aiflow.net" "$out"-    cleanup_remote "$dest"-    rmdir "$tmp" 2>/dev/null || true+    ab send_files "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":json.loads(sys.argv[2]),"dest":sys.argv[1],"reason":"schematic files for a kicad-cli job for adom-aiflow (native KiCad on the desktop)"}))' "$d" "$list")" >/dev/null+  done <<<"$groups"+}+# Run the desktop's kicad-cli: <timeout> <reason> <mkdir-or-empty> <args...>. Remote paths use forward+# slashes; Windows gets backslashes and quotes, Linux gets bash quoting. Prints the bridge reply.+run_cli() {+  local to="$1" why="$2" mk="$3"; shift 3+  if [[ "$ROS" == linux ]]; then+    lx_script "${mk:+mkdir -p $(printf '%q' "$mk") && }$(lx_cmd "$CLI" "$@")" "$to" "$why"+  else+    # every path quoted; a folder output keeps its trailing separator, doubled before the closing quote so+    # Windows does not read \" as a quote+    ab shell_execute "$(python3 - "$to" "$why" "$mk" "$CLI" "$@" <<'PY'+import json, sys+to, why, mk, cli, args = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:]+def q(a):+    a = a.replace("/", "\\")+    if "\\" in a or " " in a or ":" in a:+        return '"' + (a + "\\" if a.endswith("\\") else a) + '"'+    return a+c = q(cli) + " " + " ".join(q(a) for a in args)+if mk: c = 'mkdir ' + q(mk) + ' 2>nul & ' + c+print(json.dumps({"command": c, "timeoutSeconds": int(to), "reason": why}))+PY+)"+  fi+}+# The names in a remote folder, one per line.+list_remote() {+  if [[ "$ROS" == linux ]]; then+    lx_script "cd $(printf '%q' "$1") && ls -1A" 60 "list the kicad-cli outputs" | jstdout+  else+    ab shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the kicad-cli outputs"}))' "$1")" | jstdout+  fi | tr -d '\r' | sed '/^[[:space:]]*$/d'+}+# Pull remote files into an absolute local folder: <saveTo> <remote paths...>. Linux: a file pull_file did+# not land (a mesh-only box the relay does not hold answers target_not_found) comes back in ranged+# read_file pieces, checked against the desktop's sha256sum.+pull_to() {+  local save; save="$(absp "$1")"; shift+  mkdir -p "$save"+  ab pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the kicad-cli outputs back"}))' "$save" "$@")" >/dev/null 2>&1 || true+  [[ "$ROS" == linux ]] || return 0+  local miss=() p sums+  for p in "$@"; do [[ -f "$save/$(basename "$p")" ]] || miss+=("$p"); done+  [[ ${#miss[@]} -eq 0 ]] && return 0+  sums=$(lx_script "sha256sum -- $(printf '%q ' "${miss[@]}")" 60 "checksums of the kicad-cli outputs" | jstdout || true)+  python3 - "$T" "$TH" "$save" "$sums" "${miss[@]}" <<'PY'+import base64, hashlib, json, os, subprocess, sys+target, thread, save, sums, paths = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[5:]+want = {}+for line in sums.splitlines():+    h, _, name = line.partition("  ")+    if name: want[name.strip()] = h.strip()+for p in paths:+    data, off = b"", 0+    while True:+        args = json.dumps({"path": p, "encoding": "base64", "offset": off, "length": 1048576, "reason": "bring a kicad-cli output back"})+        cmd = ["adom-bridge"] + (["--target", target] if target else []) + ["read_file", args, "--ai-thread", thread]+        r = subprocess.run(cmd, capture_output=True, text=True)+        try: d = json.loads(r.stdout)+        except Exception: d = {}+        if not d.get("success"): data = None; break+        data += base64.b64decode(d.get("content", ""))+        if d.get("eof") or d.get("nextOffset") is None or d.get("nextOffset") == off: break+        off = d["nextOffset"]+    if data is None or want.get(p) != hashlib.sha256(data).hexdigest(): continue+    tmp = os.path.join(save, "." + os.path.basename(p) + ".part")+    with open(tmp, "wb") as f: f.write(data)+    os.replace(tmp, os.path.join(save, os.path.basename(p)))+PY+}+tail_of() { python3 -c 'import sys,json+try: d=json.load(sys.stdin)+except Exception: sys.exit(0)+print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true; }+new_job() { find_cli; find_tmp; JOB="$RTMP/aiflow-$1-$(date +%s)-$$"; }+# Bring one report back into a local file: <remote file> <local file>. Returns 1 when it did not come back.+fetch_one() {+  local remote="$1" out tmp+  out="$(absp "$2")"; tmp="$(mktemp -d)"+  pull_to "$tmp" "$remote"+  if [[ -f "$tmp/$(basename "$remote")" ]]; then+    mkdir -p "$(dirname "$out")"; mv "$tmp/$(basename "$remote")" "$out"; rmdir "$tmp" 2>/dev/null || true; return 0+  fi+  rm -rf "$tmp"; return 1+}++if [[ "${1:-}" == "version" ]]; then+  # the desktop's own kicad-cli answers (a probe; no files travel)+  find_cli+  v=$(run_cli 60 "kicad-cli version probe for adom-aiflow" "" version | jstdout | tr -d '\r' | sed '/^[[:space:]]*$/d')+  [[ -n "$v" ]] || { echo "kicad-cli-remote: no version came back from ${T:-this machine}" >&2; exit 1; }+  echo "$v"; exit 0+fi++if [[ "${1:-}" == "sch" && ( "${2:-}" == "erc" || ( "${2:-}" == "export" && "${3:-}" == "netlist" ) ) ]]; then+  if [[ "$2" == "erc" ]]; then kind=erc; shift 2; else kind=netlist; shift 3; fi+  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then+    if [[ $kind == erc ]]; then+      echo "Usage: kicad-cli-remote sch erc [--format json|report] --output FILE [--severity-all|--severity-error|--severity-warning] [--units mm|in|mils] INPUT_FILE"+      echo "  Runs the desktop's native KiCad ERC and pulls the report back (default --format json --severity-all)."+    else+      echo "Usage: kicad-cli-remote sch export netlist [--format kicadsexpr] --output FILE INPUT_FILE"+      echo "  --format  kicadsexpr (default), kicadxml, orcadpcb2, ... (passed to the desktop's native KiCad)"+    fi     exit 0   fi-  echo "kicad-cli-remote: no netlist came back" >&2-  echo "$res" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true+  fmt=""; out=""; sch=""; extra=()+  while [[ $# -gt 0 ]]; do+    case "$1" in+      --format) fmt="$2"; shift 2;;+      --output|-o) out="$2"; shift 2;;+      --units) extra+=("$1" "$2"); shift 2;;+      --severity-all|--severity-error|--severity-warning|--severity-exclusions|--exit-code-violations) extra+=("$1"); shift;;+      --*) shift;;+      *) sch="$1"; shift;;+    esac+  done+  [[ -f "$sch" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing schematic" >&2; exit 2; }+  base="$(basename "$sch" .kicad_sch)"+  new_job "$kind"+  send_schematic "$sch" "$JOB"+  rsch="$JOB/$base.kicad_sch"+  if [[ $kind == erc ]]; then+    fmt="${fmt:-json}"+    [[ " ${extra[*]:-} " == *" --severity-"* ]] || extra+=(--severity-all)+    rout="$JOB/$base.erc.$fmt"+    res=$(run_cli 300 "schematic ERC for adom-aiflow" "" sch erc --format "$fmt" "${extra[@]}" --output "$rout" "$rsch") || true+  else+    fmt="${fmt:-kicadsexpr}"+    rout="$JOB/$base.aiflow.net"+    res=$(run_cli 180 "schematic netlist export for adom-aiflow" "" sch export netlist --format "$fmt" --output "$rout" "$rsch") || true+  fi+  if fetch_one "$rout" "$out"; then cleanup_remote "$JOB"; exit 0; fi+  cleanup_remote "$JOB"+  echo "kicad-cli-remote: no $kind report came back" >&2+  echo "$res" | tail_of   exit 1 fi +if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" == "step" ]]; then+  # The board twin's STEP: the board, its project files and every ${KIPRJMOD} model travel in their+  # folders; the native output (stdout and stderr, where KiCad prints geometry warnings) is passed+  # through unchanged and the native exit status is kept, so adom-aiflow step-export can classify it.+  shift 3+  if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then+    echo "Usage: kicad-cli-remote pcb export step [flags] --output OUT.step INPUT_FILE (flags are passed to the desktop's native KiCad)"+    exit 0+  fi+  out=""; board=""; pass=()+  while [[ $# -gt 0 ]]; do+    case "$1" in+      --output|-o) out="$2"; shift 2;;+      --min-distance|--user-origin|--component-filter|--net-filter|--variant) pass+=("$1" "$2"); shift 2;;+      --*) pass+=("$1"); shift;;+      *) board="$1"; shift;;+    esac+  done+  [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }+  base="$(basename "$board" .kicad_pcb)"+  new_job step+  # the board, its project files and every ${KIPRJMOD} model, in their folders relative to the project+  while IFS= read -r line; do+    sub="${line%%$'\t'*}"; list="${line#*$'\t'}"+    d="$JOB"; [[ -n "$sub" ]] && d="$JOB/$sub"+    mapfile -t files < <(python3 -c 'import json,sys;[print(f) for f in json.loads(sys.argv[1])]' "$list")+    send_to "$d" "${files[@]}"+  done < <(python3 - "$(readlink -f "$board")" <<'PY'+import json, os, re, sys+b = sys.argv[1]; proj = os.path.dirname(b); stem = os.path.splitext(b)[0]+files = {b}+for ext in (".kicad_pro", ".kicad_dru"):+    if os.path.isfile(stem + ext): files.add(stem + ext)+for m in re.findall(r'\(model\s+"([^"]+)"', open(b, encoding="utf-8", errors="replace").read()):+    if "${KIPRJMOD}" in m:+        p = os.path.normpath(m.replace("${KIPRJMOD}", proj))+        for c in (p, os.path.splitext(p)[0] + ".step", os.path.splitext(p)[0] + ".stp"):+            if os.path.isfile(c): files.add(c)+groups = {}+for f in sorted(files):+    rel = os.path.relpath(os.path.dirname(f), proj)+    if rel.startswith(".."): continue+    groups.setdefault("" if rel == "." else rel.replace(os.sep, "/"), []).append(f)+for k, v in groups.items(): print(k + "\t" + json.dumps(v))+PY+)+  rstep="$JOB/$base.aiflow.step"+  res=$(run_cli 600 "board STEP export for adom-aiflow" "" pcb export step "${pass[@]}" --output "$rstep" "$JOB/$base.kicad_pcb") || true+  # the native output and exit status, unchanged (Windows shell_execute and Linux run_script both answer stdout/stderr/exitCode)+  echo "$res" | python3 -c 'import sys,json+try: d=json.load(sys.stdin)+except Exception: sys.exit(0)+sys.stdout.write(d.get("stdout","") or "");sys.stderr.write(d.get("stderr","") or "")' || true+  rc=$(echo "$res" | python3 -c 'import sys,json+try: d=json.load(sys.stdin)+except Exception: print(1); sys.exit(0)+c=d.get("exitCode", d.get("code")); print(1 if c is None else int(c))' 2>/dev/null || echo 1)+  if fetch_one "$rstep" "$out"; then cleanup_remote "$JOB"; exit "$rc"; fi+  cleanup_remote "$JOB"+  echo "kicad-cli-remote: the STEP did not come back" >&2+  exit $(( rc == 0 ? 1 : rc ))+fi+ if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" =~ ^(gerbers|drill|pos)$ ]]; then   kind="$3"; shift 3   if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then@@ -124,41 +357,35 @@ if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" =~ ^(gerbers|drill|p     esac   done   [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }-  find_cli; find_tmp-  job="aiflow-export-$(date +%s)-$$"-  dest="$RTMP/$job"   base="$(basename "$board" .kicad_pcb)"-  files=("$(readlink -f "$board")")-  for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done-  args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"manufacturing export for adom-aiflow (native kicad-cli on the desktop)"}))' "$dest" "${files[@]}")-  adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null-  # a folder output keeps its trailing separator, doubled before the closing quote so Windows does not read \" as a quote-  rout="$dest/out/"; [[ "$kind" == "pos" ]] && rout="$dest/out/$(basename "$out")"-  cmd=$(python3 -c 'import json,sys;cli,b,d,r,k=sys.argv[1:6];x=" ".join(sys.argv[6:]);r=r.replace("/",chr(92));r=r+chr(92) if r.endswith(chr(92)) else r;c=f"mkdir \"{d}/out\" 2>nul & \"{cli}\" pcb export {k} {x} --output \"{r}\" \"{b}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":600,"reason":"manufacturing export for adom-aiflow"}))' "$CLI" "$dest/$base.kicad_pcb" "$dest" "$rout" "$kind" "${pass[@]}")-  res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH")+  new_job export+  send_board "$board" "$JOB"+  rdir="$JOB/out"+  rout="$rdir/"; [[ "$kind" == "pos" ]] && rout="$rdir/$(basename "$out")"+  res=$(run_cli 600 "manufacturing export for adom-aiflow" "$rdir" pcb export "$kind" "${pass[@]}" --output "$rout" "$JOB/$base.kicad_pcb") || true+  # the local side, absolute (#61, #72): pull_file's saveTo and the checks below must name the same folder   if [[ "$kind" == "pos" ]]; then-    names=("$(basename "$out")"); local_dir="$(dirname "$out")"+    names=("$(basename "$out")"); local_dir="$(dirname "$(absp "$out")")"   else-    local_dir="${out%/}"-    lst=$(adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the exported manufacturing files"}))' "$dest/out")" --ai-thread "$TH")-    mapfile -t names < <(echo "$lst" | python3 -c 'import sys,json;[print(l.strip()) for l in json.load(sys.stdin).get("stdout","").splitlines() if l.strip()]')+    local_dir="$(absp "${out%/}")"+    mapfile -t names < <(list_remote "$rdir")   fi   mkdir -p "$local_dir"   code=0   if [[ ${#names[@]} -eq 0 ]]; then code=1; else-    paths=(); for n in "${names[@]}"; do paths+=("$dest/out/$n"); done-    adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the manufacturing files back"}))' "$local_dir" "${paths[@]}")" --ai-thread "$TH" >/dev/null-    for n in "${names[@]}"; do [[ -f "$local_dir/$n" ]] || code=1; done+    paths=(); for n in "${names[@]}"; do paths+=("$rdir/$n"); done+    pull_to "$local_dir" "${paths[@]}"+    for n in "${names[@]}"; do [[ -f "$local_dir/$n" ]] || { code=1; echo "kicad-cli-remote: $n did not come back to $local_dir" >&2; }; done   fi-  cleanup_remote "$dest"+  cleanup_remote "$JOB"   if [[ $code -ne 0 ]]; then     echo "kicad-cli-remote: the $kind export did not come back" >&2-    echo "$res" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true+    echo "$res" | tail_of   fi   exit $code fi -[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'pcb export gerbers|drill|pos', 'sch export netlist' and '--version' are supported" >&2; exit 2; }+[[ "${1:-}" == "pcb" && "${2:-}" == "drc" ]] || { echo "kicad-cli-remote: only 'pcb drc', 'sch erc', 'pcb export gerbers|drill|pos|step', 'sch export netlist', 'version' and '--version' are supported" >&2; exit 2; } shift 2 if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then   echo "Usage: kicad-cli-remote pcb drc [--format json] [--output FILE] [--refill-zones] [--severity-all] INPUT_FILE"@@ -176,25 +403,13 @@ while [[ $# -gt 0 ]]; do   esac done [[ -f "$board" && -n "$out" ]] || { echo "kicad-cli-remote: need --output and an existing board" >&2; exit 2; }-find_cli; find_tmp-job="aiflow-drc-$(date +%s)-$$"-dest="$RTMP/$job" base="$(basename "$board" .kicad_pcb)"-files=("$(readlink -f "$board")")-for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done-args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"offline KiCad DRC for adom-aiflow (native kicad-cli on the desktop)"}))' "$dest" "${files[@]}")-adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null-winboard="$dest/$base.kicad_pcb"; winrep="$dest/$base.drc.$fmt"-cmd=$(python3 -c 'import json,sys;cli,b,r,f=sys.argv[1:5];x=" ".join(sys.argv[5:]);c=f"\"{cli}\" pcb drc {x} --format {f} --output \"{r}\" \"{b}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":240,"reason":"offline KiCad DRC for adom-aiflow"}))' "$CLI" "$winboard" "$winrep" "$fmt" "${extra[@]}")-res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH")-tmp="$(mktemp -d)"-adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the DRC report back"}))' "$winrep" "$tmp")" --ai-thread "$TH" >/dev/null-if [[ -f "$tmp/$base.drc.$fmt" ]]; then-  mv "$tmp/$base.drc.$fmt" "$out"-  adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the DRC temp folder"}))' "$dest")" --ai-thread "$TH" >/dev/null || true-  rmdir "$tmp" 2>/dev/null || true-  exit 0-fi+new_job drc+send_board "$board" "$JOB"+rrep="$JOB/$base.drc.$fmt"+res=$(run_cli 240 "offline KiCad DRC for adom-aiflow" "" pcb drc "${extra[@]}" --format "$fmt" --output "$rrep" "$JOB/$base.kicad_pcb") || true+if fetch_one "$rrep" "$out"; then cleanup_remote "$JOB"; exit 0; fi+cleanup_remote "$JOB" echo "kicad-cli-remote: no report came back" >&2-echo "$res" | python3 -c 'import sys,json;d=json.load(sys.stdin);print(d.get("stdout","")[-800:],d.get("stderr","")[-800:])' >&2 || true+echo "$res" | tail_of exit 1
tools/laser-etch/adom-aiflow-etch+1−1
@@ -3,7 +3,7 @@ #   adom-aiflow-etch --in part.step --mpn MPN --out part-etched.step [--footprint part.kicad_mod] #   adom-aiflow-etch plan    part.step [--footprint fp.kicad_mod] [--mpn MPN] [--face top|side|auto] [--json out.json] #   adom-aiflow-etch check   part-etched.step [--outline name.svg] [--footprint fp.kicad_mod] [--plan x.mark-plan.json]-#   adom-aiflow-etch outline part.step [--etched part-etched.step] [--footprint fp.kicad_mod] [--mpn MPN] [--out-dir DIR]+#   adom-aiflow-etch outline part.step [--etched part-etched.step] [--lines "A|B"] [--plan x.mark-plan.json] [--footprint fp.kicad_mod] [--mpn MPN] [--out-dir DIR] #   adom-aiflow-etch test    --step part.step --footprint fp.kicad_mod --mpn MPN --out-dir DIR   (gate self-test) # The planner, the etch, the gate and the outline run on the shared STEP service (service-step2glb) with # plain python3: no OpenCascade, no install. `--legacy` runs the previous OpenCascade etch (etch_part_occ.py),
tools/laser-etch/mark_check.py+48−12
@@ -14,13 +14,15 @@ Checks (FAIL blocks; WARN is printed as a Hint):   colour    a light mark (sRGB luminance >= 0.5): WARN when dark (only right on a light body)   widget    cap height in pixels with the iso outline drawn 320 px wide: >= 6 px PASS, 4-6 WARN, < 4 FAIL   outline   (with --outline) the outline's name runs the same direction as the model mark, sits on the-            same face and within 15 % of the same place, and its caps are >= 6 px at 320 px+            same face and within 15 % of the same place, and its caps are >= 6 px at 320 px; its text lines+            (the data-lines record `adom-aiflow-etch outline` writes) equal the model's mark lines (the etch's+            own <etched>.mark-plan.json, which keeps a forced --lines split): FAIL when they differ The verdict is judged in the NORMALIZED pose (footprint frame after the stand-up turns), never the mounted pose on a board; check.json "frame" records that frame, the source-to-normalized and normalized-to-output turns, and whether model up (--up) and the marked face (--face) were supplied or inferred (text up always comes from pin 1). Writes <etched>.check.json and a top view of the mark from the service, <etched>.check.png, with the band (green), the reading arrow and the verdict drawn on it. Exit status 1 on FAIL."""-import argparse, io, json, math, os, re, sys+import argparse, html, io, json, math, os, re, sys  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import step_text as ST@@ -71,7 +73,36 @@ def outline_text(svg, plate=False): _iso = MG.iso  -def check_outline(svg_path, plan, mark_c_posed, meta_posed, mark_box_posed=None, verts=None):+def svg_lines(svg):+    """The text lines an outline records on its name group (data-lines="A|B"), else None."""+    m = re.search(r'<g id="adom-mark-name"[^>]*\bdata-lines="([^"]*)"', svg)+    return html.unescape(m.group(1)).split("|") if m else None+++def mark_lines(plan, sidecar=None, lines=None):+    """The lines the model's mark was etched with: an explicit split, else the etch's own record in its+    <etched>.mark-plan.json sidecar (a forced --lines lives there, not in the planner's plan), else the plan's."""+    if lines:+        return list(lines)+    if isinstance(sidecar, dict):+        got = (sidecar.get("etch") or {}).get("lines")+        if got:+            return list(got)+        plan = plan or sidecar.get("plan", sidecar)+    return list((plan or {}).get("text", {}).get("lines") or [])+++def lines_check(svg, expected):+    """(status, hint) for an outline's name lines against the model's mark lines."""+    got = svg_lines(svg)+    if got is None:+        return "WARN", "the outline carries no line record (data-lines), so its line split cannot be compared with the mark; redraw it with `adom-aiflow-etch outline --etched <model>`"+    if expected and got != list(expected):+        return "FAIL", f"outline text lines {' / '.join(got)} differ from the model's mark {' / '.join(expected)}; redraw it with `adom-aiflow-etch outline --etched <model>` (it reads the etch's lines) or pass the same --lines"+    return "PASS", None+++def check_outline(svg_path, plan, mark_c_posed, meta_posed, mark_box_posed=None, verts=None, expected_lines=None):     svg = open(svg_path).read()     vb = re.search(r'viewBox="([^"]+)"', svg)     W = float(vb.group(1).split()[2]) if vb else 320.0@@ -176,7 +207,12 @@ def check_outline(svg_path, plan, mark_c_posed, meta_posed, mark_box_posed=None,         if st == "PASS":             st = "WARN"         hints.append(f"outline name is {size_ratio:.2f}x the etched mark's length; draw it with the planner's band (adom-aiflow-etch outline)")-    res.update(status=st, angleDeg=round(angle, 1), sameDirection=bool(ok_dir), offsetPct=round(100 * dist, 1), capHeightPx320=round(cap_px, 1),+    ls, lh = lines_check(svg, expected_lines if expected_lines is not None else mark_lines(plan))+    if ls == "FAIL" or (ls == "WARN" and st == "PASS"):+        st = ls+    if lh:+        hints.append(lh)+    res.update(status=st, outlineLines=svg_lines(svg), markLines=expected_lines if expected_lines is not None else mark_lines(plan), angleDeg=round(angle, 1), sameDirection=bool(ok_dir), offsetPct=round(100 * dist, 1), capHeightPx320=round(cap_px, 1),                lengthVsMark=round(size_ratio, 2) if size_ratio else None, hints=hints)     return res @@ -251,13 +287,13 @@ def orientation_frame(plan, u_plan):     return fr  -def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_path=None, render=True, quiet=False):-    if plan is None and plan_path:-        j = json.load(open(plan_path)); plan = j.get("plan", j)-    if plan is None:-        side = os.path.splitext(step_path)[0] + ".mark-plan.json"-        if os.path.exists(side):-            j = json.load(open(side)); plan = j.get("plan", j)+def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_path=None, render=True, quiet=False, lines=None):+    sidecar = None+    side = plan_path or os.path.splitext(step_path)[0] + ".mark-plan.json"+    if os.path.exists(side):+        sidecar = json.load(open(side))+    if plan is None and sidecar is not None:+        plan = sidecar.get("plan", sidecar)     s = ST.Step(step_path)     name = MG.mark_name(s)     if plan is None:@@ -359,7 +395,7 @@ def check(step_path, plan=None, footprint=None, outline=None, mpn=None, plan_pat             c = ap_["centre"] if ap_ else c         else:             c = MP._rot(c, "z", qp)-        o = check_outline(outline, plan, c, meta_p, ap_["bbox"] if ap_ else None, verts=MG.vertices(sp))+        o = check_outline(outline, plan, c, meta_p, ap_["bbox"] if ap_ else None, verts=MG.vertices(sp), expected_lines=mark_lines(plan, sidecar, lines))         add("outline", o["status"], "; ".join(o.get("hints", [])) or (o.get("hint") or f"outline name: same direction, {o.get('offsetPct')}% offset, {o.get('capHeightPx320')} px caps"), **{k: v for k, v in o.items() if k not in ("status", "hints", "hint")})     worst = "FAIL" if any(c["status"] == "FAIL" for c in checks) else "WARN" if any(c["status"] == "WARN" for c in checks) else "PASS"     base = os.path.splitext(step_path)[0]
tools/laser-etch/mark_etch.py+1−1
@@ -151,7 +151,7 @@ def main(argv=None):     rc = 0     if not a.no_check:         import mark_check as MC-        rep = MC.check(a.out, plan=plan, footprint=fp, outline=a.outline, mpn=a.mpn)+        rep = MC.check(a.out, plan=plan, footprint=fp, outline=a.outline, mpn=a.mpn, lines=info["lines"])         result["check"] = dict(status=rep["status"], report=rep["reportPath"], image=rep.get("image"))         rc = 0 if rep["status"] != "FAIL" else 1     print(json.dumps(result))
tools/laser-etch/mark_outline.py+30−3
@@ -1,6 +1,10 @@ #!/usr/bin/env python3 """Named chip outline that IS the model's mark: `adom-aiflow-etch outline <part.step> [--footprint fp.kicad_mod]-[--mpn MPN] [--etched <x-etched.step>] [--out-dir DIR] [--styles dark,blueprint,...|all]`.+[--mpn MPN] [--etched <x-etched.step>] [--lines "A|B"] [--plan x.mark-plan.json] [--out-dir DIR] [--styles dark,blueprint,...|all]`.++The name's line split is the mark's: --lines when given, else the etch's own record in <etched>.mark-plan.json+(or --plan), which keeps a forced `adom-aiflow-etch --lines` split, else the planner's default. The split is+recorded on the SVG (data-lines on the name group) so `adom-aiflow-etch check --outline` can compare it.  How (all on the shared service, no OpenCascade):   1. the part is stood up and turned to the planner's standard iso pose (pin 1 / pins 1 to 3 toward the@@ -14,7 +18,7 @@ How (all on the shared service, no OpenCascade): Every SVG is gated (mark_check.check_outline). A failing SVG is written as *.FAILED-GATE.svg with a loud warning and Hint lines, never silently. If the calibration cannot be trusted, the outline of the posed ETCHED STEP is used instead (the mark's own edges are the name)."""-import argparse, json, math, os, sys+import argparse, html, json, math, os, sys  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import step_text as ST@@ -99,7 +103,7 @@ def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None,                     q = iso(e_to_o(iso_inv(X, Y, bz)))                     return (c1["o"][0] + c1["s"] * q[0], c1["o"][1] + c1["s"] * q[1])                 extra = "".join('<polyline points="' + " ".join("%.2f,%.2f" % m(p) for p in t["pts"]) + '"' + t["attrs"].rstrip("/").rstrip() + "/>" for t in txt)-                out_svg = svg_body.replace("</svg>", '<g id="adom-mark-name">' + extra + "</g></svg>")+                out_svg = svg_body.replace("</svg>", '<g id="adom-mark-name" data-lines="' + html.escape("|".join(L), quote=True) + '">' + extra + "</g></svg>")         if out_svg is None:             if not etched:                 log(f"Hint: WARNING could not compose the {style} name (calibration); pass --etched <part-etched.step> to draw the outline from the etched model")@@ -120,12 +124,30 @@ def outlines(step, plan, etched=None, out_dir=".", styles=("dark",), stem=None,     return dict(mode=mode, plan=plan_path, outlines=written)  +def resolve_lines(lines_arg=None, etched=None, plan_path=None, log=lambda m: print(m, file=sys.stderr)):+    """(lines, source): --lines, else the etch's sidecar (--plan, or <etched>.mark-plan.json), else (None, None)."""+    side = plan_path or (os.path.splitext(etched)[0] + ".mark-plan.json" if etched else None)+    recorded = None+    if side and os.path.exists(side):+        recorded = MC.mark_lines(None, json.load(open(side))) or None+    if lines_arg:+        got = lines_arg.split("|")+        if recorded and recorded != got:+            log(f"Hint: WARNING --lines {' / '.join(got)} differs from the etch's own lines {' / '.join(recorded)} ({side}); the outline will not match the model's mark")+        return got, "--lines"+    if recorded:+        return recorded, side+    return None, None++ def main(argv=None):     ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)     ap.add_argument("step"); ap.add_argument("--etched"); ap.add_argument("--footprint"); ap.add_argument("--mpn")     ap.add_argument("--out-dir", default="."); ap.add_argument("--styles", default="dark", help="comma list, or 'all'")     ap.add_argument("--face", default="auto", choices=["auto", "top", "side"]); ap.add_argument("--up", default="auto", choices=["auto", "z", "y"])     ap.add_argument("--stem")+    ap.add_argument("--lines", help="the name's text lines, '|' separated, as given to the etch (default: the etch's own <etched>.mark-plan.json, else the planner's)")+    ap.add_argument("--plan", help="the etch's plan/check sidecar (<out>.mark-plan.json) to read the line split from (default: beside --etched)")     ap.add_argument("--write-plain", action="store_true", help="also write the plain outlines (<stem>-3d-outline-<style>.svg) in the same pose")     ap.add_argument("--posed-out", help="also write the STEP in the outline's standard pose (the etched one when given), for a shaded iso render with the same view")     a = ap.parse_args(argv)@@ -137,6 +159,11 @@ def main(argv=None):             fp = c[0]     mpn = a.mpn or os.path.splitext(os.path.basename(a.step))[0].upper()     plan = MP.plan(a.step, fp, mpn, a.face, a.up)+    lines, src = resolve_lines(a.lines, a.etched, a.plan)+    if lines:+        if lines != plan["text"]["lines"]:+            print(f"Hint: name lines {' / '.join(lines)} from {src} (the planner's default is {' / '.join(plan['text']['lines'])})", file=sys.stderr)+        plan["text"]["lines"] = lines     r = outlines(a.step, plan, a.etched, a.out_dir, None if a.styles == "all" else tuple(a.styles.split(",")), a.stem, write_plain=a.write_plain)     if a.posed_out:         posed(a.etched or a.step, plan, [("z", plan["outline"]["poseRotZDeg"] // 90)]).write(a.posed_out)
tools/laser-etch/test_outline_lines.pyadded+71
@@ -0,0 +1,71 @@+"""Issue #73, offline (no service): the outline takes the mark's line split (--lines, else the etch's own+<etched>.mark-plan.json, which keeps a forced --lines split), records it on the SVG, and the gate fails an+outline whose text lines differ from the model's mark.+Run: python3 -m unittest -v test_outline_lines   (from tools/laser-etch)"""+import json, os, sys, tempfile, unittest++HERE = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, HERE)+import mark_check as MC+import mark_outline as MO++PLAN = {"text": {"lines": ["ABC-D", "1E-04B"]}}+SIDECAR = {"plan": PLAN, "etch": {"lines": ["ABC-D1E", "04B"]}}+++def svg(lines=None):+    attr = f' data-lines="{lines}"' if lines is not None else ""+    return f'<svg viewBox="0 0 320 200"><polyline points="0,0 10,10"/><g id="adom-mark-name"{attr}><polyline points="1,1 2,2"/></g></svg>'+++class OutlineLines(unittest.TestCase):+    def setUp(self):+        self.tmp = tempfile.TemporaryDirectory()+        self.etched = os.path.join(self.tmp.name, "part-etched.step")+        open(self.etched, "w").write("ISO-10303-21;\n")++    def tearDown(self):+        self.tmp.cleanup()++    def write_sidecar(self, v=SIDECAR):+        json.dump(v, open(os.path.splitext(self.etched)[0] + ".mark-plan.json", "w"))++    def test_forced_split_is_read_from_the_etch_sidecar(self):+        self.write_sidecar()+        self.assertEqual(MO.resolve_lines(None, self.etched), (["ABC-D1E", "04B"], os.path.splitext(self.etched)[0] + ".mark-plan.json"))++    def test_lines_flag_wins_and_warns_when_it_differs(self):+        self.write_sidecar()+        logs = []+        got, src = MO.resolve_lines("ABC|D1E-04B", self.etched, log=logs.append)+        self.assertEqual((got, src), (["ABC", "D1E-04B"], "--lines"))+        self.assertTrue(logs and "differs from the etch's own lines" in logs[0])++    def test_explicit_plan_path_and_no_record(self):+        side = os.path.join(self.tmp.name, "other.mark-plan.json")+        json.dump(SIDECAR, open(side, "w"))+        self.assertEqual(MO.resolve_lines(None, None, side)[0], ["ABC-D1E", "04B"])+        self.assertEqual(MO.resolve_lines(None, self.etched), (None, None))+        # an older sidecar with no etch record falls back to its plan's lines+        self.write_sidecar({"plan": PLAN})+        self.assertEqual(MO.resolve_lines(None, self.etched)[0], ["ABC-D", "1E-04B"])++    def test_mark_lines_prefers_the_etch_record(self):+        self.assertEqual(MC.mark_lines(PLAN, SIDECAR), ["ABC-D1E", "04B"])+        self.assertEqual(MC.mark_lines(PLAN, {"plan": PLAN}), ["ABC-D", "1E-04B"])+        self.assertEqual(MC.mark_lines(PLAN, SIDECAR, ["X", "Y"]), ["X", "Y"])++    def test_gate_fails_an_outline_with_a_different_split(self):+        st, hint = MC.lines_check(svg("ABC-D|1E-04B"), ["ABC-D1E", "04B"])+        self.assertEqual(st, "FAIL")+        self.assertIn("ABC-D / 1E-04B differ from the model's mark ABC-D1E / 04B", hint)+        self.assertEqual(MC.lines_check(svg("ABC-D1E|04B"), ["ABC-D1E", "04B"]), ("PASS", None))+        self.assertEqual(MC.lines_check(svg(None), ["ABC-D1E", "04B"])[0], "WARN")++    def test_record_survives_escaping(self):+        import html+        self.assertEqual(MC.svg_lines(svg(html.escape('A&B"|C<D', quote=True))), ['A&B"', "C<D"])+++if __name__ == "__main__":+    unittest.main()
tools/project-page.py+408−37
@@ -12,10 +12,27 @@ The parts library comes from Adom Cloud Libraries (`adom-wiki library`): one lib the parts added by their wiki pages, exported as a KiCad bundle, then repackaged in the PCM layout (metadata.json, symbols/<Lib>.kicad_sym, footprints/<Lib>.pretty, 3dmodels/<Lib>.3dshapes) with the footprints' 3D paths pointed at ${KICAD10_3RD_PARTY}/3dmodels/<pkgid_>/<Lib>.3dshapes/, which is-where PCM puts the files (adom/kicad-pcm documents the layout). Prints one JSON object."""-import hashlib, json, os, re, shutil, subprocess, sys, time, zipfile+where PCM puts the files (adom/kicad-pcm documents the layout). Prints one JSON object.++The library is made once per board: its lib_ id is kept in the run (<run>/project-page/cloud-library.json and+the config's libRef) and reused, so a second run never makes a second library of the same name. Adds that+the wiki rate-limits (HTTP 429, retry_after) are retried with backoff; the export must then hold every part+(symbols, footprints and models counted against the BOM's distinct parts), or the step is an ERROR. Parts+from the board's own project library (row "projectLib", `parts --project-lib`) are packaged beside them.+The symbol file gets one current header (SYM_VERSION) and is parse-checked (structurally, and with+`kicad-cli sym upgrade` when ADOM_AIFLOW_KICAD_CLI or kicad-cli is there) before anything is published."""+import hashlib, json, os, re, shutil, subprocess, sys, tempfile, time, zipfile  API = "https://wiki.adom.inc/api/pages"+# KiCad 9's symbol library format (KiCad 10 reads it; KiCad 10 itself writes 20251024). The Cloud Library+# export wrote 20211014 over symbols in newer syntax, which KiCad 10 refuses as a whole library (issue #64).+SYM_VERSION = 20241209+KICAD10_SYM_VERSION = 20251024+ADD_CHUNK = 10+++def fail(msg, **extra):+    raise SystemExit(json.dumps(dict({"ok": False, "error": msg}, **extra)))   def sh(args, check=True):@@ -48,38 +65,354 @@ def pkg_id(owner, slug):     return "inc.adom.wiki." + re.sub(r"[^a-z0-9.-]", "-", f"{owner}.{slug}".lower())  +def payload(v, key):+    """adom-wiki wraps replies in {status, data} envelopes, sometimes twice: the first `key` found going down."""+    while isinstance(v, dict):+        if key in v:+            return v[key]+        v = v.get("data")+    return None+++def retry_after(text):+    """Seconds to wait when adom-wiki says HTTP 429 / RATE_LIMITED, else None."""+    if not re.search(r"\b429\b|RATE_LIMITED|rate.?limit", text, re.I):+        return None+    m = re.search(r"retry[_ -]?after\W{0,4}(\d+(?:\.\d+)?)", text, re.I)+    return float(m.group(1)) if m else -1.0+++def run_retrying(args, attempts=None, log=None):+    """Run an adom-wiki call, honouring 429 retry_after with bounded retries and exponential backoff.+    Returns (ok, output, waits)."""+    attempts = attempts or int(os.environ.get("ADOM_AIFLOW_WIKI_ATTEMPTS", "8"))+    cap = float(os.environ.get("ADOM_AIFLOW_WIKI_MAX_WAIT", "300"))+    base = float(os.environ.get("ADOM_AIFLOW_WIKI_BACKOFF", "5"))+    waits = []+    out = ""+    for i in range(attempts):+        p = subprocess.run(args, capture_output=True, text=True, stdin=subprocess.DEVNULL)+        out = (p.stdout or "") + (p.stderr or "")+        ra = retry_after(out)+        if p.returncode == 0 and ra is None:+            return True, out, waits+        if ra is None or i == attempts - 1:+            return False, out, waits+        # the server's retry_after, never less than the backoff (base, 2 base, 4 base ...), at most cap+        wait = min(cap, max(ra if ra >= 0 else 0.0, base * (2 ** i)))+        waits.append(wait)+        time.sleep(wait)+    return False, out, waits++ def page_exists(ref):     return subprocess.run(["adom-wiki", "repo", "show", ref, "page.json"], capture_output=True, stdin=subprocess.DEVNULL).returncode == 0  -def cloud_library(name, parts, work):-    """Create (or reuse) the board's Cloud Library, add every part page, export the KiCad bundle."""-    libs = wjson(["adom-wiki", "library", "list"])-    items = (libs.get("data") or {}).get("libraries") or libs.get("libraries") or []-    lib = next((l for l in items if l.get("name") == name), None)+def libraries():+    return payload(wjson(["adom-wiki", "library", "list"]), "libraries") or []+++def lib_ref_of(lib):+    return (lib or {}).get("ref") or (lib or {}).get("id")+++def board_library(name, recorded, work):+    """The board's one Cloud Library: the lib_ id the run recorded, else the one library of that name,+    else a new one (recorded at once, so a failed run never leads to a second library)."""+    rec_path = os.path.join(work, "cloud-library.json")+    if not recorded and os.path.isfile(rec_path):+        try:+            recorded = json.load(open(rec_path)).get("ref")+        except Exception:+            recorded = None+    items = libraries()+    notes = []+    lib = next((l for l in items if recorded and lib_ref_of(l) == recorded), None)+    if lib is None:+        same = [l for l in items if l.get("name") == name]+        if recorded:+            notes.append(f"the recorded library {recorded} is gone; " + ("using the one named " + name if same else "making a new one"))+        if len(same) > 1:+            # duplicates from an earlier release: keep the fullest (then the oldest), never add another+            same.sort(key=lambda l: (-(l.get("item_count") or 0), l.get("created_at") or ""))+            notes.append(f"{len(same)} libraries are named {name}: using {lib_ref_of(same[0])} (the fullest); delete the others with `adom-wiki library delete <lib_id> --confirm`")+        lib = same[0] if same else None     if lib is None:         # creating a library makes it the user's active one: put their own active library back after-        was_active = next((l.get("ref") for l in items if l.get("active")), None)-        sh(["adom-wiki", "library", "create", name])-        if was_active: sh(["adom-wiki", "library", "activate", was_active], check=False)-        libs = wjson(["adom-wiki", "library", "list"])-        items = (libs.get("data") or {}).get("libraries") or libs.get("libraries") or []-        lib = next((l for l in items if l.get("name") == name), None)-    ref = (lib or {}).get("ref") or (lib or {}).get("id") or name+        was_active = next((lib_ref_of(l) for l in items if l.get("active")), None)+        created = wjson(["adom-wiki", "library", "create", name])+        ref = payload(created, "ref") or payload(created, "id")+        if was_active:+            sh(["adom-wiki", "library", "activate", was_active], check=False)+        if not ref:+            lib = next((l for l in libraries() if l.get("name") == name), None)+            ref = lib_ref_of(lib)+        if not ref:+            fail(f"adom-wiki library create {name} gave no lib_ id")+        lib = {"ref": ref, "name": name}+    ref = lib_ref_of(lib)+    json.dump({"ref": ref, "name": name}, open(rec_path, "w"))+    return ref, notes+++def cloud_library(name, parts, work, recorded=None):+    """Reuse (or create once) the board's Cloud Library, add every part page with 429 retries, export the KiCad bundle."""+    ref, notes = board_library(name, recorded, work)+    pages = list(dict.fromkeys(p["wiki"] for p in parts if p.get("wiki")))+    failed, waits = [], []+    for i in range(0, len(pages), ADD_CHUNK):+        chunk = pages[i:i + ADD_CHUNK]+        ok, out, w = run_retrying(["adom-wiki", "library", "add", ref, *chunk, "--json"])+        waits += w+        if not ok:+            # one page at a time, so the report names the pages that really failed+            for pg in chunk:+                ok1, out1, w1 = run_retrying(["adom-wiki", "library", "add", ref, pg, "--json"])+                waits += w1+                if not ok1:+                    failed.append({"page": pg, "error": out1.strip()[-300:]})+    if failed:+        fail(f"{len(failed)} of {len(pages)} part pages could not be added to the Cloud Library {ref} " ++             f"(after retries{' honouring retry_after' if waits else ''}): " + ", ".join(f["page"] for f in failed),+             cloudLibrary=ref, failedAdds=failed, notes=notes)+    bundle = os.path.join(work, "cloud-library.zip")+    if os.path.exists(bundle):+        os.remove(bundle)+    ok, out, w = run_retrying(["adom-wiki", "library", "export", ref, "-o", bundle, "--json"])+    waits += w+    try:+        report = json.loads(out[out.index("{"):]) if "{" in out else {}+    except Exception:+        report = {}+    if not ok or not os.path.isfile(bundle):+        fail("library export wrote no bundle: " + out.strip()[-400:], cloudLibrary=ref)+    return ref, bundle, report, {"addedPages": len(pages), "rateLimitWaits": waits, "notes": notes}+++# --- S-expressions: enough to count, extract and parse-check KiCad library files ---++def sx_tokens(text):+    i, n = 0, len(text)+    while i < n:+        c = text[i]+        if c in " \t\r\n":+            i += 1+        elif c in "()":+            yield c, i, i + 1; i += 1+        elif c == '"':+            j = i + 1+            while j < n and text[j] != '"':+                j += 2 if text[j] == "\\" else 1+            if j >= n:+                raise ValueError(f"unterminated string at offset {i}")+            yield "s", i, j + 1; i = j + 1+        else:+            j = i+            while j < n and text[j] not in ' \t\r\n()"':+                j += 1+            yield "a", i, j; i = j+++def sx_parse(text):+    """Nested lists [tag, ...] of atoms (strings unquoted); each list also records (start, end) in .span."""+    class L(list):+        span = (0, 0)+    stack, root = [], None+    for kind, a, b in sx_tokens(text):+        if kind == "(":+            l = L(); l.span = (a, None); stack.append(l)+        elif kind == ")":+            if not stack:+                raise ValueError(f"unbalanced ')' at offset {a}")+            l = stack.pop(); l.span = (l.span[0], b)+            if stack:+                stack[-1].append(l)+            elif root is None:+                root = l+            else:+                raise ValueError("more than one top-level expression")+        else:+            v = text[a + 1:b - 1].replace('\\"', '"') if kind == "s" else text[a:b]+            if not stack:+                raise ValueError(f"atom outside any list at offset {a}")+            stack[-1].append(v)+    if stack:+        raise ValueError(f"{len(stack)} unclosed '(' (truncated file?)")+    if root is None:+        raise ValueError("empty file")+    return root+++def sym_header_version(text):+    m = re.search(r"\(kicad_symbol_lib\s*\(version\s+(\d+)\)", text)+    return int(m.group(1)) if m else None+++def normalise_sym_header(text, version):+    """One current header: the export's (version 20211014) under KiCad 9 syntax is refused by KiCad 10."""+    return re.sub(r"(\(kicad_symbol_lib\s*\(version\s+)(\d+)(\))", lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1)+++def top_symbols(text):+    """{name: (start, end)} of the library's top-level symbols."""+    root = sx_parse(text)+    return {l[1]: l.span for l in root[1:] if isinstance(l, list) and l and l[0] == "symbol" and len(l) > 1}+++def structural_check(sym_path, fp_dir):+    """The packaged library parses: one kicad_symbol_lib with a version, named symbols (no duplicates), and+    every footprint one footprint expression. Returns a list of problems."""+    problems = []+    try:+        t = open(sym_path).read()+        root = sx_parse(t)+        if root[:1] != ["kicad_symbol_lib"]:+            problems.append("the symbol file is not a kicad_symbol_lib")+        ver = next((l[1] for l in root[1:] if isinstance(l, list) and l[:1] == ["version"] and len(l) > 1), None)+        if not (ver and ver.isdigit()):+            problems.append("the symbol library has no version")+        names = []+        for l in root[1:]:+            if not isinstance(l, list):+                problems.append(f"stray atom {l!r} at the top of the symbol library")+            elif l[:1] == ["symbol"]:+                if len(l) < 2 or isinstance(l[1], list):+                    problems.append("a symbol without a name")+                else:+                    names.append(l[1])+            elif l[:1] not in (["version"], ["generator"], ["generator_version"]):+                problems.append(f"unexpected top-level ({l[0] if l else ''}) in the symbol library")+        dups = sorted({n for n in names if names.count(n) > 1})+        if dups:+            problems.append("duplicate symbols: " + ", ".join(dups))+    except Exception as e:+        problems.append(f"symbol library does not parse: {e}")+    for f in sorted(os.listdir(fp_dir)) if os.path.isdir(fp_dir) else []:+        try:+            r = sx_parse(open(os.path.join(fp_dir, f)).read())+            if r[:1] not in (["footprint"], ["module"]):+                problems.append(f"{f} is not a footprint")+        except Exception as e:+            problems.append(f"{f} does not parse: {e}")+    return problems+++def kicad_check(sym_path):+    """`kicad-cli sym upgrade` loads the whole library the way KiCad does. (status, detail): status is+    "pass", "fail" or "unavailable" (no kicad-cli, or it could not run: the structural parse stands)."""+    kc = os.environ.get("ADOM_AIFLOW_KICAD_CLI") or shutil.which("kicad-cli")+    if not kc:+        return "unavailable", "no kicad-cli (set ADOM_AIFLOW_KICAD_CLI)"+    out = os.path.join(tempfile.mkdtemp(prefix="aiflow-symcheck-"), "check.kicad_sym")+    try:+        p = subprocess.run([kc, "sym", "upgrade", "--force", "-o", out, sym_path], capture_output=True, text=True, stdin=subprocess.DEVNULL,+                           timeout=int(os.environ.get("ADOM_AIFLOW_KICAD_CLI_TIMEOUT", "180")))+    except (OSError, subprocess.TimeoutExpired) as e:+        return "unavailable", f"{kc}: {e}"+    text = ((p.stdout or "") + (p.stderr or "")).strip()+    if p.returncode == 0 and os.path.isfile(out) and not re.search(r"unable to load|failed to load|error", text, re.I):+        return "pass", text[-200:]+    if re.search(r"load|pars|library|symbol", text, re.I):+        return "fail", text[-400:]+    return "unavailable", f"{kc} exit {p.returncode}: {text[-200:]}"+++def norm(s):+    return re.sub(r"[^A-Z0-9]", "", (s or "").upper())+++def merge_project_parts(parts, sym_text, fp_dir, m_dir, lib, pkgid):+    """Add the project-library parts (issue #66): each symbol (with its parent when it extends one), its+    footprint with the model path pointed where PCM installs it, and its STEP model. Returns the new text."""+    blocks, have = [], set(top_symbols(sym_text)) if sym_text else set()     for p in parts:+        pl = p["projectLib"]+        src = open(pl["symbolFile"]).read()+        spans = top_symbols(src)+        want = [pl["symbol"]]+        root = sx_parse(src)+        for l in root[1:]:+            if isinstance(l, list) and l[:2] == ["symbol", pl["symbol"]]:+                ext = next((x[1] for x in l if isinstance(x, list) and x[:1] == ["extends"] and len(x) > 1), None)+                if ext:+                    want.insert(0, ext)+        for name in want:+            if name in have:+                if name == pl["symbol"]:+                    fail(f"project library symbol {name} ({p['mpn']}) has the same name as a symbol already in the library")+                continue+            a, b = spans[name]+            block = src[a:b]+            if name == pl["symbol"]:+                block = re.sub(r'(\(property\s+"Footprint"\s+")[^"]*(")', lambda m: f'{m.group(1)}{lib}:{pl["footprint"]}{m.group(2)}', block, count=1)+                if '"Footprint"' not in block:+                    block = block.replace(f'(symbol "{name}"', f'(symbol "{name}" (property "Footprint" "{lib}:{pl["footprint"]}" (at 0 0 0) (effects (hide yes)))', 1)+            blocks.append(block); have.add(name)+        model = os.path.basename(pl["model"])+        shutil.copy(pl["model"], os.path.join(m_dir, model))+        t = open(pl["footprintFile"]).read()+        target = f'${{KICAD10_3RD_PARTY}}/3dmodels/{pkgid.replace(".", "_")}/{lib}.3dshapes/{model}'+        if re.search(r"\(model\s", t):+            t = re.sub(r'(\(model\s+)("[^"]*"|[^\s()]+)', lambda m: f'{m.group(1)}"{target}"', t, count=1)+        else:+            t = t.rstrip().rstrip(")") + f'\n  (model "{target}")\n)\n'+        open(os.path.join(fp_dir, pl["footprint"] + ".kicad_mod"), "w").write(t)+    if not blocks:+        return sym_text+    if not sym_text:+        sym_text = f'(kicad_symbol_lib (version {SYM_VERSION}) (generator "adom_aiflow")\n)\n'+    i = sym_text.rstrip().rfind(")")+    return sym_text[:i].rstrip() + "\n  " + "\n  ".join(blocks) + "\n)\n"+++def completeness(cfg_parts, report, sym_path, fp_dir, m_dir):+    """Every distinct BOM part in the packaged library: the export's symbol, footprint and model counts+    against the BOM's distinct parts, every symbol's footprint present, every part named. Returns+    (counts, problems)."""+    wiki = list(dict.fromkeys(p["wiki"] for p in cfg_parts if p.get("wiki")))+    proj = [p for p in cfg_parts if p.get("projectLib") and not p.get("wiki")]+    mpn_of = {}+    for p in cfg_parts:         if p.get("wiki"):-            sh(["adom-wiki", "library", "add", ref, p["wiki"]], check=False)-    bundle = os.path.join(work, "cloud-library.zip")-    report = wjson(["adom-wiki", "library", "export", ref, "-o", bundle])-    if not os.path.isfile(bundle):-        raise SystemExit(json.dumps({"ok": False, "error": "library export wrote no bundle", "report": report}))-    return ref, bundle, report+            mpn_of.setdefault(p["wiki"], p["mpn"])+    sym_text = open(sym_path).read() if os.path.isfile(sym_path) else ""+    syms = top_symbols(sym_text) if sym_text else {}+    fps = {os.path.splitext(f)[0] for f in os.listdir(fp_dir) if f.endswith(".kicad_mod")} if os.path.isdir(fp_dir) else set()+    models = [f for f in os.listdir(m_dir) if f.lower().endswith((".step", ".stp", ".wrl"))] if os.path.isdir(m_dir) else []+    distinct = len(wiki) + len({p["mpn"] for p in proj})+    rep = payload(report, "report") or report or {}+    rep_parts = payload(rep, "parts") or []+    skipped = payload(rep, "skipped") or []+    exported = {norm(x.get("mpn")) for x in rep_parts if isinstance(x, dict)} | {norm(x.get("name")) for x in rep_parts if isinstance(x, dict)} | {norm(n) for n in syms}+    problems = []+    missing = [mpn_of[w] for w in wiki if norm(mpn_of[w]) not in exported]+    missing += [p["mpn"] for p in proj if p["projectLib"]["symbol"] not in syms]+    if missing:+        problems.append(f"{len(missing)} of {distinct} parts are not in the exported library: " + ", ".join(sorted(set(missing))))+    if skipped:+        problems.append("the export skipped: " + "; ".join(f"{x.get('ref') or x.get('name') or x} ({x.get('reason', '')})" if isinstance(x, dict) else str(x) for x in skipped))+    counts = {"distinctParts": distinct, "symbols": len(syms), "footprints": len(fps), "models": len(models)}+    for k in ("symbols", "footprints", "models"):+        if counts[k] < distinct:+            problems.append(f"{counts[k]} {k} for {distinct} distinct parts")+    no_fp = []+    for name, (a, b) in syms.items():+        m = re.search(r'\(property\s+"Footprint"\s+"([^"]*)"', sym_text[a:b])+        fp = (m.group(1) if m else "").split(":")[-1]+        if fp and fp not in fps:+            no_fp.append(f"{name} -> {fp}")+    if no_fp:+        problems.append("symbols whose footprint is not in the library: " + ", ".join(no_fp[:12]))+    return counts, problems  -def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work):-    """Repackage the Cloud Library bundle in the PCM layout; returns (zip path, install size)."""+def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts=()):+    """Repackage the Cloud Library bundle (and the project-library parts) in the PCM layout, normalise and+    parse-check the symbol library; returns (zip path, install size, metadata, check)."""     src = os.path.join(work, "bundle"); shutil.rmtree(src, ignore_errors=True); os.makedirs(src)-    with zipfile.ZipFile(bundle) as z: z.extractall(src)+    if bundle:+        with zipfile.ZipFile(bundle) as z: z.extractall(src)     root = next((os.path.join(src, d) for d in os.listdir(src) if os.path.isdir(os.path.join(src, d))), src)     stage = os.path.join(work, "pcm-stage"); shutil.rmtree(stage, ignore_errors=True)     sym_dir, fp_dir, m_dir = (os.path.join(stage, "symbols"), os.path.join(stage, "footprints", f"{lib}.pretty"), os.path.join(stage, "3dmodels", f"{lib}.3dshapes"))@@ -95,17 +428,34 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work):                 # bundle-relative 3D paths -> where PCM installs them                 t = re.sub(r'\(model\s+"?3dmodels/([^")\s]+)"?', lambda mm: f'(model "${{KICAD10_3RD_PARTY}}/3dmodels/{pkgid.replace(".", "_")}/{lib}.3dshapes/{mm.group(1)}"', t)                 open(os.path.join(fp_dir, m), "w").write(t)-    # the symbols point at the footprints by library nickname: PCM installs this library as PCM_<lib>     sp = os.path.join(sym_dir, f"{lib}.kicad_sym")-    if os.path.isfile(sp):-        t = open(sp).read()+    t = open(sp).read() if os.path.isfile(sp) else ""+    proj = [p for p in parts if p.get("projectLib") and not p.get("wiki")]+    versions = [sym_header_version(t) or 0] + [sym_header_version(open(p["projectLib"]["symbolFile"]).read()) or 0 for p in proj]+    t = merge_project_parts(proj, t, fp_dir, m_dir, lib, pkgid)+    if t:+        # one header for every symbol: the newest syntax any of them uses, at least KiCad 9's+        header = max([SYM_VERSION] + versions)+        t = normalise_sym_header(t, header)+        # the symbols point at the footprints by library nickname: PCM installs this library as PCM_<lib>         t = re.sub(r'(\(property\s+"Footprint"\s+")([^":]*):', lambda mm: f'{mm.group(1)}PCM_{lib}:', t)         open(sp, "w").write(t)-    meta = {"$schema": "https://go.kicad.org/pcm/schemas/v1", "name": title, "description": brief[:150],-            "description_full": f"{brief}\n\nEvery part of this board, from its Adom wiki component page: symbol, footprint and STEP model. Project page: {page_url}",+    else:+        header = SYM_VERSION+    problems = structural_check(sp, fp_dir) if os.path.isfile(sp) else ["no symbol library"]+    kstatus, kdetail = kicad_check(sp) if not problems else ("skipped", "the structural parse failed")+    if kstatus == "fail":+        problems.append("KiCad cannot load the packaged symbol library: " + kdetail)+    check = {"symVersion": header, "structural": "pass" if not problems else "fail", "kicadCli": kstatus, "kicadCliDetail": kdetail, "problems": problems}+    if problems:+        fail("the packaged KiCad library does not load: " + "; ".join(problems), libraryCheck=check)+    has_wiki = any(p.get("wiki") for p in parts)+    meta = {"$schema": "https://go.kicad.org/pcm/schemas/v1", "name": title, "description": short(brief)[:150],+            "description_full": f"{brief}\n\nEvery part of this board" + (", from its Adom wiki component page" if has_wiki and not proj else (" (from the Adom wiki and the board's project library)" if has_wiki else ", from the board's project library")) ++                                f": symbol, footprint and STEP model. Project page: {page_url}",             "identifier": pkgid, "type": "library", "author": {"name": "Adom wiki", "contact": {"web": page_url}},             "license": "MIT", "resources": {"homepage": page_url},-            "versions": [{"version": version, "status": "stable", "kicad_version": "9.0"}]}+            "versions": [{"version": version, "status": "stable", "kicad_version": "10.0" if header >= KICAD10_SYM_VERSION else "9.0"}]}     json.dump(meta, open(os.path.join(stage, "metadata.json"), "w"), indent=1)     out = os.path.join(work, "pcm", f"{lib}-pcm-{version}.zip"); os.makedirs(os.path.dirname(out), exist_ok=True)     size = 0@@ -113,7 +463,19 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work):         for d, _, fs in os.walk(stage):             for f in sorted(fs):                 p = os.path.join(d, f); z.write(p, os.path.relpath(p, stage)); size += os.path.getsize(p)-    return out, size, meta+    check["paths"] = {"symbols": sp, "footprints": fp_dir, "models": m_dir}+    return out, size, meta, check+++def short(brief):+    """The first line of prose in a brief (a markdown file's heading and blank lines skipped)."""+    for line in (brief or "").splitlines():+        if line.lstrip().startswith("#"):+            continue+        line = line.strip().lstrip(">*- ").strip()+        if line:+            return line+    return brief or ""   def readme(cfg, lib, pkgid, repo_url):@@ -123,13 +485,13 @@ def readme(cfg, lib, pkgid, repo_url):         m["refs"] = m["refs"] + list(p.get("refs") or [])     for p in merged.values():         refs = ", ".join(p.get("refs") or [])-        wiki = f"[{p['wiki']}](https://wiki.adom.inc/{p['wiki']})" if p.get("wiki") else "not on the wiki yet"+        wiki = f"[{p['wiki']}](https://wiki.adom.inc/{p['wiki']})" if p.get("wiki") else ("project library (no public page yet)" if p.get("projectLib") else "not on the wiki yet")         rows.append(f"| {refs} | {len(p.get('refs') or [])} | {p.get('value', '')} | {p['mpn']} | {p.get('manufacturer', '')} | {wiki} |")     return f"""# {cfg['title']} -{cfg.get('summary') or cfg['brief']}+{cfg.get('summary') or re.sub(r"\A\s*#[^\n]*\n+", "", cfg['brief'])} -**Status: revision 1.** The parts are chosen and every one has its Adom wiki page; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow).+**Status: revision 1.** The parts are chosen and every one has its Adom wiki page{" or comes from the board's own project library" if any(p.get("projectLib") and not p.get("wiki") for p in cfg["parts"]) else ""}; the schematic and the board come next, built with [adom-aiflow](https://wiki.adom.inc/adom/adom-aiflow).  ## Components @@ -171,8 +533,16 @@ def main():         sh(args); created = True     lib, pkgid = lib_name(slug), pkg_id(owner, slug)     version = cfg.get("version") or "1.0.0"-    lib_ref, bundle, report = cloud_library(f"{owner}/{slug}", cfg["parts"], work)-    zpath, isize, meta = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work)+    if any(p.get("wiki") for p in cfg["parts"]):+        lib_ref, bundle, report, adds = cloud_library(f"{owner}/{slug}", cfg["parts"], work, cfg.get("libRef"))+    else:   # every part from the project library: no Cloud Library needed+        lib_ref, bundle, report, adds = None, None, {}, {"addedPages": 0, "rateLimitWaits": [], "notes": []}+    zpath, isize, meta, check = pcm_package(bundle, lib, pkgid, version, cfg["title"], cfg["brief"], page_url, work, cfg["parts"])+    counts, gaps = completeness(cfg["parts"], report, check["paths"]["symbols"], check["paths"]["footprints"], check["paths"]["models"])+    if gaps:+        # never publish a library that is short of the board's parts+        fail("the KiCad library is incomplete: " + "; ".join(gaps) + (f" (Cloud Library {lib_ref})" if lib_ref else ""),+             cloudLibrary=lib_ref, counts=counts, libraryCheck=check)     zname = os.path.basename(zpath)     files_url = f"{API}/{owner}/{slug}/files"     v = dict(meta["versions"][0]); v.update({"download_url": f"{files_url}/pcm/{zname}", "download_sha256": sha(zpath), "download_size": os.path.getsize(zpath), "install_size": isize})@@ -197,8 +567,9 @@ def main():         if p.returncode != 0 or not pushed:             raise SystemExit(json.dumps({"ok": False, "error": "push failed: " + (p.stderr or p.stdout)[-600:]}))     print(json.dumps({"ok": True, "page": cfg["page"], "url": page_url, "created": created, "cloudLibrary": lib_ref, "libName": lib,-                      "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]),-                      "exportReport": (report.get("data") or report).get("report", report.get("data")) if isinstance(report, dict) else None,+                      "identifier": pkgid, "repositoryUrl": repo_url, "zip": zpath, "parts": len(cfg["parts"]), "counts": counts, "libraryCheck": check,+                      "adds": adds, "projectLibParts": [p["mpn"] for p in cfg["parts"] if p.get("projectLib") and not p.get("wiki")],+                      "exportReport": payload(report, "report"),                       "readme": os.path.join(work, "README.md"), "commit": pushed}))  
tools/test-kicad-cli-remote.pyadded+144
@@ -0,0 +1,144 @@+#!/usr/bin/env python3+"""Offline test of tools/kicad-cli-remote against a stand-in adom-bridge (no desktop, no KiCad).++The mock bridge plays a Linux desktop (run_script) or a Windows one (shell_execute with cmd lines),+copies files for send_files / pull_file, and resolves a relative pull_file saveTo against a different+folder than the caller's, the way the real bridge does (#61, #72). A stand-in kicad-cli writes what+the native one would. Checks: every verb on both OSes (the STEP export with its model files and its+native output passed through), relative outputs come back, `sch erc` pulls its+report, remote job folders are removed, and the Linux job folder sits under the home folder."""+import json, os, shutil, stat, subprocess, sys, tempfile++HERE = os.path.dirname(os.path.abspath(__file__))+WRAPPER = os.path.join(HERE, "kicad-cli-remote")++MOCK = r'''#!/usr/bin/env python3+import base64, json, os, re, shutil, subprocess, sys+a = sys.argv[1:]+if "--target" in a:+    i = a.index("--target"); del a[i:i + 2]+if "--ai-thread" in a:+    i = a.index("--ai-thread"); del a[i:i + 2]+verb = a[0]+args = json.loads(a[1]) if len(a) > 1 and a[1].startswith("{") else {}+OS = os.environ["MOCK_OS"]; HOME = os.environ["MOCK_HOME"]; CLI = os.environ["MOCK_CLI"]+with open(os.environ["MOCK_LOG"], "a") as f: f.write(json.dumps([verb, args]) + "\n")+def out(d): print(json.dumps(d)); sys.exit(0)+def p(x): return x.replace("\\", "/")+if verb == "targets":+    out({"targets": [{"name": "box", "os": "Linux" if OS == "linux" else "Windows"}]})+if verb == "kicad_status":+    out({"install": {"kicadCli": CLI if OS == "linux" else "C:" + CLI.replace("/", "\\")}, "platform": {"os": OS}})+if verb == "send_files":+    d = p(args["dest"]); os.makedirs(d, exist_ok=True)+    for f in args["filePaths"]: shutil.copy(f, d)+    out({"success": True})+if verb == "pull_file":+    save = args["saveTo"]+    if not os.path.isabs(save): save = os.path.join(os.environ["MOCK_ELSEWHERE"], save)+    os.makedirs(save, exist_ok=True)+    for f in args["filePaths"]:+        if os.path.isfile(p(f)): shutil.copy(p(f), save)+    out({"success": True})+if verb == "run_script":+    assert OS == "linux"+    s = base64.b64decode(args["scriptB64"]).decode()+    r = subprocess.run(["bash", "-c", s], capture_output=True, text=True, env=dict(os.environ, HOME=HOME))+    out({"success": r.returncode == 0, "stdout": r.stdout, "stderr": r.stderr, "exitCode": r.returncode})+if verb == "shell_execute":+    assert OS == "windows"+    c = args["command"]+    if c == "echo %TEMP%": out({"stdout": HOME.replace("/", "\\") + "\\Temp\r\n"})+    m = re.match(r'dir /b "(.*)"$', c)+    if m: out({"stdout": "\r\n".join(sorted(os.listdir(p(m.group(1))))) + "\r\n"})+    m = re.match(r'rmdir /s /q "(.*)"$', c)+    if m: shutil.rmtree(p(m.group(1)), ignore_errors=True); out({"stdout": ""})+    m = re.match(r'mkdir "(.*?)" 2>nul & (.*)$', c)+    if m: os.makedirs(p(m.group(1)), exist_ok=True); c = m.group(2)+    toks = re.findall(r'"([^"]*)"|(\S+)', c)+    argv = [p(q or r) for q, r in toks]+    assert argv[0].startswith("C:"), argv+    argv[0] = argv[0][2:]+    argv = [x[2:] if x.startswith("C:") else x for x in argv]+    r = subprocess.run(argv, capture_output=True, text=True)+    out({"stdout": r.stdout, "stderr": r.stderr, "exitCode": r.returncode})+out({"success": False, "error": "unknown verb " + verb})+'''++FAKE_CLI = r'''#!/bin/sh+out=""; prev=""; last=""+for a in "$@"; do [ "$prev" = "--output" ] && out="$a"; prev="$a"; last="$a"; done+case "$1 $2 $3" in+"version"*) echo "10.0.7";;+"pcb drc"*) [ -f "$last" ] || exit 3; printf '{"violations":[],"unconnected_items":[]}' > "$out";;+"sch erc"*) [ -f "$last" ] || exit 3; [ -f "$(dirname "$last")/sub/child.kicad_sch" ] || exit 4; printf '{"sheets":[{"path":"/","violations":[]}]}' > "$out";;+"sch export netlist") printf '(export (version "E") (components (comp (ref "R1"))) (nets))' > "$out";;+"pcb export gerbers") for l in F_Cu B_Cu Edge_Cuts; do echo "G04 $l*" > "${out%/}/demo-$l.gbr"; done; echo '{}' > "${out%/}/demo-job.gbrjob";;+"pcb export drill") echo "M48" > "${out%/}/demo-PTH.drl";;+"pcb export pos") echo "Ref,Val" > "$out";;+"pcb export step") [ -f "$(dirname "$last")/models/part.step" ] || exit 4; echo "Wire self-interference check failed"; echo "z: 1.585; bounding box: x0: 1; y0: 2; x1: 3; y1: 4" >&2; echo "ISO-10303-21;" > "$out";;+*) echo "unknown $*" >&2; exit 2;;+esac+'''+++def run(osname):+    root = tempfile.mkdtemp(prefix="kcr-test-")+    try:+        home, bindir, work, elsewhere = (os.path.join(root, n) for n in ("home", "bin", "work", "elsewhere"))+        for d in (home, bindir, work, elsewhere, os.path.join(work, "sub")): os.makedirs(d)+        for name, body in (("adom-bridge", MOCK), ("kicad-cli", FAKE_CLI)):+            fp = os.path.join(bindir, name)+            open(fp, "w").write(body); os.chmod(fp, os.stat(fp).st_mode | stat.S_IEXEC)+        open(os.path.join(work, "demo.kicad_pcb"), "w").write('(kicad_pcb (footprint "X" (model "${KIPRJMOD}/models/part.step")))')+        os.makedirs(os.path.join(work, "models")); open(os.path.join(work, "models", "part.step"), "w").write("ISO-10303-21;")+        open(os.path.join(work, "demo.kicad_pro"), "w").write("{}")+        open(os.path.join(work, "demo.kicad_sch"), "w").write('(kicad_sch (sheet (property "Sheetfile" "sub/child.kicad_sch")))')+        open(os.path.join(work, "sub", "child.kicad_sch"), "w").write("(kicad_sch)")+        log = os.path.join(root, "log")+        env = dict(os.environ, PATH=bindir + ":" + os.environ["PATH"], MOCK_OS=osname, MOCK_HOME=home,+                   MOCK_CLI=os.path.join(bindir, "kicad-cli"), MOCK_LOG=log, MOCK_ELSEWHERE=elsewhere,+                   KICAD_REMOTE_TARGET="box", KICAD_REMOTE_THREAD="test")+        for k in ("KICAD_REMOTE_CLI", "KICAD_REMOTE_OS", "KICAD_REMOTE_TMP"): env.pop(k, None)+        def kc(*a, full=False):+            r = subprocess.run(["bash", WRAPPER, *a], cwd=work, env=env, capture_output=True, text=True)+            assert r.returncode == 0, f"{osname} {a}: {r.returncode} {r.stdout} {r.stderr}"+            return r if full else r.stdout+        assert kc("version").strip() == "10.0.7"+        kc("pcb", "drc", "--refill-zones", "--format", "json", "--output", "rel/drc.json", "demo.kicad_pcb")+        kc("sch", "erc", "--format", "json", "--severity-all", "--output", "rel/erc.json", "demo.kicad_sch")+        assert json.load(open(os.path.join(work, "rel/erc.json")))["sheets"][0]["path"] == "/"+        kc("sch", "export", "netlist", "--format", "kicadsexpr", "--output", "rel/n.net", "demo.kicad_sch")+        kc("pcb", "export", "gerbers", "--output", "run/mfg/gerbers/", "--layers", "F.Cu,B.Cu,Edge.Cuts", "demo.kicad_pcb")+        kc("pcb", "export", "drill", "--output", "run/mfg/gerbers/", "--format", "excellon", "demo.kicad_pcb")+        kc("pcb", "export", "pos", "--output", "run/mfg/demo-cpl.csv", "--format", "csv", "demo.kicad_pcb")+        # the board twin's STEP: the ${KIPRJMOD} model travels, the native output passes through+        st = kc("pcb", "export", "step", "--subst-models", "--include-tracks", "--include-silkscreen", "--output", "twin/demo.step", "demo.kicad_pcb", full=True)+        assert "Wire self-interference check failed" in st.stdout and "bounding box" in st.stderr, (st.stdout, st.stderr)+        assert open(os.path.join(work, "twin/demo.step")).read().startswith("ISO-10303-21"), "the STEP did not come back"+        g = sorted(os.listdir(os.path.join(work, "run/mfg/gerbers")))+        assert g == ["demo-B_Cu.gbr", "demo-Edge_Cuts.gbr", "demo-F_Cu.gbr", "demo-PTH.drl", "demo-job.gbrjob"], g+        for f in ("rel/drc.json", "rel/n.net", "run/mfg/demo-cpl.csv"): assert os.path.isfile(os.path.join(work, f)), f+        assert not os.listdir(elsewhere), "a relative saveTo was handed to the bridge"+        calls = [json.loads(l) for l in open(log)]+        dests = [c[1]["dest"].replace("\\", "/") for c in calls if c[0] == "send_files"]+        if osname == "linux":+            assert all(d.startswith(home + "/.adom-aiflow-kicad/aiflow-") for d in dests), dests+            assert not os.path.exists(os.path.join(home, ".adom-aiflow-kicad")), "the Linux job folder was left behind"+            assert not any(c[0] == "shell_execute" for c in calls)+        else:+            assert all(d.startswith(home + "/Temp/aiflow-") for d in dests), dests+            assert os.listdir(os.path.join(home, "Temp")) == [], os.listdir(os.path.join(home, "Temp"))+            assert not any(c[0] == "run_script" for c in calls)+        # sch erc with no report coming back fails+        os.remove(os.path.join(work, "sub", "child.kicad_sch"))+        r = subprocess.run(["bash", WRAPPER, "sch", "erc", "--output", "rel/erc2.json", "demo.kicad_sch"], cwd=work, env=env, capture_output=True, text=True)+        assert r.returncode == 1 and "no erc report came back" in r.stderr, (r.returncode, r.stderr)+        print(f"ok: {osname}")+    finally:+        shutil.rmtree(root, ignore_errors=True)+++if __name__ == "__main__":+    for o in sys.argv[1:] or ["linux", "windows"]:+        run(o)
tools/test_fusion_project.pyadded+95
@@ -0,0 +1,95 @@+"""Unit tests for tools/fusion-project.py helpers (no Fusion, no bridge): python3 -m unittest tools/test_fusion_project.py"""+import importlib.util, os, re, sys, tempfile, unittest+import xml.etree.ElementTree as ET+from pathlib import Path++os.environ.setdefault('AIFLOW_TARGET', 'test-box')  # no bridge lookup at import+os.environ.setdefault('AIFLOW_RUN', tempfile.mkdtemp(prefix='aiflow-fusion-test-'))+os.environ.setdefault('AIFLOW_DASHBOARD', 'off')+_spec = importlib.util.spec_from_file_location('fusion_project', Path(__file__).with_name('fusion-project.py'))+fp = importlib.util.module_from_spec(_spec); _spec.loader.exec_module(fp)++# a smashed part whose NAME was deleted is written self-closing; the next part is an ordinary smashed one+BOARD = '''<?xml version="1.0" encoding="utf-8"?>+<!DOCTYPE eagle SYSTEM "eagle.dtd">+<eagle version="9.7"><drawing><board>+<plain><wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/><text x="3" y="4" size="0.8" layer="21">R1</text></plain>+<libraries><library name="L"><packages><package name="P"><smd name="1" x="-0.5" y="0" dx="0.5" dy="0.6" layer="1"/><smd name="2" x="0.5" y="0" dx="0.5" dy="0.6" layer="1"/><text x="0" y="1" size="0.8" layer="25">&gt;NAME</text></package></packages></library></libraries>+<elements>+<element name="R1" library="L" package="P" value="10k" x="5" y="5" smashed="yes"/>+<element name="R2" library="L" package="P" value="1k" x="10" y="5" smashed="yes">+<attribute name="NAME" x="10" y="6.5" size="0.8" layer="25"/>+</element>+</elements><signals/></board></drawing></eagle>'''+++class Nameless(unittest.TestCase):+    def test_self_closing_smashed_part_is_nameless(self):+        self.assertEqual(fp.nameless_parts(BOARD), {'R1'})+        # the regex it replaces ran from R1's '/>' on into R2 and read R2's NAME as R1's+        old = {m.group(1) for m in re.finditer(r'<element name="([^"]+)"[^>]*smashed="yes"[^>]*>(.*?)</element>', BOARD, re.S) if 'name="NAME"' not in m.group(2)}+        self.assertNotIn('R1', old)++    def test_name_positions_read_back_per_element(self):+        e = fp.board_elements(BOARD)+        self.assertEqual(e['R1'], {'smashed': True, 'name': None, 'hasName': False})+        self.assertEqual(e['R2']['name'], (10.0, 6.5))+        self.assertEqual(fp.plain_texts(BOARD), [('R1', 3.0, 4.0)])+        self.assertEqual(fp.board_elements('not xml'), {})++    def test_board_model_keeps_both_parts_apart(self):+        els = fp.board_model(BOARD)[0]+        self.assertEqual([e['name'] for e in els], ['R1', 'R2'])+        self.assertIsNone(els[0]['nameAttr'])+        self.assertIn('y="6.5"', els[1]['nameAttr'])+++class WiredLayoutPins(unittest.TestCase):+    """EAGLE's rotation string: rotate k*90 counterclockwise, then mirror x (MR90 turns clockwise as seen+    from the front). Checked against a real Fusion schematic: the wires on an MR270 MOSFET's three pins end+    where this order puts them, and the gate pin only there."""+    LIB = ET.fromstring('<library><symbols><symbol name="S"><pin name="A" x="-2.54" y="1.27"/><pin name="B" x="2.54" y="0"/></symbol></symbols>'+                        '<devicesets><deviceset name="DS"><gates><gate name="G" symbol="S"/></gates><devices><device name=""><connects>'+                        '<connect gate="G" pin="A" pad="1"/><connect gate="G" pin="B" pad="2"/></connects></device></devices></deviceset></devicesets></library>')++    def pin1(self, rot):+        layout = {'groups': [{'name': 'g', 'parts': {'U1': [10, 10, rot]}, 'nets': {'N': [['U1.1', [0, 0]]]}}]}+        cmds = fp.wired_groups(layout, self.LIB, {'U1': 'DS'}, {}, 'lib', set())[0]['commands']+        net = next(c for c in cmds if c.startswith("NET 'N'"))+        x, y = re.findall(r'\(([-\d.]+)mm ([-\d.]+)mm\)', net)[0]+        return float(x), float(y)++    def test_pin_positions(self):+        want = {'R0': (7.46, 11.27), 'R90': (8.73, 7.46), 'MR0': (12.54, 11.27), 'MR90': (11.27, 7.46), 'MR270': (8.73, 12.54)}+        for rot, (x, y) in want.items():+            gx, gy = self.pin1(rot)+            self.assertAlmostEqual(gx, x, places=3, msg=rot); self.assertAlmostEqual(gy, y, places=3, msg=rot)+            # the board model's transform is the same order+            ang, mir = fp._rot_of(rot)+            bx, by = fp._xf(-2.54, 1.27, 10, 10, ang, mir)+            self.assertAlmostEqual(bx, x, places=6, msg=rot); self.assertAlmostEqual(by, y, places=6, msg=rot)+++class CloudSave(unittest.TestCase):+    def test_save_as_is_refused_on_a_saved_design(self):+        fresh = {'data': {'activeDocument': 'Untitled', 'documents': [{'name': 'Untitled', 'isActive': True}, {'name': 'Other v2', 'dataFile': {'name': 'Other'}}]}}+        self.assertIsNone(fp.save_refusal(fresh, 'Buck'))+        board_in_front = {'data': {'activeDocument': 'Buck v3', 'documents': [{'name': 'Buck v3', 'isActive': True, 'dataFile': {'name': 'Buck'}}]}}+        self.assertIn('fork', fp.save_refusal(board_in_front, 'Buck'))+        open_elsewhere = {'data': {'activeDocument': 'Untitled', 'documents': [{'name': 'Untitled', 'isActive': True}, {'name': 'Buck v3', 'dataFile': {'name': 'Buck'}}]}}+        self.assertIn('second', fp.save_refusal(open_elsewhere, 'Buck'))++    def test_copies_by_exact_name(self):+        listing = {'success': True, 'data': {'files': [{'name': 'Buck', 'fileExtension': 'fprj'}, {'name': 'Buck', 'fileExtension': 'fbrd'}, {'name': 'Buck2', 'fileExtension': 'fbrd'}]}}+        self.assertEqual([f['fileExtension'] for f in fp.cloud_copies(listing, 'Buck')], ['fprj', 'fbrd'])++    def test_cloud_home(self):+        class A: project = None; folder = None+        self.assertEqual(fp.cloud_place(A(), {}), ('Main', 'Adom AI Flow'))+        self.assertEqual(fp.cloud_place(A(), {'cloud': {'project': 'Claude AI', 'folder': 'Adom AI Flow'}}), ('Claude AI', 'Adom AI Flow'))+        a = A(); a.project = 'P'+        self.assertEqual(fp.cloud_place(a, {'cloud': {'project': 'Q', 'folder': 'F'}}), ('P', 'F'))+++if __name__ == '__main__':+    unittest.main()
tools/timeline/index.html+85
@@ -432,6 +432,20 @@ body.wide .stage { margin-top: 14px; }  .section-heading { flex-wrap: wrap; } } +.rules-card { margin: 0 var(--pad) 24px; }+.rules-card .card { padding: 20px 22px; }+.rules-card .rc-body { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) minmax(0,1.2fr); gap: 16px; margin-top: 14px; align-items: start; }+.rules-card figure { margin: 0; background: #111820; border: 1px solid var(--border-soft); border-radius: 12px; padding: 10px; min-width: 0; }+.rules-card figure svg { display: block; width: 100%; height: 190px; }+.rules-card figcaption { font-size: 12px; color: var(--text-2); margin-top: 6px; }+.rules-card .rc-facts { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: 13px; color: var(--text-2); min-width: 0; }+.rules-card .rc-facts b { font-family: var(--f-mono); color: var(--text); font-weight: 500; }+.rules-card .rc-facts li.flag b, .rules-card .rc-flags li { color: var(--purple); }+.rules-card .rc-flags { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 6px; font-size: 13px; overflow-wrap: anywhere; }+.rules-card .rc-flags li::before { content: "! "; font-weight: 700; }+.rules-card .rc-note { margin: 10px 0 0; font-size: 12px; color: var(--text-2); }+@media (max-width: 800px) { .rules-card .rc-body { grid-template-columns: 1fr; } }+ </style> </head> <body class="waiting-on">@@ -507,6 +521,19 @@ body.wide .stage { margin-top: 14px; } </section>  +<section class="rules-card" id="rules-card" hidden aria-label="Design rules at start">+  <div class="card">+    <div class="eyebrow"><svg class="i"><use href="#i-cpu"/></svg>Design rules at start <span id="rc-lane"></span></div>+    <div class="rc-body">+      <figure><svg id="rc-board" role="img" aria-label="Board outline to scale"></svg><figcaption id="rc-board-cap"></figcaption></figure>+      <figure><svg id="rc-detail" role="img" aria-label="Minimum trace, clearance, via pad and drill to scale"></svg><figcaption id="rc-detail-cap"></figcaption></figure>+      <ul class="rc-facts" id="rc-facts"></ul>+    </div>+    <ul class="rc-flags" id="rc-flags"></ul>+    <p class="rc-note" id="rc-note"></p>+  </div>+</section>+ <section class="lower" id="lower">   <div class="card">     <div class="eyebrow"><svg class="i"><use href="#i-loop"/></svg>Loop backs: where the AI went back to rework</div>@@ -563,6 +590,63 @@ body.wide .stage { margin-top: 14px; }     return el;   }; +  // Design-rule sanity card (presentation.json rulesCard, written by `start`). Read-only.+  const num = v => (typeof v === 'number' && isFinite(v)) ? v : null;+  const niceBar = len => { const c = [0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100, 200]; return c.reduce((b, x) => x <= len ? x : b, c[0]); };+  function renderRulesCard(card) {+    const sec = $('rules-card');+    if (!card || typeof card !== 'object') { sec.hidden = true; return; }+    sec.hidden = false;+    $('rc-lane').textContent = card.lane ? 'ยท ' + card.lane + ' lane' : '';+    const ink = 'var(--text-2)', cu = '#c9a227', teal = '#00b8b1';+    // board outline at its own scale with a scale bar+    const bsvg = $('rc-board'); bsvg.replaceChildren();+    const b = card.board || {};+    const w = num(b.widthMm), h = num(b.heightMm);+    const d = card.draw || {};+    const t = num(d.traceMm) || 0, c = num(d.clearanceMm) || 0, v = num(d.viaPadMm) || 0, dr = num(d.drillMm) || 0;+    const win = Math.max(3, 2 * t + c + v + 1.2);+    if (w && h) {+      const m = Math.max(w, h) * 0.08;+      bsvg.setAttribute('viewBox', [-m, -m, w + 2 * m, h + 2.6 * m].join(' '));+      const pts = Array.isArray(b.outline) ? b.outline.filter(p => Array.isArray(p) && num(p[0]) != null && num(p[1]) != null) : [];+      if (pts.length > 2) bsvg.appendChild(svgEl('polygon', {points: pts.map(p => p[0] + ',' + p[1]).join(' '), fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));+      else bsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: w, height: h, fill: '#16352f', stroke: teal, 'stroke-width': Math.max(w, h) / 250}));+      // the detail window, same scale as the board+      bsvg.appendChild(svgEl('rect', {x: w - win, y: h - win, width: win, height: win, fill: 'none', stroke: '#8c6bf7', 'stroke-width': Math.max(w, h) / 300}));+      const bar = niceBar(w / 3);+      bsvg.appendChild(svgEl('rect', {x: 0, y: h + m, width: bar, height: m * 0.35, fill: ink}));+      const lbl = svgEl('text', {x: bar + m * 0.4, y: h + m * 1.35, fill: ink, 'font-size': m * 0.9, 'font-family': 'JetBrains Mono, monospace'}); lbl.textContent = bar + ' mm'; bsvg.appendChild(lbl);+      $('rc-board-cap').textContent = 'Board ' + w + ' x ' + h + ' mm; the purple square is the detail window (' + win.toFixed(1) + ' mm).';+    } else { $('rc-board-cap').textContent = 'No board outline read.'; }+    // the smallest features, drawn at one scale in the detail window+    const dsvg = $('rc-detail'); dsvg.replaceChildren();+    const pad = win * 0.08;+    dsvg.setAttribute('viewBox', [-pad, -pad, win + 2 * pad, win + 2 * pad].join(' '));+    dsvg.appendChild(svgEl('rect', {x: 0, y: 0, width: win, height: win, fill: '#16352f', stroke: '#8c6bf7', 'stroke-width': win / 200}));+    const x0 = 0.4, len = win * 0.62;+    if (t > 0) {+      dsvg.appendChild(svgEl('rect', {x: x0, y: 0.3, width: t, height: len, fill: cu}));+      dsvg.appendChild(svgEl('rect', {x: x0 + t + c, y: 0.3, width: t, height: len, fill: cu}));+    }+    if (v > 0) {+      const cx = x0 + 2 * t + c + 0.6 + v / 2, cy = 0.3 + v / 2;+      dsvg.appendChild(svgEl('circle', {cx, cy, r: v / 2, fill: cu}));+      if (dr > 0) dsvg.appendChild(svgEl('circle', {cx, cy, r: Math.min(dr, v) / 2, fill: '#0d1117'}));+    }+    const bar = niceBar(win / 3);+    dsvg.appendChild(svgEl('rect', {x: x0, y: win - 0.45, width: bar, height: win / 60, fill: ink}));+    const dl = svgEl('text', {x: x0 + bar + 0.15, y: win - 0.3, fill: ink, 'font-size': win / 16, 'font-family': 'JetBrains Mono, monospace'}); dl.textContent = bar + ' mm'; dsvg.appendChild(dl);+    $('rc-detail-cap').textContent = 'Two min traces ' + t + ' mm at ' + c + ' mm clearance, via pad ' + v + ' mm with ' + dr + ' mm drill, to scale.';+    const items = Array.isArray(card.items) ? card.items : [];+    const loads = Array.isArray(card.loads) ? card.loads : [];+    $('rc-facts').innerHTML = items.map(i => '<li class="' + (i.flag ? 'flag' : '') + '">' + esc(i.label) + ' <b>' + esc(i.value) + ' ' + esc(i.unit) + '</b> <span>' + esc(i.source) + '</span></li>').join('')+      + loads.map(l => '<li class="' + (l.flag ? 'flag' : '') + '">load ' + esc(l.net) + ' <b>' + esc(l.amps) + ' A</b> <span>widest ' + esc(l.widestMm) + ' mm</span></li>').join('');+    const flags = Array.isArray(card.flags) ? card.flags : [];+    $('rc-flags').innerHTML = flags.map(f => '<li>' + esc(f) + '</li>').join('');+    $('rc-note').textContent = String(card.note || 'Read-only: this card changes no gate.') + (card.rangeSource ? ' Range: ' + card.rangeSource + '.' : '');+  }+   let presentation = null;   let presentationRaw = null;   let presentationGeneration = 0;@@ -601,6 +685,7 @@ body.wide .stage { margin-top: 14px; }       }       presentationRaw = raw;       presentation = p;+      try { renderRulesCard(p.rulesCard); } catch (_) { /* the card is optional */ }       const img = $('board-image');       img.hidden = !url;       $('preview-empty').hidden = !!url;