app
Adom Fields
Public Made by Adomby adom
Adom Fields: where the current and the heat go on your board.
← Commit history
source at 0.3.0: the solver, the app, the vendored-viewer fetch script and the skill
77 files changed
+14518−118
Cargo.lock+338Cargo.toml+40README.md+139−31SKILL.md+48−35assets/fonts/FamiljenGrotesk-Bold.ttfassets/fonts/Satoshi-Medium.ttfassets/fonts/Satoshi-Regular.ttfbin/adom-aiflowcrates/adom-aiflow/Cargo.toml+24crates/adom-aiflow/src/main.rs+2465crates/aiflow-analyze/Cargo.toml+10crates/aiflow-analyze/src/lib.rs+191crates/aiflow-board/Cargo.toml+10crates/aiflow-board/examples/dump.rs+12crates/aiflow-board/src/geom.rs+195crates/aiflow-board/src/lib.rs+338crates/aiflow-board/src/sx.rs+197crates/aiflow-board/tests/esc.rs+20crates/aiflow-bridge/Cargo.toml+10crates/aiflow-bridge/src/lib.rs+348crates/aiflow-copper/Cargo.toml+12crates/aiflow-copper/src/lib.rs+503crates/aiflow-grid/Cargo.toml+11crates/aiflow-grid/src/lib.rs+250crates/aiflow-place/Cargo.toml+11crates/aiflow-place/src/lib.rs+268crates/aiflow-pours/Cargo.toml+12crates/aiflow-pours/src/lib.rs+271crates/aiflow-router/Cargo.toml+12crates/aiflow-router/examples/route.rs+16crates/aiflow-router/src/drive.rs+1156crates/aiflow-router/src/lib.rs+800crates/aiflow-router/src/rules.rs+70crates/aiflow-run/Cargo.toml+10crates/aiflow-run/src/lib.rs+577docs/flow-hero.pngdocs/flow-hero.svg+76docs/flow-preview.pngdocs/flow.svg+118docs/spec-example.json+553docs/time.md+11docs/video.md+34flows/board.json+162install.sh+10−4page.json+63−47reference/python/__init__.pyreference/python/__pycache__/copper.cpython-312.pycreference/python/__pycache__/escboard.cpython-312.pycreference/python/__pycache__/fixture.cpython-312.pycreference/python/__pycache__/movefp.cpython-312.pycreference/python/__pycache__/pack.cpython-312.pycreference/python/__pycache__/planes.cpython-312.pycreference/python/__pycache__/pours.cpython-312.pycreference/python/__pycache__/router.cpython-312.pycreference/python/analyze.py+146reference/python/capture.py+51reference/python/cli.py+430reference/python/copper.py+481reference/python/escboard.py+532reference/python/fixture.py+688reference/python/land_moves.py+46reference/python/land_pours.py+81reference/python/land_route.py+95reference/python/movefp.py+115reference/python/pack.py+136reference/python/planes.py+121reference/python/pourplan.py+157reference/python/pours.py+167reference/python/pours_esc_example.py+182reference/python/router.py+1420reference/python/runlog.py+94screenshots/esc-finish.pngscreenshots/hero.pngskills/adom-aiflow/SKILL.md+83skills/aiflow-comparison-video/SKILL.md+48skills/aiflow-measurement/SKILL.md+51uninstall.sh+3−1Cargo.lockadded+338@@ -0,0 +1,338 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-aiflow"+version = "0.1.25"+dependencies = [+ "aiflow-analyze",+ "aiflow-board",+ "aiflow-bridge",+ "aiflow-copper",+ "aiflow-grid",+ "aiflow-place",+ "aiflow-pours",+ "aiflow-router",+ "aiflow-run",+ "clap",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-analyze"+version = "0.1.25"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-board"+version = "0.1.25"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-bridge"+version = "0.1.25"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-copper"+version = "0.1.25"+dependencies = [+ "aiflow-board",+ "aiflow-grid",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-grid"+version = "0.1.25"+dependencies = [+ "aiflow-board",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-place"+version = "0.1.25"+dependencies = [+ "aiflow-board",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-pours"+version = "0.1.25"+dependencies = [+ "aiflow-board",+ "aiflow-copper",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-router"+version = "0.1.25"+dependencies = [+ "aiflow-board",+ "aiflow-grid",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-run"+version = "0.1.25"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "clap"+version = "4.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "3.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.tomladded+40@@ -0,0 +1,40 @@+[workspace]+resolver = "2"+members = [+ "crates/aiflow-board",+ "crates/aiflow-grid",+ "crates/aiflow-router",+ "crates/aiflow-copper",+ "crates/aiflow-place",+ "crates/aiflow-pours",+ "crates/aiflow-analyze",+ "crates/aiflow-run",+ "crates/aiflow-bridge",+ "crates/adom-aiflow",+]++[workspace.package]+version = "0.1.25"+edition = "2021"+license = "MIT"+repository = "https://wiki.adom.inc/adom/adom-aiflow"++[workspace.dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }+aiflow-board = { path = "crates/aiflow-board" }+aiflow-grid = { path = "crates/aiflow-grid" }+aiflow-router = { path = "crates/aiflow-router" }+aiflow-copper = { path = "crates/aiflow-copper" }+aiflow-place = { path = "crates/aiflow-place" }+aiflow-pours = { path = "crates/aiflow-pours" }+aiflow-analyze = { path = "crates/aiflow-analyze" }+aiflow-run = { path = "crates/aiflow-run" }+aiflow-bridge = { path = "crates/aiflow-bridge" }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
README.md+139−31@@ -1,53 +1,161 @@-# Adom Fields+# AI Flow -**Adom Fields: where the current and the heat go on your board.** Solved from the board file itself, painted on the board in 3D, ordered by what matters, with the fix next to every issue. The AI does the analysis and drives the view; the human orbits, zooms and hovers for the number.+**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 parts placement through routing, copper pours, current and thermal analysis to a delivered video. The binary does the fast, deterministic parts of every step, hands back hints written for an AI, and keeps a ledger of every turn, every return to an earlier step, and the AI's time from your prompt to "done" (sessions, one per prompt, the idle cut out; docs/time.md). One flow, one clock, one file, so Claude, Codex and any other engine are compared on the same thing. -+```+adom-wiki pkg install adom/adom-aiflow+adom-aiflow --version+``` -*The ESC G431's top copper with the current density of every loaded net at 20 A. The yellow arcs are the necks: 20 A through 1.5 mm of 35 µm copper.*+> Screenshots and video clips on this page marked **after the first run** are placeholders until the first measured run under Fable 5.1 lands. The comparison numbers are examples of what the ledger produces, not results. -## What it solves+## The final video -- **Current density** per loaded net: a DC conduction solve on the filled copper of every layer, between the net's terminals (the wire pads and connectors on one side, the parts that draw the current on the other, each part's share by its pad area), through the via barrels. Amps per square millimetre on every cell, the peak and where it is.-- **Heat flow in the copper**: four layers, each conducting in its plane, coupled through the dielectric and through every via barrel, losing to the air on both faces. Heat comes from the hot tabs in the spec and from the Joule loss of the current above. Read as watts per millimetre of width on each layer, with each chip's split: along its layer, down its vias, through the bare dielectric.-- **Temperature over the board**: the same solve read as the rise above ambient, in still air (10 W/m²K) and in moving air (25 W/m²K).-- **Issues, with the fix**: a net over 60 A/mm² (about what IPC-2221 allows at 35 µm for 10 °C), a tab over its rise budget, a chip with fewer thermal vias than the spec asks.+The evidence of a run is its video, two minutes at most: only the moments something moved, every step in order, the AI's own current density and temperature rise drawings fullscreen, the run and step clocks in the corner, the words cut to the picture. Every run's page carries its own; the pair is the piece people share. -+**The first pair: Claude Fable 5.1 versus Codex (Astra) on the ESC G431.** Same board, same spec, same prompt, same flow, measured the same way. [The comparison page](docs/comparisons/board-claude-fable-5-1-20260914-vs-source-unplaced-codex-astra-20260914-0314/README.md) leads with the side-by-side video and links both runs in full. -*Heat flow in the top copper. Q3 runs hottest: it has no thermal via within 3 mm, so its 0.6 W has only the top copper to spread on.*+<video width="100%" controls><source src="/blob/app/adom-aiflow/docs/comparisons/board-claude-fable-5-1-20260914-vs-source-unplaced-codex-astra-20260914-0314/comparison-20260915224717.mp4" type="video/mp4"></video> -+*Fable delivered in 53 min of AI time with 2 returns to earlier steps; Astra in 78 min with 14. The runs: [Fable](docs/runs/board-claude-fable-5-1-20260914/README.md), [Astra](docs/runs/source-unplaced-codex-astra-20260914-0314/README.md).* -*The temperature over the board, the FR4 spreading, still air.*+## The comparison: what two runs look like side by side -## The app+Everything below comes from the two runs' ledgers and nothing else. **These are example numbers**; the first measured runs replace them. -`adom-fields serve` puts the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, vendored and served by the binary: its camera, ViewCube, studio lighting, framing and Z-up frame, as 3d-viewer-design asks): the picked field on the picked layer, the other layers faded, the vias as dots, the hot parts labelled, the legend, the number under the cursor. On the left the nets by what they carry and the hot parts hottest first; on the right the issues and the method. Every click is a command: the state lives on the server and goes out over a live channel, so `adom-fields ui view --quantity heat --layer B.Cu` does exactly what the button does, and `adom-fields tour` walks the whole board for the AI Flow video.+### The number: prompt to "done, here is your video" -+| Engine | Prompt to delivered | Finish at | Returns | Turns | Cost at API rates | Cost on a 20x plan |+|---|---|---|---|---|---|---|+| Claude Fable 5.1 | **93 min** | 71 min | 2 | 41 | $6.10 | $0.31 |+| Codex (Astra) | **146 min** | 118 min | 4 | 63 | $9.40 | $0.47 | -*Click an issue and the camera flies to it: 20 A of +VBAT through a 1.5 mm neck on the top copper.*+Commercial users on API plans pay the API figure; a 20x subscription plan pays a twentieth. Both lines, always. -## Run it+### Per step: the AI's thinking versus the binary's time (minutes) -```-adom-fields analyze --board esc.kicad_pcb --spec spec.json --out fields # about two minutes-adom-fields serve --fields fields --port 8874 & # https://<slug>.adom.cloud/proxy/8874/-adom-fields ui view --quantity current --net +VBAT --camera top # drive it-adom-fields tour # the walkthrough, for a clip-```+| Step | Fable think | Fable tool | Fable wall | Codex think | Codex tool | Codex wall | Returns F / C |+|---|---|---|---|---|---|---|---|+| intake | 6 | 0 | 6 | 9 | 0 | 9 | 0 / 0 |+| placement | 18 | 2 | 20 | 30 | 3 | 33 | 1 / 2 |+| routing | 6 | 12 | 18 | 20 | 14 | 34 | 0 / 1 |+| pours | 8 | 6 | 14 | 12 | 6 | 18 | 1 / 1 |+| current | 3 | 0 | 3 | 4 | 0 | 4 | 0 / 0 |+| thermal | 5 | 0 | 5 | 10 | 0 | 10 | 0 / 0 |+| capture | 1 | 3 | 4 | 2 | 3 | 5 | 0 / 0 |+| finish and video | 18 | 5 | 23 | 24 | 9 | 33 | 0 / 0 |+| **total** | **65** | **28** | **93** | **111** | **35** | **146** | 2 / 4 |++Thinking is the gap before each command, charged to the step the AI declared. Tool is the binary's own seconds. The sum over steps is the whole run.++++### The rework map: where each engine went back, and why++| Engine | Return | Why, from the ledger | Cost |+|---|---|---|---|+| Fable | thermal to placement | Q4's tab had no room for a B.Cu spreader under the gate driver's routing; moved TP12 and R28 | 6 min |+| Fable | thermal to pours | re-poured phase B after the move | 5 min |+| Codex | routing to placement | router closed with 3 unconnected in the escape band; moved TP4 and LED1 | 8 min |+| Codex | placement to routing | re-routed after the move | 9 min |+| Codex | current to pours | +VBAT stitch vias landed outside the pour: 2 unconnected | 7 min |+| Codex | thermal to pours | Q2 and Q6 tabs short of copper | 7 min |++### The board that came out++| Outcome | Fable | Codex |+|---|---|---|+| unrouted at finish | 0 | 0 |+| new DRC errors (13 inherited counted apart) | 0 | 0 |+| copper kept, F.Cu / B.Cu | 66 % / 74 % | 61 % / 70 % |+| pours landed / refused first | 31 / 3 | 27 / 5 |+| vias (thermal and stitch) | 13 | 9 |+| current: loaded nets passing | 7 / 7 | 7 / 7 |+| thermal: worst tab rise at spec watts | 42 C (Q4) | 55 C (Q6) |+| Kelvin taps kept clean | yes | yes |++Both must reach 0 / 0 or the run is not a result. The rest is where the engineering judgement shows: copper kept for ablation, tab temperatures, how many refusals it took.++### What it cost++| Measure | Fable | Codex |+|---|---|---|+| turns (commands run) | 41 | 63 |+| thinking, total | 61 min | 104 min |+| tool, total | 32 min | 42 min |+| longest single think | 11 min (placement) | 19 min (placement) |+| steps the AI took itself | placement | placement, routing |+| tokens in / out | 1.9 M / 140 k | 3.1 M / 210 k |+| cost at API rates | $6.10 | $9.40 |+| cost on a 20x plan | $0.31 | $0.47 |++Token and dollar figures are self-reported by each engine into run.json; everything else is stamped by the binary.++++## The ESC G431, the first board++The proving run (the binary on a placement made earlier, not a measured run of the AI) ended with the ESC routed to 100 percent, 31 pours landed, both analyses passed:++++## What the binary is made of++One Rust binary, a crate per module: `aiflow-board` (the KiCad board model), `aiflow-grid`, `aiflow-router` (0.1 mm grid A*, escapes, plane stubs, per-net vias, rip-up, Kelvin taps), `aiflow-copper` (plans to copper, KiCad's DRC offline), `aiflow-place` (courtyards, packing, moves), `aiflow-pours` (zones, keepouts, thermal and stitching vias from the spec), `aiflow-analyze` (IPC-2221, tab copper, rise budgets), `aiflow-run` (the ledger and the manifest), `aiflow-bridge` (the live half through the Adom KiCad Bridge: landing, recording, measurement, validation), and `adom-aiflow`, the command.++KiCad Bridge today. Next backends: the Altium bridge, the Fusion bridge, then Adom's own web apps (adom-schematic, adom-2dboard, adom-3dboard). The board model and the bridge crate are the only ones that know KiCad; the flow, the spec, the ledger and the analyses do not change when the backend does.+++## Roadmap++Everything below is what John has asked for, in the order it is likely to land. 0.1 is what this page describes; nothing here is built until its version says so.++**Steps in front of placement (the flow grows at the front)**+- **components**: choose the parts from the requirements; a SPICE result or a thermal result can send the AI back here.+- **libraries**: build the symbols, footprints and 3D chips for every part (with adom-symbol, adom-footprint, chip-thumbnailer).+- **schematic**: draw it, and derive the spec from it, so the spec stops being hand-written.+- **simulation**: SPICE on the critical loops before the board exists (the KiCad Bridge's SPICE verbs, #89), with the loop back to components when a part choice is wrong.++**Steps after the board (the flow grows at the back)**+- **moleculize**: add the machine pins so the board can live in the probing workcell as a molecule.+- **paste**: solder paste jetting calculations and analysis.+- **probe**: work out how to probe the board when it comes off the InstaPCB process.++**Analyses (0.2)**+- **current 0.2**: cross-sections through the filled copper instead of the narrowest-track heuristic; a current-density heat map per net.+- **thermal 0.2**: copper area weighted by distance from the tab, via conduction, the other layer's contribution; a heat map per hot part.+- **impedance**: controlled-impedance and return-path checks on the nets the spec marks.+- **ablation**: the copper-kept metric per layer as a mill-time estimate for the InstaPCB process, so "least copper to ablate" is a number the AI can push.++**The video (0.2)**+- The step's own code frames the shot: zoom to fit before a landing, the part on screen when a move lands.+- Done in 0.1.14: `compose` cuts the final video from the motion of every step visit and the AI's drawings, two minutes at most, the picture leading and the adom-tts words cut to it. Left for 0.2: the AI writing its own words per segment, gang-takes for the charts.+- The split screen for two engines, and the grid for five (Fable, Astra, Antigravity, Grok, Kimi), cut from the same step tags.+- A clip for each step on this page, and the final video at the top of it. -The spec is Adom AI Flow's: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias`, optional `stackupMm`. The board must be saved with its zones filled.+**Backends**+- The Altium bridge and the Fusion bridge, behind the same commands.+- Adom's own web apps: adom-schematic, adom-2dboard, adom-3dboard, so users can stop relying on the legacy tools; the board model and the bridge crate are the only crates that change. -## In the AI Flow+**The binary and the bridge**+- A batched landing verb in the KiCad Bridge (one DRC preflight for a whole plan) to bring a clean pass from 18 minutes to about 4.+- An ungated free-disk-space verb in ab (adom-bridge#196) so the recording budget can see the test box.+- Every step the AI replaces with its own better code comes back as a crate, filed on this page. -Adom Fields is the analysis step of [Adom's AI Flow](https://wiki.adom.inc/adom/adom-aiflow): after the pours land, the AI solves the fields, reads the issues, and walks the board on camera with `adom-fields tour` while the flow records the window. The clip goes into the run's video; the numbers go on the run page.+**Measurement**+- Token and dollar accounting written by the binary from the engine's own usage where an engine exposes it, instead of self-reported.+- The comparison page generated from the ledgers by the binary itself (`adom-aiflow compare run1 run2 ...`), so nobody assembles a chart by hand. -## Honest limits, 0.1+## Skills in this package -DC only; one copper thickness for all layers; a flat convection coefficient, no heatsink, no enclosure, no radiation; the tab watts come from the spec. A screen that finds the necks and the starved tabs and shows them, not a datasheet number. Next: heatsinks as conducting blocks, pour capacitance, differential pair and impedance checks (the signals tab), and an Altium and Fusion reader.+- `adom-aiflow`: the flow, the commands, the spec, the honesty rules.+- `aiflow-comparison-video`: how to make the comparison video and page for two runs: pull the other run, `compose --with` and its narration script (the words lead, no dead air, who won each step, how the clock works), `compare --push`.+- `aiflow-measurement`: how a run is measured, in John's words: the clock from the prompt to "done, here is your video", the AI's thinking per step, the rework loops, the append-only run.jsonl, the cost both ways. -## Give it back+## Honesty rules -Anything wrong, rigid or missing: an issue or a PR on this page. Adom processes PRs within minutes during business hours.+- The clock starts at the prompt and stops at `deliver`. A late `start` takes `--prompt-time`.+- A stage the AI takes itself is stamped by the AI and named `ai`; the binary's are `binary`. Both go on the chart.+- A run without `deliver` is not a result, and its minutes are still counting.+- A run assembled from earlier work with a back-dated prompt is a proving run of the tool, not a measured run of the AI. Say so.+- 0.1's analyses are conservative heuristics (IPC-2221 for tracks; presence, connection style, area and vias for pours and tabs) and say so in their output. 0.2 computes cross-sections through the filled copper.
SKILL.md+48−35@@ -1,54 +1,67 @@ ----name: adom-fields-description: Solve where the current and the heat go on a KiCad board and show it on the board in 3D (Adom Fields): current density per loaded net, heat flow in each copper layer with the vias, temperature over the board in still and moving air, the nets by what they carry, the hot parts by their rise, the issues with the fix; a server the AI drives over a live channel (view, select, issue, toast, tour) for the human to explore and for the AI Flow video. Trigger words: adom-fields, fields, current density, heat map, thermal analysis, copper pour analysis, thermal vias, where does the current go, hot tab, temperature rise, IR drop, current neck, heat flow.+name: adom-aiflow+description: The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. --- -# Adom Fields+# adom-aiflow: the loop, measured -One binary, three commands, one app. The AI does the analysis; the human explores the board; the AI drives the same view on camera.+This tool is the place-route-pour-analyze skill made executable: the step order is a file, the gates refuse, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself. -## Solve+Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`. -```-adom-fields analyze --board <board.kicad_pcb> --spec <spec.json> --out fields-```+Every state-changing command takes `--ai-thread "<your thread name>"`. Every run lives in one directory (`--run DIR`, default `./aiflow-run`) with a `run.json` manifest: the clock, the plan, the decisions, every stage's start and end, the outcomes, the capture markers and the summary. The manifest is the comparison; nothing counts that is not in it. -The board must be saved with its zones filled (the filled polygons are the copper). The spec is the AI Flow spec: `loads` (net: amps, maxRiseC), `hot` (ref: watts, tabNet), `copperUm`, `hotMaxRiseC`, `thermalVias` (pad, count), optional `stackupMm` (three dielectric thicknesses). About two minutes for a 65 x 75 mm four-layer board at the default 0.2 mm cell; `--pitch 0.1` is four times longer and sharper.+## The flow -What comes out, in `fields/fields.json` and `fields/tex/`:-- `nets`, ordered by amps then by peak density: peak A/mm² and where, the layers, the copper mm², the Joule loss, the vias.-- `chips`, hottest first: the rise at the tab in still and moving air, and where its watts go within 3 mm (along its layer, down its vias, through the bare dielectric, to the air), with how many vias the spec asked for.-- `issues`: `current-neck` (a loaded net over 60 A/mm², about what IPC-2221 allows at 35 µm for 10 °C), `tab-rise` (over the budget), `thermal-vias` (fewer than the spec asks), each with `at`, `layer`, `net`, `reference` and a `fix`.-- textures per layer: `J-<layer>.png` (all nets), `J-<net>-<layer>.png`, `q-<layer>.png` (heat flow, W/mm), `T-<layer>.png` and `Tm-<layer>.png` (rise, still and moving air), `copper-<layer>.png`, plus `.raw.png` 16-bit values for the readout.+The flow is a file, `flows/board.json`: the steps in order (intake, models, placement, routing, pours, current, thermal, nets, 3d, capture, finish), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow. -Terminals per net: the wire pads and connectors (MC*, J*, P*) on one side; on the other the spec's hot parts on the net (a FET's tab and its source pins both count), else the Q and U parts, else the parts with real pads, each part's share by its pad area. A net with neither is skipped and says so.+1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly.+2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself. Pours, gate, landing, measurement, both analyses and the finish are the binary's.+3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes.+6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched.+7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. -## Serve and show+## Capture, so the engines' videos line up -```-adom-fields serve --fields fields --port 8874 &-```+One clip per step. `capture open` puts the board on the test box; from then on every `step <name>` stops the previous step's clip and starts this step's own window recording, tagged with the step, and its hint says what that clip should show (the flow file's `record` line: the parts landing for placement, the nets landing for routing, the pours filling for pours, the return to an earlier step when an analysis fails). `deliver` lists the clips. The final video is cut from them, one segment per step, so two engines' videos line up step for step, and the page can show a little clip beside every step's numbers. -Open `https://<slug>.adom.cloud/proxy/8874/` in pup, the browser, or a Hydrogen webview (never localhost from outside the container). The app: a 44 px header, the board in 3D on the canonical Adom 3D viewer (adom/adom-3d-viewer, embedded; ViewCube, studio lighting, zoom to the cursor), the field painted on the picked layer with the other layers faded, the vias as dots, the hot parts labelled with their rise, the legend, the readout under the cursor, the nets and hot parts on the left, the issues and the method on the right. Phone: the two side panels open from the header buttons.+Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video: each step visit's 10x cut, decimated to motion, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces), narrated by adom-tts from the ledger. No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence. -## Drive it (the AI's wheel)+`capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers. -```-adom-fields ui view --quantity current|heat|temperature [--layer F.Cu|In1.Cu|In2.Cu|B.Cu] [--camera top|iso|bottom|fit] [--air still|moving]-adom-fields ui select --net +VBAT # one net lit alone (current)-adom-fields ui select --chip Q3 # fly to a hot part, heat view-adom-fields ui issue --index 0 # fly to an issue-adom-fields ui toast "20 A through a 1.5 mm neck"-adom-fields ui state | console-adom-fields tour --pause-ms 2500 # the scripted walkthrough: nets by relevance, each field, the hot parts, the issues-```+## The spec -The server: `GET /state`, `GET /console` (the UI's log), `GET /events` (SSE), `GET /fields.json`, `POST /ui/view|select|issue|toast`. State lives on the server; the UI, the CLI and the AI see the same view.+`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable. -## In the AI Flow video+## The rules behind the stages -Record the pup window (Adom Bridge `desktop_record_window_start` on it) while `adom-fields tour` runs: the clip is the fields step, the toasts are the captions, the AI's own analysis on the board in 3D.+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). -## Honest limits (0.1)+## What is measured (read aiflow-measurement) -DC only (no skin effect, no switching loss on the copper); one copper thickness for all layers; the 1.6 mm stack is 0.2 / 1.065 / 0.2 mm unless `stackupMm` says otherwise; convection is a flat coefficient on both faces (10 or 25 W/m²K), no heatsink, no enclosure, no radiation; the tab watts come from the spec, not from a switching model. It is a screen that finds the necks and the starved tabs, and shows them; it is not a datasheet number.+The number is the human's prompt to your "done, here is your video" (`deliver`), and the cost per step is your thinking, not the binary's seconds. Every command you run is a turn in the append-only run.jsonl with the gap before it as thinking time (`adom-aiflow ledger` reads it back); declare `step <name>` as you move, and `step <name> --back --why "..."` when a later step sends you back. `finish` is the qualified board; `deliver --video --message` ends the clock.+++## The run's own page, as it runs++`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown.++## Give it back++adom-aiflow is the token cache of every Adom user's flow. When the binary was wrong, rigid or missing a step (an RF board that needs length-matched differential pairs and impedance checks, say), or your own code did a step better, file it: `adom-wiki issue create adom/adom-aiflow ...` or a PR against the repo, then `adom-aiflow giveback <url>` so the run counts it. Adom processes PRs within minutes during business hours (a non-stop PR intake), and `deliver` reports how many issues and PRs the run's AI gave back.+++## The code checks what you filmed++Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.++## Honesty rules++- The clock starts at the prompt. A late `start` takes `--prompt-time`.+- A stage you take yourself is stamped by you and named in the manifest as `ai`; the binary's stages are `binary`. Both go on the chart.+- `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting.+- 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.
assets/fonts/FamiljenGrotesk-Bold.ttfadded⋯ 1 unchanged line ⋯
assets/fonts/Satoshi-Medium.ttfadded⋯ 1 unchanged line ⋯
assets/fonts/Satoshi-Regular.ttfadded⋯ 1 unchanged line ⋯
bin/adom-aiflowadded⋯ 1 unchanged line ⋯
crates/adom-aiflow/Cargo.tomladded+24@@ -0,0 +1,24 @@+[package]+name = "adom-aiflow"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The board loop as one command: placement, routing, pours, gate, landing, measurement, analysis, finish line"++[[bin]]+name = "adom-aiflow"+path = "src/main.rs"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+clap = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }+aiflow-router = { workspace = true }+aiflow-copper = { workspace = true }+aiflow-place = { workspace = true }+aiflow-pours = { workspace = true }+aiflow-analyze = { workspace = true }+aiflow-run = { workspace = true }+aiflow-bridge = { workspace = true }
crates/adom-aiflow/src/main.rsadded+2465@@ -0,0 +1,2465 @@+//! 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.+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++use aiflow_board::Board;+use aiflow_bridge::Bridge;+use aiflow_run::{now, Run, STAGES};+use clap::{Parser, Subcommand};+use serde_json::{json, Value};++const VERSION: &str = env!("CARGO_PKG_VERSION");++#[derive(Parser)]+#[command(name = "adom-aiflow", version = VERSION, about = "The board loop as one command: placement, routing, pours, gate, landing, measurement, analysis, finish line")]+struct Cli {+ /// The name of the AI conversation calling (required for state-changing commands)+ #[arg(long, global = true)]+ ai_thread: Option<String>,+ /// Run directory (default ./aiflow-run or $AIFLOW_RUN)+ #[arg(long, global = true)]+ run: Option<PathBuf>,+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// Start a run: copies the board, stamps the prompt time+ Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },+ /// The stages this board needs and who can take each+ Plan,+ /// Record who takes a stage: route=ai pour=binary+ Take { decisions: Vec<String> },+ /// Stamp a stage you do yourself: start or end+ Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> },+ /// Placement helpers: pack (--wish), check (--moves), land (--moves)+ Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String, /// milliseconds between parts when landing: each part is its own undo step and its own moment on camera+ #[arg(long, default_value_t = 600)] pause_ms: u64 },+ /// The grid router on the current board+ Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },+ /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)+ Pour { #[arg(long)] plan: Option<String> },+ /// KiCad DRC offline on the routing plan+ Gate { #[arg(long)] plan: Option<String> },+ /// 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 },+ /// Filled copper per layer from the live board+ Measure,+ /// current or thermal+ Analyze { what: String, #[arg(long)] plan: Option<String> },+ /// The finish line; refuses until every check passes+ Finish,+ /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.+ Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+ /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.+ Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)+ #[arg(long)] accept_suspect: bool },+ /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.+ Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)+ #[arg(long)] surface: Option<String> },+ /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.+ Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },+ /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.+ Recut,+ /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.+ Compose { #[arg(long, default_value = "final.mp4")] out: String, /// the longest the final video may be, in seconds+ #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen+ #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings+ #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air+ #[arg(long)] script: Option<PathBuf> },+ /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.+ Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video+ #[arg(long)] replaces: Option<String> },+ /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.+ Usage,+ /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement.+ Models,+ /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.+ Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container+ #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)+ #[arg(long)] on: Option<String> },+ /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.+ Ledger { #[arg(long)] json: bool },+ /// open, start, mark <label>, stop+ Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },+ /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/+ Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },+ /// Where the run stands+ Status,+ /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.+ Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },+ /// The AI's answer to the current prompt is complete ("done, here is ..."): the session's clock stops here. `deliver` is the done of the first task.+ Done { #[arg(long)] message: String },+ /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is the human away, not the AI thinking).+ Sessions { #[arg(long)] json: bool },+}++static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();++/// Close the ledger entry for this command (see Run::turn_begin).+fn close_turn(ok_: bool, first_line: &str) {+ if let Some((dir, idx)) = TURN.get() {+ if let Some(mut r) = Run::open(dir) {+ r.turn_end(*idx, ok_, first_line);+ let _ = r.save();+ }+ }+}++fn ok(msg: &str, hints: &[String]) {+ close_turn(true, msg.lines().next().unwrap_or(""));+ println!("OK: {msg}");+ for h in hints {+ if !h.is_empty() {+ println!("Hint: {h}");+ }+ }+}++fn err(msg: &str, hints: &[String]) -> ! {+ close_turn(false, msg.lines().next().unwrap_or(""));+ println!("ERROR: {msg}");+ for h in hints {+ if !h.is_empty() {+ println!("Hint: {h}");+ }+ }+ std::process::exit(2)+}++fn thread(cli: &Cli) -> String {+ cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))+}++fn run_dir(cli: &Cli) -> PathBuf {+ cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))+}++fn load_run(cli: &Cli) -> Run {+ let d = run_dir(cli);+ Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))+}++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}"), &[]))+}++fn spec_of(r: &Run) -> Value {+ read_json(r.data["spec"].as_str().unwrap_or(""))+}++fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {+ let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+ for p in &b.pads {+ if p.thru || p.net.is_empty() {+ continue;+ }+ let l = if p.on_f { "F.Cu" } else { "B.Cu" };+ let e = m.entry(p.net.clone()).or_default();+ if !e.iter().any(|x| x == l) {+ e.push(l.to_string());+ }+ }+ m+}++/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;+/// every other copper or keepout zone on the live board is the flow's own.+fn inherited_zone_ids(r: &Run) -> Vec<String> {+ let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };+ let text = std::fs::read_to_string(p).unwrap_or_default();+ let mut ids = Vec::new();+ let mut from = 0;+ while let Some(i) = text[from..].find("(zone") {+ let start = from + i;+ let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());+ if let Some(u) = text[start..end].find("(uuid \"") {+ let s = start + u + 7;+ if let Some(e) = text[s..].find('"') {+ ids.push(text[s..s + e].to_string());+ }+ }+ from = start + 5;+ }+ ids+}++/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.+fn step_table(r: &Run) -> String {+ let rep = r.step_report();+ let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+ let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+ for k in rep.keys() {+ if !names.contains(k) {+ names.push(k.clone());+ }+ }+ let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8} {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];+ let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);+ for n in names {+ let e = &rep[&n];+ let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));+ tw += w; tt += t; tb += b; tn += k;+ lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8} {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+ }+ lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));+ lines.join("\n")+}++++/// Free megabytes on the drive holding the run directory.+fn local_free_mb(dir: &Path) -> Option<u64> {+ let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;+ let text = String::from_utf8_lossy(&out.stdout);+ let line = text.lines().last()?;+ line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)+}++/// The recording budget for a step: measured window captures run about 8 MB per minute of+/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the+/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull+/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.+fn clip_budget_mb(step: &str) -> u64 {+ let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };+ minutes * 10 * 2+}++fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {+ let need = clip_budget_mb(step);+ let local = local_free_mb(dir);+ let remote = br.remote_free_mb();+ let floor = need + 1024;+ let mut msgs = Vec::new();+ if let Some(l) = local {+ if l < floor {+ return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));+ }+ msgs.push(format!("run drive {l} MB free"));+ }+ if let Some(rm) = remote {+ if rm < floor {+ return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));+ }+ msgs.push(format!("test box {rm} MB free"));+ }+ Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))+}+++/// The Adom faces, embedded so every caption on every machine is set in them.+fn run_fonts(dir: &Path) -> (String, String) {+ let fd = dir.join("fonts");+ let _ = std::fs::create_dir_all(&fd);+ let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];+ for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }+ (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())+}++/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.+fn dt(t: &str) -> String {+ t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")+}+++/// The engine's plan usage, as the providers report it: percent used per limit window. Claude+/// from the Claude Code sign-in (the same call Claude Code's /usage panel makes), Codex from the+/// Codex sign-in (the same call its /status shows). Costs no quota. Returns [{provider, label, pct}].+fn usage_snapshot() -> Vec<Value> {+ let mut out = Vec::new();+ let home = std::env::var("HOME").unwrap_or_default();+ let curl = |url: &str, headers: &[String]| -> Option<Value> {+ let mut c = std::process::Command::new("curl");+ c.args(["-s", "-m", "12", url]);+ for h in headers { c.args(["-H", h]); }+ let o = c.output().ok()?;+ serde_json::from_slice::<Value>(&o.stdout).ok()+ };+ if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {+ if let Ok(c) = serde_json::from_str::<Value>(&t) {+ let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());+ if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {+ if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {+ for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {+ let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };+ let label = match lim.get("kind").and_then(|k| k.as_str()) {+ Some("session") => "5h".to_string(),+ Some("weekly_all") => "week".to_string(),+ Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),+ Some(k) => k.to_string(),+ None => "limit".to_string(),+ };+ out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));+ }+ if out.is_empty() {+ for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {+ if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }+ }+ }+ }+ }+ }+ }+ if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {+ if let Ok(a) = serde_json::from_str::<Value>(&t) {+ let tk = a.get("tokens").cloned().unwrap_or(Value::Null);+ if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {+ let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");+ if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {+ let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);+ let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };+ for key in ["primary_window", "secondary_window"] {+ if let Some(w) = rl.get(key).filter(|w| w.is_object()) {+ out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));+ }+ }+ }+ }+ }+ }+ out+}++/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).+fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {+ let snap = usage_snapshot();+ if !snap.is_empty() {+ r.log("usage", json!({"when": when, "limits": snap}));+ if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }+ r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));+ }+ snap+}++/// First and last usage per provider and label: what the run cost in plan percent.+fn usage_delta(r: &Run) -> Vec<Value> {+ let snaps = r.data["usage"].as_array().cloned().unwrap_or_default();+ let mut first: std::collections::BTreeMap<String, (String, String, f64)> = Default::default();+ let mut last: std::collections::BTreeMap<String, f64> = Default::default();+ for s in &snaps {+ for l in s["limits"].as_array().cloned().unwrap_or_default() {+ let key = format!("{}:{}", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""));+ let pct = l["pct"].as_f64().unwrap_or(0.0);+ first.entry(key.clone()).or_insert((l["provider"].as_str().unwrap_or("").to_string(), l["label"].as_str().unwrap_or("").to_string(), pct));+ last.insert(key, pct);+ }+ }+ first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()+}++fn usage_line(r: &Run) -> String {+ let d = usage_delta(r);+ if d.is_empty() { return "plan usage: no sign-in found to read".into(); }+ format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))+}+++/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,+/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR+/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,+/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.+/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start+/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.+/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the+/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,+/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.+fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {+ let (font_display, font_text) = run_fonts(dir);+ let off = format!("{offset:.0}");+ let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;+ let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;+ let run_secs = format!("(t+{off})");+ let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {+ (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)+ } else {+ (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)+ };+ let run_inner = run_w.max(162.0);+ let step_secs = format!("(t+{step_offset:.0})");+ let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];+ let step_inner = (2.0 * colw + gap).max(62.0);+ let mut ov: Vec<String> = Vec::new();+ let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;+ ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));+ ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));+ let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);+ let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;+ ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+ ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));+ for (k, (hdr, expr)) in run_cols.iter().enumerate() {+ let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);+ ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+ ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+ }+ for (k, (hdr, expr)) in step_cols.iter().enumerate() {+ let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);+ ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));+ ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));+ }+ ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));+ ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));+ ov.join(",")+}++/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are+/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at+/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).+fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {+ format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))+}++/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the+/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with+/// its black fraction (amount=0), and only the ones under 95 percent black pass.+const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";++/// A step's display name for chips and cards.+fn step_shown(step: &str) -> String {+ match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }+}+++/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so+/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).+fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;+ let rid = caps[pos]["recordingId"].as_str()?.to_string();+ let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();+ let (p, _) = br.record_stop(&rid, &save_to);+ let cap = &mut r.data["captures"][pos];+ cap["stop"] = json!(now());+ cap["remoteFile"] = json!(p);+ cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ cap["stoppedBy"] = json!(why);+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ let local = cap["file"].as_str().map(str::to_string);+ r.log("clip-stop", json!({"step": st, "recordingId": rid, "file": p, "by": why}));+ if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }+ local+}+++/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a+/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey+/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than+/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.+fn blank_check(file: &str) -> Option<String> {+ let secs = media_seconds(file);+ if secs <= 0.0 { return Some("no frames".into()); }+ let n = 8usize;+ let mut flat = 0usize; let mut total = 0usize;+ for k in 0..n {+ let t = secs * (k as f64 + 0.5) / n as f64;+ let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;+ let px = out.stdout;+ if px.len() < 1000 { continue; }+ let mut bins = [0usize; 16];+ for b in &px { bins[(*b as usize) >> 4] += 1; }+ total += 1;+ if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }+ }+ if total == 0 { return Some("could not read frames".into()); }+ if flat * 4 > total * 3 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }+}++/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it+/// filmed before it moves on, and the run page shows it under the clip.+fn contact_sheet(file: &str) -> Option<String> {+ let secs = media_seconds(file);+ if secs <= 0.0 { return None; }+ let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));+ let fps = 9.0 / secs;+ let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);+ if ok && Path::new(&out).is_file() { Some(out) } else { None }+}++/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.+fn leading_black(file: &str) -> f64 {+ let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();+ let Ok(o) = out else { return 0.0 };+ let text = String::from_utf8_lossy(&o.stderr);+ for line in text.lines() {+ if let Some(i) = line.find("black_start:") {+ let rest = &line[i + 12..];+ let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);+ let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);+ if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }+ break;+ }+ }+ 0.0+}++/// The clip's seconds, from ffprobe (0.0 when unknown).+fn media_seconds(path: &str) -> f64 {+ std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)+}++/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts+/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.+fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {+ 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+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);+ let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;+ let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));+ let _ = (&step_name, visit, back, offset, &dir);+ let secs = media_seconds(file);+ // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an+ // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud+ let verdict = blank_check(file);+ if let Some(why) = &verdict {+ eprintln!("WARNING clip {} looks blank: {why}", 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["suspect"] = json!(why); }+ r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));+ }+ r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));+ if let Some(sheet) = contact_sheet(file) {+ r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));+ eprintln!("contact sheet (look at it before you go on): {sheet}");+ }+ let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));+ // the recorder's first frames are black before the window paints: they are cut off the head+ // of both cuts (never more than five seconds; longer black is a real clip problem, kept)+ let lead = leading_black(file);+ let ss = format!("{lead:.3}");+ let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);+ if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }+ if cut {+ let fast = media_seconds(&out);+ r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));+ eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);+ // the action cut: only the frames that changed, with the real elapsed time burned in.+ // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is+ // honest, drop near-duplicate frames, then pack what is left at 30 fps.+ let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));+ // the action cut stays RAW (no captions): the final video draws its own overlays from the+ // ledger, and a side-by-side of two runs must not paint overlays over overlays+ let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");+ let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);+ if ok_action {+ let a = media_seconds(&action);+ r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));+ eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);+ }+ }+}+++/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.+enum Piece { Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }++/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is+/// said faster when even it runs long. The picture sets the length; the words are cut to it.+fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {+ if !has_tts || vlen < 1.5 { return None; }+ let mut best: Option<(PathBuf, f64, String)> = None;+ for (k, words) in tiers.iter().enumerate() {+ let n = words.split_whitespace().count() as f64;+ let last = k + 1 == tiers.len();+ if n > vlen * 2.3 && !last { continue; }+ let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };+ let audio = workdir.join(format!("say-{idx}-{k}.mp3"));+ let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+ if !ok { continue; }+ let alen = media_seconds(audio.to_str().unwrap());+ best = Some((audio, alen, words.clone()));+ if alen <= vlen + 0.05 { break; }+ }+ best+}++/// The first clause of a sentence: up to the first semicolon, colon or comma.+fn first_clause(s: &str) -> String {+ s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()+}++/// A step's name in words for the narration.+fn step_said(step: &str) -> String {+ step_shown(step).to_lowercase().replace("3d", "3D")+}++/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).+fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {+ let led = r.ledger();+ let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();+ for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }+ let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them+ let mut pieces: Vec<(u64, Piece)> = Vec::new();+ for (i, c) in caps.iter().enumerate() {+ if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+ let visit = c["visit"].as_u64().unwrap_or(1);+ let tag = format!("{step}-{visit}");+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ if !Path::new(&fast).is_file() { continue; }+ let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+ let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));+ // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video+ if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }+ let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ // a clip that ran while the human was away (a recorder left on) is nothing to watch+ let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);+ if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }+ pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));+ }+ for e in &standing_artifacts(&led) {+ if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ if !Path::new(f).is_file() { continue; }+ let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));+ }+ pieces.sort_by_key(|p| p.0);+ if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }+ // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not+ let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();+ let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();+ let clip_budget = (max_seconds - n_stills as f64 * still_seconds).max(max_seconds * 0.5);+ let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };+ let engine = r.data["engine"].as_str().unwrap_or("").to_string();+ let minutes = r.active_minutes_total();+ let run_has_hours = minutes >= 60.0;+ let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let engine_board = format!("{engine} · {board}");+ let workdir = dir.join("compose");+ let _ = std::fs::remove_dir_all(&workdir);+ let _ = std::fs::create_dir_all(&workdir);+ let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+ let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+ let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };+ let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";+ let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+ let (mut n_clips, mut n_stills_done) = (0usize, 0usize);+ for (k, (_, piece)) in pieces.iter().enumerate() {+ let seg = workdir.join(format!("seg-{k}.mp4"));+ let aseg = workdir.join(format!("aud-{k}.m4a"));+ let (vlen, tiers): (f64, Vec<String>) = match piece {+ Piece::Clip { cap: c, idx, motion } => {+ let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));+ let tag = format!("{step}-{visit}");+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ // the run clock is the AI's time: the active seconds from the first prompt to this clip+ let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+ let offset = r.active_offset(cs) as f64;+ let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });+ let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));+ let okc = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }+ let vlen = media_seconds(seg.to_str().unwrap());+ if vlen < 0.5 { continue; }+ let _ = (idx, motion);+ let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };+ let said = step_said(step);+ let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };+ let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));+ let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });+ n_clips += 1;+ (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])+ }+ Piece::Still { file, caption, step, visit, at } => {+ let offset = r.active_offset(*at) as f64;+ let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;+ let title = format!("{} · the AI's drawing", step_shown(step));+ // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p+ let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));+ let oks = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }+ let vlen = media_seconds(seg.to_str().unwrap());+ n_stills_done += 1;+ (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])+ }+ };+ // the words are cut to the picture: the segment is exactly as long as its motion+ let said = narrate(&workdir, k, &tiers, vlen, has_tts);+ let oka = match &said {+ Some((audio, _, _)) => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),+ None => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),+ };+ if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }+ if oka { parts.push((seg, aseg)); }+ }+ if parts.is_empty() { err("nothing to compose: every segment failed to render", &[]); }+ let vlist = workdir.join("video.txt");+ let alist = workdir.join("audio.txt");+ std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", v.display())).collect::<String>()).unwrap();+ std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.display())).collect::<String>()).unwrap();+ let vcat = workdir.join("video.mp4");+ let acat = workdir.join("audio.m4a");+ let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+ // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the+ // bitrate that fits (screen captures are mostly still, so the picture survives it)+ let cap_bytes: u64 = 9_800_000;+ if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {+ let secs = media_seconds(outp.to_str().unwrap()).max(1.0);+ let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();+ let fitted = workdir.join("fitted.mp4");+ let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);+ if okr { let _ = std::fs::copy(&fitted, outp); eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); }+ }+ let secs = media_seconds(outp.to_str().unwrap());+ if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }+ (secs, n_clips, n_stills_done, rate, has_tts)+}++/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is+/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.+fn standing_artifacts(led: &[Value]) -> Vec<Value> {+ let mut out: Vec<Value> = Vec::new();+ for (i, e) in led.iter().enumerate() {+ if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));+ if !superseded { out.push(e.clone()); }+ }+ out+}++/// The sessions table for the terminal and the run page.+fn sessions_table(r: &Run) -> String {+ let ss = r.sessions();+ let mut out = String::from("| Session | Prompt at | Done at | AI time min | Idle cut min | What |\n|---|---|---|---|---|---|\n");+ for x in &ss {+ out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));+ }+ out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));+ out+}++/// One engine's clip of one step visit, ready for the side-by-side.+struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }++/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.+fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {+ let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));+ if Path::new(&fast).is_file() { return Some(fast); }+ let local = run_dir.join(Path::new(&fast).file_name()?);+ if local.is_file() { Some(local.display().to_string()) } else { None }+}++/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.+fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {+ let led = r.ledger();+ let mut act: std::collections::BTreeMap<String, f64> = Default::default();+ for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }+ let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();+ let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));+ for c in &caps {+ if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened+ if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }+ let visit = c["visit"].as_u64().unwrap_or(1);+ let Some(tenx) = tenx_of(&r.dir, f) else { continue };+ let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);+ if motion < 1.0 { continue; }+ let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));+ if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }+ let title = format!("{}{}", step_shown(step), if visit > 1 { format!(" visit {visit}{}", if c["back"].as_bool() == Some(true) { " rework" } else { "" }) } else { String::new() });+ out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });+ }+ out+}++/// A run's standing drawings per step, as (file, caption, active offset).+fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {+ let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();+ for e in standing_artifacts(&r.ledger()) {+ if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }+ let Some(f) = e["file"].as_str() else { continue };+ let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };+ let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);+ out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));+ }+ out+}++fn run_ffmpeg(args: &[&str]) -> bool {+ std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)+}++/// Two runs side by side, step by step in flow order: the left engine's visits of the step play+/// in its column, the right engine's in its column, both paced to the same seconds (the run with+/// more motion sets the pace; the other plays slower, never faster than its own clips); their own+/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.+fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {+ let (fd, ft) = run_fonts(dir);+ let workdir = dir.join("compose-pair");+ let _ = std::fs::remove_dir_all(&workdir);+ let _ = std::fs::create_dir_all(&workdir);+ let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);+ let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+ let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let (va, vb) = (visits_by_step(a), visits_by_step(b));+ let (da, db) = (drawings_by_step(a), drawings_by_step(b));+ let (ra, rb) = (a.step_report(), b.step_report());+ let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;+ // the steps in flow order, then any the runs added+ // the story's order: the build steps, the analyses, the walkthroughs, the finish line last+ let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();+ for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }+ let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);+ let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();+ // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget+ let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };+ let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;+ for s in &steps {+ let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));+ raw.push(fa.max(fb) / 4.0);+ pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));+ }+ let raw_sum: f64 = raw.iter().sum();+ let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);+ let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };+ let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };+ let (cw, ch, band) = (960, 540, 110);+ // the bars below the columns: this step's AI minutes per engine against the longest step of either run+ let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);+ let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+ let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps · {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));+ let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {+ let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));+ if let Some(((ma, va), (mb, vb))) = bars {+ let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);+ for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {+ let w = ((m / max_min) * wmax).max(4.0);+ let _ = k;+ f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));+ f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));+ f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min · {v} visit{}", if v == 1 { "" } else { "s" }))));+ }+ f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));+ }+ f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));+ f+ };+ // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)+ let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");+ let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();+ let (mut n_steps, mut n_pairs) = (0usize, 0usize);+ // with a script the words lead: each step's picture is paced to its narration (slower or faster,+ // never held, never cut short), so the voice never stops and never runs past the picture+ let scripted = script.is_some();+ let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {+ if !has_tts || words.trim().is_empty() { return None; }+ let audio = workdir.join(format!("script-{k}.mp3"));+ let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);+ if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }+ };+ for (si, step) in steps.iter().enumerate() {+ let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);+ let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));+ let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };+ if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }+ let mut seg_index = parts.len();+ // the two columns of clips+ if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {+ let mut cols: Vec<PathBuf> = Vec::new();+ for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {+ let col = workdir.join(format!("col-{si}-{side}.mp4"));+ let f = frames(vis);+ if f > 0.0 {+ // scripted: the frames spread over the words (down to one frame every five seconds+ // for a step with little motion); unscripted: never slower than one a second+ let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };+ let mut pieces: Vec<PathBuf> = Vec::new();+ for (k, v) in vis.unwrap().iter().enumerate() {+ let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));+ let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name} · {board}"), v.offset, hours, 10, rate));+ if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }+ }+ let list = workdir.join(format!("col-{si}-{side}.txt"));+ std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", p.display())).collect::<String>()).unwrap();+ let cat = workdir.join(format!("cat-{si}-{side}.mp4"));+ let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);+ // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)+ let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);+ let _ = run;+ } else {+ // no clip on this side: the run's own screenshot of the step, or its latest board shot,+ // stands in with a caption, so the column is never a dark hole+ let shot = latest_shot(&run.dir, step);+ let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));+ let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");+ let okp = match &shot {+ Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+ None => false,+ };+ if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }+ }+ if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }+ }+ if cols.len() == 2 {+ let (ma, mb) = (mins(&ra, step), mins(&rb, step));+ let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+ let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));+ if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+ let vlen = media_seconds(seg.to_str().unwrap());+ let said = step_said(step);+ let tiers = if step == "finish" {+ let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());+ vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]+ } else {+ vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]+ };+ let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+ let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+ if oka { parts.push((seg, aseg)); n_steps += 1; }+ }+ }+ }+ // the drawings of both, side by side+ let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());+ for k in 0..dra.len().max(drb.len()) {+ let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);+ let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));+ let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };+ let mut cols: Vec<PathBuf> = Vec::new();+ for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {+ let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));+ let okc = match d {+ Some((file, _, off)) => {+ let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{} · the AI's drawing", step_shown(step)), &format!("{name} · {board}"), *off, 0.0, hours));+ run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])+ }+ None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),+ };+ if okc { cols.push(col); }+ }+ if cols.len() != 2 { continue; }+ let seg = workdir.join(format!("seg-{}.mp4", parts.len()));+ let (ma, mb) = (mins(&ra, step), mins(&rb, step));+ let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));+ if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {+ let vlen = media_seconds(seg.to_str().unwrap());+ let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();+ let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];+ let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));+ let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };+ if oka { parts.push((seg, aseg)); n_pairs += 1; }+ }+ }+ let _ = &mut seg_index;+ }+ if parts.is_empty() { err("nothing to compose side by side: no step with motion in either run", &[]); }+ assemble(&workdir, &parts, outp);+ (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)+}++/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).+fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {+ match narrate(workdir, k, tiers, vlen, has_tts) {+ Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),+ None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),+ }+}++/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.+fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {+ let vlist = workdir.join("video.txt");+ let alist = workdir.join("audio.txt");+ std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", v.display())).collect::<String>()).unwrap();+ std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.display())).collect::<String>()).unwrap();+ let vcat = workdir.join("video.mp4");+ let acat = workdir.join("audio.m4a");+ let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);+ let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);+ let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);+ if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }+ let cap_bytes: u64 = 9_800_000;+ if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {+ let secs = media_seconds(outp.to_str().unwrap()).max(1.0);+ let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();+ let fitted = workdir.join("fitted.mp4");+ if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {+ let _ = std::fs::copy(&fitted, outp);+ eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));+ }+ }+}++/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).+fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {+ let (ida, idb) = (report_id(a), report_id(b));+ let rel = format!("docs/comparisons/{ida}-vs-{idb}");+ let stage = dir.join("report");+ let out = stage.join(&rel);+ let _ = std::fs::remove_dir_all(&out);+ let _ = std::fs::create_dir_all(&out);+ let slug = page.split('/').nth(1).unwrap_or(page);+ let blob = format!("/blob/app/{slug}/{rel}");+ let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());+ // the board by the spec's name when it has one (ESC G431), else by its file+ let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());+ let mut files: Vec<String> = Vec::new();+ let mut md = String::new();+ md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));+ md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");+ // the video first+ if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {+ let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+ let name = format!("comparison-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));+ md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+ }+ // the number+ let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));+ let (ra, rb) = (a.step_report(), b.step_report());+ let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);+ let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+ let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;+ md.push_str("## The number: prompt to \"done, here is your video\"\n\n");+ md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");+ md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));+ md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));+ md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (the human away) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");+ // per step+ let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();+ for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }+ md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));+ for st in &steps {+ let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());+ if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }+ }+ md.push_str("\n");+ // the drawings side by side+ let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };+ let (dra, drb) = (draw(a), draw(b));+ if !dra.is_empty() || !drb.is_empty() {+ md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");+ for st in &steps {+ let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();+ let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();+ for k in 0..la.len().max(lb.len()) {+ md.push_str("<table><tr>");+ for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {+ match it {+ Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }+ None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),+ }+ }+ md.push_str("</tr></table>\n\n");+ }+ }+ }+ // the links+ md.push_str("## The two runs, in full\n\n");+ md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));+ md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));+ md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));+ md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");+ let readme = out.join("README.md");+ std::fs::write(&readme, md).unwrap();+ files.push(format!("{rel}/README.md"));+ (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)+}++/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.+fn latest_shot(dir: &Path, step: &str) -> Option<String> {+ let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();+ shots.sort_by(|a, b| b.0.cmp(&a.0));+ let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));+ of_step.or(shots.first()).map(|(_, p)| p.display().to_string())+}++/// The give-back hint: what the AI learned belongs to every Adom user.+fn give_back_hint() -> String {+ "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()+}+++/// The run id used for its page folder: board stem, engine slug, prompt date.+fn report_id(r: &Run) -> String {+ if let Some(id) = r.data["reportId"].as_str() {+ return id.to_string();+ }+ let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");+ let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+ let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");+ // prompt date and time, so two runs by one engine on one day get their own folders+ let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+ let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();+ let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();+ format!("{board}-{eng}-{date}-{hhmm}")+}++/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).+fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {+ let id = report_id(r);+ let rel = format!("docs/runs/{id}");+ let stage = dir.join("report");+ let out = stage.join(&rel);+ // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along+ let _ = std::fs::remove_dir_all(&out);+ let _ = std::fs::create_dir_all(&out);+ let slug = page.split('/').nth(1).unwrap_or(page);+ let blob = format!("/blob/app/{slug}/{rel}");+ let led = r.ledger();+ let engine = r.data["engine"].as_str().unwrap_or("");+ let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+ let finished = r.data["clock"]["finishTime"].as_str();+ let delivered = r.data["clock"]["deliveredTime"].as_str();+ let cur = r.data["currentStep"].as_str().unwrap_or("intake");+ let now_min = r.elapsed_minutes();+ let mut md = String::new();+ md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));+ let ss = r.sessions();+ let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);+ let status = match (delivered, finished) {+ (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),+ (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),+ _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),+ };+ md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (the human away) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));+ if let Some(d) = r.data["delivery"].as_object() {+ if let Some(v) = d.get("video").and_then(|v| v.as_str()) {+ if Path::new(v).is_file() {+ // a new name for every cut, so a browser that cached the last one shows this one+ let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));+ let name = format!("final-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name));+ let vs = media_seconds(v);+ md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));+ }+ }+ }+ if let Some(v) = r.data["comparison"]["video"].as_str() {+ if Path::new(v).is_file() {+ let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());+ let name = format!("comparison-{stamp}.mp4");+ let _ = std::fs::copy(v, out.join(&name));+ md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));+ }+ }+ // sessions+ md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, the idle it did not get charged for.\n\n");+ md.push_str(&sessions_table(r));+ md.push_str("\n");+ // per-step table+ let rep = r.step_report();+ let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+ let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+ for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }+ md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+ let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);+ for n in &names {+ let e = &rep[n];+ let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));+ tw += w; tt += t; tb += b;+ md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+ }+ md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));+ // returns+ let mut returns: Vec<String> = Vec::new();+ for e in &led {+ if e["event"] == "step" && e["back"].as_bool() == Some(true) {+ returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));+ }+ }+ if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }+ // decisions and markers+ let mut marks: Vec<String> = Vec::new();+ for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }+ if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }+ // outcomes+ let o = r.outcomes();+ md.push_str("## Where the board stands\n\n");+ 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["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" })); } }+ 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");+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ // clip seconds from the ledger's artifacts+ let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action+ for e in &led {+ if e["event"] == "artifact" {+ let key = e["step"].as_str().unwrap_or("").to_string();+ let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));+ match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }+ }+ }+ let standing = standing_artifacts(&led);+ let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };+ for c in &caps {+ if c.get("discarded").is_some() { continue; }+ let Some(step) = c["step"].as_str() else { continue };+ let visit = c["visit"].as_u64().unwrap_or(1);+ let tag = format!("{step}-{visit}");+ let rework = c["back"].as_bool() == Some(true);+ let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };+ md.push_str(&format!("### {title}\n\n"));+ md.push_str(&format!("What this step does: {}\n\n", what_of(step)));+ if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }+ if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }+ let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));+ // the 10x cut next to the raw clip+ let mut have_clip = false;+ if let Some(f) = c["file"].as_str() {+ let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));+ let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));+ let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));+ if Path::new(&action).is_file() {+ let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));+ md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));+ md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+ have_clip = true;+ } else if Path::new(&fast).is_file() {+ let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));+ md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));+ md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));+ have_clip = true;+ }+ }+ if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }+ if let Some(f) = c["file"].as_str() {+ let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));+ if Path::new(&sheet).is_file() {+ let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));+ md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n\n\n"));+ }+ }+ for when in ["start", "end"] {+ let src = dir.join(format!("shot-{tag}-{when}.png"));+ if src.is_file() {+ let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));+ md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));+ md.push_str(&format!("\n\n", if when == "start" { "began" } else { "ended" }));+ }+ }+ // what the AI made during this visit: its own analysis drawings, with its caption above each+ for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {+ let Some(f) = e["file"].as_str() else { continue };+ if !Path::new(f).is_file() { continue; }+ let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");+ let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));+ let _ = std::fs::copy(f, out.join(&name));+ let cap = e["caption"].as_str().unwrap_or("");+ if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {+ md.push_str(&format!("**The AI's drawing: {cap}**\n\n\n\n"));+ } else {+ md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));+ }+ }+ }+ if let Some(d) = r.data["delivery"].as_object() {+ md.push_str("## Delivered\n\n");+ md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));+ }+ let ud = usage_delta(r);+ if !ud.is_empty() {+ md.push_str("## Plan usage, the second cost line\n\nPercent of each plan limit used, as the provider reports it, at the start of the run and at its end.\n\n| Provider | Limit | At start | At end | Used by this run |\n|---|---|---|---|---|\n");+ for x in &ud { md.push_str(&format!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }+ md.push_str("\n*The limits are per account, not per run: every AI thread signed in as this user draws on the same numbers, so a run made while other threads were working shows their usage too. Read it as an upper bound unless this run was the only thread active.*\n\n");+ }+ let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();+ md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));+ md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");+ let readme = out.join("README.md");+ let _ = std::fs::write(&readme, md);+ (stage, rel, readme)+}++/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.+fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {+ let (stage, rel, _readme) = build_report(r, dir, page);+ // the files viewer renders a sub-README as a page; the bare path serves raw markdown+ let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");+ let mut notes = Vec::new();+ if push {+ // only the files that changed since the last push go up, in batches the gateway takes+ // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said+ // out loud and the caller turns it into an ERROR+ let cache_path = dir.join("report").join(".pushed.json");+ let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();+ let mut files: Vec<(String, u64, String)> = Vec::new();+ if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {+ for e in d.filter_map(|e| e.ok()) {+ let name = e.file_name().to_string_lossy().to_string();+ if name.starts_with('.') { continue; }+ let size = e.metadata().map(|m| m.len()).unwrap_or(0);+ let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();+ files.push((format!("{rel}/{name}"), size, sum));+ }+ }+ files.sort();+ let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();+ let (mut pushed, mut failed) = (0usize, 0usize);+ let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];+ let mut acc = 0u64;+ for f in &changed {+ if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }+ batches.last_mut().unwrap().push(f); acc += f.1;+ }+ for batch in batches.iter().filter(|b| !b.is_empty()) {+ let mut cmd = std::process::Command::new("adom-wiki");+ cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);+ for f in batch { cmd.arg(&f.0); }+ match cmd.output() {+ Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }+ Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }+ Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }+ }+ }+ let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());+ notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));+ }+ if refresh && r.data["reportSurface"].as_str() == Some("webview") {+ // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());+ fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }+ let mut ids = Vec::new();+ if let Some(w) = &ws { leaves(&w["root"], &mut ids); }+ let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();+ let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();+ match panel {+ Some(pid) => {+ let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();+ notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });+ }+ None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),+ }+ } else if refresh {+ if let Some(m) = r.data["userMachine"].as_str() {+ let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));+ if !Bridge::ok(&nav) {+ let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));+ notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+ } else {+ notes.push(format!("refreshed in the browser on {m}"));+ }+ let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));+ }+ }+ (url, notes)+}++fn board_of(r: &Run) -> Board {+ Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))+}++fn bridge_of(r: &Run, cli: &Cli) -> Bridge {+ let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();+ Bridge { ai_thread: thread(cli), target }+}++fn write_json(p: &Path, v: &Value) {+ std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+}++/// The command as typed, without the thread and run plumbing.+fn command_line() -> String {+ let args: Vec<String> = std::env::args().skip(1).collect();+ let mut out = Vec::new();+ let mut skip = false;+ for a in args {+ if skip { skip = false; continue; }+ if a == "--ai-thread" || a == "--run" { skip = true; continue; }+ if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }+ out.push(a);+ }+ out.join(" ")+}++fn main() {+ let cli = Cli::parse();+ let dir = run_dir(&cli);+ // every command is a turn in the run's ledger; the gap before it is the AI's thinking time+ if !matches!(cli.cmd, Cmd::Start { .. }) {+ if let Some(mut r) = Run::open(&dir) {+ let idx = r.turn_begin(&command_line());+ let _ = r.save();+ let _ = TURN.set((dir.clone(), idx));+ // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.+ // Stop it here, on whatever command comes next, before it eats the disk (one once ran+ // nine hours on a static editor because deliver did not stop it).+ if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);+ 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, _ => 10 } as u64;+ if age > budget_min * 60 * 2 || age > 3600 {+ 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));+ stop_running_clip(&mut r2, &br, &dir, "guard");+ let _ = r2.save();+ eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);+ }+ }+ }+ }+ }+ }+ match &cli.cmd {+ Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {+ let t = thread(&cli);+ if !Path::new(board).is_file() {+ err(&format!("board not found: {board}"), &[]);+ }+ if !Path::new(spec).is_file() {+ err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);+ }+ if let (Some(r), false) = (Run::open(&dir), *force) {+ err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);+ }+ std::fs::create_dir_all(&dir).unwrap();+ let copy = dir.join("board-0.kicad_pcb");+ std::fs::copy(board, ©).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+ let pt = prompt_time.clone().unwrap_or_else(now);+ let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());+ let mut r = Run::create(&dir, ©.display().to_string(), &specp, engine, &pt, &t, target.as_deref(), remote_board.as_deref()).unwrap_or_else(|e| err(&e, &[]));+ r.data["sourceBoard"] = json!(std::fs::canonicalize(board).map(|p| p.display().to_string()).unwrap_or(board.clone()));+ let idx = r.turn_begin(&command_line());+ record_usage(&mut r, "start");+ r.save().unwrap();+ let _ = TURN.set((dir.clone(), idx));+ ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "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()]);+ }+ Cmd::Plan => {+ let mut r = load_run(&cli);+ // the flow is a file: the steps in order, who does each, what the binary offers, what comes later+ let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+ r.data["flow"] = flow.clone();+ let spec = spec_of(&r);+ let b = board_of(&r);+ let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();+ let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);+ let stages = json!({+ "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},+ "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},+ "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},+ "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},+ "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},+ "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},+ "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},+ "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},+ "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+ });+ r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});+ r.save().unwrap_or_else(|e| err(&e, &[]));+ let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];+ lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));+ for st in flow["steps"].as_array().cloned().unwrap_or_default() {+ lines.push(format!(" step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+ }+ lines.push(format!(" later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));+ lines.push("stages the binary offers:".into());+ for n in STAGES {+ let s = &r.data["plan"]["stages"][n];+ lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));+ }+ ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);+ }+ Cmd::Take { decisions } => {+ thread(&cli);+ let mut r = load_run(&cli);+ for kv in decisions {+ let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };+ if !STAGES.contains(&stage) {+ err(&format!("unknown stage {stage}"), &[format!("Stages: {}", STAGES.join(", "))]);+ }+ r.data["decisions"][stage] = json!(engine);+ }+ r.save().unwrap();+ ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);+ }+ Cmd::Stage { action, name, note, result } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if action == "start" {+ let e = r.engine_for(name);+ r.stage_start(name, Some(&e), note.as_deref());+ r.save().unwrap();+ ok(&format!("stage {name} started at {} by {e}", now()), &[]);+ } else {+ let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));+ r.stage_end(name, res, note.as_deref());+ r.save().unwrap();+ ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);+ }+ }+ Cmd::Place { what, wish, moves, out, pause_ms } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let b = board_of(&r);+ match what.as_str() {+ "pack" => {+ let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));+ let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));+ let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, 0.5);+ write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());+ let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();+ ok(&format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n")), &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }]);+ }+ "check" => {+ let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));+ let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));+ let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));+ let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+ let outp = dir.join(format!("board-{n}.kicad_pcb"));+ std::fs::write(&outp, &text).unwrap();+ let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ let moved: Vec<String> = mv.keys().cloned().collect();+ let problems = aiflow_place::check(&nb, &moved, 0.05);+ let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+ let s = aiflow_copper::summarize(&drc, None);+ let base = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+ let errors = s["errors"].as_u64().unwrap_or(0);+ if !problems.is_empty() || errors > base {+ err(&format!("the moved board is not clean: {} courtyard/outline problems, DRC {errors} errors (baseline {base}): {}", problems.len(), s["errorTypes"]), &[problems.join("; "), format!("Change the wishes and pack again; the copy stays at {} for inspection.", outp.display())]);+ }+ 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()]);+ }+ "land" => {+ let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ r.stage_start("place-land", Some("binary"), None);+ let mv = read_json(m);+ let t0 = std::time::Instant::now();+ // one part at a time: the camera sees each part land, not a board that changes at once+ let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+ });+ let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+ r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.save().unwrap();+ if let Some(i) = stopped {+ let rep = replies.last().cloned().unwrap_or(Value::Null);+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ }+ ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+ }+ other => err(&format!("place {other}: use pack, check or land"), &[]),+ }+ }+ Cmd::Route { passes, rip_per_net, rip_global } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if r.engine_for("route") == "ai" {+ err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);+ }+ let spec = spec_of(&r);+ let b = board_of(&r);+ let rules = aiflow_router::Rules::from_spec(&spec);+ r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));+ let mut router = aiflow_router::Router::new(b, rules, false);+ let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+ std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();+ let planp = dir.join("plan-route.json");+ write_json(&planp, &json!({"engine": "adom-aiflow router", "nets": plan}));+ write_json(&dir.join("plan-route-report.json"), &report);+ 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<_>>()}));+ r.save().unwrap();+ if failed > 0 {+ let mut blockers: Vec<String> = Vec::new();+ for u in &unrouted {+ if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {+ if let Some(rest) = why.strip_prefix("blocked by ") {+ for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {+ let b = b.trim_matches('"').trim_matches('\'');+ if !b.is_empty() && !blockers.contains(&b.to_string()) {+ blockers.push(b.to_string());+ }+ }+ }+ }+ }+ let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();+ 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<_>>()), "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+ }+ ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into()]);+ }+ Cmd::Pour { plan } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let spec = spec_of(&r);+ let b = board_of(&r);+ 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));+ let plan_v = planp.as_deref().map(read_json);+ r.stage_start("pour", Some("binary"), None);+ let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+ let zp = dir.join("plan-pours.json");+ let vp = dir.join("plan-vias.json");+ write_json(&zp, &json!({"zones": zones}));+ 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()}));+ r.save().unwrap();+ let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();+ ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);+ }+ Cmd::Gate { plan } => {+ thread(&cli);+ 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 to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]));+ let b = board_of(&r);+ let plan_v = read_json(&planp);+ r.stage_start("gate", Some("binary"), None);+ let (text, stats) = aiflow_copper::apply(&b, &plan_v, true).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));+ let routed = dir.join("board-routed.kicad_pcb");+ std::fs::write(&routed, &text).unwrap();+ let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));+ let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+ let mut s = aiflow_copper::summarize(&data, Some(&base));+ s["copper"] = stats;+ s["file"] = json!(routed.display().to_string());+ 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));+ r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh})), None);+ r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "routedBoard": routed.display().to_string(), "plan": planp}));+ r.save().unwrap();+ if newe > 0 || unc > 0 {+ // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever+ let mut hair: Vec<String> = Vec::new();+ for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {+ if v["type"].as_str() == Some("clearance") {+ let d = v["description"].as_str().unwrap_or("");+ let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+ let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+ if let (Some(a), Some(r)) = (actual, rule) {+ if r - a < 0.02 {+ for it in v["items"].as_array().cloned().unwrap_or_default() {+ if let Some(t) = it.as_str() {+ if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }+ }+ }+ }+ }+ }+ }+ hair.sort(); hair.dedup();+ let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };+ err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[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()]);+ }+ ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected; copper {}", s["copper"]), &["Next: adom-aiflow land route (then pour, land vias, land pours).".into()]);+ }+ Cmd::Land { what, plan, moves, pause_ms } => {+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ match what.as_str() {+ "moves" => {+ let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));+ r.stage_start("place-land", Some("binary"), None);+ let mv = read_json(m);+ let t0 = std::time::Instant::now();+ // one part at a time: the camera sees each part land, not a board that changes at once+ let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {+ eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });+ });+ let total = mv.as_object().map(|o| o.len()).unwrap_or(0);+ r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+ r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));+ r.save().unwrap();+ if let Some(i) = stopped {+ let rep = replies.last().cloned().unwrap_or(Value::Null);+ err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);+ }+ ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);+ }+ "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);+ 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()]);+ }+ plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))+ } else {+ r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))+ };+ let mut entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();+ let mut already = 0usize;+ if what == "vias" {+ // resumable: a via already on the live board at the same place on the same net is not landed twice+ let live = br.live_vias(&rb);+ entries.retain(|e| {+ let net = e["net"].as_str().unwrap_or("");+ let p = &e["paths"][0][0];+ let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));+ let dup = live.iter().any(|(n, lx, ly)| n == net && (lx - x).abs() < 0.05 && (ly - y).abs() < 0.05);+ if dup { already += 1; }+ !dup+ });+ if already > 0 {+ eprintln!("{already} vias already on the live board; landing the other {}", entries.len());+ }+ }+ let stage = format!("land-{what}");+ r.stage_start(&stage, Some("binary"), None);+ let t0 = std::time::Instant::now();+ // one trace at a time: a net with several paths lands as several undo steps+ let (replies, accepted, stopped, traces) = br.land_plan(&rb, &entries, *pause_ms, |i, n, entry, rep| {+ eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);+ });+ write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));+ r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);+ r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));+ r.save().unwrap();+ if let Some(i) = stopped {+ let rep = replies.last().cloned().unwrap_or(Value::Null);+ err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Resume from entry {} once fixed: the log has every reply.", i + 1)]);+ }+ ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+ }+ "pours" => {+ 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()));+ r.stage_start("land-pours", Some("binary"), None);+ let before = br.zone_state(&rb, true);+ let inherited = inherited_zone_ids(&r);+ let results = br.land_zones(&rb, &zones, *pause_ms, &inherited);+ if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {+ eprintln!("{}", rep["message"].as_str().unwrap_or(""));+ }+ let after = br.zone_state(&rb, true);+ let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();+ r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);+ r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));+ write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));+ r.save().unwrap();+ if let Some((net, rep)) = failed.first() {+ let mut types: BTreeMap<String, usize> = BTreeMap::new();+ for v in rep["violations"].as_array().cloned().unwrap_or_default() {+ *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;+ }+ err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);+ }+ ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);+ }+ other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),+ }+ }+ Cmd::Measure => {+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ r.stage_start("measure", Some("binary"), None);+ let zs = br.zone_state(&rb, true);+ r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);+ write_json(&dir.join("zone-state.json"), &zs);+ r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));+ r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));+ r.save().unwrap();+ ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);+ }+ Cmd::Analyze { what, plan } => {+ thread(&cli);+ 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()]));+ let plan_v = read_json(&planp);+ let spec = spec_of(&r);+ let zsp = dir.join("zone-state.json");+ 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 (passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+ r.stage_end(&name, Some(json!({"pass": passed})), None);+ r.outcome(&name, json!({"pass": passed, "results": res}));+ 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();+ // 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 {+ let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");+ if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }+ }+ let draw = match what.as_str() {+ "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),+ _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),+ };+ let fields_hint = "adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();+ let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };+ if !passed {+ err(&format!("{what} analysis failed:\n{}", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);+ }+ ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "0.1 judges tracks by IPC-2221 and pours by presence and vias; 0.2 computes cross-sections through the filled copper.".into()]);+ }+ Cmd::Finish => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ let o = r.outcomes().clone();+ let mut missing = Vec::new();+ 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 !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)).unwrap_or(true) {+ missing.push("land pours (every planned pour accepted)".into());+ }+ if !o.contains_key("measure") {+ missing.push("measure".into());+ }+ 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)"));+ }+ }+ let mut live = Value::Null;+ if let Some(target) = r.data["target"].as_str() {+ let br = Bridge { ai_thread: t.clone(), target: target.to_string() };+ live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+ let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+ if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {+ missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));+ }+ }+ 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()]);+ }+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let brs = Bridge { ai_thread: t.clone(), target };+ stop_running_clip(&mut r, &brs, &dir, "finish");+ }+ 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});+ r.data["summary"] = summary;+ 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 qualified board, {:.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()]);+ }+ Cmd::Capture { action, label, reason } => {+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let engine = r.data["engine"].as_str().unwrap_or("").to_string();+ match action.as_str() {+ "open" => {+ let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));+ r.save().unwrap();+ let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();+ let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };+ let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };+ ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);+ }+ "start" => {+ let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));+ // the PCB editor window itself, background-capturable; the monitor only when no editor is open+ let hwnd = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd());+ let (rid, kind) = match hwnd {+ Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),+ None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),+ };+ let mut entry = json!({"recordingId": rid, "start": now()});+ for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }+ r.data["captures"].as_array_mut().unwrap().push(entry);+ r.mark("capture-start");+ r.save().unwrap();+ ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);+ }+ "mark" => {+ let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));+ r.mark(&l);+ r.save().unwrap();+ ok(&format!("marker {l} at {}", now()), &[]);+ }+ "stop" => {+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let (p, _) = br.record_stop(&rid, &save_to);+ let cap = &mut r.data["captures"][pos];+ cap["stop"] = json!(now());+ cap["remoteFile"] = json!(p);+ cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ let local = cap["file"].as_str().map(str::to_string);+ r.mark("capture-stop");+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+ clip_artifacts(&mut r, &st, &f, 10);+ }+ r.save().unwrap();+ 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()]);+ }+ "reject" => {+ // the AI looked at a contact sheet and saw a clip the blank check accepted but a+ // human would not (stale pixels after a window resize, a covered canvas)+ let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));+ let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));+ r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));+ let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));+ r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));+ r.save().unwrap();+ ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);+ }+ other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),+ }+ }+ Cmd::Status => {+ let r = load_run(&cli);+ let o = r.outcomes();+ 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) { "landed" } 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 } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ if let Some(p) = page { r.data["page"] = json!(p); }+ if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }+ if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }+ r.save().unwrap();+ let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));+ if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+ let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);+ if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }+ ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);+ }+ Cmd::Giveback { url, kind, note } => {+ thread(&cli);+ let mut r = load_run(&cli);+ if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }+ r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));+ r.log("giveback", json!({"url": url, "kind": kind, "note": note}));+ r.save().unwrap();+ ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);+ }++ Cmd::Recut => {+ thread(&cli);+ let mut r = load_run(&cli);+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let mut n = 0;+ for c in &caps {+ let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };+ if !Path::new(f).is_file() { continue; }+ let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));+ clip_artifacts(&mut r, &tag, f, 10);+ n += 1;+ }+ r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));+ r.save().unwrap();+ ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);+ }+ Cmd::Compose { out, max_seconds, still_seconds, with, script } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };+ if let Some(other) = with {+ let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));+ let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));+ let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());+ r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));+ r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});+ r.save().unwrap();+ ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);+ return;+ }+ let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);+ r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));+ if r.data["delivery"].is_object() {+ r.data["delivery"]["video"] = json!(outp.display().to_string());+ r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");+ }+ r.save().unwrap();+ let minutes = r.active_minutes_total();+ ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);+ }+ Cmd::Artifact { file, kind, caption, step, replaces } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let src = PathBuf::from(file);+ if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }+ let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));+ let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+ // the file lives in the run directory so the page and the video can find it later+ let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());+ let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());+ let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };+ let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));+ let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());+ r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced}));+ r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));+ r.save().unwrap();+ ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else { "It goes on the run page under this step with your caption; only images go into the video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);+ }+ Cmd::Compare { with, page, push, refresh } => {+ let t = thread(&cli);+ let r = load_run(&cli);+ let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));+ let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));+ let (rel, url, files) = build_comparison(&r, &b, &dir, &page);+ let mut notes = Vec::new();+ if *push {+ let stage = dir.join("report");+ let mut cmd = std::process::Command::new("adom-wiki");+ cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);+ for f in &files { cmd.arg(f); }+ match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }+ }+ if *refresh {+ if let Some(m) = r.data["userMachine"].as_str() {+ let br = Bridge { ai_thread: t.clone(), target: m.to_string() };+ let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+ let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));+ notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+ }+ }+ ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);+ }+ Cmd::Prompt { text, at } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let at = at.clone().unwrap_or_else(now);+ if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }+ let n = r.sessions().len() + 1;+ r.log("prompt", json!({"n": n, "text": text, "at": at}));+ r.data["clock"]["lastPromptTime"] = json!(at);+ r.save().unwrap();+ ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Say `done --message \"...\"` the moment your answer to this prompt is complete; the clock of this session stops there, and what sits idle after it is not charged to you.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into()]);+ }+ Cmd::Done { message } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let ss = r.sessions();+ let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };+ if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }+ let t = now();+ r.log("done", json!({"n": cur.n, "message": message}));+ r.save().unwrap();+ let ss = r.sessions();+ let cur = ss.last().unwrap();+ ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min idle cut out); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);+ }+ Cmd::Sessions { json: as_json } => {+ thread(&cli);+ let r = load_run(&cli);+ let ss = r.sessions();+ if *as_json {+ println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());+ return;+ }+ println!("{}", sessions_table(&r));+ ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is charged.".into()]);+ }+ Cmd::Usage => {+ thread(&cli);+ let mut r = load_run(&cli);+ let snap = record_usage(&mut r, "manual");+ r.save().unwrap();+ if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }+ ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);+ }++ Cmd::Models => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ let br = bridge_of(&r, &cli);+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let _ = t;+ r.stage_start("models", Some("binary"), None);+ let v = br.call("kicad_model_check", &json!({"filePath": rb}));+ 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(", "))); }+ }+ r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);+ r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));+ r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));+ r.save().unwrap();+ if !Bridge::ok(&v) && d["counts"].is_null() {+ err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);+ }+ if unresolved > 0 {+ err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!(" {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);+ }+ ok(&format!("every footprint has its 3D model ({} with a model, {} expect none)", counts["withModel"], counts["noModelExpected"]), &["Next: step placement.".into()]);+ }+ Cmd::Tour { what, nets, port, on } => {+ let t = thread(&cli);+ let mut r = load_run(&cli);+ let br = match on { Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() }, _ => bridge_of(&r, &cli) };+ let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+ let editor = if what == "fields" { 0 } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };+ match what.as_str() {+ "3d" => {+ r.stage_start("tour-3d", Some("binary"), None);+ // the viewer, its own window, is this step's camera: swap the running clip onto it+ let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));+ std::thread::sleep(std::time::Duration::from_secs(6));+ let find_viewer = || -> Option<i64> {+ let w = br.call("kicad_window_info", &json!({}));+ let d = w.get("data").cloned().unwrap_or(w.clone());+ d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())+ };+ let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));+ // a viewer opened in the background never paints its GL canvas (a white window with a+ // black bar, "last render time 1 ms"): show it once, maximised, and wait until the+ // bridge's screenshot says the canvas rendered, or refuse rather than film a blank+ let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));+ let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));+ let mut painted = false;+ for _ in 0..12 {+ std::thread::sleep(std::time::Duration::from_secs(3));+ let sh = br.call("kicad_screenshot_all", &json!({}));+ let d = sh.get("data").cloned().unwrap_or(sh.clone());+ if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {+ painted = true;+ break;+ }+ }+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let (p, _) = br.record_stop(&rid, &save_to);+ r.data["captures"][pos]["stop"] = json!(now());+ r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");+ }+ if !painted {+ let _ = br.call("kicad_close_3d_viewer", &json!({}));+ r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));+ r.save().unwrap();+ err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);+ }+ let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));+ r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));+ r.save().unwrap();+ let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));+ if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }+ let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));+ let cmds = tour["commands"].as_u64().unwrap_or(0);+ std::thread::sleep(std::time::Duration::from_secs(2));+ let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));+ if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+ let (p, _) = br.record_stop(&rid, &save_to);+ r.data["captures"][pos]["stop"] = json!(now());+ r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);+ r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+ }+ let _ = br.call("kicad_close_3d_viewer", &json!({}));+ r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);+ r.outcome("tour-3d", json!({"commands": cmds}));+ r.save().unwrap();+ if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }+ ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);+ }+ "nets" => {+ let spec = spec_of(&r);+ let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+ if list.is_empty() {+ for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+ for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }+ for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {+ let b = board_of(&r);+ for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }+ }+ }+ if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }+ r.stage_start("tour-nets", Some("binary"), None);+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ let mut toured: Vec<Value> = Vec::new();+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+ std::thread::sleep(std::time::Duration::from_millis(1500));+ for net in &list {+ let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));+ if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {+ let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+ r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);+ r.save().unwrap();+ err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);+ }+ r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));+ std::thread::sleep(std::time::Duration::from_millis(2600));+ let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();+ if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }+ let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));+ std::thread::sleep(std::time::Duration::from_millis(1200));+ toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));+ }+ let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));+ r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);+ r.outcome("tour-nets", json!({"nets": toured}));+ r.save().unwrap();+ ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);+ }+ "fields" => {+ // the fields step: adom-fields solved the board and serves it; a pup window on the test+ // box shows it, the recorder films that window while `adom-fields tour` drives it+ let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);+ if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }+ let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));+ r.stage_start("tour-fields", Some("binary"), None);+ // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready+ let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));+ for _ in 0..30 {+ let rd = br.call("pup_readiness", &json!({}));+ if rd["ready"].as_bool() == Some(true) { break; }+ std::thread::sleep(std::time::Duration::from_secs(3));+ }+ // a fresh pup session every time: a session whose browser died answers browser_gone forever+ let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());+ let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));+ let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };+ // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed+ let mut hwnd: Option<i64> = None;+ for _ in 0..40 {+ std::thread::sleep(std::time::Duration::from_secs(2));+ let lw = br.call("desktop_list_windows", &json!({}));+ // the relay wraps some answers as a JSON string under `output`+ let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+ let d = lw.get("data").cloned().or_else(|| inner.get("data").cloned()).unwrap_or(lw.clone());+ let wins = d["windows"].as_array().cloned().or_else(|| lw["windows"].as_array().cloned()).or_else(|| inner["windows"].as_array().cloned()).unwrap_or_default();+ // the app's window, not a wiki page about it: the app titles itself exactly "Adom Fields"+ hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));+ if hwnd.is_some() { break; }+ }+ let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on the test box (no window titled Adom Fields{})", open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into()]); };+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));+ // the editor clip of this visit is not the walkthrough: the app window is the camera+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let (p, _) = br.record_stop(&rid, &save_to);+ r.data["captures"][pos]["stop"] = json!(now());+ r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");+ }+ std::thread::sleep(std::time::Duration::from_secs(4));+ let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));+ r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));+ r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));+ r.save().unwrap();+ if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }+ // no pace of our own: the app holds each beat and drifts the board gently, because+ // this clip is played back at ten times speed+ let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();+ let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);+ std::thread::sleep(std::time::Duration::from_secs(2));+ if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {+ let (p, _) = br.record_stop(&rid, &save_to);+ r.data["captures"][pos]["stop"] = json!(now());+ r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);+ r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }+ }+ let _ = br.call("pup_close_window", &json!({"sessionId": sid}));+ // the solved numbers ride the run: the issues are what the AI answers for+ let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);+ let issues = fields["issues"].as_array().cloned().unwrap_or_default();+ r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);+ r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));+ r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));+ r.save().unwrap();+ if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }+ ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into()]);+ }+ other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),+ }+ }+ Cmd::Ledger { json: as_json } => {+ let r = load_run(&cli);+ let led = r.ledger();+ if led.is_empty() {+ err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);+ }+ if *as_json {+ for e in &led {+ println!("{e}");+ }+ } else {+ for e in &led {+ let t = e["t"].as_str().unwrap_or("");+ match e["event"].as_str().unwrap_or("") {+ "start" => println!("{t} start engine {} prompt {}", e["engine"], e["promptTime"]),+ "turn" => println!("{t} turn {:>3} {:<10} think {:>6.1} min {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),+ "turn-end" => println!("{t} end {:>3} {:<10} tool {:>6.1} min {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),+ "step" => println!("{t} step {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),+ "stage-end" => println!("{t} stage {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),+ "marker" => println!("{t} marker {}", e["label"].as_str().unwrap_or("")),+ "finish" | "deliver" => println!("{t} {} {}", e["event"].as_str().unwrap_or(""), e["minutes"]),+ "prompt" => println!("{t} prompt {:>2} {}", e["n"], e["text"].as_str().unwrap_or("")),+ "done" => println!("{t} done {:>2} {}", e["n"], e["message"].as_str().unwrap_or("")),+ _ => {}+ }+ }+ }+ println!("{}", step_table(&r));+ }+ Cmd::Step { name, back, why } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+ let known: Vec<&str> = known.iter().map(String::as_str).collect();+ let prev = r.data["currentStep"].as_str().map(str::to_string);+ record_usage(&mut r, &format!("step:{name}"));+ r.step_set(name, *back, why.as_deref());+ // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)+ let mut clip_note = String::new();+ if let Some(target) = r.data["target"].as_str().map(str::to_string) {+ let br = Bridge { ai_thread: thread(&cli), target };+ let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+ if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+ let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+ let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+ // the step's closing screenshot: what the board looked like when the step ended+ if let Some(h) = caps[pos]["hwnd"].as_i64() {+ let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));+ if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {+ r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));+ }+ }+ let (p, _) = br.record_stop(&rid, &save_to);+ let cap = &mut r.data["captures"][pos];+ cap["stop"] = json!(now());+ cap["remoteFile"] = json!(p);+ cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+ let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+ let local = cap["file"].as_str().map(str::to_string);+ let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());+ r.log("clip-stop", json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid, "file": p}));+ if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+ clip_artifacts(&mut r, &st, &f, 10);+ }+ clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+ }+ // the fields step films the app's window through `tour fields`: no editor clip for it+ let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };+ let disk = disk_check(&dir, &br, name);+ if let Err(e) = &disk {+ r.log("clip-refused", json!({"step": name, "why": e}));+ r.save().unwrap();+ err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);+ }+ match hwnd {+ Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {+ Ok(rid) => {+ // 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}));+ 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")) {+ r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));+ }+ r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));+ clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));+ }+ Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {+ // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live+ match br.pcb_editor_hwnd() {+ Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {+ 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": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }+ Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),+ },+ _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),+ }+ }+ Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+ },+ None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),+ None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),+ }+ }+ r.save().unwrap();+ let mut report_note = String::new();+ if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+ let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+ report_note = format!("Progress page: {url} ({})", notes.join("; "));+ }+ 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]);+ }+ Cmd::Deliver { video, message, accept_suspect } => {+ thread(&cli);+ let mut r = load_run(&cli);+ let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();+ if !suspects.is_empty() && !*accept_suspect {+ err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!(" {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);+ }+ if r.data["clock"]["finishTime"].is_null() {+ err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);+ }+ let vp = Path::new(video);+ if !vp.is_file() {+ err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);+ }+ 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 };+ stop_running_clip(&mut r, &brs, &dir, "deliver");+ }+ let t = now();+ r.data["clock"]["deliveredTime"] = json!(t);+ record_usage(&mut r, "deliver");+ r.data["summary"]["planUsage"] = json!(usage_delta(&r));+ 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"]["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.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);+ let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));+ if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+ 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()]);+ }+ }+}
crates/aiflow-analyze/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "aiflow-analyze"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: analyze"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-analyze/src/lib.rsadded+191@@ -0,0 +1,191 @@+//! Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.+//!+//! 0.1 is deliberately conservative and says so: tracks and vias by IPC-2221's external-layer+//! 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.+use std::collections::BTreeMap;++use serde_json::{json, Value};++const MIL2_PER_MM2: f64 = 1550.0031;++pub fn ipc2221_amps(width_mm: f64, thickness_um: f64, rise_c: f64, external: bool) -> f64 {+ let k = if external { 0.048 } else { 0.024 };+ let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;+ k * rise_c.powf(0.44) * a.powf(0.725)+}++pub fn rise_for(width_mm: f64, thickness_um: f64, amps: f64, external: bool) -> f64 {+ let k = if external { 0.048 } else { 0.024 };+ let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;+ if a <= 0.0 || amps <= 0.0 {+ return 0.0;+ }+ (amps / (k * a.powf(0.725))).powf(1.0 / 0.44)+}++struct NetCopper {+ widths: Vec<f64>,+ vias: usize,+ via_drill: f64,+}++fn plan_widths(plan: &Value) -> BTreeMap<String, NetCopper> {+ let mut out: BTreeMap<String, NetCopper> = BTreeMap::new();+ for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let d = out.entry(net).or_insert(NetCopper { widths: Vec::new(), vias: 0, via_drill: e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3) });+ if let Some(w) = e.get("width").and_then(|w| w.as_f64()) {+ if !d.widths.iter().any(|x| (x - w).abs() < 1e-9) {+ d.widths.push(w);+ }+ }+ for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+ d.vias += path.as_array().map(|a| a.iter().filter(|w| w.get("layer").is_some() && w.get("x").is_some()).count()).unwrap_or(0);+ }+ }+ out+}++fn pours_by_net(zone_state: Option<&Value>) -> BTreeMap<String, Vec<Value>> {+ let mut m: BTreeMap<String, Vec<Value>> = BTreeMap::new();+ for z in zone_state.and_then(|z| z.get("zones")).and_then(|z| z.as_array()).cloned().unwrap_or_default() {+ if z.get("type").and_then(|t| t.as_str()) == Some("copper") {+ if let Some(net) = z.get("net").and_then(|n| n.as_str()) {+ if !net.is_empty() {+ m.entry(net.to_string()).or_default().push(z);+ }+ }+ }+ }+ m+}++fn fill_area(zs: &[Value]) -> f64 {+ zs.iter().flat_map(|z| z.get("fills").and_then(|f| f.as_array()).cloned().unwrap_or_default()).map(|f| f.get("areaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0)).sum()+}++fn layers_of(zs: &[Value]) -> Vec<String> {+ let mut v: Vec<String> = zs.iter().flat_map(|z| z.get("layers").and_then(|l| l.as_array()).cloned().unwrap_or_default()).filter_map(|l| l.as_str().map(str::to_string)).collect();+ v.sort();+ v.dedup();+ v+}++/// spec.loads: {net: {amps, peakAmps?, maxRiseC?}}, spec.copperUm outer thickness (35), spec.maxRiseC (10).+/// `pad_layers`: net -> the outer layers its SMD pads sit on (through-hole pads count for neither).+/// When every pad of a loaded net is on one outer layer and that layer is poured, the pour on that+/// layer carries the current between them; vias on the net then only spread heat.+pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>) -> (bool, Vec<Value>) {+ let t_um = spec.get("copperUm").and_then(|v| v.as_f64()).unwrap_or(35.0);+ let max_rise_default = spec.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(10.0);+ let widths = plan_widths(plan);+ let pours = pours_by_net(zone_state);+ let mut results = Vec::new();+ let mut ok = true;+ for (net, ld) in spec.get("loads").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+ let amps = ld.get("amps").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let max_rise = ld.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(max_rise_default);+ let Some(w) = widths.get(&net) else {+ results.push(json!({"net": net, "amps": amps, "status": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")}));+ ok = false;+ continue;+ };+ let mut signal: Vec<f64> = w.widths.iter().copied().filter(|x| *x > 0.2).collect();+ signal.sort_by(|a, b| a.partial_cmp(b).unwrap());+ let narrowest = signal.first().copied().unwrap_or_else(|| w.widths.iter().cloned().fold(f64::INFINITY, f64::min));+ let widest = w.widths.iter().cloned().fold(0.0, f64::max);+ let rise_narrow = rise_for(narrowest, t_um, amps, true);+ let rise_wide = rise_for(widest, t_um, amps, true);+ let zs = pours.get(&net).cloned().unwrap_or_default();+ let pour_layers = layers_of(&zs);+ let pour_area = (fill_area(&zs) * 10.0).round() / 10.0;+ let via_amps = if w.via_drill >= 0.4 { 1.5 } else { 1.0 };+ let via_capacity = w.vias as f64 * via_amps;+ let mut r = json!({"net": net, "amps": amps, "maxRiseC": max_rise, "narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": (rise_narrow * 10.0).round() / 10.0, "riseAtWidestC": (rise_wide * 10.0).round() / 10.0, "vias": w.vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area});+ if rise_wide <= max_rise {+ r["status"] = json!("pass");+ r["how"] = json!(format!("the widest track carries the load within {max_rise:.0} C"));+ } else if !pour_layers.is_empty() && pour_area > 0.0 {+ let one_layer = pad_layers.and_then(|m| m.get(&net)).filter(|l| l.len() == 1).map(|l| l[0].clone()).filter(|l| pour_layers.contains(l));+ if let (true, Some(l)) = (pour_layers.len() >= 2 && via_capacity < amps, one_layer.clone()) {+ r["status"] = json!("pass");+ r["how"] = json!(format!("every pad of {net} is on {l} and the {l} pour ({pour_area:.0} mm2 on {}) spans them; the other layer and its {} vias spread heat", pour_layers.join("+"), w.vias));+ } else if pour_layers.len() >= 2 && via_capacity < amps {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{net} is carried by pours on {} but only {} vias ({via_capacity:.0} A) join the layers for {amps:.1} A: add stitching vias (0.8/0.4 mm, about 1.5 A each)", pour_layers.join("+"), w.vias));+ ok = false;+ } else {+ r["status"] = json!("pass");+ r["how"] = json!(format!("pour on {} ({pour_area:.0} mm2 filled) carries the load; tracks are {widest:.2} mm at most", pour_layers.join("+")));+ }+ } else {+ r["status"] = json!("fail");+ let need = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0].iter().find(|w| rise_for(**w, t_um, amps, true) <= max_rise).map(|w| format!("{w}")).unwrap_or_else(|| ">6".into());+ r["hint"] = json!(format!("{net} carries {amps:.1} A on a {widest:.2} mm track ({rise_wide:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm"));+ ok = false;+ }+ results.push(r);+ }+ (ok, results)+}++/// spec.hot: {ref: {watts, tabNet, maxThetaCPerW?}}. 0.1: copper presence and vias at the tab.+pub fn analyze_thermal(plan: &Value, spec: &Value, zone_state: Option<&Value>) -> (bool, Vec<Value>) {+ let widths = plan_widths(plan);+ let pours = pours_by_net(zone_state);+ let mut results = Vec::new();+ let mut ok = true;+ for (reference, h) in spec.get("hot").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+ let net = h.get("tabNet").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let watts = h.get("watts").and_then(|v| v.as_f64()).unwrap_or(0.0);+ let zs = pours.get(&net).cloned().unwrap_or_default();+ let area = (fill_area(&zs) * 10.0).round() / 10.0;+ let layers = layers_of(&zs);+ let vias = widths.get(&net).map(|w| w.vias).unwrap_or(0);+ let theta = 40.0 / (area / 645.16).max(0.05);+ // limits: a theta ceiling (maxThetaCPerW, per part or spec-wide) and/or a rise budget+ // (maxRiseC per part, hotMaxRiseC spec-wide); with neither, 60 C/W+ let theta_limit = h.get("maxThetaCPerW").and_then(|v| v.as_f64()).or_else(|| spec.get("maxThetaCPerW").and_then(|v| v.as_f64()));+ let rise_limit = h.get("maxRiseC").and_then(|v| v.as_f64()).or_else(|| spec.get("hotMaxRiseC").and_then(|v| v.as_f64()));+ let limit = theta_limit.unwrap_or(if rise_limit.is_some() { f64::INFINITY } else { 60.0 });+ let rise = theta * watts;+ let mut r = json!({"ref": reference, "watts": watts, "tabNet": net, "pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": (theta * 10.0).round() / 10.0, "limitCPerW": theta_limit, "riseEstimateC": (rise * 10.0).round() / 10.0, "riseLimitC": rise_limit});+ if zs.is_empty() {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference} dissipates {watts:.1} W into {net} but {net} has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab"));+ ok = false;+ } else if theta > limit {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference}: {area:.0} mm2 of copper on {net} gives about {theta:.0} C/W (limit {limit:.0}): widen the pour or add the other outer layer with vias"));+ ok = false;+ } else if rise_limit.is_some_and(|l| rise > l) {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference}: {watts:.2} W into {area:.0} mm2 of {net} copper is about {rise:.0} C above ambient (budget {:.0} C): widen the pour or add the other outer layer with vias", rise_limit.unwrap()));+ ok = false;+ } else if vias == 0 && layers.len() >= 2 {+ r["status"] = json!("fail");+ r["hint"] = json!(format!("{reference}: the {net} pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab"));+ ok = false;+ } else {+ r["status"] = json!("pass");+ r["how"] = json!(format!("{area:.0} mm2 of {net} copper on {}, about {theta:.0} C/W, {rise:.0} C at {watts:.2} W, {vias} vias", layers.join("+")));+ }+ results.push(r);+ }+ (ok, results)+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn ipc_curve_is_sane() {+ // a 1 mm track of 35 um copper carries about 2 to 3 A at 10 C rise on an outer layer+ let a = ipc2221_amps(1.0, 35.0, 10.0, true);+ assert!(a > 1.5 && a < 3.5, "{a}");+ assert!(rise_for(0.25, 35.0, 20.0, true) > 100.0);+ }+}
crates/aiflow-board/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "aiflow-board"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: board"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-board/examples/dump.rsadded+12@@ -0,0 +1,12 @@+use aiflow_board::Board;+fn main() {+ let p = std::env::args().nth(1).unwrap();+ let b = Board::load(&p).unwrap();+ let thru = b.pads.iter().filter(|p| p.thru).count();+ let f = b.pads.iter().filter(|p| p.on_f && !p.thru).count();+ let bb = b.pads.iter().filter(|p| p.on_b && !p.thru).count();+ println!("pads {} thru {} smd-f {} smd-b {} footprints {} nets {} layers {:?} bbox {:?}", b.pads.len(), thru, f, bb, b.footprints.len(), b.nets.len(), b.copper_layers, b.bbox);+ let mut by_ref: std::collections::BTreeMap<String, usize> = Default::default();+ for p in &b.pads { *by_ref.entry(p.reference.clone()).or_default() += 1; }+ println!("{:?}", by_ref.iter().take(12).collect::<Vec<_>>());+}
crates/aiflow-board/src/geom.rsadded+195@@ -0,0 +1,195 @@+//! Plane geometry shared by every stage: KiCad's rotation convention, pad shapes, arcs, outlines.++pub type Pt = (f64, f64);++/// KiCad's board rotation of a local offset (y down, positive angle = counter-clockwise on screen).+pub fn rot_kicad(px: f64, py: f64, deg: f64) -> Pt {+ let a = deg.to_radians();+ let (s, c) = a.sin_cos();+ (px * c + py * s, -px * s + py * c)+}++/// A circle as a polygon that CONTAINS the true circle: the vertices sit on a radius of+/// r / cos(pi / n), so every chord is tangent to the real arc. A polygon inscribed in the arc is+/// smaller than the copper by r (1 - cos(pi / n)), and KiCad's DRC measures the copper: with+/// 10 segments per semicircle that is 0.0012 mm on a 0.3 mm oval pad, and a 0.1989 mm clearance+/// against a 0.2 mm rule.+pub fn circle_poly(cx: f64, cy: f64, r: f64, n: usize) -> Vec<Pt> {+ let rr = r / (std::f64::consts::PI / n as f64).cos();+ (0..n).map(|k| {+ let t = 2.0 * std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+ (cx + rr * t.cos(), cy + rr * t.sin())+ }).collect()+}++/// Oval pad of size sx by sy centred at the origin.+pub fn stadium_poly(sx: f64, sy: f64, n: usize) -> Vec<Pt> {+ if sx >= sy {+ let r = sy / 2.0;+ let d = (sx - sy) / 2.0;+ // circumscribed arcs (see circle_poly): the polygon contains the real copper+ let rr = r / (std::f64::consts::PI / (2 * n) as f64).cos();+ let mut pts = Vec::with_capacity(2 * n + 2);+ for k in 0..n {+ let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+ pts.push((d + rr * a.cos(), rr * a.sin()));+ }+ for k in 0..n {+ let a = std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+ pts.push((-d + rr * a.cos(), rr * a.sin()));+ }+ pts+ } else {+ stadium_poly(sy, sx, n).into_iter().map(|(x, y)| (y, x)).collect()+ }+}++/// A KiCad three-point arc (start, mid, end) sampled into a polyline including both ends.+pub fn arc_points(p0: Pt, pm: Pt, p1: Pt, max_step: f64) -> Vec<Pt> {+ let (x0, y0) = p0;+ let (xm, ym) = pm;+ let (x1, y1) = p1;+ let (ax, ay) = (xm - x0, ym - y0);+ let (bx, by) = (x1 - x0, y1 - y0);+ let d = 2.0 * (ax * by - ay * bx);+ if d.abs() < 1e-9 {+ return vec![p0, p1];+ }+ let a2 = ax * ax + ay * ay;+ let b2 = bx * bx + by * by;+ let ux = (by * a2 - ay * b2) / d;+ let uy = (ax * b2 - bx * a2) / d;+ let (cx, cy) = (x0 + ux, y0 + uy);+ let r = ux.hypot(uy);+ let t0 = (y0 - cy).atan2(x0 - cx);+ let tm = (ym - cy).atan2(xm - cx);+ let t1 = (y1 - cy).atan2(x1 - cx);+ let ccw = |a: f64, b: f64| (b - a).rem_euclid(2.0 * std::f64::consts::PI);+ let sweep = if ccw(t0, tm) <= ccw(t0, t1) { ccw(t0, t1) } else { -ccw(t1, t0) };+ let n = ((sweep.abs() * r / max_step).ceil() as usize).max(2);+ (0..=n).map(|k| {+ let t = t0 + sweep * k as f64 / n as f64;+ (cx + r * t.cos(), cy + r * t.sin())+ }).collect()+}++fn dist(a: Pt, b: Pt) -> f64 {+ (a.0 - b.0).hypot(a.1 - b.1)+}++/// Join open polylines end to end into one closed loop (the board outline).+pub fn chain_polylines(mut pieces: Vec<Vec<Pt>>, tol: f64) -> Result<Vec<Pt>, String> {+ pieces.retain(|p| p.len() >= 2);+ if pieces.is_empty() {+ return Ok(Vec::new());+ }+ let mut looped = pieces.remove(0);+ while !pieces.is_empty() {+ let end = *looped.last().unwrap();+ let mut best: Option<(usize, bool)> = None;+ for (idx, p) in pieces.iter().enumerate() {+ if dist(end, p[0]) < tol {+ best = Some((idx, false));+ break;+ }+ if dist(end, *p.last().unwrap()) < tol {+ best = Some((idx, true));+ break;+ }+ }+ let Some((idx, rev)) = best else {+ return Err(format!("Edge.Cuts outline is not one closed loop (gap at {:.3}, {:.3})", end.0, end.1));+ };+ let mut p = pieces.remove(idx);+ if rev {+ p.reverse();+ }+ looped.extend(p.into_iter().skip(1));+ }+ if looped.len() > 1 && dist(looped[0], *looped.last().unwrap()) < tol {+ looped.pop();+ }+ Ok(looped)+}++pub fn polygon_area(poly: &[Pt]) -> f64 {+ let n = poly.len();+ if n < 3 {+ return 0.0;+ }+ let mut a = 0.0;+ for i in 0..n {+ let (x0, y0) = poly[i];+ let (x1, y1) = poly[(i + 1) % n];+ a += x0 * y1 - x1 * y0;+ }+ a / 2.0+}++pub fn point_in_poly(x: f64, y: f64, poly: &[Pt]) -> bool {+ let mut inside = false;+ let n = poly.len();+ for k in 0..n {+ let (ax, ay) = poly[k];+ let (bx, by) = poly[(k + 1) % n];+ if (ay > y) != (by > y) && x < ax + (y - ay) * (bx - ax) / (by - ay) {+ inside = !inside;+ }+ }+ inside+}++pub fn seg_distance(x: f64, y: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {+ let (dx, dy) = (bx - ax, by - ay);+ let l2 = dx * dx + dy * dy;+ if l2 < 1e-12 {+ return (x - ax).hypot(y - ay);+ }+ let t = (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0);+ (x - (ax + t * dx)).hypot(y - (ay + t * dy))+}++/// 0 inside the polygon, else the distance to its boundary.+pub fn point_poly_distance(x: f64, y: f64, poly: &[Pt]) -> f64 {+ if point_in_poly(x, y, poly) {+ return 0.0;+ }+ let n = poly.len();+ let mut best = f64::INFINITY;+ for k in 0..n {+ let (ax, ay) = poly[k];+ let (bx, by) = poly[(k + 1) % n];+ best = best.min(seg_distance(x, y, ax, ay, bx, by));+ }+ best+}++/// Cheap outward growth of a polygon: every vertex pushed away from the centroid by d.+pub fn grow_poly(poly: &[Pt], d: f64) -> Vec<Pt> {+ let n = poly.len() as f64;+ let cx = poly.iter().map(|p| p.0).sum::<f64>() / n;+ let cy = poly.iter().map(|p| p.1).sum::<f64>() / n;+ poly.iter().map(|&(x, y)| {+ let (vx, vy) = (x - cx, y - cy);+ let l = vx.hypot(vy).max(1e-9);+ (x + vx / l * d, y + vy / l * d)+ }).collect()+}++pub fn thick_line_poly(a: Pt, b: Pt, hw: f64) -> Vec<Pt> {+ let (dx, dy) = (b.0 - a.0, b.1 - a.1);+ let l = dx.hypot(dy).max(1e-9);+ let (nx, ny) = (-dy / l * hw, dx / l * hw);+ vec![(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)]+}++pub fn bbox(pts: &[Pt]) -> (f64, f64, f64, f64) {+ let mut b = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+ for &(x, y) in pts {+ b.0 = b.0.min(x);+ b.1 = b.1.min(y);+ b.2 = b.2.max(x);+ b.3 = b.3.max(y);+ }+ b+}
crates/aiflow-board/src/lib.rsadded+338@@ -0,0 +1,338 @@+//! The board model every stage reads: a KiCad 10 `.kicad_pcb` as footprints, copper pads with+//! their real outlines in board coordinates, nets, the Edge.Cuts outline and the copper layers.+//! Nothing here writes a file; the writers splice into `text` using the spans the reader keeps.+pub mod geom;+pub mod sx;++use std::collections::{BTreeMap, HashMap};++pub use geom::Pt;+pub use sx::{num, quote, Node};++pub const F_CU: usize = 0;+pub const B_CU: usize = 1;+pub const LAYER_NAMES: [&str; 2] = ["F.Cu", "B.Cu"];++#[derive(Debug, Clone)]+pub struct Pad {+ pub reference: String,+ pub name: String,+ pub key: String,+ pub net: String,+ pub x: f64,+ pub y: f64,+ pub angle: f64,+ pub shape: String,+ pub size: (f64, f64),+ pub drill: Option<f64>,+ pub thru: bool,+ pub on_f: bool,+ pub on_b: bool,+ pub polys: Vec<Vec<Pt>>,+ pub fp_x: f64,+ pub fp_y: f64,+ pub long_axis: Pt,+ pub half_long: f64,+ pub half_short: f64,+ pub uuid: String,+ pub polys_centre: Pt,+ pub clearance: f64,+}++impl Pad {+ pub fn min_dim(&self) -> f64 {+ 2.0 * self.half_short+ }+ pub fn on_layer(&self, layer: usize) -> bool {+ if layer == F_CU { self.on_f } else { self.on_b }+ }+}++#[derive(Debug, Clone)]+pub struct Footprint {+ pub reference: String,+ pub x: f64,+ pub y: f64,+ pub rot: f64,+ pub layer: String,+ pub lib: String,+ pub start: usize,+ pub end: usize,+}++#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum NetFormat {+ Number,+ Name,+}++pub struct Board {+ pub path: String,+ pub text: String,+ pub root: Node,+ pub net_table: BTreeMap<i64, String>,+ pub net_format: NetFormat,+ pub pads: Vec<Pad>,+ pub footprints: BTreeMap<String, Footprint>,+ pub outline: Vec<Pt>,+ pub bbox: (f64, f64, f64, f64),+ pub nets: BTreeMap<String, Vec<usize>>,+ pub copper_layers: Vec<String>,+ pad_index: HashMap<String, usize>,+}++fn pad_local_polys(pn: &Node, shape: &str, sx: f64, sy: f64) -> Vec<Vec<Pt>> {+ let rectp = |sx: f64, sy: f64| vec![(-sx / 2.0, -sy / 2.0), (sx / 2.0, -sy / 2.0), (sx / 2.0, sy / 2.0), (-sx / 2.0, sy / 2.0)];+ let mut polys = Vec::new();+ match shape {+ "rect" | "roundrect" | "trapezoid" | "chamfer" => polys.push(rectp(sx, sy)),+ "oval" => polys.push(geom::stadium_poly(sx, sy, 10)),+ "circle" => polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32)),+ "custom" => {+ let anchor = pn.find("options").and_then(|o| o.find("anchor")).and_then(|a| a.value()).unwrap_or("rect");+ if anchor == "circle" {+ polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32));+ } else {+ polys.push(rectp(sx, sy));+ }+ if let Some(prims) = pn.find("primitives") {+ for prim in prims.nodes() {+ match prim.tag.as_str() {+ "gr_poly" => {+ if let Some(pts) = prim.find("pts") {+ let mut poly: Vec<Pt> = pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect();+ let w = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);+ if w > 0.0 {+ poly = geom::grow_poly(&poly, w / 2.0);+ }+ if poly.len() >= 3 {+ polys.push(poly);+ }+ }+ }+ "gr_rect" => {+ if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {+ if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {+ polys.push(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]);+ }+ }+ }+ "gr_circle" => {+ if let (Some(c), Some(e)) = (prim.find("center"), prim.find("end")) {+ if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {+ let r = (ex - cx).hypot(ey - cy) + prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0) / 2.0;+ polys.push(geom::circle_poly(cx, cy, r, 32));+ }+ }+ }+ "gr_line" => {+ if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {+ let hw = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.1) / 2.0;+ if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {+ polys.push(geom::thick_line_poly((x0, y0), (x1, y1), hw));+ }+ }+ }+ _ => {}+ }+ }+ }+ }+ _ => polys.push(rectp(sx, sy)),+ }+ polys+}++pub fn reference_of(fp: &Node) -> Option<String> {+ for p in fp.find_all("property") {+ if p.atom(1) == Some("Reference") {+ return p.atom(2).map(str::to_string);+ }+ }+ fp.find("fp_text").filter(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)).map(str::to_string)+}++impl Board {+ pub fn load(path: &str) -> Result<Board, String> {+ let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+ Board::from_text(path, text)+ }++ pub fn from_text(path: &str, text: String) -> Result<Board, String> {+ let root = sx::parse(&text)?;+ if root.tag != "kicad_pcb" {+ return Err(format!("{path} is not a kicad_pcb"));+ }+ let mut net_table = BTreeMap::new();+ for n in root.find_all("net") {+ if let (Some(k), Some(name)) = (n.atom(1).and_then(|a| a.parse::<i64>().ok()), n.atom(2)) {+ net_table.insert(k, name.to_string());+ }+ }+ 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() };+ b.read_footprints();+ b.outline = b.read_outline()?;+ b.bbox = geom::bbox(&b.outline);+ for (i, p) in b.pads.iter().enumerate() {+ if !p.net.is_empty() {+ b.nets.entry(p.net.clone()).or_default().push(i);+ }+ b.pad_index.insert(p.key.clone(), i);+ }+ if let Some(layers) = b.root.find("layers") {+ for l in layers.nodes() {+ let kind = l.atom(2).unwrap_or("");+ if matches!(kind, "signal" | "power" | "mixed") {+ if let Some(name) = l.atom(1) {+ b.copper_layers.push(name.to_string());+ }+ }+ }+ }+ b.text = text;+ Ok(b)+ }++ fn read_footprints(&mut self) {+ let root = self.root.clone();+ for fp in root.find_all("footprint") {+ let Some(reference) = reference_of(fp) else { continue };+ let at = fp.find("at");+ let fx = at.and_then(|a| a.number(1)).unwrap_or(0.0);+ let fy = at.and_then(|a| a.number(2)).unwrap_or(0.0);+ let rot = at.and_then(|a| a.number(3)).unwrap_or(0.0);+ let layer = fp.find("layer").and_then(|l| l.value()).unwrap_or("F.Cu").to_string();+ let fp_clearance = fp.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);+ self.footprints.insert(reference.clone(), Footprint { reference: reference.clone(), x: fx, y: fy, rot, layer, lib: fp.value().unwrap_or("").to_string(), start: fp.start, end: fp.end });+ for pn in fp.find_all("pad") {+ let name = pn.value().unwrap_or("").to_string();+ let ptype = pn.atom(2).unwrap_or("");+ let shape = pn.atom(3).unwrap_or("rect").to_string();+ let mut on_f = false;+ let mut on_b = false;+ let mut copper = false;+ if let Some(ln) = pn.find("layers") {+ for l in ln.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.as_str()) } else { None }) {+ if l == "*.Cu" {+ on_f = true; on_b = true; copper = true;+ } else if l.ends_with(".Cu") {+ copper = true;+ if l == "F.Cu" { on_f = true; }+ if l == "B.Cu" { on_b = true; }+ }+ }+ }+ if !copper || name.is_empty() {+ continue;+ }+ 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 size = pn.find("size");+ let sx_ = size.and_then(|s| s.number(1)).unwrap_or(0.0);+ let sy_ = size.and_then(|s| s.number(2)).unwrap_or(0.0);+ let drill = pn.find("drill").and_then(|d| d.number(1));+ let mut net = pn.find("net").and_then(|n| n.value()).unwrap_or("").to_string();+ if self.net_format == NetFormat::Number {+ if let Some(k) = pn.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {+ if let Some(nm) = self.net_table.get(&k) {+ net = nm.clone();+ }+ }+ }+ let (ox, oy) = geom::rot_kicad(px, py, rot);+ let (x, y) = (fx + ox, fy + oy);+ let thru = ptype == "thru_hole" || ptype == "np_thru_hole";+ let pclr = pn.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);+ let local = pad_local_polys(pn, &shape, sx_, sy_);+ let (x0, y0, x1, y1) = geom::bbox(&local.iter().flatten().copied().collect::<Vec<_>>());+ let (lx, ly) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);+ let (lcx, lcy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+ let (half_long, half_short, axis) = if lx >= ly { (lx, ly, geom::rot_kicad(1.0, 0.0, pang)) } else { (ly, lx, geom::rot_kicad(0.0, 1.0, pang)) };+ let polys: Vec<Vec<Pt>> = local.iter().map(|poly| poly.iter().map(|&(a, b)| { let (rx, ry) = geom::rot_kicad(a, b, pang); (x + rx, y + ry) }).collect()).collect();+ let (ccx, ccy) = geom::rot_kicad(lcx, lcy, pang);+ self.pads.push(Pad {+ key: format!("{reference}.{name}"), reference: reference.clone(), name, net, x, y, angle: pang, shape, size: (sx_, sy_), drill, thru,+ on_f: on_f && (thru || true) && on_f, on_b: on_b || thru, polys, fp_x: fx, fp_y: fy, long_axis: axis, half_long, half_short,+ uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(), polys_centre: (x + ccx, y + ccy), clearance: fp_clearance.max(pclr),+ });+ }+ }+ }++ fn read_outline(&self) -> Result<Vec<Pt>, String> {+ let mut pieces: Vec<Vec<Pt>> = Vec::new();+ for node in self.root.nodes() {+ if !node.tag.starts_with("gr_") {+ continue;+ }+ if node.find("layer").and_then(|l| l.value()) != Some("Edge.Cuts") {+ continue;+ }+ let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };+ match node.tag.as_str() {+ "gr_line" => {+ if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {+ pieces.push(vec![s, e]);+ }+ }+ "gr_arc" => {+ if let (Some(s), Some(m), Some(e)) = (p2(node.find("start")), p2(node.find("mid")), p2(node.find("end"))) {+ pieces.push(geom::arc_points(s, m, e, 0.35));+ }+ }+ "gr_rect" => {+ if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {+ return Ok(vec![s, (e.0, s.1), e, (s.0, e.1)]);+ }+ }+ "gr_circle" => {+ if let (Some(c), Some(e)) = (p2(node.find("center")), p2(node.find("end"))) {+ return Ok(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 64));+ }+ }+ "gr_poly" => {+ if let Some(pts) = node.find("pts") {+ return Ok(pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect());+ }+ }+ _ => {}+ }+ }+ if pieces.is_empty() {+ return Err("no Edge.Cuts outline".into());+ }+ geom::chain_polylines(pieces, 0.01)+ }++ /// The `(net ...)` line for copper on this board (matches the bridge's net_sexpr).+ pub fn net_ref_sexpr(&self, net: &str) -> Result<String, String> {+ match self.net_format {+ NetFormat::Number => {+ let n = self.net_table.iter().find(|(_, v)| v.as_str() == net).map(|(k, _)| *k).ok_or_else(|| format!("net {net:?} is not in the net table"))?;+ Ok(format!("\t\t(net {n})\n"))+ }+ NetFormat::Name => Ok(format!("\t\t(net {})\n", quote(net))),+ }+ }++ pub fn pad_by_key(&self, key: &str) -> Option<&Pad> {+ self.pad_index.get(key).map(|&i| &self.pads[i])+ }++ pub fn pad_idx(&self, key: &str) -> Option<usize> {+ self.pad_index.get(key).copied()+ }++ pub fn pads_of(&self, reference: &str) -> Vec<&Pad> {+ self.pads.iter().filter(|p| p.reference == reference).collect()+ }++ /// Pad-box (copper extent) of every pad of a footprint: (x0, y0, x1, y1).+ pub fn pad_box(&self, reference: &str) -> Option<(f64, f64, f64, f64)> {+ let pts: Vec<Pt> = self.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).collect();+ if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }+ }+}
crates/aiflow-board/src/sx.rsadded+197@@ -0,0 +1,197 @@+//! A span-keeping S-expression reader for `.kicad_pcb` files: every node knows where it starts and+//! ends in the text, so the writers splice edits without re-serialising anything.++#[derive(Debug, Clone)]+pub enum Item {+ Atom { text: String, start: usize, end: usize },+ Node(Node),+}++#[derive(Debug, Clone)]+pub struct Node {+ pub tag: String,+ pub items: Vec<Item>,+ pub start: usize,+ pub end: usize,+}++impl Node {+ /// The i-th atom (0 = the tag), unquoted.+ pub fn atom(&self, i: usize) -> Option<&str> {+ if i == 0 {+ return Some(&self.tag);+ }+ match self.items.get(i - 1) {+ Some(Item::Atom { text, .. }) => Some(text.as_str()),+ _ => None,+ }+ }++ pub fn number(&self, i: usize) -> Option<f64> {+ self.atom(i)?.parse::<f64>().ok()+ }++ /// The first atom after the tag (what `(layer "F.Cu")` means).+ pub fn value(&self) -> Option<&str> {+ self.atom(1)+ }++ pub fn find(&self, tag: &str) -> Option<&Node> {+ self.items.iter().find_map(|it| match it {+ Item::Node(n) if n.tag == tag => Some(n),+ _ => None,+ })+ }++ pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a {+ self.items.iter().filter_map(move |it| match it {+ Item::Node(n) if n.tag == tag => Some(n),+ _ => None,+ })+ }++ pub fn nodes(&self) -> impl Iterator<Item = &Node> {+ self.items.iter().filter_map(|it| match it {+ Item::Node(n) => Some(n),+ _ => None,+ })+ }+}++fn unescape(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ let mut chars = s.chars();+ while let Some(c) = chars.next() {+ if c == '\\' {+ match chars.next() {+ Some('n') => out.push('\n'),+ Some('t') => out.push('\t'),+ Some(o) => out.push(o),+ None => {}+ }+ } else {+ out.push(c);+ }+ }+ out+}++/// Parse the whole text; the root is the outer list (normally `kicad_pcb`).+pub fn parse(text: &str) -> Result<Node, String> {+ let b = text.as_bytes();+ let mut i = 0;+ while i < b.len() && b[i].is_ascii_whitespace() {+ i += 1;+ }+ if i >= b.len() || b[i] != b'(' {+ return Err("text does not start with a list".into());+ }+ let (node, _) = parse_node(text, b, i)?;+ Ok(node)+}++fn parse_node(text: &str, b: &[u8], start: usize) -> Result<(Node, usize), String> {+ // b[start] == '('+ let mut i = start + 1;+ let mut items = Vec::new();+ let mut tag = String::new();+ let mut have_tag = false;+ loop {+ while i < b.len() && b[i].is_ascii_whitespace() {+ i += 1;+ }+ if i >= b.len() {+ return Err(format!("unterminated list starting at {start}"));+ }+ match b[i] {+ b')' => {+ return Ok((Node { tag, items, start, end: i + 1 }, i + 1));+ }+ b'(' => {+ let (n, e) = parse_node(text, b, i)?;+ items.push(Item::Node(n));+ i = e;+ }+ b'"' => {+ let s = i;+ i += 1;+ let mut raw = String::new();+ while i < b.len() && b[i] != b'"' {+ if b[i] == b'\\' && i + 1 < b.len() {+ raw.push(b[i] as char);+ raw.push(b[i + 1] as char);+ i += 2;+ continue;+ }+ // copy one UTF-8 char+ let ch = text[i..].chars().next().unwrap();+ raw.push(ch);+ i += ch.len_utf8();+ }+ if i >= b.len() {+ return Err(format!("unterminated string at {s}"));+ }+ i += 1;+ let txt = unescape(&raw);+ if !have_tag {+ tag = txt;+ have_tag = true;+ } else {+ items.push(Item::Atom { text: txt, start: s, end: i });+ }+ }+ _ => {+ let s = i;+ while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'(' && b[i] != b')' {+ i += 1;+ }+ let txt = text[s..i].to_string();+ if !have_tag {+ tag = txt;+ have_tag = true;+ } else {+ items.push(Item::Atom { text: txt, start: s, end: i });+ }+ }+ }+ }+}++/// KiCad's own float formatting: trimmed, at most six decimals, never "-0".+pub fn num(v: f64) -> String {+ let mut s = format!("{:.6}", v);+ while s.ends_with('0') {+ s.pop();+ }+ if s.ends_with('.') {+ s.pop();+ }+ if s.is_empty() || s == "-" || s == "-0" {+ "0".to_string()+ } else {+ s+ }+}++pub fn quote(s: &str) -> String {+ format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn parses_nested_lists_with_spans() {+ let t = "(kicad_pcb (version 20240108) (net 0 \"\") (footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\")))";+ let n = parse(t).unwrap();+ assert_eq!(n.tag, "kicad_pcb");+ let fp = n.find("footprint").unwrap();+ assert_eq!(fp.value(), Some("Lib:X"));+ assert_eq!(fp.find("at").unwrap().number(3), Some(90.0));+ assert_eq!(&t[fp.start..fp.end], "(footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\"))");+ assert_eq!(num(1.5), "1.5");+ assert_eq!(num(-0.0000001), "0");+ assert_eq!(num(120.54), "120.54");+ }+}
crates/aiflow-board/tests/esc.rsadded+20@@ -0,0 +1,20 @@+use aiflow_board::Board;++#[test]+fn reads_the_esc_fixture_when_present() {+ let p = "/home/adom/project/adom-desktop-kicad-bridge/demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb";+ if !std::path::Path::new(p).is_file() {+ return;+ }+ let b = Board::load(p).unwrap();+ assert_eq!(b.pads.len(), 365, "copper pads");+ assert_eq!(b.footprints.len(), 149, "footprints");+ assert_eq!(b.copper_layers.len(), 4);+ assert_eq!(b.bbox, (104.0, 58.0, 168.0, 132.0));+ let q1 = b.pad_by_key("Q1.5").unwrap();+ assert_eq!(q1.net, "+VBAT");+ assert!((q1.x - 155.75).abs() < 0.05 && (q1.y - 108.5).abs() < 0.05, "{} {}", q1.x, q1.y);+ assert!(b.nets.get("GND").map(|v| v.len()).unwrap_or(0) > 60);+ let u5 = b.pad_by_key("U5.5").unwrap();+ assert!(u5.half_long > u5.half_short && u5.min_dim() < 0.5, "U5.5 is a narrow pin: {} {}", u5.half_long, u5.half_short);+}
crates/aiflow-bridge/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "aiflow-bridge"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: bridge"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-bridge/src/lib.rsadded+348@@ -0,0 +1,348 @@+//! The live half: everything that touches the desktop goes through the Adom Bridge CLI+//! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval+//! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement,+//! validation.+use std::process::Command;++use serde_json::{json, Value};++/// Shoelace area of a zone entry's `polygon` ([[x, y], ...]).+fn polygon_area_of(z: &Value) -> f64 {+ let pts: Vec<(f64, f64)> = z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).unwrap_or_default();+ if pts.len() < 3 {+ return 0.0;+ }+ let mut a = 0.0;+ for i in 0..pts.len() {+ let (x1, y1) = pts[i];+ let (x2, y2) = pts[(i + 1) % pts.len()];+ a += x1 * y2 - x2 * y1;+ }+ (a / 2.0).abs()+}++#[derive(Clone)]+pub struct Bridge {+ pub ai_thread: String,+ pub target: String,+}++impl Bridge {+ pub fn call(&self, verb: &str, args: &Value) -> Value {+ let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+ match out {+ Ok(o) => {+ let text = String::from_utf8_lossy(&o.stdout);+ serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))+ }+ Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+ }+ }++ pub fn ok(v: &Value) -> bool {+ v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+ }++ /// The live board's revision. KiCad answers nothing while its UI thread fills zones after a+ /// commit, and the bridge may report itself busy while a previous verb's refill finishes; the+ /// read is retried for up to about two minutes. The last reply is kept for the log.+ pub fn revision(&self, board: &str) -> Option<String> {+ self.revision_with_reply(board).0+ }++ pub fn revision_with_reply(&self, board: &str) -> (Option<String>, Value) {+ let mut last = Value::Null;+ for attempt in 0..20 {+ let r = self.call("kicad_routing_state", &json!({"filePath": board}));+ if let Some(rev) = r.get("revision").and_then(|r| r.as_str()) {+ return (Some(rev.to_string()), r);+ }+ eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());+ last = r;+ std::thread::sleep(std::time::Duration::from_secs(6));+ }+ (None, last)+ }++ // -- capture recipe -------------------------------------------------------------------+ pub fn open_board(&self, remote_board: &str, reason: &str) -> Result<i64, Value> {+ let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));+ let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };+ self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));+ std::thread::sleep(std::time::Duration::from_secs(40));+ self.call("desktop_bring_to_front", &json!({"hwnd": hwnd, "reason": "aiflow capture: keep the PCB editor on screen on the test box"}));+ std::thread::sleep(std::time::Duration::from_secs(2));+ self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"}));+ std::thread::sleep(std::time::Duration::from_secs(3));+ Ok(hwnd)+ }++ /// Record ONE window (Windows Graphics Capture, background-capturable): the take survives+ /// anything another thread or an updater puts in front of KiCad on a shared test box, and it+ /// never needs the foreground, so nobody at the box is disturbed.+ pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+ // a hard cap on every recording: no clip may outlive an hour, whatever the AI forgets+ let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+ r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+ }++ /// A background screenshot of one window (owned popups included), copied into `save_dir`+ /// under `name`; returns the local path.+ pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {+ let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd}));+ let d = r.get("data").cloned().unwrap_or(r.clone());+ let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+ let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+ let dest = format!("{save_dir}/{name}.png");+ std::fs::copy(&local, &dest).ok()?;+ Some(dest)+ }++ /// The PCB editor's window handle, if KiCad has one open.+ pub fn pcb_editor_hwnd(&self) -> Option<i64> {+ let r = self.call("kicad_window_info", &json!({}));+ let d = r.get("data").cloned().unwrap_or(r.clone());+ d.get("editors").and_then(|e| e.as_array()).and_then(|a| a.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor"))).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())+ }++ pub fn record_start(&self, reason: &str) -> Result<String, Value> {+ let r = self.call("desktop_record_start", &json!({"monitor": 0, "audio": false, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));+ r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+ }++ pub fn record_stop(&self, recording_id: &str, save_to: &str) -> (Option<String>, Value) {+ let r = self.call("desktop_record_stop", &json!({"recordingId": recording_id}));+ let mut p = r.get("filePath").or_else(|| r.get("path")).and_then(|v| v.as_str()).map(str::to_string);+ if p.is_none() {+ if let Some(out) = r.get("output").and_then(|o| o.as_str()) {+ if let Ok(v) = serde_json::from_str::<Value>(out) {+ p = v.get("data").and_then(|d| d.get("path")).and_then(|x| x.as_str()).map(str::to_string);+ }+ }+ }+ let p = p.map(|s| s.replace('\\', "/"));+ if let Some(path) = &p {+ self.call("pull_file", &json!({"filePaths": [path], "saveTo": save_to}));+ }+ (p, r)+ }++ // -- landing ------------------------------------------------------------------------------+ /// One kicad_move_footprint per PART, one undo step each, paused between, so the window+ /// recorder catches every part landing in its spot instead of the whole board changing in a+ /// single frame. Returns (replies, accepted, stopped-at).+ pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {+ let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default();+ let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision", "message": "kicad_routing_state gave no revision; is the board open with the IPC API on?"})], 0, Some(0)) };+ let mut replies = Vec::new();+ let mut ok = 0;+ for (i, (reference, m)) in items.iter().enumerate() {+ let refs = json!([{"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))}]);+ let r = self.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": rev, "refs": refs}));+ progress(i + 1, items.len(), reference, &r);+ if !Bridge::ok(&r) {+ replies.push(r);+ return (replies, ok, Some(i));+ }+ ok += 1;+ match r.get("revision").and_then(|v| v.as_str()) {+ Some(nr) => rev = nr.to_string(),+ None => if let Some(nr) = self.revision(board) { rev = nr },+ }+ replies.push(r);+ std::thread::sleep(std::time::Duration::from_millis(pause_ms));+ }+ (replies, ok, None)+ }++ /// Replay a plan ONE TRACE AT A TIME: an entry with several paths becomes one kicad_route_net+ /// per path, so the camera sees each trace drawn instead of a whole net appearing at once.+ /// Returns (replies, accepted, stopped-at, traces).+ pub fn land_plan(&self, board: &str, entries: &[Value], pause_ms: u64, mut progress: impl FnMut(usize, usize, &Value, &Value)) -> (Vec<Value>, usize, Option<usize>, usize) {+ let traces: Vec<Value> = entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {+ Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<Value>>(),+ _ => vec![e.clone()],+ }).collect();+ let entries: &[Value] = &traces;+ let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision"})], 0, Some(0), traces.len()) };+ let mut replies = Vec::new();+ let mut ok = 0;+ for (i, e) in entries.iter().enumerate() {+ let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});+ for k in ["viaSize", "viaDrill", "layer"] {+ if let Some(v) = e.get(k) {+ args[k] = v.clone();+ }+ }+ if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }+ let r = self.call("kicad_route_net", &args);+ let good = Bridge::ok(&r);+ progress(i + 1, entries.len(), e, &r);+ if !good {+ replies.push(r);+ return (replies, ok, Some(i), traces.len());+ }+ ok += 1;+ if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) {+ rev = nr.to_string();+ }+ replies.push(r);+ std::thread::sleep(std::time::Duration::from_millis(pause_ms));+ }+ (replies, ok, None, traces.len())+ }++ /// Land zones grouped by net (one undo step per group, lowest priority first), converging the+ /// live board on the plan: a live zone with a planned zone's name and outline area (within 2 %)+ /// is already landed and is skipped; a live zone carrying one of the plan's names but another+ /// outline is stale (the spec changed) and is removed first. The revision is read fresh before+ /// every group because KiCad's refill after a commit changes the board again after the bridge+ /// has answered.+ pub fn land_zones(&self, board: &str, zones: &[Value], pause_ms: u64, inherited_ids: &[String]) -> Vec<(String, Value)> {+ let live = self.zone_state(board, false);+ let mut out = Vec::new();+ let names: Vec<String> = zones.iter().filter_map(|z| z.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect();+ let mut consumed = vec![false; zones.len()];+ let mut stale: Vec<String> = Vec::new();+ for l in live.get("zones").and_then(|z| z.as_array()).cloned().unwrap_or_default() {+ let (Some(name), Some(id)) = (l.get("name").and_then(|n| n.as_str()), l.get("id").and_then(|i| i.as_str())) else { continue };+ let kind = l.get("type").and_then(|t| t.as_str()).unwrap_or("copper");+ let inherited = inherited_ids.iter().any(|i| i == id);+ // the flow owns every copper or keepout zone the board did not start with; a zone it+ // owns that the plan no longer describes (renamed, reshaped, dropped) is stale+ if inherited || kind == "teardrop" || (name.is_empty() && !names.is_empty() && inherited_ids.is_empty()) {+ continue;+ }+ if !names.iter().any(|n| n == name) {+ stale.push(id.to_string());+ continue;+ }+ let area = l.get("outlineAreaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0);+ let hit = zones.iter().enumerate().position(|(i, z)| !consumed[i] && z.get("name").and_then(|n| n.as_str()) == Some(name) && {+ let pa = polygon_area_of(z);+ (pa - area).abs() <= 0.02 * pa.max(area).max(0.5)+ });+ match hit {+ Some(i) => consumed[i] = true,+ None => stale.push(id.to_string()),+ }+ }+ if !stale.is_empty() {+ let Some(rev) = self.revision(board) else { return vec![("removed-stale".into(), json!({"success": false, "errorCode": "no_revision"}))] };+ let r = self.call("kicad_remove_zone", &json!({"filePath": board, "expectedRevision": rev, "itemIds": stale}));+ let n = stale.len();+ let good = Bridge::ok(&r);+ out.push(("removed-stale".into(), json!({"success": good, "removed": n, "errorCode": r["errorCode"], "message": format!("{n} live zones the flow had landed are no longer in the plan (renamed, reshaped or dropped) and were removed before landing"), "reply": r})));+ if !good {+ return out;+ }+ std::thread::sleep(std::time::Duration::from_millis(pause_ms));+ }+ let skipped = consumed.iter().filter(|c| **c).count();+ if skipped > 0 {+ out.push(("already-landed".into(), json!({"success": true, "skipped": skipped, "message": format!("{skipped} zones were already on the live board (same name and outline) and were not landed again")})));+ }+ let mut groups: Vec<(String, Vec<Value>, u64)> = Vec::new();+ for (i, z) in zones.iter().enumerate() {+ if consumed[i] {+ continue;+ }+ let key = z.get("net").and_then(|n| n.as_str()).unwrap_or("keepout").to_string();+ let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);+ if let Some(g) = groups.iter_mut().find(|g| g.0 == key) {+ g.1.push(z.clone());+ g.2 = g.2.min(prio);+ } else {+ groups.push((key, vec![z.clone()], prio));+ }+ }+ groups.sort_by_key(|g| g.2);+ for (net, zs, _) in groups {+ let mut r = json!({"success": false, "errorCode": "no_revision"});+ for attempt in 0..3 {+ let (rev, last) = self.revision_with_reply(board);+ let Some(rev) = rev else { r["lastReply"] = last; break };+ r = self.call("kicad_add_zone", &json!({"filePath": board, "expectedRevision": rev, "zones": zs}));+ if r.get("errorCode").and_then(|e| e.as_str()) != Some("stale_board") || attempt == 2 {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(1500));+ }+ let good = Bridge::ok(&r);+ out.push((net, r));+ if !good {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(pause_ms));+ }+ out+ }++ /// 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.+ 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)+ }++ pub fn zone_state(&self, board: &str, refill: bool) -> Value {+ let mut r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": refill}));+ for attempt in 0..15 {+ if r.get("filledAreaByLayerMm2").is_some() {+ break;+ }+ eprintln!("zone state read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());+ std::thread::sleep(std::time::Duration::from_secs(6));+ r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": false}));+ }+ r+ }++ /// Vias on the live board as (net, x, y).+ pub fn live_vias(&self, board: &str) -> Vec<(String, f64, f64)> {+ let r = self.call("kicad_routing_state", &json!({"filePath": board}));+ r.get("vias").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| Some((v.get("net_name")?.as_str()?.to_string(), v["position"]["x"].as_f64()?, v["position"]["y"].as_f64()?))).collect()).unwrap_or_default()+ }++ pub fn validate(&self, board: &str) -> Value {+ self.call("kicad_routing_validate", &json!({"filePath": board}))+ }++ pub fn status(&self) -> Value {+ self.call("kicad_status", &json!({}))+ }+}++#[cfg(test)]+mod tests {+ use serde_json::json;++ /// The rule behind "one trace at a time": an entry with several paths becomes one call per+ /// path, an entry with one path stays one call, so a plan's call count is its path count.+ fn expand(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {+ entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {+ Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<_>>(),+ _ => vec![e.clone()],+ }).collect()+ }++ #[test]+ fn one_call_per_trace() {+ let entries = vec![+ json!({"net": "+VBAT", "width": 1.0, "paths": [["A", [1.0, 1.0], [2.0, 2.0]], ["B", [3.0, 3.0], [4.0, 4.0]], ["C", [5.0, 5.0], [6.0, 6.0]]]}),+ json!({"net": "/SW", "width": 0.2, "paths": [["D", [7.0, 7.0], [8.0, 8.0]]]}),+ ];+ let traces = expand(&entries);+ assert_eq!(traces.len(), 4, "two entries with three and one paths must land as four traces");+ for t in &traces {+ assert_eq!(t["paths"].as_array().unwrap().len(), 1, "every landed call carries exactly one trace");+ }+ assert_eq!(traces[0]["net"], "+VBAT");+ assert_eq!(traces[3]["net"], "/SW");+ assert_eq!(traces[1]["width"], 1.0, "the net's width rides every one of its traces");+ }+}
crates/aiflow-copper/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "aiflow-copper"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: copper"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }
crates/aiflow-copper/src/lib.rsadded+503@@ -0,0 +1,503 @@+//! Plans to copper on a board file, and KiCad's DRC on the result.+//!+//! `apply` writes a routing plan (segments and vias as `kicad_route_net` would land them) into a+//! copy of the board, and by default fills every zone with a raster fill so the shared headless+//! DRC (`service-kicad pcb drc`, which does not refill zones) counts plane vias as connected.+//! `run_drc` and `summarize` give the gate its numbers: errors, new versus inherited against a+//! baseline, unconnected items.+use std::collections::{BTreeMap, HashMap};+use std::process::Command;++use aiflow_board::{num, Board, Pt};+use aiflow_grid::{Grid, Mask};+use serde_json::{json, Value};++pub const CLEARANCE: f64 = 0.2;+pub const EDGE_CLEARANCE: f64 = 0.5;+pub const SLOP: f64 = 0.1;++#[derive(Clone, Debug)]+pub struct Seg {+ pub x0: f64,+ pub y0: f64,+ pub x1: f64,+ pub y1: f64,+ pub layer: String,+ pub width: f64,+}++#[derive(Clone, Debug)]+pub struct ViaSpec {+ pub x: f64,+ pub y: f64,+ pub size: f64,+ pub drill: f64,+}++fn uuid() -> String {+ // v4-shaped from a time and counter seed: unique enough for a gate copy+ use std::sync::atomic::{AtomicU64, Ordering};+ static N: AtomicU64 = AtomicU64::new(0);+ let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) as u64;+ let n = N.fetch_add(1, Ordering::Relaxed);+ let mut h = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;+ let mut parts = Vec::new();+ for _ in 0..4 {+ h ^= h >> 33;+ h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);+ h ^= h >> 33;+ parts.push(h);+ }+ format!("{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", parts[0] as u32, (parts[1] & 0xffff) as u16, (parts[1] >> 20) as u16 & 0xfff, 0x8000 | ((parts[2] & 0x3fff) as u16), parts[3] & 0xffff_ffff_ffff)+}++fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option<String>), String> {+ if let Some(key) = spec.as_str() {+ let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;+ return Ok((board.pads[i].x, board.pads[i].y, Some(i), None));+ }+ if let Some(o) = spec.as_object() {+ let layer = o.get("layer").and_then(|l| l.as_str()).map(str::to_string);+ if let Some(key) = o.get("pad").and_then(|p| p.as_str()) {+ let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;+ return Ok((board.pads[i].x, board.pads[i].y, Some(i), layer));+ }+ return Ok((o.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?, o.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?, None, layer));+ }+ if let Some(a) = spec.as_array() {+ if a.len() == 2 {+ return Ok((a[0].as_f64().ok_or("bad x")?, a[1].as_f64().ok_or("bad y")?, None, None));+ }+ }+ Err(format!("bad waypoint {spec}"))+}++/// One plan entry to its segments and vias.+pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<ViaSpec>), String> {+ let net = entry.get("net").and_then(|n| n.as_str()).ok_or("entry needs net")?;+ if !board.nets.contains_key(net) {+ return Err(format!("unknown net {net:?}"));+ }+ let width = entry.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);+ let size = entry.get("viaSize").and_then(|w| w.as_f64()).unwrap_or(0.6);+ let drill = entry.get("viaDrill").and_then(|w| w.as_f64()).unwrap_or(0.3);+ let paths: Vec<Value> = match entry.get("paths").and_then(|p| p.as_array()) {+ Some(p) => p.clone(),+ None => vec![entry.get("points").cloned().ok_or("entry needs paths or points")?],+ };+ let mut segments = Vec::new();+ let mut vias = Vec::new();+ for path in paths {+ let pts = path.as_array().ok_or("path must be a list")?;+ if pts.len() < 2 || pts.len() > 512 {+ return Err(format!("path needs 2..512 waypoints ({net})"));+ }+ let resolved: Vec<(f64, f64, Option<usize>, Option<String>)> = pts.iter().map(|p| point(p, board)).collect::<Result<_, _>>()?;+ for (_, _, pad, _) in &resolved {+ if let Some(i) = pad {+ if board.pads[*i].net != net {+ return Err(format!("pad {} is on {}, not {}", board.pads[*i].key, board.pads[*i].net, net));+ }+ }+ }+ let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone()).unwrap_or_else(|| "F.Cu".to_string());+ for (i, (x, y, pad, explicit)) in resolved.iter().enumerate() {+ let nxt = explicit.clone().unwrap_or_else(|| layer.clone());+ if i > 0 {+ let (px, py) = (resolved[i - 1].0, resolved[i - 1].1);+ if (px, py) != (*x, *y) {+ segments.push(Seg { x0: px, y0: py, x1: *x, y1: *y, layer: layer.clone(), width });+ }+ }+ if nxt != layer {+ vias.push(ViaSpec { x: *x, y: *y, size, drill });+ layer = nxt;+ }+ if let Some(pi) = pad {+ let p = &board.pads[*pi];+ let on = if layer == "F.Cu" { p.on_f } else if layer == "B.Cu" { p.on_b } else { p.thru };+ if !on {+ return Err(format!("pad {} has no copper on {}", p.key, layer));+ }+ }+ }+ }+ Ok((segments, vias))+}++pub fn segment_sexpr(board: &Board, s: &Seg, net: &str) -> Result<String, String> {+ Ok(format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)", num(s.x0), num(s.y0), num(s.x1), num(s.y1), num(s.width), s.layer, board.net_ref_sexpr(net)?, uuid()))+}++pub fn via_sexpr(board: &Board, v: &ViaSpec, net: &str) -> Result<String, String> {+ Ok(format!("\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"F.Cu\" \"B.Cu\")\n{}\t\t(uuid \"{}\")\n\t)", num(v.x), num(v.y), num(v.size), num(v.drill), board.net_ref_sexpr(net)?, uuid()))+}++/// Insert blocks before the closing paren of the kicad_pcb list.+pub fn splice(text: &str, blocks: &[String]) -> String {+ let trimmed = text.trim_end();+ let idx = trimmed.rfind(')').unwrap_or(trimmed.len());+ let body = text[..idx].trim_end();+ let nl = if text.get(..2000).unwrap_or(text).contains("\r\n") { "\r\n" } else { "\n" };+ let mut out = String::with_capacity(text.len() + blocks.iter().map(|b| b.len() + 2).sum::<usize>());+ out.push_str(body);+ out.push_str(nl);+ out.push_str(&blocks.iter().map(|b| b.replace('\n', nl)).collect::<Vec<_>>().join(nl));+ out.push_str(nl);+ out.push(')');+ out.push_str(nl);+ out+}++// ----------------------------------------------------------------------------- plane raster fill++type Lat = (f64, f64);++fn trace_boundaries(free: &Mask) -> Vec<Vec<Lat>> {+ let (w, h) = (free.w as i64, free.h as i64);+ let get = |i: i64, j: i64| -> bool { i >= 0 && j >= 0 && i < w && j < h && free.bits[(j * w + i) as usize] };+ let mut out: HashMap<(i64, i64), Vec<(i64, i64)>> = HashMap::new();+ // lattice points scaled by 2 to stay integral: corner (i-0.5, j-0.5) -> (2i-1, 2j-1)+ for j in 0..h {+ for i in 0..w {+ if !get(i, j) {+ continue;+ }+ let tl = (2 * i - 1, 2 * j - 1);+ let tr = (2 * i + 1, 2 * j - 1);+ let br = (2 * i + 1, 2 * j + 1);+ let bl = (2 * i - 1, 2 * j + 1);+ if !get(i, j - 1) { out.entry(tl).or_default().push(tr); }+ if !get(i + 1, j) { out.entry(tr).or_default().push(br); }+ if !get(i, j + 1) { out.entry(br).or_default().push(bl); }+ if !get(i - 1, j) { out.entry(bl).or_default().push(tl); }+ }+ }+ let mut loops = Vec::new();+ let mut keys: Vec<(i64, i64)> = out.keys().copied().collect();+ keys.sort();+ let mut ki = 0;+ while !out.is_empty() {+ while ki < keys.len() && !out.contains_key(&keys[ki]) {+ ki += 1;+ }+ if ki >= keys.len() {+ break;+ }+ let a = keys[ki];+ let b = out.get_mut(&a).unwrap().pop().unwrap();+ if out[&a].is_empty() {+ out.remove(&a);+ }+ let mut lp = vec![a, b];+ let mut prev = (b.0 - a.0, b.1 - a.1);+ loop {+ let cur = *lp.last().unwrap();+ let Some(cands) = out.get_mut(&cur) else { break };+ let nxt = if cands.len() == 1 {+ cands.pop().unwrap()+ } else {+ let right = (-prev.1, prev.0);+ let left = (prev.1, -prev.0);+ let mut pick = None;+ for want in [right, prev, left] {+ if let Some(pos) = cands.iter().position(|c| (c.0 - cur.0, c.1 - cur.1) == want) {+ pick = Some(cands.remove(pos));+ break;+ }+ }+ pick.unwrap_or_else(|| cands.pop().unwrap())+ };+ if cands.is_empty() {+ out.remove(&cur);+ }+ prev = (nxt.0 - cur.0, nxt.1 - cur.1);+ if nxt == lp[0] {+ break;+ }+ lp.push(nxt);+ }+ // drop collinear vertices+ let n = lp.len();+ let mut simp = Vec::new();+ for k in 0..n {+ let p0 = lp[(k + n - 1) % n];+ let p1 = lp[k];+ let p2 = lp[(k + 1) % n];+ if (p1.0 - p0.0) * (p2.1 - p1.1) - (p1.1 - p0.1) * (p2.0 - p1.0) != 0 {+ simp.push((p1.0 as f64 / 2.0, p1.1 as f64 / 2.0));+ }+ }+ if simp.len() >= 4 {+ loops.push(simp);+ }+ }+ loops+}++fn signed_area(poly: &[Lat]) -> f64 {+ aiflow_board::geom::polygon_area(poly)+}++fn leftmost_edge(h: &[Lat]) -> Option<(f64, usize)> {+ let n = h.len();+ let mut best: Option<(f64, usize)> = None;+ for k in 0..n {+ let (a, b) = (h[k], h[(k + 1) % n]);+ if a.0 == b.0 && best.map(|x| a.0 < x.0).unwrap_or(true) {+ best = Some((a.0, k));+ }+ }+ best+}++/// Splice every hole into the outer contour through a horizontal zero-width slit to the left.+fn fracture(outer: &[Lat], holes: &[Vec<Lat>]) -> Vec<Lat> {+ let mut poly: Vec<Lat> = outer.to_vec();+ let mut order: Vec<usize> = (0..holes.len()).collect();+ order.sort_by(|&a, &b| leftmost_edge(&holes[a]).map(|x| x.0).unwrap_or(0.0).partial_cmp(&leftmost_edge(&holes[b]).map(|x| x.0).unwrap_or(0.0)).unwrap());+ for hk in order {+ let h = &holes[hk];+ let Some((x_h, k)) = leftmost_edge(h) else { continue };+ let (a, b) = (h[k], h[(k + 1) % h.len()]);+ let mut ym = (a.1 + b.1) / 2.0;+ if (b.1 - a.1).abs() >= 2.0 {+ ym = a.1 + if b.1 > a.1 { 0.5 } else { -0.5 };+ }+ let start = (x_h, ym);+ let mut hole = vec![start];+ hole.extend_from_slice(&h[k + 1..]);+ hole.extend_from_slice(&h[..k + 1]);+ hole.push(start);+ let mut hit: Option<(f64, usize)> = None;+ for e in 0..poly.len() {+ let (p0, p1) = (poly[e], poly[(e + 1) % poly.len()]);+ if p0.0 == p1.0 && p0.0 < x_h && p0.1.min(p1.1) < ym && ym < p0.1.max(p1.1) && hit.map(|x| p0.0 > x.0).unwrap_or(true) {+ hit = Some((p0.0, e));+ }+ }+ let Some((xe, e)) = hit else { continue };+ let joint = (xe, ym);+ let mut np = poly[..e + 1].to_vec();+ np.push(joint);+ np.extend(hole);+ np.push(joint);+ np.extend_from_slice(&poly[e + 1..]);+ poly = np;+ }+ poly+}++/// One fractured polygon per connected region of the plane, in board mm.+pub fn zone_fill_polygons(board: &Board, grid: &Grid, zone_net: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> Vec<Vec<Pt>> {+ let mut free = grid.outline_inside(&board.outline, EDGE_CLEARANCE + SLOP);+ let mut block = grid.mask();+ let mut by_clearance: BTreeMap<i64, Mask> = BTreeMap::new();+ for p in &board.pads {+ if !p.thru || p.net == zone_net {+ continue;+ }+ let target = if p.clearance <= CLEARANCE { &mut block } else { by_clearance.entry((p.clearance * 1000.0) as i64).or_insert_with(|| grid.mask()) };+ for poly in &p.polys {+ grid.poly_mask(poly, target);+ }+ if let Some(d) = p.drill {+ for c in grid.disk_cells((p.x, p.y), d / 2.0, None) {+ target.bits[c] = true;+ }+ }+ }+ for (net, vias) in vias_by_net {+ if net == zone_net {+ continue;+ }+ for v in vias {+ for c in grid.disk_cells((v.x, v.y), v.size / 2.0, None) {+ block.bits[c] = true;+ }+ }+ }+ let mut blocked = grid.dilate(&block, (CLEARANCE + SLOP) / grid.res);+ for (clr, m) in &by_clearance {+ blocked.or_with(&grid.dilate(m, (*clr as f64 / 1000.0 + SLOP) / grid.res));+ }+ free.and_not(&blocked);+ let loops = trace_boundaries(&free);+ let outers: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) > 0.0).collect();+ let holes: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) < 0.0).collect();+ let mut polys = Vec::new();+ for outer in outers {+ let ob = aiflow_board::geom::bbox(outer);+ let mut mine: Vec<Vec<Lat>> = Vec::new();+ for h in &holes {+ let hb = aiflow_board::geom::bbox(h);+ if hb.0 < ob.0 || hb.1 < ob.1 || hb.2 > ob.2 || hb.3 > ob.3 {+ continue;+ }+ if aiflow_board::geom::point_in_poly(h[0].0 + 1e-6, h[0].1 + 1e-6, outer) {+ mine.push((*h).clone());+ }+ }+ let poly = fracture(outer, &mine);+ polys.push(poly.into_iter().map(|(x, y)| (grid.x0 + x * grid.res, grid.y0 + y * grid.res)).collect());+ }+ polys+}++/// Every top-level zone: (node start, node end, net, layer, connect_pads span).+fn zones_of(board: &Board) -> Vec<(usize, usize, String, Option<String>, Option<(usize, usize, bool)>)> {+ let mut out = Vec::new();+ for z in board.root.find_all("zone") {+ let mut net = z.find("net").and_then(|n| n.value()).unwrap_or("").to_string();+ if board.net_format == aiflow_board::NetFormat::Number {+ if let Some(k) = z.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {+ if let Some(nm) = board.net_table.get(&k) {+ net = nm.clone();+ }+ }+ }+ if let Some(nn) = z.find("net_name").and_then(|n| n.value()) {+ net = nn.to_string();+ }+ let layer = z.find("layer").and_then(|l| l.value()).map(str::to_string);+ let cp = z.find("connect_pads").map(|c| (c.start, c.end, matches!(c.atom(1), Some("yes") | Some("no") | Some("thru_hole_only"))));+ out.push((z.start, z.end, net, layer, cp));+ }+ out+}++/// The board text with a filled_polygon list inside every zone (raster fill), and the counts.+pub fn fill_zones(board: &Board, text: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> (String, BTreeMap<String, usize>) {+ let grid = Grid::new(board.bbox);+ let mut edits: Vec<(usize, usize, String)> = Vec::new();+ let mut stats = BTreeMap::new();+ for (start, end, net, layer, cp) in zones_of(board) {+ let (Some(layer), false) = (layer, net.is_empty()) else { continue };+ let _ = start;+ let polys = zone_fill_polygons(board, &grid, &net, vias_by_net);+ let mut blocks = String::new();+ for poly in &polys {+ let pts: Vec<String> = poly.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();+ blocks.push_str(&format!("\t\t(filled_polygon\n\t\t\t(layer \"{}\")\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n", layer, pts.join(" ")));+ }+ edits.push((end - 1, end - 1, blocks + "\t"));+ if let Some((cs, _, explicit)) = cp {+ if !explicit {+ edits.push((cs, cs + "(connect_pads".len(), "(connect_pads yes".to_string()));+ }+ }+ stats.insert(net, polys.len());+ }+ edits.sort_by_key(|e| e.0);+ let mut out = String::with_capacity(text.len() + 4096);+ let mut pos = 0;+ for (at, end, ins) in edits {+ out.push_str(&text[pos..at]);+ out.push_str(&ins);+ pos = end;+ }+ out.push_str(&text[pos..]);+ (out, stats)+}++/// The board text with the plan's copper (and raster fills unless `fill` is false), and stats.+pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), String> {+ let entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned()).ok_or("plan needs nets")?;+ let mut blocks = Vec::new();+ let mut vias_by_net: HashMap<String, Vec<ViaSpec>> = HashMap::new();+ let (mut n_seg, mut n_via) = (0usize, 0usize);+ let mut length = 0.0;+ let mut widths: BTreeMap<String, usize> = BTreeMap::new();+ let mut nets = std::collections::HashSet::new();+ for e in &entries {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ nets.insert(net.clone());+ let (segs, vias) = plan_to_copper(board, e)?;+ for s in &segs {+ blocks.push(segment_sexpr(board, s, &net)?);+ length += (s.x1 - s.x0).hypot(s.y1 - s.y0);+ *widths.entry(num(s.width)).or_insert(0) += 1;+ }+ for v in &vias {+ blocks.push(via_sexpr(board, v, &net)?);+ vias_by_net.entry(net.clone()).or_default().push(v.clone());+ }+ n_seg += segs.len();+ n_via += vias.len();+ }+ let mut text = splice(&board.text, &blocks);+ let mut stats = json!({"entries": entries.len(), "nets": nets.len(), "segments": n_seg, "vias": n_via, "copperLengthMm": (length * 100.0).round() / 100.0, "segmentsByWidth": widths});+ if fill {+ let (t, fills) = fill_zones(board, &text, &vias_by_net);+ text = t;+ stats["zoneFillPolygons"] = json!(fills);+ }+ Ok((text, stats))+}++// ----------------------------------------------------------------------------- DRC++pub fn run_drc(path: &str) -> Result<Value, String> {+ let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id()));+ std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;+ let report = dir.join(format!("drc-{}.json", uuid()));+ let out = Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output().map_err(|e| format!("service-kicad not runnable: {e}"))?;+ if !out.status.success() || !report.is_file() {+ return Err(format!("service-kicad drc failed: {} {}", String::from_utf8_lossy(&out.stdout).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>(), String::from_utf8_lossy(&out.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()));+ }+ let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;+ let _ = std::fs::remove_file(&report);+ Ok(data)+}++fn fingerprint(v: &Value) -> String {+ let mut pos: Vec<String> = v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").map(|p| p.to_string()).unwrap_or_default()).collect()).unwrap_or_default();+ pos.sort();+ json!([v.get("type"), v.get("severity"), v.get("description"), pos]).to_string()+}++fn count_types(list: &[&Value]) -> BTreeMap<String, usize> {+ let mut m = BTreeMap::new();+ for v in list {+ *m.entry(v.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()).or_insert(0) += 1;+ }+ m+}++/// The gate's summary: errors, new versus inherited against a baseline report, unconnected.+pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value {+ let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();+ let errors: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("error")).collect();+ let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect();+ let mut new: Vec<&Value> = errors.clone();+ let mut inherited: Vec<&Value> = Vec::new();+ if let Some(base) = baseline {+ let mut existing: HashMap<String, usize> = HashMap::new();+ for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {+ if v.get("severity").and_then(|s| s.as_str()) == Some("error") {+ *existing.entry(fingerprint(v)).or_insert(0) += 1;+ }+ }+ new.clear();+ for v in &errors {+ let k = fingerprint(v);+ if existing.get(&k).copied().unwrap_or(0) > 0 {+ *existing.get_mut(&k).unwrap() -= 1;+ inherited.push(v);+ } else {+ new.push(v);+ }+ }+ }+ let unconnected: Vec<&Value> = data.get("unconnected_items").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();+ let fill_artifacts = warnings_all.iter().filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("isolated_copper")).count();+ let warnings: Vec<&Value> = warnings_all.iter().copied().filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("isolated_copper")).collect();+ let desc = |v: &Value| -> Vec<Value> { v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("description").cloned().unwrap_or(Value::Null)).collect()).unwrap_or_default() };+ json!({+ "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(),+ "rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings),+ "newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(),+ "unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(),+ "kicadVersion": data.get("kicad_version"),+ })+}
crates/aiflow-grid/Cargo.tomladded+11@@ -0,0 +1,11 @@+[package]+name = "aiflow-grid"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: grid"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }
crates/aiflow-grid/src/lib.rsadded+250@@ -0,0 +1,250 @@+//! The raster every geometric stage works on: 0.1 mm cells over the outline's box, with+//! conservative rasterisers (a cell counts as copper when any part of the shape touches its+//! square), disk dilation for clearance maps, and the outline's inside and edge distance.+use aiflow_board::Pt;++pub const RES: f64 = 0.1;++#[derive(Clone)]+pub struct Grid {+ pub res: f64,+ pub x0: f64,+ pub y0: f64,+ pub w: usize,+ pub h: usize,+}++/// A boolean raster, row-major (index = j * w + i).+#[derive(Clone)]+pub struct Mask {+ pub w: usize,+ pub h: usize,+ pub bits: Vec<bool>,+}++impl Mask {+ pub fn new(w: usize, h: usize) -> Mask {+ Mask { w, h, bits: vec![false; w * h] }+ }+ #[inline]+ pub fn get(&self, i: usize, j: usize) -> bool {+ self.bits[j * self.w + i]+ }+ #[inline]+ pub fn set(&mut self, i: usize, j: usize, v: bool) {+ self.bits[j * self.w + i] = v;+ }+ pub fn or_with(&mut self, other: &Mask) {+ for (a, b) in self.bits.iter_mut().zip(&other.bits) {+ *a |= *b;+ }+ }+ pub fn and_not(&mut self, other: &Mask) {+ for (a, b) in self.bits.iter_mut().zip(&other.bits) {+ *a &= !*b;+ }+ }+ pub fn any(&self) -> bool {+ self.bits.iter().any(|b| *b)+ }+ pub fn count(&self) -> usize {+ self.bits.iter().filter(|b| **b).count()+ }+}++impl Grid {+ pub fn new(bbox: (f64, f64, f64, f64)) -> Grid {+ Grid::with_res(bbox, RES)+ }++ pub fn with_res(bbox: (f64, f64, f64, f64), res: f64) -> Grid {+ Grid { res, x0: bbox.0, y0: bbox.1, w: ((bbox.2 - bbox.0) / res).round() as usize + 1, h: ((bbox.3 - bbox.1) / res).round() as usize + 1 }+ }++ /// Round half up (pads on .x5 coordinates at 0.5 mm pitch snap a consistent 5 cells apart),+ /// with 0.1 um of tolerance for KiCad's nanometre noise.+ #[inline]+ pub fn to_cell(&self, x: f64, y: f64) -> (i64, i64) {+ (((x - self.x0) / self.res + 0.5 + 1e-3).floor() as i64, ((y - self.y0) / self.res + 0.5 + 1e-3).floor() as i64)+ }++ #[inline]+ pub fn to_xy(&self, i: i64, j: i64) -> Pt {+ let r = |v: f64| (v * 10000.0).round() / 10000.0;+ (r(self.x0 + i as f64 * self.res), r(self.y0 + j as f64 * self.res))+ }++ #[inline]+ pub fn in_bounds(&self, i: i64, j: i64) -> bool {+ i >= 0 && j >= 0 && (i as usize) < self.w && (j as usize) < self.h+ }++ pub fn mask(&self) -> Mask {+ Mask::new(self.w, self.h)+ }++ /// Mark every cell whose centre is inside the polygon, plus the cells its edges pass through.+ pub fn poly_mask(&self, poly: &[Pt], mask: &mut Mask) {+ let n = poly.len();+ if n < 3 {+ return;+ }+ let (bx0, by0, bx1, by1) = aiflow_board::geom::bbox(poly);+ let i0 = (((bx0 - self.x0) / self.res).floor() as i64 - 1).max(0) as usize;+ let i1 = ((((bx1 - self.x0) / self.res).ceil() as i64 + 1).min(self.w as i64 - 1)).max(0) as usize;+ let j0 = (((by0 - self.y0) / self.res).floor() as i64 - 1).max(0) as usize;+ let j1 = ((((by1 - self.y0) / self.res).ceil() as i64 + 1).min(self.h as i64 - 1)).max(0) as usize;+ if i1 < i0 || j1 < j0 {+ return;+ }+ for j in j0..=j1 {+ let cy = self.y0 + j as f64 * self.res;+ for i in i0..=i1 {+ let cx = self.x0 + i as f64 * self.res;+ if aiflow_board::geom::point_in_poly(cx, cy, poly) {+ mask.set(i, j, true);+ }+ }+ }+ // boundary supercover+ for k in 0..n {+ let (xa, ya) = poly[k];+ let (xb, yb) = poly[(k + 1) % n];+ let l = (xb - xa).hypot(yb - ya);+ let steps = ((l / 0.025).ceil() as usize).max(1);+ for s in 0..=steps {+ let t = s as f64 / steps as f64;+ let ex = xa + (xb - xa) * t;+ let ey = ya + (yb - ya) * t;+ let ii = ((ex - self.x0) / self.res).round() as i64;+ let jj = ((ey - self.y0) / self.res).round() as i64;+ if ii >= i0 as i64 && ii <= i1 as i64 && jj >= j0 as i64 && jj <= j1 as i64 {+ mask.set(ii as usize, jj as usize, true);+ }+ }+ }+ }++ /// Flat indices of the cells whose centre is within half_width + slack of segment a-b.+ pub fn segment_cells(&self, a: Pt, b: Pt, half_width: f64, slack: Option<f64>) -> Vec<usize> {+ let slack = slack.unwrap_or(self.res / 2.0);+ let r = half_width + slack;+ let (xa, ya) = a;+ let (xb, yb) = b;+ let i0 = (((xa.min(xb) - r - self.x0) / self.res).floor() as i64).max(0);+ let i1 = (((xa.max(xb) + r - self.x0) / self.res).ceil() as i64).min(self.w as i64 - 1);+ let j0 = (((ya.min(yb) - r - self.y0) / self.res).floor() as i64).max(0);+ let j1 = (((ya.max(yb) + r - self.y0) / self.res).ceil() as i64).min(self.h as i64 - 1);+ let mut out = Vec::new();+ if i1 < i0 || j1 < j0 {+ return out;+ }+ let (dx, dy) = (xb - xa, yb - ya);+ let l2 = dx * dx + dy * dy;+ let r2 = (r + 1e-9) * (r + 1e-9);+ for j in j0..=j1 {+ let cy = self.y0 + j as f64 * self.res;+ for i in i0..=i1 {+ let cx = self.x0 + i as f64 * self.res;+ let t = if l2 < 1e-12 { 0.0 } else { (((cx - xa) * dx + (cy - ya) * dy) / l2).clamp(0.0, 1.0) };+ let px = xa + t * dx;+ let py = ya + t * dy;+ let d2 = (cx - px) * (cx - px) + (cy - py) * (cy - py);+ if d2 <= r2 {+ out.push(j as usize * self.w + i as usize);+ }+ }+ }+ out+ }++ pub fn disk_cells(&self, c: Pt, radius: f64, slack: Option<f64>) -> Vec<usize> {+ self.segment_cells(c, c, radius, slack)+ }++ /// Binary dilation by a disk of `r_cells` cells. Separable enough to be fast: per row offsets.+ pub fn dilate(&self, mask: &Mask, r_cells: f64) -> Mask {+ if r_cells <= 0.0 {+ return mask.clone();+ }+ let rr = r_cells.floor() as i64;+ let r2 = r_cells * r_cells + 1e-9;+ // horizontal half-chords per dy+ let mut chords = Vec::with_capacity((2 * rr + 1) as usize);+ for dy in -rr..=rr {+ let rem = r2 - (dy * dy) as f64;+ chords.push((dy, if rem >= 0.0 { rem.sqrt().floor() as i64 } else { -1 }));+ }+ let w = mask.w as i64;+ let h = mask.h as i64;+ let mut out = Mask::new(mask.w, mask.h);+ // pass 1: horizontal runs per dy would require a 2D scan; do it directly but skip empty rows+ for j in 0..h {+ let row = &mask.bits[(j * w) as usize..((j + 1) * w) as usize];+ if !row.iter().any(|b| *b) {+ continue;+ }+ for i in 0..w {+ if !row[i as usize] {+ continue;+ }+ for &(dy, hc) in &chords {+ if hc < 0 {+ continue;+ }+ let jj = j + dy;+ if jj < 0 || jj >= h {+ continue;+ }+ let a = (i - hc).max(0) as usize;+ let b = (i + hc).min(w - 1) as usize;+ let base = (jj * w) as usize;+ for k in a..=b {+ out.bits[base + k] = true;+ }+ }+ }+ }+ out+ }++ /// (inside mask, distance of every cell centre to the nearest outline edge).+ pub fn outline_distance(&self, outline: &[Pt]) -> (Mask, Vec<f32>) {+ let mut inside = self.mask();+ let mut dmin = vec![f32::INFINITY; self.w * self.h];+ let n = outline.len();+ for j in 0..self.h {+ let cy = self.y0 + j as f64 * self.res;+ for i in 0..self.w {+ let cx = self.x0 + i as f64 * self.res;+ let mut ins = false;+ let mut best = f64::INFINITY;+ for k in 0..n {+ let (xa, ya) = outline[k];+ let (xb, yb) = outline[(k + 1) % n];+ if (ya > cy) != (yb > cy) {+ let xint = xa + (cy - ya) * (xb - xa) / (yb - ya);+ if cx < xint {+ ins = !ins;+ }+ }+ let d = aiflow_board::geom::seg_distance(cx, cy, xa, ya, xb, yb);+ if d < best {+ best = d;+ }+ }+ inside.set(i, j, ins);+ dmin[j * self.w + i] = best as f32;+ }+ }+ (inside, dmin)+ }++ pub fn outline_inside(&self, outline: &[Pt], margin: f64) -> Mask {+ let (mut inside, dmin) = self.outline_distance(outline);+ for (b, d) in inside.bits.iter_mut().zip(&dmin) {+ *b = *b && (*d as f64) >= margin;+ }+ inside+ }+}
crates/aiflow-place/Cargo.tomladded+11@@ -0,0 +1,11 @@+[package]+name = "aiflow-place"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: place"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }
crates/aiflow-place/src/lib.rsadded+268@@ -0,0 +1,268 @@+//! Placement helpers: courtyard boxes from the file, a legal spot for a part near where it should+//! be, moving footprints in the file, and the overlap check.+//!+//! 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).+use std::collections::BTreeMap;++use aiflow_board::{geom, num, Board, Pt};+use serde::{Deserialize, Serialize};+use serde_json::Value;++#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Move {+ pub x: f64,+ pub y: f64,+ #[serde(default)]+ pub rotation: Option<f64>,+}++#[derive(Clone, Debug, Deserialize)]+pub struct Wish {+ pub x: f64,+ pub y: f64,+ #[serde(default = "default_rot")]+ pub rot: Vec<f64>,+ #[serde(default = "default_radius")]+ pub radius: f64,+ #[serde(default)]+ pub keepout: Vec<(f64, f64, f64, f64, f64)>,+ #[serde(default)]+ pub gap: Option<f64>,+}++fn default_rot() -> Vec<f64> { vec![0.0] }+fn default_radius() -> f64 { 4.0 }++type Box4 = (f64, f64, f64, f64);++/// Local (unrotated, anchor-relative) F.CrtYd or B.CrtYd points of a footprint, from the file text.+pub fn courtyard_local(board: &Board, reference: &str) -> Option<Vec<Pt>> {+ let fp = board.footprints.get(reference)?;+ let block = &board.text[fp.start..fp.end];+ let node = aiflow_board::sx::parse(block).ok()?;+ let mut pts = Vec::new();+ for n in node.nodes() {+ if !matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc") {+ continue;+ }+ let layer = n.find("layer").and_then(|l| l.value()).unwrap_or("");+ if layer != "F.CrtYd" && layer != "B.CrtYd" {+ continue;+ }+ for tag in ["start", "end", "mid", "center"] {+ if let Some(p) = n.find(tag) {+ if let (Some(x), Some(y)) = (p.number(1), p.number(2)) {+ pts.push((x, y));+ }+ }+ }+ if let Some(p) = n.find("pts") {+ for xy in p.find_all("xy") {+ if let (Some(x), Some(y)) = (xy.number(1), xy.number(2)) {+ pts.push((x, y));+ }+ }+ }+ if n.tag == "fp_circle" {+ if let (Some(c), Some(e)) = (n.find("center"), n.find("end")) {+ if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {+ let r = (ex - cx).hypot(ey - cy);+ pts.push((cx - r, cy - r));+ pts.push((cx + r, cy + r));+ }+ }+ }+ }+ if pts.is_empty() { None } else { Some(pts) }+}++pub fn placed_box(pts: &[Pt], x: f64, y: f64, rot: f64) -> Box4 {+ let placed: Vec<Pt> = pts.iter().map(|&(dx, dy)| { let (a, b) = geom::rot_kicad(dx, dy, rot); (x + a, y + b) }).collect();+ geom::bbox(&placed)+}++/// The courtyard box (pad box grown by 0.25 mm when a part has no courtyard) of every footprint.+pub fn boxes(board: &Board) -> BTreeMap<String, Box4> {+ let mut out = BTreeMap::new();+ for (r, fp) in &board.footprints {+ if let Some(pts) = courtyard_local(board, r) {+ out.insert(r.clone(), placed_box(&pts, fp.x, fp.y, fp.rot));+ } else if let Some(b) = board.pad_box(r) {+ out.insert(r.clone(), (b.0 - 0.25, b.1 - 0.25, b.2 + 0.25, b.3 + 0.25));+ }+ }+ out+}++fn clear(b: &Box4, others: &[Box4], gap: f64) -> bool {+ !others.iter().any(|o| b.0 - gap < o.2 && o.0 - gap < b.2 && b.1 - gap < o.3 && o.1 - gap < b.3)+}++/// Local outline points of a part for placement: its courtyard, else its pad box grown by 0.25.+fn local_pts(board: &Board, reference: &str) -> Vec<Pt> {+ if let Some(p) = courtyard_local(board, reference) {+ return p;+ }+ let fp = &board.footprints[reference];+ let pts: Vec<Pt> = board.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).map(|(x, y)| geom::rot_kicad(x - fp.x, y - fp.y, -(fp.rot))).collect();+ let (x0, y0, x1, y1) = geom::bbox(&pts);+ vec![(x0 - 0.25, y0 - 0.25), (x1 + 0.25, y1 + 0.25)]+}++/// For each wish, in order, the nearest legal spot: courtyard clears every other courtyard by+/// `gap`, stays `edge` inside the outline box, avoids the keepout rectangles (grown by their margin).+pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (BTreeMap<String, Move>, Vec<String>) {+ let mut boxes = boxes(board);+ let (x0, y0, x1, y1) = board.bbox;+ let mut out = BTreeMap::new();+ let mut lines = Vec::new();+ for (reference, w) in wishes {+ if !board.footprints.contains_key(reference) {+ lines.push(format!("{reference}: not on the board"));+ continue;+ }+ let pts = local_pts(board, reference);+ let others: Vec<Box4> = boxes.iter().filter(|(r, _)| *r != reference).map(|(_, b)| *b).collect();+ let g = w.gap.unwrap_or(gap);+ let n = (w.radius / 0.1) as i64;+ let mut cands: Vec<(f64, f64, f64)> = Vec::new();+ for i in -n..=n {+ for j in -n..=n {+ let d = ((i * i + j * j) as f64).sqrt() * 0.1;+ if d <= w.radius {+ cands.push((d, ((w.x + i as f64 * 0.1) * 100.0).round() / 100.0, ((w.y + j as f64 * 0.1) * 100.0).round() / 100.0));+ }+ }+ }+ cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+ let mut best: Option<(f64, f64, f64, Box4, f64)> = None;+ 'outer: for (d, x, y) in cands {+ for &rot in &w.rot {+ let b = placed_box(&pts, x, y, rot);+ if b.0 < x0 + edge || b.1 < y0 + edge || b.2 > x1 - edge || b.3 > y1 - edge {+ continue;+ }+ if !clear(&b, &others, g) {+ continue;+ }+ if w.keepout.iter().any(|(kx0, ky0, kx1, ky1, m)| b.0 - m < *kx1 && kx0 - m < b.2 && b.1 - m < *ky1 && ky0 - m < b.3) {+ continue;+ }+ best = Some((x, y, rot, b, d));+ break 'outer;+ }+ }+ match best {+ None => lines.push(format!("{reference}: NO SPOT within {:.1} mm of ({:.1}, {:.1})", w.radius, w.x, w.y)),+ Some((x, y, rot, b, d)) => {+ boxes.insert(reference.clone(), b);+ out.insert(reference.clone(), Move { x, y, rotation: Some(rot) });+ lines.push(format!("{reference}: ({x:.2}, {y:.2}, {rot}) {d:.2} mm from the wish, box x {:.2}..{:.2} y {:.2}..{:.2}", b.0, b.2, b.1, b.3));+ }+ }+ }+ (out, lines)+}++/// Move footprints in the file text: the anchor's `(at x y rot)`, and every child's own angle by+/// the rotation change (pads, properties, graphics carry the footprint rotation in the file).+pub fn move_in_text(board: &Board, moves: &BTreeMap<String, Move>) -> Result<(String, Vec<String>), String> {+ let mut edits: Vec<(usize, usize, String)> = Vec::new();+ let mut lines = Vec::new();+ for (reference, m) in moves {+ let fp = board.footprints.get(reference).ok_or_else(|| format!("{reference} is not on the board"))?;+ let block = &board.text[fp.start..fp.end];+ let node = aiflow_board::sx::parse(block).map_err(|e| format!("{reference}: {e}"))?;+ let at = node.find("at").ok_or_else(|| format!("{reference}: no (at ...)"))?;+ let orot = at.number(3).unwrap_or(0.0);+ let nrot = m.rotation.unwrap_or(orot);+ let drot = nrot - orot;+ let new_at = format!("(at {} {}{})", num(m.x), num(m.y), if nrot == 0.0 { String::new() } else { format!(" {}", num(nrot)) });+ edits.push((fp.start + at.start, fp.start + at.end, new_at));+ if drot.abs() > 1e-9 {+ // every nested (at ...) except the footprint's own+ fn walk(n: &aiflow_board::Node, base: usize, drot: f64, edits: &mut Vec<(usize, usize, String)>, top: bool) {+ for c in n.nodes() {+ if c.tag == "at" && !top {+ let a = c.number(3).unwrap_or(0.0) + drot;+ let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;+ let (x, y) = (c.atom(1).unwrap_or("0"), c.atom(2).unwrap_or("0"));+ edits.push((base + c.start, base + c.end, format!("(at {} {}{})", x, y, if a.abs() < 1e-9 { String::new() } else { format!(" {}", num(a)) })));+ } else if c.tag != "at" {+ walk(c, base, drot, edits, false);+ }+ }+ }+ for c in node.nodes() {+ if c.tag != "at" {+ walk(c, fp.start, drot, &mut edits, false);+ }+ }+ }+ lines.push(format!("moved {reference:<6} ({:.2}, {:.2}, {}) -> ({:.2}, {:.2}, {})", fp.x, fp.y, num(orot), m.x, m.y, num(nrot)));+ }+ edits.sort_by_key(|e| e.0);+ let mut out = String::with_capacity(board.text.len());+ let mut pos = 0;+ for (a, b, ins) in edits {+ if a < pos {+ return Err("overlapping edits".into());+ }+ out.push_str(&board.text[pos..a]);+ out.push_str(&ins);+ pos = b;+ }+ out.push_str(&board.text[pos..]);+ Ok((out, lines))+}++/// Courtyard overlaps and outside-the-outline parts among the moved refs on a board.+pub fn check(board: &Board, moved: &[String], gap: f64) -> Vec<String> {+ let boxes = boxes(board);+ let mut problems = Vec::new();+ let refs: Vec<&String> = boxes.keys().collect();+ for (i, a) in refs.iter().enumerate() {+ for b in &refs[i + 1..] {+ if !moved.contains(a) && !moved.contains(b) {+ continue;+ }+ let (ba, bb) = (boxes[*a], boxes[*b]);+ if ba.0 - gap < bb.2 && bb.0 - gap < ba.2 && ba.1 - gap < bb.3 && bb.1 - gap < ba.3 {+ problems.push(format!("courtyards overlap: {a} {b}"));+ }+ }+ }+ let (x0, y0, x1, y1) = board.bbox;+ for r in moved {+ if let Some(b) = boxes.get(r) {+ if b.0 < x0 + 0.5 || b.1 < y0 + 0.5 || b.2 > x1 - 0.5 || b.3 > y1 - 0.5 {+ problems.push(format!("outside the outline: {r}"));+ }+ }+ }+ problems+}++/// Wishes are packed in the order given. An array `[{"ref": "U5", "x": .., "y": ..}, ...]` keeps+/// that order; an object `{REF: {...}}` is packed alphabetically (JSON objects carry no order),+/// which is rarely what a placement wants: put the big parts first as an array.+pub fn wishes_from_json(v: &Value) -> Result<Vec<(String, Wish)>, String> {+ let mut out = Vec::new();+ if let Some(a) = v.as_array() {+ for e in a {+ let k = e.get("ref").and_then(|r| r.as_str()).ok_or("each wish in the array needs a ref")?.to_string();+ out.push((k.clone(), serde_json::from_value(e.clone()).map_err(|e| format!("{k}: {e}"))?));+ }+ return Ok(out);+ }+ let o = v.as_object().ok_or("wishes must be an array [{ref, x, y, ...}] (packed in order) or an object {REF: {...}}")?;+ for (k, w) in o {+ out.push((k.clone(), serde_json::from_value(w.clone()).map_err(|e| format!("{k}: {e}"))?));+ }+ Ok(out)+}++pub fn moves_from_json(v: &Value) -> Result<BTreeMap<String, Move>, String> {+ serde_json::from_value(v.clone()).map_err(|e| e.to_string())+}
crates/aiflow-pours/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "aiflow-pours"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: pours"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-copper = { workspace = true }
crates/aiflow-pours/src/lib.rsadded+271@@ -0,0 +1,271 @@+//! Pours from the spec, not from anyone's memory of one board: zones (with priorities and+//! connection styles), rule-area keepouts along Kelvin taps, thermal and stitching vias; and the+//! zones as `.kicad_pcb` blocks for the offline copy.+use aiflow_board::{geom, num, quote, Board, NetFormat, Pt};+use serde_json::{json, Value};++pub type Box4 = (f64, f64, f64, f64);++fn pad_box(board: &Board, keys: &[String]) -> Option<Box4> {+ let mut pts = Vec::new();+ for k in keys {+ if let Some((r, n)) = k.split_once('.') {+ for p in board.pads.iter().filter(|p| p.reference == r && p.name == n) {+ pts.extend(p.polys.iter().flatten().copied());+ }+ } else {+ for p in board.pads.iter().filter(|p| p.reference == *k) {+ pts.extend(p.polys.iter().flatten().copied());+ }+ }+ }+ if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }+}++fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Value {+ let r = |v: f64| (v * 100.0).round() / 100.0;+ json!([[r(x0), r(y0)], [r(x1), r(y0)], [r(x1), r(y1)], [r(x0), r(y1)]])+}++fn strs(v: Option<&Value>) -> Vec<String> {+ v.and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()+}++/// Segments of every non-Kelvin path in a routing plan, with net and half width, for via avoidance.+fn plan_blockers(plan: &Value) -> Vec<(Pt, Pt, String, f64)> {+ let mut out = Vec::new();+ for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);+ for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+ let mut prev: Option<Pt> = None;+ for wp in path.as_array().cloned().unwrap_or_default() {+ let pt = if let Some(a) = wp.as_array() { if a.len() == 2 { Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))) } else { None } } else if let Some(o) = wp.as_object() { match (o.get("x").and_then(|v| v.as_f64()), o.get("y").and_then(|v| v.as_f64())) { (Some(x), Some(y)) => Some((x, y)), _ => None } } else { None };+ match (wp.is_string(), pt) {+ (true, _) => prev = None,+ (false, Some(p)) => {+ if let Some(q) = prev {+ out.push((q, p, net.clone(), w / 2.0 + 0.2 + 0.45));+ }+ prev = Some(p);+ }+ _ => {}+ }+ }+ }+ }+ out+}++fn clear(x: f64, y: f64, net: &str, blockers: &[(Pt, Pt, String, f64)]) -> bool {+ !blockers.iter().any(|(a, b, n, need)| n != net && geom::seg_distance(x, y, a.0, a.1, b.0, b.1) < *need)+}++/// (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> {+ let (x0, y0, x1, y1) = board.bbox;+ let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5);+ 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 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.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) {+ z.insert("outline".into(), json!(true));+ z.insert("inset".into(), json!(e.get("inset").and_then(|v| v.as_f64()).unwrap_or(edge)));+ } else if let Some(p) = e.get("polygon") {+ z.insert("polygon".into(), p.clone());+ } else if e.get("around").is_some() {+ let keys = strs(e.get("around"));+ let bx = pad_box(board, &keys).ok_or_else(|| format!("pour {net}: none of {keys:?} found on the board"))?;+ let m = e.get("margin").and_then(|v| v.as_f64()).unwrap_or(0.8);+ let mut b = ((bx.0 - m).max(x0 + edge), (bx.1 - m).max(y0 + edge), (bx.2 + m).min(x1 - edge), (bx.3 + m).min(y1 - edge));+ for ex in strs(e.get("exclude")) {+ if let Some(xb) = pad_box(board, &[ex.clone()]) {+ if !(xb.2 < b.0 || xb.0 > b.2 || xb.3 < b.1 || xb.1 > b.3) {+ // clip on the side that loses the least+ let cuts = [(0usize, xb.2 + 1.0, (xb.2 + 1.0) - b.0), (1, xb.3 + 1.0, (xb.3 + 1.0) - b.1), (2, xb.0 - 1.0, b.2 - (xb.0 - 1.0)), (3, xb.1 - 1.0, b.3 - (xb.1 - 1.0))];+ let (side, val, _) = cuts.iter().min_by(|a, c| a.2.partial_cmp(&c.2).unwrap()).unwrap();+ match side { 0 => b.0 = *val, 1 => b.1 = *val, 2 => b.2 = *val, _ => b.3 = *val }+ }+ }+ }+ z.insert("polygon".into(), rect(b.0, b.1, b.2, b.3));+ } else {+ return Err(format!("pour {net} needs outline, polygon or around"));+ }+ z.entry("name").or_insert(json!(format!("{net} pour")));+ zones.push(Value::Object(z));+ }+ for key in strs(spec.get("solidAt")) {+ if let Some(p) = board.pad_by_key(&key) {+ let layers: Vec<&str> = if p.thru { vec!["F.Cu", "B.Cu"] } else if p.on_f { vec!["F.Cu"] } else { vec!["B.Cu"] };+ let r = if p.thru { p.half_long.max(p.half_short) + 0.6 } else { 0.9 };+ zones.push(json!({"net": p.net, "layers": layers, "polygon": rect(p.x - r, p.y - r, p.x + r, p.y + r), "priority": 1, "connection": "solid", "clearance": clearance, "minThickness": 0.25, "name": format!("{} solid joint at {key}", p.net)}));+ }+ }+ let blockers = plan.map(plan_blockers).unwrap_or_default();+ let mut vias: Vec<Value> = Vec::new();+ let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});+ for tv in spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let Some(key) = tv.get("pad").and_then(|p| p.as_str()) else { continue };+ let Some(p) = board.pad_by_key(key) else { continue };+ let net = tv.get("net").and_then(|n| n.as_str()).unwrap_or(&p.net).to_string();+ let n = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;+ let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));+ let mut placed = 0;+ for (dx, dy) in [(-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)] {+ if placed >= n {+ break;+ }+ let inside = (dx as f64).abs() < p.half_long - 0.4 && (dy as f64).abs() < p.half_short - 0.4;+ if !inside && (dx, dy) != (0.0, 0.0) {+ continue;+ }+ let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);+ if clear(x, y, &net, &blockers) {+ vias.push(via_entry(&net, x, y, size, drill));+ placed += 1;+ }+ }+ }+ for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+ let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };+ if clear(x, y, net, &blockers) {+ vias.push(via_entry(net, x, y, sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4)));+ }+ }+ let mut keepouts = 0;+ // the sense pad of every Kelvin pair (spec.kelvin: {"U6.3": "R32.2"}) gets its own keepout: a+ // pour on the same net must never touch it, or the tap measures the pour's drop instead of the+ // shunt's (and KiCad's DRC reports the starved thermal relief the keepout along the tap leaves)+ for (src, _dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {+ if let Some(p) = board.pad_by_key(&src) {+ let m = clearance + 0.15;+ let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };+ zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(p.x - hx - m, p.y - hy - m, p.x + hx + m, p.y + hy + m), "keepout": {"copperPour": true}, "name": format!("Kelvin sense pad keepout {} ({src})", p.net)}));+ keepouts += 1;+ }+ }+ if let (Some(plan), true) = (plan, spec.get("kelvin").map(|k| !k.is_null()).unwrap_or(false)) {+ for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+ if !e.get("kelvin").and_then(|k| k.as_bool()).unwrap_or(false) {+ continue;+ }+ let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+ let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25) / 2.0 + 0.2 + 0.1;+ for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+ let pts: Vec<Pt> = path.as_array().cloned().unwrap_or_default().iter().filter_map(|wp| {+ if let Some(a) = wp.as_array() { if a.len() == 2 { return Some((a[0].as_f64()?, a[1].as_f64()?)); } }+ if let Some(o) = wp.as_object() { return Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?)); }+ if let Some(k) = wp.as_str() { let p = board.pad_by_key(k)?; return Some((p.x, p.y)); }+ None+ }).collect();+ for pair in pts.windows(2) {+ let ((ax, ay), (bx, by)) = (pair[0], pair[1]);+ let (dx, dy) = (bx - ax, by - ay);+ let n = dx.hypot(dy);+ if n < 0.05 {+ continue;+ }+ let (ux, uy) = (dx / n, dy / n);+ let r = |v: f64| (v * 1000.0).round() / 1000.0;+ let poly = json!([[r(ax - uy * w), r(ay + ux * w)], [r(bx - uy * w), r(by + ux * w)], [r(bx + uy * w), r(by - ux * w)], [r(ax + uy * w), r(ay - ux * w)]]);+ zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net}")}));+ keepouts += 1;+ }+ }+ }+ }+ Ok((zones, vias, keepouts))+}++/// Shrink a convex-ish outline toward its centroid by d.+pub fn inset_polygon(poly: &[Pt], d: f64) -> Vec<Pt> {+ if d <= 0.0 {+ return poly.to_vec();+ }+ let n = poly.len() as f64;+ let cx = poly.iter().map(|p| p.0).sum::<f64>() / n;+ let cy = poly.iter().map(|p| p.1).sum::<f64>() / n;+ poly.iter().map(|&(x, y)| { let (vx, vy) = (x - cx, y - cy); let l = vx.hypot(vy).max(1e-9); (x - vx / l * d, y - vy / l * d) }).collect()+}++/// Resolve `outline: true` zones to explicit polygons (the live verb takes points).+pub fn resolve_outline(board: &Board, zones: &[Value]) -> Vec<Value> {+ let mut outline = board.outline.clone();+ if geom::polygon_area(&outline) < 0.0 {+ outline.reverse();+ }+ zones.iter().map(|z| {+ if z.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {+ let inset = z.get("inset").and_then(|v| v.as_f64()).unwrap_or(0.5);+ let mut o = z.as_object().unwrap().clone();+ o.remove("outline");+ o.remove("inset");+ let r = |v: f64| (v * 1000.0).round() / 1000.0;+ o.insert("polygon".into(), json!(inset_polygon(&outline, inset).iter().map(|(x, y)| json!([r(*x), r(*y)])).collect::<Vec<_>>()));+ Value::Object(o)+ } else {+ z.clone()+ }+ }).collect()+}++fn poly_of(z: &Value) -> Vec<Pt> {+ z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|pt| { let q = pt.as_array()?; Some((q.first()?.as_f64()?, q.get(1)?.as_f64()?)) }).collect()).unwrap_or_default()+}++/// The zone (copper pour or keepout) as a `.kicad_pcb` block, the way the bridge writes it.+pub fn zone_sexpr(board: &Board, z: &Value) -> Result<String, String> {+ let layers: Vec<String> = strs(z.get("layers"));+ let layer_line = if layers.len() == 1 { format!("\t\t(layer \"{}\")\n", layers[0]) } else { format!("\t\t(layers {})\n", layers.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" ")) };+ let pts: Vec<String> = poly_of(z).iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();+ if pts.len() < 3 {+ return Err("zone needs a polygon with three or more points".into());+ }+ let name = z.get("name").and_then(|n| n.as_str()).unwrap_or("zone");+ let uuid = format!("bf{:016x}-{:04x}-4000-8000-{:012x}", pts.len() as u64 * 7919 + name.len() as u64, layers.len(), std::process::id());+ let keepout = z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some();+ if keepout {+ let k = z.get("keepout").cloned().unwrap_or(json!({"copperPour": true}));+ let f = |n: &str| if k.get(n).and_then(|b| b.as_bool()).unwrap_or(false) { "not_allowed" } else { "allowed" };+ return Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(keepout\n\t\t\t(tracks {})\n\t\t\t(vias {})\n\t\t\t(pads {})\n\t\t\t(copperpour {})\n\t\t\t(footprints {})\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\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)",+ if board.net_format == NetFormat::Number { "\t\t(net 0)\n\t\t(net_name \"\")\n" } else { "" }, layer_line, uuid, quote(name), f("tracks"), f("vias"), f("pads"), f("copperPour"), f("footprints"), pts.join(" ")));+ }+ let net = z.get("net").and_then(|n| n.as_str()).ok_or("copper zone needs net")?;+ let net_lines = match board.net_format {+ 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 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)",+ 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(" ")))+}++/// Summary line per zone for the human.+pub fn describe(z: &Value) -> String {+ let layers = strs(z.get("layers")).join("+");+ if z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some() {+ return format!("keepout {layers:<12} {}", z.get("name").and_then(|n| n.as_str()).unwrap_or(""));+ }+ let poly = poly_of(z);+ format!("{:<10} {layers:<12} prio {} {} {}", z.get("net").and_then(|n| n.as_str()).unwrap_or("?"), z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0), z.get("connection").and_then(|c| c.as_str()).unwrap_or("thermal"), if z.get("outline").is_some() { "outline".to_string() } else { format!("{:.0} mm2", geom::polygon_area(&poly).abs()) })+}
crates/aiflow-router/Cargo.tomladded+12@@ -0,0 +1,12 @@+[package]+name = "aiflow-router"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: router"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }
crates/aiflow-router/examples/route.rsadded+16@@ -0,0 +1,16 @@+//! route BOARD SPEC OUT_PLAN [passes] : the router on its own, for checking against the reference.+use aiflow_board::Board;+use aiflow_router::{drive::route_board, Router, Rules};+fn main() {+ let a: Vec<String> = std::env::args().collect();+ let board = Board::load(&a[1]).unwrap();+ let spec: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&a[2]).unwrap()).unwrap();+ let rules = Rules::from_spec(&spec);+ let passes: usize = a.get(4).and_then(|p| p.parse().ok()).unwrap_or(1);+ let t0 = std::time::Instant::now();+ let mut r = Router::new(board, rules, true);+ let (plan, report, failed) = route_board(&mut r, passes, 8, 400);+ std::fs::write(&a[3], serde_json::to_string_pretty(&serde_json::json!({"engine": "adom-aiflow router", "nets": plan})).unwrap()).unwrap();+ std::fs::write(format!("{}.report.json", a[3]), serde_json::to_string_pretty(&report).unwrap()).unwrap();+ eprintln!("done: {failed} failed, {} entries, {:.1}s", plan.len(), t0.elapsed().as_secs_f64());+}
crates/aiflow-router/src/drive.rsadded+1156@@ -0,0 +1,1156 @@+//! The search and the driver: A* per connection, a net as a spanning tree onto its own copper,+//! rip-up when a connection fails, plane stubs, Kelvin taps, passes, and the plan.+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap, HashSet};++use aiflow_board::{geom, B_CU, F_CU};+use aiflow_grid::RES;+use serde_json::{json, Value};++use crate::{slop, End, NetState, Route, Router, Snapshot, Term, COST_TURN, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};++pub type Win = (i64, i64, i64, i64);++pub struct Search<'a> {+ pub sources: Vec<(Term, i64)>,+ pub targets: Option<&'a HashSet<Term>>,+ pub heur: Vec<(i64, i64)>,+ pub win: Win,+ pub allow_via: bool,+ pub soft: bool,+ pub via_target: bool,+ pub max_nodes: Option<usize>,+ pub vip: Vec<Term>,+}++impl Router {+ /// A* from sources to targets over the window. Returns (cells, blockers).+ pub fn search(&mut self, net: usize, width: f64, s: Search) -> (Option<Vec<Term>>, HashSet<usize>) {+ let (i0, j0, i1, j1) = s.win;+ let ww = (i1 - i0 + 1) as usize;+ let hw = (j1 - j0 + 1) as usize;+ let nl = ww * hw;+ let (obs_o, obs_d, softm, via_ok) = self.obstacle_maps(net, width, s.win, s.soft);+ let g = self.grid.clone();+ // zone, wall, history windows+ let mut zone = vec![false; nl];+ let mut wall = vec![0i64; nl];+ let mut hist = [vec![0i64; nl], vec![0i64; nl]];+ for jj in 0..hw {+ for ii in 0..ww {+ let c = (j0 as usize + jj) * g.w + i0 as usize + ii;+ zone[jj * ww + ii] = self.ic_zone.bits[c];+ wall[jj * ww + ii] = self.chan_count[c] as i64;+ hist[0][jj * ww + ii] = (self.history[0][c] as i64).clamp(0, 40);+ hist[1][jj * ww + ii] = (self.history[1][c] as i64).clamp(0, 40);+ }+ }+ let nid = self.nets[net].idx;+ for ((n, _, _), cells) in &self.chan_lines {+ if *n == nid {+ for &c in cells {+ let (cj, ci) = ((c / g.w) as i64, (c % g.w) as i64);+ if cj >= j0 && cj <= j1 && ci >= i0 && ci <= i1 {+ wall[(cj - j0) as usize * ww + (ci - i0) as usize] -= 1;+ }+ }+ }+ }+ for w in wall.iter_mut() {+ *w = (*w).clamp(0, 60);+ }+ let mut tgt = vec![false; 2 * nl];+ let vipset: HashSet<usize> = s.vip.iter().filter(|t| t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1).map(|t| (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize).collect();+ if s.via_target {+ for m in 0..nl {+ if via_ok.bits[m] && !obs_o[F_CU].bits[m] {+ tgt[F_CU * nl + m] = true;+ }+ }+ }+ if let Some(targets) = s.targets {+ for t in targets {+ if t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1 {+ tgt[t.layer() * nl + (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize] = true;+ }+ }+ }+ if !tgt.iter().any(|b| *b) {+ return (None, HashSet::new());+ }+ let mut gcost = vec![INF; 2 * nl];+ let mut parent = vec![usize::MAX; 2 * nl];+ let mut pdir = vec![-1i8; 2 * nl];+ let mut closed = vec![false; 2 * nl];+ let mut heap: BinaryHeap<Reverse<(i64, usize)>> = BinaryHeap::new();+ let hp: Vec<(i64, i64)> = s.heur.iter().map(|(i, j)| (i - i0, j - j0)).collect();+ let h = |il: i64, jl: i64| -> i64 {+ if hp.is_empty() {+ return 0;+ }+ hp.iter().map(|(hi, hj)| { let dx = (hi - il).abs(); let dy = (hj - jl).abs(); 10 * dx.max(dy) + 4 * dx.min(dy) }).min().unwrap()+ };+ for (t, c0) in &s.sources {+ let (i, j) = (t.i as i64, t.j as i64);+ if !(i >= i0 && i <= i1 && j >= j0 && j <= j1) {+ continue;+ }+ let m = (j - j0) as usize * ww + (i - i0) as usize;+ let n = t.layer() * nl + m;+ if obs_o[t.layer()].bits[m] {+ continue;+ }+ if *c0 < gcost[n] {+ gcost[n] = *c0;+ heap.push(Reverse((c0 + h(i - i0, j - j0), n)));+ }+ }+ let mut expanded = 0usize;+ let mut found: Option<usize> = None;+ while let Some(Reverse((_, n))) = heap.pop() {+ if closed[n] {+ continue;+ }+ closed[n] = true;+ if tgt[n] {+ found = Some(n);+ break;+ }+ expanded += 1;+ if let Some(cap) = s.max_nodes {+ if expanded > cap {+ break;+ }+ }+ let l = n / nl;+ let m = n - l * nl;+ let jl = (m / ww) as i64;+ let il = (m % ww) as i64;+ let gn = gcost[n];+ let pd = pdir[n];+ let mult = LAYER_MULT[l];+ for d in 0..8 {+ let ni = il + DIRS[d].0 as i64;+ let nj = jl + DIRS[d].1 as i64;+ if ni < 0 || nj < 0 || ni >= ww as i64 || nj >= hw as i64 {+ continue;+ }+ let nm = nj as usize * ww + ni as usize;+ if obs_o[l].bits[nm] {+ continue;+ }+ if d >= 4 && obs_d[l].bits[nm] && obs_d[l].bits[m] {+ continue;+ }+ let nn = l * nl + nm;+ if closed[nn] {+ continue;+ }+ let mut c = (STEP[d] as f64 * mult) as i64;+ if pd != -1 && pd as usize != d {+ c += COST_TURN;+ }+ if let Some(sm) = &softm[l] {+ if sm.bits[nm] {+ c += SOFT_EXTRA;+ }+ }+ if l == F_CU && zone[nm] {+ c += ZONE_COST;+ }+ if hist[l][nm] > 0 {+ c += HIST_COST * hist[l][nm];+ }+ let ng = gn + c;+ if ng < gcost[nn] {+ gcost[nn] = ng;+ parent[nn] = n;+ pdir[nn] = d as i8;+ heap.push(Reverse((ng + h(ni, nj), nn)));+ }+ }+ if s.allow_via && (via_ok.bits[m] || vipset.contains(&m)) {+ let l2 = 1 - l;+ if !obs_o[l2].bits[m] {+ let nn = l2 * nl + m;+ if !closed[nn] {+ let ng = gn + COST_VIA + VIA_WALL * wall[m];+ if ng < gcost[nn] {+ gcost[nn] = ng;+ parent[nn] = n;+ pdir[nn] = -1;+ heap.push(Reverse((ng + h(il, jl), nn)));+ }+ }+ }+ }+ }+ self.stat("expanded", expanded as i64);+ let Some(mut n) = found else { return (None, HashSet::new()) };+ let mut cells = Vec::new();+ loop {+ let l = n / nl;+ let m = n - l * nl;+ cells.push(Term::new(l, (m % ww) as i64 + i0, (m / ww) as i64 + j0));+ if parent[n] == usize::MAX {+ break;+ }+ n = parent[n];+ }+ cells.reverse();+ let mut blockers = HashSet::new();+ if s.soft {+ let r = ((self.rules.clearance + width / 2.0 + slop()) / RES).ceil() as i64 + 1;+ for t in &cells {+ if let Some(sm) = &softm[t.layer()] {+ let m = (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize;+ if sm.bits[m] {+ for jj in (t.j as i64 - r).max(0)..=(t.j as i64 + r).min(g.h as i64 - 1) {+ for ii in (t.i as i64 - r).max(0)..=(t.i as i64 + r).min(g.w as i64 - 1) {+ let v = self.route_raw[t.layer()][jj as usize * g.w + ii as usize];+ if v != -1 && v as usize != nid {+ blockers.insert(v as usize);+ }+ }+ }+ }+ }+ }+ }+ (Some(cells), blockers)+ }++ // -- per net -----------------------------------------------------------------------+ pub fn window_for(&self, pts: &[(i64, i64)], margin_mm: f64) -> Win {+ let m = (margin_mm / RES).round() as i64;+ let xs: Vec<i64> = pts.iter().map(|p| p.0).collect();+ let ys: Vec<i64> = pts.iter().map(|p| p.1).collect();+ ((xs.iter().min().unwrap() - m).max(0), (ys.iter().min().unwrap() - m).max(0), (xs.iter().max().unwrap() + m).min(self.grid.w as i64 - 1), (ys.iter().max().unwrap() + m).min(self.grid.h as i64 - 1))+ }++ pub fn copper_targets(&self, net: usize) -> HashSet<Term> {+ let mut t = HashSet::new();+ for c in &self.nets[net].clusters {+ if c.connected && !self.kelvin_clusters.contains(&(net, c.idx)) {+ t.extend(c.terminals.iter().copied()); // a sense pad is never a landing target either+ }+ }+ for r in &self.nets[net].routes {+ if r.kelvin {+ continue; // a Kelvin tap is not copper the rest of its net may land on+ }+ t.extend(r.cells.iter().copied());+ }+ t+ }++ fn vip_cells(&mut self, net: usize, clusters: &[usize]) -> Vec<Term> {+ let mut out = Vec::new();+ for &c in clusters {+ let cl = self.nets[net].clusters[c].clone();+ if self.board.pads[cl.primary].thru || !cl.escapes.is_empty() {+ continue;+ }+ for t in cl.terminals {+ if t.layer() == F_CU {+ let (x, y) = self.grid.to_xy(t.i as i64, t.j as i64);+ if self.via_clear_exact(net, x, y) {+ out.push(t);+ }+ }+ }+ }+ out+ }++ pub fn connect(&mut self, net: usize, cluster: usize, anchor: &[(i64, i64)], soft: bool, vip: bool) -> (Option<Route>, HashSet<usize>) {+ let mut targets = self.copper_targets(net);+ let cl = self.nets[net].clusters[cluster].clone();+ let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+ let allow = cl.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)];+ }+ let mut allpts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+ allpts.extend_from_slice(anchor);+ let mut vip_src = Vec::new();+ if vip {+ vip_src = self.vip_cells(net, &[cluster]);+ let connected: Vec<usize> = self.nets[net].clusters.iter().filter(|c| c.connected).map(|c| c.idx).collect();+ for t in self.vip_cells(net, &connected) {+ targets.insert(Term::new(B_CU, t.i as i64, t.j as i64));+ }+ }+ let stages: Vec<(f64, Option<usize>)> = if soft || vip { vec![(12.0, Some(200_000))] } else { vec![(5.0, None), (12.0, None), (80.0, Some(250_000))] };+ for width in widths {+ for (margin, cap) in &stages {+ let win = self.window_for(&allpts, *margin);+ let (cells, blockers) = self.search(net, width, Search { sources: sources.clone(), targets: Some(&targets), heur: anchor.to_vec(), win, allow_via: true, soft, via_target: false, max_nodes: *cap, vip: vip_src.clone() });+ if let Some(cells) = cells {+ let start_term = cells[0];+ let end_cell = *cells.last().unwrap();+ let start = if cl.escapes.contains_key(&start_term) { End::Escape(cluster, start_term) } else { End::Pad(cluster, start_term) };+ let mut end = End::Copper;+ for c in &self.nets[net].clusters {+ if c.connected && (c.terminals.contains(&end_cell) || (end_cell.layer() == B_CU && c.terminals.contains(&Term::new(F_CU, end_cell.i as i64, end_cell.j as i64)) && targets.contains(&end_cell))) {+ end = if c.escapes.contains_key(&end_cell) { End::Escape(c.idx, end_cell) } else { End::Pad(c.idx, end_cell) };+ }+ }+ let mut route = Route::new(cells.clone(), width, start, end.clone());+ if let End::Pad(ec, _) = end {+ if end_cell.layer() == B_CU && !self.board.pads[self.nets[net].clusters[ec].primary].thru {+ route.via_end = true;+ route.via_end_layer = F_CU;+ self.stat("viaInPad", 1);+ let key = self.nets[net].clusters[ec].key(&self.board).to_string();+ self.nets[net].via_in_pad.push(key);+ }+ }+ if cells.len() > 1 && (cells[0].i, cells[0].j) == (cells[1].i, cells[1].j) && cells[0].layer() != cells[1].layer() && !self.board.pads[cl.primary].thru {+ self.stat("viaInPad", 1);+ let key = cl.key(&self.board).to_string();+ self.nets[net].via_in_pad.push(key);+ }+ return (Some(route), blockers);+ }+ }+ }+ (None, HashSet::new())+ }++ /// Prim's order over clusters; each cluster to the existing copper. Returns blocker net ids on failure.+ pub fn route_net(&mut self, net: usize) -> HashSet<usize> {+ let n = self.nets[net].clusters.len();+ for c in 0..n {+ self.nets[net].clusters[c].connected = false;+ }+ self.nets[net].routes.clear();+ self.nets[net].failed.clear();+ self.nets[net].last_soft.clear();+ let start = (0..n).max_by(|&a, &b| {+ let ca = &self.nets[net].clusters[a];+ let cb = &self.nets[net].clusters[b];+ let pa = &self.board.pads[ca.primary];+ let pb = &self.board.pads[cb.primary];+ (ca.allow, pa.half_long * pa.half_short).partial_cmp(&(cb.allow, pb.half_long * pb.half_short)).unwrap()+ }).unwrap();+ self.nets[net].clusters[start].connected = true;+ let mut connected = vec![start];+ let mut remaining: Vec<usize> = (0..n).filter(|&c| c != start).collect();+ let mut blockers_all = HashSet::new();+ // Kelvin taps first: sense pad to shunt pad as a dedicated trace+ let kelvin = self.rules.kelvin.clone();+ for c in remaining.clone() {+ let key = self.nets[net].clusters[c].key(&self.board).to_string();+ if let Some(dst_key) = kelvin.get(&key) {+ let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dst_key);+ let Some(dst) = dst else { continue };+ if self.route_kelvin(net, c, dst) {+ // the sense pad is done; the shunt pad still joins the rest of the net through+ // the wide path (the tap itself is never a landing target, see copper_targets)+ remaining.retain(|&x| x != c);+ } else {+ let dk = self.nets[net].clusters[dst].key(&self.board).to_string();+ self.nets[net].failed.push((key, format!("kelvin tap to {dk} found no dedicated path")));+ remaining.retain(|&x| x != c);+ }+ }+ }+ while !remaining.is_empty() {+ let mut best: Option<(f64, usize, usize)> = None;+ for &c in &remaining {+ for &k in &connected {+ let a = self.nets[net].clusters[c].xy(&self.board);+ let b = self.nets[net].clusters[k].xy(&self.board);+ let d = (a.0 - b.0).hypot(a.1 - b.1);+ if best.map(|x| d < x.0).unwrap_or(true) {+ best = Some((d, c, k));+ }+ }+ }+ let (_, c, k) = best.unwrap();+ remaining.retain(|&x| x != c);+ let anchor: Vec<(i64, i64)> = self.nets[net].clusters[k].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+ let (mut route, _) = self.connect(net, c, &anchor, false, false);+ if route.is_none() {+ route = self.connect(net, c, &anchor, false, true).0;+ }+ match route {+ None => {+ let (soft_route, blockers) = self.connect(net, c, &anchor, true, false);+ blockers_all.extend(blockers.iter().copied());+ if let Some(sr) = soft_route {+ self.nets[net].last_soft.extend(sr.cells.iter().copied());+ }+ let key = self.nets[net].clusters[c].key(&self.board).to_string();+ let why = if blockers.is_empty() { "no path".to_string() } else {+ let mut names: Vec<String> = blockers.iter().map(|&b| self.name_of(b)).collect();+ names.sort();+ format!("blocked by {names:?}")+ };+ self.nets[net].failed.push((key, why));+ self.nets[net].clusters[c].connected = false;+ }+ Some(r) => {+ self.commit(net, r);+ self.nets[net].clusters[c].connected = true;+ connected.push(c);+ }+ }+ }+ blockers_all+ }++ pub fn name_of(&self, nid: usize) -> String {+ if let Some(&owner) = self.kelvin_owner.get(&nid) {+ return self.nets[owner].name.clone();+ }+ self.nets.iter().find(|n| n.idx == nid).map(|n| n.name.clone()).unwrap_or_else(|| "?".into())+ }++ pub fn commit(&mut self, net: usize, route: Route) {+ let end = *route.cells.last().unwrap();+ for r in self.nets[net].routes.iter_mut() {+ if let Some(pos) = r.cells.iter().position(|c| *c == end) {+ r.breaks.insert(pos);+ }+ }+ for e in [&route.start, &route.end] {+ if let End::Escape(c, t) = e {+ self.nets[net].used_escapes.insert((*c, *t));+ }+ }+ self.stamp_route(net, &route, 1);+ let ends = [route.start.clone(), route.end.clone()];+ self.nets[net].routes.push(route);+ for e in ends {+ match e {+ End::Escape(c, t) => {+ self.release_escapes(net, c);+ self.stamp_channel_pub(net, c, t, -1);+ }+ End::Pad(c, _) => self.release_escapes(net, c),+ _ => {}+ }+ }+ }++ pub fn stamp_channel_pub(&mut self, net: usize, c: usize, t: Term, sign: i32) {+ // the channel bookkeeping lives in lib.rs; re-expose through stamp_stub's helper+ let key = (self.nets[net].idx, c, t);+ if sign < 0 {+ if let Some(cells) = self.chan_lines.remove(&key) {+ for cell in cells {+ self.chan_count[cell] -= 1;+ }+ }+ }+ }++ pub fn release_escapes(&mut self, net: usize, cluster: usize) {+ let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();+ for t in escapes {+ let used = self.nets[net].used_escapes.contains(&(cluster, t));+ let released = self.nets[net].clusters[cluster].released.contains(&t);+ if !used && !released {+ self.stamp_stub(net, cluster, t, -1);+ self.nets[net].clusters[cluster].released.insert(t);+ }+ }+ }++ /// Re-reserve a cluster's escapes after a rip-up; an escape crossed by another net's copper stays dead.+ pub fn reserve_escapes(&mut self, net: usize, cluster: usize) {+ let g = self.grid.clone();+ let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();+ let nid = self.nets[net].idx as i16;+ for t in escapes {+ if self.nets[net].clusters[cluster].released.contains(&t) {+ let l = t.layer();+ let a = self.board.pads[self.nets[net].clusters[cluster].primary].polys_centre;+ let b = g.to_xy(t.i as i64, t.j as i64);+ let mut checks = vec![(a, b, self.rules.stub / 2.0 + self.rules.clearance)];+ if let Some(k) = self.nets[net].clusters[cluster].corridors.get(&t) {+ checks.push((b, *k, self.rules.track / 2.0 + self.rules.clearance));+ }+ let mut blocked = false;+ for (s0, s1, hw) in checks {+ for c in g.segment_cells(s0, s1, hw, Some(1e-6)) {+ let raw = self.route_raw[l][c];+ if raw != -1 && raw != nid {+ blocked = true;+ break;+ }+ }+ if blocked {+ break;+ }+ }+ if blocked {+ let cl = &mut self.nets[net].clusters[cluster];+ if cl.terminals.len() > 1 {+ cl.terminals.retain(|x| *x != t);+ }+ self.stat("deadEscapes", 1);+ continue;+ }+ self.stamp_stub(net, cluster, t, 1);+ self.nets[net].clusters[cluster].released.remove(&t);+ } else {+ self.stamp_stub_channel(net, cluster, t);+ }+ }+ }++ fn stamp_stub_channel(&mut self, net: usize, cluster: usize, t: Term) {+ // (re)count the channel only: stamp_stub(+1) would re-stamp copper that is still there+ let key = (self.nets[net].idx, cluster, t);+ if self.chan_lines.contains_key(&key) {+ return;+ }+ // reuse stamp_stub's channel computation by a +1 stub stamp on already-stamped cells (idempotent copper)+ self.stamp_stub(net, cluster, t, 1);+ }++ pub fn rip_cluster(&mut self, net: usize, cluster: usize) {+ 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) {+ self.stamp_route(net, &r, -1);+ for c in &r.cells {+ self.bump_history(c);+ }+ } else {+ keep.push(r);+ }+ }+ self.nets[net].routes = keep;+ self.nets[net].used_escapes.retain(|(ci, _)| *ci != cluster);+ self.nets[net].clusters[cluster].connected = false;+ self.reserve_escapes(net, cluster);+ let key = self.nets[net].clusters[cluster].key(&self.board).to_string();+ if !self.nets[net].failed.iter().any(|(k, _)| *k == key) {+ self.nets[net].failed.push((key, "ripped for a signal".into()));+ }+ self.stat("rips", 1);+ self.rip_total += 1;+ }++ fn bump_history(&mut self, c: &Term) {+ let g = &self.grid;+ for jj in (c.j as i64 - 2).max(0)..=(c.j as i64 + 2).min(g.h as i64 - 1) {+ for ii in (c.i as i64 - 2).max(0)..=(c.i as i64 + 2).min(g.w as i64 - 1) {+ let idx = jj as usize * g.w + ii as usize;+ self.history[c.layer()][idx] = self.history[c.layer()][idx].saturating_add(1);+ }+ }+ }++ pub fn rip(&mut self, net: usize) {+ let routes = std::mem::take(&mut self.nets[net].routes);+ for r in &routes {+ self.stamp_route(net, r, -1);+ for c in &r.cells {+ self.bump_history(c);+ }+ }+ 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.nets[net].rips += 1;+ self.rip_total += 1;+ self.stat("rips", 1);+ }++ // -- Kelvin taps -----------------------------------------------------------------------+ pub fn route_kelvin(&mut self, net: usize, src: usize, dst: usize) -> bool {+ let g = self.grid.clone();+ let mut kid = 30000 + (src % 1000);+ while self.nets.iter().any(|n| n.idx == kid) || self.kelvin_owner.contains_key(&kid) {+ kid += 1;+ }+ self.kelvin_owner.insert(kid, net);+ let nid = self.nets[net].idx as i16;+ let mut saved: Vec<(usize, Vec<usize>)> = Vec::new();+ for &cl in &[src, dst] {+ let p = self.board.pads[self.nets[net].clusters[cl].primary].clone();+ let cells = g.disk_cells((p.x, p.y), p.half_long + self.rules.escape + self.rules.corridor + 0.3, Some(1e-6));+ for l in 0..2 {+ let mine: Vec<usize> = cells.iter().copied().filter(|&c| self.pad_raw[l][c] == nid).collect();+ for &c in &mine {+ self.pad_raw[l][c] = kid as i16;+ }+ saved.push((l, mine));+ }+ }+ // a synthetic net state just for the search+ let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill };+ self.nets.push(fake);+ let fidx = self.nets.len() - 1;+ let sources: Vec<(Term, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (*t, 0)).collect();+ let targets: HashSet<Term> = self.nets[net].clusters[dst].terminals.iter().copied().collect();+ let mut pts: Vec<(i64, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+ let dpts: Vec<(i64, i64)> = self.nets[net].clusters[dst].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+ pts.extend_from_slice(&dpts);+ let allow_via = self.nets[net].plane.is_none();+ let width = self.rules.stub + 0.05;+ let mut cells = None;+ for margin in [4.0, 10.0] {+ let win = self.window_for(&pts, margin);+ let (c, _) = self.search(fidx, width, Search { sources: sources.clone(), targets: Some(&targets), heur: dpts.clone(), win, allow_via, soft: false, via_target: false, max_nodes: None, vip: Vec::new() });+ if c.is_some() {+ cells = c;+ break;+ }+ }+ self.nets.pop();+ for (l, mine) in saved {+ for c in mine {+ self.pad_raw[l][c] = nid;+ }+ }+ let Some(cells) = cells else { self.kelvin_owner.remove(&kid); return false };+ let start = if self.nets[net].clusters[src].escapes.contains_key(&cells[0]) { End::Escape(src, cells[0]) } else { End::Pad(src, cells[0]) };+ let last = *cells.last().unwrap();+ let end = if self.nets[net].clusters[dst].escapes.contains_key(&last) { End::Escape(dst, last) } else { End::Pad(dst, last) };+ let mut r = Route::new(cells.clone(), width, start, end);+ r.kelvin = true;+ self.commit(net, r);+ // the tap is foreign copper to the rest of its net, except inside the pads it joins: the+ // pickup is the pad itself, and the wide path must still be able to land on that pad+ for c in &cells {+ let xy = g.to_xy(c.i as i64, c.j as i64);+ for cell in g.disk_cells(xy, width / 2.0, Some(1e-6)) {+ if self.pad_raw[c.layer()][cell] != nid {+ self.route_raw[c.layer()][cell] = kid as i16;+ }+ }+ }+ self.nets[net].clusters[src].connected = true;+ self.kelvin_clusters.insert((net, src));+ self.stat("kelvin", 1);+ let (sk, dk) = (self.nets[net].clusters[src].key(&self.board).to_string(), self.nets[net].clusters[dst].key(&self.board).to_string());+ self.log(format!(" kelvin {sk} -> {dk}: {} cells, dedicated", cells.len()));+ true+ }++ // -- planes ------------------------------------------------------------------------------+ pub fn route_plane_net(&mut self, net: usize, retry: bool) {+ let g = self.grid.clone();+ let failed: HashSet<String> = if retry { self.nets[net].failed.iter().map(|(k, _)| k.clone()).collect() } else { HashSet::new() };+ if retry {+ self.nets[net].failed.clear();+ }+ let n = self.nets[net].clusters.len();+ let kelvin = self.rules.kelvin.clone();+ for c in 0..n {+ let cl = self.nets[net].clusters[c].clone();+ let key = cl.key(&self.board).to_string();+ if self.board.pads[cl.primary].thru {+ self.nets[net].clusters[c].connected = true;+ continue;+ }+ if kelvin.contains_key(&key) {+ continue;+ }+ if retry && (cl.connected || !failed.contains(&key)) {+ continue;+ }+ let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+ let pts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+ let win = self.window_for(&pts, self.rules.via_search_mm);+ let own: HashSet<Term> = self.copper_targets(net).into_iter().filter(|t| t.layer() == F_CU).collect();+ let track = self.rules.track;+ let (cells, _) = self.search(net, track, Search { sources, targets: Some(&own), heur: Vec::new(), win, allow_via: false, soft: false, via_target: true, max_nodes: None, vip: Vec::new() });+ match cells {+ None => {+ let p = self.board.pads[cl.primary].clone();+ let (i, j) = g.to_cell(p.x, p.y);+ if self.via_clear_exact(net, p.x, p.y) {+ let mut r = Route::new(vec![Term::new(F_CU, i, j)], track, End::Pad(c, Term::new(F_CU, i, j)), End::Via);+ r.via_end = true;+ self.commit(net, r);+ self.nets[net].clusters[c].connected = true;+ self.nets[net].via_in_pad.push(key.clone());+ self.stat("viaInPad", 1);+ } else {+ self.nets[net].failed.push((key.clone(), format!("no via site within {:.0} mm", self.rules.via_search_mm)));+ }+ }+ Some(cells) => {+ let start = if cl.escapes.contains_key(&cells[0]) { End::Escape(c, cells[0]) } else { End::Pad(c, cells[0]) };+ let joined = own.contains(cells.last().unwrap());+ let mut r = Route::new(cells, track, start, if joined { End::Copper } else { End::Via });+ r.via_end = !joined;+ self.commit(net, r);+ self.nets[net].clusters[c].connected = true;+ }+ }+ }+ }++ /// Exact geometry: may a via of this net sit at (x, y)?+ pub fn via_clear_exact(&mut self, net: usize, x: f64, y: f64) -> bool {+ let g = self.grid.clone();+ let (via, drill) = (self.nets[net].via, self.nets[net].drill);+ let rv = via / 2.0;+ let (i, j) = g.to_cell(x, y);+ if !g.in_bounds(i, j) || !self.inside_for(via).get(i as usize, j as usize) {+ return false;+ }+ let reach = rv + self.big_clr_val + 1.5;+ let name = self.nets[net].name.clone();+ for p in &self.board.pads {+ if (p.x - x).abs() > reach + p.half_long || (p.y - y).abs() > reach + p.half_long {+ continue;+ }+ if p.net == name && !p.thru {+ continue;+ }+ let mut need = if p.thru && p.net == name { drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0 + slop() } else { rv + self.rules.clearance.max(p.clearance) };+ if p.thru && p.net != name {+ need = need.max(drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0);+ }+ for poly in &p.polys {+ if geom::point_poly_distance(x, y, poly) < need {+ return false;+ }+ }+ }+ let nid = self.nets[net].idx;+ for (oi, other) in self.nets.iter().enumerate() {+ if other.idx == nid || oi == net {+ continue;+ }+ for r in &other.routes {+ let need = rv + self.rules.clearance + r.width / 2.0;+ for (ax, ay, bx, by) in route_segments(&g, &r.cells) {+ if (ax - x).abs() > 3.0 && (bx - x).abs() > 3.0 {+ continue;+ }+ if geom::seg_distance(x, y, ax, ay, bx, by) < need {+ return false;+ }+ }+ }+ }+ for &(vi, vj, vnid, dia, vdrill) in &self.vias {+ let (vx, vy) = g.to_xy(vi as i64, vj as i64);+ if (vx - x).hypot(vy - y) < self.via_gap(net, vnid, dia, vdrill) {+ return false;+ }+ }+ true+ }++ // -- driver --------------------------------------------------------------------------+ pub fn mst_length(&self, net: usize) -> f64 {+ let pts: Vec<(f64, f64)> = self.nets[net].clusters.iter().map(|c| c.xy(&self.board)).collect();+ if pts.len() < 2 {+ return 0.0;+ }+ let mut inn = vec![pts[0]];+ let mut rest: Vec<(f64, f64)> = pts[1..].to_vec();+ let mut total = 0.0;+ while !rest.is_empty() {+ let mut best = (f64::INFINITY, 0usize);+ for (ri, b) in rest.iter().enumerate() {+ for a in &inn {+ let d = (a.0 - b.0).hypot(a.1 - b.1);+ if d < best.0 {+ best = (d, ri);+ }+ }+ }+ total += best.0;+ inn.push(rest.remove(best.1));+ }+ total+ }++ pub fn run(&mut self, planes: bool, rip_per_net: usize, rip_global: usize, priority: &[String]) -> usize {+ let t0 = std::time::Instant::now();+ self.rip_total = 0;+ for n in self.nets.iter_mut() {+ n.rips = 0;+ }+ 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();+ if planes {+ for &net in &plane_nets {+ let t1 = std::time::Instant::now();+ let n = self.nets[net].clusters.len();+ for c in 0..n {+ let key = self.nets[net].clusters[c].key(&self.board).to_string();+ if let Some(dk) = kelvin.get(&key) {+ let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dk);+ if dst.map(|d| !self.route_kelvin(net, c, d)).unwrap_or(true) {+ self.nets[net].failed.push((key, "kelvin tap found no dedicated path".into()));+ }+ }+ }+ self.route_plane_net(net, false);+ let (name, plane, nr, nv, nf) = (self.nets[net].name.clone(), self.nets[net].plane.clone().unwrap(), self.nets[net].routes.len(), self.nets[net].via_in_pad.len(), self.nets[net].failed.len());+ self.log(format!(" {name:<28} plane {plane}: {} smd stubs, {nv} via-in-pad, {nf} failed, {:.1}s", nr - nv, t1.elapsed().as_secs_f64()));+ }+ }+ let mut signal: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_none() && n.clusters.len() >= 2).map(|n| n.idx).collect();+ for &n in &signal {+ let l = self.mst_length(n);+ self.nets[n].mst_len = l;+ }+ signal.sort_by(|a, b| self.nets[*a].mst_len.partial_cmp(&self.nets[*b].mst_len).unwrap());+ let mut first: Vec<usize> = signal.iter().copied().filter(|n| priority.contains(&self.nets[*n].name)).collect();+ let mut rest: Vec<usize> = signal.iter().copied().filter(|n| !priority.contains(&self.nets[*n].name)).collect();+ let mut queue: Vec<usize> = Vec::new();+ queue.append(&mut first);+ queue.append(&mut rest);+ self.log(format!("routing {} signal nets, shortest first{}", queue.len(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));+ let mut order_no = 0;+ let mut best_state: Option<(usize, Snapshot)> = None;+ while !queue.is_empty() {+ let net = queue.remove(0);+ if !self.nets[net].routes.is_empty() {+ self.rip(net);+ }+ let t1 = std::time::Instant::now();+ let blockers = self.route_net(net);+ order_no += 1;+ self.nets[net].routed_order = Some(order_no);+ {+ let n = &self.nets[net];+ let widths: Vec<String> = n.widths.iter().map(|w| format!("{w}")).collect();+ let line = format!(" {:<28} {:>2} clusters {} {} routes, {} failed, {:.1}s{}", n.name, n.clusters.len(), widths.join("/"), n.routes.len(), n.failed.len(), t1.elapsed().as_secs_f64(), if n.failed.is_empty() { String::new() } else { format!(" FAILED {:?}", n.failed) });+ self.log(line);+ }+ let score = self.nets.iter().filter(|n| !queue.contains(&n.idx)).map(|n| n.failed.len()).sum::<usize>() + queue.iter().map(|&q| self.nets[q].clusters.len().saturating_sub(1)).sum::<usize>();+ if best_state.as_ref().map(|b| score < b.0).unwrap_or(true) {+ 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();+ 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();+ if cands.is_empty() {+ let soft: HashSet<(i32, i32)> = self.nets[net].last_soft.iter().flat_map(|t| { let (i, j) = (t.i, t.j); (-6..=6).flat_map(move |di| (-6..=6).map(move |dj| (i + di, j + dj))) }).collect();+ for &b in &blockers {+ if let Some(pn) = self.nets.iter().position(|n| n.idx == b) {+ if self.nets[pn].plane.is_none() {+ continue;+ }+ for r in self.nets[pn].routes.clone() {+ if r.cells.iter().any(|t| soft.contains(&(t.i, t.j))) {+ if let Some(cl) = r.start.cluster() {+ stubs.push((pn, cl));+ }+ }+ }+ }+ }+ }+ if !cands.is_empty() || !stubs.is_empty() {+ let cn: Vec<String> = cands.iter().map(|&c| self.nets[c].name.clone()).collect();+ let sn: Vec<String> = stubs.iter().map(|(pn, cl)| self.nets[*pn].clusters[*cl].key(&self.board).to_string()).collect();+ let name = self.nets[net].name.clone();+ self.log(format!(" rip-up {cn:?}{} and retry {name}", if sn.is_empty() { String::new() } else { format!(" + plane stubs {sn:?}") }));+ for &c in &cands {+ self.rip(c);+ queue.retain(|&q| q != c);+ }+ for (pn, cl) in stubs {+ self.rip_cluster(pn, cl);+ }+ self.nets[net].rips += 1;+ queue.insert(0, net);+ for (k, &c) in cands.iter().enumerate() {+ queue.insert(1 + k, c);+ }+ continue;+ }+ }+ if planes {+ for &pn in &plane_nets {+ if !self.nets[pn].failed.is_empty() {+ self.route_plane_net(pn, true);+ }+ }+ }+ }+ if planes {+ for &pn in &plane_nets {+ if self.nets[pn].failed.is_empty() {+ continue;+ }+ let before = self.nets[pn].failed.len();+ self.route_plane_net(pn, true);+ let (name, after) = (self.nets[pn].name.clone(), self.nets[pn].failed.len());+ self.log(format!(" {name:<28} plane retry: {} of {before} stubs recovered", before - after));+ }+ }+ let mut final_failed: usize = self.nets.iter().map(|n| n.failed.len()).sum();+ if let Some((score, snap)) = best_state {+ if score < final_failed {+ self.log(format!(" restoring the best intermediate state ({score} failed connections instead of {final_failed})"));+ self.restore(&snap);+ if planes {+ for &pn in &plane_nets {+ if !self.nets[pn].failed.is_empty() {+ self.route_plane_net(pn, true);+ }+ }+ }+ final_failed = self.nets.iter().map(|n| n.failed.len()).sum();+ }+ }+ let (exp, rips) = (self.stats.get("expanded").copied().unwrap_or(0), self.stats.get("rips").copied().unwrap_or(0));+ self.log(format!("routing done in {:.1}s, {exp} node expansions, {rips} rip-ups, {final_failed} failed connections", t0.elapsed().as_secs_f64()));+ final_failed+ }++ pub fn reset(&mut self) {+ let n = self.nets.len();+ for net in 0..n {+ if !self.nets[net].routes.is_empty() {+ self.rip(net);+ }+ self.nets[net].failed.clear();+ self.nets[net].via_in_pad.clear();+ self.nets[net].routed_order = None;+ self.nets[net].used_escapes.clear();+ for c in self.nets[net].clusters.iter_mut() {+ c.connected = false;+ }+ }+ self.rip_total = 0;+ }++ pub fn snapshot(&self) -> Snapshot {+ Snapshot { nets: self.nets.iter().map(|n| (n.routes.clone(), n.used_escapes.clone(), n.failed.clone(), n.via_in_pad.clone(), n.routed_order)).collect() }+ }++ pub fn restore(&mut self, snap: &Snapshot) {+ self.reset();+ for (net, (routes, used, failed, vip, order)) in snap.nets.iter().enumerate() {+ self.nets[net].used_escapes = used.clone();+ self.nets[net].failed = failed.clone();+ self.nets[net].via_in_pad = vip.clone();+ self.nets[net].routed_order = *order;+ for r in routes {+ self.stamp_route(net, r, 1);+ if r.kelvin {+ // the tap's own-net exclusion is rebuilt by the next kelvin routing; here it is copper of its net+ }+ self.nets[net].routes.push(r.clone());+ }+ let n = self.nets[net].clusters.len();+ for c in 0..n {+ let plane_thru = self.nets[net].plane.is_some() && self.board.pads[self.nets[net].clusters[c].primary].thru;+ let touched = self.nets[net].routes.iter().any(|r| r.start.cluster() == Some(c) || (matches!(r.end, End::Pad(..) | End::Escape(..)) && r.end.cluster() == Some(c)));+ self.nets[net].clusters[c].connected = touched || plane_thru;+ }+ }+ }++ // -- plan emission -----------------------------------------------------------------------+ fn waypoints(&self, net: usize, route: &Route) -> Vec<Value> {+ let g = &self.grid;+ let cells = &route.cells;+ let xy = |c: &Term| { let p = g.to_xy(c.i as i64, c.j as i64); json!([p.0, p.1]) };+ let mut pts: Vec<Value> = Vec::new();+ match &route.start {+ End::Pad(cl, term) => {+ let key = self.nets[net].clusters[*cl].key(&self.board);+ if term.layer() == B_CU { pts.push(json!({"pad": key, "layer": "B.Cu"})); } else { pts.push(json!(key)); }+ }+ _ => pts.push(xy(&cells[0])),+ }+ let n = cells.len();+ let mut k = 1;+ while k < n {+ let (l, i, j) = (cells[k].layer(), cells[k].i, cells[k].j);+ let (pl, pi, pj) = (cells[k - 1].layer(), cells[k - 1].i, cells[k - 1].j);+ if l != pl {+ let p = g.to_xy(i as i64, j as i64);+ pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[l]}));+ k += 1;+ continue;+ }+ let (di, dj) = (i - pi, j - pj);+ let mut m = k;+ while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) && !route.breaks.contains(&m) {+ m += 1;+ }+ if m == n - 1 {+ break;+ }+ pts.push(xy(&cells[m]));+ k = m + 1;+ }+ let last = cells[n - 1];+ if route.via_end {+ let mut p = g.to_xy(last.i as i64, last.j as i64);+ if n == 1 {+ if let End::Pad(cl, _) = &route.start {+ let pad = &self.board.pads[self.nets[net].clusters[*cl].primary];+ p = (pad.x, pad.y);+ }+ }+ pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[route.via_end_layer]}));+ if let End::Pad(cl, _) = &route.end {+ pts.push(json!(self.nets[net].clusters[*cl].key(&self.board)));+ }+ } else {+ match &route.end {+ End::Pad(cl, _) => pts.push(json!(self.nets[net].clusters[*cl].key(&self.board))),+ _ => pts.push(xy(&last)),+ }+ }+ pts+ }++ pub fn plan(&self) -> Vec<Value> {+ let mut order: Vec<usize> = (0..self.nets.len()).collect();+ order.sort_by_key(|&n| (self.nets[n].routed_order.unwrap_or(1_000_000), self.nets[n].name.clone()));+ let mut entries = Vec::new();+ for n in order {+ let net = &self.nets[n];+ if net.routes.is_empty() {+ continue;+ }+ let mut by_width: Vec<(f64, Vec<Value>)> = Vec::new();+ for r in &net.routes {+ let pts = self.waypoints(n, r);+ if pts.len() < 2 {+ continue;+ }+ if r.kelvin {+ entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": true}));+ continue;+ }+ if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {+ e.1.push(Value::Array(pts));+ } else {+ by_width.push((r.width, vec![Value::Array(pts)]));+ }+ }+ let mut stubs: Vec<Value> = Vec::new();+ let mut used: Vec<(usize, Term)> = net.used_escapes.iter().copied().collect();+ used.sort();+ for (cidx, term) in used {+ let key = net.clusters[cidx].key(&self.board);+ let p = self.grid.to_xy(term.i as i64, term.j as i64);+ // the stub leaves along the pad's long axis: a pad centre is off the grid, and a stub+ // that slopes toward the escape cell from the first micrometre grazes the neighbouring+ // pin (0.1989 mm against a 0.2 rule on a 0.5 mm pitch); the jog to the cell happens at+ // the cell end, inside the stub's own width+ let pad = &self.board.pads[net.clusters[cidx].primary];+ let (ax, ay) = pad.long_axis;+ let norm = (ax * ax + ay * ay).sqrt();+ let mut pts = vec![json!(key)];+ if norm > 1e-9 {+ let (ux, uy) = (ax / norm, ay / norm);+ let t = (p.0 - pad.x) * ux + (p.1 - pad.y) * uy;+ let mid = (((pad.x + ux * t) * 1000.0).round() / 1000.0, ((pad.y + uy * t) * 1000.0).round() / 1000.0);+ let jog = (p.0 - mid.0).hypot(p.1 - mid.1);+ if jog > 1e-6 && jog < self.rules.stub && t.abs() > 1e-6 {+ pts.push(json!([mid.0, mid.1]));+ }+ }+ pts.push(json!([p.0, p.1]));+ stubs.push(Value::Array(pts));+ }+ if !stubs.is_empty() {+ entries.push(json!({"net": net.name, "width": self.rules.stub, "viaSize": net.via, "viaDrill": net.drill, "paths": stubs}));+ }+ by_width.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());+ for (w, paths) in by_width {+ entries.push(json!({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": paths}));+ }+ }+ entries+ }++ pub fn report(&self) -> Value {+ let mut nets = serde_json::Map::new();+ let mut unrouted = Vec::new();+ for net in &self.nets {+ if net.clusters.len() < 2 && net.plane.is_none() {+ continue;+ }+ let mut widths: Vec<f64> = net.routes.iter().map(|r| r.width).collect();+ widths.sort_by(|a, b| b.partial_cmp(a).unwrap());+ widths.dedup();+ nets.insert(net.name.clone(), json!({+ "clusters": net.clusters.len(), "pads": net.pads.len(), "routes": net.routes.len(), "widths": widths, "plane": net.plane,+ "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,+ }));+ for (k, why) in &net.failed {+ unrouted.push(json!([net.name, [k, why]]));+ }+ }+ json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted})+ }+}++/// Straight runs of a cell path as exact segments, vias skipped.+pub fn route_segments(g: &aiflow_grid::Grid, cells: &[Term]) -> Vec<(f64, f64, f64, f64)> {+ let mut out = Vec::new();+ let n = cells.len();+ let mut k = 0;+ while k + 1 < n {+ let l = cells[k].layer();+ let mut m = k + 1;+ if cells[m].layer() != l {+ k = m;+ continue;+ }+ let (di, dj) = (cells[m].i - cells[k].i, cells[m].j - cells[k].j);+ while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) {+ m += 1;+ }+ let a = g.to_xy(cells[k].i as i64, cells[k].j as i64);+ let b = g.to_xy(cells[m].i as i64, cells[m].j as i64);+ out.push((a.0, a.1, b.0, b.1));+ k = m;+ }+ out+}++/// Route a board: passes with the failed nets first; keeps the best pass. Returns (plan entries, report, failed).+pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_global: usize) -> (Vec<Value>, Value, usize) {+ let mut best: Option<(usize, Snapshot, usize)> = None;+ let mut priority: Vec<String> = Vec::new();+ for k in 1..=passes {+ if k > 1 {+ router.reset();+ }+ router.log(format!("=== pass {k} of {passes} ==="));+ let failed = router.run(true, rip_per_net, rip_global, &priority);+ let mut names: Vec<String> = router.nets.iter().filter(|n| !n.failed.is_empty()).map(|n| n.name.clone()).collect();+ names.sort();+ names.dedup();+ router.log(format!("=== pass {k}: {failed} failed connections in {names:?}"));+ if best.as_ref().map(|b| failed < b.0).unwrap_or(true) {+ best = Some((failed, router.snapshot(), k));+ }+ if failed == 0 {+ break;+ }+ priority = names;+ }+ let (failed, snap, k) = best.unwrap();+ router.restore(&snap);+ router.log(format!("keeping pass {k} ({failed} failed connections)"));+ let plan = router.plan();+ let report = router.report();+ (plan, report, failed)+}++#[allow(dead_code)]+fn _unused(_: HashMap<u8, u8>) {}
crates/aiflow-router/src/lib.rsadded+800@@ -0,0 +1,800 @@+//! adom-aiflow's routing engine: a grid maze router for any KiCad 10 board.+//!+//! 0.1 mm grid over the outline, two signal layers (F.Cu, B.Cu), through vias between them+//! (0.6/0.3 mm for signals, 0.8/0.4 on the wide power nets), the board's rules plus every per-pad+//! clearance override. A* per connection (octilinear, +cost per via and per turn, F.Cu preferred),+//! obstacles per layer for the net being routed (every other net's copper dilated by clearance+//! plus half the track width), narrow pins entered on their axis through a reserved escape stub,+//! same-net overlapping pads clustered, each net routed as a minimum spanning tree onto its own+//! copper, shortest nets first, plane nets stubbed to a via, rip-up with history cost when a+//! connection fails, multi-pass with the failed nets first, and Kelvin sense taps routed first as+//! dedicated traces that the rest of their net treats as foreign copper.+//!+//! Output: a plan, a list of `kicad_route_net` calls ({net, width, viaSize, viaDrill, paths}).+pub mod drive;+pub mod rules;++use std::collections::{HashMap, HashSet};++use aiflow_board::{geom, Board, Pad, Pt, B_CU, F_CU};+use aiflow_grid::{Grid, Mask, RES};++pub use rules::Rules;++pub const COST_ORTH: i64 = 10;+pub const COST_DIAG: i64 = 14;+pub const COST_VIA: i64 = 250;+pub const COST_TURN: i64 = 4;+pub const LAYER_MULT: [f64; 2] = [1.0, 1.1];+pub const SOFT_EXTRA: i64 = 80;+pub const ZONE_COST: i64 = 20;+pub const VIA_WALL: i64 = 300;+pub const HIST_COST: i64 = 12;+pub const DIRS: [(i32, i32); 8] = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)];+pub const STEP: [i64; 8] = [10, 10, 10, 10, 14, 14, 14, 14];+pub const INF: i64 = 1 << 40;++/// Half a cell diagonal: pads and stubs are off-grid. (Adding a margin here crosses a cell+/// boundary in the dilation radius and chokes the 0.5 mm-pitch escapes; a net that lands a hair+/// under the rule gets a per-net clearance from the spec instead: netClearance.)+pub fn slop() -> f64 {+ 0.05 * std::f64::consts::SQRT_2 + 1e-6+}++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]+pub struct Term {+ pub l: u8,+ pub i: i32,+ pub j: i32,+}++impl Term {+ pub fn new(l: usize, i: i64, j: i64) -> Term {+ Term { l: l as u8, i: i as i32, j: j as i32 }+ }+ pub fn layer(&self) -> usize {+ self.l as usize+ }+}++#[derive(Clone, Debug)]+pub struct Cluster {+ pub idx: usize,+ pub pads: Vec<usize>,+ pub primary: usize,+ pub terminals: Vec<Term>,+ pub escapes: HashMap<Term, Pt>,+ pub corridors: HashMap<Term, Pt>,+ pub connected: bool,+ pub allow: f64,+ pub released: HashSet<Term>,+}++impl Cluster {+ pub fn key<'a>(&self, board: &'a Board) -> &'a str {+ &board.pads[self.primary].key+ }+ pub fn xy(&self, board: &Board) -> Pt {+ (board.pads[self.primary].x, board.pads[self.primary].y)+ }+}++#[derive(Clone, Debug)]+pub enum End {+ Pad(usize, Term),+ Escape(usize, Term),+ Copper,+ Via,+}++impl End {+ pub fn cluster(&self) -> Option<usize> {+ match self {+ End::Pad(c, _) | End::Escape(c, _) => Some(*c),+ _ => None,+ }+ }+ pub fn term(&self) -> Option<Term> {+ match self {+ End::Pad(_, t) | End::Escape(_, t) => Some(*t),+ _ => None,+ }+ }+}++#[derive(Clone, Debug)]+pub struct Route {+ pub cells: Vec<Term>,+ pub width: f64,+ pub start: End,+ pub end: End,+ pub breaks: HashSet<usize>,+ pub via_end: bool,+ pub via_end_layer: usize,+ pub kelvin: bool,+}++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 }+ }+}++#[derive(Clone, Debug)]+pub struct NetState {+ pub idx: usize,+ pub name: String,+ pub pads: Vec<usize>,+ pub clusters: Vec<Cluster>,+ pub routes: Vec<Route>,+ pub widths: Vec<f64>,+ pub plane: Option<String>,+ pub mst_len: f64,+ pub used_escapes: HashSet<(usize, Term)>,+ pub failed: Vec<(String, String)>,+ pub rips: usize,+ pub routed_order: Option<usize>,+ pub via_in_pad: Vec<String>,+ pub last_soft: Vec<Term>,+ pub via: f64,+ pub drill: f64,+}++#[derive(Clone)]+pub struct Snapshot {+ pub nets: Vec<(Vec<Route>, HashSet<(usize, Term)>, Vec<(String, String)>, Vec<String>, Option<usize>)>,+}++pub struct Router {+ pub board: Board,+ pub grid: Grid,+ pub rules: Rules,+ pub verbose: bool,+ pub nets: Vec<NetState>,+ pub net_ids: HashMap<String, usize>,+ pub pad_raw: [Vec<i16>; 2],+ pub pad_owner: [Vec<i16>; 2],+ pub route_raw: [Vec<i16>; 2],+ pub big_clr: [Mask; 2],+ pub big_clr_val: f64,+ pub smd_pad_cells: HashMap<usize, Mask>,+ pub thru_cells: Mask,+ pub history: [Vec<i16>; 2],+ pub chan_count: Vec<i16>,+ pub chan_lines: HashMap<(usize, usize, Term), Vec<usize>>,+ pub vias: Vec<(i32, i32, usize, f64, f64)>,+ pub fp_index: HashMap<String, usize>,+ pub pad_cells: HashMap<String, Vec<usize>>,+ pub inside: Mask,+ pub edge_dist: Vec<f32>,+ pub inside_w: HashMap<i64, Mask>,+ pub rip_total: usize,+ pub stats: HashMap<String, i64>,+ pub kelvin_owner: HashMap<usize, usize>,+ pub kelvin_clusters: HashSet<(usize, usize)>,+ pub body_owner: Vec<i16>,+ pub ic_zone: Mask,+ pub ic_nets: HashMap<usize, HashSet<usize>>,+ pub log_lines: Vec<String>,+}++impl Router {+ pub fn log(&mut self, s: String) {+ if self.verbose {+ eprintln!("{s}");+ }+ self.log_lines.push(s);+ }++ pub fn stat(&mut self, k: &str, d: i64) {+ *self.stats.entry(k.to_string()).or_insert(0) += d;+ }++ pub fn new(board: Board, rules: Rules, verbose: bool) -> Router {+ let grid = Grid::new(board.bbox);+ let n = grid.w * grid.h;+ let mut names: Vec<&String> = board.nets.keys().collect();+ names.sort();+ let mut nets = Vec::new();+ let mut net_ids = HashMap::new();+ for (k, name) in names.iter().enumerate() {+ net_ids.insert((*name).clone(), k);+ nets.push(NetState {+ idx: k, name: (*name).clone(), pads: board.nets[*name].clone(), clusters: Vec::new(), routes: Vec::new(),+ widths: vec![rules.track], plane: rules.planes.get(*name).cloned(), mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(),+ rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small,+ });+ }+ let mut refs: Vec<&String> = board.footprints.keys().collect();+ refs.sort();+ let fp_index: HashMap<String, usize> = refs.iter().enumerate().map(|(i, r)| ((*r).clone(), i)).collect();+ let (inside, edge_dist) = grid.outline_distance(&board.outline);+ let mut r = Router {+ grid: grid.clone(), rules, verbose, nets, net_ids,+ pad_raw: [vec![-1; n], vec![-1; n]], pad_owner: [vec![-1; n], vec![-1; n]], route_raw: [vec![-1; n], vec![-1; n]],+ big_clr: [grid.mask(), grid.mask()], big_clr_val: 0.0, smd_pad_cells: HashMap::new(), thru_cells: grid.mask(),+ history: [vec![0; n], vec![0; n]], chan_count: vec![0; n], chan_lines: HashMap::new(), vias: Vec::new(), fp_index,+ 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(), board,+ };+ r.big_clr_val = r.rules.clearance;+ let t0 = std::time::Instant::now();+ r.stamp_pads();+ r.build_clusters();+ r.build_ic_zones();+ let count = r.nets.iter().filter(|n| n.clusters.len() >= 2 || n.plane.is_some()).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+ }++ // -- stamping --------------------------------------------------------------------+ fn stamp_pads(&mut self) {+ let g = self.grid.clone();+ let pads: Vec<Pad> = self.board.pads.clone();+ for p in &pads {+ let nid = self.net_ids.get(&p.net).map(|&k| k as i16).unwrap_or(-1);+ let mut m = g.mask();+ for poly in &p.polys {+ g.poly_mask(poly, &mut m);+ }+ if let Some(d) = p.drill {+ for c in g.disk_cells((p.x, p.y), d / 2.0, None) {+ m.bits[c] = true;+ }+ }+ let cells: Vec<usize> = m.bits.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();+ self.pad_cells.insert(p.key.clone(), cells.clone());+ let layers: Vec<usize> = if p.thru { vec![F_CU, B_CU] } else if p.on_f { vec![F_CU] } else { vec![B_CU] };+ let fpi = self.fp_index[&p.reference] as i16;+ for &l in &layers {+ for &c in &cells {+ self.pad_raw[l][c] = nid;+ self.pad_owner[l][c] = fpi;+ if p.clearance > self.rules.clearance {+ self.big_clr[l].bits[c] = true;+ }+ }+ if p.clearance > self.rules.clearance {+ self.big_clr_val = self.big_clr_val.max(p.clearance);+ }+ }+ if !p.thru && nid >= 0 {+ let sm = self.smd_pad_cells.entry(nid as usize).or_insert_with(|| g.mask());+ for &c in &cells {+ sm.bits[c] = true;+ }+ }+ if p.thru {+ for &c in &cells {+ self.thru_cells.bits[c] = true;+ }+ }+ }+ }++ pub fn inside_for(&mut self, w: f64) -> &Mask {+ let key = (w * 1000.0).round() as i64;+ if !self.inside_w.contains_key(&key) {+ let mut m = self.inside.clone();+ let need = (self.rules.edge + w / 2.0 + slop()) as f32;+ for (b, d) in m.bits.iter_mut().zip(&self.edge_dist) {+ *b = *b && *d >= need;+ }+ self.inside_w.insert(key, m);+ }+ &self.inside_w[&key]+ }++ pub fn stamp_route(&mut self, net: usize, route: &Route, sign: i32) {+ let g = self.grid.clone();+ let nid = if sign > 0 { self.nets[net].idx as i16 } else { -1 };+ let cells = &route.cells;+ let mut k = 0;+ while k + 1 < cells.len() {+ let (l, i0, j0) = (cells[k].layer(), cells[k].i, cells[k].j);+ let mut m = k + 1;+ let (di, dj) = (cells[m].i - i0, cells[m].j - j0);+ while m + 1 < cells.len() && cells[m + 1].layer() == l && cells[m].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) {+ m += 1;+ }+ if cells[m].layer() == l {+ let a = g.to_xy(i0 as i64, j0 as i64);+ let b = g.to_xy(cells[m].i as i64, cells[m].j as i64);+ for c in g.segment_cells(a, b, route.width / 2.0, Some(1e-6)) {+ self.route_raw[l][c] = nid;+ }+ }+ k = m;+ }+ for k in 1..cells.len() {+ if cells[k].layer() != cells[k - 1].layer() && (cells[k].i, cells[k].j) == (cells[k - 1].i, cells[k - 1].j) {+ self.stamp_via(cells[k].i, cells[k].j, net, sign);+ }+ }+ if route.via_end {+ let t = *cells.last().unwrap();+ self.stamp_via(t.i, t.j, net, sign);+ }+ }++ fn stamp_via(&mut self, i: i32, j: i32, net: usize, sign: i32) {+ let g = self.grid.clone();+ let (via, drill, nid) = (self.nets[net].via, self.nets[net].drill, self.nets[net].idx);+ let xy = g.to_xy(i as i64, j as i64);+ for c in g.disk_cells(xy, via / 2.0, Some(1e-6)) {+ for l in 0..2 {+ self.route_raw[l][c] = if sign > 0 { nid as i16 } else { -1 };+ }+ }+ if sign > 0 {+ self.vias.push((i, j, nid, via, drill));+ } else {+ self.vias.retain(|v| !(v.0 == i && v.1 == j && v.2 == nid));+ }+ }++ /// Centre-to-centre distance this net's via must keep from a via of (nid, dia, drill).+ pub fn via_gap(&self, net: usize, nid: usize, dia: f64, drill: f64) -> f64 {+ let n = &self.nets[net];+ if nid == n.idx {+ (n.drill + drill) / 2.0 + self.rules.hole_to_hole + slop()+ } else {+ n.via / 2.0 + dia / 2.0 + self.rules.clearance + slop()+ }+ }++ pub fn stamp_stub(&mut self, net: usize, cluster: usize, term: Term, sign: i32) {+ let g = self.grid.clone();+ let l = term.layer();+ let cl = self.nets[net].clusters[cluster].clone();+ let p = self.board.pads[cl.primary].clone();+ let a = p.polys_centre;+ let b = g.to_xy(term.i as i64, term.j as i64);+ let nid = if sign > 0 { self.nets[net].idx as i16 } else { -1 };+ let fpi = self.fp_index[&p.reference] as i16;+ for c in g.segment_cells(a, b, self.rules.stub / 2.0, Some(1e-6)) {+ self.pad_raw[l][c] = nid;+ if sign > 0 {+ self.pad_owner[l][c] = fpi;+ }+ }+ if let Some(kend) = cl.corridors.get(&term) {+ for c in g.segment_cells(b, *kend, self.rules.track / 2.0, Some(1e-6)) {+ self.pad_raw[l][c] = nid;+ if sign > 0 {+ self.pad_owner[l][c] = fpi;+ }+ }+ }+ self.stamp_channel(net, cluster, term, sign);+ if sign < 0 {+ let cells = self.pad_cells[&p.key].clone();+ let real = self.nets[net].idx as i16;+ for c in cells {+ self.pad_raw[l][c] = real;+ self.pad_owner[l][c] = fpi;+ }+ }+ }++ fn stamp_channel(&mut self, net: usize, cluster: usize, term: Term, sign: i32) {+ let key = (self.nets[net].idx, cluster, term);+ if sign > 0 && self.chan_lines.contains_key(&key) {+ return;+ }+ if sign < 0 {+ if let Some(cells) = self.chan_lines.remove(&key) {+ for c in cells {+ self.chan_count[c] -= 1;+ }+ }+ return;+ }+ let g = self.grid.clone();+ let cl = &self.nets[net].clusters[cluster];+ let p = &self.board.pads[cl.primary];+ let b = g.to_xy(term.i as i64, term.j as i64);+ let (ax, ay) = p.long_axis;+ let (cx, cy) = p.polys_centre;+ let sgn = if (b.0 - cx) * ax + (b.1 - cy) * ay > 0.0 { 1.0 } else { -1.0 };+ let rax = (ax * 1e6).round() / 1e6;+ let ray = (ay * 1e6).round() / 1e6;+ let cend = (b.0 + sgn * rax * self.rules.channel, b.1 + sgn * ray * self.rules.channel);+ let r = self.nets[net].via / 2.0 + self.rules.clearance + self.rules.track / 2.0 + slop();+ let via = self.nets[net].via;+ let cells: Vec<usize> = g.segment_cells(b, cend, r, Some(0.0));+ let inside = self.inside_for(via).clone();+ let cells: Vec<usize> = cells.into_iter().filter(|&c| inside.bits[c]).collect();+ for &c in &cells {+ self.chan_count[c] += 1;+ }+ self.chan_lines.insert(key, cells);+ }++ // -- clusters and terminals ----------------------------------------------------+ fn build_clusters(&mut self) {+ let nnets = self.nets.len();+ for net in 0..nnets {+ if self.nets[net].pads.len() < 2 && self.nets[net].plane.is_none() {+ continue;+ }+ let mut by_fp: Vec<(String, Vec<usize>)> = Vec::new();+ for &pi in &self.nets[net].pads.clone() {+ let r = self.board.pads[pi].reference.clone();+ if let Some(e) = by_fp.iter_mut().find(|(k, _)| *k == r) {+ e.1.push(pi);+ } else {+ by_fp.push((r, vec![pi]));+ }+ }+ let mut clusters: Vec<Cluster> = Vec::new();+ for (_, pads) in by_fp {+ let mut groups: Vec<(Vec<usize>, HashSet<usize>)> = Vec::new();+ for pi in pads {+ let mask: HashSet<usize> = self.pad_cells[&self.board.pads[pi].key].iter().copied().collect();+ let mut merged: Option<usize> = None;+ let mut gi = 0;+ while gi < groups.len() {+ if !groups[gi].1.is_disjoint(&mask) {+ if let Some(m) = merged {+ let (gp, gc) = groups.remove(gi);+ groups[m].0.extend(gp);+ groups[m].1.extend(gc);+ continue;+ } else {+ groups[gi].0.push(pi);+ groups[gi].1.extend(mask.iter().copied());+ merged = Some(gi);+ }+ }+ gi += 1;+ }+ if merged.is_none() {+ groups.push((vec![pi], mask));+ }+ }+ for (gpads, _) in groups {+ let primary = *gpads.iter().max_by(|&&a, &&b| {+ let pa = &self.board.pads[a];+ let pb = &self.board.pads[b];+ (pa.half_long * pa.half_short).partial_cmp(&(pb.half_long * pb.half_short)).unwrap()+ }).unwrap();+ clusters.push(Cluster { idx: clusters.len(), pads: gpads, primary, terminals: Vec::new(), escapes: HashMap::new(), corridors: HashMap::new(), connected: false, allow: 0.25, released: HashSet::new() });+ }+ }+ self.nets[net].clusters = clusters;+ let name = self.nets[net].name.clone();+ if let Some(w) = self.rules.wide.get(&name) {+ self.nets[net].widths = w.clone();+ self.nets[net].via = self.rules.via;+ self.nets[net].drill = self.rules.drill;+ } else if let Some(w) = self.rules.mid.get(&name) {+ self.nets[net].widths = w.clone();+ }+ let count = self.nets[net].clusters.len();+ for c in 0..count {+ self.terminals_for(net, c);+ }+ }+ // MOSFET drain and source nets get the wide treatment too+ for p in self.board.pads.clone() {+ if p.reference.starts_with('Q') && matches!(p.name.as_str(), "1" | "2" | "3" | "5") {+ if let Some(&n) = self.net_ids.get(&p.net) {+ if self.nets[n].plane.is_none() && !self.rules.wide.contains_key(&p.net) {+ self.nets[n].widths = vec![1.0, 0.5, 0.25];+ self.nets[n].via = self.rules.via;+ self.nets[n].drill = self.rules.drill;+ }+ }+ }+ }+ }++ fn build_ic_zones(&mut self) {+ let g = self.grid.clone();+ let mut by_fp: HashMap<String, Vec<(usize, usize)>> = HashMap::new();+ for (n, net) in self.nets.iter().enumerate() {+ for (c, cl) in net.clusters.iter().enumerate() {+ if !cl.escapes.is_empty() {+ by_fp.entry(self.board.pads[cl.primary].reference.clone()).or_default().push((n, c));+ }+ }+ }+ let mut refs: Vec<String> = by_fp.keys().cloned().collect();+ refs.sort();+ for r in refs {+ let clusters = &by_fp[&r];+ if clusters.len() < 2 {+ continue;+ }+ let fp = &self.board.footprints[&r];+ let mut inner = Vec::new();+ for &(n, c) in clusters {+ let p = &self.board.pads[self.nets[n].clusters[c].primary];+ let (ax, ay) = p.long_axis;+ let (cx, cy) = p.polys_centre;+ let sgn = if (fp.x - cx) * ax + (fp.y - cy) * ay > 0.0 { 1.0 } else { -1.0 };+ inner.push((cx + sgn * ax * p.half_long, cy + sgn * ay * p.half_long));+ }+ let (x0, y0, x1, y1) = geom::bbox(&inner);+ if x1 - x0 < 1.0 || y1 - y0 < 1.0 {+ continue;+ }+ let (i0, j0) = g.to_cell(x0, y0);+ let (i1, j1) = g.to_cell(x1, y1);+ let idx = self.fp_index[&r] as i16;+ for j in j0.max(0)..=j1.min(g.h as i64 - 1) {+ for i in i0.max(0)..=i1.min(g.w as i64 - 1) {+ self.body_owner[j as usize * g.w + i as usize] = idx;+ }+ }+ let pads: Vec<&Pad> = self.board.pads.iter().filter(|q| q.reference == r).collect();+ let nets: HashSet<usize> = pads.iter().filter_map(|q| self.net_ids.get(&q.net).copied()).collect();+ let m = (self.rules.ic_zone / RES).round() as i64;+ let (px0, py0, px1, py1) = geom::bbox(&pads.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());+ let (a0, b0) = g.to_cell(px0, py0);+ let (a1, b1) = g.to_cell(px1, py1);+ for j in (b0 - m).max(0)..=(b1 + m).min(g.h as i64 - 1) {+ for i in (a0 - m).max(0)..=(a1 + m).min(g.w as i64 - 1) {+ self.ic_zone.bits[j as usize * g.w + i as usize] = true;+ }+ }+ let foreign = self.nets.len() - nets.len();+ self.ic_nets.insert(idx as usize, nets);+ self.log(format!(" ic {}: body keepout {:.1} x {:.1} mm for {} foreign nets, escape band +{}/cell", r, (i1 - i0) as f64 * RES, (j1 - j0) as f64 * RES, foreign, ZONE_COST));+ }+ }++ fn terminals_for(&mut self, net: usize, c: usize) {+ let g = self.grid.clone();+ let primary = self.nets[net].clusters[c].primary;+ let p = self.board.pads[primary].clone();+ let md = p.min_dim();+ self.nets[net].clusters[c].allow = if md >= 1.0 { 1.0 } else if md >= 0.5 { 0.5 } else { 0.25 };+ if p.thru {+ let (i, j) = g.to_cell(p.x, p.y);+ self.nets[net].clusters[c].terminals = vec![Term::new(F_CU, i, j), Term::new(B_CU, i, j)];+ return;+ }+ let l = if p.on_f { F_CU } else { B_CU };+ let narrow = md < self.rules.narrow && p.half_long > p.half_short * 1.4;+ if !narrow {+ let (i, j) = g.to_cell(p.x, p.y);+ self.nets[net].clusters[c].terminals = vec![Term::new(l, i, j)];+ return;+ }+ self.nets[net].clusters[c].allow = 0.25;+ let (cx, cy) = p.polys_centre;+ let (ax, ay) = p.long_axis;+ let rax = (ax * 1e6).round() / 1e6;+ let ray = (ay * 1e6).round() / 1e6;+ let mut found: Vec<(Term, Option<Pt>)> = Vec::new();+ for sgn in [1.0f64, -1.0] {+ let mut hit: Option<(Term, Option<Pt>)> = None;+ for corridor in [self.rules.corridor, self.rules.corridor / 2.0, 0.0] {+ let mut d = self.rules.escape;+ while d <= 1.6 && hit.is_none() {+ let ex = cx + sgn * ax * (p.half_long + d);+ let ey = cy + sgn * ay * (p.half_long + d);+ let (i, j) = g.to_cell(ex, ey);+ let (bx, by) = g.to_xy(i, j);+ let kend = (bx + sgn * rax * corridor, by + sgn * ray * corridor);+ if g.in_bounds(i, j) && self.escape_ok(net, &p, l, i, j, kend) {+ hit = Some((Term::new(l, i, j), if corridor > 0.0 { Some(kend) } else { None }));+ }+ d += 0.1;+ }+ if hit.is_some() {+ break;+ }+ }+ if let Some(h) = hit {+ found.push(h);+ }+ }+ if found.is_empty() {+ let (i, j) = g.to_cell(p.x, p.y);+ self.nets[net].clusters[c].terminals = vec![Term::new(l, i, j)];+ return;+ }+ self.nets[net].clusters[c].terminals = found.iter().map(|(t, _)| *t).collect();+ for (t, kend) in found {+ let xy = g.to_xy(t.i as i64, t.j as i64);+ self.nets[net].clusters[c].escapes.insert(t, xy);+ if let Some(k) = kend {+ self.nets[net].clusters[c].corridors.insert(t, k);+ }+ self.stamp_stub(net, c, t, 1);+ }+ }++ fn escape_ok(&mut self, net: usize, p: &Pad, l: usize, i: i64, j: i64, corridor_end: Pt) -> bool {+ let g = self.grid.clone();+ let track = self.rules.track;+ if !self.inside_for(track).get(i as usize, j as usize) {+ return false;+ }+ let b = g.to_xy(i, j);+ let a = p.polys_centre;+ let mut checks = vec![(a, b, self.rules.stub / 2.0)];+ if (b.0 - corridor_end.0).hypot(b.1 - corridor_end.1) > 1e-6 {+ let (ki, kj) = g.to_cell(corridor_end.0, corridor_end.1);+ if !g.in_bounds(ki, kj) || !self.inside_for(track).get(ki as usize, kj as usize) {+ return false;+ }+ checks.push((b, corridor_end, track / 2.0));+ }+ let mut samples: Vec<(f64, f64, f64)> = Vec::new();+ for (s0, s1, hw) in checks {+ let n = (((s1.0 - s0.0).hypot(s1.1 - s0.1)) / 0.05).ceil().max(1.0) as usize;+ for k in 0..=n {+ let t = k as f64 / n as f64;+ samples.push((s0.0 + (s1.0 - s0.0) * t, s0.1 + (s1.1 - s0.1) * t, hw));+ }+ }+ samples.push((b.0, b.1, track / 2.0));+ let reach = 2.5;+ let net_name = self.nets[net].name.clone();+ for q in &self.board.pads {+ if q.reference == p.reference || q.net == net_name {+ continue;+ }+ if (q.x - b.0).abs() > reach + q.half_long || (q.y - b.1).abs() > reach + q.half_long {+ continue;+ }+ if l == F_CU && !q.on_f {+ continue;+ }+ let clr = self.rules.clearance.max(q.clearance);+ for poly in &q.polys {+ for &(x, y, hw) in &samples {+ if geom::point_poly_distance(x, y, poly) < hw + clr {+ return false;+ }+ }+ }+ }+ true+ }++ // -- obstacle maps ---------------------------------------------------------------+ /// (obs_o, obs_d, soft, via_ok) for the window (i0, j0, i1, j1 inclusive), each a Mask of window size.+ pub fn obstacle_maps(&mut self, net: usize, width: f64, win: (i64, i64, i64, i64), soft: bool) -> ([Mask; 2], [Mask; 2], [Option<Mask>; 2], Mask) {+ let g = self.grid.clone();+ let (i0, j0, i1, j1) = win;+ let clr = self.rules.clearance.max(self.rules.net_clearance.get(&self.nets[net].name).copied().unwrap_or(0.0));+ let r_o = (clr + width / 2.0 + slop()) / RES;+ let r_d = r_o + 0.0707 / RES;+ let r_big = (self.big_clr_val + width / 2.0 + slop()) / RES;+ let pad_r = (r_d.max(r_big)).ceil() as i64 + 1;+ let (pi0, pj0) = ((i0 - pad_r).max(0), (j0 - pad_r).max(0));+ let (pi1, pj1) = ((i1 + pad_r).min(g.w as i64 - 1), (j1 + pad_r).min(g.h as i64 - 1));+ let ww = (pi1 - pi0 + 1) as usize;+ let hh = (pj1 - pj0 + 1) as usize;+ let sub = Grid { res: g.res, x0: 0.0, y0: 0.0, w: ww, h: hh };+ let nid = self.nets[net].idx as i16;+ let via = self.nets[net].via;+ let mut via_block = Mask::new(ww, hh);+ let mut obs_o: [Mask; 2] = [Mask::new(1, 1), Mask::new(1, 1)];+ let mut obs_d: [Mask; 2] = [Mask::new(1, 1), Mask::new(1, 1)];+ let mut softm: [Option<Mask>; 2] = [None, None];+ let inside_w = self.inside_for(width).clone();+ let inside_via = self.inside_for(via).clone();+ for l in 0..2 {+ let mut other_pads = Mask::new(ww, hh);+ let mut other_routes = Mask::new(ww, hh);+ let mut big = Mask::new(ww, hh);+ for jj in 0..hh {+ let base = (pj0 as usize + jj) * g.w + pi0 as usize;+ for ii in 0..ww {+ let c = base + ii;+ let pr = self.pad_raw[l][c];+ let rr = self.route_raw[l][c];+ let op = pr != -1 && pr != nid;+ let orr = rr != -1 && rr != nid;+ other_pads.bits[jj * ww + ii] = op;+ other_routes.bits[jj * ww + ii] = orr;+ big.bits[jj * ww + ii] = self.big_clr[l].bits[c] && op;+ }+ }+ let mut hard = other_pads.clone();+ if !soft {+ hard.or_with(&other_routes);+ }+ let mut o = sub.dilate(&hard, r_o);+ o.or_with(&sub.dilate(&big, r_big));+ let mut d = sub.dilate(&hard, r_d);+ d.or_with(&sub.dilate(&big, r_big + 0.0707 / RES));+ for jj in 0..hh {+ for ii in 0..ww {+ let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+ if !inside_w.bits[c] {+ o.bits[jj * ww + ii] = true;+ }+ }+ }+ if l == F_CU && !self.ic_nets.is_empty() {+ let foreign: Vec<i16> = self.ic_nets.iter().filter(|(_, nets)| !nets.contains(&net)).map(|(k, _)| *k as i16).collect();+ if !foreign.is_empty() {+ for jj in 0..hh {+ for ii in 0..ww {+ let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+ let bo = self.body_owner[c];+ if bo != -1 && foreign.contains(&bo) {+ o.bits[jj * ww + ii] = true;+ d.bits[jj * ww + ii] = true;+ }+ }+ }+ }+ }+ let s = if soft { Some(sub.dilate(&other_routes, r_d)) } else { None };+ obs_o[l] = crop(&o, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+ obs_d[l] = crop(&d, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+ softm[l] = s.map(|m| crop(&m, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0));+ let mut hr = other_pads.clone();+ hr.or_with(&other_routes);+ via_block.or_with(&sub.dilate(&hr, (self.rules.clearance + via / 2.0 + slop()) / RES));+ via_block.or_with(&sub.dilate(&big, (self.big_clr_val + via / 2.0 + slop()) / RES));+ }+ if let Some(own) = self.smd_pad_cells.get(&(nid as usize)) {+ let mut m = Mask::new(ww, hh);+ for jj in 0..hh {+ for ii in 0..ww {+ m.bits[jj * ww + ii] = own.bits[(pj0 as usize + jj) * g.w + pi0 as usize + ii];+ }+ }+ via_block.or_with(&sub.dilate(&m, (via / 2.0 + slop()) / RES));+ }+ {+ let mut m = Mask::new(ww, hh);+ for jj in 0..hh {+ for ii in 0..ww {+ m.bits[jj * ww + ii] = self.thru_cells.bits[(pj0 as usize + jj) * g.w + pi0 as usize + ii];+ }+ }+ via_block.or_with(&sub.dilate(&m, (via / 2.0 + slop()) / RES));+ }+ // other vias: annular ring plus clearance for another net, hole to hole for this net+ let mut groups: HashMap<i64, Vec<(usize, usize)>> = HashMap::new();+ for &(vi, vj, vnid, dia, drill) in &self.vias {+ if vi as i64 >= pi0 && vi as i64 <= pi1 && vj as i64 >= pj0 && vj as i64 <= pj1 {+ let gap = self.via_gap(net, vnid, dia, drill);+ groups.entry((gap * 10000.0).round() as i64).or_default().push(((vj as i64 - pj0) as usize, (vi as i64 - pi0) as usize));+ }+ }+ for (gapk, cells) in groups {+ let mut vm = Mask::new(ww, hh);+ for (a, b) in cells {+ vm.bits[a * ww + b] = true;+ }+ via_block.or_with(&sub.dilate(&vm, gapk as f64 / 10000.0 / RES - 1e-6));+ }+ for jj in 0..hh {+ for ii in 0..ww {+ let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+ if !inside_via.bits[c] {+ via_block.bits[jj * ww + ii] = true;+ }+ }+ }+ let mut via_ok = crop(&via_block, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+ for b in via_ok.bits.iter_mut() {+ *b = !*b;+ }+ (obs_o, obs_d, softm, via_ok)+ }+}++/// Crop a window-sized mask (row stride ww) to the inclusive sub-window.+fn crop(m: &Mask, ww: usize, a: i64, b: i64, c: i64, d: i64) -> Mask {+ let w = (c - a + 1) as usize;+ let h = (d - b + 1) as usize;+ let mut out = Mask::new(w, h);+ for jj in 0..h {+ for ii in 0..w {+ out.bits[jj * w + ii] = m.bits[(b as usize + jj) * ww + a as usize + ii];+ }+ }+ out+}
crates/aiflow-router/src/rules.rsadded+70@@ -0,0 +1,70 @@+//! The board rules the router works to. The defaults are KiCad's for a 4-layer board with 0.2 mm+//! clearance; the spec's config overrides them (wide nets, planes, Kelvin pairs, via sizes).+use std::collections::BTreeMap;++use serde::{Deserialize, Serialize};++#[derive(Clone, Debug, Serialize, Deserialize)]+#[serde(default)]+pub struct Rules {+ pub track: f64,+ pub clearance: f64,+ /// Per-net clearance overrides (spec "netClearance": {net: mm}), for a net the DRC caught a hair under the rule.+ pub net_clearance: std::collections::BTreeMap<String, f64>,+ pub via: f64,+ pub drill: f64,+ pub via_small: f64,+ pub drill_small: f64,+ pub edge: f64,+ pub stub: f64,+ pub narrow: f64,+ pub escape: f64,+ pub corridor: f64,+ pub ic_zone: f64,+ pub channel: f64,+ pub planes: BTreeMap<String, String>,+ pub wide: BTreeMap<String, Vec<f64>>,+ pub mid: BTreeMap<String, Vec<f64>>,+ pub hole_to_hole: f64,+ pub via_search_mm: f64,+ pub kelvin: BTreeMap<String, String>,+}++impl Default for Rules {+ fn default() -> 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.2, 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(),+ }+ }+}++impl Rules {+ /// The router rules from a aiflow spec (planes, wideNets, midNets, kelvin, clearance, copper sizes).+ pub fn from_spec(spec: &serde_json::Value) -> Rules {+ let mut r = Rules::default();+ let get_map = |k: &str| -> BTreeMap<String, String> {+ spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_str()?.to_string()))).collect()).unwrap_or_default()+ };+ let get_widths = |k: &str| -> BTreeMap<String, Vec<f64>> {+ spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_array()?.iter().filter_map(|x| x.as_f64()).collect()))).collect()).unwrap_or_default()+ };+ r.planes = get_map("planes");+ r.kelvin = get_map("kelvin");+ r.wide = get_widths("wideNets");+ r.mid = get_widths("midNets");+ 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()) {+ for (k, v) in m { if let Some(x) = v.as_f64() { r.net_clearance.insert(k.clone(), x); } }+ }+ if let Some(v) = num("edgeClearance") { r.edge = v; }+ if let Some(v) = num("track") { r.track = v; }+ if let Some(v) = num("viaSignal") { r.via_small = v; }+ if let Some(v) = num("drillSignal") { r.drill_small = v; }+ if let Some(v) = num("viaPower") { r.via = v; }+ if let Some(v) = num("drillPower") { r.drill = v; }+ r+ }+}
crates/aiflow-run/Cargo.tomladded+10@@ -0,0 +1,10 @@+[package]+name = "aiflow-run"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: run"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-run/src/lib.rsadded+577@@ -0,0 +1,577 @@+//! The run manifest: one JSON file per run carrying the clock, the plan, the decisions, every+//! stage's start and end, the outcomes, the capture markers and the summary. Every command reads+//! and writes it, so the comparison between engines is what the file says.+use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++pub const STAGES: [&str; 9] = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"];++pub fn now() -> String {+ let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+ iso(t)+}++/// Seconds since the epoch as an ISO 8601 UTC timestamp.+pub fn iso(t: u64) -> String {+ let days = t / 86400;+ let rem = t % 86400;+ let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);+ // civil from days (Howard Hinnant's algorithm)+ let z = days as i64 + 719468;+ let era = if z >= 0 { z } else { z - 146096 } / 146097;+ let doe = (z - era * 146097) as u64;+ let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+ let y = yoe as i64 + era * 400;+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+ let mp = (5 * doy + 2) / 153;+ let d = doy - (153 * mp + 2) / 5 + 1;+ let mth = if mp < 10 { mp + 3 } else { mp - 9 };+ let y = if mth <= 2 { y + 1 } else { y };+ format!("{y:04}-{mth:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")+}++/// ISO 8601 UTC timestamp to seconds since the epoch (whole seconds).+pub fn parse(ts: &str) -> Option<u64> {+ let b = ts.as_bytes();+ if b.len() < 19 {+ return None;+ }+ let n = |a: usize, l: usize| ts.get(a..a + l)?.parse::<i64>().ok();+ let (y, mo, d, h, mi, s) = (n(0, 4)?, n(5, 2)?, n(8, 2)?, n(11, 2)?, n(14, 2)?, n(17, 2)?);+ let y2 = if mo <= 2 { y - 1 } else { y };+ let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;+ let yoe = y2 - era * 400;+ let mp = if mo > 2 { mo - 3 } else { mo + 9 };+ let doy = (153 * mp + 2) / 5 + d - 1;+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+ let days = era * 146097 + doe - 719468;+ Some((days * 86400 + h * 3600 + mi * 60 + s) as u64)+}++/// One human prompt and the AI's work on it.+#[derive(Clone, Debug)]+pub struct Session {+ pub n: usize,+ pub prompt_at: u64,+ pub done_at: Option<u64>,+ pub text: String,+ /// active spans (start, end), idle gaps cut out+ pub spans: Vec<(u64, u64)>,+ pub idle_seconds: u64,+ /// true when the session has no done yet, or work went on after its done+ pub open: bool,+}++impl Session {+ pub fn active_seconds(&self) -> u64 { self.spans.iter().map(|(s, e)| e.saturating_sub(*s)).sum() }+}++pub struct Run {+ pub dir: PathBuf,+ pub data: Value,+}++impl Run {+ pub fn open(dir: &Path) -> Option<Run> {+ let f = dir.join("run.json");+ let text = std::fs::read_to_string(&f).ok()?;+ Some(Run { dir: dir.to_path_buf(), data: serde_json::from_str(&text).ok()? })+ }++ pub fn create(dir: &Path, board: &str, spec: &str, engine: &str, prompt_time: &str, ai_thread: &str, target: Option<&str>, remote_board: Option<&str>) -> Result<Run, String> {+ std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;+ let r = Run { dir: dir.to_path_buf(), data: json!({+ "version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": board, "spec": spec,+ "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,+ "currentStep": null, "steps": {}, "turns": [], "delivery": null,+ }) };+ r.save()?;+ r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": board, "spec": spec, "promptTime": prompt_time, "target": target, "remoteBoard": remote_board}));+ Ok(r)+ }++ /// Append one event to run.jsonl, the append-only ledger: one line per event, never rewritten.+ /// run.json is the derived state; when the two disagree, the ledger is the record.+ pub fn log(&self, event: &str, mut fields: Value) {+ fields["t"] = json!(now());+ fields["event"] = json!(event);+ let line = fields.to_string() + "\n";+ let f = self.dir.join("run.jsonl");+ if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {+ use std::io::Write;+ let _ = fh.write_all(line.as_bytes());+ }+ }++ /// Every event in run.jsonl, in order.+ pub fn ledger(&self) -> Vec<Value> {+ std::fs::read_to_string(self.dir.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()+ }++ /// Write run.json. Two commands can run at once (a landing in the background while the AI+ /// declares a step or sets the page), so the write merges with what is on disk: a top-level+ /// key this process never had is kept from disk, and the ledger-like arrays (turns, markers,+ /// captures) are unioned by identity so neither writer drops the other's entries.+ pub fn save(&self) -> Result<(), String> {+ std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;+ let mut data = self.data.clone();+ if let Some(disk) = std::fs::read_to_string(self.dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+ if let (Some(d), Some(m)) = (disk.as_object(), data.as_object_mut()) {+ for (k, v) in d {+ if !m.contains_key(k) {+ m.insert(k.clone(), v.clone());+ }+ }+ for (key, id) in [("turns", "n"), ("markers", "t"), ("captures", "start")] {+ let mine = m.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let theirs = d.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();+ let ident = |e: &Value| format!("{}|{}", e[id], e["label"].as_str().or(e["recordingId"].as_str()).or(e["command"].as_str()).unwrap_or(""));+ let mut merged: Vec<Value> = Vec::new();+ let mut seen: Vec<String> = Vec::new();+ for e in theirs.iter().chain(mine.iter()) {+ let k = ident(e);+ if let Some(pos) = seen.iter().position(|x| *x == k) {+ // the later writer's version wins (it may carry the end / file / stop of the same entry)+ merged[pos] = e.clone();+ } else {+ seen.push(k);+ merged.push(e.clone());+ }+ }+ merged.sort_by(|a, b| a[id].to_string().cmp(&b[id].to_string()));+ if key == "turns" { merged.sort_by_key(|e| e["n"].as_u64().unwrap_or(0)); }+ m.insert(key.to_string(), Value::Array(merged));+ }+ }+ }+ let tmp = self.dir.join("run.json.tmp");+ std::fs::write(&tmp, serde_json::to_string_pretty(&data).unwrap()).map_err(|e| e.to_string())?;+ std::fs::rename(&tmp, self.dir.join("run.json")).map_err(|e| e.to_string())+ }++ pub fn engine_for(&self, stage: &str) -> String {+ if let Some(e) = self.data["decisions"].get(stage).and_then(|v| v.as_str()) {+ return e.to_string();+ }+ self.data["plan"].get("stages").and_then(|s| s.get(stage)).and_then(|s| s.get("default")).and_then(|d| d.as_str()).unwrap_or("binary").to_string()+ }++ pub fn mark(&mut self, label: &str) {+ self.data["markers"].as_array_mut().unwrap().push(json!({"t": now(), "label": label}));+ if !label.starts_with("stage-") && !label.starts_with("step:") {+ self.log("marker", json!({"label": label}));+ }+ }++ pub fn stage_start(&mut self, name: &str, engine: Option<&str>, note: Option<&str>) {+ let engine = engine.map(str::to_string).unwrap_or_else(|| self.engine_for(name));+ let stages = self.data["stages"].as_object_mut().unwrap();+ let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));+ st["runs"].as_array_mut().unwrap().push(json!({"start": now(), "end": null, "engine": engine, "note": note, "result": null}));+ self.log("stage-start", json!({"stage": name, "engine": engine, "note": note}));+ self.mark(&format!("stage-start:{name}"));+ }++ pub fn stage_end(&mut self, name: &str, result: Option<Value>, note: Option<&str>) {+ let engine = self.engine_for(name);+ let stages = self.data["stages"].as_object_mut().unwrap();+ let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));+ let runs = st["runs"].as_array_mut().unwrap();+ if runs.last().map(|r| !r["end"].is_null()).unwrap_or(true) {+ runs.push(json!({"start": now(), "end": null, "engine": engine, "note": null, "result": null}));+ }+ let last = runs.last_mut().unwrap();+ last["end"] = json!(now());+ if let Some(r) = result {+ last["result"] = r;+ }+ if let Some(n) = note {+ last["note"] = json!(n);+ }+ let (res, started) = (last["result"].clone(), last["start"].clone());+ let secs = parse(&now()).unwrap_or(0).saturating_sub(started.as_str().and_then(parse).unwrap_or(0));+ self.log("stage-end", json!({"stage": name, "binarySeconds": secs, "result": res, "note": note}));+ self.mark(&format!("stage-end:{name}"));+ }++ pub fn current_board(&self) -> String {+ self.data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("").to_string()+ }++ pub fn add_board(&mut self, path: &str) {+ self.data["boards"].as_array_mut().unwrap().push(json!(path));+ }++ pub fn outcome(&mut self, key: &str, v: Value) {+ self.log("outcome", json!({"stage": key, "outcome": v}));+ self.data["outcomes"][key] = v;+ }++ pub fn outcomes(&self) -> &Map<String, Value> {+ static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();+ self.data["outcomes"].as_object().unwrap_or_else(|| EMPTY.get_or_init(Map::new))+ }++ // -- the ledger: every command is a turn, the gap before it is the AI's thinking ----------+ /// The step a command belongs to when the AI has not declared one with `step`.+ pub fn infer_step(command: &str) -> &'static str {+ let c = command.trim();+ let first = c.split_whitespace().next().unwrap_or("");+ let second = c.split_whitespace().nth(1).unwrap_or("");+ match (first, second) {+ ("start", _) | ("plan", _) | ("take", _) => "intake",+ ("place", _) => "placement",+ ("route", _) | ("gate", _) => "routing",+ ("land", "moves") => "placement",+ ("land", "route") | ("land", "vias") => "routing",+ ("pour", _) | ("land", "pours") | ("measure", _) => "pours",+ ("analyze", "current") => "current",+ ("analyze", "thermal") => "thermal",+ ("capture", _) => "capture",+ ("finish", _) | ("deliver", _) => "finish",+ _ => "other",+ }+ }++ /// Record the start of a command. The seconds since the previous turn ended (or since the+ /// prompt, for the first turn) are the AI's thinking time and are charged to this turn's step:+ /// the thinking that led to this command. Returns the turn index.+ pub fn turn_begin(&mut self, command: &str) -> usize {+ let t = now();+ let now_s = parse(&t).unwrap_or(0);+ let last_end = self.data["turns"].as_array().and_then(|a| a.last()).and_then(|l| l["end"].as_str().or(l["at"].as_str())).and_then(parse);+ let since = last_end.or_else(|| self.data["clock"]["promptTime"].as_str().and_then(parse)).map(|p| now_s.saturating_sub(p)).unwrap_or(0);+ // a `step X` command is charged to X: the thinking that chose the step belongs to it+ let declared = command.trim().strip_prefix("step ").and_then(|r| r.split_whitespace().next()).map(str::to_string);+ let step = declared.or_else(|| self.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| Run::infer_step(command).to_string());+ let n = self.data["turns"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+ if !self.data["turns"].is_array() {+ self.data["turns"] = json!([]);+ }+ self.data["turns"].as_array_mut().unwrap().push(json!({"n": n, "step": step, "command": command, "at": t, "thinkingSeconds": since, "end": null, "binarySeconds": null, "ok": null}));+ self.log("turn", json!({"n": n, "step": step, "command": command, "thinkingSeconds": since}));+ n - 1+ }++ pub fn turn_end(&mut self, idx: usize, ok: bool, first_line: &str) {+ let t = now();+ if let Some(turn) = self.data["turns"].as_array_mut().and_then(|a| a.get_mut(idx)) {+ let started = turn["at"].as_str().and_then(parse).unwrap_or(0);+ turn["end"] = json!(t);+ turn["binarySeconds"] = json!(parse(&t).unwrap_or(0).saturating_sub(started));+ turn["ok"] = json!(ok);+ turn["said"] = json!(first_line.chars().take(200).collect::<String>());+ let (n, step, bs) = (turn["n"].clone(), turn["step"].clone(), turn["binarySeconds"].clone());+ self.log("turn-end", json!({"n": n, "step": step, "binarySeconds": bs, "ok": ok, "said": first_line.chars().take(200).collect::<String>()}));+ }+ }++ /// The AI declares the step it is working on. `back` marks a return to an earlier step+ /// (rework found by a later step); `why` says what sent it back. Every command until the next+ /// `step` is charged to this step.+ pub fn step_set(&mut self, name: &str, back: bool, why: Option<&str>) {+ let t = now();+ if let Some(prev) = self.data["currentStep"].as_str().map(str::to_string) {+ if let Some(v) = self.data["steps"][&prev]["visits"].as_array_mut().and_then(|a| a.last_mut()) {+ if v["end"].is_null() {+ v["end"] = json!(t);+ }+ }+ }+ if !self.data["steps"].is_object() {+ self.data["steps"] = json!({});+ }+ let st = self.data["steps"].as_object_mut().unwrap().entry(name.to_string()).or_insert(json!({"visits": []}));+ let n = st["visits"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+ st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": t, "end": null, "back": back, "why": why}));+ self.data["currentStep"] = json!(name);+ self.log("step", json!({"step": name, "visit": n, "back": back, "why": why}));+ self.mark(&format!("step:{name}{}", if back { " (back)" } else { "" }));+ }++ /// Per step: AI thinking seconds, binary seconds, wall seconds, turns, visits, returns, and+ /// the longest single think. Wall = thinking + binary, so the sum over steps is the whole run.+ pub fn step_report(&self) -> Map<String, Value> {+ let mut m: Map<String, Value> = Map::new();+ if let Some(rep) = self.step_report_timeline() {+ return rep;+ }+ // the ledger is the record: rebuild the turns from run.jsonl when it exists+ let ledger = self.ledger();+ let mut turns: Vec<Value> = Vec::new();+ if !ledger.is_empty() {+ for e in &ledger {+ match e["event"].as_str() {+ Some("turn") => turns.push(json!({"n": e["n"], "step": e["step"], "thinkingSeconds": e["thinkingSeconds"], "binarySeconds": 0})),+ Some("turn-end") => {+ if let Some(t) = turns.iter_mut().rev().find(|t| t["n"] == e["n"]) {+ t["binarySeconds"] = e["binarySeconds"].clone();+ }+ }+ _ => {}+ }+ }+ } else {+ turns = self.data["turns"].as_array().cloned().unwrap_or_default();+ }+ for t in turns {+ let step = t["step"].as_str().unwrap_or("other").to_string();+ let e = m.entry(step).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+ let th = t["thinkingSeconds"].as_u64().unwrap_or(0);+ let bi = t["binarySeconds"].as_u64().unwrap_or(0);+ e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + th);+ e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + bi);+ e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));+ e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);+ if th > e["longestThinkSeconds"].as_u64().unwrap_or(0) {+ e["longestThinkSeconds"] = json!(th);+ }+ }+ for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {+ let visits = st["visits"].as_array().cloned().unwrap_or_default();+ let e = m.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+ e["visits"] = json!(visits.len());+ e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());+ e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());+ }+ m+ }+++ /// The per-step report from the ledger's timeline: every second between the prompt and the+ /// last event is either tool time (at least one turn running) or thinking (no turn running),+ /// charged to the step current at that moment. Overlapping turns (a landing in the background+ /// while the AI declares the next step) count once, so the steps sum to the wall clock.+ pub fn step_report_timeline(&self) -> Option<Map<String, Value>> {+ let led = self.ledger();+ if led.is_empty() {+ return None;+ }+ let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;+ // turns as intervals, and step changes as (time, step)+ let mut turns: Vec<(u64, u64, String, u64)> = Vec::new(); // start, end, step, n+ let mut open: Vec<(u64, String, u64)> = Vec::new();+ let mut steps: Vec<(u64, String)> = vec![(t0, "intake".to_string())];+ let mut last = t0;+ for e in &led {+ let t = e["t"].as_str().and_then(parse).unwrap_or(0);+ last = last.max(t);+ match e["event"].as_str() {+ Some("turn") => open.push((t, e["step"].as_str().unwrap_or("other").to_string(), e["n"].as_u64().unwrap_or(0))),+ Some("turn-end") => {+ let n = e["n"].as_u64().unwrap_or(0);+ if let Some(pos) = open.iter().position(|o| o.2 == n) {+ let (s, st, _) = open.remove(pos);+ turns.push((s, t.max(s), st, n));+ }+ }+ Some("step") => steps.push((t, e["step"].as_str().unwrap_or("other").to_string())),+ _ => {}+ }+ }+ let delivered = self.data["clock"]["deliveredTime"].as_str().and_then(parse);+ let end = delivered.or_else(|| self.data["clock"]["finishTime"].as_str().and_then(parse)).unwrap_or(last).max(last);+ for (s, st, n) in open { turns.push((s, end, st, n)); }+ // second by second (runs are hours, not years)+ let step_at = |t: u64| -> String { steps.iter().rev().find(|(ts, _)| *ts <= t).map(|(_, s)| s.clone()).unwrap_or_else(|| "intake".to_string()) };+ let mut acc: Map<String, Value> = Map::new();+ let mut think_run: (String, u64) = (String::new(), 0);+ let mut longest: std::collections::BTreeMap<String, u64> = Default::default();+ let spans = self.active_spans();+ let mut t = t0;+ while t < end {+ // the human away is not the AI thinking: seconds outside the active spans are skipped+ if !spans.iter().any(|(s, e)| *s <= t && t < *e) { t += 1; continue; }+ let running: Vec<&(u64, u64, String, u64)> = turns.iter().filter(|(s, e, _, _)| *s <= t && t < *e).collect();+ let step = if let Some(r) = running.first() { r.2.clone() } else { step_at(t) };+ // after the delivery, work that happens to run under `finish` (recuts, composes, reports) is+ // follow-up work, listed under Sessions; the finish step's own time ends at the delivery+ if step == "finish" && delivered.map(|d| t >= d).unwrap_or(false) { t += 1; continue; }+ let e = acc.entry(step.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+ if running.is_empty() {+ e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + 1);+ if think_run.0 == step { think_run.1 += 1; } else { think_run = (step.clone(), 1); }+ let l = longest.entry(step.clone()).or_insert(0);+ if think_run.1 > *l { *l = think_run.1; }+ } else {+ e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + 1);+ think_run = (String::new(), 0);+ }+ e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));+ t += 1;+ }+ for (_, _, st, _) in &turns {+ let e = acc.entry(st.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+ e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);+ }+ for (st, l) in longest { if let Some(e) = acc.get_mut(&st) { e["longestThinkSeconds"] = json!(l); } }+ for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {+ let visits = st["visits"].as_array().cloned().unwrap_or_default();+ let e = acc.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+ e["visits"] = json!(visits.len());+ e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());+ e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());+ }+ Some(acc)+ }++ /// The idle threshold in minutes: a gap longer than this between two ledger events, with no+ /// command running, is the human away, not the AI thinking; it is cut out of the AI's time.+ pub fn idle_minutes(&self) -> u64 {+ self.data["idleMinutes"].as_u64().unwrap_or(15)+ }++ /// The run as sessions: one human prompt (the `start` event, then every `prompt`) and the AI's+ /// work on it until its `done` (or `deliver`), as active spans with the idle gaps cut out.+ /// A session without a `done` ends at its last event. The AI's time is the sum of the spans;+ /// the wall clock is not a measure of the AI.+ pub fn sessions(&self) -> Vec<Session> {+ let led = self.ledger();+ let idle = self.idle_minutes() * 60;+ let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);+ let times: Vec<u64> = led.iter().map(|e| e["t"].as_str().and_then(parse).unwrap_or(0)).collect();+ // a command runs from its `turn` to its `turn-end`; one that died without a turn-end (an ERROR+ // exit) ran until the next ledger event at most. A gap covered by a running command is not idle.+ let mut running: Vec<(u64, u64)> = Vec::new();+ for (i, e) in led.iter().enumerate() {+ if e["event"] != "turn" { continue; }+ let n = e["n"].as_u64();+ let end = led.iter().enumerate().skip(i + 1).find(|(_, x)| x["event"] == "turn-end" && x["n"].as_u64() == n).map(|(j, _)| times[j]).or_else(|| times.get(i + 1).copied()).unwrap_or(times[i]);+ running.push((times[i], end));+ }+ let covered = |a: u64, b: u64| running.iter().any(|(s, e)| *s <= a && *e >= b);+ let mut out: Vec<Session> = Vec::new();+ let mut cur: Option<Session> = None;+ let mut last_activity: u64 = t0;+ let extend = |s: &mut Session, at: u64| { if let Some(sp) = s.spans.last_mut() { if at > sp.1 { sp.1 = at; } } };+ let open = |out: &mut Vec<Session>, cur: &mut Option<Session>, at: u64, text: String| {+ if let Some(s) = cur.take() { out.push(s); }+ *cur = Some(Session { n: out.len() + 1, prompt_at: at, done_at: None, text, spans: vec![(at, at)], idle_seconds: 0, open: true });+ };+ if t0 > 0 { open(&mut out, &mut cur, t0, self.data["prompt"].as_str().unwrap_or("the first prompt").to_string()); }+ for (i, e) in led.iter().enumerate() {+ let t = times[i];+ let gap_idle = t.saturating_sub(last_activity) > idle && !covered(last_activity, t);+ match e["event"].as_str().unwrap_or("") {+ "start" => { if cur.is_none() { open(&mut out, &mut cur, t0.max(1), "the first prompt".into()); } last_activity = t.max(t0); continue; }+ "prompt" => {+ let at = e["at"].as_str().and_then(parse).unwrap_or(t);+ open(&mut out, &mut cur, at, e["text"].as_str().unwrap_or("").to_string());+ last_activity = at;+ continue;+ }+ "done" | "deliver" => {+ if let Some(s) = cur.as_mut() {+ if s.done_at.is_none() {+ if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }+ s.done_at = Some(t); s.open = false;+ }+ }+ last_activity = t;+ continue;+ }+ _ => {}+ }+ let after_done = cur.as_ref().map(|s| s.done_at.is_some() || (s.text.starts_with("follow-up") && gap_idle)).unwrap_or(false);+ if after_done {+ // work after a done with no prompt mark: a follow-up the AI did not mark; its own row,+ // and every idle gap inside it starts another, since a gap there is most likely a new prompt+ if gap_idle || cur.as_ref().map(|s| !s.open).unwrap_or(true) {+ open(&mut out, &mut cur, t, "follow-up (no prompt mark; say `prompt --text` next time)".into());+ last_activity = t;+ continue;+ }+ }+ let Some(s) = cur.as_mut() else { continue };+ if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }+ last_activity = last_activity.max(t);+ }+ if let Some(s) = cur.take() { out.push(s); }+ out+ }++ /// Returns to earlier steps before the delivery: the reworks of the task itself, not of follow-ups.+ pub fn returns_before_delivery(&self) -> u64 {+ let cutoff = self.data["clock"]["deliveredTime"].as_str().and_then(parse).unwrap_or(u64::MAX);+ let mut n = 0;+ for (_, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {+ for v in st["visits"].as_array().cloned().unwrap_or_default() {+ if v["back"].as_bool() == Some(true) && v["at"].as_str().or(v["start"].as_str()).and_then(parse).map(|t| t < cutoff).unwrap_or(true) { n += 1; }+ }+ }+ n+ }++ /// Every active span of the run, in order.+ pub fn active_spans(&self) -> Vec<(u64, u64)> {+ self.sessions().into_iter().flat_map(|s| s.spans).collect()+ }++ /// Seconds of AI time between two moments (the overlap of the active spans with [a, b]).+ pub fn active_between(&self, a: u64, b: u64) -> u64 {+ if b <= a { return 0; }+ self.active_spans().iter().map(|(s, e)| { let lo = (*s).max(a); let hi = (*e).min(b); hi.saturating_sub(lo) }).sum()+ }++ /// Seconds of AI time from the first prompt to the moment `t`: the clock the video shows.+ pub fn active_offset(&self, t: u64) -> u64 {+ let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);+ self.active_between(t0, t)+ }++ /// Minutes of AI time from the first prompt to the delivery, if delivered: the benchmark number.+ pub fn delivered_active_minutes(&self) -> Option<f64> {+ let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;+ Some(((self.active_offset(t1) as f64) / 6.0).round() / 10.0)+ }++ /// Minutes of AI time over the whole run, every session.+ pub fn active_minutes_total(&self) -> f64 {+ (self.active_spans().iter().map(|(s, e)| e.saturating_sub(*s)).sum::<u64>() as f64 / 6.0).round() / 10.0+ }++ /// Minutes from the prompt to the delivery (the AI's "done, here is your video"), if delivered.+ pub fn delivered_minutes(&self) -> Option<f64> {+ let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;+ let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;+ Some((t1.saturating_sub(t0)) as f64 / 60.0)+ }++ pub fn elapsed_minutes(&self) -> f64 {+ let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);+ let t1 = self.data["clock"]["finishTime"].as_str().and_then(parse).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));+ ((t1 as f64 - t0 as f64) / 6.0).round() / 10.0+ }++ pub fn stage_minutes(&self) -> Map<String, Value> {+ let mut out = Map::new();+ if let Some(st) = self.data["stages"].as_object() {+ for (name, s) in st {+ let mut tot = 0.0;+ for r in s["runs"].as_array().cloned().unwrap_or_default() {+ if let (Some(a), Some(b)) = (r["start"].as_str().and_then(parse), r["end"].as_str().and_then(parse)) {+ tot += (b as f64 - a as f64) / 60.0;+ }+ }+ out.insert(name.clone(), json!((tot * 10.0).round() / 10.0));+ }+ }+ out+ }+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn iso_round_trips() {+ assert_eq!(iso(0), "1970-01-01T00:00:00Z");+ let t = 1_789_299_600; // some 2026 second+ assert_eq!(parse(&iso(t)), Some(t));+ assert_eq!(parse("2026-09-13T15:38:04Z").map(iso).as_deref(), Some("2026-09-13T15:38:04Z"));+ }+}
docs/flow-hero.pngadded⋯ 1 unchanged line ⋯
docs/flow-hero.svgadded+76@@ -0,0 +1,76 @@+<svg xmlns="http://www.w3.org/2000/svg" width="806" height="892" viewBox="0 0 806 892" font-family="Familjen Grotesk, Satoshi, Inter, Segoe UI, sans-serif">+<defs><marker id="fwd" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#DB8B58"/></marker><marker id="back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#7FD6D1"/></marker><marker id="to" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#00B8B1"/></marker></defs>+<rect width="806" height="892" fill="#0B1420"/>+<text x="255" y="44" fill="#DB8B58" font-size="11.5" font-weight="700" letter-spacing="2" text-anchor="middle">THE AI: THINKS, DECIDES, GOES BACK</text>+<text x="570" y="44" fill="#00B8B1" font-size="11.5" font-weight="700" letter-spacing="2" text-anchor="middle">THE BINARY: THE FAST PARTS</text>+<text x="22" y="437" fill="#7FD6D1" font-size="11.5" font-style="italic" transform="rotate(-90 22 437)" text-anchor="middle">rework, as often as it needs</text>+<path d="M150 215 C 106 215, 106 117, 150 117" fill="none" stroke="#7FD6D1" stroke-opacity=".85" stroke-width="2" stroke-dasharray="5 6" marker-end="url(#back)"/>+<path d="M150 313 C 76 313, 76 117, 150 117" fill="none" stroke="#7FD6D1" stroke-opacity=".85" stroke-width="2" stroke-dasharray="5 6" marker-end="url(#back)"/>+<path d="M150 411 C 98 411, 98 215, 150 215" fill="none" stroke="#7FD6D1" stroke-opacity=".85" stroke-width="2" stroke-dasharray="5 6" marker-end="url(#back)"/>+<path d="M150 509 C 106 509, 106 313, 150 313" fill="none" stroke="#7FD6D1" stroke-opacity=".85" stroke-width="2" stroke-dasharray="5 6" marker-end="url(#back)"/>+<path d="M150 509 C 52 509, 52 117, 150 117" fill="none" stroke="#7FD6D1" stroke-opacity=".85" stroke-width="2" stroke-dasharray="5 6" marker-end="url(#back)"/>+<rect x="150" y="84" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="113" fill="#F2E9E1" font-size="25" font-weight="700">Place</text>+<text x="166" y="135" fill="#B9A899" font-size="12">parts for routability, current, heat</text>+<line x1="255.0" y1="152" x2="255.0" y2="180" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="111" x2="392" y2="111" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="125" x2="366" y2="125" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="93" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="123" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">place pack · check · land moves</text>+<rect x="150" y="182" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="211" fill="#F2E9E1" font-size="25" font-weight="700">Route</text>+<text x="166" y="233" fill="#B9A899" font-size="12">100 % or it failed</text>+<line x1="255.0" y1="250" x2="255.0" y2="278" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="209" x2="392" y2="209" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="223" x2="366" y2="223" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="191" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="221" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">route · gate · land</text>+<rect x="150" y="280" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="309" fill="#F2E9E1" font-size="25" font-weight="700">Pour</text>+<text x="166" y="331" fill="#B9A899" font-size="12">copper kept, Kelvin keepouts</text>+<line x1="255.0" y1="348" x2="255.0" y2="376" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="307" x2="392" y2="307" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="321" x2="366" y2="321" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="289" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="319" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">pour · land pours · measure</text>+<rect x="150" y="378" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="407" fill="#F2E9E1" font-size="25" font-weight="700">Current</text>+<text x="166" y="429" fill="#B9A899" font-size="12">IPC-2221, vias, pours</text>+<line x1="255.0" y1="446" x2="255.0" y2="474" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="405" x2="392" y2="405" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="419" x2="366" y2="419" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="387" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="417" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">analyze current</text>+<rect x="150" y="476" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="505" fill="#F2E9E1" font-size="25" font-weight="700">Thermal</text>+<text x="166" y="527" fill="#B9A899" font-size="12">copper at every hot tab</text>+<line x1="255.0" y1="544" x2="255.0" y2="572" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="503" x2="392" y2="503" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="517" x2="366" y2="517" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="485" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="515" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">analyze thermal</text>+<rect x="150" y="574" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="603" fill="#F2E9E1" font-size="25" font-weight="700">Capture</text>+<text x="166" y="625" fill="#B9A899" font-size="12">one clip per step</text>+<line x1="255.0" y1="642" x2="255.0" y2="670" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="601" x2="392" y2="601" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="615" x2="366" y2="615" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="583" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="613" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">capture</text>+<rect x="150" y="672" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="701" fill="#F2E9E1" font-size="25" font-weight="700">Finish</text>+<text x="166" y="723" fill="#B9A899" font-size="12">refuses until it is all true</text>+<line x1="255.0" y1="740" x2="255.0" y2="768" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<line x1="366" y1="699" x2="392" y2="699" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="713" x2="366" y2="713" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="681" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="711" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">finish</text>+<rect x="150" y="770" width="210" height="66" rx="9" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="166" y="799" fill="#F2E9E1" font-size="25" font-weight="700">Deliver</text>+<text x="166" y="821" fill="#B9A899" font-size="12">"done, here is your video"</text>+<line x1="366" y1="797" x2="392" y2="797" stroke="#93A3B4" stroke-width="1.3" marker-end="url(#to)"/>+<line x1="392" y1="811" x2="366" y2="811" stroke="#00B8B1" stroke-width="1.3" marker-end="url(#to)"/>+<rect x="400" y="779" width="340" height="48" rx="5" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.4"/>+<text x="414" y="809" fill="#DDF7F5" font-size="15" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace">deliver · run.jsonl</text>+<text x="380" y="876" fill="#93A3B4" font-size="11.5" text-anchor="middle">down: specs, configs, plans, scripts · up: OK / ERROR + hints · any box can be the AI's own code</text>+</svg>
docs/flow-preview.pngadded⋯ 1 unchanged line ⋯
docs/flow.svgadded+118@@ -0,0 +1,118 @@+<svg xmlns="http://www.w3.org/2000/svg" width="1520" height="900" viewBox="0 0 1520 900" font-family="Familjen Grotesk, Satoshi, Inter, Segoe UI, sans-serif">+<defs><marker id="fwd" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#DB8B58"/></marker><marker id="back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#7FD6D1"/></marker><marker id="down" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#93A3B4"/></marker><marker id="up" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0L10 5L0 10z" fill="#00B8B1"/></marker></defs>+<rect width="1520" height="900" fill="#0B1420"/>+<text x="40" y="178" fill="#DB8B58" font-size="17" font-weight="700" letter-spacing="1.5">THE AI</text>+<text x="40" y="200" fill="#DB8B58" font-size="17" font-weight="700" letter-spacing="1.5">drives, thinks,</text>+<text x="40" y="222" fill="#DB8B58" font-size="17" font-weight="700" letter-spacing="1.5">goes back</text>+<text x="40" y="538" fill="#00B8B1" font-size="17" font-weight="700" letter-spacing="1.5">THE BINARY</text>+<text x="40" y="560" fill="#00B8B1" font-size="17" font-weight="700" letter-spacing="1.5">fast, deterministic,</text>+<text x="40" y="582" fill="#00B8B1" font-size="17" font-weight="700" letter-spacing="1.5">never reinvented</text>+<path d="M601 158.0 C 601 100.0, 444 100.0, 444 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<path d="M759 158.0 C 759 62.0, 444 62.0, 444 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<path d="M916 158.0 C 916 88.0, 601 88.0, 601 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<path d="M1074 158.0 C 1074 100.0, 759 100.0, 759 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<path d="M1074 158.0 C 1074 34.0, 444 34.0, 444 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<path d="M1074 158.0 C 1074 64.0, 601 64.0, 601 158.0" fill="none" stroke="#7FD6D1" stroke-opacity=".8" stroke-width="2.2" stroke-dasharray="6 7" marker-end="url(#back)"/>+<text x="838" y="22.0" fill="#7FD6D1" font-size="16" text-anchor="middle" font-style="italic">rework, as often as it needs: a bad pour sends it back to placement, a hot tab back to the pours or the routing</text>+<rect x="215" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="286" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Spec</text>+<text x="286" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">schematic's</text>+<text x="286" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">judgement</text>+<line x1="357" y1="200" x2="371" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="373" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="444" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Place</text>+<text x="444" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">parts, courtyards,</text>+<text x="444" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">escape bands</text>+<line x1="515" y1="200" x2="528" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="530" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="601" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Route</text>+<text x="601" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">100 % or it</text>+<text x="601" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">failed</text>+<line x1="672" y1="200" x2="686" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="688" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="759" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Pour</text>+<text x="759" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">copper kept,</text>+<text x="759" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">Kelvin keepouts</text>+<line x1="830" y1="200" x2="843" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="845" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="916" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Current</text>+<text x="916" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">IPC-2221,</text>+<text x="916" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">vias, pours</text>+<line x1="987" y1="200" x2="1001" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="1003" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="1074" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Thermal</text>+<text x="1074" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">copper at</text>+<text x="1074" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">every hot tab</text>+<line x1="1145" y1="200" x2="1158" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="1160" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="1231" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Capture</text>+<text x="1231" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">window take,</text>+<text x="1231" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">markers</text>+<line x1="1302" y1="200" x2="1316" y2="200" stroke="#DB8B58" stroke-width="2.5" marker-end="url(#fwd)"/>+<rect x="1318" y="158.0" width="142" height="84" rx="10" fill="#17283F" stroke="#DB8B58" stroke-width="2"/>+<text x="1389" y="188" fill="#F2E9E1" font-size="24" font-weight="700" text-anchor="middle">Deliver</text>+<text x="1389" y="212" fill="#B9A899" font-size="12.5" text-anchor="middle">"done, here is</text>+<text x="1389" y="228" fill="#B9A899" font-size="12.5" text-anchor="middle">your video"</text>+<line x1="272" y1="248.0" x2="272" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="300" y1="504.0" x2="300" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="430" y1="248.0" x2="430" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="458" y1="504.0" x2="458" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="587" y1="248.0" x2="587" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="615" y1="504.0" x2="615" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="745" y1="248.0" x2="745" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="773" y1="504.0" x2="773" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="902" y1="248.0" x2="902" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="930" y1="504.0" x2="930" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="1060" y1="248.0" x2="1060" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="1088" y1="504.0" x2="1088" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="1217" y1="248.0" x2="1217" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="1245" y1="504.0" x2="1245" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<line x1="1375" y1="248.0" x2="1375" y2="502.0" stroke="#93A3B4" stroke-width="1.6" marker-end="url(#down)"/>+<line x1="1403" y1="504.0" x2="1403" y2="250.0" stroke="#00B8B1" stroke-width="1.6" marker-end="url(#up)"/>+<rect x="492" y="344.0" width="690" height="64" rx="8" fill="#0B1420"/>+<text x="838" y="369.0" fill="#93A3B4" font-size="15" text-anchor="middle">down: specs, configs, plans, scripts, as many as it wants, in JSON it can write itself</text>+<text x="838" y="394.0" fill="#00B8B1" font-size="15" text-anchor="middle">up: OK or ERROR, with hints about this board, this run, this step</text>+<rect x="210" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="286" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">spec.json</text>+<text x="286" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">the rules, the loads,</text>+<text x="286" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">the pours to make</text>+<rect x="368" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="444" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">place pack</text>+<text x="444" y="548.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">place check</text>+<text x="444" y="564.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">land moves</text>+<text x="444" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">courtyards, wishes,</text>+<text x="444" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">radii, keepouts</text>+<rect x="525" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="601" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">route · gate</text>+<text x="601" y="548.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">land route</text>+<text x="601" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">grid router, rip-up,</text>+<text x="601" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">Kelvin taps, DRC gate</text>+<rect x="683" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="759" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">pour · land pours</text>+<text x="759" y="548.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">measure</text>+<text x="759" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">zones, keepouts, vias,</text>+<text x="759" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">KiCad's filled copper</text>+<rect x="840" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="916" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">analyze current</text>+<text x="916" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">narrowest conductor,</text>+<text x="916" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">via capacity</text>+<rect x="998" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="1074" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">analyze thermal</text>+<text x="1074" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">theta at the tab,</text>+<text x="1074" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">rise budget</text>+<rect x="1155" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="1231" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">capture</text>+<text x="1231" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">recorder,</text>+<text x="1231" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">markers</text>+<rect x="1313" y="510.0" width="152" height="100" rx="6" fill="#0F1D2E" stroke="#00B8B1" stroke-width="1.6"/>+<text x="1389" y="532.0" fill="#DDF7F5" font-size="13.5" font-weight="700" font-family="IBM Plex Mono, Consolas, monospace" text-anchor="middle">finish · deliver</text>+<text x="1389" y="588.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">ledger, summary,</text>+<text x="1389" y="602.0" fill="#8FB8B5" font-size="11.5" text-anchor="middle">the clock stops</text>+<rect x="210" y="660.0" width="1255" height="96" rx="10" fill="none" stroke="#93A3B4" stroke-width="1.6" stroke-dasharray="8 7"/>+<text x="838" y="692.0" fill="#E8EDF2" font-size="19" font-weight="700" text-anchor="middle">Any box can be the AI's own code instead.</text>+<text x="838" y="718.0" fill="#93A3B4" font-size="15" text-anchor="middle">Too rigid, not good enough? The AI takes the step itself, writes what it needs, and drops the same JSON back into the flow.</text>+<text x="838" y="740.0" fill="#93A3B4" font-size="15" text-anchor="middle">The binary is there so it does not reinvent the fast parts every run. The ledger measures it either way.</text>+<rect x="210" y="770.0" width="1255" height="52" rx="6" fill="#17283F"/>+<text x="838" y="803.0" fill="#F2E9E1" font-size="16" text-anchor="middle"><tspan font-family="IBM Plex Mono, Consolas, monospace" fill="#DB8B58" font-weight="700">run.jsonl</tspan> · every turn, its thinking time and its tool time · every return to an earlier step, and why · one clock from the prompt to "done"</text>+<text x="760" y="878" fill="#5F7286" font-size="13.5" text-anchor="middle" letter-spacing="1">KiCad Bridge today · Altium and Fusion bridges next · then Adom's own web apps: adom-schematic, adom-2dboard, adom-3dboard</text>+</svg>
docs/spec-example.jsonadded+553@@ -0,0 +1,553 @@+{+ "board": "ESC G431 (Adom molecule: STM32G431, DRV8300, six BSC016N06NS, TPSM365 buck, INA181 current sense), 64 x 74 mm, 4 layers",+ "fab": "InstaPCB (copper ablation: keep as much copper as the electrical rules allow)",+ "copperUm": 35,+ "clearance": 0.2,+ "edgeClearance": 0.5,+ "inheritedErrors": 13,+ "inheritedErrorsNote": "six malformed MOSFET courtyards and seven U2 pad clearances come from the source libraries; they are counted separately and never fixed by the flow",+ "fixedRefs": [+ "MP1",+ "MP2",+ "MP3",+ "MP4",+ "MC1",+ "MC2",+ "MC3",+ "MC4",+ "MC5",+ "MC6",+ "MC7",+ "MC8",+ "MC9",+ "MC10",+ "MC11",+ "MC12",+ "MC13",+ "MC14",+ "MC15",+ "MC16",+ "MC17",+ "MC18",+ "MC19",+ "MC20",+ "MC21",+ "MC22",+ "MC23"+ ],+ "planes": {+ "GND": "In1.Cu",+ "+3V3": "In2.Cu"+ },+ "wideNets": {+ "+VBAT": [+ 1.0,+ 0.5,+ 0.25+ ],+ "/DRV_SHA": [+ 1.0,+ 0.5,+ 0.25+ ],+ "/DRV_SHB": [+ 1.0,+ 0.5,+ 0.25+ ],+ "/DRV_SHC": [+ 1.0,+ 0.5,+ 0.25+ ],+ "GND_OUT": [+ 1.0,+ 0.5,+ 0.25+ ]+ },+ "midNets": {+ "+5V": [+ 0.5,+ 0.25+ ],+ "+12V": [+ 0.5,+ 0.25+ ]+ },+ "kelvin": {+ "U6.3": "R32.2",+ "U6.4": "R32.1"+ },+ "kelvinNote": "INA181 IN- (pad 3) taps the shunt's GND pad, IN+ (pad 4) taps its GND_OUT pad; dedicated traces, no plane stub, pour keepouts along them",+ "loads": {+ "+VBAT": {+ "amps": 20,+ "maxRiseC": 10+ },+ "/DRV_SHA": {+ "amps": 20,+ "maxRiseC": 10+ },+ "/DRV_SHB": {+ "amps": 20,+ "maxRiseC": 10+ },+ "/DRV_SHC": {+ "amps": 20,+ "maxRiseC": 10+ },+ "GND_OUT": {+ "amps": 20,+ "maxRiseC": 10+ },+ "+12V": {+ "amps": 0.5+ },+ "+5V": {+ "amps": 0.5+ }+ },+ "hot": {+ "Q1": {+ "watts": 0.6,+ "tabNet": "+VBAT"+ },+ "Q3": {+ "watts": 0.6,+ "tabNet": "+VBAT"+ },+ "Q5": {+ "watts": 0.6,+ "tabNet": "+VBAT"+ },+ "Q2": {+ "watts": 0.6,+ "tabNet": "/DRV_SHA"+ },+ "Q4": {+ "watts": 0.6,+ "tabNet": "/DRV_SHB"+ },+ "Q6": {+ "watts": 0.6,+ "tabNet": "/DRV_SHC"+ },+ "U1": {+ "watts": 0.5,+ "tabNet": "+VBAT",+ "note": "SOT-89 pad 2 (the tab) is +VBAT, the input; the +VBAT F.Cu block has a tongue down to it"+ }+ },+ "pours": [+ {+ "net": "GND",+ "outline": true,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "priority": 0,+ "connection": "thermal",+ "thermalGap": 0.3,+ "thermalBridge": 0.35,+ "name": "GND outer pour (ablation: keep the copper)"+ },+ {+ "net": "+VBAT",+ "polygon": [+ [+ 127.58,+ 59.8+ ],+ [+ 159.13,+ 59.8+ ],+ [+ 159.13,+ 90.9+ ],+ [+ 140.0,+ 90.9+ ],+ [+ 140.0,+ 93.4+ ],+ [+ 131.0,+ 93.4+ ],+ [+ 131.0,+ 90.9+ ],+ [+ 127.58,+ 90.9+ ]+ ],+ "layers": [+ "F.Cu"+ ],+ "priority": 2,+ "connection": "solid",+ "name": "+VBAT rail block F.Cu (tongue to U1's tab)"+ },+ {+ "net": "+VBAT",+ "polygon": [+ [+ 127.58,+ 59.8+ ],+ [+ 159.13,+ 59.8+ ],+ [+ 159.13,+ 93.0+ ],+ [+ 160.6,+ 93.0+ ],+ [+ 160.6,+ 111.0+ ],+ [+ 153.3,+ 111.0+ ],+ [+ 153.3,+ 95.0+ ],+ [+ 127.58,+ 95.0+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 2,+ "connection": "solid",+ "name": "+VBAT rail block, high-side tab column and bypass B.Cu"+ },+ {+ "net": "/DRV_SHA",+ "around": [+ "Q1.1",+ "Q2.5",+ "MC5"+ ],+ "margin": 0.6,+ "layers": [+ "F.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHA phase F.Cu"+ },+ {+ "net": "/DRV_SHB",+ "layers": [+ "F.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHB phase F.Cu (with the free band right of Q3)",+ "polygon": [+ [+ 150.67,+ 92.57+ ],+ [+ 159.5,+ 92.57+ ],+ [+ 159.5,+ 87.0+ ],+ [+ 166.8,+ 87.0+ ],+ [+ 166.8,+ 104.91+ ],+ [+ 150.67,+ 104.91+ ]+ ]+ },+ {+ "net": "/DRV_SHC",+ "around": [+ "Q5.1",+ "Q6.5",+ "MC11"+ ],+ "margin": 0.6,+ "layers": [+ "F.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHC phase F.Cu"+ },+ {+ "net": "GND_OUT",+ "around": [+ "MC3",+ "R32.1"+ ],+ "margin": 0.5,+ "layers": [+ "F.Cu",+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "GND_OUT return"+ },+ {+ "net": "/DRV_SHA",+ "polygon": [+ [+ 126.0,+ 111.3+ ],+ [+ 166.8,+ 111.3+ ],+ [+ 166.8,+ 131.0+ ],+ [+ 126.0,+ 131.0+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHA heat spreader B.Cu (under Q2 and the board's bottom band)"+ },+ {+ "net": "/DRV_SHB",+ "polygon": [+ [+ 126.0,+ 95.2+ ],+ [+ 152.9,+ 95.2+ ],+ [+ 152.9,+ 99.3+ ],+ [+ 159.0,+ 99.3+ ],+ [+ 159.0,+ 104.7+ ],+ [+ 152.9,+ 104.7+ ],+ [+ 152.9,+ 106.0+ ],+ [+ 126.0,+ 106.0+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHB heat spreader B.Cu (left arm and the Q4 tab bridge)"+ },+ {+ "net": "/DRV_SHB",+ "polygon": [+ [+ 161.0,+ 87.0+ ],+ [+ 166.8,+ 87.0+ ],+ [+ 166.8,+ 108.6+ ],+ [+ 161.0,+ 108.6+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHB heat spreader B.Cu (right arm, MC9 to above Q1)"+ },+ {+ "net": "/DRV_SHC",+ "polygon": [+ [+ 131.0,+ 79.0+ ],+ [+ 159.0,+ 79.0+ ],+ [+ 159.0,+ 86.5+ ],+ [+ 152.9,+ 86.5+ ],+ [+ 152.9,+ 93.0+ ],+ [+ 131.0,+ 93.0+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHC heat spreader B.Cu (left arm and the Q6 tab)"+ },+ {+ "net": "/DRV_SHC",+ "polygon": [+ [+ 159.6,+ 73.57+ ],+ [+ 166.8,+ 73.57+ ],+ [+ 166.8,+ 86.41+ ],+ [+ 159.6,+ 86.41+ ]+ ],+ "layers": [+ "B.Cu"+ ],+ "priority": 3,+ "connection": "solid",+ "name": "/DRV_SHC heat spreader B.Cu (right arm at MC11)"+ }+ ],+ "solidAt": [+ "C45.2",+ "C43.2",+ "JP1.1"+ ],+ "thermalVias": [+ {+ "pad": "Q1.5",+ "net": "+VBAT",+ "count": 2+ },+ {+ "pad": "Q3.5",+ "net": "+VBAT",+ "count": 2+ },+ {+ "pad": "Q5.5",+ "net": "+VBAT",+ "count": 2+ },+ {+ "pad": "Q2.5",+ "net": "/DRV_SHA",+ "count": 2+ },+ {+ "pad": "Q4.5",+ "net": "/DRV_SHB",+ "count": 2+ },+ {+ "pad": "Q6.5",+ "net": "/DRV_SHC",+ "count": 2+ }+ ],+ "stitchVias": [+ {+ "net": "+VBAT",+ "x": 157.5,+ "y": 67.5+ },+ {+ "net": "+VBAT",+ "x": 145.5,+ "y": 61.0+ }+ ],+ "notPoured": [+ "/DRV_BSTA",+ "/DRV_BSTB",+ "/DRV_BSTC",+ "/SW",+ "/HSE_IN",+ "/HSE_OUT",+ "/COMP1_INM_IO1_BEMF_B",+ "/COMP1_INM_IO2_BEMF_C",+ "/COMP2_INM_IO1_BEMF_A",+ "/VBAT_SENSE",+ "/IBAT_SENSE",+ "+3V3",+ "/VDDA"+ ],+ "notPouredNote": "switching and bootstrap nodes, the crystal, the BEMF dividers and comparator inputs, the sense taps; +3V3 and /VDDA have the In2.Cu plane",+ "stitchViasNote": "inside both +VBAT rail blocks (F.Cu and B.Cu) and clear of other copper; a stitching via outside the pour is an unconnected item",+ "poursNote": "Phase current flows on F.Cu (source pads, low-side tab and connector are all F.Cu); the B.Cu phase zones are heat spreaders under the low-side tabs, fed by thermal vias. The +VBAT B.Cu block keeps a column under the high-side tabs and a bypass strip right of the FET column so Q1's tab copper stays connected past the Q4 tab bridge.",+ "hotNote": "BSC016N06NS at 20 A phase current: I2R at 3 mOhm hot (Rds(on) 1.6 mOhm typ at 25 C) over one third of the electrical cycle (six-step) is 0.4 W, plus about 0.2 W of switching at 24 kHz: 0.6 W per FET. U1 is a linear regulator on the +VBAT tab: 0.5 W.",+ "hotMaxRiseC": 60,+ "hotMaxRiseNote": "the tab may sit 60 C above ambient at the stated dissipation (theta from the 40 C/W per square inch outer-copper heuristic of 0.1)"+}
docs/time.mdadded+11@@ -0,0 +1,11 @@+# How adom-aiflow measures the AI's time (John, 2026-09-15)++The number we want is how long the AI ran on a task, not how long the wall clock ran while the human slept. A run is a set of sessions, one per human prompt, and the AI's time is the sum of its active spans.++- **A session starts at a human prompt.** The first is the `start` command's `--prompt-time` (the paste time of the first prompt). Every follow-up prompt ("add the walkthroughs", "recut the video") is `adom-aiflow prompt --text "..."` (`--at` when you start late).+- **A session ends at the AI's done.** `adom-aiflow done --message "..."` the moment the answer to that prompt is complete. `deliver` is the done of the first task ("done, here is your video").+- **Idle is cut out.** Inside a session, a gap longer than `idleMinutes` (15 by default, in run.json) between two ledger events with no command running is the human away, not the AI thinking; it is subtracted. A command that runs for an hour (a landing) is not idle: its `turn` to `turn-end` covers the gap. A command that died without a turn-end covers up to the next event only.+- **Unmarked follow-ups still count right.** Work after a done with no `prompt` mark becomes its own row ("follow-up, no prompt mark"), and every idle gap inside it starts another row, since a gap there is most likely a new prompt. The row says to mark next time.+- **The benchmark number** is the AI time from the first prompt to `deliver`: the delivery figure on the run page and the chart. Follow-up sessions are listed under Sessions with their own minutes; none of them are in the delivery number.+- **Everything else follows the AI's time.** The per-step table counts only active seconds. The video's run clock ("AI TIME, THIS RUN") is the active seconds from the first prompt to the clip, so a walkthrough recorded the next morning reads as minute 60, not hour 10. A clip recorded entirely while the human was away (a recorder left on) is left out of the video.+- `adom-aiflow sessions` prints the table; `--json` gives the spans.
docs/video.mdadded+34@@ -0,0 +1,34 @@+# The video, step by step (John, 2026-09-14)++The evidence of a run is its video, and the video is built from the steps, not recorded at the end.++1. **One clip per step.** `step <name>` stops the previous clip and starts this step's own window recording of the editor. The flow file's `record` line says what the clip should show.+2. **The step's code frames the shot.** A landing zooms to fit before it starts; a move lands with the part on screen; a refusal stays in the clip because the refusal is the story.+3. **The binary cuts the sped-up clip and the action cut itself, both RAW.** The action cut keeps only the frames that changed (near-duplicate frames dropped, each kept frame held a quarter second), with no captions: overlays belong to the final video, so a side-by-side of two runs never paints overlays over overlays. `compose` draws the step chip, the engine and board, and the run and step timers (hr / min / sec tables, lower right, Adom faces) from the ledger onto each segment, and narrates it.+3b. **The sped-up clip.** When a clip stops it is pulled into the run and a 10x version is cut next to it (a 51 s routing landing becomes a 5.1 s clip). Both go into the ledger as artifacts of that step, with their seconds: `{"event":"artifact","step":"routing","kind":"clip","seconds":51.0,"file":...}` and `kind:"clip10x"`.+4. **Every step records what it made.** The routed board, the pours plan, the zone state, the analysis tables, the per-step table: all artifacts in the ledger, so the final cut knows what exists without anyone remembering.+5. **The final video is composed, not edited.** `compose` reads the ledger and builds the video from the pieces in the order they happened: every step visit's motion (the action cut of its raw clip, so only the frames where something moved), and every drawing the AI registered with `artifact` during a step, fullscreen. Nothing else: no title pages, no held frames, no cards.+6. **The video leads; the words are cut to it.** A segment is exactly as long as its motion. The narration for it (the step, why the AI came back, the real minutes) is asked of adom-tts in three lengths, fullest first, and the first that fits the segment is used; if even the shortest runs long it is said faster and cut at the picture. The picture is never held for the words.+7. **Two minutes at most.** `compose --max-seconds` (120 by default) is the cap: when the run's motion adds up to more than the budget, every kept frame plays faster, evenly across the run (4 frames per second is the pace of one step's clip; the video says its pace in the ledger). A visit with under a second of motion stays on the page and out of the video. Each drawing takes `--still-seconds` (5) of the budget.+8. **The AI's drawings are in the video.** After `analyze current` and `analyze thermal` the hint asks the AI to do the analysis itself and draw it: a current density map on the copper; for heat, two drawings, the heat flow in the copper layer by layer (each chip's watts spreading on its layer and going down its vias, the vias drawn, the split per chip) and the temperature rise over the whole board (the FR4 spreading); then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old>` when a drawing is redone: the new one takes its place on the page and in the video). The drawing is a fullscreen shot in the video at that point of the step, with the overlays, and a figure on the run page under the step. This is the one place the video leaves the EDA window.+9. **The pours are walked after every step that judged them.** The same hints ask for `tour nets --nets <the pour nets>` under the analysis step: each net lit as a whole, pours and traces together, framed, so the clip of the current step and of the thermal step ends on the copper the analysis was about.+10. **Two engines, one cut.** Because both runs carry the same steps, the same clip tags and the same artifacts, the split-screen comparison is the same composition run twice, side by side, with the per-step numbers from both ledgers on the charts (0.2).+11. **A new name for every cut.** The run page carries the video as `final-<time of the cut>.mp4`, so a browser that cached the last cut shows this one.++0.1 does 1, 3, 4, 5, 6, 7, 8, 9 and 11; 2 and 10 are 0.2.++## Disk++Window capture writes H.264 from the box's GPU encoder as it records (no raw frames on disk, no screenshot polling) and only when the window changes, so a static board while the AI thinks costs almost nothing: the ESC replay was 128 MB for 17 minutes with routing landing the whole time, about 8 MB per minute of change. A monitor capture at 4K is about 115 MB per minute and is not used. The budget per step is the flow's expected minutes at 10 MB per minute, doubled for the pull and the 10x cut; `capture open` reports the whole run's budget against the run drive and the test box, and a step refuses to start its clip when either has less than the budget plus a 1 GB floor. Raw clips are kept as evidence next to their 10x cuts; the raw ones are the space, delete them from old runs first.++## The output spec++The final video is 1920 x 1080, 16:9, 30 fps, H.264 (yuv420p) with AAC audio, whatever the recorded window was. A clip is scaled to fit with its aspect kept and letterboxed or pillarboxed on the dark ground (0x0B1420); it is never stretched. The per-step cuts keep the window's own shape (they are evidence); `compose` normalises them.++## Plan usage is per account++The plan-usage snapshots are the provider's per-account numbers. Every AI thread signed in as the same user draws on them, so a run made while other threads were working shows their usage too. The run page says so under the table; read the delta as an upper bound unless the run was the only thread active.++## The clip guard++No recording may outlive an hour: every recording is started with a hard cap. `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget and writes a marker saying so. A recorder left running once recorded nine hours of a static editor; that cannot happen again.
flows/board.jsonadded+162@@ -0,0 +1,162 @@+{+ "name": "board",+ "title": "The board flow: parts placement to a qualified board",+ "scope": "Starts at the 3D models and parts placement on a board whose schematic, libraries and part choices already exist. Ends when the board is 100 percent routed, DRC-clean, poured, its copper measured, its current and thermal analyses passed, its nets and its 3D view walked through on camera, and the AI has delivered the video.",+ "steps": [+ {+ "name": "intake",+ "who": "ai",+ "does": "read the board and the spec, write the spec from the schematic if it is missing, plan",+ "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read"+ },+ {+ "name": "models",+ "who": "ai",+ "does": "every footprint on the board has its 3D model resolved (kicad_model_check): fetch the vendor STEP, build one, or fix the path, so the board renders as it will be built; a bare footprint in the 3D view is a missing model",+ "binary": [+ "models"+ ],+ "record": "nothing to film: the model check's list and the fixes; the 3D walkthrough later is the proof"+ },+ {+ "name": "placement",+ "who": "ai",+ "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves",+ "binary": [+ "place pack",+ "place check",+ "land moves"+ ],+ "record": "the parts landing ONE AT A TIME (land moves): each part is its own undo step, so the camera sees the board fill up part by part, never an empty board that is suddenly full"+ },+ {+ "name": "routing",+ "who": "binary",+ "does": "route to 100 percent with the spec's rules, gate with KiCad's DRC, land as undo steps; the AI takes it with take route=ai",+ "binary": [+ "route",+ "gate",+ "land route",+ "land vias"+ ],+ "record": "the traces drawn ONE AT A TIME (land route), then the vias: a net with several paths lands as several undo steps, so every trace is seen being drawn; the longest clip, the one viewers speed up"+ },+ {+ "name": "pours",+ "who": "binary",+ "does": "pour every net the spec names, keepouts along Kelvin taps, thermal and stitching vias, land, measure the copper kept",+ "binary": [+ "pour",+ "land pours",+ "measure"+ ],+ "record": "the pours filling in (land pours) and the copper readback; keep the DRC refusals in the clip, they are the story"+ },+ {+ "name": "current",+ "who": "binary",+ "does": "IPC-2221 on every loaded net, pour and via capacity, the same-layer rule; then the AI's own current density drawing and a walkthrough of the pours it judged",+ "binary": [+ "analyze current",+ "artifact --kind analysis-image",+ "tour nets --nets <the pour nets>"+ ],+ "record": "the analysis table on the board, then the AI's current density drawing (fullscreen in the video, the one place it leaves the editor), then each pour net lit and framed"+ },+ {+ "name": "thermal",+ "who": "binary",+ "does": "copper and vias at every hot tab against the rise budget; then the AI's own temperature rise drawing and a walkthrough of the pours it judged",+ "binary": [+ "analyze thermal",+ "artifact --kind analysis-image",+ "tour nets --nets <the pour nets>"+ ],+ "record": "the hot tabs and their copper, the AI's heat map fullscreen, then each pour net lit and framed; when it fails, the return to pours or placement is the clip worth keeping"+ },+ {+ "name": "fields",+ "who": "both",+ "does": "the fields on the board: adom-fields solves the current density of every loaded net, the heat flow in each copper layer with the vias, and the temperature over the board; the AI reads the issues and walks the board in the app on camera",+ "binary": [+ "adom-fields analyze",+ "adom-fields serve",+ "tour fields"+ ],+ "record": "the Adom Fields window: the board in 3D drifting gently through each beat, the heat on the top copper, each pour isolated on its own, the board turned over to the bottom, the temperature, then an issue or two; slow on purpose, the final video plays it at ten times speed"+ },+ {+ "name": "nets",+ "who": "binary",+ "does": "walk the key nets on camera: each loaded, wide or Kelvin net selected as a whole so the editor highlights its pours, tracks, vias and pads together, framed with Zoom to Selection",+ "binary": [+ "tour nets"+ ],+ "record": "the editor with one net lit at a time, framed; the markers say which net"+ },+ {+ "name": "3d",+ "who": "binary",+ "does": "the 3D walkthrough: the viewer opened from the editor, then fit, top, tilt, orbit, zoom in, swing, front, bottom, flip, top, fit, all by menu command, no mouse",+ "binary": [+ "tour 3d"+ ],+ "record": "the 3D Viewer window itself: the board turning, the components up close; full of motion, so the action cut keeps most of it"+ },+ {+ "name": "capture",+ "who": "binary",+ "does": "window recording with markers, so every engine's video lines up",+ "binary": [+ "capture open",+ "capture start",+ "capture mark",+ "capture stop"+ ],+ "record": "this step is the recorder itself: every step's clip is a window capture of the PCB editor, started when the step is declared"+ },+ {+ "name": "finish",+ "who": "both",+ "does": "finish refuses until everything is true; the AI cuts the video and delivers",+ "binary": [+ "finish",+ "deliver"+ ],+ "record": "the finish line passing (0 unconnected, 0 new errors), then the cut video and the delivery message"+ }+ ],+ "returns": "any step may send the AI back to any earlier step: step <name> --back --why \"...\"",+ "later": [+ {+ "name": "components",+ "does": "choose the parts from the requirements; SPICE may send the AI back here"+ },+ {+ "name": "libraries",+ "does": "symbols, footprints, 3D chips for every part"+ },+ {+ "name": "schematic",+ "does": "the schematic, and the spec that falls out of it"+ },+ {+ "name": "simulation",+ "does": "SPICE on the critical loops before the board exists"+ },+ {+ "name": "moleculize",+ "does": "machine pins so the board lives in the probing workcell"+ },+ {+ "name": "paste",+ "does": "solder paste jetting calculations"+ },+ {+ "name": "probe",+ "does": "how to probe the board when it comes off the InstaPCB process"+ }+ ],+ "clips": "every `step <name>` stops the previous step's clip and starts a new window recording tagged with the step, when the board is open on a test box; run.json captures[] carries one entry per clip with its step, start, stop and file, and deliver lists them; the final video is cut from these clips, one segment per step, so two engines' videos line up step for step; a return (step <name> --back --why) is a new visit and gets its own clip, tagged <step>-<visit> with the reason, so the rework is on camera and the final cut can show the loop",+ "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one"+}
install.sh+10−4@@ -1,7 +1,13 @@ #!/bin/bash+# adompkg install hook for adom-aiflow: the prebuilt binary to ~/.local/bin and the skill to ~/.claude/skills. set -e HERE="$(cd "$(dirname "$0")" && pwd)"-mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-fields"-cp "$HERE/bin/adom-fields" "$HOME/.local/bin/adom-fields" && chmod +x "$HOME/.local/bin/adom-fields"-cp "$HERE/skills/adom-fields/SKILL.md" "$HOME/.claude/skills/adom-fields/SKILL.md"-echo "OK: adom-fields installed at ~/.local/bin/adom-fields. Run 'adom-fields --version'."+mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-aiflow" "$HOME/.claude/skills/aiflow-measurement" "$HOME/.claude/skills/aiflow-comparison-video"+cp "$HERE/bin/adom-aiflow" "$HOME/.local/bin/adom-aiflow"+chmod +x "$HOME/.local/bin/adom-aiflow"+cp "$HERE/skills/adom-aiflow/SKILL.md" "$HOME/.claude/skills/adom-aiflow/SKILL.md"+cp "$HERE/skills/aiflow-measurement/SKILL.md" "$HOME/.claude/skills/aiflow-measurement/SKILL.md"+cp "$HERE/skills/aiflow-comparison-video/SKILL.md" "$HOME/.claude/skills/aiflow-comparison-video/SKILL.md"+command -v service-kicad >/dev/null 2>&1 || echo "Hint: the offline gate needs service-kicad (adom-wiki pkg install adom/service-kicad)"+command -v adom-bridge >/dev/null 2>&1 || echo "Hint: the live stages need adom-bridge (the Adom Bridge CLI) and the KiCad Bridge on a desktop"+echo "OK: adom-aiflow installed at ~/.local/bin/adom-aiflow. Run 'adom-aiflow --version'."
page.json+63−47@@ -1,67 +1,83 @@ {- "slug": "adom-fields",+ "slug": "adom-aiflow", "type": "app",- "version": "0.3.0",- "title": "Adom Fields",- "brief": "Adom Fields: where the current and the heat go on your board.",- "summary": "Current density on the copper, heat flow through the pours and the vias, and the temperature over the board, solved from the board file itself and shown on the board in 3D. The AI does the analysis and drives the view; the human orbits, zooms, hovers for the number under the cursor, and reads the nets by what they carry and the issues by what to fix. A step in Adom's AI Flow: the walkthrough goes into the video.",- "description": "Adom Fields reads a KiCad board (its filled polygons, tracks, pads and vias) and a spec (which nets carry what current, which parts dissipate what) and solves three fields on it: the DC current density of every loaded net between its terminals; the heat flow in each copper layer, coupled through the dielectric and every via barrel, from the hot tabs and from the Joule loss of that current; and the temperature rise over the whole board in still and in moving air. Then it serves the board in 3D: the fields painted on each layer, the vias drawn, the hot parts labelled, the nets ordered by what they carry, the issues (a neck where the current squeezes, a tab over its rise budget, a chip missing its thermal vias) with the fix, and the number under the cursor. Everything the UI does a command does, over a live channel, so the AI shows the board off on camera.",+ "version": "0.1.25",+ "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 today; Altium, Fusion and 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.",+ "brief": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.", "category": "Developer Tools", "tags": [ "kicad", "pcb",+ "eda",+ "routing",+ "placement",+ "copper pours",+ "ablation", "current density", "thermal",- "heat",- "copper pours",- "vias",- "3d",- "analysis",- "eda"+ "kelvin",+ "comparison" ], "status": "live",- "discovery_pitch": "Solve where the current and the heat go on a KiCad board and show it on the board in 3D: current density per net, heat flow per layer with the vias, temperature over the board, the nets by what they carry, the issues with the fix. One command to solve, one to serve, one to drive it on camera.",+ "discovery_pitch": "Take a KiCad board from parked parts to a routed, poured, analysed board through one command that keeps the clock, records who did each stage, and refuses to finish early. Both Claude and Codex run the same flow, so their numbers compare.", "discovery_triggers": [- "adom-fields",+ "100% routed",+ "ablation copper",+ "adom-aiflow",+ "ai flow",+ "aiflow",+ "board flow",+ "board loop",+ "compare engines on a board",+ "copper pours", "current density",- "heat map of a board",- "thermal analysis of a pcb",- "copper pour analysis",- "thermal vias",- "where does the current go",- "hot mosfet tab",- "temperature rise of a board",- "IR drop",- "current neck",- "heat flow in copper",- "fields"- ],- "sample_prompts": [- "solve the fields of this board and show me where the 20 A goes",- "which mosfet tab runs hottest and why",- "walk me through the copper pours with the heat on them",- "is any track carrying too much current"+ "fable vs codex board",+ "finish the board",+ "flow for making a board",+ "from placement to a probed board",+ "kelvin keepout",+ "measure the ai per step",+ "place the parts",+ "route the board",+ "run.jsonl",+ "thermal analysis" ],- "hero": {- "type": "image",- "path": "docs/hero.png"- },- "bundled": true,+ "dependencies": {}, "scripts": { "install": "./install.sh", "uninstall": "./uninstall.sh" },+ "visibility": "public", "install": {- "binary": "bin/adom-fields"+ "binary_name": "adom-aiflow" },- "dependencies": {},- "org": "adom",- "tag": "insiders",- "author": {- "name": "John Lauer",- "email": "[email protected]"+ "hero": {+ "type": "image",+ "path": "screenshots/hero.png" },- "visibility": {- "public": true- }-}+ "bundled": true,+ "sample_prompts": [+ {+ "label": "Run the flow on a board",+ "prompt": "aiflow the ESC: place, route, pour, analyze and deliver the video"+ },+ {+ "label": "Route to 100 percent",+ "prompt": "route this board to 100 percent and refuse to finish with anything unconnected"+ },+ {+ "label": "Pours and thermal",+ "prompt": "pour the copper, keep the Kelvin taps clean, and tell me if any FET tab is too hot"+ },+ {+ "label": "Measure the AI per step",+ "prompt": "how long did the AI think at each step of the board flow, and where did it go back"+ },+ {+ "label": "Compare engines",+ "prompt": "run the same board flow on Fable and Codex and show me the comparison"+ }+ ]+}
reference/python/__init__.pyaddedreference/python/__pycache__/copper.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/escboard.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/fixture.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/movefp.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/pack.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/planes.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/pours.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/__pycache__/router.cpython-312.pycadded⋯ 1 unchanged line ⋯
reference/python/analyze.pyadded+146@@ -0,0 +1,146 @@+"""Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.++0.1 is deliberately conservative and says so in its hints: it judges the tracks and vias by+IPC-2221's external-layer curve and the pours by presence, connection style and via count, not+by a field solve. A PASS here means "no obvious under-sized conductor and every hot tab has+copper"; a FAIL names the net or the part and what to change. The 0.2 pass (cross-sections+through the filled polygons, IR drop per net) replaces the pour heuristics.+"""+import json+import math+import os+import sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from escboard import Board # noqa: E402++MIL2_PER_MM2 = 1550.0031+++def ipc2221_amps(width_mm, thickness_um, rise_c, external=True):+ """IPC-2221 track current for a temperature rise: I = k * dT^0.44 * A^0.725 (A in mil^2)."""+ k = 0.048 if external else 0.024+ a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2+ return k * (rise_c ** 0.44) * (a_mil2 ** 0.725)+++def rise_for(width_mm, thickness_um, amps, external=True):+ k = 0.048 if external else 0.024+ a_mil2 = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2+ if a_mil2 <= 0 or amps <= 0:+ return 0.0+ return (amps / (k * a_mil2 ** 0.725)) ** (1 / 0.44)+++def plan_widths(plan):+ """Per net: the widths used, the via count and the number of entries, from a routing plan."""+ out = {}+ for e in plan.get("nets", []):+ d = out.setdefault(e["net"], {"widths": set(), "vias": 0, "viaSize": e.get("viaSize"), "viaDrill": e.get("viaDrill"), "kelvin": 0})+ d["widths"].add(float(e["width"]))+ if e.get("kelvin"):+ d["kelvin"] += 1+ for path in e.get("paths", []):+ d["vias"] += sum(1 for w in path if isinstance(w, dict) and "layer" in w and "x" in w)+ return out+++def analyze_current(plan, spec, zone_state=None):+ """spec.loads: {net: {"amps": continuous A, "peakAmps"?: A, "maxRiseC"?: C}}; spec.copperUm outer thickness (35)."""+ loads = spec.get("loads", {})+ t_um = float(spec.get("copperUm", 35))+ max_rise_default = float(spec.get("maxRiseC", 10))+ widths = plan_widths(plan)+ pours = {}+ for z in (zone_state or {}).get("zones", []):+ if z.get("type") == "copper" and z.get("net"):+ pours.setdefault(z["net"], []).append(z)+ results = []+ ok = True+ for net, ld in loads.items():+ amps = float(ld.get("amps", 0))+ max_rise = float(ld.get("maxRiseC", max_rise_default))+ w = widths.get(net)+ r = {"net": net, "amps": amps, "maxRiseC": max_rise}+ if w is None:+ r.update({"status": "no-copper", "hint": "net %s carries %.1f A but the routing plan has no copper for it" % (net, amps)})+ ok = False+ results.append(r)+ continue+ signal_widths = sorted(x for x in w["widths"] if x > 0.2)+ narrowest = min(signal_widths) if signal_widths else min(w["widths"])+ widest = max(w["widths"])+ rise_narrow = rise_for(narrowest, t_um, amps)+ rise_wide = rise_for(widest, t_um, amps)+ pour_layers = sorted({l for z in pours.get(net, []) for l in z.get("layers", [])})+ pour_area = round(sum(f.get("areaMm2", 0) for z in pours.get(net, []) for f in z.get("fills", [])), 1)+ vias = w["vias"]+ via_amps = 1.5 if (w.get("viaDrill") or 0.3) >= 0.4 else 1.0+ via_capacity = vias * via_amps+ r.update({"narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": round(rise_narrow, 1), "riseAtWidestC": round(rise_wide, 1),+ "vias": vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area})+ # judgement: a track alone must not be the only path unless it is within the rise; a pour on both outer+ # layers carries the load if it exists and the vias between layers can carry it+ if rise_wide <= max_rise:+ r["status"] = "pass"; r["how"] = "the widest track carries the load within %.0f C" % max_rise+ elif pour_layers and pour_area > 0:+ if len(pour_layers) >= 2 and via_capacity < amps:+ r["status"] = "fail"; r["hint"] = "%s is carried by pours on %s but only %d vias (%.0f A) join the layers for %.1f A: add stitching vias (0.8/0.4 mm, about 1.5 A each)" % (net, "+".join(pour_layers), vias, via_capacity, amps)+ ok = False+ else:+ r["status"] = "pass"; r["how"] = "pour on %s (%.0f mm2 filled) carries the load; tracks are %.2f mm at most" % ("+".join(pour_layers), pour_area, widest)+ else:+ r["status"] = "fail"+ need = None+ for wmm in (0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0):+ if rise_for(wmm, t_um, amps) <= max_rise:+ need = wmm; break+ r["hint"] = "%s carries %.1f A on a %.2f mm track (%.0f C rise, limit %.0f C) and has no pour: pour it on the outer layers or widen to %s mm" % (net, amps, widest, rise_wide, max_rise, need or ">6")+ ok = False+ results.append(r)+ return ok, results+++def analyze_thermal(plan, spec, zone_state=None, board=None):+ """spec.hot: {ref: {"watts": W, "tabNet": net, "tabPad"?: name, "maxThetaCPerW"?: C/W}}. 0.1: copper presence and vias."""+ hot = spec.get("hot", {})+ widths = plan_widths(plan)+ pours = {}+ for z in (zone_state or {}).get("zones", []):+ if z.get("type") == "copper" and z.get("net"):+ pours.setdefault(z["net"], []).append(z)+ results = []+ ok = True+ for ref, h in hot.items():+ net = h.get("tabNet")+ watts = float(h.get("watts", 0))+ r = {"ref": ref, "watts": watts, "tabNet": net}+ zs = pours.get(net, [])+ area = round(sum(f.get("areaMm2", 0) for z in zs for f in z.get("fills", [])), 1)+ layers = sorted({l for z in zs for l in z.get("layers", [])})+ solid = any("solid" in (z.get("name") or "").lower() or z.get("connection") == "solid" for z in zs)+ vias = widths.get(net, {}).get("vias", 0)+ # one square inch of 35 um copper is roughly 40 C/W in still air; scale by area, floor at the package's own theta+ theta = 40.0 / max(area / 645.16, 0.05)+ limit = float(h.get("maxThetaCPerW", spec.get("maxThetaCPerW", 60)))+ r.update({"pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": round(theta, 1), "limitCPerW": limit, "riseEstimateC": round(theta * watts, 1)})+ if not zs:+ r["status"] = "fail"; r["hint"] = "%s dissipates %.1f W into %s but %s has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab" % (ref, watts, net, net)+ ok = False+ elif theta > limit:+ r["status"] = "fail"; r["hint"] = "%s: %.0f mm2 of copper on %s gives about %.0f C/W (limit %.0f): widen the pour or add the other outer layer with vias" % (ref, area, net, theta, limit)+ ok = False+ elif vias == 0 and len(layers) >= 2:+ r["status"] = "fail"; r["hint"] = "%s: the %s pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab" % (ref, net)+ ok = False+ else:+ r["status"] = "pass"; r["how"] = "%.0f mm2 of %s copper on %s, about %.0f C/W, %d vias" % (area, net, "+".join(layers), theta, vias)+ results.append(r)+ return ok, results+++if __name__ == "__main__":+ plan = json.load(open(sys.argv[1])); spec = json.load(open(sys.argv[2]))+ zs = json.load(open(sys.argv[3])) if len(sys.argv) > 3 else None+ ok, res = analyze_current(plan, spec, zs)+ print(json.dumps({"pass": ok, "results": res}, indent=1))
reference/python/capture.pyadded+51@@ -0,0 +1,51 @@+"""The capture recipe: the same picture for every engine, with stage markers.++open: the board on the desktop's KiCad through the bridge, foreground OpenGL on a test box we own+ (the etiquette pushes a new window back once, so the restore is repeated), maximised, zoom to fit.+start: ab's native monitor recorder; stop: stops it and pulls the MP4 into the run directory.+mark: writes a labelled timestamp into the run manifest; the split-screen cut aligns engines on them.+"""+import json+import subprocess+import time+++def bridge(ai_thread, target, verb, args, timeout=300):+ cmd = ["adom-bridge", "--ai-thread", ai_thread, "--target", target, verb, json.dumps(args)]+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout).stdout+ try:+ return json.loads(out)+ except Exception:+ return {"status": "parse_error", "raw": out[:400]}+++def open_board(ai_thread, target, remote_board, reason):+ r = bridge(ai_thread, target, "kicad_open_board", {"filePath": remote_board, "foreground": True, "foregroundReason": reason})+ hwnd = r.get("hwnd")+ if not hwnd:+ return None, r+ bridge(ai_thread, target, "desktop_ui_window", {"hwnd": hwnd, "action": "maximize", "reason": "boardflow capture: fill the monitor"})+ time.sleep(40)+ bridge(ai_thread, target, "desktop_bring_to_front", {"hwnd": hwnd, "reason": "boardflow capture: keep the PCB editor on screen on the test box"})+ time.sleep(2)+ bridge(ai_thread, target, "kicad_send_key", {"hwnd": hwnd, "key": "ctrl+home"})+ time.sleep(3)+ return hwnd, r+++def record_start(ai_thread, target, reason, max_ms=1_800_000):+ r = bridge(ai_thread, target, "desktop_record_start", {"monitor": 0, "audio": False, "fps": 30, "reason": reason, "maxDurationMs": max_ms})+ return r.get("recordingId") or r.get("id"), r+++def record_stop(ai_thread, target, recording_id, save_to):+ r = bridge(ai_thread, target, "desktop_record_stop", {"recordingId": recording_id})+ p = r.get("filePath") or r.get("path")+ if not p and r.get("output"):+ try:+ p = json.loads(r["output"]).get("data", {}).get("path")+ except Exception:+ p = None+ p = (p or "").replace("\\", "/")+ pulled = bridge(ai_thread, target, "pull_file", {"filePaths": [p], "saveTo": save_to}, timeout=900) if p else {}+ return p, r, pulled
reference/python/cli.pyadded+430@@ -0,0 +1,430 @@+#!/usr/bin/env python3+"""adom-boardflow: 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 this run.+See SKILL.md for the flow. Every state-changing command needs --ai-thread.+"""+import argparse+import json+import os+import subprocess+import sys+import time+from pathlib import Path++HERE = Path(__file__).resolve().parent+sys.path.insert(0, str(HERE))+from runlog import Run, now, STAGES # noqa: E402++VERSION = "0.1.0"+++def ok(msg, *hints):+ print("OK: " + msg)+ for h in hints:+ if h:+ print("Hint: " + h)+++def err(msg, *hints, code=2):+ print("ERROR: " + msg)+ for h in hints:+ if h:+ print("Hint: " + h)+ sys.exit(code)+++def sh(args, cwd=None, timeout=7200):+ r = subprocess.run([sys.executable] + args if args and args[0].endswith(".py") else args, cwd=cwd, capture_output=True, text=True, timeout=timeout)+ return r.returncode, r.stdout, r.stderr+++def need_thread(a):+ if not a.ai_thread:+ err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", "Re-run with --ai-thread \"<your thread name>\".", code=3)+++def load_run(a, must=True):+ r = Run(a.run)+ if must and not r.exists():+ err("no run at %s" % a.run, "Start one: adom-boardflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>")+ return r+++def spec_of(r):+ return json.load(open(r.data["spec"], encoding="utf-8"))+++# ---------------------------------------------------------------------------------------------+def cmd_start(a):+ need_thread(a)+ if not Path(a.board).is_file():+ err("board not found: %s" % a.board)+ if not Path(a.spec).is_file():+ err("spec not found: %s" % a.spec, "docs/spec-example.json on the adom-boardflow page is the shape.")+ r = Run(a.run)+ if r.exists() and not a.force:+ err("a run already exists at %s (engine %s, prompt time %s)" % (a.run, r.data["engine"], r.data["clock"]["promptTime"]), "Pick another --run directory, or --force to start over.")+ Path(a.run).mkdir(parents=True, exist_ok=True)+ board_copy = Path(a.run) / "board-0.kicad_pcb"+ board_copy.write_bytes(Path(a.board).read_bytes())+ prompt_time = a.prompt_time or now()+ r = Run.create(a.run, board_copy, str(Path(a.spec).resolve()), a.engine, prompt_time, a.ai_thread, a.target, a.remote_board)+ ok("run started for engine %s at %s; the clock started at the prompt, %s" % (a.engine, a.run, prompt_time),+ "Next: adom-boardflow plan --run %s (the stages this board needs and who can take each)." % a.run,+ "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.")+++def cmd_plan(a):+ r = load_run(a)+ spec = spec_of(r)+ sys.path.insert(0, str(HERE))+ from escboard import Board+ b = Board(r.current_board())+ parked = 0+ x0, y0, x1, y1 = b.bbox+ for ref, fp in b.footprints.items():+ if fp["x"] > x1:+ parked += 1+ fixed = len(spec.get("fixedRefs", []))+ stages = {+ "place": {"engines": ["ai"], "default": "ai", "why": "%d parts parked outside the outline, %d fixed by the interface; the AI places (kicad_placement_state, adom-boardflow place pack/check/land help), the binary checks" % (parked, fixed)},+ "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says --take route=ai"},+ "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},+ "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},+ "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},+ "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},+ "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},+ "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},+ "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+ }+ r.data["plan"] = {"at": now(), "stages": stages, "board": {"footprints": len(b.footprints), "nets": len(b.nets), "parked": parked, "layers": b.copper_layers}}+ r.save()+ lines = ["%-16s %-12s %s" % (n, "/".join(s["engines"]), s["why"]) for n, s in stages.items()]+ ok("plan for %s: %d footprints, %d nets, %d copper layers, %d parked\n%s" % (r.data["engine"], len(b.footprints), len(b.nets), len(b.copper_layers), parked, "\n".join(lines)),+ "Take a stage yourself with: adom-boardflow take route=ai --run %s (default: binary where it is offered)." % a.run,+ "Every stage stamps the clock; for a stage you take yourself call `adom-boardflow stage start <name>` and `stage end <name>` around your own work.")+++def cmd_take(a):+ need_thread(a)+ r = load_run(a)+ for kv in a.decision:+ stage, engine = kv.split("=", 1)+ if stage not in STAGES:+ err("unknown stage %s" % stage, "Stages: " + ", ".join(STAGES))+ r.data["decisions"][stage] = engine+ r.save()+ ok("decisions recorded: %s" % json.dumps(r.data["decisions"]), "They are part of the run manifest and the comparison chart.")+++def cmd_stage(a):+ need_thread(a)+ r = load_run(a)+ if a.action == "start":+ r.stage_start(a.name, engine=r.engine_for(a.name), note=a.note)+ ok("stage %s started at %s by %s" % (a.name, now(), r.engine_for(a.name)))+ else:+ result = json.loads(a.result) if a.result else None+ r.stage_end(a.name, result=result, note=a.note)+ ok("stage %s ended at %s" % (a.name, now()), "Minutes so far per stage: %s; %.1f min since the prompt." % (json.dumps(r.stage_minutes()), r.elapsed_minutes()))+++# -- place -------------------------------------------------------------------------------------+def cmd_place(a):+ r = load_run(a)+ board = r.current_board()+ if a.what == "pack":+ need_thread(a)+ code, out, e = sh([str(HERE / "pack.py"), board, "--wish", a.wish, "--out", a.out, "--edge", "0.5", "--gap", "0.05"])+ if code:+ err("pack failed: %s" % (e or out)[-400:])+ ok("legal spots found:\n" + out.strip(), "Land them live with `adom-boardflow place land --moves %s` or check offline first with `place check --moves %s`." % (a.out, a.out))+ elif a.what == "check":+ need_thread(a)+ n = len(r.data["boards"])+ out_board = str(Path(a.run) / ("board-%d.kicad_pcb" % n))+ code, out, e = sh([str(HERE / "movefp.py"), board, "--out", out_board, "--json", a.moves, "--check"])+ if code and "check:" not in out:+ err("move failed: %s" % (e or out)[-400:])+ drc = sh(["service-kicad", "pcb", "drc", "--format", "json", "--out", out_board + ".drc.json", out_board])+ try:+ v = json.load(open(out_board + ".drc.json")).get("violations", [])+ except Exception:+ v = []+ errs = [x for x in v if x.get("severity") == "error"]+ base = int((spec_of(r).get("inheritedErrors") or 0))+ types = {}+ for x in errs:+ types[x["type"]] = types.get(x["type"], 0) + 1+ if len(errs) > base:+ err("the moved board has %d DRC errors (baseline %d): %s" % (len(errs), base, json.dumps(types)), "Courtyard overlaps or edge violations from the moves; change the wishes and pack again. The board copy stays at %s." % out_board)+ r.add_board(out_board)+ ok("moves applied offline to %s; DRC %d errors, all inherited (%s)" % (out_board, len(errs), json.dumps(types)), "This is now the run's current board. Land the same moves live with `place land`.")+ elif a.what == "land":+ need_thread(a)+ if not r.data.get("target"):+ err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")+ r.stage_start("place-land")+ code, out, e = sh([str(HERE / "land_moves.py"), "--target", r.data["target"], "--board", r.data["remoteBoard"], "--moves", a.moves, "--thread", a.ai_thread, "--log", str(Path(a.run) / "place-land-log.json")])+ r.stage_end("place-land", result={"ok": code == 0})+ if code:+ err("live placement refused: %s" % out[-600:], "The bridge checks courtyards and the outline exactly; read offendingRefs and pack again.")+ ok("placement landed live as one undo step:\n" + out.strip())+++# -- route -------------------------------------------------------------------------------------+def cmd_route(a):+ need_thread(a)+ r = load_run(a)+ spec = spec_of(r)+ board = r.current_board()+ if r.engine_for("route") == "ai":+ err("route is taken by the AI in this run (take route=ai)", "Write your plan in the plan JSON shape and run `adom-boardflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.")+ cfg = Path(a.run) / "router-config.json"+ json.dump({"wide": spec.get("wideNets", {}), "mid": spec.get("midNets", {}), "planes": spec.get("planes", {}), "kelvin": spec.get("kelvin", {})}, open(cfg, "w"), indent=1)+ plan = str(Path(a.run) / "plan-route.json"); report = str(Path(a.run) / "plan-route-report.json"); log = Path(a.run) / "router.log"+ r.stage_start("route", engine="binary", note="passes=%d" % a.passes)+ with open(log, "w") as lf:+ proc = subprocess.run([sys.executable, str(HERE / "router.py"), board, "--out", plan, "--report", report, "--passes", str(a.passes), "--config", str(cfg)], stdout=lf, stderr=subprocess.STDOUT, text=True)+ rep = json.load(open(report)) if Path(report).is_file() else {"unrouted": [("?", ("?", "router did not write a report"))]}+ unrouted = rep.get("unrouted", [])+ r.stage_end("route", result={"unrouted": len(unrouted), "passes": a.passes, "plan": plan})+ r.data["outcomes"]["route"] = {"plan": plan, "unrouted": len(unrouted), "detail": unrouted[:20]}+ r.save()+ if unrouted:+ blockers = sorted({b for _, (_, why) in unrouted for b in why.replace("blocked by ", "").strip("[]").replace("'", "").split(", ") if why.startswith("blocked")})+ err("routing closed %d connection(s) short after %d passes: %s" % (len(unrouted), a.passes, "; ".join("%s at %s (%s)" % (n, p, why) for n, (p, why) in unrouted[:6])),+ "More passes: --passes %d." % (a.passes + 4),+ "Or go back to placement: the blockers are %s; 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[:8] or "listed in the report"),+ "Or take the stage yourself: `adom-boardflow take route=ai`.")+ ok("routing closed: 0 unrouted after %d passes; plan %s" % (a.passes, plan), "Next: adom-boardflow gate (KiCad DRC offline on the plan).")+++# -- pour --------------------------------------------------------------------------------------+def cmd_pour(a):+ need_thread(a)+ r = load_run(a)+ plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")+ out = str(Path(a.run) / "plan-pours.json"); vias = str(Path(a.run) / "plan-vias.json")+ r.stage_start("pour", engine="binary")+ code, o, e = sh([str(HERE / "pourplan.py"), r.current_board(), "--spec", r.data["spec"], "--out", out, "--vias", vias] + (["--plan", plan] if plan else []))+ r.stage_end("pour", result={"ok": code == 0, "pours": out, "vias": vias})+ if code:+ err("pour plan failed: %s" % (e or o)[-400:], "spec.pours entries need net, layers and one of outline, polygon or around.")+ r.data["outcomes"]["pour"] = {"pours": out, "vias": vias}+ r.save()+ ok("pours planned:\n" + o.strip(), "Gate them live: pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `adom-boardflow land pours` does it.")+++# -- gate --------------------------------------------------------------------------------------+def cmd_gate(a):+ need_thread(a)+ r = load_run(a)+ plan = a.plan or r.data.get("outcomes", {}).get("route", {}).get("plan")+ if not plan:+ err("no routing plan to gate", "Run `adom-boardflow route` or pass --plan yourplan.json.")+ routed = str(Path(a.run) / "board-routed.kicad_pcb"); report = str(Path(a.run) / "gate.json")+ r.stage_start("gate", engine="binary")+ code, o, e = sh([str(HERE / "copper.py"), r.current_board(), plan, "--out", routed, "--drc", "--baseline", r.current_board(), "--report", report])+ g = json.load(open(report)) if Path(report).is_file() else {}+ r.stage_end("gate", result={k: g.get(k) for k in ("newErrors", "inheritedErrors", "unconnected")})+ r.data["outcomes"]["gate"] = {"report": report, "newErrors": g.get("newErrors"), "unconnected": g.get("unconnected"), "routedBoard": routed}+ r.save()+ if g.get("newErrors") or g.get("unconnected"):+ err("gate failed: %s new DRC errors, %s unconnected (inherited %s)" % (g.get("newErrors"), g.get("unconnected"), g.get("inheritedErrors")),+ "New errors: %s" % json.dumps(g.get("newErrorTypes")), "Unconnected: %s" % json.dumps((g.get("unconnectedList") or [])[:6]),+ "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.")+ ok("gate passed: 0 new DRC errors (%s inherited), 0 unconnected; copper %s" % (g.get("inheritedErrors"), json.dumps(g.get("copper"))), "Next: adom-boardflow land route (then land vias, land pours).")+++# -- land --------------------------------------------------------------------------------------+def cmd_land(a):+ need_thread(a)+ r = load_run(a)+ if not r.data.get("target"):+ err("the run has no desktop target", "Start the run with --target <box> --remote-board <path on the box>.")+ T, B = r.data["target"], r.data["remoteBoard"]+ if a.what == "route":+ plan = r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan+ if not r.data.get("outcomes", {}).get("gate", {}).get("newErrors") == 0:+ err("the gate has not passed for this plan", "Run `adom-boardflow gate` first; the finish line needs it.")+ r.stage_start("land-route")+ code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", plan, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-route-log.json")])+ r.stage_end("land-route", result={"ok": code == 0})+ tail = "\n".join(o.strip().splitlines()[-3:])+ if code:+ err("live routing stopped: %s" % tail, "A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.")+ r.data["outcomes"]["land-route"] = tail; r.save()+ ok("routing landed live, one undo step per net:\n" + tail)+ elif a.what == "vias":+ vias = r.data.get("outcomes", {}).get("pour", {}).get("vias")+ r.stage_start("land-vias")+ code, o, e = sh([str(HERE / "land_route.py"), "--target", T, "--board", B, "--plan", vias, "--pause", "0.6", "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-vias-log.json")])+ r.stage_end("land-vias", result={"ok": code == 0})+ ok("thermal and stitching vias landed:\n" + "\n".join(o.strip().splitlines()[-2:]))+ elif a.what == "pours":+ pours = r.data.get("outcomes", {}).get("pour", {}).get("pours")+ r.stage_start("land-pours")+ code, o, e = sh([str(HERE / "land_pours.py"), "--target", T, "--board", B, "--pours", pours, "--local", r.current_board(), "--thread", a.ai_thread, "--log", str(Path(a.run) / "land-pours-log.json")])+ r.stage_end("land-pours", result={"ok": code == 0})+ log = Path(a.run) / "land-pours-log.json"+ L = json.load(open(log)) if log.is_file() else {}+ failed = [c["net"] for c in L.get("calls", []) if c["reply"].get("success") is not True]+ r.data["outcomes"]["land-pours"] = {"before": L.get("before"), "after": L.get("after", {}).get("filledAreaByLayerMm2") if isinstance(L.get("after"), dict) else None, "failed": failed}+ r.save()+ if failed:+ first = next(c for c in L["calls"] if c["reply"].get("success") is not True)+ v = first["reply"].get("violations") or []+ types = {}+ for x in v:+ types[x.get("type")] = types.get(x.get("type"), 0) + 1+ err("pour for %s refused by the preflight: %s" % (failed[0], json.dumps(types)),+ "starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.")+ ok("pours landed: copper per layer before %s, after %s" % (json.dumps(L.get("before")), json.dumps(r.data["outcomes"]["land-pours"]["after"])), "Next: adom-boardflow measure, then analyze current and analyze thermal, then finish.")+++# -- measure / analyze / finish ------------------------------------------------------------------+def zone_state(r, ai_thread, refill=True):+ from capture import bridge+ return bridge(ai_thread, r.data["target"], "kicad_zone_state", {"filePath": r.data["remoteBoard"], "refill": refill})+++def cmd_measure(a):+ need_thread(a)+ r = load_run(a)+ if not r.data.get("target"):+ err("measure reads the live board", "Start the run with --target and --remote-board.")+ r.stage_start("measure")+ zs = zone_state(r, a.ai_thread)+ r.stage_end("measure", result={"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2")})+ json.dump(zs, open(Path(a.run) / "zone-state.json", "w"), indent=1)+ r.data["outcomes"]["measure"] = {"filledAreaByLayerMm2": zs.get("filledAreaByLayerMm2"), "coveragePercent": zs.get("filledCoverageByLayerPercent"), "zones": zs.get("zoneCount")}+ r.save()+ ok("copper kept per layer (KiCad's fills): %s; coverage %s" % (json.dumps(zs.get("filledAreaByLayerMm2")), json.dumps(zs.get("filledCoverageByLayerPercent"))), "Next: adom-boardflow analyze current, analyze thermal.")+++def cmd_analyze(a):+ need_thread(a)+ r = load_run(a)+ from analyze import analyze_current, analyze_thermal+ plan = json.load(open(r.data.get("outcomes", {}).get("route", {}).get("plan") or a.plan))+ spec = spec_of(r)+ zs_path = Path(a.run) / "zone-state.json"+ zs = json.load(open(zs_path)) if zs_path.is_file() else None+ name = "analyze-" + a.what+ r.stage_start(name)+ passed, res = (analyze_current if a.what == "current" else analyze_thermal)(plan, spec, zs)+ r.stage_end(name, result={"pass": passed})+ r.data["outcomes"][name] = {"pass": passed, "results": res}+ r.save()+ lines = ["%s %s: %s" % (x.get("net") or x.get("ref"), x["status"], x.get("how") or x.get("hint")) for x in res]+ if not passed:+ err("%s analysis failed:\n%s" % (a.what, "\n".join(lines)), "Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.")+ ok("%s analysis passed:\n%s" % (a.what, "\n".join(lines)), "0.1 judges tracks by IPC-2221 and pours by presence and vias; the 0.2 pass computes cross-sections through the filled copper.")+++def cmd_finish(a):+ need_thread(a)+ r = load_run(a)+ o = r.data.get("outcomes", {})+ missing = []+ if (o.get("gate") or {}).get("newErrors") != 0 or (o.get("gate") or {}).get("unconnected") != 0:+ missing.append("gate: 0 new DRC errors and 0 unconnected (run gate)")+ if "land-route" not in o:+ missing.append("land route")+ if not o.get("land-pours") or o["land-pours"].get("failed"):+ missing.append("land pours (every planned pour accepted)")+ if not o.get("measure"):+ missing.append("measure")+ for k in ("analyze-current", "analyze-thermal"):+ if not (o.get(k) or {}).get("pass"):+ missing.append(k + " (pass)")+ live = None+ if r.data.get("target"):+ from capture import bridge+ live = bridge(a.ai_thread, r.data["target"], "kicad_routing_validate", {"filePath": r.data["remoteBoard"]})+ inherited = int(spec_of(r).get("inheritedErrors") or 0)+ if live.get("unconnected") != 0 or (live.get("errors") or 0) > inherited:+ missing.append("live validate: %s unconnected, %s errors (inherited %d)" % (live.get("unconnected"), live.get("errors"), inherited))+ if missing:+ err("not finished: " + "; ".join(missing), "A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.")+ r.data["clock"]["finishTime"] = now()+ r.data["summary"] = {"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "stageMinutes": r.stage_minutes(), "decisions": r.data["decisions"],+ "copper": o.get("measure"), "gate": o.get("gate"), "live": {k: live.get(k) for k in ("errors", "unconnected", "warnings")} if live else None,+ "current": (o.get("analyze-current") or {}).get("pass"), "thermal": (o.get("analyze-thermal") or {}).get("pass")}+ r.save()+ ok("finished: %s from the prompt to a qualified board, %.1f min; stages %s" % (r.data["engine"], r.elapsed_minutes(), json.dumps(r.stage_minutes())),+ "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.",+ "Cut the comparison with gang-takes split mode from the captures and markers in run.json.")+++# -- capture -----------------------------------------------------------------------------------+def cmd_capture(a):+ need_thread(a)+ r = load_run(a)+ from capture import open_board, record_start, record_stop+ if not r.data.get("target"):+ err("capture needs a desktop target", "Start the run with --target and --remote-board.")+ if a.action == "open":+ hwnd, rep = open_board(a.ai_thread, r.data["target"], r.data["remoteBoard"], a.reason or "boardflow run %s on the test box" % r.data["engine"])+ if not hwnd:+ err("could not open the board: %s" % json.dumps(rep)[:300])+ r.data["captures"].append({"opened": now(), "hwnd": hwnd}); r.save()+ ok("board open, maximised, zoomed to fit (hwnd %s)" % hwnd, "Next: adom-boardflow capture start.")+ elif a.action == "start":+ rid, rep = record_start(a.ai_thread, r.data["target"], a.reason or "boardflow run %s" % r.data["engine"])+ if not rid:+ err("recorder did not start: %s" % json.dumps(rep)[:300])+ r.data["captures"].append({"recordingId": rid, "start": now()}); r.mark("capture-start"); r.save()+ ok("recording %s started at %s" % (rid, now()), "Every stage command writes markers; `capture mark <label>` adds your own.")+ elif a.action == "mark":+ r.mark(a.label); r.save()+ ok("marker %s at %s" % (a.label, now()))+ elif a.action == "stop":+ cap = next((c for c in reversed(r.data["captures"]) if c.get("recordingId") and not c.get("file")), None)+ if not cap:+ err("no running recording in this run")+ p, rep, pulled = record_stop(a.ai_thread, r.data["target"], cap["recordingId"], str(Path(a.run).resolve()))+ cap["stop"] = now(); cap["remoteFile"] = p; cap["file"] = str(Path(a.run) / Path(p).name) if p else None+ r.mark("capture-stop"); r.save()+ ok("recording stopped; %s pulled to %s" % (p, cap["file"]), "Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.")+++def cmd_status(a):+ r = load_run(a)+ d = r.data+ o = d.get("outcomes", {})+ ok("run %s, engine %s, %.1f min since the prompt (%s)\nstages (min): %s\ndecisions: %s\nroute: %s unrouted; gate: %s new / %s unconnected; pours: %s; measure: %s; current: %s; thermal: %s; finished: %s" % (+ a.run, d["engine"], r.elapsed_minutes(), d["clock"]["promptTime"], json.dumps(r.stage_minutes()), json.dumps(d["decisions"]),+ (o.get("route") or {}).get("unrouted"), (o.get("gate") or {}).get("newErrors"), (o.get("gate") or {}).get("unconnected"),+ "landed" if o.get("land-pours") and not o["land-pours"].get("failed") else "no", json.dumps((o.get("measure") or {}).get("filledAreaByLayerMm2")),+ (o.get("analyze-current") or {}).get("pass"), (o.get("analyze-thermal") or {}).get("pass"), d["clock"]["finishTime"]))+++def main(argv=None):+ ap = argparse.ArgumentParser(prog="adom-boardflow", description=__doc__.split("\n\n")[0])+ ap.add_argument("--ai-thread", help="the name of the AI conversation calling (required for state-changing commands)")+ ap.add_argument("--run", default=os.environ.get("BOARDFLOW_RUN", "./boardflow-run"), help="run directory (default ./boardflow-run or $BOARDFLOW_RUN)")+ ap.add_argument("--version", action="version", version="adom-boardflow " + VERSION)+ sub = ap.add_subparsers(dest="cmd", required=True)+ s = sub.add_parser("start", help="start a run: copies the board, stamps the prompt time"); s.add_argument("--board", required=True); s.add_argument("--spec", required=True); s.add_argument("--engine", required=True); s.add_argument("--prompt-time"); s.add_argument("--target"); s.add_argument("--remote-board"); s.add_argument("--force", action="store_true"); s.set_defaults(f=cmd_start)+ s = sub.add_parser("plan", help="the stages this board needs and who can take each"); s.set_defaults(f=cmd_plan)+ s = sub.add_parser("take", help="record who takes a stage: route=ai pour=binary"); s.add_argument("decision", nargs="+"); s.set_defaults(f=cmd_take)+ s = sub.add_parser("stage", help="stamp a stage you do yourself"); s.add_argument("action", choices=["start", "end"]); s.add_argument("name"); s.add_argument("--note"); s.add_argument("--result"); s.set_defaults(f=cmd_stage)+ s = sub.add_parser("place", help="placement helpers: pack, check, land"); s.add_argument("what", choices=["pack", "check", "land"]); s.add_argument("--wish"); s.add_argument("--out", default="moves.json"); s.add_argument("--moves"); s.set_defaults(f=cmd_place)+ s = sub.add_parser("route", help="the grid router on the current board"); s.add_argument("--passes", type=int, default=5); s.set_defaults(f=cmd_route)+ s = sub.add_parser("pour", help="pours and vias from the spec"); s.add_argument("--plan"); s.set_defaults(f=cmd_pour)+ s = sub.add_parser("gate", help="KiCad DRC offline on the routing plan"); s.add_argument("--plan"); s.set_defaults(f=cmd_gate)+ s = sub.add_parser("land", help="replay live through the bridge"); s.add_argument("what", choices=["route", "vias", "pours"]); s.add_argument("--plan"); s.set_defaults(f=cmd_land)+ s = sub.add_parser("measure", help="filled copper per layer from the live board"); s.set_defaults(f=cmd_measure)+ s = sub.add_parser("analyze", help="current or thermal"); s.add_argument("what", choices=["current", "thermal"]); s.add_argument("--plan"); s.set_defaults(f=cmd_analyze)+ s = sub.add_parser("finish", help="the finish line; refuses until every check passes"); s.set_defaults(f=cmd_finish)+ s = sub.add_parser("capture", help="open, start, mark, stop"); s.add_argument("action", choices=["open", "start", "mark", "stop"]); s.add_argument("label", nargs="?"); s.add_argument("--reason"); s.set_defaults(f=cmd_capture)+ s = sub.add_parser("status", help="where the run stands"); s.set_defaults(f=cmd_status)+ a = ap.parse_args(argv)+ a.f(a)+++if __name__ == "__main__":+ main()
reference/python/copper.pyadded+481@@ -0,0 +1,481 @@+#!/usr/bin/env python3+"""Apply a routing plan OFFLINE into a copy of the board and gate it with the shared KiCad DRC.++ python3 demo/routing/esc/write_copper.py BOARD-planes.kicad_pcb PLAN.json [--out FILE]+ [--no-fill-planes] [--drc] [--baseline BOARD.kicad_pcb]+ [--report drc-report.json]++The plan is the list of `kicad_route_net` calls the router wrote (see ai_router.py): each entry+is {"net", "width", "points": [...]} or {"net", "width", "paths": [[...], ...]} plus optional+"viaSize" / "viaDrill". Waypoints are "REF.PAD" (the pad centre), [x, y], or {"x", "y",+"layer": "B.Cu"} / {"pad": "REF.PAD", "layer": ...}; a layer marker means a through via AT that+point and every following segment on the new layer, exactly as rust/crates/kicad-core/src/pcb.rs+`plan` reads it. Segments and vias are written as KiCad 10 s-expressions with the board's net+reference, a fresh uuid each, and spliced in before the closing paren.++Plane zones (GND on In1.Cu, +3V3 on In2.Cu from add_planes.py) have no fill in the file and the+shared `service-kicad pcb drc` does not refill zones, so by default this script also writes a+raster fill for every zone: overlapping 0.1 mm strips covering the outline (0.5 mm edge clearance)+minus every other-net through pad, via and hole expanded by the 0.2 mm clearance and the grid+slop. KiCad's own fill on the live board replaces it (thermal reliefs and all); here it lets the+DRC count each plane-net via as connected. --no-fill-planes leaves the zones unfilled.++--drc runs `service-kicad pcb drc` on the written file and prints errors, warnings, unconnected+items and the violation types. With --baseline, errors already present on the unrouted board+(same type, description and item positions) are listed separately as inherited, the way the+bridge's route_net compares before/after; the gate is zero NEW errors and zero unconnected.+"""+import argparse+import json+import math+import os+import subprocess+import sys+import tempfile+import uuid+from collections import Counter+from pathlib import Path++import numpy as np++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from escboard import Board, Grid, fmt # noqa: E402++CLEARANCE = 0.2+EDGE_CLEARANCE = 0.5+SLOP = 0.1+++# ----------------------------------------------------------------------------- plan -> copper++def _point(spec, board):+ """(x, y, pad, explicit_layer) for one waypoint, matching pcb.rs resolve_point."""+ layer = None+ pad = None+ if isinstance(spec, str):+ pad = board.pad_by_key(spec)+ if pad is None:+ raise ValueError("unknown pad %r" % spec)+ return pad.x, pad.y, pad, None+ if isinstance(spec, dict):+ layer = spec.get("layer")+ if spec.get("pad"):+ pad = board.pad_by_key(str(spec["pad"]))+ if pad is None:+ raise ValueError("unknown pad %r" % spec["pad"])+ return pad.x, pad.y, pad, layer+ return float(spec["x"]), float(spec["y"]), None, layer+ if isinstance(spec, (list, tuple)) and len(spec) == 2:+ return float(spec[0]), float(spec[1]), None, None+ raise ValueError("bad waypoint %r" % (spec,))+++def plan_to_copper(board, entry):+ """One plan entry -> (segments, vias): segments (x0,y0,x1,y1,layer,width), vias (x,y,size,drill)."""+ net = entry["net"]+ if net not in board.nets:+ raise ValueError("unknown net %r" % net)+ width = float(entry.get("width", 0.25))+ size = float(entry.get("viaSize", 0.6))+ drill = float(entry.get("viaDrill", 0.3))+ paths = entry.get("paths") or [entry["points"]]+ segments, vias = [], []+ for path in paths:+ if not 2 <= len(path) <= 512:+ raise ValueError("path needs 2..512 waypoints (%s)" % net)+ resolved = [_point(p, board) for p in path]+ for x, y, pad, _ in resolved:+ if pad is not None and pad.net != net:+ raise ValueError("pad %s is on %s, not %s" % (pad.key, pad.net, net))+ layer = entry.get("layer") or resolved[0][3] or "F.Cu"+ for i, (x, y, pad, explicit) in enumerate(resolved):+ nxt = explicit or layer+ if i > 0:+ px, py = resolved[i - 1][0], resolved[i - 1][1]+ if (px, py) != (x, y):+ segments.append((px, py, x, y, layer, width))+ if nxt != layer:+ vias.append((x, y, size, drill))+ layer = nxt+ if pad is not None and layer not in pad.layers:+ raise ValueError("pad %s has no copper on %s" % (pad.key, layer))+ return segments, vias+++def segment_sexpr(board, s, net):+ x0, y0, x1, y1, layer, w = s+ return ("\t(segment\n\t\t(start %s %s)\n\t\t(end %s %s)\n\t\t(width %s)\n\t\t(layer \"%s\")\n%s\t\t(uuid \"%s\")\n\t)"+ % (fmt(x0), fmt(y0), fmt(x1), fmt(y1), fmt(w), layer, board.net_ref_sexpr(net), uuid.uuid4()))+++def via_sexpr(board, v, net):+ x, y, size, drill = v+ return ("\t(via\n\t\t(at %s %s)\n\t\t(size %s)\n\t\t(drill %s)\n\t\t(layers \"F.Cu\" \"B.Cu\")\n%s\t\t(uuid \"%s\")\n\t)"+ % (fmt(x), fmt(y), fmt(size), fmt(drill), board.net_ref_sexpr(net), uuid.uuid4()))+++def splice(text, blocks):+ trimmed = text.rstrip()+ idx = trimmed.rfind(")")+ body = text[:idx].rstrip()+ nl = "\r\n" if "\r\n" in text[:2000] else "\n"+ return body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl+++# ----------------------------------------------------------------------------- plane raster fill++def zone_fill_polygons(board, zone_net, zone_layer, all_vias_by_net, grid=None):+ """ONE fractured polygon per connected region of the plane (KiCad's own fill format: the outer+ contour with every hole spliced in through a zero-width slit). KiCad does not connect+ overlapping sub-polygons of one zone to each other, so a fill made of strips would be a set+ of islands; a single polygon is one island the way a real fill is.++ all_vias_by_net: {net: [(x, y, size, drill), ...]} for every via the plan adds."""+ grid = grid or Grid(board.bbox)+ free = grid.outline_inside(board.outline, EDGE_CLEARANCE + SLOP)+ block = np.zeros((grid.H, grid.W), dtype=bool)+ by_clearance = {} # pads with a local clearance override are dilated by their own value+ for p in board.pads:+ if not p.thru or p.net == zone_net:+ continue+ target = block if p.clearance <= CLEARANCE else by_clearance.setdefault(p.clearance, np.zeros((grid.H, grid.W), dtype=bool))+ for poly in p.polys:+ grid.poly_mask(poly, target)+ if p.drill:+ jj, ii = grid.disk_cells((p.x, p.y), p.drill / 2.0)+ target[jj, ii] = True+ for net, vias in all_vias_by_net.items():+ if net == zone_net:+ continue+ for x, y, size, drill in vias:+ jj, ii = grid.disk_cells((x, y), size / 2.0)+ block[jj, ii] = True+ block = grid.dilate(block, (CLEARANCE + SLOP) / grid.res)+ for clr, mask in by_clearance.items():+ block |= grid.dilate(mask, (clr + SLOP) / grid.res)+ free &= ~block+ loops = _trace_boundaries(free)+ outers = [lp for lp in loops if _signed_area(lp) > 0]+ holes = [lp for lp in loops if _signed_area(lp) < 0]+ polys = []+ for outer in outers:+ ob = _bbox(outer)+ mine = []+ for h in holes:+ hb = _bbox(h)+ if hb[0] < ob[0] or hb[1] < ob[1] or hb[2] > ob[2] or hb[3] > ob[3]:+ continue+ if _point_in_poly(h[0][0] + 1e-6, h[0][1] + 1e-6, outer):+ mine.append(h)+ poly = _fracture(outer, mine)+ polys.append([(grid.x0 + x * grid.res, grid.y0 + y * grid.res) for x, y in poly])+ return polys+++def _trace_boundaries(free):+ """Boundary loops of a binary raster as lattice polygons (cell-corner coordinates, in cells).+ Region on the right of the direction of travel; outer loops and holes get opposite signed areas."""+ H, W = free.shape+ padded = np.zeros((H + 2, W + 2), dtype=bool)+ padded[1:-1, 1:-1] = free+ f = padded+ # directed edges around free cells whose neighbour across that side is not free+ edges = []+ jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[0:-2, 1:-1]) # top side: TL -> TR+ edges += [((i - 0.5, j - 0.5), (i + 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]+ jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 2:]) # right side: TR -> BR+ edges += [((i + 0.5, j - 0.5), (i + 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]+ jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[2:, 1:-1]) # bottom side: BR -> BL+ edges += [((i + 0.5, j + 0.5), (i - 0.5, j + 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]+ jj, ii = np.nonzero(f[1:-1, 1:-1] & ~f[1:-1, 0:-2]) # left side: BL -> TL+ edges += [((i - 0.5, j + 0.5), (i - 0.5, j - 0.5)) for j, i in zip(jj.tolist(), ii.tolist())]+ out = {}+ for a, b in edges:+ out.setdefault(a, []).append(b)+ loops = []+ while out:+ a = next(iter(out))+ b = out[a].pop()+ if not out[a]:+ del out[a]+ loop = [a, b]+ prev = (b[0] - a[0], b[1] - a[1])+ while True:+ cur = loop[-1]+ cands = out.get(cur)+ if not cands:+ break+ if len(cands) == 1:+ nxt = cands.pop()+ else:+ # a pinch point: prefer the right turn (toward the region), then straight, then left+ right = (-prev[1], prev[0])+ pick = None+ for want in (right, prev, (prev[1], -prev[0])):+ for c in cands:+ if (c[0] - cur[0], c[1] - cur[1]) == want:+ pick = c+ break+ if pick is not None:+ break+ nxt = pick if pick is not None else cands[-1]+ cands.remove(nxt)+ if not cands:+ del out[cur]+ prev = (nxt[0] - cur[0], nxt[1] - cur[1])+ if nxt == loop[0]:+ break+ loop.append(nxt)+ # drop collinear vertices+ simp = []+ n = len(loop)+ for k in range(n):+ p0, p1, p2 = loop[k - 1], loop[k], loop[(k + 1) % n]+ if (p1[0] - p0[0]) * (p2[1] - p1[1]) - (p1[1] - p0[1]) * (p2[0] - p1[0]) != 0:+ simp.append(p1)+ if len(simp) >= 4:+ loops.append(simp)+ return loops+++def _signed_area(poly):+ a = 0.0+ for k in range(len(poly)):+ x0, y0 = poly[k]+ x1, y1 = poly[(k + 1) % len(poly)]+ a += x0 * y1 - x1 * y0+ return a / 2.0+++def _bbox(poly):+ xs = [p[0] for p in poly]+ ys = [p[1] for p in poly]+ return min(xs), min(ys), max(xs), max(ys)+++def _point_in_poly(x, y, poly):+ inside = False+ n = len(poly)+ for k in range(n):+ ax, ay = poly[k]+ bx, by = poly[(k + 1) % n]+ if (ay > y) != (by > y):+ if x < ax + (y - ay) * (bx - ax) / (by - ay):+ inside = not inside+ return inside+++def _fracture(outer, holes):+ """Splice every hole into the outer contour through a horizontal zero-width slit to the left,+ holes in order of their left edge so a slit only ever meets contour that is already part of+ the polygon. The slit leaves the hole from the middle of its leftmost vertical edge, so it never+ hits a lattice vertex."""+ poly = list(outer)++ def leftmost_edge(h):+ best = None+ n = len(h)+ for k in range(n):+ a, b = h[k], h[(k + 1) % n]+ if a[0] == b[0] and (best is None or a[0] < best[0]):+ best = (a[0], k)+ return best++ order = sorted(range(len(holes)), key=lambda k: leftmost_edge(holes[k])[0])+ for hk in order:+ h = holes[hk]+ x_h, k = leftmost_edge(h)+ a, b = h[k], h[(k + 1) % len(h)]+ ym = (a[1] + b[1]) / 2.0+ if abs(b[1] - a[1]) >= 2:+ ym = a[1] + (0.5 if b[1] > a[1] else -0.5) # a whole cell from the vertex: never a lattice vertex+ start = (x_h, ym)+ # hole contour starting at the slit point+ hole = [start] + h[k + 1:] + h[:k + 1] + [start]+ # nearest vertical edge of the polygon to the left of the slit point at height ym+ hit = None+ for e in range(len(poly)):+ p0, p1 = poly[e], poly[(e + 1) % len(poly)]+ if p0[0] == p1[0] and p0[0] < x_h and min(p0[1], p1[1]) < ym < max(p0[1], p1[1]):+ if hit is None or p0[0] > hit[0]:+ hit = (p0[0], e)+ if hit is None:+ continue # nothing to the left inside this outer: leave the hole out (cannot happen for a hole)+ xe, e = hit+ joint = (xe, ym)+ poly = poly[:e + 1] + [joint] + hole + [joint] + poly[e + 1:]+ return poly+++def zones_of(board):+ """[(node, net, layer)] for every top-level zone."""+ out = []+ for z in board.root.find_all("zone"):+ n = z.find("net")+ net = n.value() if n is not None else ""+ if board.net_format == "number" and n is not None:+ try:+ net = board.net_table.get(int(n.atom(1)), net)+ except (TypeError, ValueError):+ pass+ nn = z.find("net_name")+ if nn is not None:+ net = nn.value()+ layer = z.find("layer").value() if z.find("layer") is not None else None+ out.append((z, net, layer))+ return out+++def fill_zones(board, text, vias_by_net):+ """Return the board text with a filled_polygon list appended inside every zone."""+ grid = Grid(board.bbox)+ edits = []+ stats = {}+ for node, net, layer in zones_of(board):+ if not net or not layer:+ continue+ polys = zone_fill_polygons(board, net, layer, vias_by_net, grid)+ blocks = []+ for poly in polys:+ pts = " ".join("(xy %s %s)" % (fmt(x), fmt(y)) for x, y in poly)+ blocks.append("\t\t(filled_polygon\n\t\t\t(layer \"%s\")\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % (layer, pts))+ # insert before the zone's closing paren+ edits.append((node.end - 1, node.end - 1, "".join(blocks) + "\t"))+ # a raster fill has no thermal spokes: connect pads solidly in this gate copy so the DRC+ # does not report starved thermals on the through pads inside the plane+ cp = node.find("connect_pads")+ if cp is not None and cp.atom(1) not in ("yes", "no", "thru_hole_only"):+ edits.append((cp.start, cp.start + len("(connect_pads"), "(connect_pads yes"))+ stats[net] = len(polys)+ edits.sort()+ out = []+ pos = 0+ for at, end, ins in edits:+ out.append(text[pos:at])+ out.append(ins)+ pos = end+ out.append(text[pos:])+ return "".join(out), stats+++# ----------------------------------------------------------------------------- DRC++def run_drc(path):+ with tempfile.TemporaryDirectory() as tmp:+ report = Path(tmp) / "drc.json"+ proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],+ capture_output=True, text=True, timeout=900)+ if proc.returncode or not report.is_file():+ raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-600:], proc.stderr[-600:]))+ return json.loads(report.read_text(encoding="utf-8"))+++def fingerprint(v):+ return json.dumps([v.get("type"), v.get("severity"), v.get("description"),+ sorted(json.dumps(i.get("pos"), sort_keys=True) for i in v.get("items", []))], sort_keys=True)+++def summarize(data, baseline=None):+ viol = data.get("violations", [])+ errors = [v for v in viol if v.get("severity") == "error"]+ warnings = [v for v in viol if v.get("severity") == "warning"]+ inherited = []+ new = errors+ if baseline is not None:+ existing = Counter(fingerprint(v) for v in baseline.get("violations", []) if v.get("severity") == "error")+ new, inherited = [], []+ for v in errors:+ k = fingerprint(v)+ if existing.get(k, 0) > 0:+ existing[k] -= 1+ inherited.append(v)+ else:+ new.append(v)+ unconnected = data.get("unconnected_items", [])+ # isolated_copper warnings on a raster-filled plane are the strips themselves (each filled+ # polygon that touches no pad is an "island" to KiCad); the live fill has none of them+ fill_artifacts = [v for v in warnings if v.get("type") == "isolated_copper"]+ warnings = [v for v in warnings if v.get("type") != "isolated_copper"]+ return {+ "errors": len(errors), "newErrors": len(new), "inheritedErrors": len(inherited),+ "warnings": len(warnings), "unconnected": len(unconnected),+ "rasterFillIslandWarnings": len(fill_artifacts),+ "errorTypes": dict(Counter(v.get("type") for v in errors)),+ "newErrorTypes": dict(Counter(v.get("type") for v in new)),+ "warningTypes": dict(Counter(v.get("type") for v in warnings)),+ "newErrorList": [{"type": v.get("type"), "description": v.get("description"),+ "items": [i.get("description") for i in v.get("items", [])],+ "pos": [i.get("pos") for i in v.get("items", [])]} for v in new],+ "unconnectedList": [[i.get("description") for i in u.get("items", [])] for u in unconnected],+ "kicadVersion": data.get("kicad_version"),+ }+++# ----------------------------------------------------------------------------- main++def apply(board, plan, fill_planes=True):+ """Return (text, stats) for the board text with the plan's copper (and optional fills)."""+ entries = plan["nets"] if isinstance(plan, dict) else plan+ blocks = []+ vias_by_net = {}+ n_seg = n_via = 0+ length = 0.0+ widths = Counter()+ for entry in entries:+ segs, vias = plan_to_copper(board, entry)+ for s in segs:+ blocks.append(segment_sexpr(board, s, entry["net"]))+ length += math.hypot(s[2] - s[0], s[3] - s[1])+ widths[s[5]] += 1+ for v in vias:+ blocks.append(via_sexpr(board, v, entry["net"]))+ vias_by_net.setdefault(entry["net"], []).append(v)+ n_seg += len(segs)+ n_via += len(vias)+ text = splice(board.text, blocks)+ stats = {"entries": len(entries), "nets": len({e["net"] for e in entries}), "segments": n_seg, "vias": n_via,+ "copperLengthMm": round(length, 2), "segmentsByWidth": {str(k): v for k, v in sorted(widths.items())}}+ if fill_planes:+ text, fills = fill_zones(board, text, vias_by_net)+ stats["zoneFillPolygons"] = fills+ return text, stats+++def main(argv=None):+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board", help="the board WITH planes (add_planes.py output)")+ ap.add_argument("plan", help="ai_router.py plan JSON")+ ap.add_argument("--out", help="output .kicad_pcb (default: <plan>-routed.kicad_pcb beside the plan)")+ ap.add_argument("--no-fill-planes", action="store_true")+ ap.add_argument("--drc", action="store_true")+ ap.add_argument("--baseline", help="unrouted board whose DRC errors are inherited (default: the input board)")+ ap.add_argument("--report", help="write the DRC summary JSON here")+ a = ap.parse_args(argv)+ board = Board(a.board)+ plan = json.load(open(a.plan, encoding="utf-8"))+ text, stats = apply(board, plan, fill_planes=not a.no_fill_planes)+ out = Path(a.out) if a.out else Path(a.plan).with_name(Path(a.plan).stem + "-routed.kicad_pcb")+ out.write_text(text, encoding="utf-8", newline="")+ Board(str(out))+ print("%s: %s" % (out, json.dumps(stats)))+ if a.drc:+ base = run_drc(a.baseline or a.board)+ data = run_drc(out)+ s = summarize(data, base)+ s["copper"] = stats+ s["file"] = str(out)+ print("drc: %d errors (%d new, %d inherited), %d warnings (+%d raster-fill island warnings), %d unconnected; new error types %s; warning types %s; KiCad %s" % (+ s["errors"], s["newErrors"], s["inheritedErrors"], s["warnings"], s["rasterFillIslandWarnings"], s["unconnected"],+ json.dumps(s["newErrorTypes"], sort_keys=True), json.dumps(s["warningTypes"], sort_keys=True), s["kicadVersion"]))+ for v in s["newErrorList"][:40]:+ print(" NEW %s: %s %s" % (v["type"], v["description"], v["items"]))+ for u in s["unconnectedList"][:40]:+ print(" UNCONNECTED %s" % (u,))+ if a.report:+ Path(a.report).write_text(json.dumps(s, indent=1) + "\n", encoding="utf-8")+ return 0 if (s["newErrors"] == 0 and s["unconnected"] == 0) else 2+ return 0+++if __name__ == "__main__":+ sys.exit(main())
reference/python/escboard.pyadded+532@@ -0,0 +1,532 @@+#!/usr/bin/env python3+"""Board model, pad geometry and grid rasterisation shared by the ESC routing scripts.++Reads a KiCad 10 `.kicad_pcb` with the span-keeping tokenizer from+tools/make_placement_fixture.py and turns it into what a grid router needs:++ * every copper pad in BOARD coordinates (footprint position + rotation, KiCad's+ y-down convention: x' = x*cos + y*sin, y' = -x*sin + y*cos), with its outline as+ one or more polygons (rect/roundrect as the rectangle, oval as a stadium, circle as+ a 32-gon, custom pads as their anchor plus every gr_poly/gr_rect/gr_circle primitive)+ * the Edge.Cuts outline as one closed polygon (arcs sampled)+ * the net format (KiCad 10 boards carry `(net "NAME")` on pads and no net table)+ * a 0.1 mm grid with conservative rasterisers (a cell is copper if any part of the+ shape touches the cell square) and a disk dilation for clearance maps++Python 3 plus numpy. Nothing here writes a file.+"""+import math+import os+import sys++import numpy as np++_TOOLS = os.path.dirname(os.path.abspath(__file__))+sys.path.insert(0, os.path.abspath(_TOOLS))+from fixture import parse, reference_of, num # noqa: E402++RES = 0.1 # mm per grid cell+F_CU, B_CU = 0, 1+LAYER_NAMES = {F_CU: "F.Cu", B_CU: "B.Cu"}+LAYER_IDS = {"F.Cu": F_CU, "B.Cu": B_CU}+++# ----------------------------------------------------------------------------- geometry++def rot_kicad(px, py, deg):+ """KiCad's board rotation of a local offset (y-down, positive angle = counterclockwise on screen)."""+ a = math.radians(deg)+ c, s = math.cos(a), math.sin(a)+ return px * c + py * s, -px * s + py * c+++def circle_poly(cx, cy, r, n=32):+ return [(cx + r * math.cos(2 * math.pi * k / n), cy + r * math.sin(2 * math.pi * k / n)) for k in range(n)]+++def stadium_poly(sx, sy, n=10):+ """Oval pad of size sx x sy centred at 0 (local frame)."""+ if sx >= sy:+ r = sy / 2.0+ d = (sx - sy) / 2.0+ pts = []+ for k in range(n + 1):+ a = -math.pi / 2 + math.pi * k / n+ pts.append((d + r * math.cos(a), r * math.sin(a)))+ for k in range(n + 1):+ a = math.pi / 2 + math.pi * k / n+ pts.append((-d + r * math.cos(a), r * math.sin(a)))+ return pts+ pts = stadium_poly(sy, sx, n)+ return [(y, x) for x, y in pts]+++def arc_points(p0, pm, p1, max_step=0.35):+ """Sample a KiCad three-point arc (start, mid, end) into a polyline including both ends."""+ (x0, y0), (xm, ym), (x1, y1) = p0, pm, p1+ ax, ay = xm - x0, ym - y0+ bx, by = x1 - x0, y1 - y0+ d = 2.0 * (ax * by - ay * bx)+ if abs(d) < 1e-9:+ return [p0, p1]+ a2 = ax * ax + ay * ay+ b2 = bx * bx + by * by+ ux = (by * a2 - ay * b2) / d+ uy = (ax * b2 - bx * a2) / d+ cx, cy = x0 + ux, y0 + uy+ r = math.hypot(ux, uy)+ t0 = math.atan2(y0 - cy, x0 - cx)+ tm = math.atan2(ym - cy, xm - cx)+ t1 = math.atan2(y1 - cy, x1 - cx)++ def ccw(a, b):+ return (b - a) % (2 * math.pi)++ if ccw(t0, tm) <= ccw(t0, t1):+ sweep = ccw(t0, t1)+ else:+ sweep = -ccw(t1, t0)+ n = max(2, int(math.ceil(abs(sweep) * r / max_step)))+ return [(cx + r * math.cos(t0 + sweep * k / n), cy + r * math.sin(t0 + sweep * k / n)) for k in range(n + 1)]+++def chain_polylines(pieces, tol=0.01):+ """Join open polylines end to end into one closed loop (the board outline)."""+ pieces = [list(p) for p in pieces if len(p) >= 2]+ if not pieces:+ return []+ loop = pieces.pop(0)+ while pieces:+ end = loop[-1]+ best = None+ for idx, p in enumerate(pieces):+ if math.dist(end, p[0]) < tol:+ best = (idx, False)+ break+ if math.dist(end, p[-1]) < tol:+ best = (idx, True)+ break+ if best is None:+ raise ValueError("Edge.Cuts outline is not one closed loop (gap at %s)" % (end,))+ idx, rev = best+ p = pieces.pop(idx)+ if rev:+ p.reverse()+ loop.extend(p[1:])+ if math.dist(loop[0], loop[-1]) < tol:+ loop.pop()+ return loop+++def polygon_area(poly):+ a = 0.0+ for i in range(len(poly)):+ x0, y0 = poly[i]+ x1, y1 = poly[(i + 1) % len(poly)]+ a += x0 * y1 - x1 * y0+ return a / 2.0+++# ----------------------------------------------------------------------------- pads++class Pad:+ __slots__ = ("ref", "name", "net", "x", "y", "angle", "shape", "size", "drill", "thru", "layers",+ "polys", "key", "fp_x", "fp_y", "long_axis", "half_long", "half_short", "uuid", "polys_centre", "clearance")++ def __init__(self):+ self.polys = []++ def __repr__(self):+ return "Pad(%s %s at %.3f,%.3f)" % (self.key, self.net, self.x, self.y)++ @property+ def min_dim(self):+ return 2.0 * self.half_short++ def local_extent(self):+ """Extents of the pad copper in its own (rotated) frame: (half_long, half_short, axis unit vector)."""+ return self.half_long, self.half_short, self.long_axis+++def _pad_local_polys(pad_node, shape, sx, sy):+ """Polygons of the pad shape in the pad-local unrotated frame, centred on the pad origin."""+ polys = []+ if shape in ("rect", "roundrect", "trapezoid", "chamfer"):+ polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])+ elif shape == "oval":+ polys.append(stadium_poly(sx, sy))+ elif shape == "circle":+ polys.append(circle_poly(0.0, 0.0, sx / 2.0))+ elif shape == "custom":+ opts = pad_node.find("options")+ anchor = "rect"+ if opts is not None and opts.find("anchor") is not None:+ anchor = opts.find("anchor").value()+ if anchor == "circle":+ polys.append(circle_poly(0.0, 0.0, sx / 2.0))+ else:+ polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])+ prims = pad_node.find("primitives")+ if prims is not None:+ for prim in prims.items:+ if not hasattr(prim, "tag"):+ continue+ if prim.tag == "gr_poly":+ pts = prim.find("pts")+ if pts is not None:+ poly = [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]+ w = prim.find("width")+ wv = w.number(1) if w is not None else 0.0+ if wv and wv > 0:+ # stroked outline: widen by half the stroke (conservative bbox growth)+ poly = _grow_poly(poly, wv / 2.0)+ polys.append(poly)+ elif prim.tag == "gr_rect":+ s, e = prim.find("start"), prim.find("end")+ if s is not None and e is not None:+ x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)+ polys.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])+ elif prim.tag == "gr_circle":+ c, e = prim.find("center"), prim.find("end")+ if c is not None and e is not None:+ r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))+ w = prim.find("width")+ r += (w.number(1) if w is not None else 0.0) / 2.0+ polys.append(circle_poly(c.number(1), c.number(2), r))+ elif prim.tag == "gr_line":+ s, e = prim.find("start"), prim.find("end")+ w = prim.find("width")+ if s is not None and e is not None:+ hw = (w.number(1) if w is not None else 0.1) / 2.0+ polys.append(_thick_line_poly((s.number(1), s.number(2)), (e.number(1), e.number(2)), hw))+ else:+ polys.append([(-sx / 2, -sy / 2), (sx / 2, -sy / 2), (sx / 2, sy / 2), (-sx / 2, sy / 2)])+ return polys+++def _grow_poly(poly, d):+ """Cheap outward growth of a polygon: push every vertex away from the centroid by d."""+ cx = sum(p[0] for p in poly) / len(poly)+ cy = sum(p[1] for p in poly) / len(poly)+ out = []+ for x, y in poly:+ vx, vy = x - cx, y - cy+ n = math.hypot(vx, vy) or 1.0+ out.append((x + vx / n * d, y + vy / n * d))+ return out+++def _thick_line_poly(a, b, hw):+ dx, dy = b[0] - a[0], b[1] - a[1]+ n = math.hypot(dx, dy) or 1.0+ nx, ny = -dy / n * hw, dx / n * hw+ return [(a[0] + nx, a[1] + ny), (b[0] + nx, b[1] + ny), (b[0] - nx, b[1] - ny), (a[0] - nx, a[1] - ny)]+++def _oriented_extent(polys):+ xs = [p[0] for poly in polys for p in poly]+ ys = [p[1] for poly in polys for p in poly]+ return min(xs), min(ys), max(xs), max(ys)+++# ----------------------------------------------------------------------------- board++class Board:+ def __init__(self, path):+ self.path = path+ with open(path, "r", encoding="utf-8", newline="") as f:+ self.text = f.read()+ self.root = parse(self.text)+ if self.root.tag != "kicad_pcb":+ raise ValueError("%s is not a kicad_pcb" % path)+ self.net_table = {} # number -> name when the file has a (net N "NAME") table+ for n in self.root.find_all("net"):+ try:+ self.net_table[int(n.atom(1))] = n.atom(2) or ""+ except (TypeError, ValueError):+ pass+ self.net_format = "number" if self.net_table else "name"+ self.pads = []+ self.footprints = {}+ self._read_footprints()+ self.outline = self._read_outline()+ xs = [p[0] for p in self.outline]+ ys = [p[1] for p in self.outline]+ self.bbox = (min(xs), min(ys), max(xs), max(ys))+ self.nets = {}+ for p in self.pads:+ if p.net:+ self.nets.setdefault(p.net, []).append(p)+ self.copper_layers = [a.atom(1) for a in self.root.find("layers").items[1:] if a.atom(2) == "signal" or a.atom(2) == "power" or a.atom(2) == "mixed"]++ # -- footprints and pads+ def _read_footprints(self):+ for fp in self.root.find_all("footprint"):+ ref = reference_of(fp)+ at = fp.find("at")+ fx, fy = at.number(1), at.number(2)+ rot = at.number(3) or 0.0+ layer = fp.find("layer").value() if fp.find("layer") is not None else "F.Cu"+ fp_clr = fp.find("clearance")+ fp_clearance = fp_clr.number(1) if fp_clr is not None else 0.0+ self.footprints[ref] = {"x": fx, "y": fy, "rot": rot, "layer": layer, "lib": fp.atom(1), "node": fp}+ for pn in fp.find_all("pad"):+ name = pn.value()+ ptype = pn.atom(2)+ shape = pn.atom(3)+ layers_node = pn.find("layers")+ layers = [a.value for a in layers_node.items[1:]] if layers_node is not None else []+ copper = set()+ for l in layers:+ if l == "*.Cu":+ copper.update(("F.Cu", "B.Cu"))+ elif l.endswith(".Cu"):+ copper.add(l)+ if not copper or not name:+ continue # paste-only apertures and unnamed helper pads carry no copper+ pat = pn.find("at")+ px, py = pat.number(1), pat.number(2)+ pang = pat.number(3) if pat.atom(3) is not None else rot+ size = pn.find("size")+ sx, sy = size.number(1), size.number(2)+ dr = pn.find("drill")+ pad = Pad()+ pad.ref, pad.name, pad.key = ref, name, "%s.%s" % (ref, name)+ nn = pn.find("net")+ pad.net = nn.value() if nn is not None else ""+ if self.net_format == "number" and nn is not None:+ try:+ pad.net = self.net_table.get(int(nn.atom(1)), pad.net)+ except (TypeError, ValueError):+ pass+ ox, oy = rot_kicad(px, py, rot)+ pad.x, pad.y = fx + ox, fy + oy+ pad.angle = pang+ pad.shape, pad.size = shape, (sx, sy)+ pad.drill = dr.number(1) if dr is not None else None+ pad.thru = ptype == "thru_hole" or ptype == "np_thru_hole"+ pad.layers = frozenset(l for l in copper if l in ("F.Cu", "B.Cu"))+ pad.fp_x, pad.fp_y = fx, fy+ u = pn.find("uuid")+ pad.uuid = u.value() if u is not None else ""+ pclr = pn.find("clearance")+ # KiCad: the effective clearance between two items is the largest of the rule and both local overrides+ pad.clearance = max(fp_clearance, pclr.number(1) if pclr is not None else 0.0)+ local = _pad_local_polys(pn, shape, sx, sy)+ x0, y0, x1, y1 = _oriented_extent(local)+ lx, ly = (x1 - x0) / 2.0, (y1 - y0) / 2.0+ # centre of the copper in the local frame (custom pads are often offset from the origin)+ lcx, lcy = (x0 + x1) / 2.0, (y0 + y1) / 2.0+ if lx >= ly:+ pad.half_long, pad.half_short = lx, ly+ axis = rot_kicad(1.0, 0.0, pang)+ else:+ pad.half_long, pad.half_short = ly, lx+ axis = rot_kicad(0.0, 1.0, pang)+ pad.long_axis = axis+ for poly in local:+ pad.polys.append([(pad.x + rot_kicad(x, y, pang)[0], pad.y + rot_kicad(x, y, pang)[1]) for x, y in poly])+ # keep the copper centre for escapes (custom pads)+ ccx, ccy = rot_kicad(lcx, lcy, pang)+ pad.polys_centre = (pad.x + ccx, pad.y + ccy)+ self.pads.append(pad)++ # -- outline+ def _read_outline(self):+ pieces = []+ for node in self.root.items:+ if not hasattr(node, "tag"):+ continue+ layer = node.find("layer") if node.tag and node.tag.startswith("gr_") else None+ if layer is None or layer.value() != "Edge.Cuts":+ continue+ if node.tag == "gr_line":+ s, e = node.find("start"), node.find("end")+ pieces.append([(s.number(1), s.number(2)), (e.number(1), e.number(2))])+ elif node.tag == "gr_arc":+ s, m, e = node.find("start"), node.find("mid"), node.find("end")+ pieces.append(arc_points((s.number(1), s.number(2)), (m.number(1), m.number(2)), (e.number(1), e.number(2))))+ elif node.tag == "gr_rect":+ s, e = node.find("start"), node.find("end")+ x0, y0, x1, y1 = s.number(1), s.number(2), e.number(1), e.number(2)+ return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]+ elif node.tag == "gr_circle":+ c, e = node.find("center"), node.find("end")+ r = math.hypot(e.number(1) - c.number(1), e.number(2) - c.number(2))+ return circle_poly(c.number(1), c.number(2), r, 64)+ elif node.tag == "gr_poly":+ pts = node.find("pts")+ return [(xy.number(1), xy.number(2)) for xy in pts.find_all("xy")]+ if not pieces:+ raise ValueError("no Edge.Cuts outline")+ return chain_polylines(pieces)++ def net_ref_sexpr(self, net_name):+ """The `(net ...)` line for copper on this board, matching the bridge's net_sexpr."""+ if self.net_format == "number":+ number = next((n for n, name in self.net_table.items() if name == net_name), None)+ if number is None:+ raise KeyError("net %r is not in the net table" % net_name)+ return "\t\t(net %d)\n" % number+ return "\t\t(net %s)\n" % _quote(net_name)++ def pad_by_key(self, key):+ for p in self.pads:+ if p.key == key:+ return p+ return None+++def _quote(s):+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'+++# ----------------------------------------------------------------------------- grid++class Grid:+ """A 0.1 mm cell grid covering the outline bbox. Cell (i, j) is centred on (x0 + i*RES, y0 + j*RES)."""++ def __init__(self, bbox, res=RES):+ self.res = res+ self.x0, self.y0 = bbox[0], bbox[1]+ self.W = int(round((bbox[2] - bbox[0]) / res)) + 1+ self.H = int(round((bbox[3] - bbox[1]) / res)) + 1++ def to_cell(self, x, y):+ # round half UP (not Python's half-to-even) so pads at a 0.5 mm pitch on .x5 coordinates+ # snap to cells a consistent 5 apart+ # 1e-3 cell (0.1 um) of tolerance: KiCad coordinates carry nanometre noise (123.849998)+ return int(math.floor((x - self.x0) / self.res + 0.5 + 1e-3)), int(math.floor((y - self.y0) / self.res + 0.5 + 1e-3))++ def to_xy(self, i, j):+ return round(self.x0 + i * self.res, 4), round(self.y0 + j * self.res, 4)++ def in_bounds(self, i, j):+ return 0 <= i < self.W and 0 <= j < self.H++ # -- rasterisers (all conservative: any touch of the cell square marks the cell)+ def poly_mask(self, poly, mask=None):+ if mask is None:+ mask = np.zeros((self.H, self.W), dtype=bool)+ xs = np.array([p[0] for p in poly])+ ys = np.array([p[1] for p in poly])+ i0 = max(0, int(math.floor((xs.min() - self.x0) / self.res)) - 1)+ i1 = min(self.W - 1, int(math.ceil((xs.max() - self.x0) / self.res)) + 1)+ j0 = max(0, int(math.floor((ys.min() - self.y0) / self.res)) - 1)+ j1 = min(self.H - 1, int(math.ceil((ys.max() - self.y0) / self.res)) + 1)+ if i1 < i0 or j1 < j0:+ return mask+ ci = np.arange(i0, i1 + 1)+ cj = np.arange(j0, j1 + 1)+ cx = self.x0 + ci * self.res+ cy = self.y0 + cj * self.res+ CX, CY = np.meshgrid(cx, cy)+ inside = np.zeros(CX.shape, dtype=bool)+ n = len(poly)+ for k in range(n):+ xa, ya = poly[k]+ xb, yb = poly[(k + 1) % n]+ if ya == yb:+ continue+ cond = (ya > CY) != (yb > CY)+ xint = xa + (CY - ya) * (xb - xa) / (yb - ya)+ inside ^= cond & (CX < xint)+ # boundary supercover: sample each edge finely and mark the containing cells+ for k in range(n):+ xa, ya = poly[k]+ xb, yb = poly[(k + 1) % n]+ L = math.hypot(xb - xa, yb - ya)+ steps = max(1, int(math.ceil(L / 0.025)))+ t = np.linspace(0.0, 1.0, steps + 1)+ ex = xa + (xb - xa) * t+ ey = ya + (yb - ya) * t+ ii = np.round((ex - self.x0) / self.res).astype(int)+ jj = np.round((ey - self.y0) / self.res).astype(int)+ ok = (ii >= i0) & (ii <= i1) & (jj >= j0) & (jj <= j1)+ inside[jj[ok] - j0, ii[ok] - i0] = True+ mask[j0:j1 + 1, i0:i1 + 1] |= inside+ return mask++ def segment_cells(self, a, b, half_width, slack=None):+ """Cells whose centre is within half_width + slack of segment a-b. Returns (jj, ii) index arrays."""+ if slack is None:+ slack = self.res / 2.0+ r = half_width + slack+ xa, ya = a+ xb, yb = b+ i0 = max(0, int(math.floor((min(xa, xb) - r - self.x0) / self.res)))+ i1 = min(self.W - 1, int(math.ceil((max(xa, xb) + r - self.x0) / self.res)))+ j0 = max(0, int(math.floor((min(ya, yb) - r - self.y0) / self.res)))+ j1 = min(self.H - 1, int(math.ceil((max(ya, yb) + r - self.y0) / self.res)))+ if i1 < i0 or j1 < j0:+ return np.array([], dtype=int), np.array([], dtype=int)+ ci = np.arange(i0, i1 + 1)+ cj = np.arange(j0, j1 + 1)+ CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)+ dx, dy = xb - xa, yb - ya+ L2 = dx * dx + dy * dy+ if L2 < 1e-12:+ t = np.zeros_like(CX)+ else:+ t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)+ px = xa + t * dx+ py = ya + t * dy+ d = np.hypot(CX - px, CY - py)+ jj, ii = np.nonzero(d <= r + 1e-9)+ return jj + j0, ii + i0++ def disk_cells(self, c, radius, slack=None):+ return self.segment_cells(c, c, radius, slack)++ def dilate(self, mask, r_cells):+ """Binary dilation by a disk of radius r_cells (float, in cells)."""+ if r_cells <= 0:+ return mask.copy()+ R = int(math.floor(r_cells))+ H, W = mask.shape+ padded = np.zeros((H + 2 * R, W + 2 * R), dtype=bool)+ padded[R:R + H, R:R + W] = mask+ out = np.zeros_like(mask)+ r2 = r_cells * r_cells + 1e-9+ for dy in range(-R, R + 1):+ for dx in range(-R, R + 1):+ if dx * dx + dy * dy <= r2:+ out |= padded[R + dy:R + dy + H, R + dx:R + dx + W]+ return out++ def outline_inside(self, outline, margin):+ """Cells whose centre is inside the outline polygon and at least `margin` from every edge."""+ inside, dmin = self.outline_distance(outline)+ return inside & (dmin >= margin)++ def outline_distance(self, outline):+ """(inside mask, distance of every cell centre to the nearest outline edge)."""+ inside = np.zeros((self.H, self.W), dtype=bool)+ ci = np.arange(self.W)+ cj = np.arange(self.H)+ CX, CY = np.meshgrid(self.x0 + ci * self.res, self.y0 + cj * self.res)+ n = len(outline)+ for k in range(n):+ xa, ya = outline[k]+ xb, yb = outline[(k + 1) % n]+ if ya == yb:+ continue+ cond = (ya > CY) != (yb > CY)+ xint = xa + (CY - ya) * (xb - xa) / (yb - ya)+ inside ^= cond & (CX < xint)+ dmin = np.full(CX.shape, np.inf)+ for k in range(n):+ xa, ya = outline[k]+ xb, yb = outline[(k + 1) % n]+ dx, dy = xb - xa, yb - ya+ L2 = dx * dx + dy * dy+ if L2 < 1e-12:+ continue+ t = np.clip(((CX - xa) * dx + (CY - ya) * dy) / L2, 0.0, 1.0)+ d = np.hypot(CX - (xa + t * dx), CY - (ya + t * dy))+ dmin = np.minimum(dmin, d)+ return inside, dmin+++def fmt(v):+ return num(v)
reference/python/fixture.pyadded+688@@ -0,0 +1,688 @@+#!/usr/bin/env python3+"""Build the placement demo fixture from a routed, placed KiCad 10 board.++ python3 tools/make_placement_fixture.py BOARD.kicad_pcb [--out DIR] [--gap 2] [--margin 5]++Writes two copies beside each other (default: next to the input):++ <name>-unplaced.kicad_pcb every footprint moved out of the board outline into a parking+ grid to the right of it (rows sorted by reference, 2 mm gaps,+ rotation 0, side kept), every segment, arc and via deleted,+ zones kept with their fills dropped+ <name>-unrouted.kicad_pcb the original placement, only the copper and the fills stripped++The file is edited in place at the byte level: a small s-expression tokenizer records where+every node starts and ends, and only the nodes that change are rewritten, so everything+else (indentation, number formatting, uuids, the setup block) is byte for byte the input.+Teardrop zones (KiCad's generated copper around pads and vias, `(attr (teardrop ...))`)+belong to the copper that is removed and are dropped too unless --keep-teardrops.++Refuses a board with no Edge.Cuts outline: there is nowhere to park relative to.+Python 3, standard library only. `--self-test` runs the built-in checks.+"""+import argparse+import json+import math+import re+import sys+from pathlib import Path++GAP_MM = 2.0+MARGIN_MM = 5.0+COURTYARD_FALLBACK_MM = 0.25+COPPER_TAGS = ("segment", "via", "arc")+ANGLED_CHILDREN = ("pad", "property", "fp_text")+++# ----------------------------------------------------------------------------- s-expressions++class Node:+ """A list node: tag, children (Node or Atom), and its [start, end) span in the text."""+ __slots__ = ("start", "end", "items")++ def __init__(self, start):+ self.start = start+ self.end = None+ self.items = []++ @property+ def tag(self):+ first = self.items[0] if self.items else None+ return first.text if isinstance(first, Atom) else None++ def find(self, tag):+ for c in self.items:+ if isinstance(c, Node) and c.tag == tag:+ return c+ return None++ def find_all(self, tag):+ return [c for c in self.items if isinstance(c, Node) and c.tag == tag]++ def atom(self, i):+ c = self.items[i] if i < len(self.items) else None+ return c.value if isinstance(c, Atom) else None++ def value(self):+ return self.atom(1)++ def number(self, i):+ v = self.atom(i)+ try:+ return float(v)+ except (TypeError, ValueError):+ return None+++class Atom:+ __slots__ = ("text", "start", "end")++ def __init__(self, text, start, end):+ self.text, self.start, self.end = text, start, end++ @property+ def value(self):+ """The atom without quotes, escapes resolved (only what KiCad emits)."""+ if self.text.startswith('"') and self.text.endswith('"') and len(self.text) >= 2:+ return self.text[1:-1].replace('\\"', '"').replace("\\\\", "\\")+ return self.text+++def parse(text):+ """Parse the first top-level list. Every node keeps its exact span in `text`."""+ n = len(text)+ i = 0+ root = None+ stack = []+ while i < n:+ c = text[i]+ if c == "(":+ node = Node(i)+ if stack:+ stack[-1].items.append(node)+ stack.append(node)+ i += 1+ elif c == ")":+ if not stack:+ raise ValueError("unbalanced ')' at offset %d" % i)+ node = stack.pop()+ node.end = i + 1+ if not stack:+ root = node+ break+ i += 1+ elif c == '"':+ j = i + 1+ while j < n:+ if text[j] == "\\":+ j += 2+ continue+ if text[j] == '"':+ break+ j += 1+ if j >= n:+ raise ValueError("unterminated string at offset %d" % i)+ stack[-1].items.append(Atom(text[i:j + 1], i, j + 1))+ i = j + 1+ elif c.isspace():+ i += 1+ else:+ j = i+ while j < n and not text[j].isspace() and text[j] not in "()":+ j += 1+ if not stack:+ raise ValueError("atom outside any list at offset %d" % i)+ stack[-1].items.append(Atom(text[i:j], i, j))+ i = j+ if root is None:+ raise ValueError("no complete top-level list")+ return root+++def num(v):+ """KiCad-style trimmed float: 1.5, not 1.500000; -0 is 0."""+ s = ("%.6f" % v).rstrip("0").rstrip(".")+ if s in ("", "-", "-0"):+ return "0"+ return s+++def normalize_deg(d):+ r = math.fmod(d, 360.0)+ if r <= -180.0:+ r += 360.0+ if r > 180.0:+ r -= 360.0+ return 0.0 if r == 0 else r+++def ref_key(ref):+ letters = re.match(r"[^0-9]*", ref).group(0)+ rest = ref[len(letters):]+ digits = re.match(r"[0-9]*", rest).group(0)+ return (letters.upper(), int(digits) if digits else 0, rest[len(digits):])+++def line_span(text, node):+ """The span to cut for a whole-line node: from the start of its line through its newline."""+ s = node.start+ ls = text.rfind("\n", 0, s) + 1+ whole_line = not text[ls:s].strip()+ if not whole_line:+ ls = s # something else on the line before it: cut the node only+ e = node.end+ if whole_line:+ if text[e:e + 2] == "\r\n":+ e += 2+ elif text[e:e + 1] == "\n":+ e += 1+ return ls, e+++# ----------------------------------------------------------------------------- geometry++def points_of(node, coords=("start", "mid", "end", "center")):+ pts = []+ for tag in coords:+ c = node.find(tag)+ if c is not None:+ x, y = c.number(1), c.number(2)+ if x is not None and y is not None:+ pts.append((x, y))+ p = node.find("pts")+ if p is not None:+ for xy in p.find_all("xy"):+ x, y = xy.number(1), xy.number(2)+ if x is not None and y is not None:+ pts.append((x, y))+ return pts+++def shape_extent(node):+ """Vertices that bound one graphic shape (rect corners, circle extremes, arc points)."""+ tag = node.tag or ""+ if tag.endswith("rect"):+ a, b = node.find("start"), node.find("end")+ if a is None or b is None:+ return []+ ax, ay, bx, by = a.number(1), a.number(2), b.number(1), b.number(2)+ return [(ax, ay), (bx, ay), (bx, by), (ax, by)]+ if tag.endswith("circle"):+ c, e = node.find("center"), node.find("end")+ if c is None or e is None:+ return []+ cx, cy = c.number(1), c.number(2)+ r = math.hypot(e.number(1) - cx, e.number(2) - cy)+ return [(cx - r, cy), (cx + r, cy), (cx, cy - r), (cx, cy + r)]+ return points_of(node)+++def bbox(points):+ if not points:+ return None+ xs = [p[0] for p in points]+ ys = [p[1] for p in points]+ return (min(xs), min(ys), max(xs), max(ys))+++def layer_of(node):+ l = node.find("layer")+ return l.value() if l is not None else None+++def outline_bbox(root):+ pts = []+ for tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly"):+ for node in root.find_all(tag):+ if layer_of(node) == "Edge.Cuts":+ pts.extend(shape_extent(node))+ for fp in root.find_all("footprint"):+ at = fp.find("at")+ fx, fy = (at.number(1), at.number(2)) if at is not None else (0.0, 0.0)+ for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):+ for node in fp.find_all(tag):+ if layer_of(node) == "Edge.Cuts":+ pts.extend((x + fx, y + fy) for x, y in shape_extent(node))+ return bbox(pts)+++def courtyard_local(fp):+ """The footprint's courtyard box in local coordinates at rotation 0, and its source."""+ pts = []+ for tag in ("fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly"):+ for node in fp.find_all(tag):+ if layer_of(node) in ("F.CrtYd", "B.CrtYd"):+ pts.extend(shape_extent(node))+ b = bbox(pts)+ if b:+ return b, "courtyard"+ corners = []+ for pad in fp.find_all("pad"):+ at, size = pad.find("at"), pad.find("size")+ if at is None:+ continue+ x, y = at.number(1), at.number(2)+ w = (size.number(1) if size is not None else 0.0) / 2.0+ h = (size.number(2) if size is not None else 0.0) / 2.0+ corners.extend([(x - w, y - h), (x + w, y + h)])+ b = bbox(corners)+ if b:+ m = COURTYARD_FALLBACK_MM+ return (b[0] - m, b[1] - m, b[2] + m, b[3] + m), "pads+0.25mm"+ return (-0.5, -0.5, 0.5, 0.5), "position"+++def reference_of(fp):+ for p in fp.find_all("property"):+ if p.value() == "Reference":+ return p.atom(2) or ""+ return ""+++# ----------------------------------------------------------------------------- the edit++def parking_grid(footprints, outline, gap=GAP_MM, margin=MARGIN_MM):+ """Rows of footprints to the right of the outline, sorted by reference.++ footprints: list of (ref, courtyard_local_bbox). Returns {ref: (x, y)} footprint+ origins and the parking bbox. Row width grows with the total area so the block stays+ roughly square rather than one long strip."""+ cells = sorted(footprints, key=lambda f: ref_key(f[0]))+ area = sum((c[2] - c[0] + gap) * (c[3] - c[1] + gap) for _, c in cells)+ row_width = max(40.0, math.ceil(math.sqrt(area) * 1.15))+ x0 = outline[2] + margin+ y0 = outline[1]+ cursor_x, cursor_y, row_h = x0, y0, 0.0+ origins = {}+ pts = []+ for ref, c in cells:+ w, h = c[2] - c[0], c[3] - c[1]+ if cursor_x > x0 and cursor_x + w > x0 + row_width:+ cursor_x = x0+ cursor_y += row_h + gap+ row_h = 0.0+ origins[ref] = (cursor_x - c[0], cursor_y - c[1])+ pts.extend([(cursor_x, cursor_y), (cursor_x + w, cursor_y + h)])+ cursor_x += w + gap+ row_h = max(row_h, h)+ return origins, bbox(pts)+++def at_edit(at_node, x, y, angle, keep_angle_atom):+ """Rewrite one (at ...) node. angle None keeps the node's own angle handling."""+ parts = ["at", num(x), num(y)]+ if angle is not None and (keep_angle_atom or angle != 0.0):+ parts.append(num(angle))+ return (at_node.start, at_node.end, "(" + " ".join(parts) + ")")+++def plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones=False, strip_graphics=False, keep_refs=None):+ """All edits for one copy. Returns (edits, summary)."""+ edits = []+ removed = {"segments": 0, "vias": 0, "arcs": 0, "filledPolygons": 0, "teardropZones": 0, "groupsDropped": 0, "groupMembersDropped": 0}+ deleted_ids = set()+ deleted_nodes = set()++ def uuid_of(node):+ u = node.find("uuid")+ return u.value() if u is not None else None++ for node in root.items:+ if not isinstance(node, Node):+ continue+ if node.tag in COPPER_TAGS:+ edits.append((*line_span(text, node), ""))+ removed[{"segment": "segments", "via": "vias", "arc": "arcs"}[node.tag]] += 1+ if uuid_of(node):+ deleted_ids.add(uuid_of(node))+ elif node.tag == "zone" and strip_zones:+ edits.append((*line_span(text, node), ""))+ deleted_nodes.add(id(node))+ removed["zonesStripped"] = removed.get("zonesStripped", 0) + 1+ if uuid_of(node):+ deleted_ids.add(uuid_of(node))+ continue+ elif strip_graphics and node.tag in ("gr_line", "gr_arc", "gr_rect", "gr_circle", "gr_poly", "gr_text", "gr_text_box", "dimension", "image", "table") and layer_of(node) != "Edge.Cuts":+ edits.append((*line_span(text, node), ""))+ deleted_nodes.add(id(node))+ removed["graphicsStripped"] = removed.get("graphicsStripped", 0) + 1+ if uuid_of(node):+ deleted_ids.add(uuid_of(node))+ continue+ elif node.tag == "zone" and not keep_teardrops:+ attr = node.find("attr")+ if attr is not None and attr.find("teardrop") is not None:+ edits.append((*line_span(text, node), ""))+ deleted_nodes.add(id(node))+ removed["teardropZones"] += 1+ if uuid_of(node):+ deleted_ids.add(uuid_of(node))+ continue++ def strip_fills(node):+ for c in node.items:+ if not isinstance(c, Node) or id(c) in deleted_nodes:+ continue+ if c.tag == "filled_polygon":+ edits.append((*line_span(text, c), ""))+ removed["filledPolygons"] += 1+ elif c.tag in ("zone", "footprint"):+ strip_fills(c)+ strip_fills(root)++ outline = outline_bbox(root)+ parked = 0+ parking = None+ fps = root.find_all("footprint")+ import re as _re+ keep_re = _re.compile(keep_refs) if keep_refs else None+ kept_refs = []+ if keep_re:+ for fp in fps:+ ref = reference_of(fp)+ if keep_re.fullmatch(ref or ""):+ kept_refs.append(ref)+ if fp.find("locked") is None:+ # insert (locked yes) right after the layer line of the footprint+ layer = fp.find("layer")+ if layer is not None:+ st, en = line_span(text, layer)+ edits.append((en, en, "\t\t(locked yes)\n"))+ fps = [fp for fp in fps if not keep_re.fullmatch(reference_of(fp) or "")]+ if park:+ cells = []+ for fp in fps:+ cells.append((reference_of(fp), courtyard_local(fp)[0]))+ origins, parking = parking_grid(cells, outline, gap, margin)+ for fp in fps:+ ref = reference_of(fp)+ at = fp.find("at")+ if at is None:+ continue+ rot = at.number(3) or 0.0+ x, y = origins[ref]+ edits.append(at_edit(at, x, y, None, False))+ if rot != 0.0:+ # Child angles are stored absolute (footprint angle included): keep each+ # child's angle relative to its footprint when the footprint goes to 0.+ for child in fp.items:+ if isinstance(child, Node) and child.tag in ANGLED_CHILDREN:+ cat = child.find("at")+ if cat is None:+ continue+ has_angle = cat.atom(3) is not None+ old = cat.number(3) or 0.0+ edits.append(at_edit(cat, cat.number(1), cat.number(2), normalize_deg(old - rot), has_angle))+ parked += 1++ # Groups that referenced removed copper: drop the dead members, drop empty groups.+ for g in root.find_all("group"):+ members = g.find("members")+ if members is None:+ continue+ ids = [a for a in members.items[1:] if isinstance(a, Atom)]+ dead = [a for a in ids if a.value in deleted_ids]+ if not dead:+ continue+ if len(dead) == len(ids):+ edits.append((*line_span(text, g), ""))+ removed["groupsDropped"] += 1+ else:+ keep = [a.text for a in ids if a.value not in deleted_ids]+ edits.append((members.start, members.end, "(members " + " ".join(keep) + ")"))+ removed["groupMembersDropped"] += len(dead)++ summary = {+ "footprints": len(fps), "footprintsParked": parked, "removed": removed,+ "outline": {"minX": outline[0], "minY": outline[1], "maxX": outline[2], "maxY": outline[3]},++ "parking": None if parking is None else {"minX": round(parking[0], 3), "minY": round(parking[1], 3), "maxX": round(parking[2], 3), "maxY": round(parking[3], 3)},+ }+ summary["keptInPlace"] = kept_refs+ return edits, summary+++def apply_edits(text, edits):+ edits = sorted(edits, key=lambda e: (e[0], e[1]))+ for i in range(1, len(edits)):+ if edits[i][0] < edits[i - 1][1]:+ raise ValueError("overlapping edits at %d" % edits[i][0])+ out = []+ pos = 0+ for s, e, rep in edits:+ out.append(text[pos:s])+ out.append(rep)+ pos = e+ out.append(text[pos:])+ return "".join(out)+++def make(text, park, gap=GAP_MM, margin=MARGIN_MM, keep_teardrops=False, strip_zones=False, strip_graphics=False, keep_refs=None):+ root = parse(text)+ if root.tag != "kicad_pcb":+ raise ValueError("not a kicad_pcb file")+ if outline_bbox(root) is None:+ raise ValueError("no Edge.Cuts outline found; refusing (nothing to park relative to)")+ edits, summary = plan_edits(text, root, park, gap, margin, keep_teardrops, strip_zones, strip_graphics, keep_refs)+ return apply_edits(text, edits), summary+++def main(argv=None):+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board", nargs="?", help="a .kicad_pcb (KiCad 10 s-expression)")+ ap.add_argument("--out", help="output directory (default: beside the input)")+ ap.add_argument("--gap", type=float, default=GAP_MM, help="gap between parked footprints, mm")+ ap.add_argument("--margin", type=float, default=MARGIN_MM, help="gap between the outline and the parking grid, mm")+ ap.add_argument("--keep-teardrops", action="store_true", help="keep teardrop zones (generated copper) instead of dropping them")+ ap.add_argument("--strip-zones", action="store_true", help="drop every zone (the demo board starts with no copper pours at all)")+ ap.add_argument("--keep-refs", help="regex of references that stay where they are and get locked (a molecule's machine pins and contacts, e.g. 'M[CP]\\d+')")+ ap.add_argument("--strip-graphics", action="store_true", help="drop board-level graphics and text on every layer except Edge.Cuts (labels and logos that describe the original layout)")+ ap.add_argument("--self-test", action="store_true")+ a = ap.parse_args(argv)+ if a.self_test:+ return self_test()+ if not a.board:+ ap.error("board is required")+ src = Path(a.board)+ # newline="" keeps the file's own line endings (KiCad on Windows writes CRLF).+ with open(src, "r", encoding="utf-8", newline="") as f:+ text = f.read()+ out_dir = Path(a.out) if a.out else src.parent+ out_dir.mkdir(parents=True, exist_ok=True)+ stem = src.name[: -len(".kicad_pcb")] if src.name.endswith(".kicad_pcb") else src.stem+ report = {"source": str(src), "outputs": {}}+ for kind, park in (("unplaced", True), ("unrouted", False)):+ new_text, summary = make(text, park, a.gap, a.margin, a.keep_teardrops, a.strip_zones, a.strip_graphics, a.keep_refs)+ dest = out_dir / ("%s-%s.kicad_pcb" % (stem, kind))+ with open(dest, "w", encoding="utf-8", newline="") as f:+ f.write(new_text)+ parse(new_text) # the output must still be one balanced document+ summary["path"] = str(dest)+ summary["bytes"] = len(new_text.encode("utf-8"))+ report["outputs"][kind] = summary+ r = summary["removed"]+ line = "%s: %d footprints" % (dest.name, summary["footprints"])+ if park:+ p = summary["parking"]+ line += ", %d parked in x %s..%s y %s..%s" % (summary["footprintsParked"], num(p["minX"]), num(p["maxX"]), num(p["minY"]), num(p["maxY"]))+ line += "; removed %d segments, %d vias, %d arcs, %d filled polygons, %d teardrop zones" % (+ r["segments"], r["vias"], r["arcs"], r["filledPolygons"], r["teardropZones"])+ if r.get("zonesStripped") or r.get("graphicsStripped"):+ line += ", %d zones stripped, %d graphics stripped" % (r.get("zonesStripped", 0), r.get("graphicsStripped", 0))+ if r["groupsDropped"] or r["groupMembersDropped"]:+ line += ", %d groups dropped, %d group members dropped" % (r["groupsDropped"], r["groupMembersDropped"])+ print(line)+ o = report["outputs"]["unplaced"]["outline"]+ print("outline bbox: x %s..%s y %s..%s (%s x %s mm); source %d bytes" % (+ num(o["minX"]), num(o["maxX"]), num(o["minY"]), num(o["maxY"]), num(o["maxX"] - o["minX"]), num(o["maxY"] - o["minY"]), len(text.encode("utf-8"))))+ (out_dir / ("%s-placement-fixture.json" % stem)).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")+ return 0+++# ----------------------------------------------------------------------------- self-test++SAMPLE = """(kicad_pcb+\t(version 20260206)+\t(generator "pcbnew")+\t(layers+\t\t(0 "F.Cu" signal)+\t\t(2 "B.Cu" signal)+\t)+\t(footprint "T:R2"+\t\t(layer "F.Cu")+\t\t(uuid "r2")+\t\t(at 120 110 90)+\t\t(property "Reference" "R2"+\t\t\t(at 0 -1.17 90)+\t\t\t(layer "F.SilkS")+\t\t)+\t\t(fp_rect+\t\t\t(start -1 -0.5)+\t\t\t(end 1 0.5)+\t\t\t(layer "F.CrtYd")+\t\t)+\t\t(pad "1" smd rect+\t\t\t(at -0.5 0 90)+\t\t\t(size 0.6 0.5)+\t\t\t(layers "F.Cu")+\t\t\t(net "A")+\t\t)+\t\t(pad "2" smd rect+\t\t\t(at 0.5 0)+\t\t\t(size 0.6 0.5)+\t\t\t(layers "F.Cu")+\t\t\t(net "B")+\t\t)+\t)+\t(footprint "T:R10"+\t\t(layer "B.Cu")+\t\t(uuid "r10")+\t\t(at 130 110)+\t\t(property "Reference" "R10"+\t\t\t(at 0 -1.17 0)+\t\t)+\t\t(pad "1" smd rect+\t\t\t(at -0.5 0)+\t\t\t(size 0.6 0.5)+\t\t\t(layers "B.Cu")+\t\t\t(net "A")+\t\t)+\t)+\t(gr_rect+\t\t(start 100 100)+\t\t(end 150 130)+\t\t(layer "Edge.Cuts")+\t\t(uuid "edge")+\t)+\t(segment+\t\t(start 119.5 110)+\t\t(end 129.5 110)+\t\t(width 0.2)+\t\t(layer "F.Cu")+\t\t(net "A")+\t\t(uuid "seg1")+\t)+\t(via+\t\t(at 125 110)+\t\t(size 0.6)+\t\t(drill 0.3)+\t\t(layers "F.Cu" "B.Cu")+\t\t(net "A")+\t\t(uuid "via1")+\t)+\t(zone+\t\t(net "GND")+\t\t(layer "F.Cu")+\t\t(uuid "z1")+\t\t(polygon+\t\t\t(pts+\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130) (xy 100 130)+\t\t\t)+\t\t)+\t\t(filled_polygon+\t\t\t(layer "F.Cu")+\t\t\t(pts+\t\t\t\t(xy 100 100) (xy 150 100) (xy 150 130)+\t\t\t)+\t\t)+\t)+\t(zone+\t\t(net "A")+\t\t(layer "F.Cu")+\t\t(uuid "td1")+\t\t(attr+\t\t\t(teardrop+\t\t\t\t(type padvia)+\t\t\t)+\t\t)+\t\t(filled_polygon+\t\t\t(layer "F.Cu")+\t\t\t(pts+\t\t\t\t(xy 1 1) (xy 2 2) (xy 3 3)+\t\t\t)+\t\t)+\t)+\t(group ""+\t\t(uuid "g1")+\t\t(members "seg1" "edge")+\t)+\t(group ""+\t\t(uuid "g2")+\t\t(members "seg1" "via1")+\t)+\t(embedded_fonts no)+)+"""+++def self_test():+ unplaced, s = make(SAMPLE, True)+ root = parse(unplaced)+ assert s["removed"] == {"segments": 1, "vias": 1, "arcs": 0, "filledPolygons": 1, "teardropZones": 1, "groupsDropped": 1, "groupMembersDropped": 1}, s+ assert s["footprintsParked"] == 2+ assert s["outline"] == {"minX": 100.0, "minY": 100.0, "maxX": 150.0, "maxY": 130.0}+ assert not root.find_all("segment") and not root.find_all("via")+ zones = root.find_all("zone")+ assert len(zones) == 1 and zones[0].find("uuid").value() == "z1", "user zone kept, teardrop dropped"+ assert not zones[0].find("filled_polygon") and zones[0].find("polygon") is not None+ groups = root.find_all("group")+ assert len(groups) == 1 and [a.value for a in groups[0].find("members").items[1:]] == ["edge"]+ fps = {reference_of(fp): fp for fp in root.find_all("footprint")}+ # R2 sorts before R10; both start at outline.maxX + 5 on the outline's top row.+ r2, r10 = fps["R2"], fps["R10"]+ assert r2.find("at").atom(3) is None, "rotation zeroed"+ assert r2.find("at").number(1) == 156.0 and r2.find("at").number(2) == 100.5, unplaced+ assert r10.find("at").number(1) > r2.find("at").number(1) + 2.0+ assert layer_of(r10) == "B.Cu", "side kept"+ # Child angles stay relative: the 90 absolute became 0, the missing angle became -90.+ pads = r2.find_all("pad")+ assert pads[0].find("at").atom(3) == "0", pads[0].find("at").atom(3)+ assert pads[1].find("at").atom(3) == "-90", pads[1].find("at").atom(3)+ assert r2.find_all("property")[0].find("at").atom(3) == "0"+ assert r10.find_all("pad")[0].find("at").atom(3) is None, "untouched at rotation 0"+ # Everything else is byte for byte the input.+ assert "(generator \"pcbnew\")" in unplaced and "(embedded_fonts no)" in unplaced+ assert unplaced.count("\t(footprint") == 2 and unplaced.endswith(")\n")+ unrouted, s2 = make(SAMPLE, False)+ assert s2["footprintsParked"] == 0 and s2["parking"] is None+ assert "(at 120 110 90)" in unrouted and "(at 130 110)" in unrouted+ assert "(segment" not in unrouted and "(via" not in unrouted and "filled_polygon" not in unrouted+ kept, s3 = make(SAMPLE, False, keep_teardrops=True)+ assert s3["removed"]["teardropZones"] == 0 and kept.count("(zone") == 2+ try:+ make("(kicad_pcb (version 20260206) (footprint \"X\" (at 1 1)))", True)+ raise AssertionError("no outline must refuse")+ except ValueError as e:+ assert "Edge.Cuts" in str(e)+ crlf, s4 = make(SAMPLE.replace("\n", "\r\n"), True)+ assert "\n" not in crlf.replace("\r\n", "") and s4["removed"]["segments"] == 1, "CRLF files keep CRLF and lose no line"+ assert crlf.count("\r\n") == unplaced.count("\n"), (crlf.count("\r\n"), unplaced.count("\n"))+ assert num(1.5) == "1.5" and num(100.0) == "100" and num(-0.0) == "0" and num(0.1234567) == "0.123457"+ assert normalize_deg(270) == -90 and normalize_deg(-270) == 90 and normalize_deg(180) == 180 and normalize_deg(360) == 0+ assert ref_key("C2") < ref_key("C10") < ref_key("R1")+ print("self-test ok")+ return 0+++if __name__ == "__main__":+ sys.exit(main())
reference/python/land_moves.pyadded+46@@ -0,0 +1,46 @@+#!/usr/bin/env python3+"""Land a placement adjustment on the live board: one kicad_move_footprint batch, one native undo step.++Usage: adjust_live.py --target ConfRoomROG --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" --moves moves.json+ [--thread kicad-bridge-esc-routing] [--log adjust-log.json] [--dry-run]++moves.json is {REF: {"x", "y", "rotation"}} (what tools/pack_moves.py writes and tools/move_footprints.py+takes), so the offline fixture and the live board end up identical. The bridge refuses courtyard+overlaps and positions outside the outline and verifies every pose by read-back.+"""+import argparse, json, subprocess, sys, time++ap = argparse.ArgumentParser()+ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--moves", required=True)+ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="adjust-log.json")+ap.add_argument("--dry-run", action="store_true")+a = ap.parse_args()+T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]++def call(verb, args, timeout=240):+ t0 = time.time()+ out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout+ try:+ r = json.loads(out)+ except Exception:+ r = {"status": "parse_error", "raw": out[:400]}+ r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))+ return r++moves = json.load(open(a.moves))+st = call("kicad_placement_state", {"filePath": a.board})+if st.get("status") != "ok":+ print("placement_state failed", json.dumps(st)[:600]); sys.exit(1)+rev = st["revision"]+refs = [{"ref": r, "x": m["x"], "y": m["y"], "rotation": m.get("rotation", 0)} for r, m in moves.items()]+args = {"filePath": a.board, "expectedRevision": rev, "refs": refs}+if a.dry_run:+ args["dryRun"] = True+r = call("kicad_move_footprint", args)+print("move_footprint:", r.get("status"), r.get("errorCode") or "", "revision", str(r.get("revision", ""))[:8], r["_dt"], "s")+if r.get("status") != "ok":+ print(json.dumps(r)[:1500])+v = call("kicad_placement_validate", {"filePath": a.board}) if not a.dry_run else {}+print("validate:", {k: v.get(k) for k in ("status", "overlaps", "outside", "ratsnestMm", "errors")})+json.dump({"start": {k: x for k, x in st.items() if k != "footprints"}, "moves": moves, "reply": r, "validate": v}, open(a.log, "w"), indent=1)+sys.exit(0 if r.get("status") == "ok" else 2)
reference/python/land_pours.pyadded+81@@ -0,0 +1,81 @@+#!/usr/bin/env python3+"""Land a pours plan on the live board through kicad_add_zone, one undo step per group, then measure.++Usage: pours_live.py --target ConfRoomROG --board "C:/.../board.kicad_pcb" --pours pours.json+ [--thread kicad-bridge-esc-routing] [--log pours-log.json] [--dry-run] [--pause 1.0]++The JSON is the tools/add_pours.py shape ({"zones": [...]}); "outline": true zones get the board+outline (inset by "inset" mm) resolved here from the local copy of the board given with --local,+because the verb takes explicit polygons. Zones are grouped by net into one kicad_add_zone call+each (one native undo step per net), lowest priority first. kicad_zone_state {refill:true} is read+before and after, so the log carries the copper kept per layer, the number this round is about.+"""+import argparse, json, subprocess, sys, time, os++ap = argparse.ArgumentParser()+ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--pours", required=True)+ap.add_argument("--local", help="local copy of the board, for outline: true zones")+ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="pours-log.json")+ap.add_argument("--dry-run", action="store_true"); ap.add_argument("--pause", type=float, default=1.0)+a = ap.parse_args()+T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]+++def call(verb, args, timeout=300):+ t0 = time.time()+ out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout+ try:+ r = json.loads(out)+ except Exception:+ r = {"status": "parse_error", "raw": out[:400]}+ r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))+ return r+++spec = json.load(open(a.pours, encoding="utf-8"))+zones = spec["zones"] if isinstance(spec, dict) else spec+outline = None+if any(z.get("outline") for z in zones):+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+ + from escboard import Board, polygon_area+ from pours import inset_polygon+ b = Board(a.local)+ outline = list(b.outline)+ if polygon_area(outline) < 0:+ outline.reverse()+groups = {}+for z in zones:+ zz = {k: v for k, v in z.items() if k not in ("outline", "inset")}+ if z.get("outline"):+ zz["polygon"] = [[round(x, 3), round(y, 3)] for x, y in inset_polygon(outline, float(z.get("inset", 0.5)))]+ groups.setdefault(z["net"], []).append(zz)+order = sorted(groups, key=lambda n: min(z.get("priority", 0) for z in groups[n]))++before = call("kicad_zone_state", {"filePath": a.board, "refill": True})+print("before:", before.get("filledAreaByLayerMm2"), before.get("filledCoverageByLayerPercent"))+st = call("kicad_routing_state", {"filePath": a.board})+rev = st.get("revision")+if not rev:+ print("routing_state failed", json.dumps(st)[:600]); sys.exit(1)+log = {"before": {k: before.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount")}, "calls": []}+t_start = time.time()+for net in order:+ args = {"filePath": a.board, "expectedRevision": rev, "zones": groups[net]}+ if a.dry_run:+ args["dryRun"] = True+ r = call("kicad_add_zone", args)+ ok = r.get("success") is True+ print("%-10s %d zone(s): %s %s ids %s refilled %s %.1fs %s" % (net, len(groups[net]), "ok" if ok else "FAILED", r.get("errorCode") or "", len(r.get("itemIds") or []), r.get("refilled"), r["_dt"], (r.get("postCommitError") or "")[:80]))+ log["calls"].append({"net": net, "zones": groups[net], "reply": {k: v for k, v in r.items() if k not in ("_hint",)}})+ if not ok:+ print(json.dumps(r)[:1500]); break+ rev = r.get("revision") or rev+ time.sleep(a.pause)+after = call("kicad_zone_state", {"filePath": a.board, "refill": True})+print("after:", after.get("filledAreaByLayerMm2"), after.get("filledCoverageByLayerPercent"))+v = call("kicad_routing_validate", {"filePath": a.board}) if not a.dry_run else {}+print("validate:", {k: v.get(k) for k in ("errors", "warnings", "unconnected", "clean", "zonesRefilled")})+log["after"] = {k: after.get(k) for k in ("filledAreaByLayerMm2", "filledCoverageByLayerPercent", "zoneCount", "zones")}+log["validate"] = v; log["wall_seconds"] = round(time.time() - t_start, 1)+json.dump(log, open(a.log, "w"), indent=1)
reference/python/land_route.pyadded+95@@ -0,0 +1,95 @@+#!/usr/bin/env python3+"""Replay a routing plan on the live board through the bridge, one kicad_route_net per entry.++Usage: route_live.py --target arav-rog --board "C:/.../esc-g431-fable-placed-planes.kicad_pcb" \\+ --plan fable-esc-routing-plan.json [--pause 0.6] [--thread "kicad-bridge-esc-routing"] \\+ [--log route-log.json] [--dry-run] [--start N] [--save]++Mirrors demo/placement/fable/place_live.py: kicad_routing_state gives the revision, every+plan entry becomes one kicad_route_net call (one native Undo step, DRC-checked by the bridge+against a snapshot) with expectedRevision from the previous reply, and the reply's revision+feeds the next call. Every reply is logged with timestamps. A drc_rejected or stale_board+reply stops the run and is printed in full; kicad_routing_validate closes the run and the+whole log is written as JSON. --dry-run sends every call with dryRun:true (nothing lands, the+revision never changes). --start N skips the first N entries (resume after a stop).+"""+import argparse+import json+import subprocess+import sys+import time++ap = argparse.ArgumentParser()+ap.add_argument("--target", required=True); ap.add_argument("--board", required=True); ap.add_argument("--plan", required=True)+ap.add_argument("--pause", type=float, default=0.6)+ap.add_argument("--thread", default="kicad-bridge-esc-routing"); ap.add_argument("--log", default="route-log.json")+ap.add_argument("--dry-run", action="store_true")+ap.add_argument("--start", type=int, default=0, help="skip the first N plan entries")+ap.add_argument("--save", action="store_true", help="ask the bridge to save the board after the last entry")+a = ap.parse_args()+T = ["adom-bridge", "--ai-thread", a.thread, "--target", a.target]+++def call(verb, args, timeout=240):+ t0 = time.time()+ out = subprocess.run(T + [verb, json.dumps(args)], capture_output=True, text=True, timeout=timeout).stdout+ try:+ r = json.loads(out)+ except Exception:+ r = {"status": "parse_error", "raw": out[:400]}+ r["_t0"] = t0; r["_dt"] = round(time.time() - t0, 2); r["_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(t0))+ return r+++plan = json.load(open(a.plan, encoding="utf-8"))+entries = plan["nets"] if isinstance(plan, dict) else plan+entries = entries[a.start:]++st = call("kicad_routing_state", {"filePath": a.board})+if st.get("success") is False or st.get("status") == "error" or not st.get("revision"):+ print("routing_state failed", json.dumps(st)[:800]); sys.exit(1)+rev = st["revision"]+print(f"start: revision {rev[:8]}, {len(st.get('pads', []))} pads, {len(st.get('segments', []))} segments, {len(st.get('vias', []))} vias on the board, "+ f"{len(st.get('netsRemaining', []))} nets remaining; {len(entries)} plan entries{' (dry run)' if a.dry_run else ''}")+log = {"start": {k: v for k, v in st.items() if k not in ("pads",)}, "plan": a.plan, "board": a.board, "target": a.target,+ "dryRun": a.dry_run, "entries": []}+t_start = time.time()+ok_count = 0+for i, e in enumerate(entries, 1):+ args = {"filePath": a.board, "expectedRevision": rev, "net": e["net"], "width": e["width"]}+ for k in ("viaSize", "viaDrill", "layer"):+ if k in e:+ args[k] = e[k]+ if "paths" in e:+ args["paths"] = e["paths"]+ else:+ args["points"] = e["points"]+ if a.dry_run:+ args["dryRun"] = True+ if a.save and i == len(entries) and not a.dry_run:+ args["save"] = True+ r = call("kicad_route_net", args)+ ok = r.get("success", True) is not False and r.get("status") != "error" and r.get("status") != "parse_error"+ code = r.get("errorCode") or ""+ drc = r.get("drc") or {}+ n_items = len(r.get("itemIds") or []) if not a.dry_run else len(r.get("segments") or []) + len(r.get("vias") or [])+ paths = len(e.get("paths") or [1])+ print(f"{i}/{len(entries)} {e['net']:<26} w{e['width']:<5} {paths} path(s): {'ok' if ok else 'FAIL'} {code} "+ f"items {n_items} drc errors {drc.get('errors')} unconnected {drc.get('unconnected')} rev {str(r.get('revision', ''))[:8]} {r['_dt']}s")+ log["entries"].append({"index": i + a.start, "net": e["net"], "width": e["width"], "reply": {k: v for k, v in r.items() if k not in ("_hint", "_timeoutHint")}})+ if not ok:+ print(json.dumps(r)[:2000])+ if code in ("drc_rejected", "stale_board") or not a.dry_run:+ break+ else:+ ok_count += 1+ if not a.dry_run:+ rev = r.get("revision") or rev+ time.sleep(a.pause)+v = call("kicad_routing_validate", {"filePath": a.board}, timeout=600)+log["validate"] = {k: val for k, val in v.items() if k not in ("_hint",)}+log["wall_seconds"] = round(time.time() - t_start, 1)+log["ok_entries"] = ok_count+print("validate:", {k: v.get(k) for k in ("status", "success", "errors", "warnings", "unconnected", "clean", "stale", "zonesRefilled")}, "wall", log["wall_seconds"], "s")+json.dump(log, open(a.log, "w"), indent=1)+print("log:", a.log)
reference/python/movefp.pyadded+115@@ -0,0 +1,115 @@+#!/usr/bin/env python3+"""Move footprints in a .kicad_pcb file offline (the placement-adjustment step of the place-route loop).++ python3 tools/move_footprints.py BOARD.kicad_pcb --out MOVED.kicad_pcb --move REF=x,y[,rot] [--move ...]+ [--json moves.json] [--check]++A footprint's pads, courtyard and text ride along with its `(at x y rot)`, so moving the anchor moves+the part. --json takes {"REF": {"x":..,"y":..,"rotation":..}, ...} (the shape kicad_move_footprint takes).+--check reports courtyard-box overlaps and parts outside the Edge.Cuts bbox after the move, from the+file's own geometry, so the plan can be gated before it goes anywhere near the live board (the bridge's+kicad_move_footprint does the exact check when the plan is landed).+"""+import argparse, json, math, re, sys++def footprint_blocks(text):+ """Yield (start, end, ref) for every top-level (footprint ...) block."""+ for m in re.finditer(r'\n\t\(footprint ', text):+ s = m.start() + 1+ depth = 0; i = s+ while True:+ c = text[i]+ if c == '(':+ depth += 1+ elif c == ')':+ depth -= 1+ if depth == 0:+ break+ i += 1+ block = text[s:i + 1]+ r = re.search(r'\(property "Reference" "([^"]+)"', block)+ yield s, i + 1, (r.group(1) if r else None)++def move_block(block, x, y, rot):+ m = re.search(r'\n\t\t\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', block)+ if not m:+ raise SystemExit("no footprint (at ...) found")+ ox, oy, orot = float(m.group(1)), float(m.group(2)), float(m.group(3) or 0)+ nrot = orot if rot is None else rot+ drot = nrot - orot+ new = "\n\t\t(at %s %s%s)" % (fmt(x), fmt(y), "" if nrot == 0 else " " + fmt(nrot))+ block = block[:m.start()] + new + block[m.end():]+ if abs(drot) > 1e-9:+ # pads and text carry the footprint rotation in their own (at ...): rotate those too+ def rot_at(mm):+ a = float(mm.group(4) or 0) + drot+ a = ((a + 180) % 360) - 180+ return "(at %s %s%s)" % (mm.group(2), mm.group(3), "" if abs(a) < 1e-9 else " " + fmt(a))+ # only the children (pads, properties, graphics: three tabs or deeper), never the footprint's own (at ...)+ block = re.sub(r'(\n\t\t\t+)\(at (-?[\d.]+) (-?[\d.]+)(?: (-?[\d.]+))?\)', lambda mm: mm.group(1) + rot_at(mm), block)+ return block, (ox, oy, orot)++def fmt(v):+ s = "%.6f" % v+ s = s.rstrip("0").rstrip(".")+ return s if s not in ("", "-0") else "0"++def main():+ ap = argparse.ArgumentParser()+ ap.add_argument("board"); ap.add_argument("--out", required=True)+ ap.add_argument("--move", action="append", default=[], help="REF=x,y[,rot]")+ ap.add_argument("--json", help="moves as {REF: {x, y, rotation}}")+ ap.add_argument("--check", action="store_true")+ a = ap.parse_args()+ moves = {}+ if a.json:+ for ref, m in json.load(open(a.json)).items():+ moves[ref] = (float(m["x"]), float(m["y"]), None if m.get("rotation") is None else float(m["rotation"]))+ for mv in a.move:+ ref, rest = mv.split("=", 1)+ parts = [float(v) for v in rest.split(",")]+ moves[ref] = (parts[0], parts[1], parts[2] if len(parts) > 2 else None)+ text = open(a.board, encoding="utf-8").read()+ out = []; last = 0; done = {}+ for s, e, ref in footprint_blocks(text):+ if ref in moves:+ block, old = move_block(text[s:e], *moves[ref])+ out.append(text[last:s]); out.append(block); last = e+ done[ref] = old+ out.append(text[last:])+ text = "".join(out)+ missing = sorted(set(moves) - set(done))+ if missing:+ raise SystemExit("not found: %s" % missing)+ open(a.out, "w", encoding="utf-8").write(text)+ for ref, (ox, oy, orot) in done.items():+ x, y, rot = moves[ref]+ print("moved %-6s (%.2f, %.2f, %g) -> (%.2f, %.2f, %s)" % (ref, ox, oy, orot, x, y, "same" if rot is None else "%g" % rot))+ if a.check:+ sys.path.insert(0, __import__("os").path.dirname(__import__("os").path.abspath(__file__)))+ from escboard import Board+ b = Board(a.out)+ boxes = {}+ for p in b.pads:+ xs = [q[0] for poly in p.polys for q in poly]; ys = [q[1] for poly in p.polys for q in poly]+ bx = boxes.setdefault(p.ref, [1e9, 1e9, -1e9, -1e9])+ bx[0] = min(bx[0], min(xs)); bx[1] = min(bx[1], min(ys)); bx[2] = max(bx[2], max(xs)); bx[3] = max(bx[3], max(ys))+ gap = 0.25 # courtyard-ish gap between pad boxes+ refs = sorted(boxes)+ bad = 0+ for i, r1 in enumerate(refs):+ for r2 in refs[i + 1:]:+ A, B = boxes[r1], boxes[r2]+ if A[0] - gap < B[2] and B[0] - gap < A[2] and A[1] - gap < B[3] and B[1] - gap < A[3]:+ if r1 in moves or r2 in moves:+ print("OVERLAP (pad boxes within %.2f mm): %s %s" % (gap, r1, r2)); bad += 1+ (x0, y0, x1, y1) = b.bbox+ for ref in moves:+ A = boxes.get(ref)+ if A and (A[0] < x0 + 0.5 or A[1] < y0 + 0.5 or A[2] > x1 - 0.5 or A[3] > y1 - 0.5):+ print("OUTSIDE: %s" % ref); bad += 1+ print("check: %d problems" % bad)+ return 2 if bad else 0++if __name__ == "__main__":+ sys.exit(main())
reference/python/pack.pyadded+136@@ -0,0 +1,136 @@+#!/usr/bin/env python3+"""Find legal spots for a few parts near where they should be (the placement-adjustment helper).++ python3 tools/pack_moves.py BOARD.kicad_pcb --wish wishes.json --out moves.json [--gap 0.4]++wishes.json: an ordered object {REF: {"x":.., "y":.., "rot":[0,90], "radius": 4.0, "keepout": [[x0,y0,x1,y1,margin], ...]}}.+For each part in order: candidates on a 0.1 mm grid around the wish, nearest first, each rotation in the+given order; the first whose courtyard clears every other part's courtyard (pad box + 0.25 mm when a part has none) by `gap` (or the part's own "gap"), stays `edge` inside the outline+bbox and avoids the keepout rectangles (grown by their margin) wins. Prints what it found; the output is+the shape kicad_move_footprint and tools/move_footprints.py take.+"""+import argparse, json, math, os, re, sys+from escboard import Board, rot_kicad # noqa: E402++def courtyard_local(text, ref):+ """Local (unrotated, anchor-relative) F.CrtYd points of a footprint from the file text, or None."""+ i = text.find('(property "Reference" "%s"' % ref)+ if i < 0:+ return None+ s = text.rfind("\n\t(footprint ", 0, i)+ depth = 0; j = s + 1+ while True:+ c = text[j]+ if c == "(":+ depth += 1+ elif c == ")":+ depth -= 1+ if depth == 0:+ break+ j += 1+ block = text[s:j + 1]+ pts = []+ for m in re.finditer(r"\((fp_line|fp_rect|fp_poly|fp_circle|fp_arc)\b(.*?)\n\t\t\)", block, re.S):+ body = m.group(2)+ if '(layer "F.CrtYd")' not in body and '(layer "B.CrtYd")' not in body:+ continue+ for xm in re.finditer(r"\((?:start|end|xy|center|mid) (-?[\d.]+) (-?[\d.]+)\)", body):+ pts.append((float(xm.group(1)), float(xm.group(2))))+ if m.group(1) == "fp_circle":+ c = re.search(r"\(center (-?[\d.]+) (-?[\d.]+)\)", body); e = re.search(r"\(end (-?[\d.]+) (-?[\d.]+)\)", body)+ if c and e:+ r = math.hypot(float(e.group(1)) - float(c.group(1)), float(e.group(2)) - float(c.group(2)))+ cx, cy = float(c.group(1)), float(c.group(2))+ pts += [(cx - r, cy - r), (cx + r, cy + r)]+ return pts or None+++def pad_boxes(board):+ boxes = {}+ for p in board.pads:+ xs = [q[0] for poly in p.polys for q in poly]; ys = [q[1] for poly in p.polys for q in poly]+ bx = boxes.setdefault(p.ref, [1e9, 1e9, -1e9, -1e9])+ bx[0] = min(bx[0], min(xs)); bx[1] = min(bx[1], min(ys)); bx[2] = max(bx[2], max(xs)); bx[3] = max(bx[3], max(ys))+ return boxes++def rel_box(board, ref):+ """The part's pad box relative to its anchor at rotation 0 (undo the file rotation)."""+ fp = board.footprints[ref]+ pts = []+ for p in board.pads:+ if p.ref != ref:+ continue+ for poly in p.polys:+ for (x, y) in poly:+ pts.append(rot_kicad(x - fp["x"], y - fp["y"], -(fp["rot"] or 0)))+ return pts++def placed_box(pts, x, y, rot):+ rp = [rot_kicad(dx, dy, rot) for dx, dy in pts]+ xs = [x + a for a, _ in rp]; ys = [y + c for _, c in rp]+ return [min(xs), min(ys), max(xs), max(ys)]++def clear(box, others, gap):+ for o in others:+ if box[0] - gap < o[2] and o[0] - gap < box[2] and box[1] - gap < o[3] and o[1] - gap < box[3]:+ return False+ return True++def main():+ ap = argparse.ArgumentParser()+ ap.add_argument("board"); ap.add_argument("--wish", required=True); ap.add_argument("--out", required=True)+ ap.add_argument("--gap", type=float, default=0.1); ap.add_argument("--edge", type=float, default=1.0)+ a = ap.parse_args()+ b = Board(a.board)+ text = open(a.board, encoding="utf-8").read()+ boxes = pad_boxes(b)+ crt = {}+ for ref, fp in b.footprints.items():+ pts = courtyard_local(text, ref)+ if pts:+ crt[ref] = pts+ boxes[ref] = placed_box(pts, fp["x"], fp["y"], fp["rot"] or 0)+ else:+ bx = boxes.get(ref)+ if bx:+ boxes[ref] = [bx[0] - 0.25, bx[1] - 0.25, bx[2] + 0.25, bx[3] + 0.25]+ wishes = json.load(open(a.wish))+ x0, y0, x1, y1 = b.bbox+ out = {}+ for ref, w in wishes.items():+ pts = crt.get(ref) or [(dx - 0.25, dy - 0.25) for dx, dy in rel_box(b, ref)] + [(dx + 0.25, dy + 0.25) for dx, dy in rel_box(b, ref)]+ others = [bx for r, bx in boxes.items() if r != ref]+ keep = w.get("keepout", [])+ best = None+ R = w.get("radius", 4.0)+ cands = []+ n = int(R / 0.1)+ for i in range(-n, n + 1):+ for j in range(-n, n + 1):+ d = math.hypot(i, j) * 0.1+ if d <= R:+ cands.append((d, round(w["x"] + i * 0.1, 2), round(w["y"] + j * 0.1, 2)))+ cands.sort()+ for d, x, y in cands:+ for rot in w.get("rot", [0]):+ box = placed_box(pts, x, y, rot)+ if box[0] < x0 + a.edge or box[1] < y0 + a.edge or box[2] > x1 - a.edge or box[3] > y1 - a.edge:+ continue+ if not clear(box, others, w.get("gap", a.gap)):+ continue+ if any(box[0] - m < kx1 and kx0 - m < box[2] and box[1] - m < ky1 and ky0 - m < box[3] for (kx0, ky0, kx1, ky1, m) in keep):+ continue+ best = (x, y, rot, box, d)+ break+ if best:+ break+ if best is None:+ print("%-5s NO SPOT within %.1f mm of (%.1f, %.1f)" % (ref, R, w["x"], w["y"])); continue+ x, y, rot, box, d = best+ boxes[ref] = box+ out[ref] = {"x": x, "y": y, "rotation": rot}+ print("%-5s -> (%.2f, %.2f, %g) %.2f mm from the wish box x %.2f..%.2f y %.2f..%.2f" % (ref, x, y, rot, d, box[0], box[2], box[1], box[3]))+ json.dump(out, open(a.out, "w"), indent=1)++if __name__ == "__main__":+ main()
reference/python/planes.pyadded+121@@ -0,0 +1,121 @@+#!/usr/bin/env python3+"""Add full-outline copper planes to a copy of a KiCad 10 board.++ python3 tools/add_planes.py BOARD.kicad_pcb [--out FILE] [--plane GND:In1.Cu] [--plane +3V3:In2.Cu]+ [--clearance 0.2] [--min-thickness 0.25] [--drc]++Default planes: GND on In1.Cu and +3V3 on In2.Cu. Each plane is one zone whose polygon is the+Edge.Cuts outline (lines as they are, arcs sampled to short chords), thermal-relief pad+connection with the given clearance, priority 0 (KiCad's default, so the line is not written),+fill enabled, `(filled_areas_thickness no)` and NO filled_polygon: KiCad fills it on load or+when a DRC refills zones. The net is written the way this board writes nets: KiCad 10 boards+carry `(net "NAME")` on every pad and no net table, so the zone gets `(net "NAME")`; a board+with a `(net N "NAME")` table gets `(net N)` plus `(net_name "NAME")`.++The zones are spliced in before the closing paren; every other byte of the file is untouched.+--drc runs `service-kicad pcb drc` (the shared headless KiCad, which refills zones) on the+output and prints the error, warning and unconnected counts.+"""+import argparse+import json+import os+import subprocess+import sys+import tempfile+import uuid+from pathlib import Path++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from fixture import num # noqa: E402+from escboard import Board, polygon_area # noqa: E402+++def zone_sexpr(board, net_name, layer, outline, clearance, min_thickness):+ if board.net_format == "number":+ number = next((n for n, name in board.net_table.items() if name == net_name), None)+ if number is None:+ raise KeyError("net %r is not in the net table" % net_name)+ net_lines = "\t\t(net %d)\n\t\t(net_name \"%s\")\n" % (number, net_name.replace('"', '\\"'))+ else:+ net_lines = board.net_ref_sexpr(net_name)+ pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in outline)+ return (+ "\t(zone\n"+ + net_lines+ + "\t\t(layer \"%s\")\n" % layer+ + "\t\t(uuid \"%s\")\n" % uuid.uuid4()+ + "\t\t(name \"%s plane\")\n" % net_name.replace('"', '\\"')+ + "\t\t(hatch edge 0.5)\n"+ + "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n" % num(clearance)+ + "\t\t(min_thickness %s)\n" % num(min_thickness)+ + "\t\t(filled_areas_thickness no)\n"+ + "\t\t(fill yes\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n"+ + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts+ + "\t)"+ )+++def splice(text, blocks):+ """Insert blocks before the closing paren of the kicad_pcb list, like the bridge's pcb::splice."""+ trimmed = text.rstrip()+ idx = trimmed.rfind(")")+ body = text[:idx].rstrip()+ nl = "\r\n" if "\r\n" in text[:2000] else "\n"+ out = body + nl + nl.join(b.replace("\n", nl) for b in blocks) + nl + ")" + nl+ return out+++def run_drc(path):+ """service-kicad pcb drc as JSON: (errors, warnings, unconnected, report dict)."""+ with tempfile.TemporaryDirectory() as tmp:+ report = Path(tmp) / "drc.json"+ proc = subprocess.run(["service-kicad", "pcb", "drc", "--format", "json", "--out", str(report), str(path)],+ capture_output=True, text=True, timeout=600)+ if proc.returncode or not report.is_file():+ raise RuntimeError("service-kicad drc failed: %s %s" % (proc.stdout[-400:], proc.stderr[-400:]))+ data = json.loads(report.read_text(encoding="utf-8"))+ viol = data.get("violations", [])+ errors = sum(1 for v in viol if v.get("severity") == "error")+ warnings = sum(1 for v in viol if v.get("severity") == "warning")+ return errors, warnings, len(data.get("unconnected_items", [])), data+++def main(argv=None):+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board")+ ap.add_argument("--out", help="output path (default: <board>-planes.kicad_pcb beside the input)")+ ap.add_argument("--plane", action="append", help="NET:LAYER, repeatable (default GND:In1.Cu and +3V3:In2.Cu)")+ ap.add_argument("--clearance", type=float, default=0.2)+ ap.add_argument("--min-thickness", type=float, default=0.25)+ ap.add_argument("--drc", action="store_true", help="run service-kicad pcb drc on the output and print the counts")+ a = ap.parse_args(argv)+ planes = [p.split(":", 1) for p in (a.plane or ["GND:In1.Cu", "+3V3:In2.Cu"])]+ src = Path(a.board)+ board = Board(str(src))+ for net, layer in planes:+ if net not in board.nets:+ raise SystemExit("net %r has no pads on this board" % net)+ if layer not in board.copper_layers:+ raise SystemExit("layer %r is not a copper layer of this board (%s)" % (layer, board.copper_layers))+ outline = list(board.outline)+ if polygon_area(outline) < 0:+ outline.reverse()+ blocks = [zone_sexpr(board, net, layer, outline, a.clearance, a.min_thickness) for net, layer in planes]+ out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-planes.kicad_pcb")+ text = splice(board.text, blocks)+ out.write_text(text, encoding="utf-8", newline="")+ Board(str(out)) # must still parse as one balanced document+ print("%s: %d zones (%s), %d outline points, net format %s, %d bytes" % (+ out, len(planes), ", ".join("%s on %s" % (n, l) for n, l in planes), len(outline), board.net_format, len(text.encode("utf-8"))))+ if a.drc:+ errors, warnings, unconnected, data = run_drc(out)+ by_type = {}+ for v in data.get("violations", []):+ by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1+ print("drc: %d errors, %d warnings, %d unconnected items; by type %s (KiCad %s)" % (+ errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version")))+ return 0+++if __name__ == "__main__":+ sys.exit(main())
reference/python/pourplan.pyadded+157@@ -0,0 +1,157 @@+"""A pours plan from the spec, not from the AI's memory of one board.++spec.pours is a list of entries; each becomes zones in the kicad_add_zone shape:+ {"net": "GND", "outline": true, "layers": ["F.Cu","B.Cu"], "priority": 0, "connection": "thermal",+ "thermalGap": 0.3, "thermalBridge": 0.35}+ {"net": "+VBAT", "around": ["MC1","C2","C3","D1"], "margin": 1.0, "layers": ["F.Cu","B.Cu"], "priority": 2,+ "connection": "solid", "exclude": ["U4"]} # the box around those parts' pads, grown by margin,+ # clipped so it does not reach the excluded parts' boxes+ {"net": "/DRV_SHA", "around": ["Q1.1","Q2.5","MC5"], "margin": 0.6, "layers": ["F.Cu"], "priority": 3, "connection": "solid"}+ {"net": "+VBAT", "polygon": [[x,y],...], "layers": ["B.Cu"], "priority": 2} # explicit+spec.solidAt: ["C45.2"] pads that get a small solid patch (starved thermal reliefs)+spec.thermalVias: [{"pad": "Q1.5", "net": "+VBAT", "count": 2, "size": 0.8, "drill": 0.4}]+spec.kelvin: {"U6.3": "R32.2", ...} taps get pour keepouts along the routed tap (from the routing plan's kelvin entries)+"""+import json+import math+import os+import sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from escboard import Board # noqa: E402+++def pad_box(pads):+ xs = [q[0] for p in pads for poly in p.polys for q in poly]+ ys = [q[1] for p in pads for poly in p.polys for q in poly]+ return [min(xs), min(ys), max(xs), max(ys)]+++def rect(x0, y0, x1, y1):+ return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]+++def select(board, keys):+ out = []+ for k in keys:+ if "." in k:+ ref, name = k.split(".", 1)+ out += [p for p in board.pads if p.ref == ref and p.name == name]+ else:+ out += [p for p in board.pads if p.ref == k]+ return out+++def build(board, spec, plan=None):+ x0, y0, x1, y1 = board.bbox+ edge = float(spec.get("edgeClearance", 0.5))+ zones = []+ for e in spec.get("pours", []):+ z = {k: v for k, v in e.items() if k not in ("around", "margin", "exclude", "polygon", "outline", "inset")}+ z.setdefault("clearance", spec.get("clearance", 0.2)); z.setdefault("minThickness", 0.25)+ if e.get("outline"):+ z["outline"] = True; z["inset"] = float(e.get("inset", edge))+ elif e.get("polygon"):+ z["polygon"] = e["polygon"]+ elif e.get("around"):+ pads = select(board, e["around"])+ if not pads:+ raise SystemExit("pour %s: none of %s found on the board" % (e.get("net"), e["around"]))+ m = float(e.get("margin", 0.8))+ bx = pad_box(pads)+ bx = [max(x0 + edge, bx[0] - m), max(y0 + edge, bx[1] - m), min(x1 - edge, bx[2] + m), min(y1 - edge, bx[3] + m)]+ for ex in e.get("exclude", []):+ xb = pad_box(select(board, [ex]))+ # clip the pour box away from the excluded part on the side where they overlap least+ if not (xb[2] < bx[0] or xb[0] > bx[2] or xb[3] < bx[1] or xb[1] > bx[3]):+ cuts = [("x1", xb[0] - 1.0, bx[2] - (xb[0] - 1.0)), ("x0", xb[2] + 1.0, (xb[2] + 1.0) - bx[0]), ("y1", xb[1] - 1.0, bx[3] - (xb[1] - 1.0)), ("y0", xb[3] + 1.0, (xb[3] + 1.0) - bx[1])]+ side, val, _ = min(cuts, key=lambda c: c[2])+ {"x0": lambda: bx.__setitem__(0, val), "y0": lambda: bx.__setitem__(1, val), "x1": lambda: bx.__setitem__(2, val), "y1": lambda: bx.__setitem__(3, val)}[side]()+ z["polygon"] = rect(*bx)+ else:+ raise SystemExit("pour %s needs outline, polygon or around" % e.get("net"))+ z.setdefault("name", "%s pour" % z.get("net"))+ zones.append(z)+ for key in spec.get("solidAt", []):+ pads = select(board, [key])+ if pads:+ p = pads[0]+ zones.append({"net": p.net, "layers": ["F.Cu" if "F.Cu" in p.layers else "B.Cu"], "polygon": rect(p.x - 0.9, p.y - 0.9, p.x + 0.9, p.y + 0.9), "priority": 1, "connection": "solid",+ "clearance": spec.get("clearance", 0.2), "minThickness": 0.25, "name": "%s solid joint at %s" % (p.net, key)})+ vias = []+ blockers = []+ if plan:+ for e in plan.get("nets", []):+ for path in e.get("paths", []):+ prev = None+ for w in path:+ if isinstance(w, str):+ prev = None; continue+ if isinstance(w, dict) and "x" not in w:+ continue+ pt = (w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1])+ if prev is not None:+ blockers.append((prev, pt, e["net"], float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.45))+ prev = pt++ def clear(x, y, net):+ for (ax, ay), (bx, by), n, need in blockers:+ if n == net:+ continue+ dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy+ t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))+ if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:+ return False+ return True++ for tv in spec.get("thermalVias", []):+ pads = select(board, [tv["pad"]])+ if not pads:+ continue+ p = pads[0]; net = tv.get("net", p.net); n = int(tv.get("count", 2)); placed = 0+ for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)):+ if placed >= n:+ break+ x, y = round(p.x + dx, 2), round(p.y + dy, 2)+ if abs(dx) < p.half_long - 0.4 and abs(dy) < p.half_short - 0.4 or (dx, dy) == (0.0, 0.0):+ if clear(x, y, net):+ vias.append({"net": net, "width": 0.5, "viaSize": tv.get("size", 0.8), "viaDrill": tv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]}); placed += 1+ for sv in spec.get("stitchVias", []):+ x, y = sv["x"], sv["y"]+ if clear(x, y, sv["net"]):+ vias.append({"net": sv["net"], "width": 0.5, "viaSize": sv.get("size", 0.8), "viaDrill": sv.get("drill", 0.4), "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]})+ keepouts = 0+ if plan and spec.get("kelvin"):+ for e in plan.get("nets", []):+ if not e.get("kelvin"):+ continue+ for path in e["paths"]:+ pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]+ for (ax, ay), (bx, by) in zip(pts, pts[1:]):+ dx, dy = bx - ax, by - ay; n = math.hypot(dx, dy)+ if n < 0.05:+ continue+ ux, uy = dx / n, dy / n; w = float(e.get("width", 0.25)) / 2.0 + 0.2 + 0.1+ poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)], [round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]+ zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True}, "name": "Kelvin tap keepout %s" % e["net"]})+ keepouts += 1+ return zones, vias, keepouts+++def main():+ import argparse+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board"); ap.add_argument("--spec", required=True); ap.add_argument("--plan"); ap.add_argument("--out", required=True); ap.add_argument("--vias")+ a = ap.parse_args()+ board = Board(a.board); spec = json.load(open(a.spec)); plan = json.load(open(a.plan)) if a.plan else None+ zones, vias, keepouts = build(board, spec, plan)+ json.dump({"zones": zones}, open(a.out, "w"), indent=1)+ if a.vias:+ json.dump({"engine": "adom-boardflow pour plan (thermal and stitching vias)", "nets": vias}, open(a.vias, "w"), indent=1)+ print("%d zones (%d keepouts), %d vias -> %s" % (len(zones), keepouts, len(vias), a.out))+ for z in zones:+ print(" %-10s %-12s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %s %s %s" % (z.get("priority", 0), z.get("connection", "thermal"), "outline" if z.get("outline") else "%d pts" % len(z["polygon"]))))+++if __name__ == "__main__":+ main()
reference/python/pours.pyadded+167@@ -0,0 +1,167 @@+#!/usr/bin/env python3+"""Add copper pours (zones) to a copy of a KiCad board offline, from the same JSON kicad_add_zone takes.++ python3 tools/add_pours.py BOARD.kicad_pcb --pours pours.json [--out FILE] [--drc] [--baseline BOARD.kicad_pcb]++pours.json is {"zones": [ {"net": "+VBAT", "layers": ["F.Cu", "B.Cu"], "polygon": [[x, y], ...] | "outline": true,+"priority": 2, "clearance": 0.2, "minThickness": 0.25, "connection": "thermal" | "solid",+"thermalGap": 0.5, "thermalBridge": 0.5, "name": "..."}, ... ]}. Coordinates are mm, +y down, board frame.+"outline": true uses the Edge.Cuts outline (arcs sampled) inset by the edge clearance; "inset": mm sets it.++The zones are written the way KiCad stores them (no filled polygons: KiCad fills them on load and the+shared DRC refills them), spliced before the closing paren; every other byte is untouched. --drc runs+service-kicad's DRC on the result and, with --baseline, reports NEW errors against the unrouted board.+This is the offline half of the kicad-copper-pours skill: gate the pours here, then land the same JSON+live with kicad_add_zone.+"""+import argparse+import json+import os+import sys+import uuid+from pathlib import Path++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from fixture import num # noqa: E402+from escboard import Board, polygon_area # noqa: E402+from planes import splice, run_drc # noqa: E402+++def inset_polygon(poly, d):+ """Shrink a convex-ish outline by d toward its centroid (good enough for a rectangle with rounded corners)."""+ if d <= 0:+ return list(poly)+ cx = sum(p[0] for p in poly) / len(poly)+ cy = sum(p[1] for p in poly) / len(poly)+ out = []+ for (x, y) in poly:+ vx, vy = x - cx, y - cy+ n = (vx * vx + vy * vy) ** 0.5 or 1.0+ out.append((x - vx / n * d, y - vy / n * d))+ return out+++def keepout_sexpr(board, z):+ """A rule area: no net, the layers, the keepout flags (copperPour by default), the outline."""+ k = z.get("keepout") or {"copperPour": True}+ layers = z.get("layers") or [z["layer"]]+ layer_line = "\t\t(layer \"%s\")\n" % layers[0] if len(layers) == 1 else "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)+ a = lambda b: "not_allowed" if b else "allowed"+ pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in z["_polygon"])+ return (+ "\t(zone\n"+ + ("\t\t(net 0)\n\t\t(net_name \"\")\n" if board.net_format == "number" else "")+ + layer_line+ + "\t\t(uuid \"%s\")\n" % uuid.uuid4()+ + "\t\t(name \"%s\")\n" % z.get("name", "keepout").replace('"', '\\"')+ + "\t\t(hatch edge 0.5)\n"+ + "\t\t(keepout\n\t\t\t(tracks %s)\n\t\t\t(vias %s)\n\t\t\t(pads %s)\n\t\t\t(copperpour %s)\n\t\t\t(footprints %s)\n\t\t)\n" % (a(k.get("tracks")), a(k.get("vias")), a(k.get("pads")), a(k.get("copperPour")), a(k.get("footprints")))+ + "\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n"+ + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts+ + "\t)"+ )+++def zone_sexpr(board, z):+ if z.get("type") == "keepout" or z.get("keepout"):+ return keepout_sexpr(board, z)+ net_name = z["net"]+ if board.net_format == "number":+ number = next((n for n, name in board.net_table.items() if name == net_name), None)+ if number is None:+ raise KeyError("net %r is not in the net table" % net_name)+ net_lines = "\t\t(net %d)\n\t\t(net_name \"%s\")\n" % (number, net_name.replace('"', '\\"'))+ else:+ net_lines = board.net_ref_sexpr(net_name)+ layers = z.get("layers") or [z["layer"]]+ if len(layers) == 1:+ layer_line = "\t\t(layer \"%s\")\n" % layers[0]+ else:+ layer_line = "\t\t(layers %s)\n" % " ".join('"%s"' % l for l in layers)+ poly = z["_polygon"]+ pts = " ".join("(xy %s %s)" % (num(x), num(y)) for x, y in poly)+ solid = z.get("connection", "thermal") in ("solid", "full")+ prio = int(z.get("priority", 0) or 0)+ return (+ "\t(zone\n"+ + net_lines+ + layer_line+ + "\t\t(uuid \"%s\")\n" % uuid.uuid4()+ + "\t\t(name \"%s\")\n" % z.get("name", "%s pour" % net_name).replace('"', '\\"')+ + "\t\t(hatch edge 0.5)\n"+ + ("\t\t(priority %d)\n" % prio if prio > 0 else "")+ + ("\t\t(connect_pads yes\n\t\t\t(clearance %s)\n\t\t)\n" if solid else "\t\t(connect_pads\n\t\t\t(clearance %s)\n\t\t)\n") % num(float(z.get("clearance", 0.2)))+ + "\t\t(min_thickness %s)\n" % num(float(z.get("minThickness", 0.25)))+ + "\t\t(filled_areas_thickness no)\n"+ + "\t\t(fill yes\n\t\t\t(thermal_gap %s)\n\t\t\t(thermal_bridge_width %s)\n\t\t)\n" % (num(float(z.get("thermalGap", 0.5))), num(float(z.get("thermalBridge", 0.5))))+ + "\t\t(polygon\n\t\t\t(pts\n\t\t\t\t%s\n\t\t\t)\n\t\t)\n" % pts+ + "\t)"+ )+++def main(argv=None):+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board")+ ap.add_argument("--pours", required=True, help="JSON: {zones: [...]} (the kicad_add_zone shape)")+ ap.add_argument("--out")+ ap.add_argument("--drc", action="store_true")+ ap.add_argument("--baseline", help="board whose DRC errors are inherited (default: the input board)")+ ap.add_argument("--edge", type=float, default=0.5, help="edge clearance used for outline: true")+ a = ap.parse_args(argv)+ src = Path(a.board)+ board = Board(str(src))+ spec = json.load(open(a.pours, encoding="utf-8"))+ zones = spec["zones"] if isinstance(spec, dict) else spec+ outline = list(board.outline)+ if polygon_area(outline) < 0:+ outline.reverse()+ blocks = []+ summary = []+ for z in zones:+ keep = z.get("type") == "keepout" or bool(z.get("keepout"))+ if not keep and z["net"] not in board.nets:+ raise SystemExit("net %r has no pads on this board" % z["net"])+ for l in (z.get("layers") or [z["layer"]]):+ if l not in board.copper_layers:+ raise SystemExit("layer %r is not a copper layer (%s)" % (l, board.copper_layers))+ if z.get("outline"):+ poly = inset_polygon(outline, float(z.get("inset", a.edge)))+ else:+ poly = [(float(p[0]), float(p[1])) for p in z["polygon"]]+ if len(poly) >= 2 and poly[0] == poly[-1]:+ poly.pop()+ if len(poly) < 3:+ raise SystemExit("zone %s needs three or more points" % z["net"])+ z["_polygon"] = poly+ blocks.append(zone_sexpr(board, z))+ summary.append("%s on %s (%.0f mm2, %s)" % (z.get("net") or "keepout", "+".join(z.get("layers") or [z["layer"]]), abs(polygon_area(poly)), "keepout" if keep else "priority %s, %s" % (z.get("priority", 0), z.get("connection", "thermal"))))+ out = Path(a.out) if a.out else src.with_name(src.name[: -len(".kicad_pcb")] + "-pours.kicad_pcb")+ text = splice(board.text, blocks)+ out.write_text(text, encoding="utf-8", newline="")+ Board(str(out))+ print("%s: %d zones: %s" % (out, len(blocks), "; ".join(summary)))+ if a.drc:+ errors, warnings, unconnected, data = run_drc(out)+ by_type = {}+ for v in data.get("violations", []):+ if v.get("severity") == "error":+ by_type[v.get("type")] = by_type.get(v.get("type"), 0) + 1+ line = "drc: %d errors, %d warnings, %d unconnected; error types %s (KiCad %s)" % (errors, warnings, unconnected, json.dumps(by_type, sort_keys=True), data.get("kicad_version"))+ if a.baseline:+ b_err, _, _, b_data = run_drc(Path(a.baseline))+ def sig(v):+ return (v.get("type"), tuple(sorted((i.get("description") or "") for i in v.get("items", []))))+ base = {sig(v) for v in b_data.get("violations", []) if v.get("severity") == "error"}+ new = [v for v in data.get("violations", []) if v.get("severity") == "error" and sig(v) not in base]+ line += "; %d NEW against the baseline (%d inherited)" % (len(new), errors - len(new))+ for v in new[:20]:+ print(" new:", v.get("type"), [(i.get("description") or "")[:60] for i in v.get("items", [])])+ print(line)+ return 0 if (not new and unconnected == 0) else 2+ print(line)+ return 0 if (errors == 0 and unconnected == 0) else 2+ return 0+++if __name__ == "__main__":+ sys.exit(main())
reference/python/pours_esc_example.pyadded+182@@ -0,0 +1,182 @@+#!/usr/bin/env python3+"""Claude Fable 5.1's copper-pour plan for the ESC G431, from the board's own pad geometry.++ python3 demo/routing/esc/pours_plan.py BOARD.kicad_pcb --out pours.json [--vias vias-plan.json]++Reads the placed board, finds the power stage (the six MOSFET tabs and source pads, the three+phase contacts, the +VBAT contact, the bulk capacitors, the shunt and the GND_OUT contact) and+writes the pours as the JSON kicad_add_zone takes (tools/add_pours.py gates it offline):++ * GND on F.Cu and B.Cu, whole outline, priority 0, thermal reliefs: the copper that stays+ everywhere nothing else needs it (ablation), stitched to the In1.Cu plane by every through+ pad and the existing stub vias.+ * +VBAT, priority 2: the rail block (contact MC1, TVS, the bulk and ceramic capacitors, the+ high-side tab of phase C) on F.Cu and B.Cu, plus a strip on B.Cu behind the MOSFET column+ that reaches every high-side tab through thermal vias in the tabs. The gate driver and its+ bootstrap nodes are left out of the rail pour on purpose (switching nodes beside gate drive).+ * One pour per phase (/DRV_SHA, /DRV_SHB, /DRV_SHC), priority 3, solid connections: on F.Cu+ from the high-side source pads over the low-side tab to the phase contact; on B.Cu only+ under the low-side tab, so the +VBAT strip on B.Cu stays continuous. Compact by design:+ these are the switching nodes.+ * GND_OUT (the battery return through the shunt), priority 3, solid: from the return contact+ MC3 to the shunt's GND_OUT pad, on both outer layers. The two Kelvin traces to the sense+ amplifier stay outside every pour.++Not poured, deliberately: the bootstrap and gate-drive nets, the crystal and its capacitors, the+BEMF dividers and comparator inputs, /VBAT_SENSE and /IBAT_SENSE, +3V3 and /VDDA on the outer+layers (they have the In2.Cu plane), and anything under the MCU's escape bands.++--vias also writes the thermal/stitching vias as route_net entries (write_copper.py and+route_live.py replay them): two 0.8/0.4 vias in each high-side tab (F.Cu tab to the B.Cu +VBAT+strip) and a row along the B.Cu strip.+"""+import argparse, json, math, os, sys++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from escboard import Board # noqa: E402++PHASES = {"/DRV_SHA": "MC5", "/DRV_SHB": "MC9", "/DRV_SHC": "MC11"}+++def box(pads):+ xs = [q[0] for p in pads for poly in p.polys for q in poly]+ ys = [q[1] for p in pads for poly in p.polys for q in poly]+ return [min(xs), min(ys), max(xs), max(ys)]+++def rect(x0, y0, x1, y1):+ return [[round(x0, 2), round(y0, 2)], [round(x1, 2), round(y0, 2)], [round(x1, 2), round(y1, 2)], [round(x0, 2), round(y1, 2)]]+++def main():+ ap = argparse.ArgumentParser()+ ap.add_argument("board"); ap.add_argument("--out", required=True); ap.add_argument("--vias")+ ap.add_argument("--avoid", help="routing plan JSON: a via candidate that would hit another net's copper is moved to the next spot in the tab; its Kelvin entries get pour keepouts")+ ap.add_argument("--solid-at", action="append", default=[], help="REF.PAD that gets a small solid-connection patch (starved thermal relief)")+ a = ap.parse_args()+ b = Board(a.board)+ pads = b.pads+ x0, y0, x1, y1 = b.bbox+ edge = 0.5+ # the MOSFET column: high-side tab (+VBAT) and low-side tab (phase) per phase+ q = {}+ for p in pads:+ if p.ref.startswith("Q") and p.name == "5":+ q.setdefault(p.net, []).append(p)+ hs_tabs = q.get("+VBAT", []) # three tabs, one per phase+ zones = []+ vias = []+ # 1. GND everywhere, both outer layers, lowest priority+ zones.append({"net": "GND", "layers": ["F.Cu", "B.Cu"], "outline": True, "inset": edge, "priority": 0, "connection": "thermal",+ "clearance": 0.2, "minThickness": 0.25, "thermalGap": 0.3, "thermalBridge": 0.35, "name": "GND outer pour (ablation: keep the copper)"})+ # pads where only one thermal spoke fits get a small solid patch (KiCad's rule wants two spokes or a solid joint)+ for key in a.solid_at:+ ref, name = key.split(".")+ pd = next(p for p in pads if p.ref == ref and p.name == name)+ zones.append({"net": pd.net, "layers": ["F.Cu"], "polygon": rect(pd.x - 0.9, pd.y - 0.9, pd.x + 0.9, pd.y + 0.9), "priority": 1, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "%s solid joint at %s (one spoke only)" % (pd.net, key)})+ # 2. the rail: contact MC1, TVS D1, bulk caps C2/C3, the ceramic bank, down to the phase-C high-side tab+ rail_parts = [p for p in pads if p.net == "+VBAT" and (p.ref.startswith("C") or p.ref in ("D1", "MC1"))]+ rb = box(rail_parts)+ col = box([p for p in pads if p.ref.startswith("Q")])+ driver = box([p for p in pads if p.ref == "U4"])+ rail_left = 127.5 # right of the buck (U2) and its SW node; the TVS is inside+ rail_bottom = driver[1] - 1.0 # above the gate driver's band+ strip_right = x1 - edge - 1.6+ zones.append({"net": "+VBAT", "layers": ["F.Cu"], "polygon": rect(rail_left, y0 + edge, strip_right, rail_bottom), "priority": 2, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block F.Cu"})+ # B.Cu: the rail block plus the strip behind the MOSFET column as ONE L-shaped zone (two zones of one+ # net and one priority may not touch). The strip reaches every high-side tab through the thermal vias;+ # the phase pours under the low-side tabs (priority 3) punch through it and the +VBAT copper flows around.+ L = [[rail_left, y0 + edge], [strip_right, y0 + edge], [strip_right, col[3] + 1.0], [col[0] - 0.8, col[3] + 1.0], [col[0] - 0.8, rail_bottom], [rail_left, rail_bottom]]+ zones.append({"net": "+VBAT", "layers": ["B.Cu"], "polygon": [[round(x, 2), round(y, 2)] for x, y in L], "priority": 2, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "+VBAT rail block and column strip B.Cu"})+ # 3. one compact pour per phase+ for net, contact in PHASES.items():+ hs_src = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name in ("1", "2", "3")]+ ls_tab = [p for p in pads if p.ref.startswith("Q") and p.net == net and p.name == "5"]+ mc = [p for p in pads if p.ref == contact]+ fb = box(hs_src + ls_tab + mc)+ zones.append({"net": net, "layers": ["F.Cu"], "polygon": rect(fb[0] - 0.5, fb[1] - 0.6, x1 - edge, fb[3] + 0.6), "priority": 3, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "%s phase F.Cu" % net})+ lb = box(ls_tab)+ zones.append({"net": net, "layers": ["B.Cu"], "polygon": rect(lb[0] - 1.5, lb[1] - 0.3, lb[2] + 0.3, lb[3] + 0.6), "priority": 3, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "%s under the low-side tab B.Cu" % net})+ # 4. the return through the shunt: contact MC3 to the shunt's GND_OUT pad+ mc3 = [p for p in pads if p.ref == "MC3"]+ r32 = [p for p in pads if p.ref == "R32" and p.net == "GND_OUT"]+ gb = box(mc3 + r32)+ zones.append({"net": "GND_OUT", "layers": ["F.Cu", "B.Cu"], "polygon": rect(gb[0] - 0.4, max(y0 + edge, gb[1] - 0.6), gb[2] + 0.4, gb[3] + 0.6), "priority": 3, "connection": "solid",+ "clearance": 0.2, "minThickness": 0.25, "name": "GND_OUT return"})+ # thermal vias in every high-side tab (two per tab, moved within the tab when the routed copper of+ # another net passes under that spot on B.Cu), and two stitching vias along the B.Cu strip+ blockers = []+ if a.avoid:+ for e in json.load(open(a.avoid))["nets"]:+ if e["net"] == "+VBAT":+ continue+ for path in e["paths"]:+ prev = None; layer = "F.Cu"+ for w in path:+ if isinstance(w, str):+ prev = None; continue+ if isinstance(w, dict):+ if "x" not in w:+ continue+ layer = w.get("layer", layer); pt = (w["x"], w["y"])+ else:+ pt = (w[0], w[1])+ if prev is not None:+ blockers.append((prev, pt, e["width"] / 2.0 + 0.2 + 0.4))+ prev = pt++ def clear(x, y):+ for (ax, ay), (bx, by), need in blockers:+ dx, dy = bx - ax, by - ay; L = dx * dx + dy * dy+ t = 0 if L == 0 else max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L))+ if math.hypot(x - (ax + t * dx), y - (ay + t * dy)) < need:+ return False+ return True++ for t in hs_tabs:+ placed = 0+ for dx, dy in ((-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3)):+ if placed >= 2:+ break+ x, y = round(t.x + dx, 2), round(t.y + dy, 2)+ if clear(x, y):+ vias.append({"net": "+VBAT", "x": x, "y": y}); placed += 1+ strip_x = round(col[2] + 1.9, 2)+ for yy in (90.0, 118.0):+ if clear(strip_x, yy):+ vias.append({"net": "+VBAT", "x": strip_x, "y": yy})+ # pour keepouts around every Kelvin tap in the routing plan (both outer layers, the whole trace, not the pads)+ if a.avoid:+ for e in json.load(open(a.avoid))["nets"]:+ if not e.get("kelvin"):+ continue+ for path in e["paths"]:+ pts = [(w["x"], w["y"]) if isinstance(w, dict) else (w[0], w[1]) for w in path if not isinstance(w, str)]+ for (ax, ay), (bx, by) in zip(pts, pts[1:]):+ dx, dy = bx - ax, by - ay+ n = math.hypot(dx, dy)+ if n < 0.05:+ continue+ ux, uy = dx / n, dy / n+ w = 0.45 # half width of the keepout: trace half width 0.15 + clearance 0.2 + margin 0.1+ poly = [[round(ax - uy * w, 3), round(ay + ux * w, 3)], [round(bx - uy * w, 3), round(by + ux * w, 3)],+ [round(bx + uy * w, 3), round(by - ux * w, 3)], [round(ax + uy * w, 3), round(ay - ux * w, 3)]]+ zones.append({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": True},+ "name": "Kelvin tap keepout %s" % e["net"]})+ json.dump({"zones": zones}, open(a.out, "w"), indent=1)+ print("%d zones -> %s" % (len(zones), a.out))+ for z in zones:+ print(" %-10s %-12s %s %s" % (z.get("net") or "keepout", "+".join(z["layers"]), "keepout" if z.get("keepout") else "prio %d %s" % (z["priority"], z["connection"]), z.get("polygon") or "outline"))+ if a.vias:+ entries = [{"net": v["net"], "width": 0.5, "viaSize": 0.8, "viaDrill": 0.4, "paths": [[[v["x"], v["y"]], {"x": v["x"], "y": v["y"], "layer": "B.Cu"}]]} for v in vias]+ json.dump({"engine": "Claude Fable 5.1 pours plan (thermal and stitching vias)", "nets": entries}, open(a.vias, "w"), indent=1)+ print("%d vias -> %s" % (len(entries), a.vias))+++if __name__ == "__main__":+ main()
reference/python/router.pyadded+1420@@ -0,0 +1,1420 @@+#!/usr/bin/env python3+"""adom-boardflow's routing engine: a grid maze router for any KiCad 10 board (born on the ESC G431).++ python3 demo/routing/esc/ai_router.py BOARD-planes.kicad_pcb [--out plan.json] [--report report.json]+ [--nets NET,NET] [--no-planes] [--seed 0] [--verbose]++What it does+ * 0.1 mm grid over the Edge.Cuts outline, two signal layers (F.Cu, B.Cu), through vias between+ them (0.6 mm / 0.3 mm drill for signals, 0.8 mm / 0.4 mm on the wide power nets), KiCad's default rules for this board: 0.25 mm track, 0.2 mm+ clearance, 0.5 mm copper-to-edge, plus every per-pad or per-footprint `(clearance X)` override.+ * A* (octilinear, 8 neighbours) per connection. Costs: 10 per orthogonal cell, 14 diagonal,+ x1.25 on B.Cu (prefer F.Cu), +400 per via (heavily penalised), +4 per direction change.+ * Obstacles per layer for the net being routed: every pad and every committed segment/via of+ another net, dilated by (that item's clearance + half the track width + grid slop); the+ outline inset by the edge clearance; through pads on both layers. Own-net copper is free.+ A diagonal step is allowed only when one of its ends also clears the larger dilation that+ covers the midpoint of the step, so 45-degree segments never clip a corner.+ * Narrow pads (fine-pitch IC pins) are entered on their long axis: a 0.2 mm stub from the exact+ pad centre to an on-grid escape point beyond either pad end; the route starts there. Other+ pads are hit at their centre. Same-net pads that overlap (MOSFET source pads) form one cluster.+ * A net with N clusters is routed as a minimum spanning tree: Prim's order, each new cluster+ routed to ANY copper the net already has (pads, tracks, vias), so branches land on their own+ copper and junctions split the existing segment at an on-grid point.+ * Order: nets by MST length, shortest first; plane-net stubs last.+ * Widths: +VBAT and the phase nets (/DRV_SHA, /DRV_SHB, /DRV_SHC, which are also the MOSFET+ drain and source nets) try 1.0 mm, then 0.5, then 0.25; +5V and +12V try 0.5 then 0.25;+ everything else 0.25. A pad narrower than the width caps it (a 1.0 mm track never enters a+ 0.35 mm pin). Fallback happens per connection when the wider route finds no path.+ * GND and +3V3 are plane nets (In1.Cu and In2.Cu): no traces. Each SMD pad gets the shortest+ stub to a spot where a via fits (Dijkstra to the first via-legal cell), through pads need+ nothing. If no spot fits within 6 mm the via goes in the pad (reported).+ * Rip-up and retry: when a connection fails on every width, a second search treats other nets'+ tracks as expensive instead of solid; the nets it crosses are ripped up (most recently routed+ first), the connection is routed again, and the ripped nets are re-queued. Bounded per net+ and globally.++Output: a JSON plan, a list of kicad_route_net calls: {"net", "width", "viaSize", "viaDrill",+"paths": [[...], ...]} where a waypoint is "REF.PAD", [x, y] or {"x", "y", "layer": "B.Cu"}+(a layer marker = a via there). write_copper.py applies it offline, route_live.py replays it.+"""+import argparse+import heapq+import json+import math+import os+import sys+import time+from collections import defaultdict++import numpy as np++sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))+from escboard import Board, Grid, F_CU, B_CU, LAYER_NAMES, RES # noqa: E402++# ----------------------------------------------------------------------------- rules++RULES = {+ "track": 0.25, "clearance": 0.2, "via": 0.8, "drill": 0.4, "edge": 0.5,+ "stub": 0.2, # escape stub width for narrow pins+ "narrow": 0.5, # pads with a short side under this get an axis escape+ "escape": 0.45, # escape point this far beyond the pad end (grows if blocked)+ "corridor": 0.5, # reserved own-net corridor beyond the escape point (other nets keep out)+ "ic_zone": 2.0, # soft-cost band around a fine-pitch IC (routes should not run along a pin row)+ "channel": 2.5, # no via on the line of another reserved pin escape this far beyond the pad+ "planes": {"GND": "In1.Cu", "+3V3": "In2.Cu"},+ "wide": {"+VBAT": [1.0, 0.5, 0.25], "/DRV_SHA": [1.0, 0.5, 0.25], "/DRV_SHB": [1.0, 0.5, 0.25], "/DRV_SHC": [1.0, 0.5, 0.25],+ "GND_OUT": [1.0, 0.5, 0.25]}, # the battery return through the shunt carries the same current as +VBAT+ "mid": {"+5V": [0.5, 0.25], "+12V": [0.5, 0.25]},+ "via_small": 0.6, "drill_small": 0.3, # signal vias; the wide power nets keep 0.8 / 0.4+ "hole_to_hole": 0.25, # KiCad's default hole-to-hole minimum: same-net vias may sit this close (drill edge to drill edge)+ "via_search_mm": 6.0,+ # Kelvin sense taps: sense pad -> shunt pad. The sense pad gets NO plane stub and no share of the+ # net's wide copper; it gets one dedicated 0.25 mm trace on the pad's own layer to the shunt pad,+ # kept clear of every other piece of the same net (a pour keepout protects it afterwards).+ "kelvin": {},+}+SLOP = 0.05 * math.sqrt(2) + 1e-6 # half a cell diagonal: pads and stubs are off-grid+COST_ORTH, COST_DIAG, COST_VIA, COST_TURN = 10, 14, 250, 4+LAYER_MULT = {F_CU: 1.0, B_CU: 1.1}+SOFT_EXTRA = 80+ZONE_COST = 20 # per F.Cu cell inside a fine-pitch IC's escape band+VIA_WALL = 300 # per unrouted neighbour pin channel a via would wall in+HIST_COST = 12 # per cell per time a ripped-up route was there (contested corridors get pricier)+DIRS = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)]+STEP = [COST_ORTH] * 4 + [COST_DIAG] * 4+INF = 1 << 30+++def log(*a):+ print(*a, flush=True)+++# ----------------------------------------------------------------------------- data++class Cluster:+ """Pads of one net in one footprint whose copper overlaps: routed once."""++ def __init__(self, idx, pads):+ self.idx = idx+ self.pads = pads+ self.primary = max(pads, key=lambda p: p.half_long * p.half_short)+ self.terminals = [] # [(layer, i, j)] cells a route may start or end at+ self.escapes = {} # (layer, i, j) -> escape point (x, y) exact grid coords (stub end)+ self.corridors = {} # (layer, i, j) -> corridor end (x, y) beyond the escape point+ self.connected = False+ self.allow = 0.25++ @property+ def xy(self):+ return self.primary.x, self.primary.y++ @property+ def key(self):+ return self.primary.key+++class Route:+ def __init__(self, cells, width, start, end):+ self.cells = cells # [(layer, i, j)]+ self.width = width+ self.start = start # ("pad", Cluster, term) | ("escape", Cluster, term)+ self.end = end # ("pad", Cluster, term) | ("escape", ...) | ("copper",) | ("via",)+ self.breaks = set() # indices into cells where a segment must end (junctions)+ self.via_end = False # a via at the last cell (plane stub, or a via in the target pad)+ self.via_end_layer = "B.Cu" # the layer the via switches to+ self.kelvin = False # a dedicated sense tap (kept apart from its own net; pour keepout later)+++class NetState:+ def __init__(self, idx, name, pads):+ self.idx, self.name, self.pads = idx, name, pads+ self.clusters = []+ self.routes = []+ self.widths = [RULES["track"]]+ self.plane = RULES["planes"].get(name)+ self.mst_len = 0.0+ self.used_escapes = set() # (cluster idx, terminal)+ self.failed = [] # [(cluster key, reason)]+ self.rips = 0+ self.routed_order = None+ self.via_in_pad = []+ self.last_soft = []+ self.via = RULES["via_small"]+ self.drill = RULES["drill_small"]+++def _seg_distance(x, y, ax, ay, bx, by):+ dx, dy = bx - ax, by - ay+ L2 = dx * dx + dy * dy+ if L2 < 1e-12:+ return math.hypot(x - ax, y - ay)+ t = max(0.0, min(1.0, ((x - ax) * dx + (y - ay) * dy) / L2))+ return math.hypot(x - (ax + t * dx), y - (ay + t * dy))+++def _point_poly_distance(x, y, poly):+ """0 inside the polygon, else the distance to its boundary."""+ inside = False+ n = len(poly)+ best = float("inf")+ for k in range(n):+ ax, ay = poly[k]+ bx, by = poly[(k + 1) % n]+ if (ay > y) != (by > y):+ xint = ax + (y - ay) * (bx - ax) / (by - ay)+ if x < xint:+ inside = not inside+ d = _seg_distance(x, y, ax, ay, bx, by)+ if d < best:+ best = d+ return 0.0 if inside else best+++def _route_segments(g, cells):+ """Straight runs of a cell path as exact segments (x0, y0, x1, y1), vias skipped."""+ out = []+ k = 0+ n = len(cells)+ while k < n - 1:+ L, i0, j0 = cells[k]+ m = k + 1+ if cells[m][0] != L:+ k = m+ continue+ di, dj = cells[m][1] - i0, cells[m][2] - j0+ while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):+ m += 1+ a = g.to_xy(i0, j0)+ b = g.to_xy(cells[m][1], cells[m][2])+ out.append((a[0], a[1], b[0], b[1]))+ k = m+ return out+++# ----------------------------------------------------------------------------- router++class Router:+ def __init__(self, board, verbose=False):+ self.board = board+ self.verbose = verbose+ self.grid = Grid(board.bbox)+ g = self.grid+ self.H, self.W = g.H, g.W+ self.nets = {}+ self.net_ids = {}+ for k, (name, pads) in enumerate(sorted(board.nets.items())):+ self.net_ids[name] = k+ self.nets[name] = NetState(k, name, pads)+ # raw copper by layer: pads (+ reserved stubs) and routes, net id or -1+ self.pad_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}+ self.pad_owner = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}+ self.route_raw = {L: np.full((g.H, g.W), -1, dtype=np.int16) for L in (F_CU, B_CU)}+ self.big_clr = {L: np.zeros((g.H, g.W), dtype=bool) for L in (F_CU, B_CU)} # pads with a 0.6 override+ self.big_clr_val = RULES["clearance"]+ self.smd_pad_cells = {} # net id -> bool mask (F.Cu smd pads) for via placement+ self.thru_cells = np.zeros((g.H, g.W), dtype=bool) # every through pad: never a via site+ self.history = {L: np.zeros((g.H, g.W), dtype=np.int16) for L in (F_CU, B_CU)} # contested cells (rip-ups)+ self.chan_count = np.zeros((g.H, g.W), dtype=np.int16) # how many unrouted pin channels a via here would wall+ self.chan_lines = {} # (net idx, cluster idx, term) -> (jj, ii) of the walled region+ self.vias = [] # [(i, j, net id, diameter, drill)]+ self.kelvin_owner = {} # synthetic net id of a Kelvin tap -> the real NetState (never ripped up)+ self.fp_index = {ref: n for n, ref in enumerate(sorted(board.footprints))}+ self.pad_cells = {} # pad key -> (jj, ii)+ t0 = time.time()+ self._stamp_pads()+ self.inside, self.edge_dist = g.outline_distance(board.outline)+ self.inside_w = {}+ self.order = []+ self.rip_total = 0+ self.stats = defaultdict(int)+ self._build_clusters()+ self._build_ic_zones()+ log("router: %d x %d cells, %d pads stamped, %d nets to route, init %.1fs" % (+ g.W, g.H, len(board.pads), len([n for n in self.nets.values() if len(n.clusters) >= 2 or n.plane]), time.time() - t0))++ # -- stamping ------------------------------------------------------------------+ def _stamp_pads(self):+ g = self.grid+ for p in self.board.pads:+ nid = self.net_ids.get(p.net, -1)+ m = np.zeros((g.H, g.W), dtype=bool)+ for poly in p.polys:+ g.poly_mask(poly, m)+ if p.drill:+ jj, ii = g.disk_cells((p.x, p.y), p.drill / 2.0)+ m[jj, ii] = True+ jj, ii = np.nonzero(m)+ self.pad_cells[p.key] = (jj, ii)+ layers = [F_CU, B_CU] if p.thru else [F_CU if "F.Cu" in p.layers else B_CU]+ for L in layers:+ self.pad_raw[L][jj, ii] = nid+ self.pad_owner[L][jj, ii] = self.fp_index[p.ref]+ if p.clearance > RULES["clearance"]:+ self.big_clr[L][jj, ii] = True+ self.big_clr_val = max(self.big_clr_val, p.clearance)+ if not p.thru and nid >= 0:+ sm = self.smd_pad_cells.setdefault(nid, np.zeros((g.H, g.W), dtype=bool))+ sm[jj, ii] = True+ if p.thru:+ self.thru_cells[jj, ii] = True++ def inside_for(self, w):+ key = round(w, 3)+ if key not in self.inside_w:+ self.inside_w[key] = self.inside & (self.edge_dist >= RULES["edge"] + w / 2.0 + SLOP)+ return self.inside_w[key]++ def stamp_route(self, net, route, sign=1):+ """Add (sign=1) or remove (sign=-1) a route's copper from route_raw."""+ g = self.grid+ nid = net.idx if sign > 0 else -1+ cells = route.cells+ k = 0+ while k < len(cells) - 1:+ # straight run from k+ L, i0, j0 = cells[k]+ m = k + 1+ di, dj = cells[m][1] - i0, cells[m][2] - j0+ while m + 1 < len(cells) and cells[m + 1][0] == L and cells[m][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj):+ m += 1+ if cells[m][0] == L:+ a = g.to_xy(i0, j0)+ b = g.to_xy(cells[m][1], cells[m][2])+ jj, ii = g.segment_cells(a, b, route.width / 2.0, 0.0 + 1e-6)+ self.route_raw[L][jj, ii] = nid+ k = m+ # vias: layer changes+ for k in range(1, len(cells)):+ if cells[k][0] != cells[k - 1][0] and cells[k][1:] == cells[k - 1][1:]:+ self._stamp_via(cells[k][1], cells[k][2], net, sign)+ if route.via_end:+ L, i, j = cells[-1]+ self._stamp_via(i, j, net, sign)+ if len(cells) == 1 and route.via_end:+ pass++ def _stamp_via(self, i, j, net, sign):+ g = self.grid+ nid = net.idx+ xy = g.to_xy(i, j)+ jj, ii = g.disk_cells(xy, net.via / 2.0, 0.0 + 1e-6)+ for L in (F_CU, B_CU):+ self.route_raw[L][jj, ii] = nid if sign > 0 else -1+ if sign > 0:+ self.vias.append((i, j, nid, net.via, net.drill))+ else:+ self.vias = [v for v in self.vias if not (v[0] == i and v[1] == j and v[2] == nid)]++ def via_gap(self, net, nid, dia, drill):+ """Centre-to-centre distance this net's via must keep from a via of (nid, dia, drill)."""+ if nid == net.idx:+ return (net.drill + drill) / 2.0 + RULES["hole_to_hole"] + SLOP+ return net.via / 2.0 + dia / 2.0 + RULES["clearance"] + SLOP++ def stamp_stub(self, net, cluster, term, sign=1):+ """Reserve or release the escape stub copper (exact pad centre to the on-grid escape point)."""+ g = self.grid+ L, i, j = term+ p = cluster.primary+ a = p.polys_centre+ b = g.to_xy(i, j)+ jj, ii = g.segment_cells(a, b, RULES["stub"] / 2.0, 1e-6)+ self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1+ if sign > 0:+ self.pad_owner[L][jj, ii] = self.fp_index[p.ref]+ kend = cluster.corridors.get(term)+ if kend is not None:+ jj, ii = g.segment_cells(b, kend, RULES["track"] / 2.0, 1e-6)+ self.pad_raw[L][jj, ii] = net.idx if sign > 0 else -1+ if sign > 0:+ self.pad_owner[L][jj, ii] = self.fp_index[p.ref]+ self._stamp_channel(net, cluster, term, sign)+ if sign < 0:+ # restore the pad itself+ pj, pi = self.pad_cells[p.key]+ self.pad_raw[L][pj, pi] = net.idx+ self.pad_owner[L][pj, pi] = self.fp_index[p.ref]++ def _stamp_channel(self, net, cluster, term, sign):+ """Count (or uncount) the region where a via would wall in this unrouted pin's straight path."""+ g = self.grid+ key = (net.idx, cluster.idx, term)+ if sign > 0 and key in self.chan_lines:+ return+ if sign < 0:+ hit = self.chan_lines.pop(key, None)+ if hit is not None:+ self.chan_count[hit[0], hit[1]] -= 1+ return+ L, i, j = term+ p = cluster.primary+ b = g.to_xy(i, j)+ ax, ay = p.long_axis+ cx, cy = p.polys_centre+ sgn = 1.0 if (b[0] - cx) * ax + (b[1] - cy) * ay > 0 else -1.0+ cend = (b[0] + sgn * round(ax, 6) * RULES["channel"], b[1] + sgn * round(ay, 6) * RULES["channel"])+ r = net.via / 2.0 + RULES["clearance"] + RULES["track"] / 2.0 + SLOP+ jj, ii = g.segment_cells(b, cend, r, 0.0)+ ok = self.inside_for(net.via)[jj, ii]+ jj, ii = jj[ok], ii[ok]+ self.chan_count[jj, ii] += 1+ self.chan_lines[key] = (jj, ii)++ # -- clusters and terminals ----------------------------------------------------+ def _build_clusters(self):+ g = self.grid+ for net in self.nets.values():+ if len(net.pads) < 2 and not net.plane:+ continue+ by_fp = defaultdict(list)+ for p in net.pads:+ by_fp[p.ref].append(p)+ clusters = []+ for ref, pads in by_fp.items():+ groups = []+ for p in pads:+ mask = set(zip(*[a.tolist() for a in self.pad_cells[p.key]]))+ merged = None+ for gidx, (gpads, gcells) in enumerate(groups):+ if mask & gcells:+ if merged is None:+ gpads.append(p)+ gcells |= mask+ merged = gidx+ else:+ groups[merged][0].extend(gpads)+ groups[merged][1] |= gcells+ groups[gidx] = None+ groups = [x for x in groups if x is not None]+ if merged is None:+ groups.append(([p], set(mask)))+ for gpads, _ in groups:+ clusters.append(Cluster(len(clusters), gpads))+ net.clusters = clusters+ if net.name in RULES["wide"]:+ net.widths = list(RULES["wide"][net.name])+ net.via, net.drill = RULES["via"], RULES["drill"]+ elif net.name in RULES["mid"]:+ net.widths = list(RULES["mid"][net.name])+ for c in clusters:+ self._terminals_for(net, c)+ # MOSFET drain/source nets get the wide treatment too+ for p in self.board.pads:+ if p.ref.startswith("Q") and p.name in ("1", "2", "3", "5") and p.net in self.nets:+ n = self.nets[p.net]+ if not n.plane and n.name not in RULES["wide"]:+ n.widths = [1.0, 0.5, 0.25]+ n.via, n.drill = RULES["via"], RULES["drill"]++ def _build_ic_zones(self):+ """Fine-pitch ICs (two or more escaped pins): the body interior on F.Cu is a keepout for nets+ without a pin on that IC; a band around the IC carries a soft cost for every net."""+ g = self.grid+ self.body_owner = np.full((g.H, g.W), -1, dtype=np.int16)+ self.ic_zone = np.zeros((g.H, g.W), dtype=bool)+ self.ic_nets = {} # fp index -> set of net ids with a pin on it+ by_fp = defaultdict(list)+ for net in self.nets.values():+ for c in net.clusters:+ if c.escapes:+ by_fp[c.primary.ref].append(c)+ for ref, clusters in by_fp.items():+ if len(clusters) < 2:+ continue+ fp = self.board.footprints[ref]+ inner = []+ for c in clusters:+ p = c.primary+ ax, ay = p.long_axis+ cx, cy = p.polys_centre+ sgn = 1.0 if (fp["x"] - cx) * ax + (fp["y"] - cy) * ay > 0 else -1.0+ inner.append((cx + sgn * ax * p.half_long, cy + sgn * ay * p.half_long))+ pads = [q for q in self.board.pads if q.ref == ref]+ xs = [pt[0] for pt in inner]+ ys = [pt[1] for pt in inner]+ if max(xs) - min(xs) < 1.0 or max(ys) - min(ys) < 1.0:+ continue+ i0, j0 = g.to_cell(min(xs), min(ys))+ i1, j1 = g.to_cell(max(xs), max(ys))+ idx = self.fp_index[ref]+ self.body_owner[j0:j1 + 1, i0:i1 + 1] = idx+ self.ic_nets[idx] = {self.net_ids[q.net] for q in pads if q.net in self.net_ids}+ m = int(round(RULES["ic_zone"] / RES))+ px = [q.x for q in pads]+ py = [q.y for q in pads]+ a0, b0 = g.to_cell(min(px), min(py))+ a1, b1 = g.to_cell(max(px), max(py))+ self.ic_zone[max(0, b0 - m):b1 + m + 1, max(0, a0 - m):a1 + m + 1] = True+ log(" ic %s: body keepout %.1f x %.1f mm for %d foreign nets, escape band +%d/cell" % (+ ref, (i1 - i0) * RES, (j1 - j0) * RES, len(self.nets) - len(self.ic_nets[idx]), ZONE_COST))++ def _terminals_for(self, net, c):+ g = self.grid+ p = c.primary+ md = p.min_dim+ c.allow = 1.0 if md >= 1.0 else (0.5 if md >= 0.5 else 0.25)+ if p.thru:+ i, j = g.to_cell(p.x, p.y)+ c.terminals = [(F_CU, i, j), (B_CU, i, j)]+ return+ L = F_CU if "F.Cu" in p.layers else B_CU+ narrow = md < RULES["narrow"] and p.half_long > p.half_short * 1.4+ if not narrow:+ i, j = g.to_cell(p.x, p.y)+ c.terminals = [(L, i, j)]+ return+ c.allow = 0.25+ cx, cy = p.polys_centre+ ax, ay = p.long_axis+ found = []+ for sgn in (1.0, -1.0):+ hit = None+ # a full corridor first; a shorter one when a neighbouring part sits close to the pin end+ for corridor in (RULES["corridor"], RULES["corridor"] / 2.0, 0.0):+ d = RULES["escape"]+ while d <= 1.6 and hit is None:+ ex, ey = cx + sgn * ax * (p.half_long + d), cy + sgn * ay * (p.half_long + d)+ i, j = g.to_cell(ex, ey)+ # the corridor leaves the ON-GRID escape cell along the pin axis, so an axis-aligned+ # pin row gets straight corridors that rasterise three cells wide, never four+ bx, by = g.to_xy(i, j)+ kx, ky = bx + sgn * round(ax, 6) * corridor, by + sgn * round(ay, 6) * corridor+ if g.in_bounds(i, j) and self._escape_ok(net, p, L, i, j, (kx, ky)):+ hit = ((L, i, j), sgn, (kx, ky) if corridor > 0 else None)+ d += 0.1+ if hit is not None:+ break+ if hit is not None:+ found.append(hit)+ c.terminals = [t for t, _, _ in found]+ for t, sgn, kend in found:+ c.escapes[t] = g.to_xy(t[1], t[2])+ if kend is not None:+ c.corridors[t] = kend+ self.stamp_stub(net, c, t, 1)+ if not found:+ i, j = g.to_cell(p.x, p.y)+ c.terminals = [(L, i, j)]++ def _escape_ok(self, net, p, L, i, j, corridor_end):+ """Exact geometry: the escape cell, the stub and the reserved corridor beyond it clear every+ pad of another footprint (same-footprint neighbours are the regular pin field, fine by+ construction), and the corridor end lies inside the outline."""+ g = self.grid+ if not self.inside_for(RULES["track"])[j, i]:+ return False+ b = g.to_xy(i, j)+ a = p.polys_centre+ checks = [(a, b, RULES["stub"] / 2.0)]+ if math.dist(b, corridor_end) > 1e-6:+ ki, kj = g.to_cell(*corridor_end)+ if not g.in_bounds(ki, kj) or not self.inside_for(RULES["track"])[kj, ki]:+ return False+ checks.append((b, corridor_end, RULES["track"] / 2.0))+ samples = []+ for (s0, s1, hw) in checks:+ n = max(1, int(math.ceil(math.dist(s0, s1) / 0.05)))+ for k in range(n + 1):+ t = k / n+ samples.append((s0[0] + (s1[0] - s0[0]) * t, s0[1] + (s1[1] - s0[1]) * t, hw))+ # the route leaving the escape cell: a track of the net's width in any direction+ samples.append((b[0], b[1], RULES["track"] / 2.0))+ reach = 2.5+ for q in self.board.pads:+ if q.ref == p.ref or q.net == net.name:+ continue+ if abs(q.x - b[0]) > reach + q.half_long or abs(q.y - b[1]) > reach + q.half_long:+ continue+ if L == F_CU and "F.Cu" not in q.layers:+ continue+ clr = max(RULES["clearance"], q.clearance)+ for poly in q.polys:+ for (x, y, hw) in samples:+ if _point_poly_distance(x, y, poly) < hw + clr:+ return False+ return True++ # -- obstacle maps ---------------------------------------------------------------+ def obstacle_maps(self, net, width, win, soft=False):+ """(obs_o, obs_d, soft_mask) per layer for the window, plus via_ok. Windows are (i0, j0, i1, j1) inclusive."""+ g = self.grid+ i0, j0, i1, j1 = win+ r_o = (RULES["clearance"] + width / 2.0 + SLOP) / RES+ r_d = r_o + 0.0707 / RES+ r_big = (self.big_clr_val + width / 2.0 + SLOP) / RES+ pad_R = int(math.ceil(max(r_d, r_big))) + 1+ # work on a padded window so the dilation sees copper just outside it+ I0, J0 = max(0, i0 - pad_R), max(0, j0 - pad_R)+ I1, J1 = min(self.W - 1, i1 + pad_R), min(self.H - 1, j1 + pad_R)+ obs_o, obs_d, softm = {}, {}, {}+ via_block = np.zeros((J1 - J0 + 1, I1 - I0 + 1), dtype=bool)+ for L in (F_CU, B_CU):+ praw = self.pad_raw[L][J0:J1 + 1, I0:I1 + 1]+ rraw = self.route_raw[L][J0:J1 + 1, I0:I1 + 1]+ other_pads = (praw != -1) & (praw != net.idx)+ other_routes = (rraw != -1) & (rraw != net.idx)+ big = self.big_clr[L][J0:J1 + 1, I0:I1 + 1] & other_pads+ hard = other_pads if soft else (other_pads | other_routes)+ o = g.dilate(hard, r_o) | g.dilate(big, r_big) | ~self.inside_for(width)[J0:J1 + 1, I0:I1 + 1]+ d = g.dilate(hard, r_d) | g.dilate(big, r_big + 0.0707 / RES)+ if L == F_CU and self.ic_nets:+ foreign = [idx for idx, nets in self.ic_nets.items() if net.idx not in nets]+ if foreign:+ body = np.isin(self.body_owner[J0:J1 + 1, I0:I1 + 1], foreign)+ o |= body+ d |= body+ s = g.dilate(other_routes, r_d) if soft else None+ sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))+ obs_o[L] = o[sl]+ obs_d[L] = d[sl]+ softm[L] = s[sl] if s is not None else None+ via_block |= g.dilate(other_pads | other_routes, (RULES["clearance"] + net.via / 2.0 + SLOP) / RES)+ via_block |= g.dilate(big, (self.big_clr_val + net.via / 2.0 + SLOP) / RES)+ own_smd = self.smd_pad_cells.get(net.idx)+ if own_smd is not None:+ via_block |= g.dilate(own_smd[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)+ via_block |= g.dilate(self.thru_cells[J0:J1 + 1, I0:I1 + 1], (net.via / 2.0 + SLOP) / RES)+ # other vias: annular ring + clearance for another net, hole to hole for this net+ groups = defaultdict(list)+ for (vi, vj, nid, dia, drill) in self.vias:+ if I0 <= vi <= I1 and J0 <= vj <= J1:+ groups[round(self.via_gap(net, nid, dia, drill), 4)].append((vj - J0, vi - I0))+ for gap, cells in groups.items():+ vm = np.zeros_like(via_block)+ for (a, b) in cells:+ vm[a, b] = True+ via_block |= g.dilate(vm, gap / RES - 1e-6)+ via_ok = ~via_block & self.inside_for(net.via)[J0:J1 + 1, I0:I1 + 1]+ sl = (slice(j0 - J0, j1 - J0 + 1), slice(i0 - I0, i1 - I0 + 1))+ return obs_o, obs_d, softm, via_ok[sl]++ # -- A* --------------------------------------------------------------------------+ def search(self, net, width, sources, targets, heur_pts, win, allow_via=True, soft=False, via_target=False, max_nodes=None, vip=()):+ """A* from sources to targets. sources: [(L,i,j,cost0)]; targets: {(L,i,j)} or None when via_target.+ Returns (cells, blockers) with cells [(L,i,j)] or None."""+ i0, j0, i1, j1 = win+ Ww, Hw = i1 - i0 + 1, j1 - j0 + 1+ NL = Ww * Hw+ obs_o, obs_d, softm, via_ok = self.obstacle_maps(net, width, win, soft)+ oo = {L: obs_o[L].ravel().tobytes() for L in (F_CU, B_CU)}+ od = {L: obs_d[L].ravel().tobytes() for L in (F_CU, B_CU)}+ sm = {L: (softm[L].ravel().tobytes() if softm[L] is not None else None) for L in (F_CU, B_CU)}+ vo = via_ok.ravel().tobytes()+ zone = self.ic_zone[j0:j1 + 1, i0:i1 + 1].ravel().tobytes()+ wallm = self.chan_count[j0:j1 + 1, i0:i1 + 1].astype(np.int32)+ for (nid, cidx, term), (cj, ci) in self.chan_lines.items():+ if nid == net.idx:+ sel = (cj >= j0) & (cj <= j1) & (ci >= i0) & (ci <= i1)+ wallm[cj[sel] - j0, ci[sel] - i0] -= 1+ wall = np.clip(wallm, 0, 60).astype(np.uint8).ravel().tobytes()+ hist = {L: np.clip(self.history[L][j0:j1 + 1, i0:i1 + 1], 0, 40).astype(np.uint8).ravel().tobytes() for L in (F_CU, B_CU)}+ tgt = bytearray(2 * NL)+ vipset = set()+ for (L, i, j) in vip:+ if i0 <= i <= i1 and j0 <= j <= j1:+ vipset.add((j - j0) * Ww + (i - i0))+ if via_target:+ for L in (F_CU,):+ base = L * NL+ for m in range(NL):+ if vo[m] and not oo[L][m]:+ tgt[base + m] = 1+ if targets:+ for (L, i, j) in targets:+ if i0 <= i <= i1 and j0 <= j <= j1:+ tgt[L * NL + (j - j0) * Ww + (i - i0)] = 1+ if not any(tgt):+ return None, set()+ gcost = [INF] * (2 * NL)+ parent = [-1] * (2 * NL)+ pdir = [-1] * (2 * NL)+ closed = bytearray(2 * NL)+ heap = []+ hp = [(i - i0, j - j0) for (i, j) in heur_pts]++ def h(il, jl):+ best = INF+ for (hi, hj) in hp:+ dx, dy = abs(hi - il), abs(hj - jl)+ v = 10 * max(dx, dy) + 4 * min(dx, dy)+ if v < best:+ best = v+ return best if hp else 0++ for (L, i, j, c0) in sources:+ if not (i0 <= i <= i1 and j0 <= j <= j1):+ continue+ m = (j - j0) * Ww + (i - i0)+ n = L * NL + m+ if oo[L][m]:+ continue+ if c0 < gcost[n]:+ gcost[n] = c0+ heapq.heappush(heap, (c0 + h(i - i0, j - j0), n))+ expanded = 0+ found = -1+ while heap:+ f, n = heapq.heappop(heap)+ if closed[n]:+ continue+ closed[n] = 1+ if tgt[n]:+ found = n+ break+ expanded += 1+ if max_nodes and expanded > max_nodes:+ break+ L = n // NL+ m = n - L * NL+ jl = m // Ww+ il = m - jl * Ww+ gn = gcost[n]+ pd = pdir[n]+ ooL, odL, smL, hiL = oo[L], od[L], sm[L], hist[L]+ mult = LAYER_MULT[L]+ for d in range(8):+ ni = il + DIRS[d][0]+ nj = jl + DIRS[d][1]+ if ni < 0 or nj < 0 or ni >= Ww or nj >= Hw:+ continue+ nm = nj * Ww + ni+ if ooL[nm]:+ continue+ if d >= 4 and odL[nm] and odL[m]:+ continue+ nn = L * NL + nm+ if closed[nn]:+ continue+ c = STEP[d] * mult+ if pd != -1 and pd != d:+ c += COST_TURN+ if smL is not None and smL[nm]:+ c += SOFT_EXTRA+ if L == F_CU and zone[nm]:+ c += ZONE_COST+ if hiL[nm]:+ c += HIST_COST * hiL[nm]+ ng = gn + c+ if ng < gcost[nn]:+ gcost[nn] = ng+ parent[nn] = n+ pdir[nn] = d+ heapq.heappush(heap, (ng + h(ni, nj), nn))+ if allow_via and (vo[m] or m in vipset):+ L2 = 1 - L+ if not oo[L2][m]:+ nn = L2 * NL + m+ if not closed[nn]:+ ng = gn + COST_VIA + VIA_WALL * wall[m]+ if ng < gcost[nn]:+ gcost[nn] = ng+ parent[nn] = n+ pdir[nn] = -1+ heapq.heappush(heap, (ng + h(il, jl), nn))+ self.stats["expanded"] += expanded+ if found < 0:+ return None, set()+ cells = []+ n = found+ while n != -1:+ L = n // NL+ m = n - L * NL+ jl = m // Ww+ il = m - jl * Ww+ cells.append((L, il + i0, jl + j0))+ n = parent[n]+ cells.reverse()+ blockers = set()+ if soft:+ r = int(math.ceil((RULES["clearance"] + width / 2.0 + SLOP) / RES)) + 1+ for (L, i, j) in cells:+ if sm[L] is not None and sm[L][(j - j0) * Ww + (i - i0)]:+ sub = self.route_raw[L][max(0, j - r):j + r + 1, max(0, i - r):i + r + 1]+ for v in np.unique(sub):+ if v != -1 and v != net.idx:+ blockers.add(int(v))+ return cells, blockers++ # -- per net -----------------------------------------------------------------------+ def window_for(self, pts, margin_mm):+ m = int(round(margin_mm / RES))+ xs = [p[0] for p in pts]+ ys = [p[1] for p in pts]+ return (max(0, min(xs) - m), max(0, min(ys) - m), min(self.W - 1, max(xs) + m), min(self.H - 1, max(ys) + m))++ def copper_targets(self, net):+ """Every cell of the net's connected copper: connected clusters' terminals and route centrelines."""+ t = set()+ for c in net.clusters:+ if c.connected:+ t.update(c.terminals)+ for r in net.routes:+ t.update(r.cells)+ return t++ def vip_cells(self, net, clusters):+ """Direct SMD pad terminals where a via may sit in the pad (clear of every other net)."""+ out = []+ for c in clusters:+ if c.primary.thru or c.escapes:+ continue+ for t in c.terminals:+ if t[0] == F_CU and self._via_clear_exact(net, *self.grid.to_xy(t[1], t[2])):+ out.append(t)+ return out++ def connect(self, net, cluster, anchor_pts, soft=False, vip=False):+ """Route `cluster` to the net's existing copper. Returns (Route|None, blockers).++ vip=True is the last resort before rip-up: a via may sit in the pad of the cluster being+ routed or in the pad it lands on when every exit on F.Cu is walled in."""+ targets = self.copper_targets(net)+ sources = []+ for t in cluster.terminals:+ sources.append((t[0], t[1], t[2], 0))+ widths = [w for w in net.widths if w <= cluster.allow] or [min(net.widths)]+ src_pts = [(t[1], t[2]) for t in cluster.terminals]+ allpts = src_pts + list(anchor_pts)+ vip_src, vip_tgt = [], []+ if vip:+ vip_src = self.vip_cells(net, [cluster])+ vip_tgt = self.vip_cells(net, [c for c in net.clusters if c.connected])+ targets = set(targets) | {(B_CU, t[1], t[2]) for t in vip_tgt}+ if soft:+ stages = [(12.0, 200000)]+ elif vip:+ stages = [(12.0, 200000)]+ else:+ stages = [(5.0, None), (12.0, None), (80.0, 250000)]+ for width in widths:+ for margin, cap in stages:+ win = self.window_for(allpts, margin)+ cells, blockers = self.search(net, width, sources, targets, anchor_pts, win, soft=soft, max_nodes=cap, vip=vip_src)+ if cells:+ start_term = cells[0]+ end_cell = cells[-1]+ start = ("escape", cluster, start_term) if start_term in cluster.escapes else ("pad", cluster, start_term)+ end = ("copper",)+ for c in net.clusters:+ if c.connected and (end_cell in c.terminals or (end_cell[0] == B_CU and (F_CU, end_cell[1], end_cell[2]) in c.terminals and end_cell in targets)):+ end = ("escape", c, end_cell) if end_cell in c.escapes else ("pad", c, end_cell)+ route = Route(cells, width, start, end)+ if end[0] == "pad" and end_cell[0] == B_CU and not end[1].primary.thru:+ route.via_end = True+ route.via_end_layer = "F.Cu"+ self.stats["viaInPad"] += 1+ net.via_in_pad.append(end[1].key)+ if len(cells) > 1 and cells[0][1:] == cells[1][1:] and cells[0][0] != cells[1][0] and not cluster.primary.thru:+ self.stats["viaInPad"] += 1+ net.via_in_pad.append(cluster.key)+ return route, blockers+ return None, set()++ def route_net(self, net):+ """Prim's order over clusters; each cluster to the existing copper. Returns list of blocker net ids on failure."""+ clusters = [c for c in net.clusters]+ for c in clusters:+ c.connected = False+ net.routes = []+ net.failed = []+ net.last_soft = []+ # start from the biggest pad (the trunk grows from the power pads)+ start = max(clusters, key=lambda c: (c.allow, c.primary.half_long * c.primary.half_short))+ start.connected = True+ connected = [start]+ remaining = [c for c in clusters if c is not start]+ blockers_all = set()+ # Kelvin taps first: sense pad to shunt pad as a dedicated trace, then the rest of the net treats+ # that trace as foreign copper so the wide current path never merges with it+ for c in list(remaining):+ if c.key in RULES["kelvin"]:+ dst = next((k for k in clusters if k.key == RULES["kelvin"][c.key]), None)+ if dst is None:+ continue+ if self.route_kelvin(net, c, dst):+ remaining.remove(c)+ connected.append(c)+ if dst in remaining:+ remaining.remove(dst)+ connected.append(dst)+ else:+ net.failed.append((c.key, "kelvin tap to %s found no dedicated path" % dst.key))+ remaining.remove(c)+ while remaining:+ best = None+ for c in remaining:+ for k in connected:+ d = math.dist(c.xy, k.xy)+ if best is None or d < best[0]:+ best = (d, c, k)+ d, c, k = best+ remaining.remove(c)+ anchor = [(t[1], t[2]) for t in k.terminals]+ route, _ = self.connect(net, c, anchor)+ if route is None:+ route, _ = self.connect(net, c, anchor, vip=True)+ if route is None:+ soft_route, blockers = self.connect(net, c, anchor, soft=True)+ blockers_all |= blockers+ if soft_route is not None:+ net.last_soft.extend(soft_route.cells)+ net.failed.append((c.key, "no path" if not blockers else "blocked by %s" % sorted(self.name_of(b) for b in blockers)))+ c.connected = False+ continue+ self.commit(net, route)+ c.connected = True+ connected.append(c)+ return blockers_all++ def name_of(self, nid):+ if nid in self.kelvin_owner:+ return self.kelvin_owner[nid].name+ for n in self.nets.values():+ if n.idx == nid:+ return n.name+ return "?"++ def commit(self, net, route):+ # junction: if the route ends inside another route of this net, split it there+ end = route.cells[-1]+ for r in net.routes:+ if end in r.cells:+ r.breaks.add(r.cells.index(end))+ for term in (route.start, route.end):+ if term[0] == "escape":+ net.used_escapes.add((term[1].idx, term[2]))+ net.routes.append(route)+ self.stamp_route(net, route, 1)+ # a routed cluster no longer needs its other escape: release that reservation+ for term in (route.start, route.end):+ if term[0] in ("escape", "pad"):+ self.release_escapes(net, term[1])+ if term[0] == "escape":+ self._stamp_channel(net, term[1], term[2], -1)++ def release_escapes(self, net, cluster):+ for t in list(cluster.escapes):+ if (cluster.idx, t) not in net.used_escapes and (cluster.idx, t) not in cluster.__dict__.setdefault("released", set()):+ self.stamp_stub(net, cluster, t, -1)+ cluster.released.add((cluster.idx, t))++ def reserve_escapes(self, net, cluster):+ """Re-reserve a cluster's escapes after a rip-up. An escape that was released and has since+ been crossed by another net's copper (a via dropped into the freed corridor) stays dead:+ re-stamping it would let the next route leave through that copper."""+ g = self.grid+ for t in list(cluster.escapes):+ if (cluster.idx, t) in cluster.__dict__.get("released", set()):+ L, i, j = t+ a = cluster.primary.polys_centre+ b = g.to_xy(i, j)+ pts = [(a, b, RULES["stub"] / 2.0 + RULES["clearance"])]+ kend = cluster.corridors.get(t)+ if kend is not None:+ pts.append((b, kend, RULES["track"] / 2.0 + RULES["clearance"]))+ blocked = False+ for (s0, s1, hw) in pts:+ jj, ii = g.segment_cells(s0, s1, hw, 1e-6)+ raw = self.route_raw[L][jj, ii]+ if np.any((raw != -1) & (raw != net.idx)):+ blocked = True+ break+ if blocked:+ if t in cluster.terminals and len(cluster.terminals) > 1:+ cluster.terminals.remove(t)+ self.stats["deadEscapes"] += 1+ continue+ self.stamp_stub(net, cluster, t, 1)+ cluster.released.discard((cluster.idx, t))+ else:+ self._stamp_channel(net, cluster, t, 1)++ def rip_cluster(self, net, cluster):+ """Rip the stub of one plane-net cluster (its route to the plane) so a signal can pass; the+ cluster is re-queued as a plane retry."""+ keep = []+ for r in net.routes:+ if r.start[1] is cluster:+ self.stamp_route(net, r, -1)+ for (L, i, j) in r.cells:+ self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1+ else:+ keep.append(r)+ net.routes = keep+ net.used_escapes = {(ci, t) for (ci, t) in net.used_escapes if ci != cluster.idx}+ cluster.connected = False+ self.reserve_escapes(net, cluster)+ if cluster.key not in {k for k, _ in net.failed}:+ net.failed.append((cluster.key, "ripped for a signal"))+ self.stats["rips"] += 1+ self.rip_total += 1++ def rip(self, net):+ g = self.grid+ for r in net.routes:+ self.stamp_route(net, r, -1)+ # remember where the contested copper was: a little dearer for every later search+ for (L, i, j) in r.cells:+ self.history[L][max(0, j - 2):j + 3, max(0, i - 2):i + 3] += 1+ net.routes = []+ net.used_escapes = set()+ for c in net.clusters:+ c.connected = False+ self.reserve_escapes(net, c)+ net.rips += 1+ self.rip_total += 1+ self.stats["rips"] += 1++ # -- Kelvin taps -------------------------------------------------------------------------+ def route_kelvin(self, net, src, dst):+ """One dedicated trace from the sense pad `src` to the shunt pad `dst` (same net): every other+ piece of this net is an obstacle for the search (a synthetic net id owns the two pads and the+ trace), no vias (a via would tie the tap to the plane), narrow width, the pad's own layer."""+ g = self.grid+ kid = 30000 + (src.idx % 1000) # a net id no board net uses; int16 raw maps hold it+ while any(n.idx == kid for n in self.nets.values()):+ kid += 1+ fake = NetState(kid, net.name, [])+ self.kelvin_owner[kid] = net+ saved = []+ for cl in (src, dst):+ p = cl.primary+ jj, ii = g.disk_cells((p.x, p.y), p.half_long + RULES["escape"] + RULES["corridor"] + 0.3, 1e-6)+ for L in (F_CU, B_CU):+ m = self.pad_raw[L][jj, ii] == net.idx+ saved.append((L, jj[m], ii[m]))+ self.pad_raw[L][jj[m], ii[m]] = kid+ try:+ sources = [(t[0], t[1], t[2], 0) for t in src.terminals]+ targets = set(dst.terminals)+ pts = [(t[1], t[2]) for t in src.terminals] + [(t[1], t[2]) for t in dst.terminals]+ cells = None+ for margin in (4.0, 10.0):+ win = self.window_for(pts, margin)+ # a via on a plane-net tap would tie it to the plane; a tap on a signal net may change layer+ cells, _ = self.search(fake, RULES["stub"] + 0.05, sources, targets, [(t[1], t[2]) for t in dst.terminals], win, allow_via=not net.plane)+ if cells:+ break+ finally:+ for (L, pj, pi) in saved:+ self.pad_raw[L][pj, pi] = net.idx+ if not cells:+ return False+ start = ("escape", src, cells[0]) if cells[0] in src.escapes else ("pad", src, cells[0])+ end = ("escape", dst, cells[-1]) if cells[-1] in dst.escapes else ("pad", dst, cells[-1])+ r = Route(cells, RULES["stub"] + 0.05, start, end)+ r.kelvin = True+ self.commit(net, r)+ # the trace is foreign copper to the rest of its own net from here on+ for (L, i, j) in cells:+ jj, ii = g.disk_cells(g.to_xy(i, j), r.width / 2.0, 1e-6)+ self.route_raw[L][jj, ii] = kid+ src.connected = True+ dst.connected = True+ self.stats["kelvin"] += 1+ log(" kelvin %s -> %s: %d cells, dedicated" % (src.key, dst.key, len(cells)))+ return True++ # -- planes --------------------------------------------------------------------------+ def route_plane_net(self, net, retry=False):+ g = self.grid+ if retry:+ failed = {k for k, _ in net.failed}+ net.failed = []+ for c in net.clusters:+ if c.primary.thru:+ c.connected = True+ continue+ if c.key in RULES["kelvin"]:+ continue # a Kelvin sense pad never gets a plane stub; route_kelvin gives it its trace+ if retry and (c.connected or c.key not in failed):+ continue+ sources = [(t[0], t[1], t[2], 0) for t in c.terminals]+ win = self.window_for([(t[1], t[2]) for t in c.terminals], RULES["via_search_mm"])+ # a via site, or the net's own copper already on its way to the plane (a neighbour's stub)+ own = {cell for cell in self.copper_targets(net) if cell[0] == F_CU}+ cells, _ = self.search(net, RULES["track"], sources, own, [], win, allow_via=False, via_target=True)+ if cells is None:+ # via in pad: the pad centre if a via there clears every other net+ i, j = g.to_cell(*c.xy)+ ok = self._via_clear_exact(net, c.primary.x, c.primary.y)+ if ok:+ r = Route([(F_CU, i, j)], RULES["track"], ("pad", c, (F_CU, i, j)), ("via",))+ r.via_end = True+ self.commit(net, r)+ c.connected = True+ net.via_in_pad.append(c.key)+ self.stats["viaInPad"] += 1+ else:+ net.failed.append((c.key, "no via site within %.0f mm" % RULES["via_search_mm"]))+ continue+ start_term = cells[0]+ start = ("escape", c, start_term) if start_term in c.escapes else ("pad", c, start_term)+ joined = cells[-1] in own+ r = Route(cells, RULES["track"], start, ("copper",) if joined else ("via",))+ r.via_end = not joined+ self.commit(net, r)+ c.connected = True++ def _via_clear_exact(self, net, x, y):+ """Exact geometry: may a via of this net sit at (x, y)? Pads, routes and vias of other nets,+ every through pad, the outline and existing vias of any net are checked with real distances."""+ g = self.grid+ rv = net.via / 2.0+ i, j = g.to_cell(x, y)+ if not g.in_bounds(i, j) or not self.inside_for(net.via)[j, i]:+ return False+ reach = rv + self.big_clr_val + 1.5+ for p in self.board.pads:+ if abs(p.x - x) > reach + p.half_long or abs(p.y - y) > reach + p.half_long:+ continue+ if p.net == net.name and not p.thru:+ continue+ if p.thru and p.net == net.name:+ need = net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0 + SLOP # same net: the holes must not meet+ else:+ need = rv + max(RULES["clearance"], p.clearance)+ if p.thru and p.net != net.name:+ need = max(need, net.drill / 2.0 + RULES["hole_to_hole"] + (p.drill or 0) / 2.0)+ for poly in p.polys:+ if _point_poly_distance(x, y, poly) < need:+ return False+ for other in self.nets.values():+ if other.idx == net.idx:+ continue+ for r in other.routes:+ need = rv + RULES["clearance"] + r.width / 2.0+ for (ax, ay, bx, by) in _route_segments(g, r.cells):+ if abs(ax - x) > 3 and abs(bx - x) > 3:+ continue+ if _seg_distance(x, y, ax, ay, bx, by) < need:+ return False+ for (vi, vj, nid, dia, drill) in self.vias:+ vx, vy = g.to_xy(vi, vj)+ if math.hypot(vx - x, vy - y) < self.via_gap(net, nid, dia, drill):+ return False+ return True++ def _via_clear_other(self, net, i, j):+ g = self.grid+ r = (RULES["clearance"] + net.via / 2.0 + SLOP) / RES+ R = int(math.ceil(r)) + 1+ for L in (F_CU, B_CU):+ for raw in (self.pad_raw[L], self.route_raw[L]):+ sub = raw[max(0, j - R):j + R + 1, max(0, i - R):i + R + 1]+ jj, ii = np.nonzero((sub != -1) & (sub != net.idx))+ for a, b in zip(jj, ii):+ if (a + max(0, j - R) - j) ** 2 + (b + max(0, i - R) - i) ** 2 <= r * r:+ return False+ for (vi, vj, nid, dia, drill) in self.vias:+ if math.hypot(vi - i, vj - j) * RES < self.via_gap(net, nid, dia, drill):+ return False+ return bool(self.inside_for(net.via)[j, i])++ # -- driver --------------------------------------------------------------------------+ def mst_length(self, net):+ pts = [c.xy for c in net.clusters]+ if len(pts) < 2:+ return 0.0+ inn = [pts[0]]+ rest = pts[1:]+ total = 0.0+ while rest:+ best = min(((math.dist(a, b), b) for a in inn for b in rest), key=lambda x: x[0])+ total += best[0]+ inn.append(best[1])+ rest.remove(best[1])+ return total++ def run(self, only=None, planes=True, rip_per_net=8, rip_global=400, priority=None):+ t0 = time.time()+ self.rip_total = 0+ for n in self.nets.values():+ n.rips = 0+ if planes:+ # plane stubs first: the shortest connections on the board, and their vias then shape+ # the signal routing instead of hunting for holes in it afterwards+ for name in RULES["planes"]:+ net = self.nets.get(name)+ if net is None or (only is not None and name not in only):+ continue+ t1 = time.time()+ for c in net.clusters:+ if c.key in RULES["kelvin"]:+ dst = next((k for k in net.clusters if k.key == RULES["kelvin"][c.key]), None)+ if dst is None or not self.route_kelvin(net, c, dst):+ net.failed.append((c.key, "kelvin tap found no dedicated path"))+ self.route_plane_net(net)+ log(" %-28s plane %s: %d smd stubs, %d via-in-pad, %d failed, %.1fs" % (+ net.name, net.plane, len(net.routes) - len(net.via_in_pad), len(net.via_in_pad), len(net.failed), time.time() - t1))+ signal = [n for n in self.nets.values() if not n.plane and len(n.clusters) >= 2 and (only is None or n.name in only)]+ for n in signal:+ n.mst_len = self.mst_length(n)+ first = [n for n in signal if n.name in (priority or ())]+ rest = [n for n in signal if n.name not in (priority or ())]+ queue = sorted(first, key=lambda n: n.mst_len) + sorted(rest, key=lambda n: n.mst_len)+ log("routing %d signal nets, shortest first (%.1f mm .. %.1f mm MST)%s" % (+ len(queue), queue[0].mst_len if queue else 0, queue[-1].mst_len if queue else 0,+ "" if not first else ", %d nets that failed last pass go first: %s" % (len(first), [n.name for n in first])))+ done = []+ order_no = 0+ best_state = None+ while queue:+ net = queue.pop(0)+ if net.routes:+ self.rip(net)+ t1 = time.time()+ blockers = self.route_net(net)+ order_no += 1+ net.routed_order = order_no+ log(" %-28s %2d clusters %s %d routes, %d failed, %.1fs%s" % (+ net.name, len(net.clusters), "/".join("%.2g" % w for w in net.widths), len(net.routes), len(net.failed), time.time() - t1,+ "" if not net.failed else " FAILED %s" % net.failed))+ score = sum(len(n.failed) for n in self.nets.values() if n not in queue) + sum(max(0, len(n.clusters) - 1) for n in queue)+ if best_state is None or score < best_state[0]:+ best_state = (score, self.snapshot())+ if net.failed and blockers and net.rips < rip_per_net and self.rip_total < rip_global:+ cands = sorted((self.nets[self.name_of(b)] for b in blockers if b not in self.kelvin_owner and self.name_of(b) in self.nets and self.nets[self.name_of(b)].routes and not self.nets[self.name_of(b)].plane),+ key=lambda n: -(n.routed_order or 0))[:4]+ stubs = []+ soft = set()+ for (L, i, j) in net.last_soft:+ for di in range(-6, 7):+ for dj in range(-6, 7):+ soft.add((i + di, j + dj))+ # plane stubs are ripped only when nothing else blocks: a stub moves easily, a+ # signal net that has to be re-routed around a moved stub does not+ for b in (blockers if not cands else ()):+ pn = self.nets[self.name_of(b)]+ if not pn.plane:+ continue+ for r in list(pn.routes):+ if any((i, j) in soft for (_, i, j) in r.cells):+ stubs.append((pn, r.start[1]))+ if cands or stubs:+ log(" rip-up %s%s and retry %s" % ([c.name for c in cands], "" if not stubs else " + plane stubs %s" % [c.key for _, c in stubs], net.name))+ for c in cands:+ self.rip(c)+ if c in queue:+ queue.remove(c)+ for pn, cl in stubs:+ self.rip_cluster(pn, cl)+ net.rips += 1+ queue.insert(0, net)+ for c in cands:+ queue.insert(1, c)+ continue+ # a signal landed: give any ripped plane stubs their spot back right away+ for name in RULES["planes"]:+ pn = self.nets.get(name)+ if pn is not None and pn.failed and planes:+ self.route_plane_net(pn, retry=True)+ done.append(net)+ if planes:+ # second chance for plane stubs that were walled in by reservations the signal routing has since released+ for name in RULES["planes"]:+ net = self.nets.get(name)+ if net is None or not net.failed or (only is not None and name not in only):+ continue+ before = len(net.failed)+ self.route_plane_net(net, retry=True)+ log(" %-28s plane retry: %d of %d stubs recovered" % (net.name, before - len(net.failed), before))+ final = sum(len(n.failed) for n in self.nets.values())+ if best_state is not None and best_state[0] < final:+ log(" restoring the best intermediate state (%d failed connections instead of %d)" % (best_state[0], final))+ self.restore(best_state[1])+ if planes:+ for name in RULES["planes"]:+ pn = self.nets.get(name)+ if pn is not None and pn.failed:+ self.route_plane_net(pn, retry=True)+ final = sum(len(n.failed) for n in self.nets.values())+ log("routing done in %.1fs, %d node expansions, %d rip-ups, %d failed connections" % (time.time() - t0, self.stats["expanded"], self.stats["rips"], final))+ return final++ def reset(self):+ """Drop every route and reservation state so a fresh pass can start (history is kept)."""+ for net in self.nets.values():+ if net.routes:+ self.rip(net)+ net.failed = []+ net.via_in_pad = []+ net.routed_order = None+ net.used_escapes = set()+ for c in net.clusters:+ c.connected = False+ self.rip_total = 0++ def snapshot(self):+ return {name: (list(n.routes), set(n.used_escapes), list(n.failed), list(n.via_in_pad), n.routed_order) for name, n in self.nets.items()}++ def restore(self, snap):+ self.reset()+ for name, (routes, used, failed, vip, order) in snap.items():+ net = self.nets[name]+ net.used_escapes = set(used)+ net.failed = list(failed)+ net.via_in_pad = list(vip)+ net.routed_order = order+ for r in routes:+ net.routes.append(r)+ self.stamp_route(net, r, 1)+ for c in net.clusters:+ c.connected = any(r.start[1] is c or (r.end[0] in ("pad", "escape") and r.end[1] is c) for r in routes) or (net.plane and c.primary.thru)++ # -- plan emission -----------------------------------------------------------------------+ def _waypoints(self, net, route):+ g = self.grid+ cells = route.cells+ pts = []++ def xy(c):+ return list(g.to_xy(c[1], c[2]))++ # start+ kind, cl, term = route.start+ if kind == "pad":+ if term[0] == B_CU:+ pts.append({"pad": cl.primary.key, "layer": "B.Cu"})+ else:+ pts.append(cl.primary.key)+ else:+ pts.append(xy(cells[0]))+ # middle: collinear merge with forced breaks and layer changes+ k = 1+ n = len(cells)+ while k < n:+ L, i, j = cells[k]+ pL, pi, pj = cells[k - 1]+ if L != pL:+ # via at this cell+ pts.append({"x": xy(cells[k])[0], "y": xy(cells[k])[1], "layer": LAYER_NAMES[L]})+ k += 1+ continue+ di, dj = i - pi, j - pj+ m = k+ while m + 1 < n and cells[m + 1][0] == L and (cells[m + 1][1] - cells[m][1], cells[m + 1][2] - cells[m][2]) == (di, dj) and m not in route.breaks:+ m += 1+ if m == n - 1:+ break+ pts.append(xy(cells[m]))+ k = m + 1+ # end+ last = cells[-1]+ ek = route.end[0]+ if route.via_end:+ p = xy(last)+ if len(cells) == 1 and kind == "pad":+ p = [cl.primary.x, cl.primary.y]+ pts.append({"x": p[0], "y": p[1], "layer": route.via_end_layer})+ if ek == "pad":+ pts.append(route.end[1].primary.key)+ elif ek == "pad":+ cl = route.end[1]+ if pts and isinstance(pts[-1], dict) and pts[-1].get("layer") and len(cells) > 1 and cells[-1][1:] == cells[-2][1:]:+ pass+ pts.append(cl.primary.key)+ else:+ pts.append(xy(last))+ # a via exactly at the last cell followed by the pad name: keep both (the marker then the pad)+ return pts++ def plan(self):+ entries = []+ g = self.grid+ for net in sorted(self.nets.values(), key=lambda n: (n.routed_order or 10 ** 6, n.name)):+ if not net.routes:+ continue+ by_width = defaultdict(list)+ for r in net.routes:+ pts = self._waypoints(net, r)+ if len(pts) < 2:+ continue+ if getattr(r, "kelvin", False):+ entries.append({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": True})+ continue+ by_width[r.width].append(pts)+ stubs = []+ for (cidx, term) in sorted(net.used_escapes):+ cl = net.clusters[cidx]+ stubs.append([cl.primary.key, list(g.to_xy(term[1], term[2]))])+ if stubs:+ entries.append({"net": net.name, "width": RULES["stub"], "viaSize": net.via, "viaDrill": net.drill, "paths": stubs})+ for w in sorted(by_width, reverse=True):+ entries.append({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": by_width[w]})+ return entries++ def report(self):+ nets = {}+ for net in self.nets.values():+ if len(net.clusters) < 2 and not net.plane:+ continue+ nets[net.name] = {+ "clusters": len(net.clusters), "pads": len(net.pads), "routes": len(net.routes),+ "widths": sorted({r.width for r in net.routes}, reverse=True), "plane": net.plane,+ "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order,+ "mstMm": round(net.mst_len, 2),+ }+ return {"rules": RULES, "nets": nets, "stats": dict(self.stats),+ "unrouted": [(n, f) for n in nets for f in nets[n]["failed"]]}+++def main(argv=None):+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+ ap.add_argument("board")+ ap.add_argument("--out", help="plan JSON (default: <board dir>/fable-esc-routing-plan.json)")+ ap.add_argument("--report", help="router report JSON")+ ap.add_argument("--nets", help="comma separated subset of nets")+ ap.add_argument("--no-planes", action="store_true")+ ap.add_argument("--passes", type=int, default=3, help="full passes; nets that failed a pass are routed first in the next")+ ap.add_argument("--rip-per-net", type=int, default=8); ap.add_argument("--rip-global", type=int, default=400)+ ap.add_argument("--config", help="JSON overriding the board rules: wide, mid, planes, kelvin, via sizes, track, clearance")+ ap.add_argument("--verbose", action="store_true")+ a = ap.parse_args(argv)+ if a.config:+ cfg = json.load(open(a.config, encoding="utf-8"))+ for k in ("wide", "mid", "planes", "kelvin"):+ if k in cfg:+ RULES[k] = cfg[k]+ for k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "via_search_mm"):+ if k in cfg:+ RULES[k] = float(cfg[k])+ board = Board(a.board)+ r = Router(board, a.verbose)+ only = set(a.nets.split(",")) if a.nets else None+ best = None+ priority = None+ for k in range(1, a.passes + 1):+ if k > 1:+ r.reset()+ log("=== pass %d of %d ===" % (k, a.passes))+ failed = r.run(only=only, planes=not a.no_planes, priority=priority, rip_per_net=a.rip_per_net, rip_global=a.rip_global)+ names = sorted({n.name for n in r.nets.values() if n.failed})+ log("=== pass %d: %d failed connections in %s" % (k, failed, names))+ if best is None or failed < best[0]:+ best = (failed, r.snapshot(), k)+ if failed == 0:+ break+ priority = names+ if best is not None:+ r.restore(best[1])+ log("keeping pass %d (%d failed connections)" % (best[2], best[0]))+ entries = r.plan()+ rep = r.report()+ out = a.out or os.path.join(os.path.dirname(os.path.abspath(a.board)), "fable-esc-routing-plan.json")+ with open(out, "w", encoding="utf-8") as f:+ json.dump({"engine": "Claude Fable 5.1 grid maze router (ai_router.py)", "board": os.path.basename(a.board),+ "rules": {k: v for k, v in RULES.items() if k in ("track", "clearance", "via", "drill", "via_small", "drill_small", "edge", "planes", "wide", "mid")},+ "nets": entries}, f, indent=1)+ log("plan: %s (%d entries, %d nets)" % (out, len(entries), len({e["net"] for e in entries})))+ if rep["unrouted"]:+ log("UNROUTED: %s" % rep["unrouted"])+ if a.report:+ with open(a.report, "w", encoding="utf-8") as f:+ json.dump(rep, f, indent=1)+ return 0 if not rep["unrouted"] else 3+++if __name__ == "__main__":+ sys.exit(main())
reference/python/runlog.pyadded+94@@ -0,0 +1,94 @@+"""The run manifest: one JSON file per run that carries the clock, the plan, the decisions,+the stage outcomes, the capture markers and the final summary. Every command reads and+writes it, so the comparison between engines is what the file says, not what anyone remembers."""+import json+import os+import time+from pathlib import Path++STAGES = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"]+++def now():+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())+++def parse(ts):+ return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone+++class Run:+ def __init__(self, path):+ self.dir = Path(path)+ self.file = self.dir / "run.json"+ self.data = json.load(open(self.file, encoding="utf-8")) if self.file.is_file() else None++ def exists(self):+ return self.data is not None++ def save(self):+ self.dir.mkdir(parents=True, exist_ok=True)+ tmp = self.file.with_suffix(".tmp")+ json.dump(self.data, open(tmp, "w", encoding="utf-8"), indent=1)+ os.replace(tmp, self.file)++ @staticmethod+ def create(path, board, spec, engine, prompt_time, ai_thread, target=None, remote_board=None):+ r = Run(path)+ r.data = {+ "version": 1, "engine": engine, "aiThread": ai_thread, "board": str(board), "spec": spec,+ "target": target, "remoteBoard": remote_board,+ "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": None},+ "plan": None, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [str(board)],+ "outcomes": {}, "summary": None,+ }+ r.save()+ return r++ # -- stages ---------------------------------------------------------------------+ def stage_start(self, name, engine=None, note=None):+ st = self.data["stages"].setdefault(name, {"runs": []})+ st["runs"].append({"start": now(), "end": None, "engine": engine or self.engine_for(name), "note": note, "result": None})+ self.mark("stage-start:" + name)+ self.save()++ def stage_end(self, name, result=None, note=None):+ st = self.data["stages"].setdefault(name, {"runs": []})+ if not st["runs"] or st["runs"][-1]["end"] is not None:+ st["runs"].append({"start": now(), "end": None, "engine": self.engine_for(name), "note": None, "result": None})+ st["runs"][-1]["end"] = now()+ if result is not None:+ st["runs"][-1]["result"] = result+ if note:+ st["runs"][-1]["note"] = note+ self.mark("stage-end:" + name)+ self.save()++ def engine_for(self, name):+ return self.data["decisions"].get(name) or ((self.data.get("plan") or {}).get("stages", {}).get(name, {}) or {}).get("default", "binary")++ def mark(self, label):+ self.data["markers"].append({"t": now(), "label": label})++ def current_board(self):+ return self.data["boards"][-1]++ def add_board(self, path):+ self.data["boards"].append(str(path))+ self.save()++ # -- time -----------------------------------------------------------------------+ def elapsed_minutes(self):+ t0 = parse(self.data["clock"]["promptTime"])+ t1 = parse(self.data["clock"]["finishTime"]) if self.data["clock"]["finishTime"] else time.time()+ return round((t1 - t0) / 60.0, 1)++ def stage_minutes(self):+ out = {}+ for name, st in self.data["stages"].items():+ tot = 0.0+ for r in st["runs"]:+ if r["end"]:+ tot += parse(r["end"]) - parse(r["start"])+ out[name] = round(tot / 60.0, 1)+ return out
screenshots/esc-finish.pngadded⋯ 1 unchanged line ⋯
screenshots/hero.png⋯ 1 unchanged line ⋯
skills/adom-aiflow/SKILL.mdadded+83@@ -0,0 +1,83 @@+---+name: adom-aiflow+description: The board loop as one command for KiCad boards on the Adom KiCad Bridge: start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+---++# adom-aiflow: the loop, measured++This tool is the place-route-pour-analyze skill made executable: the step order is a file, the gates refuse, the hints arrive as the answer to the command you just ran, and the ledger records what you did. Follow the commands and the skill follows itself.++Install: `adom-wiki pkg install adom/adom-aiflow` (one Rust binary; `service-kicad` for the offline DRC gate, and the KiCad Bridge on a desktop for the live stages). `adom-aiflow --version`.++Every state-changing command takes `--ai-thread "<your thread name>"`. Every run lives in one directory (`--run DIR`, default `./aiflow-run`) with a `run.json` manifest: the clock, the plan, the decisions, every stage's start and end, the outcomes, the capture markers and the summary. The manifest is the comparison; nothing counts that is not in it.++## The flow++The flow is a file, `flows/board.json`: the steps in order (intake, models, placement, routing, pours, current, thermal, nets, 3d, capture, finish), who does each, what the binary offers at each, and the steps that come later (components, libraries, schematic, simulation before the board; moleculize, paste, probe after it). `models` (kicad_model_check) refuses to go on while any footprint lacks its 3D model, so the board renders as it will be built. `nets` (`tour nets`) selects each key net as a whole through the IPC API, pours, tracks, vias and pads together, and frames it with Zoom to Selected Objects, one net at a time on camera. `3d` (`tour 3d`) opens the 3D Viewer and runs the walkthrough by View-menu commands (kicad_3d_view: fit, top, tilt, orbit, zoom, swing, front, bottom, flip), no mouse, with the viewer window as the step's camera. `plan` prints the flow.++1. **`start --board B.kicad_pcb --spec spec.json --engine <you> --prompt-time <UTC of the paste> [--target <box> --remote-board <path on the box>]`**: copies the board into the run and starts the clock at the prompt, not at your first command. Record the paste time honestly.+2. **`plan`**: the stages this board needs and who can take each. Placement is yours (the binary helps: `place pack --wish`, `place check --moves`, `place land --moves`). Routing is the binary's grid router by default; say `take route=ai` if you route yourself. Pours, gate, landing, measurement, both analyses and the finish are the binary's.+3. **`stage start <name>` / `stage end <name>`** around any stage you do yourself, so the clock and the manifest see it.+4. **`route [--passes N]`**: the grid router on the current board with the spec's rules (wide nets, planes, per-net vias, Kelvin taps). If it closes short, the ERROR names the pins and the blockers and the three levers: more passes, back to placement (`place pack` the parts it names or the test points and indicators in the escape band, `place check`, route again), or take the stage yourself.+5. **`gate`**: KiCad's DRC offline on the plan, inherited errors separated. Nothing lands until it passes.+6. **`land route`**, then **`pour`**, **`land vias`**, **`land pours`**: live through the bridge as native undo steps, each DRC-checked before it lands. A refused pour's ERROR says why (starved thermal reliefs, intersecting zones, clearance) and what to change in the spec. Both landings converge the live board on the plan, so you change the spec, run `pour` and land again: a via already at its planned place is not landed twice, a zone with its planned name and outline is kept, and every zone the flow landed that the plan no longer describes (renamed, reshaped, dropped) is removed first. Zones the board started with are never touched.+7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again.+8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`.++## The fields step (adom-fields)++After the pours and the analyses: `adom-fields analyze --board <the current board> --spec <spec> --out fields` (about two minutes; `adom-wiki pkg install adom/adom-fields` if it is missing), `adom-fields serve --fields fields --port 8874 &`, then `step fields` and `tour fields`: the binary opens the app in a pup window on the test box, films that window while `adom-fields tour` walks the board gently (the app paces itself: slow eased moves about nine seconds long, the board drifting a few degrees through every held beat, one copper pour isolated at a time, a quick turn-over to the bottom layer, because the flow plays this clip back at ten times speed, where a snap reads as frenetic) (each net lit, the hot parts, the issues flown to; the app's toasts are the captions), and records the issues in the run. The issues (a current neck, a tab over its rise budget, a chip missing its thermal vias) are yours to answer before delivery; the app's screenshots are drawings you can register with `artifact`.++## Time: sessions, not the wall clock++The AI's time on a run is the sum of its sessions: one per human prompt, from the prompt to the AI's done, with idle gaps (over 15 min, no command running) cut out. The first prompt is `start --prompt-time`; every follow-up prompt is `prompt --text "..."` (`--at <time>` when you start late); the answer's end is `done --message "..."` (`deliver` is the done of the first task). Say `done` the moment an answer is complete, so nothing idle is charged; work after a done with no `prompt` mark is listed as an unmarked follow-up, one row per idle gap. The delivery number on the page is the AI time from the first prompt to `deliver`; follow-ups have their own rows. `sessions` prints the table. The per-step table and the video's run clock ("AI TIME, THIS RUN") count active seconds only.++## Landing is one at a time, on purpose++`place land` moves ONE PART per call and `land route` draws ONE TRACE per call (a net with several paths lands as several undo steps), each its own moment on camera, `--pause-ms` apart (600 by default). That is what makes the video show the board filling part by part and every trace being drawn, instead of an empty board that is suddenly full. It costs wall time: the ESC lands 122 parts and about 320 traces at roughly five seconds each, so budget half an hour for the routing landing and a few minutes for the placement. Never batch them to save time; the clip is the evidence. `--pause-ms 300` is the shortest pace the recorder still catches.++## Capture, so the engines' videos line up++One clip per step. `capture open` puts the board on the test box; from then on every `step <name>` stops the previous step's clip and starts this step's own window recording, tagged with the step, and its hint says what that clip should show (the flow file's `record` line: the parts landing for placement, the nets landing for routing, the pours filling for pours, the return to an earlier step when an analysis fails). `deliver` lists the clips. The final video is cut from them, one segment per step, so two engines' videos line up step for step, and the page can show a little clip beside every step's numbers.++Per-step clips stay RAW: the recording, its 10x cut and its motion-only action cut carry no captions, because the final video (and a side-by-side of two runs) draws its own overlays from the ledger. `compose` builds the final 1920x1080 video, two minutes at most (`--max-seconds 120`): each step visit's motion only, in order, with the step chip, the engine and board, and the run and step timers as small hr / min / sec tables in the lower right (Adom faces); the AI's analysis drawings fullscreen for five seconds each; adom-tts narration cut to fit each segment (the picture leads, the words are shortened or said faster, never the other way round); when the motion adds up to more than the budget every frame plays faster, evenly. No title pages, no held frames. A visit with under a second of motion stays on the page and out of the video. The page carries each cut under a new name (`final-<time>.mp4`) so a cached one never shows.++After `analyze current` and `analyze thermal`, do the analysis yourself and draw it: a current density map on the copper (a DC conduction solve on the board's filled polygons between each loaded net's terminals) and for heat two drawings: the heat flow in the copper layer by layer, like the current picture (a multi-layer solve, each chip's watts spreading on its layer and going down its vias, the vias drawn, each chip's split between layer, vias and bare dielectric), then the temperature rise over the whole board (the FR4 spreading); each a 1920x1080 PNG, then `artifact --kind analysis-image --file <png> --caption "..."` for each (`--replaces <old png>` when you redo one; it goes fullscreen into the video and onto the page under the step), then `tour nets --nets <the pour nets the hint lists>` under the same step so the clip ends on the copper the analysis judged, lit net by net.++No recording may outlive an hour (a hard cap on every recording), `finish` and `deliver` stop the running clip, and every command stops a clip that has run past twice its step's budget: a recorder left running is a mistake, not evidence.++`capture open` puts the board on screen the same way for every engine (foreground OpenGL on a test box we own, maximised, zoom to fit). `capture start` records the PCB editor window itself through Adom Bridge's window recorder (Windows Graphics Capture, background-capturable: whatever another thread or an updater puts in front of KiCad on a shared box does not reach the take, and nobody at the box is disturbed; the monitor is recorded only when no editor is open), every stage command writes a marker with its timestamp into the manifest, `capture mark <label>` adds yours, `capture stop` pulls the MP4 into the run. The split-screen comparison (gang-takes split mode) aligns the two engines on those markers.++## The spec++`docs/spec-example.json` on the page is the ESC G431's: copper thickness, clearances, the inherited error count, fixed refs (the molecule interface), planes, wide and mid nets, Kelvin pairs, loads per net (amps, max rise), hot parts (watts, tab net), the pours (outline, around parts with a margin and exclusions, or explicit polygons; priorities and connection styles), solid patches, thermal and stitching vias, and the nets that are deliberately not poured. Write the spec from the schematic before you start; it is the electrical judgement, and it is what makes two engines' runs comparable.++## The rules behind the stages++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).++## What is measured (read aiflow-measurement)++The number is the human's prompt to your "done, here is your video" (`deliver`), and the cost per step is your thinking, not the binary's seconds. Every command you run is a turn in the append-only run.jsonl with the gap before it as thinking time (`adom-aiflow ledger` reads it back); declare `step <name>` as you move, and `step <name> --back --why "..."` when a later step sends you back. `finish` is the qualified board; `deliver --video --message` ends the clock.+++## The run's own page, as it runs++`report --page <owner/slug> --user-machine <box> --push --refresh` once, early (`--surface webview` instead of a machine when you run inside Adom Hydrogen: the page then lives in a workspace tab named "AI Flow run" and reloads there). From then on every `step` and `deliver` rebuilds the run's sub-README (per-step times, returns and why, decisions, every step visit's 10x clip and its start and end screenshots, where the board stands, what was delivered, what was given back), pushes it to `docs/runs/<id>/` on that page's repo, and reloads it in the human's browser on their machine, so they watch the run progress for hours without asking. The rendered view is `https://wiki.adom.inc/<owner>/<slug>/files/docs/runs/<id>/README.md`; the same path without `files/` is raw markdown.++## Give it back++adom-aiflow is the token cache of every Adom user's flow. When the binary was wrong, rigid or missing a step (an RF board that needs length-matched differential pairs and impedance checks, say), or your own code did a step better, file it: `adom-wiki issue create adom/adom-aiflow ...` or a PR against the repo, then `adom-aiflow giveback <url>` so the run counts it. Adom processes PRs within minutes during business hours (a non-stop PR intake), and `deliver` reports how many issues and PRs the run's AI gave back.+++## The code checks what you filmed++Every clip stop runs a blank check (nine sampled frames; a clip whose frames are one flat colour filmed an unpainted or covered window) and writes a contact sheet, `<clip>-sheet.png`, nine frames on one image. The stop prints the sheet's path: LOOK AT IT before the next step, the way a camera operator checks the take. A flagged clip is marked `suspect` in the manifest, shown on the run page with the reason, left out of the composed video, and `deliver` refuses while one is unresolved (`--accept-suspect` overrides, and the message must say why). `tour 3d` shows the viewer once and waits for a painted canvas before it records, because a viewer opened in the background never draws its GL canvas.++## Honesty rules++- The clock starts at the prompt. A late `start` takes `--prompt-time`.+- A stage you take yourself is stamped by you and named in the manifest as `ai`; the binary's stages are `binary`. Both go on the chart.+- `finish` is the only thing that ends a run. A run without `finish` is not a result, and its minutes are still counting.+- 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.
skills/aiflow-comparison-video/SKILL.mdadded+48@@ -0,0 +1,48 @@+---+name: aiflow-comparison-video+description: How to make the comparison video and page for two AI Flow runs (two engines, one board) with adom-aiflow: pull the other run, cut the side-by-side with `compose --with`, write the narration script the words lead (the setup, what each step is, who won each step, the overall winner, how the clock was measured), never dead air, then `compare --push` for the sub-README that leads with the video and links both runs. Trigger words: comparison video, side by side, fable vs astra, two engines, compare runs, narration script, comparison page, who won.+---++# The comparison video and page (adom-aiflow)++Two runs of the same flow on the same board make one comparison: one video, one page. The page is the thing people share, so the video has to explain itself to someone who never saw the flow.++## 1. Get the other run next to yours++The other engine's run directory must be readable from this container: its `run.json`, `run.jsonl`, every clip's `<clip>-10x.mp4`, and its drawings. When it lives on another machine, pull them through Adom Bridge (`pull_file` from `\\wsl.localhost\<distro>\home\...` for a Hydrogen Desktop workspace) into a local folder; `compose --with` finds a clip's 10x cut by file name in that folder when the recorded path does not exist here. Ask the other engine for a hand-over issue on the page (run page URL, run directory, clip list, drawings, sessions table) so nothing is guessed.++## 2. Cut the side-by-side++```+adom-aiflow compose --with <other run dir> --script <narration.json> --out esc-a-vs-b.mp4+```++Left is this run, right is the other. Steps run in the story's order (placement, routing, pours, current, thermal, fields, nets, 3D, finish), each step's two columns paced together, each run's own clocks in its own corner (AI time), both AIs' drawings side by side after their step, each step's AI-minute bars below, the delivery footer at the bottom. A column that fails to render says so on stderr; a step with nothing on either side is skipped.++Without `--script`, the picture leads (motion only, about two minutes, words cut to fit). With `--script`, the words lead: each step's picture is paced to its narration, slower or faster, never held and never cut short, so there is no dead air anywhere. Use the script for the video people share.++## 3. Write the narration script++A JSON object, step name to words; `drawings:<step>` for that step's drawing pair, `drawings:<step>:<n>` for the n-th pair. Every step in the video gets words (a step without words keeps its own pace and its own default line, which is how dead air creeps back in). About 2.5 words per second of picture you want.++What the words must do, in order:+- **The setup, over placement and into routing.** Who asked for what, on which board, and what state the board started in (for the ESC: the components moved off, the traces and copper torn up, only the outline kept; then place, route, pour, make it a molecule). Placement is over in a second of motion, so give it twenty seconds of words and let the story spill into routing.+- **What is happening, not what the tool is.** Routing: the AI works the flow, chooses the placement and the rules, goes back and moves parts when a pin cannot escape; not an autorouter button. Say which engine is on which side once.+- **Who won each step**, with the two numbers (AI minutes, returns) and the name at the end of the line.+- **The drawings**: what the picture is and what both AIs found (the necks, the hot tab, the missing via).+- **The finish**: the overall winner with both totals and returns, then how the clock works: the AI's own time from the pasted prompt to "done, here is your video", idle gaps over fifteen minutes cut out, so it measures the AI and not the human's day.++No dead air, no title pages, no em dashes in the words (they read badly and the voice pauses on them), numbers as words the voice says well ("twenty-eight minutes"), the engine names as people say them.++## 4. Look at it, then the page++```+ffmpeg -i esc-a-vs-b.mp4 -vf fps=1/8,scale=320:-2,tile=4x4 sheet.png # every step, both columns, the drawings+adom-aiflow compare --with <other run dir> --page <owner/slug> --push --refresh+```++`compare` writes `docs/comparisons/<a>-vs-<b>/README.md` on the page: the video first with one paragraph, the number (AI time to delivery, returns, turns, thinking and binary minutes for both), the per-step table, the drawings side by side with each AI's caption, and the links to both run pages and the flow. Link it from the page's README so people find it. A re-cut is `compose --with` again, then `compare --push` again; the video gets a new name each time so no browser shows the old one.++## What the numbers mean++AI time is the sum of the run's sessions, prompt to done, idle cut out (docs/time.md). The delivery number is the first session; the walkthroughs and drawings added later are follow-ups and are not in it. Per-step minutes are the AI's active time under that step. Returns are the `--back` visits before delivery.
skills/aiflow-measurement/SKILL.mdadded+51@@ -0,0 +1,51 @@+---+name: aiflow-measurement+description: How an adom-aiflow run is measured, in John's words (2026-09-14). The number is the wall clock from the human's first prompt to the AI saying "done, here is your video", and the per-step cost is the AI's thinking, not the binary's seconds. Every command is a turn in run.jsonl with the thinking gap before it in an append-only run.jsonl; every return to an earlier step (placement moved because a pour had bad heatsinking) is recorded with its reason. Read this before you start, run, report, or compare a aiflow run, and before you hand a run to another engine. Trigger words: aiflow stats, measure the run, how long did each step take, prompt to done, AI thinking time, iterative rework, go back to placement, fable vs codex comparison, deliver the video, run.json ledger.+---++# Measuring a aiflow run++What is being measured is the AI. Deterministic code is fast; the AI thinking its way through the board, the placement, the routing, the pours, the current and thermal work is slow, and that is the cost the chart has to show. Reporting the binary's stage seconds ("route 25 s", "capture open 51 s") while the human sat watching the AI think for twenty minutes is wrong: it took twenty minutes.++## The clock++- Starts at the human's first prompt that starts the aiflow. Pass it exactly: `start --prompt-time <UTC of that message>`.+- Stops when the AI says "I'm 100 % done. Here is your video. Check it out." That moment is `deliver --video <mp4> --message "<what you said>"`. `finish` (the qualified board) is a milestone inside the run, not the end of it.+- Nothing inside is excluded. Waiting for the human, an overnight gap, a re-take: all of it counts. If a gap should be explained, explain it in a marker; do not subtract it.++## The ledger (run.jsonl, append-only)++- `run.jsonl` is the record: one JSON line per event, appended, never rewritten (a crash or a rewrite cannot lose a turn). Events: `start`, `turn` (a command begins), `turn-end`, `step` (a visit, with `back` and `why`), `stage-start`/`stage-end` (the binary's own work), `outcome`, `marker`, `clip-start`/`clip-stop` (one clip per step), `artifact` (what a step made: the clip and its seconds, the 10x cut, the zone state, the plans), `finish`, `deliver`. The final video is composed from the artifacts (docs/video.md). `run.json` is the derived state and summary; when they disagree, the ledger wins. `adom-aiflow ledger` prints it in order with the per-step table; `--json` streams the lines.+- `turn`: every command the AI runs is a turn: the step it belongs to, the command, when it began, `thinkingSeconds` (the gap since the previous turn ended, or since the prompt for the first one: the AI's thinking that led to this command), `binarySeconds` (the tool's own time), and the first line it answered. The binary writes this itself; nothing to remember.+- `steps`: the AI declares what it is working on with `step <name>` (intake, placement, routing, pours, current, thermal, capture, finish, or its own name). Every command until the next `step` is charged to it. A return to an earlier step is `step <name> --back --why "<what sent you back>"`; the reason is part of the record.+- `summary.steps` at `finish` and `deliver`: per step, wall = thinking + tool, turns, visits, returns, longest single think. The sum over steps is the whole run. `promptToDeliveredMinutes` is the headline number; `promptToFinishMinutes` is the milestone.++## Iteration is expected++The AI can do a fine placement early and only find out at the pours that a low-side tab has no room for copper, or at the thermal step that a heat spreader fills as islands under dense routing. Then it goes back: moves parts, moves vias, reroutes, pours again, analyzes again. That is the same iterative analysis a human does, and every return is a visit with a reason in the ledger. Do not hide rework by editing the spec quietly: declare the step, say why, do the work, come forward again.++## Evidence the human will check++The final video and the page are the evidence that the run is any good. They have to show, per step, the real length of time (the AI's), and the substance: the copper pour current analysis, the heat analysis, the copper kept for ablation, and what was refused and why. A video that shows a 51-second stage the human waited twenty minutes for is not evidence.++## Before a comparison++Get it right with one engine first, on a fresh prompt, from `start` to `deliver`, and read the ledger back: does every step carry its thinking time, do the returns say why, does the video match the ledger. Only then hand the same board, spec, flow and prompt to the other engine, so both runs measure the same thing.++## Honesty++- A run that was assembled from earlier work with a back-dated prompt is a proving run of the tool, not a measured run of the AI. Say so.+- If you have not run the flow end to end from a prompt, the answer to "how long did it take you" is "I have not run it yet; I have to attempt it for the first time to answer."+++## Plan usage, the second cost line++Neither engine can see its own token bill reliably, so the binary reads what the provider shows the human: the percent used of every plan limit (Claude: the 5-hour window, the week, and each model-scoped weekly limit such as Fable; Codex: its primary and secondary rate-limit windows), from the Claude Code and Codex sign-ins in the container, with the same calls the Hydrogen usage widgets make. It snapshots at `start`, at every `step`, at `finish` and at `deliver` (`adom-aiflow usage` any time), writes each snapshot to run.jsonl, and the summary and the run page show "Fable 13 % to 16 %" per limit. That is the cost line to compare when tokens and dollars are nulls. Footnote it every time: the limits are per account, so other AI threads signed in as the same user move the same numbers; the delta is an upper bound unless the run was the only thread active.++## Cost, two ways++Each engine writes its token counts and the dollar figure at API rates into run.json (`tokens.in`, `tokens.out`, `usd.api`). The chart shows that figure, which is what commercial users on API plans pay, and beside it the same figure divided by 20 (`usd.plan20x`), which is the real cost on a 20x subscription plan. Both lines, always; neither alone tells the story.++## Sessions (2026-09-15)++The clock of record is AI time, not wall time: the sum of the run's sessions (a human prompt to the AI's done) with idle gaps over 15 min cut out. Mark follow-up prompts with `prompt --text` and answers with `done --message`; `deliver` closes the first task. The benchmark is the AI time from the first prompt to `deliver`. docs/time.md on the page has the rules.
uninstall.sh+3−1@@ -1,2 +1,4 @@ #!/bin/bash-rm -f "$HOME/.local/bin/adom-fields"; rm -rf "$HOME/.claude/skills/adom-fields"; echo "OK: adom-fields removed"+rm -f "$HOME/.local/bin/adom-aiflow"+rm -rf "$HOME/.claude/skills/adom-aiflow" "$HOME/.claude/skills/aiflow-measurement"+echo "OK: adom-aiflow removed."