app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
source at 0.1.25: the whole crate workspace, the flow, the skills and the docs
38 files changed
+6608−325
Cargo.lock+10−10Cargo.toml+1−1SKILL.md+1−73bin/adom-aiflowcrates/adom-aiflow/src/main.rs+1428−120crates/aiflow-bridge/src/lib.rs+71−13crates/aiflow-run/src/lib.rs+141−1flows/board.json+12−92install.sh−3page.json+2−8reference/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+94skills/adom-aiflow/SKILL.md−4uninstall.shCargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -22,7 +22,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.1.0"+version = "0.1.25" dependencies = [ "serde", "serde_json",@@ -30,7 +30,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.1.0"+version = "0.1.25" dependencies = [ "serde", "serde_json",@@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.1.0"+version = "0.1.25" dependencies = [ "serde", "serde_json",@@ -46,7 +46,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-board", "aiflow-grid",@@ -56,7 +56,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-board", "serde",@@ -65,7 +65,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-board", "serde",@@ -74,7 +74,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-board", "aiflow-copper",@@ -84,7 +84,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.1.0"+version = "0.1.25" dependencies = [ "aiflow-board", "aiflow-grid",@@ -94,7 +94,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.1.0"+version = "0.1.25" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.1.0"+version = "0.1.25" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1−73@@ -24,27 +24,11 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 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.+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. `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. @@ -81,59 +65,3 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - `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.--## Component coverage before placement--The early `components` step audits component identity, library coverage and model quality before `models` and placement. Run `plan` to read its ordered sourcing workflow. Inventory every reference on the actual board and schematic, and track exact manufacturer/MPN, supplier code, package and required ratings. Reuse exact wiki component pages first; check their assets rather than assuming a page contains a complete CAD bundle. Use `adom-parts-search` next and manufacturer/distributor sites for gaps. Pup is the browser fallback for JavaScript or blocked curl/fetch downloads; read its skill for current commands.--Publish missing identified component pages with verified symbol/footprint/model assets and provenance; improve existing pages instead of duplicating them. Do not invent identities for generic land patterns, DNP parts or bare copper test pads, or silently substitute a similar part. Record unresolved identities and missing assets separately. Generated/approximate bodies must be labelled. Keep model paths portable, preserve layout, rerun `kicad_model_check`, and visibly inspect native KiCad 3D before calling model coverage complete. The AI performs this sourcing audit; these instructions do not add an automatic sourcing or component-identity gate.--Global component pages serve ALL ADOM USERS. Publish only distinct reusable manufacturer parts or independently specified reusable custom components, after duplicate checking. Keep page titles and assets part-focused and portable. Board reference mappings, unresolved identities, one-off footprints, bare copper features and project-only assemblies belong in the board project wiki page, not new component catalog entries. Do not turn project-specific placeholders into generic-looking pages merely by renaming them.--## Early component quality and optional MPN marking--Before placement, declare `step components` and run `components` with your `--run` and `--ai-thread`. It writes `components.json`, inventories every board reference and preserves review notes on rerun. This is an AI review register, not an automated geometry or licensing certification. Record wiki links, classification, dimensions, terminals, pin-1/polarity, plain/marked variants, top/bottom/oblique visual evidence and redistribution evidence. Unknowns remain unknown.--Use manufacturer drawings and permitted CAD as reference checks. Third-party downloads may be private reference-only; never redistribute them without permission, or call extracted/recolored/etched geometry independently authored. Compare independently generated geometry with dimensional and visual evidence. Publish only reusable assets on global pages; board-only definitions stay in the project.--Offer `components --etch on` or `--etch off` (initial default `ask` is not consent). Discover adom/adom-chip-laser and the current STEP engine. Keep the plain model; cache a separate MPN-marked STEP, GLB and reviewed thumbnails when enabled, with input hash, generator version/parameters, text/mode and native-render evidence. Protect pin-1, polarity and functional features. Marking is an identification aid, not a claim of real factory markings. Do not regenerate a valid shared cache, and do not claim KiCad/Fusion/Altium parity until each named viewer was checked.--## Provenance is required for shared component assets--Creating a new global component page is optional; truthful per-file provenance is required when publishing or improving one. Reuse and improve existing pages first. Keep board-only definitions in the board project. Add a readable provenance section and machine-readable asset record, linked prominently from the component README (including HTML READMEs without removing their existing content).--For every symbol, footprint, STEP, GLB and optional marked variant, record the source URL/file/revision and retrieval time, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator and version, parameters, units, transforms, modifications, redistribution evidence, checks performed and unresolved limits. A hash proves file identity, not quality or permission. Existing wiki availability alone does not establish upstream authorship or rights.--For AI-created geometry, explicitly say it is AI-created and identify the datasheet page/figure/table, dimensional numbers and assumptions used by the generator. Publish the generator or reproducible parameters, disclose simplifications, and distinguish independent geometry from a converted/extracted reference. Keep restricted reference CAD private; document what the comparison tested and did not test. Preserve the lineage from plain input to marked STEP to GLB, including full marking text/mode.--Link to the component page's issue tracker and ask reviewers to include the filename/hash, disputed dimension or pin, source evidence, EDA/version and screenshot. Never fill provenance gaps with plausible guesses. Unknown origins and failed or missing validation remain visible until supported by evidence.--## Optional live progress--Offer the Hydrogen progress widget at intake; default off unless requested. Use `widget enable`, then open AI Flow progress in Hydrogen Widgets. Reuse saved milestone PNGs through `widget event --file <inside-run image> --label <milestone>`; the service also follows saved ledger artifacts and step changes. Use it for component models, longest-axis MPN variants, symbols, placement, pours and Fields. `widget disable` stops updates and thumbnail work. No AI calls or extra captures are required; the progress bar counts planned steps, not remaining time. See widgets/README.md.--## Keep both native views current after every update--After EVERY board, footprint, silkscreen, library-binding or 3D-model update, update BOTH the native 2D board editor and its associated 3D viewer before reporting or showing the result. A file write, successful transfer, DRC result or web preview is not a refreshed native view. Verify the exact saved board path/revision in the editor, then regenerate/reload the 3D view and inspect the changed features after painting settles. Capture evidence from both exact windows; confirm models, markings and layer visibility, not only the window titles.--Use supported native refresh/reload commands through the owning EDA bridge. If an offline edit or model cache requires closing and reopening, inspect unsaved changes and dialogs first, preserve user work, close only task-owned stale windows, reopen the latest board, and open its linked 3D viewer. Never save stale editor contents over a newer disk revision. Keep one current editor/viewer pair rather than accumulating old windows. Re-discover HWNDs after reopening; preserve the user's foreground and view preferences unless showing a view was requested. Refresh a completed edit or coherent batch promptly; do not wait until the final video. If either view cannot be verified, state which one remains stale and resolve it before claiming the update is shown.--## Shared component artwork and label placement--Read `eda-component-hero` and `eda-led-appearance` from `adom/eda-engineering` before creating or revising component heroes or LED variants. The existing wiki-component contract remains applicable. Use `hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>` for source-preservation and structural checks, then perform the published visual review. Never put hero-only footprint helpers in board model files.--For silkscreen, read `docs/silkscreen.md`: measure native stroke bounds, search and repack before shrinking, preserve ref/value association and per-face pin positions, and treat 0.2 mm as exceptional. `silkscreen-layout` runs the shared candidate search; `tools/silkscreen-leaders.py` can reserve leader routes first. Native edits and verification belong to the bridge.--### Complete obstacle coverage is required--Before placing or accepting silkscreen, enumerate every fitted model, via and drilled hole across the entire board, on both faces. Record coverage counts and stable IDs. A local search region does not excuse leaving the rest of the board unchecked. Project the actual fitted 3D model into the board plane with its scale, offset, rotation and footprint transform; include overhangs. Conservative transformed model bounds are acceptable when a tighter silhouette is unavailable. F.Fab, courtyard or pad envelopes are fallbacks, not proof of fitted-body coverage: a footprint can have no Fab drawing, and the space between separated pads can still be occupied by the body. Missing model geometry must be reported and supplied a documented conservative envelope, never a tiny placeholder that silently allows text beneath the component.--Reserve every via hole on both faces, including tented vias when visible labeling is the requirement, and use the larger of the hole keepout and actual exposed mask opening. Also reserve through holes, slots, exposed pads and existing silk. Check actual stroked text extents, not just its anchor. After each re-layout, audit ALL existing labels and leaders against this complete obstacle set. A collision-free position is not enough: keep each reference/value next to its own component or add a clear, noncrossing leader. Do not move a label beside a different component merely to clear an obstacle. Native DRC and fitted 3D visibility are separate acceptance checks; a DRC baseline does not establish readable assembled silkscreen. Refresh and inspect both native 2D and 3D views before presenting the result.--## Optional live silkscreen dashboard--Offer `silkscreen-dashboard show` with the run and ai-thread flags. The shared observer renders real timestamped solver events, sampled candidate bounds and reasons, selected proposals and unresolved labels, in 2D and a fitted-model 3D preview. `silkscreen-layout --events <run>/silkscreen-dashboard/events.jsonl` emits these without AI/provider calls or deliberate solver pauses. Read `tools/silkscreen-dashboard/SKILL.md` for the manifest, transform limits, lifecycle and recording contract. Live and replay are visibly distinct; replay exports are not raw native CAD footage. Selected proposals remain unverified until native application, DRC and assembled visibility checks pass. Fusion/Altium adapters are not implemented merely because the manifest is tool-neutral.---## Final documentation and priority review-Prefer readable documentation blocks in open board interior, not crowded rim space. Include a verified description of what the board does, based on its project wiki/spec. Physical connector, test-point and contact labels remain local on both faces. Check interior alternatives across readable sizes before using an edge fallback; reflow coherent sections around holes, never scatter their sentences. Preserve left-aligned visible text, heading hierarchy and ordered rows. Record reasons and selected font heights. The complete priority/pass checklist is in AI Flow `docs/silkscreen-priority-pass.md`; its ordered requirements apply before native DRC and both-view visual acceptance.
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+1428−120@@ -26,14 +26,6 @@ struct Cli { #[derive(Subcommand)] enum Cmd {- SilkscreenLayout { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] events: Option<PathBuf> },- SilkscreenDashboard { action: String, #[arg(long, default_value="auto")] port: String, #[arg(long, default_value="wv")] surface: String, #[arg(long)] url: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] json: Option<String>, #[arg(long)] reason: Option<String> },- SilkscreenAudit { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },- SilkscreenSections { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },- SilkscreenPreflight { #[arg(long)] input: PathBuf, #[arg(long)] out: PathBuf },- SilkscreenMask { #[arg(long)] board: PathBuf, #[arg(long)] out: PathBuf },- SilkscreenContactPolicy { #[arg(long)] input: PathBuf, #[arg(long)] geometry: PathBuf, #[arg(long)] out: PathBuf, #[arg(long)] no_contact_borders: bool },- /// 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@@ -43,7 +35,8 @@ enum Cmd { /// 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 },+ 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)@@ -61,18 +54,46 @@ enum Cmd { /// 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 { #[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();@@ -227,6 +248,251 @@ fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> { 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)@@ -235,17 +501,581 @@ fn media_seconds(path: &str) -> f64 { /// 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);- r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0}));+ // 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"));- let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-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);+ // 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 {@@ -274,6 +1104,8 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { 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}");@@ -286,18 +1118,44 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { 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 from the prompt.", r.delivered_minutes().unwrap_or(0.0)),- (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min from the prompt; the video is being cut.", now_min),- _ => format!("**Running**: step `{cur}`, {:.1} min since the prompt. This page updates at every step change.", now_min),+ (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).\n\n", r.data["flow"]["name"].as_str().unwrap_or("board")));+ 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\n| Step | Wall min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");+ 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];@@ -332,16 +1190,18 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { 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)> = std::collections::BTreeMap::new();+ 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));- 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), _ => {} }+ 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}");@@ -350,21 +1210,34 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { 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"));- if Path::new(&fast).is_file() {- let dest = out.join(format!("{tag}.mp4"));- let _ = std::fs::copy(&fast, &dest);- let (real, quick) = secs.get(&tag).copied().unwrap_or((0.0, 0.0));+ 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() {@@ -373,13 +1246,30 @@ fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) { 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");- if let Some(v) = d.get("video").and_then(|v| v.as_str()) {- if Path::new(v).is_file() { let _ = std::fs::copy(v, out.join("final.mp4")); md.push_str(&format!("**Video: the final cut of this run, every step in order.**\n\n<video width=\"100%\" controls><source src=\"{blob}/final.mp4\" type=\"video/mp4\"></video>\n\n")); }- }- md.push_str(&format!("> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));+ 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") }));@@ -396,15 +1286,42 @@ fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, th let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"); let mut notes = Vec::new(); if push {- let files: Vec<String> = std::fs::read_dir(stage.join(&rel)).map(|d| d.filter_map(|e| e.ok()).map(|e| format!("{rel}/{}", e.file_name().to_string_lossy())).collect()).unwrap_or_default();- 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 &files { cmd.arg(f); }- match cmd.output() {- Ok(o) if o.status.success() => notes.push(format!("pushed {} files to {page}/{rel}", files.len())),- Ok(o) => notes.push(format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>())),- Err(e) => notes.push(format!("push failed: {e}")),+ // 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@@ -475,64 +1392,30 @@ fn main() { 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::SilkscreenSections { input, out } => {- thread(&cli); let _r=load_run(&cli);- let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-sections.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("section layout could not run: {e}"), &[]));- if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Keep each documentation section together. Change its region, column layout or font; never scatter its rows to claim full coverage.".into()]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Preserve headings, ordered rows, common alignment and spacing. Tables supplement physical terminal labels. Review both readable faces and native DRC before acceptance.".into()]);- }- Cmd::SilkscreenPreflight { input, out } => {- thread(&cli); let _r=load_run(&cli);- let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-preflight.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("preflight could not run: {e}"), &[]));- if !result.status.success(){err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &["Repack every reported collision before transferring to the EDA. Include full custom-pad mask geometry, fitted bodies, holes and footprint artwork.".into()]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Preflight passed; confirm with native DRC and inspect both faces. Association quality remains a separate visual gate.".into()]);- }- Cmd::SilkscreenMask { board, out } => {- thread(&cli); let _r=load_run(&cli);- let result=std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-kicad-mask.py")).arg("--board").arg(board).arg("--out").arg(out).output().unwrap_or_else(|e|err(&format!("mask export could not run: {e}"), &[]));- if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &["Unsupported mask geometry is a blocking preflight error, never an anchor-box fallback.".into()]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Use these complete native mask polygons with fitted body, hole and artwork obstacles.".into()]);- }- Cmd::SilkscreenContactPolicy { input, geometry, out, no_contact_borders } => {- thread(&cli); let _r=load_run(&cli);- let mut command=std::process::Command::new("python3");command.arg("-c").arg(include_str!("../../../tools/silkscreen-contact-policy.py")).arg("--input").arg(input).arg("--geometry").arg(geometry).arg("--out").arg(out);- if *no_contact_borders{command.arg("--no-contact-borders");}- let result=command.output().unwrap_or_else(|e|err(&format!("contact policy could not run: {e}"), &[]));- if !result.status.success(){err(&String::from_utf8_lossy(&result.stderr), &[]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Solve outside-first contact candidates; frames default on and reserve their stroke. Repeated connector columns share border rails and row height; record layout exceptions. Use --no-contact-borders to disable frames. Report inside fallbacks and clearance gaps.".into(), "Before layout, rank interfaces for this board. ESCs need prominent verified PHASE A/B/C, battery and ground labels; functional terminal names can outrank connector references.".into()]);- }- Cmd::SilkscreenAudit { input, out } => {- thread(&cli); let _r = load_run(&cli);- let result = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/silkscreen-obstacle-check.py")).arg("--input").arg(input).arg("--out").arg(out).output().unwrap_or_else(|e| err(&format!("silkscreen audit needs python3: {e}"), &[]));- if !result.status.success() { err(&String::from_utf8_lossy(&result.stdout), &["Supply every fitted body and every via/hole on both faces; audit the entire board, then verify label association and native 2D/3D views.".into()]); }- ok(&String::from_utf8_lossy(&result.stdout), &["Geometry audit only; native CAD review remains required.".into()]);- }- Cmd::SilkscreenDashboard { action, port, surface, url, target, json, reason } => {- let t = thread(&cli); let _r = load_run(&cli);- let mut command = std::process::Command::new("adom-aiflow-silkscreen");- command.arg(action).arg("--run").arg(&dir).arg("--ai-thread").arg(t).arg("--port").arg(port).arg("--surface").arg(surface);- if let Some(v)=url { command.arg("--url").arg(v); }- if let Some(v)=target { command.arg("--target").arg(v); }- if let Some(v)=json { command.arg("--json").arg(v); }- if let Some(v)=reason { command.arg("--reason").arg(v); }- let result=command.output().unwrap_or_else(|e|err(&format!("silkscreen dashboard helper missing: {e}"), &["Install the matching AI Flow package helper.".into()]));- if !result.status.success() {err(&format!("{} {}",String::from_utf8_lossy(&result.stdout),String::from_utf8_lossy(&result.stderr)), &[]);}- ok(&String::from_utf8_lossy(&result.stdout), &["Live feed and replay visualize recorded solver events. Proposed labels do not modify the native board.".into()]);- }- Cmd::SilkscreenLayout { input, out, events } => {- thread(&cli);- let _r = load_run(&cli);- let source = include_str!("../../../tools/silkscreen-layout.py");- let mut command = std::process::Command::new("python3");- command.arg("-c").arg(source).arg("--input").arg(input).arg("--out").arg(out);- if let Some(v)=events {command.arg("--events").arg(v);}- let result = command.output().unwrap_or_else(|e| err(&format!("silkscreen planner needs python3: {e}"), &[]));- if !result.status.success() { err(&format!("silkscreen planning incomplete: {} {}", String::from_utf8_lossy(&result.stdout), String::from_utf8_lossy(&result.stderr)), &["Inspect unresolved labels, expand candidate positions and repack nearby text before reducing fonts. Board unchanged.".into()]); }- ok(&String::from_utf8_lossy(&result.stdout), &["Check reference/value completeness and hierarchy, outside-first ordered contacts and board-specific interface prominence. Run silkscreen-preflight before native DRC, then refresh both native views. Native edits belong to the EDA bridge.".into()]);- } Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => { let t = thread(&cli); if !Path::new(board).is_file() {@@ -552,6 +1435,7 @@ fn main() { 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()]);@@ -619,7 +1503,7 @@ fn main() { 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 } => {+ Cmd::Place { what, wish, moves, out, pause_ms } => { thread(&cli); let mut r = load_run(&cli); let b = board_of(&r);@@ -658,14 +1542,21 @@ fn main() { 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 reply = br.land_moves(&rb, &read_json(m));- let good = Bridge::ok(&reply);- r.stage_end("place-land", Some(json!({"ok": good})), 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 !good {- err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"].as_str().or(reply["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", reply["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again.".into()]);+ 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!("placement landed live as one undo step (revision {})", reply["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?")), &[]);+ 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"), &[]), }@@ -781,14 +1672,21 @@ fn main() { "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 reply = br.land_moves(&rb, &read_json(m));- let good = Bridge::ok(&reply);- r.stage_end("place-land", Some(json!({"ok": good})), 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 !good {- err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"]), &[format!("offendingRefs: {}", reply["offendingRefs"])]);+ 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("placement landed live as one undo step", &[]);+ 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" {@@ -820,18 +1718,19 @@ fn main() { let stage = format!("land-{what}"); r.stage_start(&stage, Some("binary"), None); let t0 = std::time::Instant::now();- let (replies, accepted, stopped) = br.land_plan(&rb, &entries, *pause_ms, |i, n, rep| {- eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().unwrap_or(entries[i - 1]["net"].as_str().unwrap_or("?")), entries[i - 1]["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"]);+ // 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(), "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));- r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);- r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));+ 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 entry {} of {}: {} {}", i + 1, entries.len(), 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)]);+ 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} entries, one undo step each, {:.1} s", t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);+ 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", &[]));@@ -890,10 +1789,22 @@ fn main() { 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()]);+ 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")), &["0.1 judges tracks by IPC-2221 and pours by presence and vias; 0.2 computes cross-sections through the filled copper.".into()]);+ 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);@@ -930,7 +1841,12 @@ fn main() { 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())}));@@ -975,7 +1891,7 @@ fn main() { } "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 { err("no running recording in this run", &[]) };+ 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);@@ -983,7 +1899,7 @@ fn main() { 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 = cap["step"].clone();+ 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()) {@@ -992,7 +1908,20 @@ fn main() { 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()]); }- other => err(&format!("capture {other}: use open, start, mark or stop"), &[]),+ "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 => {@@ -1014,6 +1943,7 @@ fn main() { 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 } => {@@ -1025,6 +1955,359 @@ fn main() { 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();@@ -1046,6 +2329,8 @@ fn main() { "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("")), _ => {} } }@@ -1058,6 +2343,7 @@ fn main() { 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();@@ -1088,7 +2374,8 @@ fn main() { } clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default())); }- 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());+ // 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}));@@ -1109,8 +2396,19 @@ fn main() { 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)")), } }@@ -1125,9 +2423,13 @@ fn main() { 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 } => {+ 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()]); }@@ -1136,17 +2438,23 @@ fn main() { 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(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));+ 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).");+ 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("; ")));
crates/aiflow-bridge/src/lib.rs+71−13@@ -81,7 +81,8 @@ impl Bridge { /// 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> {- let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason}));+ // 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) } @@ -105,7 +106,7 @@ impl Bridge { } 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": 1_800_000}));+ 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) } @@ -127,16 +128,43 @@ impl Bridge { } // -- landing ------------------------------------------------------------------------------- /// One kicad_move_footprint batch (one undo step) from {REF: {x, y, rotation}}.- pub fn land_moves(&self, board: &str, moves: &Value) -> Value {- let Some(rev) = self.revision(board) else { return json!({"success": false, "errorCode": "no_revision", "message": "kicad_routing_state gave no revision; is the board open with the IPC API on?"}) };- let refs: Vec<Value> = moves.as_object().map(|o| o.iter().map(|(r, m)| json!({"ref": r, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))})).collect()).unwrap_or_default();- self.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": rev, "refs": refs}))+ /// 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 plan entries one kicad_route_net per entry; returns (replies, accepted, stopped-at).- pub fn land_plan(&self, board: &str, entries: &[Value], pause_ms: u64, mut progress: impl FnMut(usize, usize, &Value)) -> (Vec<Value>, usize, Option<usize>) {- let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision"})], 0, Some(0)) };+ /// 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() {@@ -149,10 +177,10 @@ impl Bridge { 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(), &r);+ progress(i + 1, entries.len(), e, &r); if !good { replies.push(r);- return (replies, ok, Some(i));+ return (replies, ok, Some(i), traces.len()); } ok += 1; if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) {@@ -161,7 +189,7 @@ impl Bridge { replies.push(r); std::thread::sleep(std::time::Duration::from_millis(pause_ms)); }- (replies, ok, None)+ (replies, ok, None, traces.len()) } /// Land zones grouped by net (one undo step per group, lowest priority first), converging the@@ -288,3 +316,33 @@ impl Bridge { 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-run/src/lib.rs+141−1@@ -49,6 +49,24 @@ pub fn parse(ts: &str) -> Option<u64> { 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,@@ -355,17 +373,24 @@ impl Run { _ => {} } }- let end = self.data["clock"]["deliveredTime"].as_str().and_then(parse).or_else(|| self.data["clock"]["finishTime"].as_str().and_then(parse)).unwrap_or(last).max(last);+ 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);@@ -394,6 +419,121 @@ impl Run { 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)?;
flows/board.json+12−92@@ -1,85 +1,27 @@ { "name": "board", "title": "The board flow: parts placement to a qualified board",- "scope": "Starts with component identity, reusable CAD review and native library assembly for an existing schematic/board. Reviews the selected model variants on camera before placement, then routes, pours, analyzes and delivers a bounded final video.",+ "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; offer the optional Hydrogen progress widget (default off; enable when requested), reusing saved milestone images without extra AI calls",- "record": "nothing on screen yet: the clip is the board opening on the test box (capture open) and the spec being read",- "workflow": [- "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."- ]- },- {- "name": "components",- "who": "ai",- "does": "Audit wiki component identity, reusable CAD quality and redistribution evidence before placement; offer cached optional MPN-marked variants.",- "binary": [- "components"- ],- "workflow": [- "Inventory every reference from the actual board and schematic: manufacturer, MPN, supplier code, package, value and ratings; group repeated exact parts and record their references. Do not invent an MPN or silently substitute a similar value/package. Distinguish populated parts, DNP parts and bare copper/mechanical features; a bare test pad needs no purchased component or fictitious 3D body.",- "Search the wiki FIRST for each exact manufacturer/MPN and supplier code; reuse the matching component page and inspect its symbol, footprint and STEP/WRL assets. A page existing is not proof its CAD bundle is complete. Record the page URL and missing assets per reference.",- "For missing identities or assets, use adom-parts-search next, then original manufacturer and distributor websites. Use Pup to navigate and download through the automated browser when curl/fetch is blocked or the site requires JavaScript; do not treat a blocked fetch as proof the part is unavailable. Read the relevant parts-search and Pup skills for current commands.",- "Verify manufacturer, exact ordering code, package dimensions, pin numbering and required electrical ratings against source evidence before accepting CAD or a candidate part. Preserve source URLs and provenance. Label generated or approximate models as such; never present an approximate body as a verified vendor model.",- "Global component pages are shared resources for ALL ADOM USERS, never a board-specific BOM dump. Create one only for a distinct reusable manufacturer part or independently specified reusable custom component, after searching for duplicates. Write a part-focused page with portable verified assets and provenance, and improve existing pages. Keep board references, unresolved identities, one-off land patterns, bare copper test pads and project-only assemblies inside the board project wiki page; do not create catalog placeholders or rename a one-off feature to make it appear global.",- "Use portable project/library model paths, preserve placement and routing, rerun kicad_model_check and inspect the saved board in native KiCad 3D. Report reference coverage, reused/created page URLs, unresolved identities and missing CAD separately. A successful download or wiki publication alone does not close a missing-model finding.",- "Judge quality per component, not per board screenshot: publish a linked visual register with top, bottom and oblique views. Check dimensions, units, terminal count and pitch, pin-1/polarity, body/pad alignment, standoff, materials and visible details. File resolution alone is not quality. Record pass, needs-work, reference-only or unknown with evidence and limits.",- "Prefer manufacturer CAD when the source permits the intended redistribution. Keep third-party/Ultra Librarian downloads as private reference-only inputs unless redistribution is explicitly permitted. Compare independently generated models against manufacturer drawings and permitted reference views; retain dimensional deviations and source hashes. Converting, extracting, recoloring, or etching a restricted model does not make it independently authored or license-cleared.",- "Offer optional MPN marking using adom/adom-chip-laser or the current adom-step2glb laser-etch service. Ask whether to enable it; default to unmarked models until the user chooses. Check the shared page cache first. Preserve the plain STEP and publish an additional marked STEP, derived GLB and reviewed thumbnails only for assets eligible for redistribution. Keep the mark clear of pin-1, polarity, terminals and optical/mechanical features. Marking is an identification aid, not evidence of actual factory top-marking, especially on tiny passives.",- "Cache reviewed artifacts on the existing global component page: source/plain STEP, optional MPN STEP, GLB, thumbnails and machine-readable provenance. Record input hash, generator/tool version, parameters, units, transforms, marking text/mode, reference evidence and review results. Cache keys must change when geometry, text or generator parameters change. Keep board-only transforms and mappings in the board project. Never claim cross-EDA parity without rendering the variant in the named native viewers.",- "When publishing or improving a component page, make per-file provenance mandatory even though creating a new page is optional. Record original source URL/file and revision, retrieval date, source and output SHA-256, authoring classification (manufacturer-supplied, source-derived, AI-created, or unknown), generator/version and parameters, units/transforms, redistribution evidence and limitations. For AI-created geometry cite the actual datasheet page/figure/table and dimensions used, list simplifications and reference comparisons, and never present copied/extracted CAD as independent work. State which checks ran and which remain unverified; retain plain and marked variant lineage. Put a readable provenance section on the page plus a machine-readable asset record and a link to that component's issue tracker. Unknown provenance stays unknown, not a fabricated source. Reuse/improve existing pages first; offer new global-page publication only for reusable components, keeping board-specific records in the project.",- "For optional MPN marking, fit the text along the longest usable top-face direction with the largest legible size, respecting pin-1 and mechanical features. Compare both orientations and retain the package coordinate frame. Cache and identify the marked variant explicitly.",- "Offer the optional Hydrogen progress widget. If enabled, reuse saved component, marked-model, symbol and later board/analysis thumbnails via widget event; do not generate extra screenshots or call a model solely for the widget. Respect widget disable immediately.",- "Before accepting a wiki component, read its actual native model_3d and STEP bindings and hash the referenced downloads against the reviewed variants. Wait for the native viewer to finish painting, then inspect the visible body and readable mark; nonzero meshes or a successful HTTP request do not prove a painted model. Check the static hero too. Use a descriptive manufacturer/MPN/function title in both page.json and package.json; follow wiki-component and preserve other contributors and original files.",- "A resolved 3D file may still be hidden by native EDA appearance filters (for example KiCad excludes models not in position files). Check those filters before inventing replacement parts or changing BOM/placement flags. Keep bridge-specific visibility, binding and native audit operations in the owning bridge; report missing capabilities there. Explicit rights-holder permission may authorize a derivative despite a restrictive package default; record who authorized what and its scope, without extending it to third-party assets.",- "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."- ]- },- {- "name": "libraries",- "who": "ai",- "does": "Assemble and validate the reviewed symbols, footprints and selected model variants in the chosen EDA before placement.",- "workflow": [- "Use the components register to build a portable project library for the actual EDA (KiCad, Altium or Fusion). Verify symbol pin numbers against footprint pads, package dimensions and pin-1/polarity; resolve model transforms and selected plain or marked STEP paths. Record native tool/version and evidence per part.",- "Keep board reference mappings and native project transforms in the board project, while reusable source CAD and provenance remain on their existing global component pages. Do not claim native EDA support from a GLB gallery or a file extension alone.",- "Return to components with --back --why when identity, rights or geometry cannot be resolved; preserve honest unknowns and report the affected references."- ],- "record": "Native library review with the selected symbol, footprint and model; saved verification evidence belongs with the library register."+ "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": "Check native board model resolution and inspect the selected library variants in the chosen EDA; use the component register for unresolved assets.",+ "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": "Native board 3D inspection and model-check evidence; the separate library-tour step reviews each selected component.",- "workflow": [- "Use the components register and its reviewed plain or explicitly selected marked variants; fix portable model paths, run kicad_model_check, then inspect the native board render. Do not substitute a model merely to make the missing-file gate pass.",- "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",- "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."- ]- },- {- "name": "library-tour",- "who": "ai",- "does": "Review each selected component model in motion and produce a separate short library overview for the final video.",- "workflow": [- "Use tools/library-tour.py with the reviewed library.json, --run and --ai-thread. The manifest must identify the same plain or explicitly chosen marked variants and hashes that the native board uses. A marking choice does not authorize silent plain-model fallback.",- "Inspect the detailed orbit tour and the moving overview in a rendered browser. Reject blank, clipped, wrong-variant or static clips; retain raw recordings and contact sheets. The detailed tour stays separately linked for component review.",- "Budget approximately five seconds for the moving overview within the entire final video limit of 120 seconds. Do not insert the whole BOM walkthrough or one wiki-page shot per part into the final video. Optional scrolling wiki-page clips belong in the separate detailed review.",- "Automatic overview selection in released compose requires the complete maintained release source (issue #16); until integrated, report that limitation explicitly and keep the reviewed clips and selection manifest available for integration.",- "Read eda-component-hero and eda-led-appearance from the shared EDA skillpack. Component wiki heroes need separate nonphysical teal dashed COPPER boundaries (alpha 0.50), actual silk (0.30), and mapped signal labels (0.50); preserve physical pin1, MPN, animations and lights. Keep board-use models clean. Run hero-check --source <original.glb> --glb <hero.glb> --evidence <overlay.json> --out <check.json>, then inspect published top/underside/pin1 and native LED surfaces. Raised LED skins must have verified non-coplanar clearance, including the MPN; STEP color is not a light. Record source, footprint, pin map, font and generator hashes. Never claim structural checks establish visual or electrical qualification."- ],- "record": "Two separate raw clips: a detailed component orbit tour and a roughly five-second moving overview. No title pages or baked captions; the final composer supplies its own overlays."+ "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; reserve visible space for connector labels and the two-sided service silkscreen",+ "does": "place the parts for routability, current and heat; the binary packs, checks courtyards and lands moves", "binary": [ "place pack", "place check",@@ -152,29 +94,6 @@ ], "record": "the editor with one net lit at a time, framed; the markers say which net" },- {- "name": "silkscreen",- "who": "ai",- "does": "Make both faces useful in real service: references and values, verified connector and machine-contact pinouts, polarity, board identity and bring-up labels; review native plots and assembled visibility. Offer the optional shared silkscreen dashboard: real solver events, candidate reasons, live/replay separation, fitted-model inspection and separately recorded detailed/5s replays. Audit every fitted body and every via/hole across the whole board, both faces, with coverage counts. Missing Fab geometry is not free space; native DRC does not establish fitted visibility. Keep unresolved labels explicit and do not apply an incomplete layout. Native geometry/edit/undo/refresh belongs to the EDA bridge.",- "workflow": [- "Read the InstaPCB silkscreen skill for the selected process; reserve label space during placement and finalize after copper and analysis stabilize. Use both faces, a clear font-size hierarchy, and the approved process profile for small secondary text.",- "Build a source-backed label manifest from actual schematic, pad numbers, nets and approved requirements. Include reference/value pairs, connector pinouts, power polarity, test points, switch/LED functions, revision and documentation link. Never infer voltage/current ratings from net names or component absolute maxima.",- "For every machine pin, machine contact and edge-pin connector, repeat its reference/pin number and verified signal or power function on BOTH faces. A mounted board may expose only one side during debugging. Put the repeated labels beside the same physical connection where possible; when crowded, use a short clear leader to the actual connection on that face; a remote keyed legend is supplementary only. A pinout table on the other face alone does not satisfy this check. Review both faces in the mounted-access context, with bottom text correctly mirrored and pin numbering preserved.",- "Every test point MUST have visible silkscreen identifying both its reference and verified net/signal or measurement function. Prioritize these labels before ordinary component values. Keep them adjacent to the accessible probe pad, or use a short unambiguous leader/key on the same accessible face when crowded. Check complete test-point coverage against the actual board; missing or ambiguous labels are unresolved findings, never silently omitted. Repeat on the opposite face when useful for mounted-board debugging, without implying a probe pad exists there.",- "Keep each reference unmistakably associated with its own component. Prefer reducing reference font size locally over moving a label farther away. Aim for complete reference coverage; use a short clear leader only when proximity alone is ambiguous. Treat approximately 0.8 mm as an initial reference size, not a minimum. For dense InstaPCB artwork Adam explicitly permits secondary values at 0.3 mm or even 0.2 mm (2026-09-16); try 0.5, 0.3 then 0.2 mm while preserving the ref/value pairing and required stroke/spacing. These tiny sizes are user-requested artwork options, not independently verified laser-process capability. Keep the actual sizes and any unresolved physical legibility/DFM limits in the review; do not silently omit labels or globally weaken fab rules. Inspect the result at actual size and close-up.",- "Avoid mask openings, contact surfaces, holes, fiducials and bodies that hide labels. Inspect bottom mirroring, actual-size legibility and both native 2D/3D faces. Use EDA bridge text/plot/DRC operations; give missing primitives back to the bridge.",- "Run native DRC against the chosen fab profile and compare with the baseline. Preserve connectivity, placement, copper, outline and model transforms for silk-only edits. Register the label manifest and top/bottom evidence; return here when placements or pinouts change.",- "Record BEFORE the first silkscreen mutation: show labels appearing individually or in small meaningful groups, pairing references with smaller values. Film actual moves, resizing, rotations and overlap corrections in order; preserve a timestamped operation/reason sidecar and raw uncaptioned footage. Use native bridge edits and refresh, never invented verbs. If native incremental editing is missing, file the bridge gap; identify any checkpoint reconstruction as a replay, never as original live placement. Keep a detailed action cut and use 3\u20135 seconds of accelerated population/rework in the final 120-second film. Read docs/silkscreen.md for recording and evidence rules.",- "Require positional contact labels on BOTH faces: a pinout table is supplementary, never a substitute for text beside each actual machine contact, machine pin or edge connection. Separate primary reference (MC10) and smaller secondary function (DSHOT) as independently sized paired text. Prefer consistent reading directions. Where association remains ambiguous, add a short gentle curved silkscreen leader ending outside the intended pad mask opening; avoid crossings and obstacles. Verify one-to-one pad association, bottom mirroring, label/leader clearance and legibility in close-up native views and DRC. Film real additions and rework; retain mapping and unresolved constraints. See docs/silkscreen.md.",- "For the requested InstaPCB profile, attempt a nearby value for EVERY resistor and capacitor, including rotated components and references. Search both orientations and adjacent sides at 0.5, 0.3 and 0.2 mm as needed, preserving an unmistakable reference/value association. Do not skip values merely because the reference is rotated, an initial placement fails, or a bottom table exists. Repack nearby silk or use a clear short leader when necessary. Audit actual-board value coverage and report each unresolved value explicitly; a back-side key is supplementary, not completion. Retain native mask/overlap checks and distinguish requested artwork sizes from measured physical legibility.",- "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences.",- "Search space before shrinking text: 0.2 mm is a last resort. Use native text bounds and per-face body/pad/mask/via/silk obstacles, keep separate boxes for ref/value pairs, repack neighboring labels, preserve pin-row order and validate leader paths. Run silkscreen-layout on candidates; bounded search is not native verification. Apply by stable item ID, compare native DRC with baseline, inspect fitted visibility, and refresh BOTH native views. See docs/silkscreen.md."- ],- "record": "Native incremental label population on both faces, individual ref/value pairs or meaningful groups, plus real rework. Raw uncaptioned clip and timestamped edit sidecar; detailed action cut separate from the 3\u20135 second final-video excerpt. Clearly label reconstructed replay.",- "hints": [- "Final documentation pass: prefer open interior regions for a verified board description and ordered left-aligned service blocks. Physical pin/contact labels remain local. Apply docs/silkscreen-priority-pass.md; inspect interior alternatives at readable fonts and report explicit edge fallbacks. Native Rust silkscreen-sections performs the documentation solve; preflight, native DRC and both EDA views still gate acceptance."- ]- }, { "name": "3d", "who": "binary",@@ -182,10 +101,7 @@ "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",- "workflow": [- "After EVERY board or model update, refresh and verify BOTH the native 2D editor and its linked 3D viewer before showing/reporting completion. Check exact saved board revision and actual rendered changes in both windows. Reload cached models; if reopening is required preserve unsaved user work, close only task-owned stale windows and retain one current editor/viewer pair. Never overwrite a newer disk edit from a stale editor. Use native bridge controls and keep foreground preferences."- ]+ "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",@@ -213,9 +129,13 @@ "returns": "any step may send the AI back to any earlier step: step <name> --back --why \"...\"", "later": [ {- "name": "part-selection",+ "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"
install.sh−3@@ -11,6 +11,3 @@ cp "$HERE/skills/aiflow-comparison-video/SKILL.md" "$HOME/.claude/skills/aiflow- 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'."--chmod +x "$HERE/tools/silkscreen-dashboard/server.py"-ln -sf "$HERE/tools/silkscreen-dashboard/server.py" "$HOME/.local/bin/adom-aiflow-silkscreen"
page.json+2−8@@ -79,11 +79,5 @@ "label": "Compare engines", "prompt": "run the same board flow on Fable and Codex and show me the comparison" }- ],- "org": "adom",- "tag": "insiders",- "author": {- "name": "John Lauer",- "email": "[email protected]"- }-}+ ]+}
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
skills/adom-aiflow/SKILL.md−4@@ -81,7 +81,3 @@ Every clip stop runs a blank check (nine sampled frames; a clip whose frames are - `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.--## Silkscreen placement, complete coverage and observable decisions--Read `docs/silkscreen.md` for complete coverage, font/locality search, both-face contact/test-point labels, values and leader rules. Read `tools/silkscreen-dashboard/SKILL.md` for optional live/replay observation and recordings. Native geometry, edit/undo and 2D/3D refresh remain bridge responsibilities. `silkscreen-layout` proposes; `silkscreen-audit` checks an independent whole-board inventory; neither claims native acceptance. The dashboard makes no AI/provider calls. Fusion and Altium data adapters still need implementation and native tests.
uninstall.sh