← Commit history
Cargo.lockadded+338
@@ -0,0 +1,338 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "adom-aiflow"+version = "0.1.0"+dependencies = [+ "aiflow-analyze",+ "aiflow-board",+ "aiflow-bridge",+ "aiflow-copper",+ "aiflow-grid",+ "aiflow-place",+ "aiflow-pours",+ "aiflow-router",+ "aiflow-run",+ "clap",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-analyze"+version = "0.1.0"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-board"+version = "0.1.0"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-bridge"+version = "0.1.0"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-copper"+version = "0.1.0"+dependencies = [+ "aiflow-board",+ "aiflow-grid",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-grid"+version = "0.1.0"+dependencies = [+ "aiflow-board",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-place"+version = "0.1.0"+dependencies = [+ "aiflow-board",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-pours"+version = "0.1.0"+dependencies = [+ "aiflow-board",+ "aiflow-copper",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-router"+version = "0.1.0"+dependencies = [+ "aiflow-board",+ "aiflow-grid",+ "serde",+ "serde_json",+]++[[package]]+name = "aiflow-run"+version = "0.1.0"+dependencies = [+ "serde",+ "serde_json",+]++[[package]]+name = "anstream"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"+dependencies = [+ "anstyle",+ "anstyle-parse",+ "anstyle-query",+ "anstyle-wincon",+ "colorchoice",+ "is_terminal_polyfill",+ "utf8parse",+]++[[package]]+name = "anstyle"+version = "1.0.14"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"++[[package]]+name = "anstyle-parse"+version = "1.0.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"+dependencies = [+ "utf8parse",+]++[[package]]+name = "anstyle-query"+version = "1.1.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"+dependencies = [+ "windows-sys",+]++[[package]]+name = "anstyle-wincon"+version = "3.0.11"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"+dependencies = [+ "anstyle",+ "once_cell_polyfill",+ "windows-sys",+]++[[package]]+name = "clap"+version = "4.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"+dependencies = [+ "clap_builder",+ "clap_derive",+]++[[package]]+name = "clap_builder"+version = "4.6.6"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"+dependencies = [+ "anstream",+ "anstyle",+ "clap_lex",+ "strsim",+]++[[package]]+name = "clap_derive"+version = "4.6.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"+dependencies = [+ "heck",+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "clap_lex"+version = "1.1.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"++[[package]]+name = "colorchoice"+version = "1.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"++[[package]]+name = "heck"+version = "0.5.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"++[[package]]+name = "is_terminal_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"++[[package]]+name = "itoa"+version = "1.0.18"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"++[[package]]+name = "memchr"+version = "2.8.3"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"++[[package]]+name = "once_cell_polyfill"+version = "1.70.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"++[[package]]+name = "proc-macro2"+version = "1.0.107"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"+dependencies = [+ "unicode-ident",+]++[[package]]+name = "quote"+version = "1.0.47"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"+dependencies = [+ "proc-macro2",+]++[[package]]+name = "serde"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"+dependencies = [+ "serde_core",+ "serde_derive",+]++[[package]]+name = "serde_core"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"+dependencies = [+ "serde_derive",+]++[[package]]+name = "serde_derive"+version = "1.0.229"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"+dependencies = [+ "proc-macro2",+ "quote",+ "syn",+]++[[package]]+name = "serde_json"+version = "1.0.151"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"+dependencies = [+ "itoa",+ "memchr",+ "serde",+ "serde_core",+ "zmij",+]++[[package]]+name = "strsim"+version = "0.11.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"++[[package]]+name = "syn"+version = "3.0.5"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"+dependencies = [+ "proc-macro2",+ "quote",+ "unicode-ident",+]++[[package]]+name = "unicode-ident"+version = "1.0.24"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"++[[package]]+name = "utf8parse"+version = "0.2.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"++[[package]]+name = "windows-link"+version = "0.2.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"++[[package]]+name = "windows-sys"+version = "0.61.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"+dependencies = [+ "windows-link",+]++[[package]]+name = "zmij"+version = "1.0.23"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
Cargo.tomladded+40
@@ -0,0 +1,40 @@+[workspace]+resolver = "2"+members = [+    "crates/aiflow-board",+    "crates/aiflow-grid",+    "crates/aiflow-router",+    "crates/aiflow-copper",+    "crates/aiflow-place",+    "crates/aiflow-pours",+    "crates/aiflow-analyze",+    "crates/aiflow-run",+    "crates/aiflow-bridge",+    "crates/adom-aiflow",+]++[workspace.package]+version = "0.1.0"+edition = "2021"+license = "MIT"+repository = "https://wiki.adom.inc/adom/adom-aiflow"++[workspace.dependencies]+serde = { version = "1", features = ["derive"] }+serde_json = "1"+clap = { version = "4", features = ["derive"] }+aiflow-board = { path = "crates/aiflow-board" }+aiflow-grid = { path = "crates/aiflow-grid" }+aiflow-router = { path = "crates/aiflow-router" }+aiflow-copper = { path = "crates/aiflow-copper" }+aiflow-place = { path = "crates/aiflow-place" }+aiflow-pours = { path = "crates/aiflow-pours" }+aiflow-analyze = { path = "crates/aiflow-analyze" }+aiflow-run = { path = "crates/aiflow-run" }+aiflow-bridge = { path = "crates/aiflow-bridge" }++[profile.release]+opt-level = 3+lto = true+codegen-units = 1+strip = true
crates/adom-aiflow/Cargo.tomladded+24
@@ -0,0 +1,24 @@+[package]+name = "adom-aiflow"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "The board loop as one command: placement, routing, pours, gate, landing, measurement, analysis, finish line"++[[bin]]+name = "adom-aiflow"+path = "src/main.rs"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+clap = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }+aiflow-router = { workspace = true }+aiflow-copper = { workspace = true }+aiflow-place = { workspace = true }+aiflow-pours = { workspace = true }+aiflow-analyze = { workspace = true }+aiflow-run = { workspace = true }+aiflow-bridge = { workspace = true }
crates/adom-aiflow/src/main.rsadded+1094
@@ -0,0 +1,1094 @@+//! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line.+//! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run.+use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++use aiflow_board::Board;+use aiflow_bridge::Bridge;+use aiflow_run::{now, Run, STAGES};+use clap::{Parser, Subcommand};+use serde_json::{json, Value};++const VERSION: &str = env!("CARGO_PKG_VERSION");++#[derive(Parser)]+#[command(name = "adom-aiflow", version = VERSION, about = "The board loop as one command: placement, routing, pours, gate, landing, measurement, analysis, finish line")]+struct Cli {+    /// The name of the AI conversation calling (required for state-changing commands)+    #[arg(long, global = true)]+    ai_thread: Option<String>,+    /// Run directory (default ./aiflow-run or $AIFLOW_RUN)+    #[arg(long, global = true)]+    run: Option<PathBuf>,+    #[command(subcommand)]+    cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+    /// Start a run: copies the board, stamps the prompt time+    Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },+    /// The stages this board needs and who can take each+    Plan,+    /// Record who takes a stage: route=ai pour=binary+    Take { decisions: Vec<String> },+    /// Stamp a stage you do yourself: start or end+    Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> },+    /// Placement helpers: pack (--wish), check (--moves), land (--moves)+    Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String },+    /// The grid router on the current board+    Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },+    /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)+    Pour { #[arg(long)] plan: Option<String> },+    /// KiCad DRC offline on the routing plan+    Gate { #[arg(long)] plan: Option<String> },+    /// Replay live through the bridge: route, vias, pours, moves+    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64 },+    /// Filled copper per layer from the live board+    Measure,+    /// current or thermal+    Analyze { what: String, #[arg(long)] plan: Option<String> },+    /// The finish line; refuses until every check passes+    Finish,+    /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.+    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },+    /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.+    Deliver { #[arg(long)] video: String, #[arg(long)] message: String },+    /// 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> },+    /// 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> },+    /// Where the run stands+    Status,+}++static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();++/// Close the ledger entry for this command (see Run::turn_begin).+fn close_turn(ok_: bool, first_line: &str) {+    if let Some((dir, idx)) = TURN.get() {+        if let Some(mut r) = Run::open(dir) {+            r.turn_end(*idx, ok_, first_line);+            let _ = r.save();+        }+    }+}++fn ok(msg: &str, hints: &[String]) {+    close_turn(true, msg.lines().next().unwrap_or(""));+    println!("OK: {msg}");+    for h in hints {+        if !h.is_empty() {+            println!("Hint: {h}");+        }+    }+}++fn err(msg: &str, hints: &[String]) -> ! {+    close_turn(false, msg.lines().next().unwrap_or(""));+    println!("ERROR: {msg}");+    for h in hints {+        if !h.is_empty() {+            println!("Hint: {h}");+        }+    }+    std::process::exit(2)+}++fn thread(cli: &Cli) -> String {+    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))+}++fn run_dir(cli: &Cli) -> PathBuf {+    cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))+}++fn load_run(cli: &Cli) -> Run {+    let d = run_dir(cli);+    Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))+}++fn read_json(p: &str) -> Value {+    serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))+}++fn spec_of(r: &Run) -> Value {+    read_json(r.data["spec"].as_str().unwrap_or(""))+}++fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {+    let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();+    for p in &b.pads {+        if p.thru || p.net.is_empty() {+            continue;+        }+        let l = if p.on_f { "F.Cu" } else { "B.Cu" };+        let e = m.entry(p.net.clone()).or_default();+        if !e.iter().any(|x| x == l) {+            e.push(l.to_string());+        }+    }+    m+}++/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;+/// every other copper or keepout zone on the live board is the flow's own.+fn inherited_zone_ids(r: &Run) -> Vec<String> {+    let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };+    let text = std::fs::read_to_string(p).unwrap_or_default();+    let mut ids = Vec::new();+    let mut from = 0;+    while let Some(i) = text[from..].find("(zone") {+        let start = from + i;+        let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());+        if let Some(u) = text[start..end].find("(uuid \"") {+            let s = start + u + 7;+            if let Some(e) = text[s..].find('"') {+                ids.push(text[s..s + e].to_string());+            }+        }+        from = start + 5;+    }+    ids+}++/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.+fn step_table(r: &Run) -> String {+    let rep = r.step_report();+    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];+    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();+    for k in rep.keys() {+        if !names.contains(k) {+            names.push(k.clone());+        }+    }+    let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8}  {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];+    let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);+    for n in names {+        let e = &rep[&n];+        let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));+        tw += w; tt += t; tb += b; tn += k;+        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+    }+    lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));+    lines.join("\n")+}++++/// Free megabytes on the drive holding the run directory.+fn local_free_mb(dir: &Path) -> Option<u64> {+    let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;+    let text = String::from_utf8_lossy(&out.stdout);+    let line = text.lines().last()?;+    line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)+}++/// The recording budget for a step: measured window captures run about 8 MB per minute of+/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the+/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull+/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.+fn clip_budget_mb(step: &str) -> u64 {+    let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };+    minutes * 10 * 2+}++fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {+    let need = clip_budget_mb(step);+    let local = local_free_mb(dir);+    let remote = br.remote_free_mb();+    let floor = need + 1024;+    let mut msgs = Vec::new();+    if let Some(l) = local {+        if l < floor {+            return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));+        }+        msgs.push(format!("run drive {l} MB free"));+    }+    if let Some(rm) = remote {+        if rm < floor {+            return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));+        }+        msgs.push(format!("test box {rm} MB free"));+    }+    Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))+}++/// The clip's seconds, from ffprobe (0.0 when unknown).+fn media_seconds(path: &str) -> f64 {+    std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)+}++/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts+/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.+fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {+    let secs = media_seconds(file);+    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0}));+    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);+    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 give-back hint: what the AI learned belongs to every Adom user.+fn give_back_hint() -> String {+    "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()+}+++/// The run id used for its page folder: board stem, engine slug, prompt date.+fn report_id(r: &Run) -> String {+    if let Some(id) = r.data["reportId"].as_str() {+        return id.to_string();+    }+    let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");+    let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();+    let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");+    // prompt date and time, so two runs by one engine on one day get their own folders+    let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+    let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();+    let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();+    format!("{board}-{eng}-{date}-{hhmm}")+}++/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).+fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {+    let id = report_id(r);+    let rel = format!("docs/runs/{id}");+    let stage = dir.join("report");+    let out = stage.join(&rel);+    let _ = std::fs::create_dir_all(&out);+    let slug = page.split('/').nth(1).unwrap_or(page);+    let blob = format!("/blob/app/{slug}/{rel}");+    let led = r.ledger();+    let engine = r.data["engine"].as_str().unwrap_or("");+    let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");+    let finished = r.data["clock"]["finishTime"].as_str();+    let delivered = r.data["clock"]["deliveredTime"].as_str();+    let cur = r.data["currentStep"].as_str().unwrap_or("intake");+    let now_min = r.elapsed_minutes();+    let mut md = String::new();+    md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));+    let 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),+    };+    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")));+    // 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");+    let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);+    for n in &names {+        let e = &rep[n];+        let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));+        tw += w; tt += t; tb += b;+        md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));+    }+    md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));+    // returns+    let mut returns: Vec<String> = Vec::new();+    for e in &led {+        if e["event"] == "step" && e["back"].as_bool() == Some(true) {+            returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));+        }+    }+    if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }+    // decisions and markers+    let mut marks: Vec<String> = Vec::new();+    for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }+    if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }+    // outcomes+    let o = r.outcomes();+    md.push_str("## Where the board stands\n\n");+    if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }+    if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }+    if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }+    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours: {} groups, failed {}\n", x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }+    if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }+    for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }+    md.push_str("\n");+    // clips and screenshots per visit+    md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");+    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+    // clip seconds from the ledger's artifacts+    let mut secs: std::collections::BTreeMap<String, (f64, f64)> = std::collections::BTreeMap::new();+    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 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 {+        let Some(step) = c["step"].as_str() else { continue };+        let visit = c["visit"].as_u64().unwrap_or(1);+        let tag = format!("{step}-{visit}");+        let rework = c["back"].as_bool() == Some(true);+        let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };+        md.push_str(&format!("### {title}\n\n"));+        md.push_str(&format!("What this step does: {}\n\n", what_of(step)));+        if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }+        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 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));+                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")); }+        for when in ["start", "end"] {+            let src = dir.join(format!("shot-{tag}-{when}.png"));+            if src.is_file() {+                let _ = std::fs::copy(&src, out.join(format!("shot-{tag}-{when}.png")));+                md.push_str(&format!("**Screenshot: the board when {title} {}.**\n\n", if when == "start" { "began" } else { "ended" }));+                md.push_str(&format!("![the board when {title} {}]({blob}/shot-{tag}-{when}.png)\n\n", if when == "start" { "began" } else { "ended" }));+            }+        }+    }+    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("")));+    }+    let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();+    md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));+    md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");+    let readme = out.join("README.md");+    let _ = std::fs::write(&readme, md);+    (stage, rel, readme)+}++/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.+fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {+    let (stage, rel, _readme) = build_report(r, dir, page);+    // the files viewer renders a sub-README as a page; the bare path serves raw markdown+    let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");+    let mut notes = Vec::new();+    if push {+        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}")),+        }+    }+    if refresh && r.data["reportSurface"].as_str() == Some("webview") {+        // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle+        let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+        let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());+        fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }+        let mut ids = Vec::new();+        if let Some(w) = &ws { leaves(&w["root"], &mut ids); }+        let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();+        let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();+        match panel {+            Some(pid) => {+                let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();+                notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });+            }+            None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),+        }+    } else if refresh {+        if let Some(m) = r.data["userMachine"].as_str() {+            let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };+            let bust = format!("{url}?v={}", now().replace([':', '-'], ""));+            let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));+            if !Bridge::ok(&nav) {+                let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));+                notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });+            } else {+                notes.push(format!("refreshed in the browser on {m}"));+            }+            let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));+        }+    }+    (url, notes)+}++fn board_of(r: &Run) -> Board {+    Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))+}++fn bridge_of(r: &Run, cli: &Cli) -> Bridge {+    let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();+    Bridge { ai_thread: thread(cli), target }+}++fn write_json(p: &Path, v: &Value) {+    std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));+}++/// The command as typed, without the thread and run plumbing.+fn command_line() -> String {+    let args: Vec<String> = std::env::args().skip(1).collect();+    let mut out = Vec::new();+    let mut skip = false;+    for a in args {+        if skip { skip = false; continue; }+        if a == "--ai-thread" || a == "--run" { skip = true; continue; }+        if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }+        out.push(a);+    }+    out.join(" ")+}++fn main() {+    let cli = Cli::parse();+    let dir = run_dir(&cli);+    // every command is a turn in the run's ledger; the gap before it is the AI's thinking time+    if !matches!(cli.cmd, Cmd::Start { .. }) {+        if let Some(mut r) = Run::open(&dir) {+            let idx = r.turn_begin(&command_line());+            let _ = r.save();+            let _ = TURN.set((dir.clone(), idx));+        }+    }+    match &cli.cmd {+        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {+            let t = thread(&cli);+            if !Path::new(board).is_file() {+                err(&format!("board not found: {board}"), &[]);+            }+            if !Path::new(spec).is_file() {+                err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);+            }+            if let (Some(r), false) = (Run::open(&dir), *force) {+                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);+            }+            std::fs::create_dir_all(&dir).unwrap();+            let copy = dir.join("board-0.kicad_pcb");+            std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));+            let pt = prompt_time.clone().unwrap_or_else(now);+            let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());+            let mut r = Run::create(&dir, &copy.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());+            r.save().unwrap();+            let _ = TURN.set((dir.clone(), idx));+            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);+        }+        Cmd::Plan => {+            let mut r = load_run(&cli);+            // the flow is a file: the steps in order, who does each, what the binary offers, what comes later+            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();+            r.data["flow"] = flow.clone();+            let spec = spec_of(&r);+            let b = board_of(&r);+            let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();+            let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);+            let stages = json!({+                "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},+                "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},+                "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},+                "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},+                "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},+                "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},+                "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},+                "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},+                "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},+            });+            r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});+            r.save().unwrap_or_else(|e| err(&e, &[]));+            let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];+            lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));+            for st in flow["steps"].as_array().cloned().unwrap_or_default() {+                lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));+            }+            lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));+            lines.push("stages the binary offers:".into());+            for n in STAGES {+                let s = &r.data["plan"]["stages"][n];+                lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));+            }+            ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);+        }+        Cmd::Take { decisions } => {+            thread(&cli);+            let mut r = load_run(&cli);+            for kv in decisions {+                let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };+                if !STAGES.contains(&stage) {+                    err(&format!("unknown stage {stage}"), &[format!("Stages: {}", STAGES.join(", "))]);+                }+                r.data["decisions"][stage] = json!(engine);+            }+            r.save().unwrap();+            ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);+        }+        Cmd::Stage { action, name, note, result } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if action == "start" {+                let e = r.engine_for(name);+                r.stage_start(name, Some(&e), note.as_deref());+                r.save().unwrap();+                ok(&format!("stage {name} started at {} by {e}", now()), &[]);+            } else {+                let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));+                r.stage_end(name, res, note.as_deref());+                r.save().unwrap();+                ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);+            }+        }+        Cmd::Place { what, wish, moves, out } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let b = board_of(&r);+            match what.as_str() {+                "pack" => {+                    let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));+                    let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));+                    let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, 0.5);+                    write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());+                    let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();+                    ok(&format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n")), &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }]);+                }+                "check" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));+                    let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));+                    let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));+                    let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);+                    let outp = dir.join(format!("board-{n}.kicad_pcb"));+                    std::fs::write(&outp, &text).unwrap();+                    let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+                    let moved: Vec<String> = mv.keys().cloned().collect();+                    let problems = aiflow_place::check(&nb, &moved, 0.05);+                    let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));+                    let s = aiflow_copper::summarize(&drc, None);+                    let base = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+                    let errors = s["errors"].as_u64().unwrap_or(0);+                    if !problems.is_empty() || errors > base {+                        err(&format!("the moved board is not clean: {} courtyard/outline problems, DRC {errors} errors (baseline {base}): {}", problems.len(), s["errorTypes"]), &[problems.join("; "), format!("Change the wishes and pack again; the copy stays at {} for inspection.", outp.display())]);+                    }+                    r.add_board(&outp.display().to_string());+                    r.save().unwrap();+                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited) and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);+                }+                "land" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));+                    let br = bridge_of(&r, &cli);+                    let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+                    r.stage_start("place-land", Some("binary"), None);+                    let reply = br.land_moves(&rb, &read_json(m));+                    let good = Bridge::ok(&reply);+                    r.stage_end("place-land", Some(json!({"ok": good})), None);+                    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()]);+                    }+                    ok(&format!("placement landed live as one undo step (revision {})", reply["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?")), &[]);+                }+                other => err(&format!("place {other}: use pack, check or land"), &[]),+            }+        }+        Cmd::Route { passes, rip_per_net, rip_global } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if r.engine_for("route") == "ai" {+                err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);+            }+            let spec = spec_of(&r);+            let b = board_of(&r);+            let rules = aiflow_router::Rules::from_spec(&spec);+            r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));+            let mut router = aiflow_router::Router::new(b, rules, false);+            let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);+            std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();+            let planp = dir.join("plan-route.json");+            write_json(&planp, &json!({"engine": "adom-aiflow router", "nets": plan}));+            write_json(&dir.join("plan-route-report.json"), &report);+            let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();+            r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);+            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>()}));+            r.save().unwrap();+            if failed > 0 {+                let mut blockers: Vec<String> = Vec::new();+                for u in &unrouted {+                    if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {+                        if let Some(rest) = why.strip_prefix("blocked by ") {+                            for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {+                                let b = b.trim_matches('"').trim_matches('\'');+                                if !b.is_empty() && !blockers.contains(&b.to_string()) {+                                    blockers.push(b.to_string());+                                }+                            }+                        }+                    }+                }+                let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();+                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);+            }+            ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into()]);+        }+        Cmd::Pour { plan } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let spec = spec_of(&r);+            let b = board_of(&r);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));+            let plan_v = planp.as_deref().map(read_json);+            r.stage_start("pour", Some("binary"), None);+            let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+            let zp = dir.join("plan-pours.json");+            let vp = dir.join("plan-vias.json");+            write_json(&zp, &json!({"zones": zones}));+            write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));+            r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);+            r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));+            r.save().unwrap();+            let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();+            ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);+        }+        Cmd::Gate { plan } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]));+            let b = board_of(&r);+            let plan_v = read_json(&planp);+            r.stage_start("gate", Some("binary"), None);+            let (text, stats) = aiflow_copper::apply(&b, &plan_v, true).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));+            let routed = dir.join("board-routed.kicad_pcb");+            std::fs::write(&routed, &text).unwrap();+            let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));+            let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+            let mut s = aiflow_copper::summarize(&data, Some(&base));+            s["copper"] = stats;+            s["file"] = json!(routed.display().to_string());+            write_json(&dir.join("gate.json"), &s);+            let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));+            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh})), None);+            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "routedBoard": routed.display().to_string(), "plan": planp}));+            r.save().unwrap();+            if newe > 0 || unc > 0 {+                // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever+                let mut hair: Vec<String> = Vec::new();+                for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {+                    if v["type"].as_str() == Some("clearance") {+                        let d = v["description"].as_str().unwrap_or("");+                        let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+                        let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());+                        if let (Some(a), Some(r)) = (actual, rule) {+                            if r - a < 0.02 {+                                for it in v["items"].as_array().cloned().unwrap_or_default() {+                                    if let Some(t) = it.as_str() {+                                        if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }+                                    }+                                }+                            }+                        }+                    }+                }+                hair.sort(); hair.dedup();+                let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };+                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);+            }+            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected; copper {}", s["copper"]), &["Next: adom-aiflow land route (then pour, land vias, land pours).".into()]);+        }+        Cmd::Land { what, plan, moves, pause_ms } => {+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            match what.as_str() {+                "moves" => {+                    let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));+                    r.stage_start("place-land", Some("binary"), None);+                    let reply = br.land_moves(&rb, &read_json(m));+                    let good = Bridge::ok(&reply);+                    r.stage_end("place-land", Some(json!({"ok": good})), None);+                    r.save().unwrap();+                    if !good {+                        err(&format!("live placement refused: {} {}", reply["errorCode"], reply["message"]), &[format!("offendingRefs: {}", reply["offendingRefs"])]);+                    }+                    ok("placement landed live as one undo step", &[]);+                }+                "route" | "vias" => {+                    let planp = if what == "route" {+                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0);+                        if !gate_ok && plan.is_none() {+                            err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);+                        }+                        plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))+                    } else {+                        r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))+                    };+                    let mut entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();+                    let mut already = 0usize;+                    if what == "vias" {+                        // resumable: a via already on the live board at the same place on the same net is not landed twice+                        let live = br.live_vias(&rb);+                        entries.retain(|e| {+                            let net = e["net"].as_str().unwrap_or("");+                            let p = &e["paths"][0][0];+                            let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));+                            let dup = live.iter().any(|(n, lx, ly)| n == net && (lx - x).abs() < 0.05 && (ly - y).abs() < 0.05);+                            if dup { already += 1; }+                            !dup+                        });+                        if already > 0 {+                            eprintln!("{already} vias already on the live board; landing the other {}", entries.len());+                        }+                    }+                    let stage = format!("land-{what}");+                    r.stage_start(&stage, Some("binary"), None);+                    let t0 = std::time::Instant::now();+                    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"]);+                    });+                    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}));+                    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)]);+                    }+                    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() }]);+                }+                "pours" => {+                    let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));+                    let b = board_of(&r);+                    let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));+                    r.stage_start("land-pours", Some("binary"), None);+                    let before = br.zone_state(&rb, true);+                    let inherited = inherited_zone_ids(&r);+                    let results = br.land_zones(&rb, &zones, *pause_ms, &inherited);+                    if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {+                        eprintln!("{}", rep["message"].as_str().unwrap_or(""));+                    }+                    let after = br.zone_state(&rb, true);+                    let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();+                    r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);+                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));+                    write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));+                    r.save().unwrap();+                    if let Some((net, rep)) = failed.first() {+                        let mut types: BTreeMap<String, usize> = BTreeMap::new();+                        for v in rep["violations"].as_array().cloned().unwrap_or_default() {+                            *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;+                        }+                        err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);+                    }+                    ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);+                }+                other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),+            }+        }+        Cmd::Measure => {+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            r.stage_start("measure", Some("binary"), None);+            let zs = br.zone_state(&rb, true);+            r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);+            write_json(&dir.join("zone-state.json"), &zs);+            r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));+            r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));+            r.save().unwrap();+            ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);+        }+        Cmd::Analyze { what, plan } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));+            let plan_v = read_json(&planp);+            let spec = spec_of(&r);+            let zsp = dir.join("zone-state.json");+            let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };+            let name = format!("analyze-{what}");+            r.stage_start(&name, Some("binary"), None);+            let (passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+            r.stage_end(&name, Some(json!({"pass": passed})), None);+            r.outcome(&name, json!({"pass": passed, "results": res}));+            r.save().unwrap();+            let lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();+            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()]);+            }+            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()]);+        }+        Cmd::Finish => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            let o = r.outcomes().clone();+            let mut missing = Vec::new();+            let gate = o.get("gate").cloned().unwrap_or(Value::Null);+            if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {+                missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());+            }+            if !o.contains_key("land-route") {+                missing.push("land route".into());+            }+            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true) {+                missing.push("land pours (every planned pour accepted)".into());+            }+            if !o.contains_key("measure") {+                missing.push("measure".into());+            }+            for k in ["analyze-current", "analyze-thermal"] {+                if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {+                    missing.push(format!("{k} (pass)"));+                }+            }+            let mut live = Value::Null;+            if let Some(target) = r.data["target"].as_str() {+                let br = Bridge { ai_thread: t.clone(), target: target.to_string() };+                live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));+                let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);+                if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {+                    missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));+                }+            }+            if !missing.is_empty() {+                err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);+            }+            r.data["clock"]["finishTime"] = json!(now());+            let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});+            r.data["summary"] = summary;+            r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));+            r.save().unwrap();+            ok(&format!("finished: {} from the prompt to a qualified board, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);+        }+        Cmd::Capture { action, label, reason } => {+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let engine = r.data["engine"].as_str().unwrap_or("").to_string();+            match action.as_str() {+                "open" => {+                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));+                    r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));+                    r.save().unwrap();+                    let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();+                    let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };+                    let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };+                    ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);+                }+                "start" => {+                    let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));+                    // the PCB editor window itself, background-capturable; the monitor only when no editor is open+                    let hwnd = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd());+                    let (rid, kind) = match hwnd {+                        Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),+                        None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),+                    };+                    let mut entry = json!({"recordingId": rid, "start": now()});+                    for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }+                    r.data["captures"].as_array_mut().unwrap().push(entry);+                    r.mark("capture-start");+                    r.save().unwrap();+                    ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);+                }+                "mark" => {+                    let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));+                    r.mark(&l);+                    r.save().unwrap();+                    ok(&format!("marker {l} at {}", now()), &[]);+                }+                "stop" => {+                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                    let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { err("no running recording in this run", &[]) };+                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    let (p, _) = br.record_stop(&rid, &save_to);+                    let cap = &mut r.data["captures"][pos];+                    cap["stop"] = json!(now());+                    cap["remoteFile"] = json!(p);+                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                    let st = cap["step"].clone();+                    let local = cap["file"].as_str().map(str::to_string);+                    r.mark("capture-stop");+                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+                        clip_artifacts(&mut r, &st, &f, 10);+                    }+                    r.save().unwrap();+                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into()]);+                }+                other => err(&format!("capture {other}: use open, start, mark or stop"), &[]),+            }+        }+        Cmd::Status => {+            let r = load_run(&cli);+            let o = r.outcomes();+            ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",+                dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],+                o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),+                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { "landed" } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),+                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);+        }+        Cmd::Report { page, push, refresh, user_machine, surface } => {+            let t = thread(&cli);+            let mut r = load_run(&cli);+            if let Some(p) = page { r.data["page"] = json!(p); }+            if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }+            if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }+            r.save().unwrap();+            let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));+            if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }+            let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);+            ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);+        }+        Cmd::Giveback { url, kind, note } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }+            r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));+            r.log("giveback", json!({"url": url, "kind": kind, "note": note}));+            r.save().unwrap();+            ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);+        }+        Cmd::Ledger { json: as_json } => {+            let r = load_run(&cli);+            let led = r.ledger();+            if led.is_empty() {+                err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);+            }+            if *as_json {+                for e in &led {+                    println!("{e}");+                }+            } else {+                for e in &led {+                    let t = e["t"].as_str().unwrap_or("");+                    match e["event"].as_str().unwrap_or("") {+                        "start" => println!("{t}  start      engine {} prompt {}", e["engine"], e["promptTime"]),+                        "turn" => println!("{t}  turn {:>3}   {:<10} think {:>6.1} min  {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),+                        "turn-end" => println!("{t}  end  {:>3}   {:<10} tool  {:>6.1} min  {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),+                        "step" => println!("{t}  step       {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),+                        "stage-end" => println!("{t}  stage      {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),+                        "marker" => println!("{t}  marker     {}", e["label"].as_str().unwrap_or("")),+                        "finish" | "deliver" => println!("{t}  {}    {}", e["event"].as_str().unwrap_or(""), e["minutes"]),+                        _ => {}+                    }+                }+            }+            println!("{}", step_table(&r));+        }+        Cmd::Step { name, back, why } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());+            let known: Vec<&str> = known.iter().map(String::as_str).collect();+            let prev = r.data["currentStep"].as_str().map(str::to_string);+            r.step_set(name, *back, why.as_deref());+            // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)+            let mut clip_note = String::new();+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let br = Bridge { ai_thread: thread(&cli), target };+                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();+                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {+                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();+                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                    // the step's closing screenshot: what the board looked like when the step ended+                    if let Some(h) = caps[pos]["hwnd"].as_i64() {+                        let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));+                        if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {+                            r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));+                        }+                    }+                    let (p, _) = br.record_stop(&rid, &save_to);+                    let cap = &mut r.data["captures"][pos];+                    cap["stop"] = json!(now());+                    cap["remoteFile"] = json!(p);+                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));+                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));+                    let local = cap["file"].as_str().map(str::to_string);+                    let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());+                    r.log("clip-stop", json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid, "file": p}));+                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {+                        clip_artifacts(&mut r, &st, &f, 10);+                    }+                    clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));+                }+                let hwnd = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd());+                let disk = disk_check(&dir, &br, name);+                if let Err(e) = &disk {+                    r.log("clip-refused", json!({"step": name, "why": e}));+                    r.save().unwrap();+                    err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);+                }+                match hwnd {+                    Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {+                        Ok(rid) => {+                            // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back+                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);+                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));+                            r.mark(&format!("clip-start:{name}-{visit}"));+                            let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();+                            if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {+                                r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));+                            }+                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));+                            clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));+                        }+                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),+                    },+                    None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),+                }+            }+            r.save().unwrap();+            let mut report_note = String::new();+            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+                report_note = format!("Progress page: {url} ({})", notes.join("; "));+            }+            let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();+            let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };+            let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };+            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note]);+        }+        Cmd::Deliver { video, message } => {+            thread(&cli);+            let mut r = load_run(&cli);+            if r.data["clock"]["finishTime"].is_null() {+                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);+            }+            let vp = Path::new(video);+            if !vp.is_file() {+                err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);+            }+            let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);+            let t = now();+            r.data["clock"]["deliveredTime"] = json!(t);+            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.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).");+            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {+                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));+                report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));+            }+            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);+        }+    }+}
crates/aiflow-analyze/Cargo.tomladded+10
@@ -0,0 +1,10 @@+[package]+name = "aiflow-analyze"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: analyze"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-analyze/src/lib.rsadded+191
@@ -0,0 +1,191 @@+//! Analysis passes for the finish line: current capacity and thermal, from the routed and poured board.+//!+//! 0.1 is deliberately conservative and says so: tracks and vias by IPC-2221's external-layer+//! curve, pours by presence, connection style and via count, not by a field solve. PASS means "no+//! obvious under-sized conductor and every hot tab has copper"; FAIL names the net or the part and+//! the fix. 0.2 computes cross-sections through the filled polygons and IR drop per net.+use std::collections::BTreeMap;++use serde_json::{json, Value};++const MIL2_PER_MM2: f64 = 1550.0031;++pub fn ipc2221_amps(width_mm: f64, thickness_um: f64, rise_c: f64, external: bool) -> f64 {+    let k = if external { 0.048 } else { 0.024 };+    let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;+    k * rise_c.powf(0.44) * a.powf(0.725)+}++pub fn rise_for(width_mm: f64, thickness_um: f64, amps: f64, external: bool) -> f64 {+    let k = if external { 0.048 } else { 0.024 };+    let a = width_mm * (thickness_um / 1000.0) * MIL2_PER_MM2;+    if a <= 0.0 || amps <= 0.0 {+        return 0.0;+    }+    (amps / (k * a.powf(0.725))).powf(1.0 / 0.44)+}++struct NetCopper {+    widths: Vec<f64>,+    vias: usize,+    via_drill: f64,+}++fn plan_widths(plan: &Value) -> BTreeMap<String, NetCopper> {+    let mut out: BTreeMap<String, NetCopper> = BTreeMap::new();+    for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+        let d = out.entry(net).or_insert(NetCopper { widths: Vec::new(), vias: 0, via_drill: e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3) });+        if let Some(w) = e.get("width").and_then(|w| w.as_f64()) {+            if !d.widths.iter().any(|x| (x - w).abs() < 1e-9) {+                d.widths.push(w);+            }+        }+        for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+            d.vias += path.as_array().map(|a| a.iter().filter(|w| w.get("layer").is_some() && w.get("x").is_some()).count()).unwrap_or(0);+        }+    }+    out+}++fn pours_by_net(zone_state: Option<&Value>) -> BTreeMap<String, Vec<Value>> {+    let mut m: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for z in zone_state.and_then(|z| z.get("zones")).and_then(|z| z.as_array()).cloned().unwrap_or_default() {+        if z.get("type").and_then(|t| t.as_str()) == Some("copper") {+            if let Some(net) = z.get("net").and_then(|n| n.as_str()) {+                if !net.is_empty() {+                    m.entry(net.to_string()).or_default().push(z);+                }+            }+        }+    }+    m+}++fn fill_area(zs: &[Value]) -> f64 {+    zs.iter().flat_map(|z| z.get("fills").and_then(|f| f.as_array()).cloned().unwrap_or_default()).map(|f| f.get("areaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0)).sum()+}++fn layers_of(zs: &[Value]) -> Vec<String> {+    let mut v: Vec<String> = zs.iter().flat_map(|z| z.get("layers").and_then(|l| l.as_array()).cloned().unwrap_or_default()).filter_map(|l| l.as_str().map(str::to_string)).collect();+    v.sort();+    v.dedup();+    v+}++/// spec.loads: {net: {amps, peakAmps?, maxRiseC?}}, spec.copperUm outer thickness (35), spec.maxRiseC (10).+/// `pad_layers`: net -> the outer layers its SMD pads sit on (through-hole pads count for neither).+/// When every pad of a loaded net is on one outer layer and that layer is poured, the pour on that+/// layer carries the current between them; vias on the net then only spread heat.+pub fn analyze_current(plan: &Value, spec: &Value, zone_state: Option<&Value>, pad_layers: Option<&BTreeMap<String, Vec<String>>>) -> (bool, Vec<Value>) {+    let t_um = spec.get("copperUm").and_then(|v| v.as_f64()).unwrap_or(35.0);+    let max_rise_default = spec.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(10.0);+    let widths = plan_widths(plan);+    let pours = pours_by_net(zone_state);+    let mut results = Vec::new();+    let mut ok = true;+    for (net, ld) in spec.get("loads").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+        let amps = ld.get("amps").and_then(|v| v.as_f64()).unwrap_or(0.0);+        let max_rise = ld.get("maxRiseC").and_then(|v| v.as_f64()).unwrap_or(max_rise_default);+        let Some(w) = widths.get(&net) else {+            results.push(json!({"net": net, "amps": amps, "status": "no-copper", "hint": format!("net {net} carries {amps:.1} A but the routing plan has no copper for it")}));+            ok = false;+            continue;+        };+        let mut signal: Vec<f64> = w.widths.iter().copied().filter(|x| *x > 0.2).collect();+        signal.sort_by(|a, b| a.partial_cmp(b).unwrap());+        let narrowest = signal.first().copied().unwrap_or_else(|| w.widths.iter().cloned().fold(f64::INFINITY, f64::min));+        let widest = w.widths.iter().cloned().fold(0.0, f64::max);+        let rise_narrow = rise_for(narrowest, t_um, amps, true);+        let rise_wide = rise_for(widest, t_um, amps, true);+        let zs = pours.get(&net).cloned().unwrap_or_default();+        let pour_layers = layers_of(&zs);+        let pour_area = (fill_area(&zs) * 10.0).round() / 10.0;+        let via_amps = if w.via_drill >= 0.4 { 1.5 } else { 1.0 };+        let via_capacity = w.vias as f64 * via_amps;+        let mut r = json!({"net": net, "amps": amps, "maxRiseC": max_rise, "narrowestTrackMm": narrowest, "widestTrackMm": widest, "riseAtNarrowestC": (rise_narrow * 10.0).round() / 10.0, "riseAtWidestC": (rise_wide * 10.0).round() / 10.0, "vias": w.vias, "viaCapacityA": via_capacity, "pourLayers": pour_layers, "pourFilledMm2": pour_area});+        if rise_wide <= max_rise {+            r["status"] = json!("pass");+            r["how"] = json!(format!("the widest track carries the load within {max_rise:.0} C"));+        } else if !pour_layers.is_empty() && pour_area > 0.0 {+            let one_layer = pad_layers.and_then(|m| m.get(&net)).filter(|l| l.len() == 1).map(|l| l[0].clone()).filter(|l| pour_layers.contains(l));+            if let (true, Some(l)) = (pour_layers.len() >= 2 && via_capacity < amps, one_layer.clone()) {+                r["status"] = json!("pass");+                r["how"] = json!(format!("every pad of {net} is on {l} and the {l} pour ({pour_area:.0} mm2 on {}) spans them; the other layer and its {} vias spread heat", pour_layers.join("+"), w.vias));+            } else if pour_layers.len() >= 2 && via_capacity < amps {+                r["status"] = json!("fail");+                r["hint"] = json!(format!("{net} is carried by pours on {} but only {} vias ({via_capacity:.0} A) join the layers for {amps:.1} A: add stitching vias (0.8/0.4 mm, about 1.5 A each)", pour_layers.join("+"), w.vias));+                ok = false;+            } else {+                r["status"] = json!("pass");+                r["how"] = json!(format!("pour on {} ({pour_area:.0} mm2 filled) carries the load; tracks are {widest:.2} mm at most", pour_layers.join("+")));+            }+        } else {+            r["status"] = json!("fail");+            let need = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0].iter().find(|w| rise_for(**w, t_um, amps, true) <= max_rise).map(|w| format!("{w}")).unwrap_or_else(|| ">6".into());+            r["hint"] = json!(format!("{net} carries {amps:.1} A on a {widest:.2} mm track ({rise_wide:.0} C rise, limit {max_rise:.0} C) and has no pour: pour it on the outer layers or widen to {need} mm"));+            ok = false;+        }+        results.push(r);+    }+    (ok, results)+}++/// spec.hot: {ref: {watts, tabNet, maxThetaCPerW?}}. 0.1: copper presence and vias at the tab.+pub fn analyze_thermal(plan: &Value, spec: &Value, zone_state: Option<&Value>) -> (bool, Vec<Value>) {+    let widths = plan_widths(plan);+    let pours = pours_by_net(zone_state);+    let mut results = Vec::new();+    let mut ok = true;+    for (reference, h) in spec.get("hot").and_then(|l| l.as_object()).cloned().unwrap_or_default() {+        let net = h.get("tabNet").and_then(|n| n.as_str()).unwrap_or("").to_string();+        let watts = h.get("watts").and_then(|v| v.as_f64()).unwrap_or(0.0);+        let zs = pours.get(&net).cloned().unwrap_or_default();+        let area = (fill_area(&zs) * 10.0).round() / 10.0;+        let layers = layers_of(&zs);+        let vias = widths.get(&net).map(|w| w.vias).unwrap_or(0);+        let theta = 40.0 / (area / 645.16).max(0.05);+        // limits: a theta ceiling (maxThetaCPerW, per part or spec-wide) and/or a rise budget+        // (maxRiseC per part, hotMaxRiseC spec-wide); with neither, 60 C/W+        let theta_limit = h.get("maxThetaCPerW").and_then(|v| v.as_f64()).or_else(|| spec.get("maxThetaCPerW").and_then(|v| v.as_f64()));+        let rise_limit = h.get("maxRiseC").and_then(|v| v.as_f64()).or_else(|| spec.get("hotMaxRiseC").and_then(|v| v.as_f64()));+        let limit = theta_limit.unwrap_or(if rise_limit.is_some() { f64::INFINITY } else { 60.0 });+        let rise = theta * watts;+        let mut r = json!({"ref": reference, "watts": watts, "tabNet": net, "pourLayers": layers, "pourFilledMm2": area, "viasOnNet": vias, "thetaEstimateCPerW": (theta * 10.0).round() / 10.0, "limitCPerW": theta_limit, "riseEstimateC": (rise * 10.0).round() / 10.0, "riseLimitC": rise_limit});+        if zs.is_empty() {+            r["status"] = json!("fail");+            r["hint"] = json!(format!("{reference} dissipates {watts:.1} W into {net} but {net} has no pour: pour the tab net on the outer layers with a solid connection and thermal vias in the tab"));+            ok = false;+        } else if theta > limit {+            r["status"] = json!("fail");+            r["hint"] = json!(format!("{reference}: {area:.0} mm2 of copper on {net} gives about {theta:.0} C/W (limit {limit:.0}): widen the pour or add the other outer layer with vias"));+            ok = false;+        } else if rise_limit.is_some_and(|l| rise > l) {+            r["status"] = json!("fail");+            r["hint"] = json!(format!("{reference}: {watts:.2} W into {area:.0} mm2 of {net} copper is about {rise:.0} C above ambient (budget {:.0} C): widen the pour or add the other outer layer with vias", rise_limit.unwrap()));+            ok = false;+        } else if vias == 0 && layers.len() >= 2 {+            r["status"] = json!("fail");+            r["hint"] = json!(format!("{reference}: the {net} pour is on two layers but no via joins them near the tab: add thermal vias in or beside the tab"));+            ok = false;+        } else {+            r["status"] = json!("pass");+            r["how"] = json!(format!("{area:.0} mm2 of {net} copper on {}, about {theta:.0} C/W, {rise:.0} C at {watts:.2} W, {vias} vias", layers.join("+")));+        }+        results.push(r);+    }+    (ok, results)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn ipc_curve_is_sane() {+        // a 1 mm track of 35 um copper carries about 2 to 3 A at 10 C rise on an outer layer+        let a = ipc2221_amps(1.0, 35.0, 10.0, true);+        assert!(a > 1.5 && a < 3.5, "{a}");+        assert!(rise_for(0.25, 35.0, 20.0, true) > 100.0);+    }+}
crates/aiflow-board/Cargo.tomladded+10
@@ -0,0 +1,10 @@+[package]+name = "aiflow-board"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: board"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-board/examples/dump.rsadded+12
@@ -0,0 +1,12 @@+use aiflow_board::Board;+fn main() {+    let p = std::env::args().nth(1).unwrap();+    let b = Board::load(&p).unwrap();+    let thru = b.pads.iter().filter(|p| p.thru).count();+    let f = b.pads.iter().filter(|p| p.on_f && !p.thru).count();+    let bb = b.pads.iter().filter(|p| p.on_b && !p.thru).count();+    println!("pads {} thru {} smd-f {} smd-b {} footprints {} nets {} layers {:?} bbox {:?}", b.pads.len(), thru, f, bb, b.footprints.len(), b.nets.len(), b.copper_layers, b.bbox);+    let mut by_ref: std::collections::BTreeMap<String, usize> = Default::default();+    for p in &b.pads { *by_ref.entry(p.reference.clone()).or_default() += 1; }+    println!("{:?}", by_ref.iter().take(12).collect::<Vec<_>>());+}
crates/aiflow-board/src/geom.rsadded+195
@@ -0,0 +1,195 @@+//! Plane geometry shared by every stage: KiCad's rotation convention, pad shapes, arcs, outlines.++pub type Pt = (f64, f64);++/// KiCad's board rotation of a local offset (y down, positive angle = counter-clockwise on screen).+pub fn rot_kicad(px: f64, py: f64, deg: f64) -> Pt {+    let a = deg.to_radians();+    let (s, c) = a.sin_cos();+    (px * c + py * s, -px * s + py * c)+}++/// A circle as a polygon that CONTAINS the true circle: the vertices sit on a radius of+/// r / cos(pi / n), so every chord is tangent to the real arc. A polygon inscribed in the arc is+/// smaller than the copper by r (1 - cos(pi / n)), and KiCad's DRC measures the copper: with+/// 10 segments per semicircle that is 0.0012 mm on a 0.3 mm oval pad, and a 0.1989 mm clearance+/// against a 0.2 mm rule.+pub fn circle_poly(cx: f64, cy: f64, r: f64, n: usize) -> Vec<Pt> {+    let rr = r / (std::f64::consts::PI / n as f64).cos();+    (0..n).map(|k| {+        let t = 2.0 * std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+        (cx + rr * t.cos(), cy + rr * t.sin())+    }).collect()+}++/// Oval pad of size sx by sy centred at the origin.+pub fn stadium_poly(sx: f64, sy: f64, n: usize) -> Vec<Pt> {+    if sx >= sy {+        let r = sy / 2.0;+        let d = (sx - sy) / 2.0;+        // circumscribed arcs (see circle_poly): the polygon contains the real copper+        let rr = r / (std::f64::consts::PI / (2 * n) as f64).cos();+        let mut pts = Vec::with_capacity(2 * n + 2);+        for k in 0..n {+            let a = -std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+            pts.push((d + rr * a.cos(), rr * a.sin()));+        }+        for k in 0..n {+            let a = std::f64::consts::FRAC_PI_2 + std::f64::consts::PI * (k as f64 + 0.5) / n as f64;+            pts.push((-d + rr * a.cos(), rr * a.sin()));+        }+        pts+    } else {+        stadium_poly(sy, sx, n).into_iter().map(|(x, y)| (y, x)).collect()+    }+}++/// A KiCad three-point arc (start, mid, end) sampled into a polyline including both ends.+pub fn arc_points(p0: Pt, pm: Pt, p1: Pt, max_step: f64) -> Vec<Pt> {+    let (x0, y0) = p0;+    let (xm, ym) = pm;+    let (x1, y1) = p1;+    let (ax, ay) = (xm - x0, ym - y0);+    let (bx, by) = (x1 - x0, y1 - y0);+    let d = 2.0 * (ax * by - ay * bx);+    if d.abs() < 1e-9 {+        return vec![p0, p1];+    }+    let a2 = ax * ax + ay * ay;+    let b2 = bx * bx + by * by;+    let ux = (by * a2 - ay * b2) / d;+    let uy = (ax * b2 - bx * a2) / d;+    let (cx, cy) = (x0 + ux, y0 + uy);+    let r = ux.hypot(uy);+    let t0 = (y0 - cy).atan2(x0 - cx);+    let tm = (ym - cy).atan2(xm - cx);+    let t1 = (y1 - cy).atan2(x1 - cx);+    let ccw = |a: f64, b: f64| (b - a).rem_euclid(2.0 * std::f64::consts::PI);+    let sweep = if ccw(t0, tm) <= ccw(t0, t1) { ccw(t0, t1) } else { -ccw(t1, t0) };+    let n = ((sweep.abs() * r / max_step).ceil() as usize).max(2);+    (0..=n).map(|k| {+        let t = t0 + sweep * k as f64 / n as f64;+        (cx + r * t.cos(), cy + r * t.sin())+    }).collect()+}++fn dist(a: Pt, b: Pt) -> f64 {+    (a.0 - b.0).hypot(a.1 - b.1)+}++/// Join open polylines end to end into one closed loop (the board outline).+pub fn chain_polylines(mut pieces: Vec<Vec<Pt>>, tol: f64) -> Result<Vec<Pt>, String> {+    pieces.retain(|p| p.len() >= 2);+    if pieces.is_empty() {+        return Ok(Vec::new());+    }+    let mut looped = pieces.remove(0);+    while !pieces.is_empty() {+        let end = *looped.last().unwrap();+        let mut best: Option<(usize, bool)> = None;+        for (idx, p) in pieces.iter().enumerate() {+            if dist(end, p[0]) < tol {+                best = Some((idx, false));+                break;+            }+            if dist(end, *p.last().unwrap()) < tol {+                best = Some((idx, true));+                break;+            }+        }+        let Some((idx, rev)) = best else {+            return Err(format!("Edge.Cuts outline is not one closed loop (gap at {:.3}, {:.3})", end.0, end.1));+        };+        let mut p = pieces.remove(idx);+        if rev {+            p.reverse();+        }+        looped.extend(p.into_iter().skip(1));+    }+    if looped.len() > 1 && dist(looped[0], *looped.last().unwrap()) < tol {+        looped.pop();+    }+    Ok(looped)+}++pub fn polygon_area(poly: &[Pt]) -> f64 {+    let n = poly.len();+    if n < 3 {+        return 0.0;+    }+    let mut a = 0.0;+    for i in 0..n {+        let (x0, y0) = poly[i];+        let (x1, y1) = poly[(i + 1) % n];+        a += x0 * y1 - x1 * y0;+    }+    a / 2.0+}++pub fn point_in_poly(x: f64, y: f64, poly: &[Pt]) -> bool {+    let mut inside = false;+    let n = poly.len();+    for k in 0..n {+        let (ax, ay) = poly[k];+        let (bx, by) = poly[(k + 1) % n];+        if (ay > y) != (by > y) && x < ax + (y - ay) * (bx - ax) / (by - ay) {+            inside = !inside;+        }+    }+    inside+}++pub fn seg_distance(x: f64, y: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {+    let (dx, dy) = (bx - ax, by - ay);+    let l2 = dx * dx + dy * dy;+    if l2 < 1e-12 {+        return (x - ax).hypot(y - ay);+    }+    let t = (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0);+    (x - (ax + t * dx)).hypot(y - (ay + t * dy))+}++/// 0 inside the polygon, else the distance to its boundary.+pub fn point_poly_distance(x: f64, y: f64, poly: &[Pt]) -> f64 {+    if point_in_poly(x, y, poly) {+        return 0.0;+    }+    let n = poly.len();+    let mut best = f64::INFINITY;+    for k in 0..n {+        let (ax, ay) = poly[k];+        let (bx, by) = poly[(k + 1) % n];+        best = best.min(seg_distance(x, y, ax, ay, bx, by));+    }+    best+}++/// Cheap outward growth of a polygon: every vertex pushed away from the centroid by d.+pub fn grow_poly(poly: &[Pt], d: f64) -> Vec<Pt> {+    let n = poly.len() as f64;+    let cx = poly.iter().map(|p| p.0).sum::<f64>() / n;+    let cy = poly.iter().map(|p| p.1).sum::<f64>() / n;+    poly.iter().map(|&(x, y)| {+        let (vx, vy) = (x - cx, y - cy);+        let l = vx.hypot(vy).max(1e-9);+        (x + vx / l * d, y + vy / l * d)+    }).collect()+}++pub fn thick_line_poly(a: Pt, b: Pt, hw: f64) -> Vec<Pt> {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l = dx.hypot(dy).max(1e-9);+    let (nx, ny) = (-dy / l * hw, dx / l * hw);+    vec![(a.0 + nx, a.1 + ny), (b.0 + nx, b.1 + ny), (b.0 - nx, b.1 - ny), (a.0 - nx, a.1 - ny)]+}++pub fn bbox(pts: &[Pt]) -> (f64, f64, f64, f64) {+    let mut b = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);+    for &(x, y) in pts {+        b.0 = b.0.min(x);+        b.1 = b.1.min(y);+        b.2 = b.2.max(x);+        b.3 = b.3.max(y);+    }+    b+}
crates/aiflow-board/src/lib.rsadded+338
@@ -0,0 +1,338 @@+//! The board model every stage reads: a KiCad 10 `.kicad_pcb` as footprints, copper pads with+//! their real outlines in board coordinates, nets, the Edge.Cuts outline and the copper layers.+//! Nothing here writes a file; the writers splice into `text` using the spans the reader keeps.+pub mod geom;+pub mod sx;++use std::collections::{BTreeMap, HashMap};++pub use geom::Pt;+pub use sx::{num, quote, Node};++pub const F_CU: usize = 0;+pub const B_CU: usize = 1;+pub const LAYER_NAMES: [&str; 2] = ["F.Cu", "B.Cu"];++#[derive(Debug, Clone)]+pub struct Pad {+    pub reference: String,+    pub name: String,+    pub key: String,+    pub net: String,+    pub x: f64,+    pub y: f64,+    pub angle: f64,+    pub shape: String,+    pub size: (f64, f64),+    pub drill: Option<f64>,+    pub thru: bool,+    pub on_f: bool,+    pub on_b: bool,+    pub polys: Vec<Vec<Pt>>,+    pub fp_x: f64,+    pub fp_y: f64,+    pub long_axis: Pt,+    pub half_long: f64,+    pub half_short: f64,+    pub uuid: String,+    pub polys_centre: Pt,+    pub clearance: f64,+}++impl Pad {+    pub fn min_dim(&self) -> f64 {+        2.0 * self.half_short+    }+    pub fn on_layer(&self, layer: usize) -> bool {+        if layer == F_CU { self.on_f } else { self.on_b }+    }+}++#[derive(Debug, Clone)]+pub struct Footprint {+    pub reference: String,+    pub x: f64,+    pub y: f64,+    pub rot: f64,+    pub layer: String,+    pub lib: String,+    pub start: usize,+    pub end: usize,+}++#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum NetFormat {+    Number,+    Name,+}++pub struct Board {+    pub path: String,+    pub text: String,+    pub root: Node,+    pub net_table: BTreeMap<i64, String>,+    pub net_format: NetFormat,+    pub pads: Vec<Pad>,+    pub footprints: BTreeMap<String, Footprint>,+    pub outline: Vec<Pt>,+    pub bbox: (f64, f64, f64, f64),+    pub nets: BTreeMap<String, Vec<usize>>,+    pub copper_layers: Vec<String>,+    pad_index: HashMap<String, usize>,+}++fn pad_local_polys(pn: &Node, shape: &str, sx: f64, sy: f64) -> Vec<Vec<Pt>> {+    let rectp = |sx: f64, sy: f64| vec![(-sx / 2.0, -sy / 2.0), (sx / 2.0, -sy / 2.0), (sx / 2.0, sy / 2.0), (-sx / 2.0, sy / 2.0)];+    let mut polys = Vec::new();+    match shape {+        "rect" | "roundrect" | "trapezoid" | "chamfer" => polys.push(rectp(sx, sy)),+        "oval" => polys.push(geom::stadium_poly(sx, sy, 10)),+        "circle" => polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32)),+        "custom" => {+            let anchor = pn.find("options").and_then(|o| o.find("anchor")).and_then(|a| a.value()).unwrap_or("rect");+            if anchor == "circle" {+                polys.push(geom::circle_poly(0.0, 0.0, sx / 2.0, 32));+            } else {+                polys.push(rectp(sx, sy));+            }+            if let Some(prims) = pn.find("primitives") {+                for prim in prims.nodes() {+                    match prim.tag.as_str() {+                        "gr_poly" => {+                            if let Some(pts) = prim.find("pts") {+                                let mut poly: Vec<Pt> = pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect();+                                let w = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0);+                                if w > 0.0 {+                                    poly = geom::grow_poly(&poly, w / 2.0);+                                }+                                if poly.len() >= 3 {+                                    polys.push(poly);+                                }+                            }+                        }+                        "gr_rect" => {+                            if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {+                                if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {+                                    polys.push(vec![(x0, y0), (x1, y0), (x1, y1), (x0, y1)]);+                                }+                            }+                        }+                        "gr_circle" => {+                            if let (Some(c), Some(e)) = (prim.find("center"), prim.find("end")) {+                                if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {+                                    let r = (ex - cx).hypot(ey - cy) + prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.0) / 2.0;+                                    polys.push(geom::circle_poly(cx, cy, r, 32));+                                }+                            }+                        }+                        "gr_line" => {+                            if let (Some(s), Some(e)) = (prim.find("start"), prim.find("end")) {+                                let hw = prim.find("width").and_then(|w| w.number(1)).unwrap_or(0.1) / 2.0;+                                if let (Some(x0), Some(y0), Some(x1), Some(y1)) = (s.number(1), s.number(2), e.number(1), e.number(2)) {+                                    polys.push(geom::thick_line_poly((x0, y0), (x1, y1), hw));+                                }+                            }+                        }+                        _ => {}+                    }+                }+            }+        }+        _ => polys.push(rectp(sx, sy)),+    }+    polys+}++pub fn reference_of(fp: &Node) -> Option<String> {+    for p in fp.find_all("property") {+        if p.atom(1) == Some("Reference") {+            return p.atom(2).map(str::to_string);+        }+    }+    fp.find("fp_text").filter(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)).map(str::to_string)+}++impl Board {+    pub fn load(path: &str) -> Result<Board, String> {+        let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+        Board::from_text(path, text)+    }++    pub fn from_text(path: &str, text: String) -> Result<Board, String> {+        let root = sx::parse(&text)?;+        if root.tag != "kicad_pcb" {+            return Err(format!("{path} is not a kicad_pcb"));+        }+        let mut net_table = BTreeMap::new();+        for n in root.find_all("net") {+            if let (Some(k), Some(name)) = (n.atom(1).and_then(|a| a.parse::<i64>().ok()), n.atom(2)) {+                net_table.insert(k, name.to_string());+            }+        }+        let net_format = if net_table.is_empty() { NetFormat::Name } else { NetFormat::Number };+        let mut b = Board { path: path.to_string(), text: String::new(), root, net_table, net_format, pads: Vec::new(), footprints: BTreeMap::new(), outline: Vec::new(), bbox: (0.0, 0.0, 0.0, 0.0), nets: BTreeMap::new(), copper_layers: Vec::new(), pad_index: HashMap::new() };+        b.read_footprints();+        b.outline = b.read_outline()?;+        b.bbox = geom::bbox(&b.outline);+        for (i, p) in b.pads.iter().enumerate() {+            if !p.net.is_empty() {+                b.nets.entry(p.net.clone()).or_default().push(i);+            }+            b.pad_index.insert(p.key.clone(), i);+        }+        if let Some(layers) = b.root.find("layers") {+            for l in layers.nodes() {+                let kind = l.atom(2).unwrap_or("");+                if matches!(kind, "signal" | "power" | "mixed") {+                    if let Some(name) = l.atom(1) {+                        b.copper_layers.push(name.to_string());+                    }+                }+            }+        }+        b.text = text;+        Ok(b)+    }++    fn read_footprints(&mut self) {+        let root = self.root.clone();+        for fp in root.find_all("footprint") {+            let Some(reference) = reference_of(fp) else { continue };+            let at = fp.find("at");+            let fx = at.and_then(|a| a.number(1)).unwrap_or(0.0);+            let fy = at.and_then(|a| a.number(2)).unwrap_or(0.0);+            let rot = at.and_then(|a| a.number(3)).unwrap_or(0.0);+            let layer = fp.find("layer").and_then(|l| l.value()).unwrap_or("F.Cu").to_string();+            let fp_clearance = fp.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);+            self.footprints.insert(reference.clone(), Footprint { reference: reference.clone(), x: fx, y: fy, rot, layer, lib: fp.value().unwrap_or("").to_string(), start: fp.start, end: fp.end });+            for pn in fp.find_all("pad") {+                let name = pn.value().unwrap_or("").to_string();+                let ptype = pn.atom(2).unwrap_or("");+                let shape = pn.atom(3).unwrap_or("rect").to_string();+                let mut on_f = false;+                let mut on_b = false;+                let mut copper = false;+                if let Some(ln) = pn.find("layers") {+                    for l in ln.items.iter().filter_map(|it| if let sx::Item::Atom { text, .. } = it { Some(text.as_str()) } else { None }) {+                        if l == "*.Cu" {+                            on_f = true; on_b = true; copper = true;+                        } else if l.ends_with(".Cu") {+                            copper = true;+                            if l == "F.Cu" { on_f = true; }+                            if l == "B.Cu" { on_b = true; }+                        }+                    }+                }+                if !copper || name.is_empty() {+                    continue;+                }+                let pat = pn.find("at");+                let px = pat.and_then(|a| a.number(1)).unwrap_or(0.0);+                let py = pat.and_then(|a| a.number(2)).unwrap_or(0.0);+                let pang = pat.and_then(|a| a.number(3)).unwrap_or(rot);+                let size = pn.find("size");+                let sx_ = size.and_then(|s| s.number(1)).unwrap_or(0.0);+                let sy_ = size.and_then(|s| s.number(2)).unwrap_or(0.0);+                let drill = pn.find("drill").and_then(|d| d.number(1));+                let mut net = pn.find("net").and_then(|n| n.value()).unwrap_or("").to_string();+                if self.net_format == NetFormat::Number {+                    if let Some(k) = pn.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {+                        if let Some(nm) = self.net_table.get(&k) {+                            net = nm.clone();+                        }+                    }+                }+                let (ox, oy) = geom::rot_kicad(px, py, rot);+                let (x, y) = (fx + ox, fy + oy);+                let thru = ptype == "thru_hole" || ptype == "np_thru_hole";+                let pclr = pn.find("clearance").and_then(|c| c.number(1)).unwrap_or(0.0);+                let local = pad_local_polys(pn, &shape, sx_, sy_);+                let (x0, y0, x1, y1) = geom::bbox(&local.iter().flatten().copied().collect::<Vec<_>>());+                let (lx, ly) = ((x1 - x0) / 2.0, (y1 - y0) / 2.0);+                let (lcx, lcy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);+                let (half_long, half_short, axis) = if lx >= ly { (lx, ly, geom::rot_kicad(1.0, 0.0, pang)) } else { (ly, lx, geom::rot_kicad(0.0, 1.0, pang)) };+                let polys: Vec<Vec<Pt>> = local.iter().map(|poly| poly.iter().map(|&(a, b)| { let (rx, ry) = geom::rot_kicad(a, b, pang); (x + rx, y + ry) }).collect()).collect();+                let (ccx, ccy) = geom::rot_kicad(lcx, lcy, pang);+                self.pads.push(Pad {+                    key: format!("{reference}.{name}"), reference: reference.clone(), name, net, x, y, angle: pang, shape, size: (sx_, sy_), drill, thru,+                    on_f: on_f && (thru || true) && on_f, on_b: on_b || thru, polys, fp_x: fx, fp_y: fy, long_axis: axis, half_long, half_short,+                    uuid: pn.find("uuid").and_then(|u| u.value()).unwrap_or("").to_string(), polys_centre: (x + ccx, y + ccy), clearance: fp_clearance.max(pclr),+                });+            }+        }+    }++    fn read_outline(&self) -> Result<Vec<Pt>, String> {+        let mut pieces: Vec<Vec<Pt>> = Vec::new();+        for node in self.root.nodes() {+            if !node.tag.starts_with("gr_") {+                continue;+            }+            if node.find("layer").and_then(|l| l.value()) != Some("Edge.Cuts") {+                continue;+            }+            let p2 = |n: Option<&Node>| -> Option<Pt> { let n = n?; Some((n.number(1)?, n.number(2)?)) };+            match node.tag.as_str() {+                "gr_line" => {+                    if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {+                        pieces.push(vec![s, e]);+                    }+                }+                "gr_arc" => {+                    if let (Some(s), Some(m), Some(e)) = (p2(node.find("start")), p2(node.find("mid")), p2(node.find("end"))) {+                        pieces.push(geom::arc_points(s, m, e, 0.35));+                    }+                }+                "gr_rect" => {+                    if let (Some(s), Some(e)) = (p2(node.find("start")), p2(node.find("end"))) {+                        return Ok(vec![s, (e.0, s.1), e, (s.0, e.1)]);+                    }+                }+                "gr_circle" => {+                    if let (Some(c), Some(e)) = (p2(node.find("center")), p2(node.find("end"))) {+                        return Ok(geom::circle_poly(c.0, c.1, (e.0 - c.0).hypot(e.1 - c.1), 64));+                    }+                }+                "gr_poly" => {+                    if let Some(pts) = node.find("pts") {+                        return Ok(pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect());+                    }+                }+                _ => {}+            }+        }+        if pieces.is_empty() {+            return Err("no Edge.Cuts outline".into());+        }+        geom::chain_polylines(pieces, 0.01)+    }++    /// The `(net ...)` line for copper on this board (matches the bridge's net_sexpr).+    pub fn net_ref_sexpr(&self, net: &str) -> Result<String, String> {+        match self.net_format {+            NetFormat::Number => {+                let n = self.net_table.iter().find(|(_, v)| v.as_str() == net).map(|(k, _)| *k).ok_or_else(|| format!("net {net:?} is not in the net table"))?;+                Ok(format!("\t\t(net {n})\n"))+            }+            NetFormat::Name => Ok(format!("\t\t(net {})\n", quote(net))),+        }+    }++    pub fn pad_by_key(&self, key: &str) -> Option<&Pad> {+        self.pad_index.get(key).map(|&i| &self.pads[i])+    }++    pub fn pad_idx(&self, key: &str) -> Option<usize> {+        self.pad_index.get(key).copied()+    }++    pub fn pads_of(&self, reference: &str) -> Vec<&Pad> {+        self.pads.iter().filter(|p| p.reference == reference).collect()+    }++    /// Pad-box (copper extent) of every pad of a footprint: (x0, y0, x1, y1).+    pub fn pad_box(&self, reference: &str) -> Option<(f64, f64, f64, f64)> {+        let pts: Vec<Pt> = self.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).collect();+        if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }+    }+}
crates/aiflow-board/src/sx.rsadded+197
@@ -0,0 +1,197 @@+//! A span-keeping S-expression reader for `.kicad_pcb` files: every node knows where it starts and+//! ends in the text, so the writers splice edits without re-serialising anything.++#[derive(Debug, Clone)]+pub enum Item {+    Atom { text: String, start: usize, end: usize },+    Node(Node),+}++#[derive(Debug, Clone)]+pub struct Node {+    pub tag: String,+    pub items: Vec<Item>,+    pub start: usize,+    pub end: usize,+}++impl Node {+    /// The i-th atom (0 = the tag), unquoted.+    pub fn atom(&self, i: usize) -> Option<&str> {+        if i == 0 {+            return Some(&self.tag);+        }+        match self.items.get(i - 1) {+            Some(Item::Atom { text, .. }) => Some(text.as_str()),+            _ => None,+        }+    }++    pub fn number(&self, i: usize) -> Option<f64> {+        self.atom(i)?.parse::<f64>().ok()+    }++    /// The first atom after the tag (what `(layer "F.Cu")` means).+    pub fn value(&self) -> Option<&str> {+        self.atom(1)+    }++    pub fn find(&self, tag: &str) -> Option<&Node> {+        self.items.iter().find_map(|it| match it {+            Item::Node(n) if n.tag == tag => Some(n),+            _ => None,+        })+    }++    pub fn find_all<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a {+        self.items.iter().filter_map(move |it| match it {+            Item::Node(n) if n.tag == tag => Some(n),+            _ => None,+        })+    }++    pub fn nodes(&self) -> impl Iterator<Item = &Node> {+        self.items.iter().filter_map(|it| match it {+            Item::Node(n) => Some(n),+            _ => None,+        })+    }+}++fn unescape(s: &str) -> String {+    let mut out = String::with_capacity(s.len());+    let mut chars = s.chars();+    while let Some(c) = chars.next() {+        if c == '\\' {+            match chars.next() {+                Some('n') => out.push('\n'),+                Some('t') => out.push('\t'),+                Some(o) => out.push(o),+                None => {}+            }+        } else {+            out.push(c);+        }+    }+    out+}++/// Parse the whole text; the root is the outer list (normally `kicad_pcb`).+pub fn parse(text: &str) -> Result<Node, String> {+    let b = text.as_bytes();+    let mut i = 0;+    while i < b.len() && b[i].is_ascii_whitespace() {+        i += 1;+    }+    if i >= b.len() || b[i] != b'(' {+        return Err("text does not start with a list".into());+    }+    let (node, _) = parse_node(text, b, i)?;+    Ok(node)+}++fn parse_node(text: &str, b: &[u8], start: usize) -> Result<(Node, usize), String> {+    // b[start] == '('+    let mut i = start + 1;+    let mut items = Vec::new();+    let mut tag = String::new();+    let mut have_tag = false;+    loop {+        while i < b.len() && b[i].is_ascii_whitespace() {+            i += 1;+        }+        if i >= b.len() {+            return Err(format!("unterminated list starting at {start}"));+        }+        match b[i] {+            b')' => {+                return Ok((Node { tag, items, start, end: i + 1 }, i + 1));+            }+            b'(' => {+                let (n, e) = parse_node(text, b, i)?;+                items.push(Item::Node(n));+                i = e;+            }+            b'"' => {+                let s = i;+                i += 1;+                let mut raw = String::new();+                while i < b.len() && b[i] != b'"' {+                    if b[i] == b'\\' && i + 1 < b.len() {+                        raw.push(b[i] as char);+                        raw.push(b[i + 1] as char);+                        i += 2;+                        continue;+                    }+                    // copy one UTF-8 char+                    let ch = text[i..].chars().next().unwrap();+                    raw.push(ch);+                    i += ch.len_utf8();+                }+                if i >= b.len() {+                    return Err(format!("unterminated string at {s}"));+                }+                i += 1;+                let txt = unescape(&raw);+                if !have_tag {+                    tag = txt;+                    have_tag = true;+                } else {+                    items.push(Item::Atom { text: txt, start: s, end: i });+                }+            }+            _ => {+                let s = i;+                while i < b.len() && !b[i].is_ascii_whitespace() && b[i] != b'(' && b[i] != b')' {+                    i += 1;+                }+                let txt = text[s..i].to_string();+                if !have_tag {+                    tag = txt;+                    have_tag = true;+                } else {+                    items.push(Item::Atom { text: txt, start: s, end: i });+                }+            }+        }+    }+}++/// KiCad's own float formatting: trimmed, at most six decimals, never "-0".+pub fn num(v: f64) -> String {+    let mut s = format!("{:.6}", v);+    while s.ends_with('0') {+        s.pop();+    }+    if s.ends_with('.') {+        s.pop();+    }+    if s.is_empty() || s == "-" || s == "-0" {+        "0".to_string()+    } else {+        s+    }+}++pub fn quote(s: &str) -> String {+    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn parses_nested_lists_with_spans() {+        let t = "(kicad_pcb (version 20240108) (net 0 \"\") (footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\")))";+        let n = parse(t).unwrap();+        assert_eq!(n.tag, "kicad_pcb");+        let fp = n.find("footprint").unwrap();+        assert_eq!(fp.value(), Some("Lib:X"));+        assert_eq!(fp.find("at").unwrap().number(3), Some(90.0));+        assert_eq!(&t[fp.start..fp.end], "(footprint \"Lib:X\" (at 1 2 90) (property \"Reference\" \"R1\"))");+        assert_eq!(num(1.5), "1.5");+        assert_eq!(num(-0.0000001), "0");+        assert_eq!(num(120.54), "120.54");+    }+}
crates/aiflow-board/tests/esc.rsadded+20
@@ -0,0 +1,20 @@+use aiflow_board::Board;++#[test]+fn reads_the_esc_fixture_when_present() {+    let p = "/home/adom/project/adom-desktop-kicad-bridge/demo/routing/esc/esc-g431-fable-placed-planes.kicad_pcb";+    if !std::path::Path::new(p).is_file() {+        return;+    }+    let b = Board::load(p).unwrap();+    assert_eq!(b.pads.len(), 365, "copper pads");+    assert_eq!(b.footprints.len(), 149, "footprints");+    assert_eq!(b.copper_layers.len(), 4);+    assert_eq!(b.bbox, (104.0, 58.0, 168.0, 132.0));+    let q1 = b.pad_by_key("Q1.5").unwrap();+    assert_eq!(q1.net, "+VBAT");+    assert!((q1.x - 155.75).abs() < 0.05 && (q1.y - 108.5).abs() < 0.05, "{} {}", q1.x, q1.y);+    assert!(b.nets.get("GND").map(|v| v.len()).unwrap_or(0) > 60);+    let u5 = b.pad_by_key("U5.5").unwrap();+    assert!(u5.half_long > u5.half_short && u5.min_dim() < 0.5, "U5.5 is a narrow pin: {} {}", u5.half_long, u5.half_short);+}
crates/aiflow-bridge/Cargo.tomladded+10
@@ -0,0 +1,10 @@+[package]+name = "aiflow-bridge"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: bridge"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-bridge/src/lib.rsadded+290
@@ -0,0 +1,290 @@+//! The live half: everything that touches the desktop goes through the Adom Bridge CLI+//! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval+//! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement,+//! validation.+use std::process::Command;++use serde_json::{json, Value};++/// Shoelace area of a zone entry's `polygon` ([[x, y], ...]).+fn polygon_area_of(z: &Value) -> f64 {+    let pts: Vec<(f64, f64)> = z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).unwrap_or_default();+    if pts.len() < 3 {+        return 0.0;+    }+    let mut a = 0.0;+    for i in 0..pts.len() {+        let (x1, y1) = pts[i];+        let (x2, y2) = pts[(i + 1) % pts.len()];+        a += x1 * y2 - x2 * y1;+    }+    (a / 2.0).abs()+}++#[derive(Clone)]+pub struct Bridge {+    pub ai_thread: String,+    pub target: String,+}++impl Bridge {+    pub fn call(&self, verb: &str, args: &Value) -> Value {+        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();+        match out {+            Ok(o) => {+                let text = String::from_utf8_lossy(&o.stdout);+                serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))+            }+            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),+        }+    }++    pub fn ok(v: &Value) -> bool {+        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())+    }++    /// The live board's revision. KiCad answers nothing while its UI thread fills zones after a+    /// commit, and the bridge may report itself busy while a previous verb's refill finishes; the+    /// read is retried for up to about two minutes. The last reply is kept for the log.+    pub fn revision(&self, board: &str) -> Option<String> {+        self.revision_with_reply(board).0+    }++    pub fn revision_with_reply(&self, board: &str) -> (Option<String>, Value) {+        let mut last = Value::Null;+        for attempt in 0..20 {+            let r = self.call("kicad_routing_state", &json!({"filePath": board}));+            if let Some(rev) = r.get("revision").and_then(|r| r.as_str()) {+                return (Some(rev.to_string()), r);+            }+            eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());+            last = r;+            std::thread::sleep(std::time::Duration::from_secs(6));+        }+        (None, last)+    }++    // -- capture recipe -------------------------------------------------------------------+    pub fn open_board(&self, remote_board: &str, reason: &str) -> Result<i64, Value> {+        let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));+        let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };+        self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));+        std::thread::sleep(std::time::Duration::from_secs(40));+        self.call("desktop_bring_to_front", &json!({"hwnd": hwnd, "reason": "aiflow capture: keep the PCB editor on screen on the test box"}));+        std::thread::sleep(std::time::Duration::from_secs(2));+        self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"}));+        std::thread::sleep(std::time::Duration::from_secs(3));+        Ok(hwnd)+    }++    /// Record ONE window (Windows Graphics Capture, background-capturable): the take survives+    /// anything another thread or an updater puts in front of KiCad on a shared test box, and it+    /// never needs the foreground, so nobody at the box is disturbed.+    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {+        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason}));+        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+    }++    /// A background screenshot of one window (owned popups included), copied into `save_dir`+    /// under `name`; returns the local path.+    pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {+        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);+        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;+        let dest = format!("{save_dir}/{name}.png");+        std::fs::copy(&local, &dest).ok()?;+        Some(dest)+    }++    /// The PCB editor's window handle, if KiCad has one open.+    pub fn pcb_editor_hwnd(&self) -> Option<i64> {+        let r = self.call("kicad_window_info", &json!({}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        d.get("editors").and_then(|e| e.as_array()).and_then(|a| a.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor"))).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())+    }++    pub fn record_start(&self, reason: &str) -> Result<String, Value> {+        let r = self.call("desktop_record_start", &json!({"monitor": 0, "audio": false, "fps": 30, "reason": reason, "maxDurationMs": 1_800_000}));+        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)+    }++    pub fn record_stop(&self, recording_id: &str, save_to: &str) -> (Option<String>, Value) {+        let r = self.call("desktop_record_stop", &json!({"recordingId": recording_id}));+        let mut p = r.get("filePath").or_else(|| r.get("path")).and_then(|v| v.as_str()).map(str::to_string);+        if p.is_none() {+            if let Some(out) = r.get("output").and_then(|o| o.as_str()) {+                if let Ok(v) = serde_json::from_str::<Value>(out) {+                    p = v.get("data").and_then(|d| d.get("path")).and_then(|x| x.as_str()).map(str::to_string);+                }+            }+        }+        let p = p.map(|s| s.replace('\\', "/"));+        if let Some(path) = &p {+            self.call("pull_file", &json!({"filePaths": [path], "saveTo": save_to}));+        }+        (p, r)+    }++    // -- landing ------------------------------------------------------------------------------+    /// One kicad_move_footprint 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}))+    }++    /// 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)) };+        let mut replies = Vec::new();+        let mut ok = 0;+        for (i, e) in entries.iter().enumerate() {+            let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});+            for k in ["viaSize", "viaDrill", "layer"] {+                if let Some(v) = e.get(k) {+                    args[k] = v.clone();+                }+            }+            if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }+            let r = self.call("kicad_route_net", &args);+            let good = Bridge::ok(&r);+            progress(i + 1, entries.len(), &r);+            if !good {+                replies.push(r);+                return (replies, ok, Some(i));+            }+            ok += 1;+            if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) {+                rev = nr.to_string();+            }+            replies.push(r);+            std::thread::sleep(std::time::Duration::from_millis(pause_ms));+        }+        (replies, ok, None)+    }++    /// Land zones grouped by net (one undo step per group, lowest priority first), converging the+    /// live board on the plan: a live zone with a planned zone's name and outline area (within 2 %)+    /// is already landed and is skipped; a live zone carrying one of the plan's names but another+    /// outline is stale (the spec changed) and is removed first. The revision is read fresh before+    /// every group because KiCad's refill after a commit changes the board again after the bridge+    /// has answered.+    pub fn land_zones(&self, board: &str, zones: &[Value], pause_ms: u64, inherited_ids: &[String]) -> Vec<(String, Value)> {+        let live = self.zone_state(board, false);+        let mut out = Vec::new();+        let names: Vec<String> = zones.iter().filter_map(|z| z.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect();+        let mut consumed = vec![false; zones.len()];+        let mut stale: Vec<String> = Vec::new();+        for l in live.get("zones").and_then(|z| z.as_array()).cloned().unwrap_or_default() {+            let (Some(name), Some(id)) = (l.get("name").and_then(|n| n.as_str()), l.get("id").and_then(|i| i.as_str())) else { continue };+            let kind = l.get("type").and_then(|t| t.as_str()).unwrap_or("copper");+            let inherited = inherited_ids.iter().any(|i| i == id);+            // the flow owns every copper or keepout zone the board did not start with; a zone it+            // owns that the plan no longer describes (renamed, reshaped, dropped) is stale+            if inherited || kind == "teardrop" || (name.is_empty() && !names.is_empty() && inherited_ids.is_empty()) {+                continue;+            }+            if !names.iter().any(|n| n == name) {+                stale.push(id.to_string());+                continue;+            }+            let area = l.get("outlineAreaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0);+            let hit = zones.iter().enumerate().position(|(i, z)| !consumed[i] && z.get("name").and_then(|n| n.as_str()) == Some(name) && {+                let pa = polygon_area_of(z);+                (pa - area).abs() <= 0.02 * pa.max(area).max(0.5)+            });+            match hit {+                Some(i) => consumed[i] = true,+                None => stale.push(id.to_string()),+            }+        }+        if !stale.is_empty() {+            let Some(rev) = self.revision(board) else { return vec![("removed-stale".into(), json!({"success": false, "errorCode": "no_revision"}))] };+            let r = self.call("kicad_remove_zone", &json!({"filePath": board, "expectedRevision": rev, "itemIds": stale}));+            let n = stale.len();+            let good = Bridge::ok(&r);+            out.push(("removed-stale".into(), json!({"success": good, "removed": n, "errorCode": r["errorCode"], "message": format!("{n} live zones the flow had landed are no longer in the plan (renamed, reshaped or dropped) and were removed before landing"), "reply": r})));+            if !good {+                return out;+            }+            std::thread::sleep(std::time::Duration::from_millis(pause_ms));+        }+        let skipped = consumed.iter().filter(|c| **c).count();+        if skipped > 0 {+            out.push(("already-landed".into(), json!({"success": true, "skipped": skipped, "message": format!("{skipped} zones were already on the live board (same name and outline) and were not landed again")})));+        }+        let mut groups: Vec<(String, Vec<Value>, u64)> = Vec::new();+        for (i, z) in zones.iter().enumerate() {+            if consumed[i] {+                continue;+            }+            let key = z.get("net").and_then(|n| n.as_str()).unwrap_or("keepout").to_string();+            let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);+            if let Some(g) = groups.iter_mut().find(|g| g.0 == key) {+                g.1.push(z.clone());+                g.2 = g.2.min(prio);+            } else {+                groups.push((key, vec![z.clone()], prio));+            }+        }+        groups.sort_by_key(|g| g.2);+        for (net, zs, _) in groups {+            let mut r = json!({"success": false, "errorCode": "no_revision"});+            for attempt in 0..3 {+                let (rev, last) = self.revision_with_reply(board);+                let Some(rev) = rev else { r["lastReply"] = last; break };+                r = self.call("kicad_add_zone", &json!({"filePath": board, "expectedRevision": rev, "zones": zs}));+                if r.get("errorCode").and_then(|e| e.as_str()) != Some("stale_board") || attempt == 2 {+                    break;+                }+                std::thread::sleep(std::time::Duration::from_millis(1500));+            }+            let good = Bridge::ok(&r);+            out.push((net, r));+            if !good {+                break;+            }+            std::thread::sleep(std::time::Duration::from_millis(pause_ms));+        }+        out+    }++    /// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait+    /// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.+    /// Free megabytes on the test box's recording drive (the temp folder's drive), via a shell query.+    pub fn remote_free_mb(&self) -> Option<u64> {+        // shell_execute is human-gated on ab (Tier 2: it asks the person at the box and needs a reason);+        // when it is refused or unanswered this returns None and the check falls back to the run drive+        let r = self.call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip, so a run never fills the disk"}));+        let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");+        out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)+    }++    pub fn zone_state(&self, board: &str, refill: bool) -> Value {+        let mut r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": refill}));+        for attempt in 0..15 {+            if r.get("filledAreaByLayerMm2").is_some() {+                break;+            }+            eprintln!("zone state read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());+            std::thread::sleep(std::time::Duration::from_secs(6));+            r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": false}));+        }+        r+    }++    /// Vias on the live board as (net, x, y).+    pub fn live_vias(&self, board: &str) -> Vec<(String, f64, f64)> {+        let r = self.call("kicad_routing_state", &json!({"filePath": board}));+        r.get("vias").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| Some((v.get("net_name")?.as_str()?.to_string(), v["position"]["x"].as_f64()?, v["position"]["y"].as_f64()?))).collect()).unwrap_or_default()+    }++    pub fn validate(&self, board: &str) -> Value {+        self.call("kicad_routing_validate", &json!({"filePath": board}))+    }++    pub fn status(&self) -> Value {+        self.call("kicad_status", &json!({}))+    }+}
crates/aiflow-copper/Cargo.tomladded+12
@@ -0,0 +1,12 @@+[package]+name = "aiflow-copper"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: copper"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }
crates/aiflow-copper/src/lib.rsadded+503
@@ -0,0 +1,503 @@+//! Plans to copper on a board file, and KiCad's DRC on the result.+//!+//! `apply` writes a routing plan (segments and vias as `kicad_route_net` would land them) into a+//! copy of the board, and by default fills every zone with a raster fill so the shared headless+//! DRC (`service-kicad pcb drc`, which does not refill zones) counts plane vias as connected.+//! `run_drc` and `summarize` give the gate its numbers: errors, new versus inherited against a+//! baseline, unconnected items.+use std::collections::{BTreeMap, HashMap};+use std::process::Command;++use aiflow_board::{num, Board, Pt};+use aiflow_grid::{Grid, Mask};+use serde_json::{json, Value};++pub const CLEARANCE: f64 = 0.2;+pub const EDGE_CLEARANCE: f64 = 0.5;+pub const SLOP: f64 = 0.1;++#[derive(Clone, Debug)]+pub struct Seg {+    pub x0: f64,+    pub y0: f64,+    pub x1: f64,+    pub y1: f64,+    pub layer: String,+    pub width: f64,+}++#[derive(Clone, Debug)]+pub struct ViaSpec {+    pub x: f64,+    pub y: f64,+    pub size: f64,+    pub drill: f64,+}++fn uuid() -> String {+    // v4-shaped from a time and counter seed: unique enough for a gate copy+    use std::sync::atomic::{AtomicU64, Ordering};+    static N: AtomicU64 = AtomicU64::new(0);+    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) as u64;+    let n = N.fetch_add(1, Ordering::Relaxed);+    let mut h = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;+    let mut parts = Vec::new();+    for _ in 0..4 {+        h ^= h >> 33;+        h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);+        h ^= h >> 33;+        parts.push(h);+    }+    format!("{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", parts[0] as u32, (parts[1] & 0xffff) as u16, (parts[1] >> 20) as u16 & 0xfff, 0x8000 | ((parts[2] & 0x3fff) as u16), parts[3] & 0xffff_ffff_ffff)+}++fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option<String>), String> {+    if let Some(key) = spec.as_str() {+        let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;+        return Ok((board.pads[i].x, board.pads[i].y, Some(i), None));+    }+    if let Some(o) = spec.as_object() {+        let layer = o.get("layer").and_then(|l| l.as_str()).map(str::to_string);+        if let Some(key) = o.get("pad").and_then(|p| p.as_str()) {+            let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;+            return Ok((board.pads[i].x, board.pads[i].y, Some(i), layer));+        }+        return Ok((o.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?, o.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?, None, layer));+    }+    if let Some(a) = spec.as_array() {+        if a.len() == 2 {+            return Ok((a[0].as_f64().ok_or("bad x")?, a[1].as_f64().ok_or("bad y")?, None, None));+        }+    }+    Err(format!("bad waypoint {spec}"))+}++/// One plan entry to its segments and vias.+pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<ViaSpec>), String> {+    let net = entry.get("net").and_then(|n| n.as_str()).ok_or("entry needs net")?;+    if !board.nets.contains_key(net) {+        return Err(format!("unknown net {net:?}"));+    }+    let width = entry.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);+    let size = entry.get("viaSize").and_then(|w| w.as_f64()).unwrap_or(0.6);+    let drill = entry.get("viaDrill").and_then(|w| w.as_f64()).unwrap_or(0.3);+    let paths: Vec<Value> = match entry.get("paths").and_then(|p| p.as_array()) {+        Some(p) => p.clone(),+        None => vec![entry.get("points").cloned().ok_or("entry needs paths or points")?],+    };+    let mut segments = Vec::new();+    let mut vias = Vec::new();+    for path in paths {+        let pts = path.as_array().ok_or("path must be a list")?;+        if pts.len() < 2 || pts.len() > 512 {+            return Err(format!("path needs 2..512 waypoints ({net})"));+        }+        let resolved: Vec<(f64, f64, Option<usize>, Option<String>)> = pts.iter().map(|p| point(p, board)).collect::<Result<_, _>>()?;+        for (_, _, pad, _) in &resolved {+            if let Some(i) = pad {+                if board.pads[*i].net != net {+                    return Err(format!("pad {} is on {}, not {}", board.pads[*i].key, board.pads[*i].net, net));+                }+            }+        }+        let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone()).unwrap_or_else(|| "F.Cu".to_string());+        for (i, (x, y, pad, explicit)) in resolved.iter().enumerate() {+            let nxt = explicit.clone().unwrap_or_else(|| layer.clone());+            if i > 0 {+                let (px, py) = (resolved[i - 1].0, resolved[i - 1].1);+                if (px, py) != (*x, *y) {+                    segments.push(Seg { x0: px, y0: py, x1: *x, y1: *y, layer: layer.clone(), width });+                }+            }+            if nxt != layer {+                vias.push(ViaSpec { x: *x, y: *y, size, drill });+                layer = nxt;+            }+            if let Some(pi) = pad {+                let p = &board.pads[*pi];+                let on = if layer == "F.Cu" { p.on_f } else if layer == "B.Cu" { p.on_b } else { p.thru };+                if !on {+                    return Err(format!("pad {} has no copper on {}", p.key, layer));+                }+            }+        }+    }+    Ok((segments, vias))+}++pub fn segment_sexpr(board: &Board, s: &Seg, net: &str) -> Result<String, String> {+    Ok(format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)", num(s.x0), num(s.y0), num(s.x1), num(s.y1), num(s.width), s.layer, board.net_ref_sexpr(net)?, uuid()))+}++pub fn via_sexpr(board: &Board, v: &ViaSpec, net: &str) -> Result<String, String> {+    Ok(format!("\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"F.Cu\" \"B.Cu\")\n{}\t\t(uuid \"{}\")\n\t)", num(v.x), num(v.y), num(v.size), num(v.drill), board.net_ref_sexpr(net)?, uuid()))+}++/// Insert blocks before the closing paren of the kicad_pcb list.+pub fn splice(text: &str, blocks: &[String]) -> String {+    let trimmed = text.trim_end();+    let idx = trimmed.rfind(')').unwrap_or(trimmed.len());+    let body = text[..idx].trim_end();+    let nl = if text.get(..2000).unwrap_or(text).contains("\r\n") { "\r\n" } else { "\n" };+    let mut out = String::with_capacity(text.len() + blocks.iter().map(|b| b.len() + 2).sum::<usize>());+    out.push_str(body);+    out.push_str(nl);+    out.push_str(&blocks.iter().map(|b| b.replace('\n', nl)).collect::<Vec<_>>().join(nl));+    out.push_str(nl);+    out.push(')');+    out.push_str(nl);+    out+}++// ----------------------------------------------------------------------------- plane raster fill++type Lat = (f64, f64);++fn trace_boundaries(free: &Mask) -> Vec<Vec<Lat>> {+    let (w, h) = (free.w as i64, free.h as i64);+    let get = |i: i64, j: i64| -> bool { i >= 0 && j >= 0 && i < w && j < h && free.bits[(j * w + i) as usize] };+    let mut out: HashMap<(i64, i64), Vec<(i64, i64)>> = HashMap::new();+    // lattice points scaled by 2 to stay integral: corner (i-0.5, j-0.5) -> (2i-1, 2j-1)+    for j in 0..h {+        for i in 0..w {+            if !get(i, j) {+                continue;+            }+            let tl = (2 * i - 1, 2 * j - 1);+            let tr = (2 * i + 1, 2 * j - 1);+            let br = (2 * i + 1, 2 * j + 1);+            let bl = (2 * i - 1, 2 * j + 1);+            if !get(i, j - 1) { out.entry(tl).or_default().push(tr); }+            if !get(i + 1, j) { out.entry(tr).or_default().push(br); }+            if !get(i, j + 1) { out.entry(br).or_default().push(bl); }+            if !get(i - 1, j) { out.entry(bl).or_default().push(tl); }+        }+    }+    let mut loops = Vec::new();+    let mut keys: Vec<(i64, i64)> = out.keys().copied().collect();+    keys.sort();+    let mut ki = 0;+    while !out.is_empty() {+        while ki < keys.len() && !out.contains_key(&keys[ki]) {+            ki += 1;+        }+        if ki >= keys.len() {+            break;+        }+        let a = keys[ki];+        let b = out.get_mut(&a).unwrap().pop().unwrap();+        if out[&a].is_empty() {+            out.remove(&a);+        }+        let mut lp = vec![a, b];+        let mut prev = (b.0 - a.0, b.1 - a.1);+        loop {+            let cur = *lp.last().unwrap();+            let Some(cands) = out.get_mut(&cur) else { break };+            let nxt = if cands.len() == 1 {+                cands.pop().unwrap()+            } else {+                let right = (-prev.1, prev.0);+                let left = (prev.1, -prev.0);+                let mut pick = None;+                for want in [right, prev, left] {+                    if let Some(pos) = cands.iter().position(|c| (c.0 - cur.0, c.1 - cur.1) == want) {+                        pick = Some(cands.remove(pos));+                        break;+                    }+                }+                pick.unwrap_or_else(|| cands.pop().unwrap())+            };+            if cands.is_empty() {+                out.remove(&cur);+            }+            prev = (nxt.0 - cur.0, nxt.1 - cur.1);+            if nxt == lp[0] {+                break;+            }+            lp.push(nxt);+        }+        // drop collinear vertices+        let n = lp.len();+        let mut simp = Vec::new();+        for k in 0..n {+            let p0 = lp[(k + n - 1) % n];+            let p1 = lp[k];+            let p2 = lp[(k + 1) % n];+            if (p1.0 - p0.0) * (p2.1 - p1.1) - (p1.1 - p0.1) * (p2.0 - p1.0) != 0 {+                simp.push((p1.0 as f64 / 2.0, p1.1 as f64 / 2.0));+            }+        }+        if simp.len() >= 4 {+            loops.push(simp);+        }+    }+    loops+}++fn signed_area(poly: &[Lat]) -> f64 {+    aiflow_board::geom::polygon_area(poly)+}++fn leftmost_edge(h: &[Lat]) -> Option<(f64, usize)> {+    let n = h.len();+    let mut best: Option<(f64, usize)> = None;+    for k in 0..n {+        let (a, b) = (h[k], h[(k + 1) % n]);+        if a.0 == b.0 && best.map(|x| a.0 < x.0).unwrap_or(true) {+            best = Some((a.0, k));+        }+    }+    best+}++/// Splice every hole into the outer contour through a horizontal zero-width slit to the left.+fn fracture(outer: &[Lat], holes: &[Vec<Lat>]) -> Vec<Lat> {+    let mut poly: Vec<Lat> = outer.to_vec();+    let mut order: Vec<usize> = (0..holes.len()).collect();+    order.sort_by(|&a, &b| leftmost_edge(&holes[a]).map(|x| x.0).unwrap_or(0.0).partial_cmp(&leftmost_edge(&holes[b]).map(|x| x.0).unwrap_or(0.0)).unwrap());+    for hk in order {+        let h = &holes[hk];+        let Some((x_h, k)) = leftmost_edge(h) else { continue };+        let (a, b) = (h[k], h[(k + 1) % h.len()]);+        let mut ym = (a.1 + b.1) / 2.0;+        if (b.1 - a.1).abs() >= 2.0 {+            ym = a.1 + if b.1 > a.1 { 0.5 } else { -0.5 };+        }+        let start = (x_h, ym);+        let mut hole = vec![start];+        hole.extend_from_slice(&h[k + 1..]);+        hole.extend_from_slice(&h[..k + 1]);+        hole.push(start);+        let mut hit: Option<(f64, usize)> = None;+        for e in 0..poly.len() {+            let (p0, p1) = (poly[e], poly[(e + 1) % poly.len()]);+            if p0.0 == p1.0 && p0.0 < x_h && p0.1.min(p1.1) < ym && ym < p0.1.max(p1.1) && hit.map(|x| p0.0 > x.0).unwrap_or(true) {+                hit = Some((p0.0, e));+            }+        }+        let Some((xe, e)) = hit else { continue };+        let joint = (xe, ym);+        let mut np = poly[..e + 1].to_vec();+        np.push(joint);+        np.extend(hole);+        np.push(joint);+        np.extend_from_slice(&poly[e + 1..]);+        poly = np;+    }+    poly+}++/// One fractured polygon per connected region of the plane, in board mm.+pub fn zone_fill_polygons(board: &Board, grid: &Grid, zone_net: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> Vec<Vec<Pt>> {+    let mut free = grid.outline_inside(&board.outline, EDGE_CLEARANCE + SLOP);+    let mut block = grid.mask();+    let mut by_clearance: BTreeMap<i64, Mask> = BTreeMap::new();+    for p in &board.pads {+        if !p.thru || p.net == zone_net {+            continue;+        }+        let target = if p.clearance <= CLEARANCE { &mut block } else { by_clearance.entry((p.clearance * 1000.0) as i64).or_insert_with(|| grid.mask()) };+        for poly in &p.polys {+            grid.poly_mask(poly, target);+        }+        if let Some(d) = p.drill {+            for c in grid.disk_cells((p.x, p.y), d / 2.0, None) {+                target.bits[c] = true;+            }+        }+    }+    for (net, vias) in vias_by_net {+        if net == zone_net {+            continue;+        }+        for v in vias {+            for c in grid.disk_cells((v.x, v.y), v.size / 2.0, None) {+                block.bits[c] = true;+            }+        }+    }+    let mut blocked = grid.dilate(&block, (CLEARANCE + SLOP) / grid.res);+    for (clr, m) in &by_clearance {+        blocked.or_with(&grid.dilate(m, (*clr as f64 / 1000.0 + SLOP) / grid.res));+    }+    free.and_not(&blocked);+    let loops = trace_boundaries(&free);+    let outers: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) > 0.0).collect();+    let holes: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) < 0.0).collect();+    let mut polys = Vec::new();+    for outer in outers {+        let ob = aiflow_board::geom::bbox(outer);+        let mut mine: Vec<Vec<Lat>> = Vec::new();+        for h in &holes {+            let hb = aiflow_board::geom::bbox(h);+            if hb.0 < ob.0 || hb.1 < ob.1 || hb.2 > ob.2 || hb.3 > ob.3 {+                continue;+            }+            if aiflow_board::geom::point_in_poly(h[0].0 + 1e-6, h[0].1 + 1e-6, outer) {+                mine.push((*h).clone());+            }+        }+        let poly = fracture(outer, &mine);+        polys.push(poly.into_iter().map(|(x, y)| (grid.x0 + x * grid.res, grid.y0 + y * grid.res)).collect());+    }+    polys+}++/// Every top-level zone: (node start, node end, net, layer, connect_pads span).+fn zones_of(board: &Board) -> Vec<(usize, usize, String, Option<String>, Option<(usize, usize, bool)>)> {+    let mut out = Vec::new();+    for z in board.root.find_all("zone") {+        let mut net = z.find("net").and_then(|n| n.value()).unwrap_or("").to_string();+        if board.net_format == aiflow_board::NetFormat::Number {+            if let Some(k) = z.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {+                if let Some(nm) = board.net_table.get(&k) {+                    net = nm.clone();+                }+            }+        }+        if let Some(nn) = z.find("net_name").and_then(|n| n.value()) {+            net = nn.to_string();+        }+        let layer = z.find("layer").and_then(|l| l.value()).map(str::to_string);+        let cp = z.find("connect_pads").map(|c| (c.start, c.end, matches!(c.atom(1), Some("yes") | Some("no") | Some("thru_hole_only"))));+        out.push((z.start, z.end, net, layer, cp));+    }+    out+}++/// The board text with a filled_polygon list inside every zone (raster fill), and the counts.+pub fn fill_zones(board: &Board, text: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> (String, BTreeMap<String, usize>) {+    let grid = Grid::new(board.bbox);+    let mut edits: Vec<(usize, usize, String)> = Vec::new();+    let mut stats = BTreeMap::new();+    for (start, end, net, layer, cp) in zones_of(board) {+        let (Some(layer), false) = (layer, net.is_empty()) else { continue };+        let _ = start;+        let polys = zone_fill_polygons(board, &grid, &net, vias_by_net);+        let mut blocks = String::new();+        for poly in &polys {+            let pts: Vec<String> = poly.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();+            blocks.push_str(&format!("\t\t(filled_polygon\n\t\t\t(layer \"{}\")\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n", layer, pts.join(" ")));+        }+        edits.push((end - 1, end - 1, blocks + "\t"));+        if let Some((cs, _, explicit)) = cp {+            if !explicit {+                edits.push((cs, cs + "(connect_pads".len(), "(connect_pads yes".to_string()));+            }+        }+        stats.insert(net, polys.len());+    }+    edits.sort_by_key(|e| e.0);+    let mut out = String::with_capacity(text.len() + 4096);+    let mut pos = 0;+    for (at, end, ins) in edits {+        out.push_str(&text[pos..at]);+        out.push_str(&ins);+        pos = end;+    }+    out.push_str(&text[pos..]);+    (out, stats)+}++/// The board text with the plan's copper (and raster fills unless `fill` is false), and stats.+pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), String> {+    let entries: Vec<Value> = plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned()).ok_or("plan needs nets")?;+    let mut blocks = Vec::new();+    let mut vias_by_net: HashMap<String, Vec<ViaSpec>> = HashMap::new();+    let (mut n_seg, mut n_via) = (0usize, 0usize);+    let mut length = 0.0;+    let mut widths: BTreeMap<String, usize> = BTreeMap::new();+    let mut nets = std::collections::HashSet::new();+    for e in &entries {+        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+        nets.insert(net.clone());+        let (segs, vias) = plan_to_copper(board, e)?;+        for s in &segs {+            blocks.push(segment_sexpr(board, s, &net)?);+            length += (s.x1 - s.x0).hypot(s.y1 - s.y0);+            *widths.entry(num(s.width)).or_insert(0) += 1;+        }+        for v in &vias {+            blocks.push(via_sexpr(board, v, &net)?);+            vias_by_net.entry(net.clone()).or_default().push(v.clone());+        }+        n_seg += segs.len();+        n_via += vias.len();+    }+    let mut text = splice(&board.text, &blocks);+    let mut stats = json!({"entries": entries.len(), "nets": nets.len(), "segments": n_seg, "vias": n_via, "copperLengthMm": (length * 100.0).round() / 100.0, "segmentsByWidth": widths});+    if fill {+        let (t, fills) = fill_zones(board, &text, &vias_by_net);+        text = t;+        stats["zoneFillPolygons"] = json!(fills);+    }+    Ok((text, stats))+}++// ----------------------------------------------------------------------------- DRC++pub fn run_drc(path: &str) -> Result<Value, String> {+    let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id()));+    std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;+    let report = dir.join(format!("drc-{}.json", uuid()));+    let out = Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output().map_err(|e| format!("service-kicad not runnable: {e}"))?;+    if !out.status.success() || !report.is_file() {+        return Err(format!("service-kicad drc failed: {} {}", String::from_utf8_lossy(&out.stdout).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>(), String::from_utf8_lossy(&out.stderr).chars().rev().take(600).collect::<String>().chars().rev().collect::<String>()));+    }+    let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;+    let _ = std::fs::remove_file(&report);+    Ok(data)+}++fn fingerprint(v: &Value) -> String {+    let mut pos: Vec<String> = v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").map(|p| p.to_string()).unwrap_or_default()).collect()).unwrap_or_default();+    pos.sort();+    json!([v.get("type"), v.get("severity"), v.get("description"), pos]).to_string()+}++fn count_types(list: &[&Value]) -> BTreeMap<String, usize> {+    let mut m = BTreeMap::new();+    for v in list {+        *m.entry(v.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()).or_insert(0) += 1;+    }+    m+}++/// The gate's summary: errors, new versus inherited against a baseline report, unconnected.+pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value {+    let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();+    let errors: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("error")).collect();+    let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect();+    let mut new: Vec<&Value> = errors.clone();+    let mut inherited: Vec<&Value> = Vec::new();+    if let Some(base) = baseline {+        let mut existing: HashMap<String, usize> = HashMap::new();+        for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {+            if v.get("severity").and_then(|s| s.as_str()) == Some("error") {+                *existing.entry(fingerprint(v)).or_insert(0) += 1;+            }+        }+        new.clear();+        for v in &errors {+            let k = fingerprint(v);+            if existing.get(&k).copied().unwrap_or(0) > 0 {+                *existing.get_mut(&k).unwrap() -= 1;+                inherited.push(v);+            } else {+                new.push(v);+            }+        }+    }+    let unconnected: Vec<&Value> = data.get("unconnected_items").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();+    let fill_artifacts = warnings_all.iter().filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("isolated_copper")).count();+    let warnings: Vec<&Value> = warnings_all.iter().copied().filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("isolated_copper")).collect();+    let desc = |v: &Value| -> Vec<Value> { v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("description").cloned().unwrap_or(Value::Null)).collect()).unwrap_or_default() };+    json!({+        "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(),+        "rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings),+        "newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(),+        "unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(),+        "kicadVersion": data.get("kicad_version"),+    })+}
crates/aiflow-grid/Cargo.tomladded+11
@@ -0,0 +1,11 @@+[package]+name = "aiflow-grid"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: grid"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }
crates/aiflow-grid/src/lib.rsadded+250
@@ -0,0 +1,250 @@+//! The raster every geometric stage works on: 0.1 mm cells over the outline's box, with+//! conservative rasterisers (a cell counts as copper when any part of the shape touches its+//! square), disk dilation for clearance maps, and the outline's inside and edge distance.+use aiflow_board::Pt;++pub const RES: f64 = 0.1;++#[derive(Clone)]+pub struct Grid {+    pub res: f64,+    pub x0: f64,+    pub y0: f64,+    pub w: usize,+    pub h: usize,+}++/// A boolean raster, row-major (index = j * w + i).+#[derive(Clone)]+pub struct Mask {+    pub w: usize,+    pub h: usize,+    pub bits: Vec<bool>,+}++impl Mask {+    pub fn new(w: usize, h: usize) -> Mask {+        Mask { w, h, bits: vec![false; w * h] }+    }+    #[inline]+    pub fn get(&self, i: usize, j: usize) -> bool {+        self.bits[j * self.w + i]+    }+    #[inline]+    pub fn set(&mut self, i: usize, j: usize, v: bool) {+        self.bits[j * self.w + i] = v;+    }+    pub fn or_with(&mut self, other: &Mask) {+        for (a, b) in self.bits.iter_mut().zip(&other.bits) {+            *a |= *b;+        }+    }+    pub fn and_not(&mut self, other: &Mask) {+        for (a, b) in self.bits.iter_mut().zip(&other.bits) {+            *a &= !*b;+        }+    }+    pub fn any(&self) -> bool {+        self.bits.iter().any(|b| *b)+    }+    pub fn count(&self) -> usize {+        self.bits.iter().filter(|b| **b).count()+    }+}++impl Grid {+    pub fn new(bbox: (f64, f64, f64, f64)) -> Grid {+        Grid::with_res(bbox, RES)+    }++    pub fn with_res(bbox: (f64, f64, f64, f64), res: f64) -> Grid {+        Grid { res, x0: bbox.0, y0: bbox.1, w: ((bbox.2 - bbox.0) / res).round() as usize + 1, h: ((bbox.3 - bbox.1) / res).round() as usize + 1 }+    }++    /// Round half up (pads on .x5 coordinates at 0.5 mm pitch snap a consistent 5 cells apart),+    /// with 0.1 um of tolerance for KiCad's nanometre noise.+    #[inline]+    pub fn to_cell(&self, x: f64, y: f64) -> (i64, i64) {+        (((x - self.x0) / self.res + 0.5 + 1e-3).floor() as i64, ((y - self.y0) / self.res + 0.5 + 1e-3).floor() as i64)+    }++    #[inline]+    pub fn to_xy(&self, i: i64, j: i64) -> Pt {+        let r = |v: f64| (v * 10000.0).round() / 10000.0;+        (r(self.x0 + i as f64 * self.res), r(self.y0 + j as f64 * self.res))+    }++    #[inline]+    pub fn in_bounds(&self, i: i64, j: i64) -> bool {+        i >= 0 && j >= 0 && (i as usize) < self.w && (j as usize) < self.h+    }++    pub fn mask(&self) -> Mask {+        Mask::new(self.w, self.h)+    }++    /// Mark every cell whose centre is inside the polygon, plus the cells its edges pass through.+    pub fn poly_mask(&self, poly: &[Pt], mask: &mut Mask) {+        let n = poly.len();+        if n < 3 {+            return;+        }+        let (bx0, by0, bx1, by1) = aiflow_board::geom::bbox(poly);+        let i0 = (((bx0 - self.x0) / self.res).floor() as i64 - 1).max(0) as usize;+        let i1 = ((((bx1 - self.x0) / self.res).ceil() as i64 + 1).min(self.w as i64 - 1)).max(0) as usize;+        let j0 = (((by0 - self.y0) / self.res).floor() as i64 - 1).max(0) as usize;+        let j1 = ((((by1 - self.y0) / self.res).ceil() as i64 + 1).min(self.h as i64 - 1)).max(0) as usize;+        if i1 < i0 || j1 < j0 {+            return;+        }+        for j in j0..=j1 {+            let cy = self.y0 + j as f64 * self.res;+            for i in i0..=i1 {+                let cx = self.x0 + i as f64 * self.res;+                if aiflow_board::geom::point_in_poly(cx, cy, poly) {+                    mask.set(i, j, true);+                }+            }+        }+        // boundary supercover+        for k in 0..n {+            let (xa, ya) = poly[k];+            let (xb, yb) = poly[(k + 1) % n];+            let l = (xb - xa).hypot(yb - ya);+            let steps = ((l / 0.025).ceil() as usize).max(1);+            for s in 0..=steps {+                let t = s as f64 / steps as f64;+                let ex = xa + (xb - xa) * t;+                let ey = ya + (yb - ya) * t;+                let ii = ((ex - self.x0) / self.res).round() as i64;+                let jj = ((ey - self.y0) / self.res).round() as i64;+                if ii >= i0 as i64 && ii <= i1 as i64 && jj >= j0 as i64 && jj <= j1 as i64 {+                    mask.set(ii as usize, jj as usize, true);+                }+            }+        }+    }++    /// Flat indices of the cells whose centre is within half_width + slack of segment a-b.+    pub fn segment_cells(&self, a: Pt, b: Pt, half_width: f64, slack: Option<f64>) -> Vec<usize> {+        let slack = slack.unwrap_or(self.res / 2.0);+        let r = half_width + slack;+        let (xa, ya) = a;+        let (xb, yb) = b;+        let i0 = (((xa.min(xb) - r - self.x0) / self.res).floor() as i64).max(0);+        let i1 = (((xa.max(xb) + r - self.x0) / self.res).ceil() as i64).min(self.w as i64 - 1);+        let j0 = (((ya.min(yb) - r - self.y0) / self.res).floor() as i64).max(0);+        let j1 = (((ya.max(yb) + r - self.y0) / self.res).ceil() as i64).min(self.h as i64 - 1);+        let mut out = Vec::new();+        if i1 < i0 || j1 < j0 {+            return out;+        }+        let (dx, dy) = (xb - xa, yb - ya);+        let l2 = dx * dx + dy * dy;+        let r2 = (r + 1e-9) * (r + 1e-9);+        for j in j0..=j1 {+            let cy = self.y0 + j as f64 * self.res;+            for i in i0..=i1 {+                let cx = self.x0 + i as f64 * self.res;+                let t = if l2 < 1e-12 { 0.0 } else { (((cx - xa) * dx + (cy - ya) * dy) / l2).clamp(0.0, 1.0) };+                let px = xa + t * dx;+                let py = ya + t * dy;+                let d2 = (cx - px) * (cx - px) + (cy - py) * (cy - py);+                if d2 <= r2 {+                    out.push(j as usize * self.w + i as usize);+                }+            }+        }+        out+    }++    pub fn disk_cells(&self, c: Pt, radius: f64, slack: Option<f64>) -> Vec<usize> {+        self.segment_cells(c, c, radius, slack)+    }++    /// Binary dilation by a disk of `r_cells` cells. Separable enough to be fast: per row offsets.+    pub fn dilate(&self, mask: &Mask, r_cells: f64) -> Mask {+        if r_cells <= 0.0 {+            return mask.clone();+        }+        let rr = r_cells.floor() as i64;+        let r2 = r_cells * r_cells + 1e-9;+        // horizontal half-chords per dy+        let mut chords = Vec::with_capacity((2 * rr + 1) as usize);+        for dy in -rr..=rr {+            let rem = r2 - (dy * dy) as f64;+            chords.push((dy, if rem >= 0.0 { rem.sqrt().floor() as i64 } else { -1 }));+        }+        let w = mask.w as i64;+        let h = mask.h as i64;+        let mut out = Mask::new(mask.w, mask.h);+        // pass 1: horizontal runs per dy would require a 2D scan; do it directly but skip empty rows+        for j in 0..h {+            let row = &mask.bits[(j * w) as usize..((j + 1) * w) as usize];+            if !row.iter().any(|b| *b) {+                continue;+            }+            for i in 0..w {+                if !row[i as usize] {+                    continue;+                }+                for &(dy, hc) in &chords {+                    if hc < 0 {+                        continue;+                    }+                    let jj = j + dy;+                    if jj < 0 || jj >= h {+                        continue;+                    }+                    let a = (i - hc).max(0) as usize;+                    let b = (i + hc).min(w - 1) as usize;+                    let base = (jj * w) as usize;+                    for k in a..=b {+                        out.bits[base + k] = true;+                    }+                }+            }+        }+        out+    }++    /// (inside mask, distance of every cell centre to the nearest outline edge).+    pub fn outline_distance(&self, outline: &[Pt]) -> (Mask, Vec<f32>) {+        let mut inside = self.mask();+        let mut dmin = vec![f32::INFINITY; self.w * self.h];+        let n = outline.len();+        for j in 0..self.h {+            let cy = self.y0 + j as f64 * self.res;+            for i in 0..self.w {+                let cx = self.x0 + i as f64 * self.res;+                let mut ins = false;+                let mut best = f64::INFINITY;+                for k in 0..n {+                    let (xa, ya) = outline[k];+                    let (xb, yb) = outline[(k + 1) % n];+                    if (ya > cy) != (yb > cy) {+                        let xint = xa + (cy - ya) * (xb - xa) / (yb - ya);+                        if cx < xint {+                            ins = !ins;+                        }+                    }+                    let d = aiflow_board::geom::seg_distance(cx, cy, xa, ya, xb, yb);+                    if d < best {+                        best = d;+                    }+                }+                inside.set(i, j, ins);+                dmin[j * self.w + i] = best as f32;+            }+        }+        (inside, dmin)+    }++    pub fn outline_inside(&self, outline: &[Pt], margin: f64) -> Mask {+        let (mut inside, dmin) = self.outline_distance(outline);+        for (b, d) in inside.bits.iter_mut().zip(&dmin) {+            *b = *b && (*d as f64) >= margin;+        }+        inside+    }+}
crates/aiflow-place/Cargo.tomladded+11
@@ -0,0 +1,11 @@+[package]+name = "aiflow-place"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: place"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }
crates/aiflow-place/src/lib.rsadded+268
@@ -0,0 +1,268 @@+//! Placement helpers: courtyard boxes from the file, a legal spot for a part near where it should+//! be, moving footprints in the file, and the overlap check.+//!+//! The AI decides where parts go; these are the mechanics that keep a decision legal before it+//! reaches the live board (the bridge's `kicad_move_footprint` checks courtyards exactly when it lands).+use std::collections::BTreeMap;++use aiflow_board::{geom, num, Board, Pt};+use serde::{Deserialize, Serialize};+use serde_json::Value;++#[derive(Clone, Debug, Serialize, Deserialize)]+pub struct Move {+    pub x: f64,+    pub y: f64,+    #[serde(default)]+    pub rotation: Option<f64>,+}++#[derive(Clone, Debug, Deserialize)]+pub struct Wish {+    pub x: f64,+    pub y: f64,+    #[serde(default = "default_rot")]+    pub rot: Vec<f64>,+    #[serde(default = "default_radius")]+    pub radius: f64,+    #[serde(default)]+    pub keepout: Vec<(f64, f64, f64, f64, f64)>,+    #[serde(default)]+    pub gap: Option<f64>,+}++fn default_rot() -> Vec<f64> { vec![0.0] }+fn default_radius() -> f64 { 4.0 }++type Box4 = (f64, f64, f64, f64);++/// Local (unrotated, anchor-relative) F.CrtYd or B.CrtYd points of a footprint, from the file text.+pub fn courtyard_local(board: &Board, reference: &str) -> Option<Vec<Pt>> {+    let fp = board.footprints.get(reference)?;+    let block = &board.text[fp.start..fp.end];+    let node = aiflow_board::sx::parse(block).ok()?;+    let mut pts = Vec::new();+    for n in node.nodes() {+        if !matches!(n.tag.as_str(), "fp_line" | "fp_rect" | "fp_poly" | "fp_circle" | "fp_arc") {+            continue;+        }+        let layer = n.find("layer").and_then(|l| l.value()).unwrap_or("");+        if layer != "F.CrtYd" && layer != "B.CrtYd" {+            continue;+        }+        for tag in ["start", "end", "mid", "center"] {+            if let Some(p) = n.find(tag) {+                if let (Some(x), Some(y)) = (p.number(1), p.number(2)) {+                    pts.push((x, y));+                }+            }+        }+        if let Some(p) = n.find("pts") {+            for xy in p.find_all("xy") {+                if let (Some(x), Some(y)) = (xy.number(1), xy.number(2)) {+                    pts.push((x, y));+                }+            }+        }+        if n.tag == "fp_circle" {+            if let (Some(c), Some(e)) = (n.find("center"), n.find("end")) {+                if let (Some(cx), Some(cy), Some(ex), Some(ey)) = (c.number(1), c.number(2), e.number(1), e.number(2)) {+                    let r = (ex - cx).hypot(ey - cy);+                    pts.push((cx - r, cy - r));+                    pts.push((cx + r, cy + r));+                }+            }+        }+    }+    if pts.is_empty() { None } else { Some(pts) }+}++pub fn placed_box(pts: &[Pt], x: f64, y: f64, rot: f64) -> Box4 {+    let placed: Vec<Pt> = pts.iter().map(|&(dx, dy)| { let (a, b) = geom::rot_kicad(dx, dy, rot); (x + a, y + b) }).collect();+    geom::bbox(&placed)+}++/// The courtyard box (pad box grown by 0.25 mm when a part has no courtyard) of every footprint.+pub fn boxes(board: &Board) -> BTreeMap<String, Box4> {+    let mut out = BTreeMap::new();+    for (r, fp) in &board.footprints {+        if let Some(pts) = courtyard_local(board, r) {+            out.insert(r.clone(), placed_box(&pts, fp.x, fp.y, fp.rot));+        } else if let Some(b) = board.pad_box(r) {+            out.insert(r.clone(), (b.0 - 0.25, b.1 - 0.25, b.2 + 0.25, b.3 + 0.25));+        }+    }+    out+}++fn clear(b: &Box4, others: &[Box4], gap: f64) -> bool {+    !others.iter().any(|o| b.0 - gap < o.2 && o.0 - gap < b.2 && b.1 - gap < o.3 && o.1 - gap < b.3)+}++/// Local outline points of a part for placement: its courtyard, else its pad box grown by 0.25.+fn local_pts(board: &Board, reference: &str) -> Vec<Pt> {+    if let Some(p) = courtyard_local(board, reference) {+        return p;+    }+    let fp = &board.footprints[reference];+    let pts: Vec<Pt> = board.pads.iter().filter(|p| p.reference == reference).flat_map(|p| p.polys.iter().flatten().copied()).map(|(x, y)| geom::rot_kicad(x - fp.x, y - fp.y, -(fp.rot))).collect();+    let (x0, y0, x1, y1) = geom::bbox(&pts);+    vec![(x0 - 0.25, y0 - 0.25), (x1 + 0.25, y1 + 0.25)]+}++/// For each wish, in order, the nearest legal spot: courtyard clears every other courtyard by+/// `gap`, stays `edge` inside the outline box, avoids the keepout rectangles (grown by their margin).+pub fn pack(board: &Board, wishes: &[(String, Wish)], gap: f64, edge: f64) -> (BTreeMap<String, Move>, Vec<String>) {+    let mut boxes = boxes(board);+    let (x0, y0, x1, y1) = board.bbox;+    let mut out = BTreeMap::new();+    let mut lines = Vec::new();+    for (reference, w) in wishes {+        if !board.footprints.contains_key(reference) {+            lines.push(format!("{reference}: not on the board"));+            continue;+        }+        let pts = local_pts(board, reference);+        let others: Vec<Box4> = boxes.iter().filter(|(r, _)| *r != reference).map(|(_, b)| *b).collect();+        let g = w.gap.unwrap_or(gap);+        let n = (w.radius / 0.1) as i64;+        let mut cands: Vec<(f64, f64, f64)> = Vec::new();+        for i in -n..=n {+            for j in -n..=n {+                let d = ((i * i + j * j) as f64).sqrt() * 0.1;+                if d <= w.radius {+                    cands.push((d, ((w.x + i as f64 * 0.1) * 100.0).round() / 100.0, ((w.y + j as f64 * 0.1) * 100.0).round() / 100.0));+                }+            }+        }+        cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+        let mut best: Option<(f64, f64, f64, Box4, f64)> = None;+        'outer: for (d, x, y) in cands {+            for &rot in &w.rot {+                let b = placed_box(&pts, x, y, rot);+                if b.0 < x0 + edge || b.1 < y0 + edge || b.2 > x1 - edge || b.3 > y1 - edge {+                    continue;+                }+                if !clear(&b, &others, g) {+                    continue;+                }+                if w.keepout.iter().any(|(kx0, ky0, kx1, ky1, m)| b.0 - m < *kx1 && kx0 - m < b.2 && b.1 - m < *ky1 && ky0 - m < b.3) {+                    continue;+                }+                best = Some((x, y, rot, b, d));+                break 'outer;+            }+        }+        match best {+            None => lines.push(format!("{reference}: NO SPOT within {:.1} mm of ({:.1}, {:.1})", w.radius, w.x, w.y)),+            Some((x, y, rot, b, d)) => {+                boxes.insert(reference.clone(), b);+                out.insert(reference.clone(), Move { x, y, rotation: Some(rot) });+                lines.push(format!("{reference}: ({x:.2}, {y:.2}, {rot}) {d:.2} mm from the wish, box x {:.2}..{:.2} y {:.2}..{:.2}", b.0, b.2, b.1, b.3));+            }+        }+    }+    (out, lines)+}++/// Move footprints in the file text: the anchor's `(at x y rot)`, and every child's own angle by+/// the rotation change (pads, properties, graphics carry the footprint rotation in the file).+pub fn move_in_text(board: &Board, moves: &BTreeMap<String, Move>) -> Result<(String, Vec<String>), String> {+    let mut edits: Vec<(usize, usize, String)> = Vec::new();+    let mut lines = Vec::new();+    for (reference, m) in moves {+        let fp = board.footprints.get(reference).ok_or_else(|| format!("{reference} is not on the board"))?;+        let block = &board.text[fp.start..fp.end];+        let node = aiflow_board::sx::parse(block).map_err(|e| format!("{reference}: {e}"))?;+        let at = node.find("at").ok_or_else(|| format!("{reference}: no (at ...)"))?;+        let orot = at.number(3).unwrap_or(0.0);+        let nrot = m.rotation.unwrap_or(orot);+        let drot = nrot - orot;+        let new_at = format!("(at {} {}{})", num(m.x), num(m.y), if nrot == 0.0 { String::new() } else { format!(" {}", num(nrot)) });+        edits.push((fp.start + at.start, fp.start + at.end, new_at));+        if drot.abs() > 1e-9 {+            // every nested (at ...) except the footprint's own+            fn walk(n: &aiflow_board::Node, base: usize, drot: f64, edits: &mut Vec<(usize, usize, String)>, top: bool) {+                for c in n.nodes() {+                    if c.tag == "at" && !top {+                        let a = c.number(3).unwrap_or(0.0) + drot;+                        let a = ((a + 180.0).rem_euclid(360.0)) - 180.0;+                        let (x, y) = (c.atom(1).unwrap_or("0"), c.atom(2).unwrap_or("0"));+                        edits.push((base + c.start, base + c.end, format!("(at {} {}{})", x, y, if a.abs() < 1e-9 { String::new() } else { format!(" {}", num(a)) })));+                    } else if c.tag != "at" {+                        walk(c, base, drot, edits, false);+                    }+                }+            }+            for c in node.nodes() {+                if c.tag != "at" {+                    walk(c, fp.start, drot, &mut edits, false);+                }+            }+        }+        lines.push(format!("moved {reference:<6} ({:.2}, {:.2}, {}) -> ({:.2}, {:.2}, {})", fp.x, fp.y, num(orot), m.x, m.y, num(nrot)));+    }+    edits.sort_by_key(|e| e.0);+    let mut out = String::with_capacity(board.text.len());+    let mut pos = 0;+    for (a, b, ins) in edits {+        if a < pos {+            return Err("overlapping edits".into());+        }+        out.push_str(&board.text[pos..a]);+        out.push_str(&ins);+        pos = b;+    }+    out.push_str(&board.text[pos..]);+    Ok((out, lines))+}++/// Courtyard overlaps and outside-the-outline parts among the moved refs on a board.+pub fn check(board: &Board, moved: &[String], gap: f64) -> Vec<String> {+    let boxes = boxes(board);+    let mut problems = Vec::new();+    let refs: Vec<&String> = boxes.keys().collect();+    for (i, a) in refs.iter().enumerate() {+        for b in &refs[i + 1..] {+            if !moved.contains(a) && !moved.contains(b) {+                continue;+            }+            let (ba, bb) = (boxes[*a], boxes[*b]);+            if ba.0 - gap < bb.2 && bb.0 - gap < ba.2 && ba.1 - gap < bb.3 && bb.1 - gap < ba.3 {+                problems.push(format!("courtyards overlap: {a} {b}"));+            }+        }+    }+    let (x0, y0, x1, y1) = board.bbox;+    for r in moved {+        if let Some(b) = boxes.get(r) {+            if b.0 < x0 + 0.5 || b.1 < y0 + 0.5 || b.2 > x1 - 0.5 || b.3 > y1 - 0.5 {+                problems.push(format!("outside the outline: {r}"));+            }+        }+    }+    problems+}++/// Wishes are packed in the order given. An array `[{"ref": "U5", "x": .., "y": ..}, ...]` keeps+/// that order; an object `{REF: {...}}` is packed alphabetically (JSON objects carry no order),+/// which is rarely what a placement wants: put the big parts first as an array.+pub fn wishes_from_json(v: &Value) -> Result<Vec<(String, Wish)>, String> {+    let mut out = Vec::new();+    if let Some(a) = v.as_array() {+        for e in a {+            let k = e.get("ref").and_then(|r| r.as_str()).ok_or("each wish in the array needs a ref")?.to_string();+            out.push((k.clone(), serde_json::from_value(e.clone()).map_err(|e| format!("{k}: {e}"))?));+        }+        return Ok(out);+    }+    let o = v.as_object().ok_or("wishes must be an array [{ref, x, y, ...}] (packed in order) or an object {REF: {...}}")?;+    for (k, w) in o {+        out.push((k.clone(), serde_json::from_value(w.clone()).map_err(|e| format!("{k}: {e}"))?));+    }+    Ok(out)+}++pub fn moves_from_json(v: &Value) -> Result<BTreeMap<String, Move>, String> {+    serde_json::from_value(v.clone()).map_err(|e| e.to_string())+}
crates/aiflow-pours/Cargo.tomladded+12
@@ -0,0 +1,12 @@+[package]+name = "aiflow-pours"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: pours"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-copper = { workspace = true }
crates/aiflow-pours/src/lib.rsadded+271
@@ -0,0 +1,271 @@+//! Pours from the spec, not from anyone's memory of one board: zones (with priorities and+//! connection styles), rule-area keepouts along Kelvin taps, thermal and stitching vias; and the+//! zones as `.kicad_pcb` blocks for the offline copy.+use aiflow_board::{geom, num, quote, Board, NetFormat, Pt};+use serde_json::{json, Value};++pub type Box4 = (f64, f64, f64, f64);++fn pad_box(board: &Board, keys: &[String]) -> Option<Box4> {+    let mut pts = Vec::new();+    for k in keys {+        if let Some((r, n)) = k.split_once('.') {+            for p in board.pads.iter().filter(|p| p.reference == r && p.name == n) {+                pts.extend(p.polys.iter().flatten().copied());+            }+        } else {+            for p in board.pads.iter().filter(|p| p.reference == *k) {+                pts.extend(p.polys.iter().flatten().copied());+            }+        }+    }+    if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }+}++fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Value {+    let r = |v: f64| (v * 100.0).round() / 100.0;+    json!([[r(x0), r(y0)], [r(x1), r(y0)], [r(x1), r(y1)], [r(x0), r(y1)]])+}++fn strs(v: Option<&Value>) -> Vec<String> {+    v.and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()+}++/// Segments of every non-Kelvin path in a routing plan, with net and half width, for via avoidance.+fn plan_blockers(plan: &Value) -> Vec<(Pt, Pt, String, f64)> {+    let mut out = Vec::new();+    for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+        let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);+        for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+            let mut prev: Option<Pt> = None;+            for wp in path.as_array().cloned().unwrap_or_default() {+                let pt = if let Some(a) = wp.as_array() { if a.len() == 2 { Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))) } else { None } } else if let Some(o) = wp.as_object() { match (o.get("x").and_then(|v| v.as_f64()), o.get("y").and_then(|v| v.as_f64())) { (Some(x), Some(y)) => Some((x, y)), _ => None } } else { None };+                match (wp.is_string(), pt) {+                    (true, _) => prev = None,+                    (false, Some(p)) => {+                        if let Some(q) = prev {+                            out.push((q, p, net.clone(), w / 2.0 + 0.2 + 0.45));+                        }+                        prev = Some(p);+                    }+                    _ => {}+                }+            }+        }+    }+    out+}++fn clear(x: f64, y: f64, net: &str, blockers: &[(Pt, Pt, String, f64)]) -> bool {+    !blockers.iter().any(|(a, b, n, need)| n != net && geom::seg_distance(x, y, a.0, a.1, b.0, b.1) < *need)+}++/// (zones in the kicad_add_zone shape, via plan entries, keepout count) from the spec and the routing plan.+pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> {+    let (x0, y0, x1, y1) = board.bbox;+    let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5);+    let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);+    let mut zones: Vec<Value> = Vec::new();+    for e in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+        let net = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string();+        if !board.nets.contains_key(&net) {+            return Err(format!("pour {net}: the board has no pads on that net"));+        }+        let mut z = serde_json::Map::new();+        for (k, v) in e.as_object().unwrap() {+            if !matches!(k.as_str(), "around" | "margin" | "exclude" | "polygon" | "outline" | "inset") {+                z.insert(k.clone(), v.clone());+            }+        }+        z.entry("clearance").or_insert(json!(clearance));+        z.entry("minThickness").or_insert(json!(0.25));+        if e.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {+            z.insert("outline".into(), json!(true));+            z.insert("inset".into(), json!(e.get("inset").and_then(|v| v.as_f64()).unwrap_or(edge)));+        } else if let Some(p) = e.get("polygon") {+            z.insert("polygon".into(), p.clone());+        } else if e.get("around").is_some() {+            let keys = strs(e.get("around"));+            let bx = pad_box(board, &keys).ok_or_else(|| format!("pour {net}: none of {keys:?} found on the board"))?;+            let m = e.get("margin").and_then(|v| v.as_f64()).unwrap_or(0.8);+            let mut b = ((bx.0 - m).max(x0 + edge), (bx.1 - m).max(y0 + edge), (bx.2 + m).min(x1 - edge), (bx.3 + m).min(y1 - edge));+            for ex in strs(e.get("exclude")) {+                if let Some(xb) = pad_box(board, &[ex.clone()]) {+                    if !(xb.2 < b.0 || xb.0 > b.2 || xb.3 < b.1 || xb.1 > b.3) {+                        // clip on the side that loses the least+                        let cuts = [(0usize, xb.2 + 1.0, (xb.2 + 1.0) - b.0), (1, xb.3 + 1.0, (xb.3 + 1.0) - b.1), (2, xb.0 - 1.0, b.2 - (xb.0 - 1.0)), (3, xb.1 - 1.0, b.3 - (xb.1 - 1.0))];+                        let (side, val, _) = cuts.iter().min_by(|a, c| a.2.partial_cmp(&c.2).unwrap()).unwrap();+                        match side { 0 => b.0 = *val, 1 => b.1 = *val, 2 => b.2 = *val, _ => b.3 = *val }+                    }+                }+            }+            z.insert("polygon".into(), rect(b.0, b.1, b.2, b.3));+        } else {+            return Err(format!("pour {net} needs outline, polygon or around"));+        }+        z.entry("name").or_insert(json!(format!("{net} pour")));+        zones.push(Value::Object(z));+    }+    for key in strs(spec.get("solidAt")) {+        if let Some(p) = board.pad_by_key(&key) {+            let layers: Vec<&str> = if p.thru { vec!["F.Cu", "B.Cu"] } else if p.on_f { vec!["F.Cu"] } else { vec!["B.Cu"] };+            let r = if p.thru { p.half_long.max(p.half_short) + 0.6 } else { 0.9 };+            zones.push(json!({"net": p.net, "layers": layers, "polygon": rect(p.x - r, p.y - r, p.x + r, p.y + r), "priority": 1, "connection": "solid", "clearance": clearance, "minThickness": 0.25, "name": format!("{} solid joint at {key}", p.net)}));+        }+    }+    let blockers = plan.map(plan_blockers).unwrap_or_default();+    let mut vias: Vec<Value> = Vec::new();+    let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});+    for tv in spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+        let Some(key) = tv.get("pad").and_then(|p| p.as_str()) else { continue };+        let Some(p) = board.pad_by_key(key) else { continue };+        let net = tv.get("net").and_then(|n| n.as_str()).unwrap_or(&p.net).to_string();+        let n = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;+        let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));+        let mut placed = 0;+        for (dx, dy) in [(-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)] {+            if placed >= n {+                break;+            }+            let inside = (dx as f64).abs() < p.half_long - 0.4 && (dy as f64).abs() < p.half_short - 0.4;+            if !inside && (dx, dy) != (0.0, 0.0) {+                continue;+            }+            let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);+            if clear(x, y, &net, &blockers) {+                vias.push(via_entry(&net, x, y, size, drill));+                placed += 1;+            }+        }+    }+    for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {+        let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };+        if clear(x, y, net, &blockers) {+            vias.push(via_entry(net, x, y, sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4)));+        }+    }+    let mut keepouts = 0;+    // the sense pad of every Kelvin pair (spec.kelvin: {"U6.3": "R32.2"}) gets its own keepout: a+    // pour on the same net must never touch it, or the tap measures the pour's drop instead of the+    // shunt's (and KiCad's DRC reports the starved thermal relief the keepout along the tap leaves)+    for (src, _dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {+        if let Some(p) = board.pad_by_key(&src) {+            let m = clearance + 0.15;+            let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };+            zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(p.x - hx - m, p.y - hy - m, p.x + hx + m, p.y + hy + m), "keepout": {"copperPour": true}, "name": format!("Kelvin sense pad keepout {} ({src})", p.net)}));+            keepouts += 1;+        }+    }+    if let (Some(plan), true) = (plan, spec.get("kelvin").map(|k| !k.is_null()).unwrap_or(false)) {+        for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+            if !e.get("kelvin").and_then(|k| k.as_bool()).unwrap_or(false) {+                continue;+            }+            let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+            let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25) / 2.0 + 0.2 + 0.1;+            for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+                let pts: Vec<Pt> = path.as_array().cloned().unwrap_or_default().iter().filter_map(|wp| {+                    if let Some(a) = wp.as_array() { if a.len() == 2 { return Some((a[0].as_f64()?, a[1].as_f64()?)); } }+                    if let Some(o) = wp.as_object() { return Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?)); }+                    if let Some(k) = wp.as_str() { let p = board.pad_by_key(k)?; return Some((p.x, p.y)); }+                    None+                }).collect();+                for pair in pts.windows(2) {+                    let ((ax, ay), (bx, by)) = (pair[0], pair[1]);+                    let (dx, dy) = (bx - ax, by - ay);+                    let n = dx.hypot(dy);+                    if n < 0.05 {+                        continue;+                    }+                    let (ux, uy) = (dx / n, dy / n);+                    let r = |v: f64| (v * 1000.0).round() / 1000.0;+                    let poly = json!([[r(ax - uy * w), r(ay + ux * w)], [r(bx - uy * w), r(by + ux * w)], [r(bx + uy * w), r(by - ux * w)], [r(ax + uy * w), r(ay - ux * w)]]);+                    zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": poly, "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net}")}));+                    keepouts += 1;+                }+            }+        }+    }+    Ok((zones, vias, keepouts))+}++/// Shrink a convex-ish outline toward its centroid by d.+pub fn inset_polygon(poly: &[Pt], d: f64) -> Vec<Pt> {+    if d <= 0.0 {+        return poly.to_vec();+    }+    let n = poly.len() as f64;+    let cx = poly.iter().map(|p| p.0).sum::<f64>() / n;+    let cy = poly.iter().map(|p| p.1).sum::<f64>() / n;+    poly.iter().map(|&(x, y)| { let (vx, vy) = (x - cx, y - cy); let l = vx.hypot(vy).max(1e-9); (x - vx / l * d, y - vy / l * d) }).collect()+}++/// Resolve `outline: true` zones to explicit polygons (the live verb takes points).+pub fn resolve_outline(board: &Board, zones: &[Value]) -> Vec<Value> {+    let mut outline = board.outline.clone();+    if geom::polygon_area(&outline) < 0.0 {+        outline.reverse();+    }+    zones.iter().map(|z| {+        if z.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {+            let inset = z.get("inset").and_then(|v| v.as_f64()).unwrap_or(0.5);+            let mut o = z.as_object().unwrap().clone();+            o.remove("outline");+            o.remove("inset");+            let r = |v: f64| (v * 1000.0).round() / 1000.0;+            o.insert("polygon".into(), json!(inset_polygon(&outline, inset).iter().map(|(x, y)| json!([r(*x), r(*y)])).collect::<Vec<_>>()));+            Value::Object(o)+        } else {+            z.clone()+        }+    }).collect()+}++fn poly_of(z: &Value) -> Vec<Pt> {+    z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|pt| { let q = pt.as_array()?; Some((q.first()?.as_f64()?, q.get(1)?.as_f64()?)) }).collect()).unwrap_or_default()+}++/// The zone (copper pour or keepout) as a `.kicad_pcb` block, the way the bridge writes it.+pub fn zone_sexpr(board: &Board, z: &Value) -> Result<String, String> {+    let layers: Vec<String> = strs(z.get("layers"));+    let layer_line = if layers.len() == 1 { format!("\t\t(layer \"{}\")\n", layers[0]) } else { format!("\t\t(layers {})\n", layers.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" ")) };+    let pts: Vec<String> = poly_of(z).iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();+    if pts.len() < 3 {+        return Err("zone needs a polygon with three or more points".into());+    }+    let name = z.get("name").and_then(|n| n.as_str()).unwrap_or("zone");+    let uuid = format!("bf{:016x}-{:04x}-4000-8000-{:012x}", pts.len() as u64 * 7919 + name.len() as u64, layers.len(), std::process::id());+    let keepout = z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some();+    if keepout {+        let k = z.get("keepout").cloned().unwrap_or(json!({"copperPour": true}));+        let f = |n: &str| if k.get(n).and_then(|b| b.as_bool()).unwrap_or(false) { "not_allowed" } else { "allowed" };+        return Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(keepout\n\t\t\t(tracks {})\n\t\t\t(vias {})\n\t\t\t(pads {})\n\t\t\t(copperpour {})\n\t\t\t(footprints {})\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",+            if board.net_format == NetFormat::Number { "\t\t(net 0)\n\t\t(net_name \"\")\n" } else { "" }, layer_line, uuid, quote(name), f("tracks"), f("vias"), f("pads"), f("copperPour"), f("footprints"), pts.join(" ")));+    }+    let net = z.get("net").and_then(|n| n.as_str()).ok_or("copper zone needs net")?;+    let net_lines = match board.net_format {+        NetFormat::Number => format!("{}\t\t(net_name {})\n", board.net_ref_sexpr(net)?, quote(net)),+        NetFormat::Name => board.net_ref_sexpr(net)?,+    };+    let solid = matches!(z.get("connection").and_then(|c| c.as_str()), Some("solid") | Some("full"));+    let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);+    let clearance = z.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);+    let min_t = z.get("minThickness").and_then(|v| v.as_f64()).unwrap_or(0.25);+    let gap = z.get("thermalGap").and_then(|v| v.as_f64()).unwrap_or(0.5);+    let bridge = z.get("thermalBridge").and_then(|v| v.as_f64()).unwrap_or(0.5);+    Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n{}{}\t\t(min_thickness {})\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",+        net_lines, layer_line, uuid, quote(name), if prio > 0 { format!("\t\t(priority {prio})\n") } else { String::new() },+        if solid { format!("\t\t(connect_pads yes\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)) } else { format!("\t\t(connect_pads\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)) },+        num(min_t), num(gap), num(bridge), pts.join(" ")))+}++/// Summary line per zone for the human.+pub fn describe(z: &Value) -> String {+    let layers = strs(z.get("layers")).join("+");+    if z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some() {+        return format!("keepout    {layers:<12} {}", z.get("name").and_then(|n| n.as_str()).unwrap_or(""));+    }+    let poly = poly_of(z);+    format!("{:<10} {layers:<12} prio {} {} {}", z.get("net").and_then(|n| n.as_str()).unwrap_or("?"), z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0), z.get("connection").and_then(|c| c.as_str()).unwrap_or("thermal"), if z.get("outline").is_some() { "outline".to_string() } else { format!("{:.0} mm2", geom::polygon_area(&poly).abs()) })+}
crates/aiflow-router/Cargo.tomladded+12
@@ -0,0 +1,12 @@+[package]+name = "aiflow-router"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: router"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }+aiflow-board = { workspace = true }+aiflow-grid = { workspace = true }
crates/aiflow-router/examples/route.rsadded+16
@@ -0,0 +1,16 @@+//! route BOARD SPEC OUT_PLAN [passes]  : the router on its own, for checking against the reference.+use aiflow_board::Board;+use aiflow_router::{drive::route_board, Router, Rules};+fn main() {+    let a: Vec<String> = std::env::args().collect();+    let board = Board::load(&a[1]).unwrap();+    let spec: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&a[2]).unwrap()).unwrap();+    let rules = Rules::from_spec(&spec);+    let passes: usize = a.get(4).and_then(|p| p.parse().ok()).unwrap_or(1);+    let t0 = std::time::Instant::now();+    let mut r = Router::new(board, rules, true);+    let (plan, report, failed) = route_board(&mut r, passes, 8, 400);+    std::fs::write(&a[3], serde_json::to_string_pretty(&serde_json::json!({"engine": "adom-aiflow router", "nets": plan})).unwrap()).unwrap();+    std::fs::write(format!("{}.report.json", a[3]), serde_json::to_string_pretty(&report).unwrap()).unwrap();+    eprintln!("done: {failed} failed, {} entries, {:.1}s", plan.len(), t0.elapsed().as_secs_f64());+}
crates/aiflow-router/src/drive.rsadded+1156
@@ -0,0 +1,1156 @@+//! The search and the driver: A* per connection, a net as a spanning tree onto its own copper,+//! rip-up when a connection fails, plane stubs, Kelvin taps, passes, and the plan.+use std::cmp::Reverse;+use std::collections::{BinaryHeap, HashMap, HashSet};++use aiflow_board::{geom, B_CU, F_CU};+use aiflow_grid::RES;+use serde_json::{json, Value};++use crate::{slop, End, NetState, Route, Router, Snapshot, Term, COST_TURN, COST_VIA, DIRS, HIST_COST, INF, LAYER_MULT, SOFT_EXTRA, STEP, VIA_WALL, ZONE_COST};++pub type Win = (i64, i64, i64, i64);++pub struct Search<'a> {+    pub sources: Vec<(Term, i64)>,+    pub targets: Option<&'a HashSet<Term>>,+    pub heur: Vec<(i64, i64)>,+    pub win: Win,+    pub allow_via: bool,+    pub soft: bool,+    pub via_target: bool,+    pub max_nodes: Option<usize>,+    pub vip: Vec<Term>,+}++impl Router {+    /// A* from sources to targets over the window. Returns (cells, blockers).+    pub fn search(&mut self, net: usize, width: f64, s: Search) -> (Option<Vec<Term>>, HashSet<usize>) {+        let (i0, j0, i1, j1) = s.win;+        let ww = (i1 - i0 + 1) as usize;+        let hw = (j1 - j0 + 1) as usize;+        let nl = ww * hw;+        let (obs_o, obs_d, softm, via_ok) = self.obstacle_maps(net, width, s.win, s.soft);+        let g = self.grid.clone();+        // zone, wall, history windows+        let mut zone = vec![false; nl];+        let mut wall = vec![0i64; nl];+        let mut hist = [vec![0i64; nl], vec![0i64; nl]];+        for jj in 0..hw {+            for ii in 0..ww {+                let c = (j0 as usize + jj) * g.w + i0 as usize + ii;+                zone[jj * ww + ii] = self.ic_zone.bits[c];+                wall[jj * ww + ii] = self.chan_count[c] as i64;+                hist[0][jj * ww + ii] = (self.history[0][c] as i64).clamp(0, 40);+                hist[1][jj * ww + ii] = (self.history[1][c] as i64).clamp(0, 40);+            }+        }+        let nid = self.nets[net].idx;+        for ((n, _, _), cells) in &self.chan_lines {+            if *n == nid {+                for &c in cells {+                    let (cj, ci) = ((c / g.w) as i64, (c % g.w) as i64);+                    if cj >= j0 && cj <= j1 && ci >= i0 && ci <= i1 {+                        wall[(cj - j0) as usize * ww + (ci - i0) as usize] -= 1;+                    }+                }+            }+        }+        for w in wall.iter_mut() {+            *w = (*w).clamp(0, 60);+        }+        let mut tgt = vec![false; 2 * nl];+        let vipset: HashSet<usize> = s.vip.iter().filter(|t| t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1).map(|t| (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize).collect();+        if s.via_target {+            for m in 0..nl {+                if via_ok.bits[m] && !obs_o[F_CU].bits[m] {+                    tgt[F_CU * nl + m] = true;+                }+            }+        }+        if let Some(targets) = s.targets {+            for t in targets {+                if t.i as i64 >= i0 && t.i as i64 <= i1 && t.j as i64 >= j0 && t.j as i64 <= j1 {+                    tgt[t.layer() * nl + (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize] = true;+                }+            }+        }+        if !tgt.iter().any(|b| *b) {+            return (None, HashSet::new());+        }+        let mut gcost = vec![INF; 2 * nl];+        let mut parent = vec![usize::MAX; 2 * nl];+        let mut pdir = vec![-1i8; 2 * nl];+        let mut closed = vec![false; 2 * nl];+        let mut heap: BinaryHeap<Reverse<(i64, usize)>> = BinaryHeap::new();+        let hp: Vec<(i64, i64)> = s.heur.iter().map(|(i, j)| (i - i0, j - j0)).collect();+        let h = |il: i64, jl: i64| -> i64 {+            if hp.is_empty() {+                return 0;+            }+            hp.iter().map(|(hi, hj)| { let dx = (hi - il).abs(); let dy = (hj - jl).abs(); 10 * dx.max(dy) + 4 * dx.min(dy) }).min().unwrap()+        };+        for (t, c0) in &s.sources {+            let (i, j) = (t.i as i64, t.j as i64);+            if !(i >= i0 && i <= i1 && j >= j0 && j <= j1) {+                continue;+            }+            let m = (j - j0) as usize * ww + (i - i0) as usize;+            let n = t.layer() * nl + m;+            if obs_o[t.layer()].bits[m] {+                continue;+            }+            if *c0 < gcost[n] {+                gcost[n] = *c0;+                heap.push(Reverse((c0 + h(i - i0, j - j0), n)));+            }+        }+        let mut expanded = 0usize;+        let mut found: Option<usize> = None;+        while let Some(Reverse((_, n))) = heap.pop() {+            if closed[n] {+                continue;+            }+            closed[n] = true;+            if tgt[n] {+                found = Some(n);+                break;+            }+            expanded += 1;+            if let Some(cap) = s.max_nodes {+                if expanded > cap {+                    break;+                }+            }+            let l = n / nl;+            let m = n - l * nl;+            let jl = (m / ww) as i64;+            let il = (m % ww) as i64;+            let gn = gcost[n];+            let pd = pdir[n];+            let mult = LAYER_MULT[l];+            for d in 0..8 {+                let ni = il + DIRS[d].0 as i64;+                let nj = jl + DIRS[d].1 as i64;+                if ni < 0 || nj < 0 || ni >= ww as i64 || nj >= hw as i64 {+                    continue;+                }+                let nm = nj as usize * ww + ni as usize;+                if obs_o[l].bits[nm] {+                    continue;+                }+                if d >= 4 && obs_d[l].bits[nm] && obs_d[l].bits[m] {+                    continue;+                }+                let nn = l * nl + nm;+                if closed[nn] {+                    continue;+                }+                let mut c = (STEP[d] as f64 * mult) as i64;+                if pd != -1 && pd as usize != d {+                    c += COST_TURN;+                }+                if let Some(sm) = &softm[l] {+                    if sm.bits[nm] {+                        c += SOFT_EXTRA;+                    }+                }+                if l == F_CU && zone[nm] {+                    c += ZONE_COST;+                }+                if hist[l][nm] > 0 {+                    c += HIST_COST * hist[l][nm];+                }+                let ng = gn + c;+                if ng < gcost[nn] {+                    gcost[nn] = ng;+                    parent[nn] = n;+                    pdir[nn] = d as i8;+                    heap.push(Reverse((ng + h(ni, nj), nn)));+                }+            }+            if s.allow_via && (via_ok.bits[m] || vipset.contains(&m)) {+                let l2 = 1 - l;+                if !obs_o[l2].bits[m] {+                    let nn = l2 * nl + m;+                    if !closed[nn] {+                        let ng = gn + COST_VIA + VIA_WALL * wall[m];+                        if ng < gcost[nn] {+                            gcost[nn] = ng;+                            parent[nn] = n;+                            pdir[nn] = -1;+                            heap.push(Reverse((ng + h(il, jl), nn)));+                        }+                    }+                }+            }+        }+        self.stat("expanded", expanded as i64);+        let Some(mut n) = found else { return (None, HashSet::new()) };+        let mut cells = Vec::new();+        loop {+            let l = n / nl;+            let m = n - l * nl;+            cells.push(Term::new(l, (m % ww) as i64 + i0, (m / ww) as i64 + j0));+            if parent[n] == usize::MAX {+                break;+            }+            n = parent[n];+        }+        cells.reverse();+        let mut blockers = HashSet::new();+        if s.soft {+            let r = ((self.rules.clearance + width / 2.0 + slop()) / RES).ceil() as i64 + 1;+            for t in &cells {+                if let Some(sm) = &softm[t.layer()] {+                    let m = (t.j as i64 - j0) as usize * ww + (t.i as i64 - i0) as usize;+                    if sm.bits[m] {+                        for jj in (t.j as i64 - r).max(0)..=(t.j as i64 + r).min(g.h as i64 - 1) {+                            for ii in (t.i as i64 - r).max(0)..=(t.i as i64 + r).min(g.w as i64 - 1) {+                                let v = self.route_raw[t.layer()][jj as usize * g.w + ii as usize];+                                if v != -1 && v as usize != nid {+                                    blockers.insert(v as usize);+                                }+                            }+                        }+                    }+                }+            }+        }+        (Some(cells), blockers)+    }++    // -- per net -----------------------------------------------------------------------+    pub fn window_for(&self, pts: &[(i64, i64)], margin_mm: f64) -> Win {+        let m = (margin_mm / RES).round() as i64;+        let xs: Vec<i64> = pts.iter().map(|p| p.0).collect();+        let ys: Vec<i64> = pts.iter().map(|p| p.1).collect();+        ((xs.iter().min().unwrap() - m).max(0), (ys.iter().min().unwrap() - m).max(0), (xs.iter().max().unwrap() + m).min(self.grid.w as i64 - 1), (ys.iter().max().unwrap() + m).min(self.grid.h as i64 - 1))+    }++    pub fn copper_targets(&self, net: usize) -> HashSet<Term> {+        let mut t = HashSet::new();+        for c in &self.nets[net].clusters {+            if c.connected && !self.kelvin_clusters.contains(&(net, c.idx)) {+                t.extend(c.terminals.iter().copied());   // a sense pad is never a landing target either+            }+        }+        for r in &self.nets[net].routes {+            if r.kelvin {+                continue;   // a Kelvin tap is not copper the rest of its net may land on+            }+            t.extend(r.cells.iter().copied());+        }+        t+    }++    fn vip_cells(&mut self, net: usize, clusters: &[usize]) -> Vec<Term> {+        let mut out = Vec::new();+        for &c in clusters {+            let cl = self.nets[net].clusters[c].clone();+            if self.board.pads[cl.primary].thru || !cl.escapes.is_empty() {+                continue;+            }+            for t in cl.terminals {+                if t.layer() == F_CU {+                    let (x, y) = self.grid.to_xy(t.i as i64, t.j as i64);+                    if self.via_clear_exact(net, x, y) {+                        out.push(t);+                    }+                }+            }+        }+        out+    }++    pub fn connect(&mut self, net: usize, cluster: usize, anchor: &[(i64, i64)], soft: bool, vip: bool) -> (Option<Route>, HashSet<usize>) {+        let mut targets = self.copper_targets(net);+        let cl = self.nets[net].clusters[cluster].clone();+        let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+        let allow = cl.allow;+        let mut widths: Vec<f64> = self.nets[net].widths.iter().copied().filter(|w| *w <= allow).collect();+        if widths.is_empty() {+            widths = vec![self.nets[net].widths.iter().cloned().fold(f64::INFINITY, f64::min)];+        }+        let mut allpts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+        allpts.extend_from_slice(anchor);+        let mut vip_src = Vec::new();+        if vip {+            vip_src = self.vip_cells(net, &[cluster]);+            let connected: Vec<usize> = self.nets[net].clusters.iter().filter(|c| c.connected).map(|c| c.idx).collect();+            for t in self.vip_cells(net, &connected) {+                targets.insert(Term::new(B_CU, t.i as i64, t.j as i64));+            }+        }+        let stages: Vec<(f64, Option<usize>)> = if soft || vip { vec![(12.0, Some(200_000))] } else { vec![(5.0, None), (12.0, None), (80.0, Some(250_000))] };+        for width in widths {+            for (margin, cap) in &stages {+                let win = self.window_for(&allpts, *margin);+                let (cells, blockers) = self.search(net, width, Search { sources: sources.clone(), targets: Some(&targets), heur: anchor.to_vec(), win, allow_via: true, soft, via_target: false, max_nodes: *cap, vip: vip_src.clone() });+                if let Some(cells) = cells {+                    let start_term = cells[0];+                    let end_cell = *cells.last().unwrap();+                    let start = if cl.escapes.contains_key(&start_term) { End::Escape(cluster, start_term) } else { End::Pad(cluster, start_term) };+                    let mut end = End::Copper;+                    for c in &self.nets[net].clusters {+                        if c.connected && (c.terminals.contains(&end_cell) || (end_cell.layer() == B_CU && c.terminals.contains(&Term::new(F_CU, end_cell.i as i64, end_cell.j as i64)) && targets.contains(&end_cell))) {+                            end = if c.escapes.contains_key(&end_cell) { End::Escape(c.idx, end_cell) } else { End::Pad(c.idx, end_cell) };+                        }+                    }+                    let mut route = Route::new(cells.clone(), width, start, end.clone());+                    if let End::Pad(ec, _) = end {+                        if end_cell.layer() == B_CU && !self.board.pads[self.nets[net].clusters[ec].primary].thru {+                            route.via_end = true;+                            route.via_end_layer = F_CU;+                            self.stat("viaInPad", 1);+                            let key = self.nets[net].clusters[ec].key(&self.board).to_string();+                            self.nets[net].via_in_pad.push(key);+                        }+                    }+                    if cells.len() > 1 && (cells[0].i, cells[0].j) == (cells[1].i, cells[1].j) && cells[0].layer() != cells[1].layer() && !self.board.pads[cl.primary].thru {+                        self.stat("viaInPad", 1);+                        let key = cl.key(&self.board).to_string();+                        self.nets[net].via_in_pad.push(key);+                    }+                    return (Some(route), blockers);+                }+            }+        }+        (None, HashSet::new())+    }++    /// Prim's order over clusters; each cluster to the existing copper. Returns blocker net ids on failure.+    pub fn route_net(&mut self, net: usize) -> HashSet<usize> {+        let n = self.nets[net].clusters.len();+        for c in 0..n {+            self.nets[net].clusters[c].connected = false;+        }+        self.nets[net].routes.clear();+        self.nets[net].failed.clear();+        self.nets[net].last_soft.clear();+        let start = (0..n).max_by(|&a, &b| {+            let ca = &self.nets[net].clusters[a];+            let cb = &self.nets[net].clusters[b];+            let pa = &self.board.pads[ca.primary];+            let pb = &self.board.pads[cb.primary];+            (ca.allow, pa.half_long * pa.half_short).partial_cmp(&(cb.allow, pb.half_long * pb.half_short)).unwrap()+        }).unwrap();+        self.nets[net].clusters[start].connected = true;+        let mut connected = vec![start];+        let mut remaining: Vec<usize> = (0..n).filter(|&c| c != start).collect();+        let mut blockers_all = HashSet::new();+        // Kelvin taps first: sense pad to shunt pad as a dedicated trace+        let kelvin = self.rules.kelvin.clone();+        for c in remaining.clone() {+            let key = self.nets[net].clusters[c].key(&self.board).to_string();+            if let Some(dst_key) = kelvin.get(&key) {+                let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dst_key);+                let Some(dst) = dst else { continue };+                if self.route_kelvin(net, c, dst) {+                    // the sense pad is done; the shunt pad still joins the rest of the net through+                    // the wide path (the tap itself is never a landing target, see copper_targets)+                    remaining.retain(|&x| x != c);+                } else {+                    let dk = self.nets[net].clusters[dst].key(&self.board).to_string();+                    self.nets[net].failed.push((key, format!("kelvin tap to {dk} found no dedicated path")));+                    remaining.retain(|&x| x != c);+                }+            }+        }+        while !remaining.is_empty() {+            let mut best: Option<(f64, usize, usize)> = None;+            for &c in &remaining {+                for &k in &connected {+                    let a = self.nets[net].clusters[c].xy(&self.board);+                    let b = self.nets[net].clusters[k].xy(&self.board);+                    let d = (a.0 - b.0).hypot(a.1 - b.1);+                    if best.map(|x| d < x.0).unwrap_or(true) {+                        best = Some((d, c, k));+                    }+                }+            }+            let (_, c, k) = best.unwrap();+            remaining.retain(|&x| x != c);+            let anchor: Vec<(i64, i64)> = self.nets[net].clusters[k].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+            let (mut route, _) = self.connect(net, c, &anchor, false, false);+            if route.is_none() {+                route = self.connect(net, c, &anchor, false, true).0;+            }+            match route {+                None => {+                    let (soft_route, blockers) = self.connect(net, c, &anchor, true, false);+                    blockers_all.extend(blockers.iter().copied());+                    if let Some(sr) = soft_route {+                        self.nets[net].last_soft.extend(sr.cells.iter().copied());+                    }+                    let key = self.nets[net].clusters[c].key(&self.board).to_string();+                    let why = if blockers.is_empty() { "no path".to_string() } else {+                        let mut names: Vec<String> = blockers.iter().map(|&b| self.name_of(b)).collect();+                        names.sort();+                        format!("blocked by {names:?}")+                    };+                    self.nets[net].failed.push((key, why));+                    self.nets[net].clusters[c].connected = false;+                }+                Some(r) => {+                    self.commit(net, r);+                    self.nets[net].clusters[c].connected = true;+                    connected.push(c);+                }+            }+        }+        blockers_all+    }++    pub fn name_of(&self, nid: usize) -> String {+        if let Some(&owner) = self.kelvin_owner.get(&nid) {+            return self.nets[owner].name.clone();+        }+        self.nets.iter().find(|n| n.idx == nid).map(|n| n.name.clone()).unwrap_or_else(|| "?".into())+    }++    pub fn commit(&mut self, net: usize, route: Route) {+        let end = *route.cells.last().unwrap();+        for r in self.nets[net].routes.iter_mut() {+            if let Some(pos) = r.cells.iter().position(|c| *c == end) {+                r.breaks.insert(pos);+            }+        }+        for e in [&route.start, &route.end] {+            if let End::Escape(c, t) = e {+                self.nets[net].used_escapes.insert((*c, *t));+            }+        }+        self.stamp_route(net, &route, 1);+        let ends = [route.start.clone(), route.end.clone()];+        self.nets[net].routes.push(route);+        for e in ends {+            match e {+                End::Escape(c, t) => {+                    self.release_escapes(net, c);+                    self.stamp_channel_pub(net, c, t, -1);+                }+                End::Pad(c, _) => self.release_escapes(net, c),+                _ => {}+            }+        }+    }++    pub fn stamp_channel_pub(&mut self, net: usize, c: usize, t: Term, sign: i32) {+        // the channel bookkeeping lives in lib.rs; re-expose through stamp_stub's helper+        let key = (self.nets[net].idx, c, t);+        if sign < 0 {+            if let Some(cells) = self.chan_lines.remove(&key) {+                for cell in cells {+                    self.chan_count[cell] -= 1;+                }+            }+        }+    }++    pub fn release_escapes(&mut self, net: usize, cluster: usize) {+        let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();+        for t in escapes {+            let used = self.nets[net].used_escapes.contains(&(cluster, t));+            let released = self.nets[net].clusters[cluster].released.contains(&t);+            if !used && !released {+                self.stamp_stub(net, cluster, t, -1);+                self.nets[net].clusters[cluster].released.insert(t);+            }+        }+    }++    /// Re-reserve a cluster's escapes after a rip-up; an escape crossed by another net's copper stays dead.+    pub fn reserve_escapes(&mut self, net: usize, cluster: usize) {+        let g = self.grid.clone();+        let escapes: Vec<Term> = self.nets[net].clusters[cluster].escapes.keys().copied().collect();+        let nid = self.nets[net].idx as i16;+        for t in escapes {+            if self.nets[net].clusters[cluster].released.contains(&t) {+                let l = t.layer();+                let a = self.board.pads[self.nets[net].clusters[cluster].primary].polys_centre;+                let b = g.to_xy(t.i as i64, t.j as i64);+                let mut checks = vec![(a, b, self.rules.stub / 2.0 + self.rules.clearance)];+                if let Some(k) = self.nets[net].clusters[cluster].corridors.get(&t) {+                    checks.push((b, *k, self.rules.track / 2.0 + self.rules.clearance));+                }+                let mut blocked = false;+                for (s0, s1, hw) in checks {+                    for c in g.segment_cells(s0, s1, hw, Some(1e-6)) {+                        let raw = self.route_raw[l][c];+                        if raw != -1 && raw != nid {+                            blocked = true;+                            break;+                        }+                    }+                    if blocked {+                        break;+                    }+                }+                if blocked {+                    let cl = &mut self.nets[net].clusters[cluster];+                    if cl.terminals.len() > 1 {+                        cl.terminals.retain(|x| *x != t);+                    }+                    self.stat("deadEscapes", 1);+                    continue;+                }+                self.stamp_stub(net, cluster, t, 1);+                self.nets[net].clusters[cluster].released.remove(&t);+            } else {+                self.stamp_stub_channel(net, cluster, t);+            }+        }+    }++    fn stamp_stub_channel(&mut self, net: usize, cluster: usize, t: Term) {+        // (re)count the channel only: stamp_stub(+1) would re-stamp copper that is still there+        let key = (self.nets[net].idx, cluster, t);+        if self.chan_lines.contains_key(&key) {+            return;+        }+        // reuse stamp_stub's channel computation by a +1 stub stamp on already-stamped cells (idempotent copper)+        self.stamp_stub(net, cluster, t, 1);+    }++    pub fn rip_cluster(&mut self, net: usize, cluster: usize) {+        let routes = std::mem::take(&mut self.nets[net].routes);+        let mut keep = Vec::new();+        for r in routes {+            if r.start.cluster() == Some(cluster) {+                self.stamp_route(net, &r, -1);+                for c in &r.cells {+                    self.bump_history(c);+                }+            } else {+                keep.push(r);+            }+        }+        self.nets[net].routes = keep;+        self.nets[net].used_escapes.retain(|(ci, _)| *ci != cluster);+        self.nets[net].clusters[cluster].connected = false;+        self.reserve_escapes(net, cluster);+        let key = self.nets[net].clusters[cluster].key(&self.board).to_string();+        if !self.nets[net].failed.iter().any(|(k, _)| *k == key) {+            self.nets[net].failed.push((key, "ripped for a signal".into()));+        }+        self.stat("rips", 1);+        self.rip_total += 1;+    }++    fn bump_history(&mut self, c: &Term) {+        let g = &self.grid;+        for jj in (c.j as i64 - 2).max(0)..=(c.j as i64 + 2).min(g.h as i64 - 1) {+            for ii in (c.i as i64 - 2).max(0)..=(c.i as i64 + 2).min(g.w as i64 - 1) {+                let idx = jj as usize * g.w + ii as usize;+                self.history[c.layer()][idx] = self.history[c.layer()][idx].saturating_add(1);+            }+        }+    }++    pub fn rip(&mut self, net: usize) {+        let routes = std::mem::take(&mut self.nets[net].routes);+        for r in &routes {+            self.stamp_route(net, r, -1);+            for c in &r.cells {+                self.bump_history(c);+            }+        }+        self.nets[net].used_escapes.clear();+        let n = self.nets[net].clusters.len();+        for c in 0..n {+            self.nets[net].clusters[c].connected = false;+            self.reserve_escapes(net, c);+        }+        self.nets[net].rips += 1;+        self.rip_total += 1;+        self.stat("rips", 1);+    }++    // -- Kelvin taps -----------------------------------------------------------------------+    pub fn route_kelvin(&mut self, net: usize, src: usize, dst: usize) -> bool {+        let g = self.grid.clone();+        let mut kid = 30000 + (src % 1000);+        while self.nets.iter().any(|n| n.idx == kid) || self.kelvin_owner.contains_key(&kid) {+            kid += 1;+        }+        self.kelvin_owner.insert(kid, net);+        let nid = self.nets[net].idx as i16;+        let mut saved: Vec<(usize, Vec<usize>)> = Vec::new();+        for &cl in &[src, dst] {+            let p = self.board.pads[self.nets[net].clusters[cl].primary].clone();+            let cells = g.disk_cells((p.x, p.y), p.half_long + self.rules.escape + self.rules.corridor + 0.3, Some(1e-6));+            for l in 0..2 {+                let mine: Vec<usize> = cells.iter().copied().filter(|&c| self.pad_raw[l][c] == nid).collect();+                for &c in &mine {+                    self.pad_raw[l][c] = kid as i16;+                }+                saved.push((l, mine));+            }+        }+        // a synthetic net state just for the search+        let fake = NetState { idx: kid, name: self.nets[net].name.clone(), pads: Vec::new(), clusters: Vec::new(), routes: Vec::new(), widths: vec![self.rules.stub + 0.05], plane: None, mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(), rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: self.nets[net].via, drill: self.nets[net].drill };+        self.nets.push(fake);+        let fidx = self.nets.len() - 1;+        let sources: Vec<(Term, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (*t, 0)).collect();+        let targets: HashSet<Term> = self.nets[net].clusters[dst].terminals.iter().copied().collect();+        let mut pts: Vec<(i64, i64)> = self.nets[net].clusters[src].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+        let dpts: Vec<(i64, i64)> = self.nets[net].clusters[dst].terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+        pts.extend_from_slice(&dpts);+        let allow_via = self.nets[net].plane.is_none();+        let width = self.rules.stub + 0.05;+        let mut cells = None;+        for margin in [4.0, 10.0] {+            let win = self.window_for(&pts, margin);+            let (c, _) = self.search(fidx, width, Search { sources: sources.clone(), targets: Some(&targets), heur: dpts.clone(), win, allow_via, soft: false, via_target: false, max_nodes: None, vip: Vec::new() });+            if c.is_some() {+                cells = c;+                break;+            }+        }+        self.nets.pop();+        for (l, mine) in saved {+            for c in mine {+                self.pad_raw[l][c] = nid;+            }+        }+        let Some(cells) = cells else { self.kelvin_owner.remove(&kid); return false };+        let start = if self.nets[net].clusters[src].escapes.contains_key(&cells[0]) { End::Escape(src, cells[0]) } else { End::Pad(src, cells[0]) };+        let last = *cells.last().unwrap();+        let end = if self.nets[net].clusters[dst].escapes.contains_key(&last) { End::Escape(dst, last) } else { End::Pad(dst, last) };+        let mut r = Route::new(cells.clone(), width, start, end);+        r.kelvin = true;+        self.commit(net, r);+        // the tap is foreign copper to the rest of its net, except inside the pads it joins: the+        // pickup is the pad itself, and the wide path must still be able to land on that pad+        for c in &cells {+            let xy = g.to_xy(c.i as i64, c.j as i64);+            for cell in g.disk_cells(xy, width / 2.0, Some(1e-6)) {+                if self.pad_raw[c.layer()][cell] != nid {+                    self.route_raw[c.layer()][cell] = kid as i16;+                }+            }+        }+        self.nets[net].clusters[src].connected = true;+        self.kelvin_clusters.insert((net, src));+        self.stat("kelvin", 1);+        let (sk, dk) = (self.nets[net].clusters[src].key(&self.board).to_string(), self.nets[net].clusters[dst].key(&self.board).to_string());+        self.log(format!("    kelvin {sk} -> {dk}: {} cells, dedicated", cells.len()));+        true+    }++    // -- planes ------------------------------------------------------------------------------+    pub fn route_plane_net(&mut self, net: usize, retry: bool) {+        let g = self.grid.clone();+        let failed: HashSet<String> = if retry { self.nets[net].failed.iter().map(|(k, _)| k.clone()).collect() } else { HashSet::new() };+        if retry {+            self.nets[net].failed.clear();+        }+        let n = self.nets[net].clusters.len();+        let kelvin = self.rules.kelvin.clone();+        for c in 0..n {+            let cl = self.nets[net].clusters[c].clone();+            let key = cl.key(&self.board).to_string();+            if self.board.pads[cl.primary].thru {+                self.nets[net].clusters[c].connected = true;+                continue;+            }+            if kelvin.contains_key(&key) {+                continue;+            }+            if retry && (cl.connected || !failed.contains(&key)) {+                continue;+            }+            let sources: Vec<(Term, i64)> = cl.terminals.iter().map(|t| (*t, 0)).collect();+            let pts: Vec<(i64, i64)> = cl.terminals.iter().map(|t| (t.i as i64, t.j as i64)).collect();+            let win = self.window_for(&pts, self.rules.via_search_mm);+            let own: HashSet<Term> = self.copper_targets(net).into_iter().filter(|t| t.layer() == F_CU).collect();+            let track = self.rules.track;+            let (cells, _) = self.search(net, track, Search { sources, targets: Some(&own), heur: Vec::new(), win, allow_via: false, soft: false, via_target: true, max_nodes: None, vip: Vec::new() });+            match cells {+                None => {+                    let p = self.board.pads[cl.primary].clone();+                    let (i, j) = g.to_cell(p.x, p.y);+                    if self.via_clear_exact(net, p.x, p.y) {+                        let mut r = Route::new(vec![Term::new(F_CU, i, j)], track, End::Pad(c, Term::new(F_CU, i, j)), End::Via);+                        r.via_end = true;+                        self.commit(net, r);+                        self.nets[net].clusters[c].connected = true;+                        self.nets[net].via_in_pad.push(key.clone());+                        self.stat("viaInPad", 1);+                    } else {+                        self.nets[net].failed.push((key.clone(), format!("no via site within {:.0} mm", self.rules.via_search_mm)));+                    }+                }+                Some(cells) => {+                    let start = if cl.escapes.contains_key(&cells[0]) { End::Escape(c, cells[0]) } else { End::Pad(c, cells[0]) };+                    let joined = own.contains(cells.last().unwrap());+                    let mut r = Route::new(cells, track, start, if joined { End::Copper } else { End::Via });+                    r.via_end = !joined;+                    self.commit(net, r);+                    self.nets[net].clusters[c].connected = true;+                }+            }+        }+    }++    /// Exact geometry: may a via of this net sit at (x, y)?+    pub fn via_clear_exact(&mut self, net: usize, x: f64, y: f64) -> bool {+        let g = self.grid.clone();+        let (via, drill) = (self.nets[net].via, self.nets[net].drill);+        let rv = via / 2.0;+        let (i, j) = g.to_cell(x, y);+        if !g.in_bounds(i, j) || !self.inside_for(via).get(i as usize, j as usize) {+            return false;+        }+        let reach = rv + self.big_clr_val + 1.5;+        let name = self.nets[net].name.clone();+        for p in &self.board.pads {+            if (p.x - x).abs() > reach + p.half_long || (p.y - y).abs() > reach + p.half_long {+                continue;+            }+            if p.net == name && !p.thru {+                continue;+            }+            let mut need = if p.thru && p.net == name { drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0 + slop() } else { rv + self.rules.clearance.max(p.clearance) };+            if p.thru && p.net != name {+                need = need.max(drill / 2.0 + self.rules.hole_to_hole + p.drill.unwrap_or(0.0) / 2.0);+            }+            for poly in &p.polys {+                if geom::point_poly_distance(x, y, poly) < need {+                    return false;+                }+            }+        }+        let nid = self.nets[net].idx;+        for (oi, other) in self.nets.iter().enumerate() {+            if other.idx == nid || oi == net {+                continue;+            }+            for r in &other.routes {+                let need = rv + self.rules.clearance + r.width / 2.0;+                for (ax, ay, bx, by) in route_segments(&g, &r.cells) {+                    if (ax - x).abs() > 3.0 && (bx - x).abs() > 3.0 {+                        continue;+                    }+                    if geom::seg_distance(x, y, ax, ay, bx, by) < need {+                        return false;+                    }+                }+            }+        }+        for &(vi, vj, vnid, dia, vdrill) in &self.vias {+            let (vx, vy) = g.to_xy(vi as i64, vj as i64);+            if (vx - x).hypot(vy - y) < self.via_gap(net, vnid, dia, vdrill) {+                return false;+            }+        }+        true+    }++    // -- driver --------------------------------------------------------------------------+    pub fn mst_length(&self, net: usize) -> f64 {+        let pts: Vec<(f64, f64)> = self.nets[net].clusters.iter().map(|c| c.xy(&self.board)).collect();+        if pts.len() < 2 {+            return 0.0;+        }+        let mut inn = vec![pts[0]];+        let mut rest: Vec<(f64, f64)> = pts[1..].to_vec();+        let mut total = 0.0;+        while !rest.is_empty() {+            let mut best = (f64::INFINITY, 0usize);+            for (ri, b) in rest.iter().enumerate() {+                for a in &inn {+                    let d = (a.0 - b.0).hypot(a.1 - b.1);+                    if d < best.0 {+                        best = (d, ri);+                    }+                }+            }+            total += best.0;+            inn.push(rest.remove(best.1));+        }+        total+    }++    pub fn run(&mut self, planes: bool, rip_per_net: usize, rip_global: usize, priority: &[String]) -> usize {+        let t0 = std::time::Instant::now();+        self.rip_total = 0;+        for n in self.nets.iter_mut() {+            n.rips = 0;+        }+        let plane_nets: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_some()).map(|n| n.idx).collect();+        let kelvin = self.rules.kelvin.clone();+        if planes {+            for &net in &plane_nets {+                let t1 = std::time::Instant::now();+                let n = self.nets[net].clusters.len();+                for c in 0..n {+                    let key = self.nets[net].clusters[c].key(&self.board).to_string();+                    if let Some(dk) = kelvin.get(&key) {+                        let dst = self.nets[net].clusters.iter().position(|k| k.key(&self.board) == dk);+                        if dst.map(|d| !self.route_kelvin(net, c, d)).unwrap_or(true) {+                            self.nets[net].failed.push((key, "kelvin tap found no dedicated path".into()));+                        }+                    }+                }+                self.route_plane_net(net, false);+                let (name, plane, nr, nv, nf) = (self.nets[net].name.clone(), self.nets[net].plane.clone().unwrap(), self.nets[net].routes.len(), self.nets[net].via_in_pad.len(), self.nets[net].failed.len());+                self.log(format!("  {name:<28} plane {plane}: {} smd stubs, {nv} via-in-pad, {nf} failed, {:.1}s", nr - nv, t1.elapsed().as_secs_f64()));+            }+        }+        let mut signal: Vec<usize> = self.nets.iter().filter(|n| n.plane.is_none() && n.clusters.len() >= 2).map(|n| n.idx).collect();+        for &n in &signal {+            let l = self.mst_length(n);+            self.nets[n].mst_len = l;+        }+        signal.sort_by(|a, b| self.nets[*a].mst_len.partial_cmp(&self.nets[*b].mst_len).unwrap());+        let mut first: Vec<usize> = signal.iter().copied().filter(|n| priority.contains(&self.nets[*n].name)).collect();+        let mut rest: Vec<usize> = signal.iter().copied().filter(|n| !priority.contains(&self.nets[*n].name)).collect();+        let mut queue: Vec<usize> = Vec::new();+        queue.append(&mut first);+        queue.append(&mut rest);+        self.log(format!("routing {} signal nets, shortest first{}", queue.len(), if priority.is_empty() { String::new() } else { format!(", {} nets that failed last pass go first: {:?}", priority.len(), priority) }));+        let mut order_no = 0;+        let mut best_state: Option<(usize, Snapshot)> = None;+        while !queue.is_empty() {+            let net = queue.remove(0);+            if !self.nets[net].routes.is_empty() {+                self.rip(net);+            }+            let t1 = std::time::Instant::now();+            let blockers = self.route_net(net);+            order_no += 1;+            self.nets[net].routed_order = Some(order_no);+            {+                let n = &self.nets[net];+                let widths: Vec<String> = n.widths.iter().map(|w| format!("{w}")).collect();+                let line = format!("  {:<28} {:>2} clusters  {}  {} routes, {} failed, {:.1}s{}", n.name, n.clusters.len(), widths.join("/"), n.routes.len(), n.failed.len(), t1.elapsed().as_secs_f64(), if n.failed.is_empty() { String::new() } else { format!("  FAILED {:?}", n.failed) });+                self.log(line);+            }+            let score = self.nets.iter().filter(|n| !queue.contains(&n.idx)).map(|n| n.failed.len()).sum::<usize>() + queue.iter().map(|&q| self.nets[q].clusters.len().saturating_sub(1)).sum::<usize>();+            if best_state.as_ref().map(|b| score < b.0).unwrap_or(true) {+                best_state = Some((score, self.snapshot()));+            }+            if !self.nets[net].failed.is_empty() && !blockers.is_empty() && self.nets[net].rips < rip_per_net && self.rip_total < rip_global {+                let mut cands: Vec<usize> = blockers.iter().copied().filter(|b| !self.kelvin_owner.contains_key(b)).filter_map(|b| self.nets.iter().position(|n| n.idx == b)).filter(|&n| !self.nets[n].routes.is_empty() && self.nets[n].plane.is_none()).collect();+                cands.sort_by_key(|&n| std::cmp::Reverse(self.nets[n].routed_order.unwrap_or(0)));+                cands.truncate(4);+                let mut stubs: Vec<(usize, usize)> = Vec::new();+                if cands.is_empty() {+                    let soft: HashSet<(i32, i32)> = self.nets[net].last_soft.iter().flat_map(|t| { let (i, j) = (t.i, t.j); (-6..=6).flat_map(move |di| (-6..=6).map(move |dj| (i + di, j + dj))) }).collect();+                    for &b in &blockers {+                        if let Some(pn) = self.nets.iter().position(|n| n.idx == b) {+                            if self.nets[pn].plane.is_none() {+                                continue;+                            }+                            for r in self.nets[pn].routes.clone() {+                                if r.cells.iter().any(|t| soft.contains(&(t.i, t.j))) {+                                    if let Some(cl) = r.start.cluster() {+                                        stubs.push((pn, cl));+                                    }+                                }+                            }+                        }+                    }+                }+                if !cands.is_empty() || !stubs.is_empty() {+                    let cn: Vec<String> = cands.iter().map(|&c| self.nets[c].name.clone()).collect();+                    let sn: Vec<String> = stubs.iter().map(|(pn, cl)| self.nets[*pn].clusters[*cl].key(&self.board).to_string()).collect();+                    let name = self.nets[net].name.clone();+                    self.log(format!("    rip-up {cn:?}{} and retry {name}", if sn.is_empty() { String::new() } else { format!(" + plane stubs {sn:?}") }));+                    for &c in &cands {+                        self.rip(c);+                        queue.retain(|&q| q != c);+                    }+                    for (pn, cl) in stubs {+                        self.rip_cluster(pn, cl);+                    }+                    self.nets[net].rips += 1;+                    queue.insert(0, net);+                    for (k, &c) in cands.iter().enumerate() {+                        queue.insert(1 + k, c);+                    }+                    continue;+                }+            }+            if planes {+                for &pn in &plane_nets {+                    if !self.nets[pn].failed.is_empty() {+                        self.route_plane_net(pn, true);+                    }+                }+            }+        }+        if planes {+            for &pn in &plane_nets {+                if self.nets[pn].failed.is_empty() {+                    continue;+                }+                let before = self.nets[pn].failed.len();+                self.route_plane_net(pn, true);+                let (name, after) = (self.nets[pn].name.clone(), self.nets[pn].failed.len());+                self.log(format!("  {name:<28} plane retry: {} of {before} stubs recovered", before - after));+            }+        }+        let mut final_failed: usize = self.nets.iter().map(|n| n.failed.len()).sum();+        if let Some((score, snap)) = best_state {+            if score < final_failed {+                self.log(format!("  restoring the best intermediate state ({score} failed connections instead of {final_failed})"));+                self.restore(&snap);+                if planes {+                    for &pn in &plane_nets {+                        if !self.nets[pn].failed.is_empty() {+                            self.route_plane_net(pn, true);+                        }+                    }+                }+                final_failed = self.nets.iter().map(|n| n.failed.len()).sum();+            }+        }+        let (exp, rips) = (self.stats.get("expanded").copied().unwrap_or(0), self.stats.get("rips").copied().unwrap_or(0));+        self.log(format!("routing done in {:.1}s, {exp} node expansions, {rips} rip-ups, {final_failed} failed connections", t0.elapsed().as_secs_f64()));+        final_failed+    }++    pub fn reset(&mut self) {+        let n = self.nets.len();+        for net in 0..n {+            if !self.nets[net].routes.is_empty() {+                self.rip(net);+            }+            self.nets[net].failed.clear();+            self.nets[net].via_in_pad.clear();+            self.nets[net].routed_order = None;+            self.nets[net].used_escapes.clear();+            for c in self.nets[net].clusters.iter_mut() {+                c.connected = false;+            }+        }+        self.rip_total = 0;+    }++    pub fn snapshot(&self) -> Snapshot {+        Snapshot { nets: self.nets.iter().map(|n| (n.routes.clone(), n.used_escapes.clone(), n.failed.clone(), n.via_in_pad.clone(), n.routed_order)).collect() }+    }++    pub fn restore(&mut self, snap: &Snapshot) {+        self.reset();+        for (net, (routes, used, failed, vip, order)) in snap.nets.iter().enumerate() {+            self.nets[net].used_escapes = used.clone();+            self.nets[net].failed = failed.clone();+            self.nets[net].via_in_pad = vip.clone();+            self.nets[net].routed_order = *order;+            for r in routes {+                self.stamp_route(net, r, 1);+                if r.kelvin {+                    // the tap's own-net exclusion is rebuilt by the next kelvin routing; here it is copper of its net+                }+                self.nets[net].routes.push(r.clone());+            }+            let n = self.nets[net].clusters.len();+            for c in 0..n {+                let plane_thru = self.nets[net].plane.is_some() && self.board.pads[self.nets[net].clusters[c].primary].thru;+                let touched = self.nets[net].routes.iter().any(|r| r.start.cluster() == Some(c) || (matches!(r.end, End::Pad(..) | End::Escape(..)) && r.end.cluster() == Some(c)));+                self.nets[net].clusters[c].connected = touched || plane_thru;+            }+        }+    }++    // -- plan emission -----------------------------------------------------------------------+    fn waypoints(&self, net: usize, route: &Route) -> Vec<Value> {+        let g = &self.grid;+        let cells = &route.cells;+        let xy = |c: &Term| { let p = g.to_xy(c.i as i64, c.j as i64); json!([p.0, p.1]) };+        let mut pts: Vec<Value> = Vec::new();+        match &route.start {+            End::Pad(cl, term) => {+                let key = self.nets[net].clusters[*cl].key(&self.board);+                if term.layer() == B_CU { pts.push(json!({"pad": key, "layer": "B.Cu"})); } else { pts.push(json!(key)); }+            }+            _ => pts.push(xy(&cells[0])),+        }+        let n = cells.len();+        let mut k = 1;+        while k < n {+            let (l, i, j) = (cells[k].layer(), cells[k].i, cells[k].j);+            let (pl, pi, pj) = (cells[k - 1].layer(), cells[k - 1].i, cells[k - 1].j);+            if l != pl {+                let p = g.to_xy(i as i64, j as i64);+                pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[l]}));+                k += 1;+                continue;+            }+            let (di, dj) = (i - pi, j - pj);+            let mut m = k;+            while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) && !route.breaks.contains(&m) {+                m += 1;+            }+            if m == n - 1 {+                break;+            }+            pts.push(xy(&cells[m]));+            k = m + 1;+        }+        let last = cells[n - 1];+        if route.via_end {+            let mut p = g.to_xy(last.i as i64, last.j as i64);+            if n == 1 {+                if let End::Pad(cl, _) = &route.start {+                    let pad = &self.board.pads[self.nets[net].clusters[*cl].primary];+                    p = (pad.x, pad.y);+                }+            }+            pts.push(json!({"x": p.0, "y": p.1, "layer": aiflow_board::LAYER_NAMES[route.via_end_layer]}));+            if let End::Pad(cl, _) = &route.end {+                pts.push(json!(self.nets[net].clusters[*cl].key(&self.board)));+            }+        } else {+            match &route.end {+                End::Pad(cl, _) => pts.push(json!(self.nets[net].clusters[*cl].key(&self.board))),+                _ => pts.push(xy(&last)),+            }+        }+        pts+    }++    pub fn plan(&self) -> Vec<Value> {+        let mut order: Vec<usize> = (0..self.nets.len()).collect();+        order.sort_by_key(|&n| (self.nets[n].routed_order.unwrap_or(1_000_000), self.nets[n].name.clone()));+        let mut entries = Vec::new();+        for n in order {+            let net = &self.nets[n];+            if net.routes.is_empty() {+                continue;+            }+            let mut by_width: Vec<(f64, Vec<Value>)> = Vec::new();+            for r in &net.routes {+                let pts = self.waypoints(n, r);+                if pts.len() < 2 {+                    continue;+                }+                if r.kelvin {+                    entries.push(json!({"net": net.name, "width": r.width, "viaSize": net.via, "viaDrill": net.drill, "paths": [pts], "kelvin": true}));+                    continue;+                }+                if let Some(e) = by_width.iter_mut().find(|(w, _)| (*w - r.width).abs() < 1e-9) {+                    e.1.push(Value::Array(pts));+                } else {+                    by_width.push((r.width, vec![Value::Array(pts)]));+                }+            }+            let mut stubs: Vec<Value> = Vec::new();+            let mut used: Vec<(usize, Term)> = net.used_escapes.iter().copied().collect();+            used.sort();+            for (cidx, term) in used {+                let key = net.clusters[cidx].key(&self.board);+                let p = self.grid.to_xy(term.i as i64, term.j as i64);+                // the stub leaves along the pad's long axis: a pad centre is off the grid, and a stub+                // that slopes toward the escape cell from the first micrometre grazes the neighbouring+                // pin (0.1989 mm against a 0.2 rule on a 0.5 mm pitch); the jog to the cell happens at+                // the cell end, inside the stub's own width+                let pad = &self.board.pads[net.clusters[cidx].primary];+                let (ax, ay) = pad.long_axis;+                let norm = (ax * ax + ay * ay).sqrt();+                let mut pts = vec![json!(key)];+                if norm > 1e-9 {+                    let (ux, uy) = (ax / norm, ay / norm);+                    let t = (p.0 - pad.x) * ux + (p.1 - pad.y) * uy;+                    let mid = (((pad.x + ux * t) * 1000.0).round() / 1000.0, ((pad.y + uy * t) * 1000.0).round() / 1000.0);+                    let jog = (p.0 - mid.0).hypot(p.1 - mid.1);+                    if jog > 1e-6 && jog < self.rules.stub && t.abs() > 1e-6 {+                        pts.push(json!([mid.0, mid.1]));+                    }+                }+                pts.push(json!([p.0, p.1]));+                stubs.push(Value::Array(pts));+            }+            if !stubs.is_empty() {+                entries.push(json!({"net": net.name, "width": self.rules.stub, "viaSize": net.via, "viaDrill": net.drill, "paths": stubs}));+            }+            by_width.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap());+            for (w, paths) in by_width {+                entries.push(json!({"net": net.name, "width": w, "viaSize": net.via, "viaDrill": net.drill, "paths": paths}));+            }+        }+        entries+    }++    pub fn report(&self) -> Value {+        let mut nets = serde_json::Map::new();+        let mut unrouted = Vec::new();+        for net in &self.nets {+            if net.clusters.len() < 2 && net.plane.is_none() {+                continue;+            }+            let mut widths: Vec<f64> = net.routes.iter().map(|r| r.width).collect();+            widths.sort_by(|a, b| b.partial_cmp(a).unwrap());+            widths.dedup();+            nets.insert(net.name.clone(), json!({+                "clusters": net.clusters.len(), "pads": net.pads.len(), "routes": net.routes.len(), "widths": widths, "plane": net.plane,+                "failed": net.failed, "viaInPad": net.via_in_pad, "ripUps": net.rips, "order": net.routed_order, "mstMm": (net.mst_len * 100.0).round() / 100.0,+            }));+            for (k, why) in &net.failed {+                unrouted.push(json!([net.name, [k, why]]));+            }+        }+        json!({"rules": serde_json::to_value(&self.rules).unwrap(), "nets": nets, "stats": self.stats, "unrouted": unrouted})+    }+}++/// Straight runs of a cell path as exact segments, vias skipped.+pub fn route_segments(g: &aiflow_grid::Grid, cells: &[Term]) -> Vec<(f64, f64, f64, f64)> {+    let mut out = Vec::new();+    let n = cells.len();+    let mut k = 0;+    while k + 1 < n {+        let l = cells[k].layer();+        let mut m = k + 1;+        if cells[m].layer() != l {+            k = m;+            continue;+        }+        let (di, dj) = (cells[m].i - cells[k].i, cells[m].j - cells[k].j);+        while m + 1 < n && cells[m + 1].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) {+            m += 1;+        }+        let a = g.to_xy(cells[k].i as i64, cells[k].j as i64);+        let b = g.to_xy(cells[m].i as i64, cells[m].j as i64);+        out.push((a.0, a.1, b.0, b.1));+        k = m;+    }+    out+}++/// Route a board: passes with the failed nets first; keeps the best pass. Returns (plan entries, report, failed).+pub fn route_board(router: &mut Router, passes: usize, rip_per_net: usize, rip_global: usize) -> (Vec<Value>, Value, usize) {+    let mut best: Option<(usize, Snapshot, usize)> = None;+    let mut priority: Vec<String> = Vec::new();+    for k in 1..=passes {+        if k > 1 {+            router.reset();+        }+        router.log(format!("=== pass {k} of {passes} ==="));+        let failed = router.run(true, rip_per_net, rip_global, &priority);+        let mut names: Vec<String> = router.nets.iter().filter(|n| !n.failed.is_empty()).map(|n| n.name.clone()).collect();+        names.sort();+        names.dedup();+        router.log(format!("=== pass {k}: {failed} failed connections in {names:?}"));+        if best.as_ref().map(|b| failed < b.0).unwrap_or(true) {+            best = Some((failed, router.snapshot(), k));+        }+        if failed == 0 {+            break;+        }+        priority = names;+    }+    let (failed, snap, k) = best.unwrap();+    router.restore(&snap);+    router.log(format!("keeping pass {k} ({failed} failed connections)"));+    let plan = router.plan();+    let report = router.report();+    (plan, report, failed)+}++#[allow(dead_code)]+fn _unused(_: HashMap<u8, u8>) {}
crates/aiflow-router/src/lib.rsadded+800
@@ -0,0 +1,800 @@+//! adom-aiflow's routing engine: a grid maze router for any KiCad 10 board.+//!+//! 0.1 mm grid over the outline, two signal layers (F.Cu, B.Cu), through vias between them+//! (0.6/0.3 mm for signals, 0.8/0.4 on the wide power nets), the board's rules plus every per-pad+//! clearance override. A* per connection (octilinear, +cost per via and per turn, F.Cu preferred),+//! obstacles per layer for the net being routed (every other net's copper dilated by clearance+//! plus half the track width), narrow pins entered on their axis through a reserved escape stub,+//! same-net overlapping pads clustered, each net routed as a minimum spanning tree onto its own+//! copper, shortest nets first, plane nets stubbed to a via, rip-up with history cost when a+//! connection fails, multi-pass with the failed nets first, and Kelvin sense taps routed first as+//! dedicated traces that the rest of their net treats as foreign copper.+//!+//! Output: a plan, a list of `kicad_route_net` calls ({net, width, viaSize, viaDrill, paths}).+pub mod drive;+pub mod rules;++use std::collections::{HashMap, HashSet};++use aiflow_board::{geom, Board, Pad, Pt, B_CU, F_CU};+use aiflow_grid::{Grid, Mask, RES};++pub use rules::Rules;++pub const COST_ORTH: i64 = 10;+pub const COST_DIAG: i64 = 14;+pub const COST_VIA: i64 = 250;+pub const COST_TURN: i64 = 4;+pub const LAYER_MULT: [f64; 2] = [1.0, 1.1];+pub const SOFT_EXTRA: i64 = 80;+pub const ZONE_COST: i64 = 20;+pub const VIA_WALL: i64 = 300;+pub const HIST_COST: i64 = 12;+pub const DIRS: [(i32, i32); 8] = [(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1)];+pub const STEP: [i64; 8] = [10, 10, 10, 10, 14, 14, 14, 14];+pub const INF: i64 = 1 << 40;++/// Half a cell diagonal: pads and stubs are off-grid. (Adding a margin here crosses a cell+/// boundary in the dilation radius and chokes the 0.5 mm-pitch escapes; a net that lands a hair+/// under the rule gets a per-net clearance from the spec instead: netClearance.)+pub fn slop() -> f64 {+    0.05 * std::f64::consts::SQRT_2 + 1e-6+}++#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]+pub struct Term {+    pub l: u8,+    pub i: i32,+    pub j: i32,+}++impl Term {+    pub fn new(l: usize, i: i64, j: i64) -> Term {+        Term { l: l as u8, i: i as i32, j: j as i32 }+    }+    pub fn layer(&self) -> usize {+        self.l as usize+    }+}++#[derive(Clone, Debug)]+pub struct Cluster {+    pub idx: usize,+    pub pads: Vec<usize>,+    pub primary: usize,+    pub terminals: Vec<Term>,+    pub escapes: HashMap<Term, Pt>,+    pub corridors: HashMap<Term, Pt>,+    pub connected: bool,+    pub allow: f64,+    pub released: HashSet<Term>,+}++impl Cluster {+    pub fn key<'a>(&self, board: &'a Board) -> &'a str {+        &board.pads[self.primary].key+    }+    pub fn xy(&self, board: &Board) -> Pt {+        (board.pads[self.primary].x, board.pads[self.primary].y)+    }+}++#[derive(Clone, Debug)]+pub enum End {+    Pad(usize, Term),+    Escape(usize, Term),+    Copper,+    Via,+}++impl End {+    pub fn cluster(&self) -> Option<usize> {+        match self {+            End::Pad(c, _) | End::Escape(c, _) => Some(*c),+            _ => None,+        }+    }+    pub fn term(&self) -> Option<Term> {+        match self {+            End::Pad(_, t) | End::Escape(_, t) => Some(*t),+            _ => None,+        }+    }+}++#[derive(Clone, Debug)]+pub struct Route {+    pub cells: Vec<Term>,+    pub width: f64,+    pub start: End,+    pub end: End,+    pub breaks: HashSet<usize>,+    pub via_end: bool,+    pub via_end_layer: usize,+    pub kelvin: bool,+}++impl Route {+    pub fn new(cells: Vec<Term>, width: f64, start: End, end: End) -> Route {+        Route { cells, width, start, end, breaks: HashSet::new(), via_end: false, via_end_layer: B_CU, kelvin: false }+    }+}++#[derive(Clone, Debug)]+pub struct NetState {+    pub idx: usize,+    pub name: String,+    pub pads: Vec<usize>,+    pub clusters: Vec<Cluster>,+    pub routes: Vec<Route>,+    pub widths: Vec<f64>,+    pub plane: Option<String>,+    pub mst_len: f64,+    pub used_escapes: HashSet<(usize, Term)>,+    pub failed: Vec<(String, String)>,+    pub rips: usize,+    pub routed_order: Option<usize>,+    pub via_in_pad: Vec<String>,+    pub last_soft: Vec<Term>,+    pub via: f64,+    pub drill: f64,+}++#[derive(Clone)]+pub struct Snapshot {+    pub nets: Vec<(Vec<Route>, HashSet<(usize, Term)>, Vec<(String, String)>, Vec<String>, Option<usize>)>,+}++pub struct Router {+    pub board: Board,+    pub grid: Grid,+    pub rules: Rules,+    pub verbose: bool,+    pub nets: Vec<NetState>,+    pub net_ids: HashMap<String, usize>,+    pub pad_raw: [Vec<i16>; 2],+    pub pad_owner: [Vec<i16>; 2],+    pub route_raw: [Vec<i16>; 2],+    pub big_clr: [Mask; 2],+    pub big_clr_val: f64,+    pub smd_pad_cells: HashMap<usize, Mask>,+    pub thru_cells: Mask,+    pub history: [Vec<i16>; 2],+    pub chan_count: Vec<i16>,+    pub chan_lines: HashMap<(usize, usize, Term), Vec<usize>>,+    pub vias: Vec<(i32, i32, usize, f64, f64)>,+    pub fp_index: HashMap<String, usize>,+    pub pad_cells: HashMap<String, Vec<usize>>,+    pub inside: Mask,+    pub edge_dist: Vec<f32>,+    pub inside_w: HashMap<i64, Mask>,+    pub rip_total: usize,+    pub stats: HashMap<String, i64>,+    pub kelvin_owner: HashMap<usize, usize>,+    pub kelvin_clusters: HashSet<(usize, usize)>,+    pub body_owner: Vec<i16>,+    pub ic_zone: Mask,+    pub ic_nets: HashMap<usize, HashSet<usize>>,+    pub log_lines: Vec<String>,+}++impl Router {+    pub fn log(&mut self, s: String) {+        if self.verbose {+            eprintln!("{s}");+        }+        self.log_lines.push(s);+    }++    pub fn stat(&mut self, k: &str, d: i64) {+        *self.stats.entry(k.to_string()).or_insert(0) += d;+    }++    pub fn new(board: Board, rules: Rules, verbose: bool) -> Router {+        let grid = Grid::new(board.bbox);+        let n = grid.w * grid.h;+        let mut names: Vec<&String> = board.nets.keys().collect();+        names.sort();+        let mut nets = Vec::new();+        let mut net_ids = HashMap::new();+        for (k, name) in names.iter().enumerate() {+            net_ids.insert((*name).clone(), k);+            nets.push(NetState {+                idx: k, name: (*name).clone(), pads: board.nets[*name].clone(), clusters: Vec::new(), routes: Vec::new(),+                widths: vec![rules.track], plane: rules.planes.get(*name).cloned(), mst_len: 0.0, used_escapes: HashSet::new(), failed: Vec::new(),+                rips: 0, routed_order: None, via_in_pad: Vec::new(), last_soft: Vec::new(), via: rules.via_small, drill: rules.drill_small,+            });+        }+        let mut refs: Vec<&String> = board.footprints.keys().collect();+        refs.sort();+        let fp_index: HashMap<String, usize> = refs.iter().enumerate().map(|(i, r)| ((*r).clone(), i)).collect();+        let (inside, edge_dist) = grid.outline_distance(&board.outline);+        let mut r = Router {+            grid: grid.clone(), rules, verbose, nets, net_ids,+            pad_raw: [vec![-1; n], vec![-1; n]], pad_owner: [vec![-1; n], vec![-1; n]], route_raw: [vec![-1; n], vec![-1; n]],+            big_clr: [grid.mask(), grid.mask()], big_clr_val: 0.0, smd_pad_cells: HashMap::new(), thru_cells: grid.mask(),+            history: [vec![0; n], vec![0; n]], chan_count: vec![0; n], chan_lines: HashMap::new(), vias: Vec::new(), fp_index,+            pad_cells: HashMap::new(), inside, edge_dist, inside_w: HashMap::new(), rip_total: 0, stats: HashMap::new(), kelvin_owner: HashMap::new(), kelvin_clusters: HashSet::new(),+            body_owner: vec![-1; n], ic_zone: grid.mask(), ic_nets: HashMap::new(), log_lines: Vec::new(), board,+        };+        r.big_clr_val = r.rules.clearance;+        let t0 = std::time::Instant::now();+        r.stamp_pads();+        r.build_clusters();+        r.build_ic_zones();+        let count = r.nets.iter().filter(|n| n.clusters.len() >= 2 || n.plane.is_some()).count();+        r.log(format!("router: {} x {} cells, {} pads stamped, {} nets to route, init {:.1}s", r.grid.w, r.grid.h, r.board.pads.len(), count, t0.elapsed().as_secs_f64()));+        r+    }++    // -- stamping --------------------------------------------------------------------+    fn stamp_pads(&mut self) {+        let g = self.grid.clone();+        let pads: Vec<Pad> = self.board.pads.clone();+        for p in &pads {+            let nid = self.net_ids.get(&p.net).map(|&k| k as i16).unwrap_or(-1);+            let mut m = g.mask();+            for poly in &p.polys {+                g.poly_mask(poly, &mut m);+            }+            if let Some(d) = p.drill {+                for c in g.disk_cells((p.x, p.y), d / 2.0, None) {+                    m.bits[c] = true;+                }+            }+            let cells: Vec<usize> = m.bits.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect();+            self.pad_cells.insert(p.key.clone(), cells.clone());+            let layers: Vec<usize> = if p.thru { vec![F_CU, B_CU] } else if p.on_f { vec![F_CU] } else { vec![B_CU] };+            let fpi = self.fp_index[&p.reference] as i16;+            for &l in &layers {+                for &c in &cells {+                    self.pad_raw[l][c] = nid;+                    self.pad_owner[l][c] = fpi;+                    if p.clearance > self.rules.clearance {+                        self.big_clr[l].bits[c] = true;+                    }+                }+                if p.clearance > self.rules.clearance {+                    self.big_clr_val = self.big_clr_val.max(p.clearance);+                }+            }+            if !p.thru && nid >= 0 {+                let sm = self.smd_pad_cells.entry(nid as usize).or_insert_with(|| g.mask());+                for &c in &cells {+                    sm.bits[c] = true;+                }+            }+            if p.thru {+                for &c in &cells {+                    self.thru_cells.bits[c] = true;+                }+            }+        }+    }++    pub fn inside_for(&mut self, w: f64) -> &Mask {+        let key = (w * 1000.0).round() as i64;+        if !self.inside_w.contains_key(&key) {+            let mut m = self.inside.clone();+            let need = (self.rules.edge + w / 2.0 + slop()) as f32;+            for (b, d) in m.bits.iter_mut().zip(&self.edge_dist) {+                *b = *b && *d >= need;+            }+            self.inside_w.insert(key, m);+        }+        &self.inside_w[&key]+    }++    pub fn stamp_route(&mut self, net: usize, route: &Route, sign: i32) {+        let g = self.grid.clone();+        let nid = if sign > 0 { self.nets[net].idx as i16 } else { -1 };+        let cells = &route.cells;+        let mut k = 0;+        while k + 1 < cells.len() {+            let (l, i0, j0) = (cells[k].layer(), cells[k].i, cells[k].j);+            let mut m = k + 1;+            let (di, dj) = (cells[m].i - i0, cells[m].j - j0);+            while m + 1 < cells.len() && cells[m + 1].layer() == l && cells[m].layer() == l && (cells[m + 1].i - cells[m].i, cells[m + 1].j - cells[m].j) == (di, dj) {+                m += 1;+            }+            if cells[m].layer() == l {+                let a = g.to_xy(i0 as i64, j0 as i64);+                let b = g.to_xy(cells[m].i as i64, cells[m].j as i64);+                for c in g.segment_cells(a, b, route.width / 2.0, Some(1e-6)) {+                    self.route_raw[l][c] = nid;+                }+            }+            k = m;+        }+        for k in 1..cells.len() {+            if cells[k].layer() != cells[k - 1].layer() && (cells[k].i, cells[k].j) == (cells[k - 1].i, cells[k - 1].j) {+                self.stamp_via(cells[k].i, cells[k].j, net, sign);+            }+        }+        if route.via_end {+            let t = *cells.last().unwrap();+            self.stamp_via(t.i, t.j, net, sign);+        }+    }++    fn stamp_via(&mut self, i: i32, j: i32, net: usize, sign: i32) {+        let g = self.grid.clone();+        let (via, drill, nid) = (self.nets[net].via, self.nets[net].drill, self.nets[net].idx);+        let xy = g.to_xy(i as i64, j as i64);+        for c in g.disk_cells(xy, via / 2.0, Some(1e-6)) {+            for l in 0..2 {+                self.route_raw[l][c] = if sign > 0 { nid as i16 } else { -1 };+            }+        }+        if sign > 0 {+            self.vias.push((i, j, nid, via, drill));+        } else {+            self.vias.retain(|v| !(v.0 == i && v.1 == j && v.2 == nid));+        }+    }++    /// Centre-to-centre distance this net's via must keep from a via of (nid, dia, drill).+    pub fn via_gap(&self, net: usize, nid: usize, dia: f64, drill: f64) -> f64 {+        let n = &self.nets[net];+        if nid == n.idx {+            (n.drill + drill) / 2.0 + self.rules.hole_to_hole + slop()+        } else {+            n.via / 2.0 + dia / 2.0 + self.rules.clearance + slop()+        }+    }++    pub fn stamp_stub(&mut self, net: usize, cluster: usize, term: Term, sign: i32) {+        let g = self.grid.clone();+        let l = term.layer();+        let cl = self.nets[net].clusters[cluster].clone();+        let p = self.board.pads[cl.primary].clone();+        let a = p.polys_centre;+        let b = g.to_xy(term.i as i64, term.j as i64);+        let nid = if sign > 0 { self.nets[net].idx as i16 } else { -1 };+        let fpi = self.fp_index[&p.reference] as i16;+        for c in g.segment_cells(a, b, self.rules.stub / 2.0, Some(1e-6)) {+            self.pad_raw[l][c] = nid;+            if sign > 0 {+                self.pad_owner[l][c] = fpi;+            }+        }+        if let Some(kend) = cl.corridors.get(&term) {+            for c in g.segment_cells(b, *kend, self.rules.track / 2.0, Some(1e-6)) {+                self.pad_raw[l][c] = nid;+                if sign > 0 {+                    self.pad_owner[l][c] = fpi;+                }+            }+        }+        self.stamp_channel(net, cluster, term, sign);+        if sign < 0 {+            let cells = self.pad_cells[&p.key].clone();+            let real = self.nets[net].idx as i16;+            for c in cells {+                self.pad_raw[l][c] = real;+                self.pad_owner[l][c] = fpi;+            }+        }+    }++    fn stamp_channel(&mut self, net: usize, cluster: usize, term: Term, sign: i32) {+        let key = (self.nets[net].idx, cluster, term);+        if sign > 0 && self.chan_lines.contains_key(&key) {+            return;+        }+        if sign < 0 {+            if let Some(cells) = self.chan_lines.remove(&key) {+                for c in cells {+                    self.chan_count[c] -= 1;+                }+            }+            return;+        }+        let g = self.grid.clone();+        let cl = &self.nets[net].clusters[cluster];+        let p = &self.board.pads[cl.primary];+        let b = g.to_xy(term.i as i64, term.j as i64);+        let (ax, ay) = p.long_axis;+        let (cx, cy) = p.polys_centre;+        let sgn = if (b.0 - cx) * ax + (b.1 - cy) * ay > 0.0 { 1.0 } else { -1.0 };+        let rax = (ax * 1e6).round() / 1e6;+        let ray = (ay * 1e6).round() / 1e6;+        let cend = (b.0 + sgn * rax * self.rules.channel, b.1 + sgn * ray * self.rules.channel);+        let r = self.nets[net].via / 2.0 + self.rules.clearance + self.rules.track / 2.0 + slop();+        let via = self.nets[net].via;+        let cells: Vec<usize> = g.segment_cells(b, cend, r, Some(0.0));+        let inside = self.inside_for(via).clone();+        let cells: Vec<usize> = cells.into_iter().filter(|&c| inside.bits[c]).collect();+        for &c in &cells {+            self.chan_count[c] += 1;+        }+        self.chan_lines.insert(key, cells);+    }++    // -- clusters and terminals ----------------------------------------------------+    fn build_clusters(&mut self) {+        let nnets = self.nets.len();+        for net in 0..nnets {+            if self.nets[net].pads.len() < 2 && self.nets[net].plane.is_none() {+                continue;+            }+            let mut by_fp: Vec<(String, Vec<usize>)> = Vec::new();+            for &pi in &self.nets[net].pads.clone() {+                let r = self.board.pads[pi].reference.clone();+                if let Some(e) = by_fp.iter_mut().find(|(k, _)| *k == r) {+                    e.1.push(pi);+                } else {+                    by_fp.push((r, vec![pi]));+                }+            }+            let mut clusters: Vec<Cluster> = Vec::new();+            for (_, pads) in by_fp {+                let mut groups: Vec<(Vec<usize>, HashSet<usize>)> = Vec::new();+                for pi in pads {+                    let mask: HashSet<usize> = self.pad_cells[&self.board.pads[pi].key].iter().copied().collect();+                    let mut merged: Option<usize> = None;+                    let mut gi = 0;+                    while gi < groups.len() {+                        if !groups[gi].1.is_disjoint(&mask) {+                            if let Some(m) = merged {+                                let (gp, gc) = groups.remove(gi);+                                groups[m].0.extend(gp);+                                groups[m].1.extend(gc);+                                continue;+                            } else {+                                groups[gi].0.push(pi);+                                groups[gi].1.extend(mask.iter().copied());+                                merged = Some(gi);+                            }+                        }+                        gi += 1;+                    }+                    if merged.is_none() {+                        groups.push((vec![pi], mask));+                    }+                }+                for (gpads, _) in groups {+                    let primary = *gpads.iter().max_by(|&&a, &&b| {+                        let pa = &self.board.pads[a];+                        let pb = &self.board.pads[b];+                        (pa.half_long * pa.half_short).partial_cmp(&(pb.half_long * pb.half_short)).unwrap()+                    }).unwrap();+                    clusters.push(Cluster { idx: clusters.len(), pads: gpads, primary, terminals: Vec::new(), escapes: HashMap::new(), corridors: HashMap::new(), connected: false, allow: 0.25, released: HashSet::new() });+                }+            }+            self.nets[net].clusters = clusters;+            let name = self.nets[net].name.clone();+            if let Some(w) = self.rules.wide.get(&name) {+                self.nets[net].widths = w.clone();+                self.nets[net].via = self.rules.via;+                self.nets[net].drill = self.rules.drill;+            } else if let Some(w) = self.rules.mid.get(&name) {+                self.nets[net].widths = w.clone();+            }+            let count = self.nets[net].clusters.len();+            for c in 0..count {+                self.terminals_for(net, c);+            }+        }+        // MOSFET drain and source nets get the wide treatment too+        for p in self.board.pads.clone() {+            if p.reference.starts_with('Q') && matches!(p.name.as_str(), "1" | "2" | "3" | "5") {+                if let Some(&n) = self.net_ids.get(&p.net) {+                    if self.nets[n].plane.is_none() && !self.rules.wide.contains_key(&p.net) {+                        self.nets[n].widths = vec![1.0, 0.5, 0.25];+                        self.nets[n].via = self.rules.via;+                        self.nets[n].drill = self.rules.drill;+                    }+                }+            }+        }+    }++    fn build_ic_zones(&mut self) {+        let g = self.grid.clone();+        let mut by_fp: HashMap<String, Vec<(usize, usize)>> = HashMap::new();+        for (n, net) in self.nets.iter().enumerate() {+            for (c, cl) in net.clusters.iter().enumerate() {+                if !cl.escapes.is_empty() {+                    by_fp.entry(self.board.pads[cl.primary].reference.clone()).or_default().push((n, c));+                }+            }+        }+        let mut refs: Vec<String> = by_fp.keys().cloned().collect();+        refs.sort();+        for r in refs {+            let clusters = &by_fp[&r];+            if clusters.len() < 2 {+                continue;+            }+            let fp = &self.board.footprints[&r];+            let mut inner = Vec::new();+            for &(n, c) in clusters {+                let p = &self.board.pads[self.nets[n].clusters[c].primary];+                let (ax, ay) = p.long_axis;+                let (cx, cy) = p.polys_centre;+                let sgn = if (fp.x - cx) * ax + (fp.y - cy) * ay > 0.0 { 1.0 } else { -1.0 };+                inner.push((cx + sgn * ax * p.half_long, cy + sgn * ay * p.half_long));+            }+            let (x0, y0, x1, y1) = geom::bbox(&inner);+            if x1 - x0 < 1.0 || y1 - y0 < 1.0 {+                continue;+            }+            let (i0, j0) = g.to_cell(x0, y0);+            let (i1, j1) = g.to_cell(x1, y1);+            let idx = self.fp_index[&r] as i16;+            for j in j0.max(0)..=j1.min(g.h as i64 - 1) {+                for i in i0.max(0)..=i1.min(g.w as i64 - 1) {+                    self.body_owner[j as usize * g.w + i as usize] = idx;+                }+            }+            let pads: Vec<&Pad> = self.board.pads.iter().filter(|q| q.reference == r).collect();+            let nets: HashSet<usize> = pads.iter().filter_map(|q| self.net_ids.get(&q.net).copied()).collect();+            let m = (self.rules.ic_zone / RES).round() as i64;+            let (px0, py0, px1, py1) = geom::bbox(&pads.iter().map(|q| (q.x, q.y)).collect::<Vec<_>>());+            let (a0, b0) = g.to_cell(px0, py0);+            let (a1, b1) = g.to_cell(px1, py1);+            for j in (b0 - m).max(0)..=(b1 + m).min(g.h as i64 - 1) {+                for i in (a0 - m).max(0)..=(a1 + m).min(g.w as i64 - 1) {+                    self.ic_zone.bits[j as usize * g.w + i as usize] = true;+                }+            }+            let foreign = self.nets.len() - nets.len();+            self.ic_nets.insert(idx as usize, nets);+            self.log(format!("  ic {}: body keepout {:.1} x {:.1} mm for {} foreign nets, escape band +{}/cell", r, (i1 - i0) as f64 * RES, (j1 - j0) as f64 * RES, foreign, ZONE_COST));+        }+    }++    fn terminals_for(&mut self, net: usize, c: usize) {+        let g = self.grid.clone();+        let primary = self.nets[net].clusters[c].primary;+        let p = self.board.pads[primary].clone();+        let md = p.min_dim();+        self.nets[net].clusters[c].allow = if md >= 1.0 { 1.0 } else if md >= 0.5 { 0.5 } else { 0.25 };+        if p.thru {+            let (i, j) = g.to_cell(p.x, p.y);+            self.nets[net].clusters[c].terminals = vec![Term::new(F_CU, i, j), Term::new(B_CU, i, j)];+            return;+        }+        let l = if p.on_f { F_CU } else { B_CU };+        let narrow = md < self.rules.narrow && p.half_long > p.half_short * 1.4;+        if !narrow {+            let (i, j) = g.to_cell(p.x, p.y);+            self.nets[net].clusters[c].terminals = vec![Term::new(l, i, j)];+            return;+        }+        self.nets[net].clusters[c].allow = 0.25;+        let (cx, cy) = p.polys_centre;+        let (ax, ay) = p.long_axis;+        let rax = (ax * 1e6).round() / 1e6;+        let ray = (ay * 1e6).round() / 1e6;+        let mut found: Vec<(Term, Option<Pt>)> = Vec::new();+        for sgn in [1.0f64, -1.0] {+            let mut hit: Option<(Term, Option<Pt>)> = None;+            for corridor in [self.rules.corridor, self.rules.corridor / 2.0, 0.0] {+                let mut d = self.rules.escape;+                while d <= 1.6 && hit.is_none() {+                    let ex = cx + sgn * ax * (p.half_long + d);+                    let ey = cy + sgn * ay * (p.half_long + d);+                    let (i, j) = g.to_cell(ex, ey);+                    let (bx, by) = g.to_xy(i, j);+                    let kend = (bx + sgn * rax * corridor, by + sgn * ray * corridor);+                    if g.in_bounds(i, j) && self.escape_ok(net, &p, l, i, j, kend) {+                        hit = Some((Term::new(l, i, j), if corridor > 0.0 { Some(kend) } else { None }));+                    }+                    d += 0.1;+                }+                if hit.is_some() {+                    break;+                }+            }+            if let Some(h) = hit {+                found.push(h);+            }+        }+        if found.is_empty() {+            let (i, j) = g.to_cell(p.x, p.y);+            self.nets[net].clusters[c].terminals = vec![Term::new(l, i, j)];+            return;+        }+        self.nets[net].clusters[c].terminals = found.iter().map(|(t, _)| *t).collect();+        for (t, kend) in found {+            let xy = g.to_xy(t.i as i64, t.j as i64);+            self.nets[net].clusters[c].escapes.insert(t, xy);+            if let Some(k) = kend {+                self.nets[net].clusters[c].corridors.insert(t, k);+            }+            self.stamp_stub(net, c, t, 1);+        }+    }++    fn escape_ok(&mut self, net: usize, p: &Pad, l: usize, i: i64, j: i64, corridor_end: Pt) -> bool {+        let g = self.grid.clone();+        let track = self.rules.track;+        if !self.inside_for(track).get(i as usize, j as usize) {+            return false;+        }+        let b = g.to_xy(i, j);+        let a = p.polys_centre;+        let mut checks = vec![(a, b, self.rules.stub / 2.0)];+        if (b.0 - corridor_end.0).hypot(b.1 - corridor_end.1) > 1e-6 {+            let (ki, kj) = g.to_cell(corridor_end.0, corridor_end.1);+            if !g.in_bounds(ki, kj) || !self.inside_for(track).get(ki as usize, kj as usize) {+                return false;+            }+            checks.push((b, corridor_end, track / 2.0));+        }+        let mut samples: Vec<(f64, f64, f64)> = Vec::new();+        for (s0, s1, hw) in checks {+            let n = (((s1.0 - s0.0).hypot(s1.1 - s0.1)) / 0.05).ceil().max(1.0) as usize;+            for k in 0..=n {+                let t = k as f64 / n as f64;+                samples.push((s0.0 + (s1.0 - s0.0) * t, s0.1 + (s1.1 - s0.1) * t, hw));+            }+        }+        samples.push((b.0, b.1, track / 2.0));+        let reach = 2.5;+        let net_name = self.nets[net].name.clone();+        for q in &self.board.pads {+            if q.reference == p.reference || q.net == net_name {+                continue;+            }+            if (q.x - b.0).abs() > reach + q.half_long || (q.y - b.1).abs() > reach + q.half_long {+                continue;+            }+            if l == F_CU && !q.on_f {+                continue;+            }+            let clr = self.rules.clearance.max(q.clearance);+            for poly in &q.polys {+                for &(x, y, hw) in &samples {+                    if geom::point_poly_distance(x, y, poly) < hw + clr {+                        return false;+                    }+                }+            }+        }+        true+    }++    // -- obstacle maps ---------------------------------------------------------------+    /// (obs_o, obs_d, soft, via_ok) for the window (i0, j0, i1, j1 inclusive), each a Mask of window size.+    pub fn obstacle_maps(&mut self, net: usize, width: f64, win: (i64, i64, i64, i64), soft: bool) -> ([Mask; 2], [Mask; 2], [Option<Mask>; 2], Mask) {+        let g = self.grid.clone();+        let (i0, j0, i1, j1) = win;+        let clr = self.rules.clearance.max(self.rules.net_clearance.get(&self.nets[net].name).copied().unwrap_or(0.0));+        let r_o = (clr + width / 2.0 + slop()) / RES;+        let r_d = r_o + 0.0707 / RES;+        let r_big = (self.big_clr_val + width / 2.0 + slop()) / RES;+        let pad_r = (r_d.max(r_big)).ceil() as i64 + 1;+        let (pi0, pj0) = ((i0 - pad_r).max(0), (j0 - pad_r).max(0));+        let (pi1, pj1) = ((i1 + pad_r).min(g.w as i64 - 1), (j1 + pad_r).min(g.h as i64 - 1));+        let ww = (pi1 - pi0 + 1) as usize;+        let hh = (pj1 - pj0 + 1) as usize;+        let sub = Grid { res: g.res, x0: 0.0, y0: 0.0, w: ww, h: hh };+        let nid = self.nets[net].idx as i16;+        let via = self.nets[net].via;+        let mut via_block = Mask::new(ww, hh);+        let mut obs_o: [Mask; 2] = [Mask::new(1, 1), Mask::new(1, 1)];+        let mut obs_d: [Mask; 2] = [Mask::new(1, 1), Mask::new(1, 1)];+        let mut softm: [Option<Mask>; 2] = [None, None];+        let inside_w = self.inside_for(width).clone();+        let inside_via = self.inside_for(via).clone();+        for l in 0..2 {+            let mut other_pads = Mask::new(ww, hh);+            let mut other_routes = Mask::new(ww, hh);+            let mut big = Mask::new(ww, hh);+            for jj in 0..hh {+                let base = (pj0 as usize + jj) * g.w + pi0 as usize;+                for ii in 0..ww {+                    let c = base + ii;+                    let pr = self.pad_raw[l][c];+                    let rr = self.route_raw[l][c];+                    let op = pr != -1 && pr != nid;+                    let orr = rr != -1 && rr != nid;+                    other_pads.bits[jj * ww + ii] = op;+                    other_routes.bits[jj * ww + ii] = orr;+                    big.bits[jj * ww + ii] = self.big_clr[l].bits[c] && op;+                }+            }+            let mut hard = other_pads.clone();+            if !soft {+                hard.or_with(&other_routes);+            }+            let mut o = sub.dilate(&hard, r_o);+            o.or_with(&sub.dilate(&big, r_big));+            let mut d = sub.dilate(&hard, r_d);+            d.or_with(&sub.dilate(&big, r_big + 0.0707 / RES));+            for jj in 0..hh {+                for ii in 0..ww {+                    let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+                    if !inside_w.bits[c] {+                        o.bits[jj * ww + ii] = true;+                    }+                }+            }+            if l == F_CU && !self.ic_nets.is_empty() {+                let foreign: Vec<i16> = self.ic_nets.iter().filter(|(_, nets)| !nets.contains(&net)).map(|(k, _)| *k as i16).collect();+                if !foreign.is_empty() {+                    for jj in 0..hh {+                        for ii in 0..ww {+                            let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+                            let bo = self.body_owner[c];+                            if bo != -1 && foreign.contains(&bo) {+                                o.bits[jj * ww + ii] = true;+                                d.bits[jj * ww + ii] = true;+                            }+                        }+                    }+                }+            }+            let s = if soft { Some(sub.dilate(&other_routes, r_d)) } else { None };+            obs_o[l] = crop(&o, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+            obs_d[l] = crop(&d, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+            softm[l] = s.map(|m| crop(&m, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0));+            let mut hr = other_pads.clone();+            hr.or_with(&other_routes);+            via_block.or_with(&sub.dilate(&hr, (self.rules.clearance + via / 2.0 + slop()) / RES));+            via_block.or_with(&sub.dilate(&big, (self.big_clr_val + via / 2.0 + slop()) / RES));+        }+        if let Some(own) = self.smd_pad_cells.get(&(nid as usize)) {+            let mut m = Mask::new(ww, hh);+            for jj in 0..hh {+                for ii in 0..ww {+                    m.bits[jj * ww + ii] = own.bits[(pj0 as usize + jj) * g.w + pi0 as usize + ii];+                }+            }+            via_block.or_with(&sub.dilate(&m, (via / 2.0 + slop()) / RES));+        }+        {+            let mut m = Mask::new(ww, hh);+            for jj in 0..hh {+                for ii in 0..ww {+                    m.bits[jj * ww + ii] = self.thru_cells.bits[(pj0 as usize + jj) * g.w + pi0 as usize + ii];+                }+            }+            via_block.or_with(&sub.dilate(&m, (via / 2.0 + slop()) / RES));+        }+        // other vias: annular ring plus clearance for another net, hole to hole for this net+        let mut groups: HashMap<i64, Vec<(usize, usize)>> = HashMap::new();+        for &(vi, vj, vnid, dia, drill) in &self.vias {+            if vi as i64 >= pi0 && vi as i64 <= pi1 && vj as i64 >= pj0 && vj as i64 <= pj1 {+                let gap = self.via_gap(net, vnid, dia, drill);+                groups.entry((gap * 10000.0).round() as i64).or_default().push(((vj as i64 - pj0) as usize, (vi as i64 - pi0) as usize));+            }+        }+        for (gapk, cells) in groups {+            let mut vm = Mask::new(ww, hh);+            for (a, b) in cells {+                vm.bits[a * ww + b] = true;+            }+            via_block.or_with(&sub.dilate(&vm, gapk as f64 / 10000.0 / RES - 1e-6));+        }+        for jj in 0..hh {+            for ii in 0..ww {+                let c = (pj0 as usize + jj) * g.w + pi0 as usize + ii;+                if !inside_via.bits[c] {+                    via_block.bits[jj * ww + ii] = true;+                }+            }+        }+        let mut via_ok = crop(&via_block, ww, i0 - pi0, j0 - pj0, i1 - pi0, j1 - pj0);+        for b in via_ok.bits.iter_mut() {+            *b = !*b;+        }+        (obs_o, obs_d, softm, via_ok)+    }+}++/// Crop a window-sized mask (row stride ww) to the inclusive sub-window.+fn crop(m: &Mask, ww: usize, a: i64, b: i64, c: i64, d: i64) -> Mask {+    let w = (c - a + 1) as usize;+    let h = (d - b + 1) as usize;+    let mut out = Mask::new(w, h);+    for jj in 0..h {+        for ii in 0..w {+            out.bits[jj * w + ii] = m.bits[(b as usize + jj) * ww + a as usize + ii];+        }+    }+    out+}
crates/aiflow-router/src/rules.rsadded+70
@@ -0,0 +1,70 @@+//! The board rules the router works to. The defaults are KiCad's for a 4-layer board with 0.2 mm+//! clearance; the spec's config overrides them (wide nets, planes, Kelvin pairs, via sizes).+use std::collections::BTreeMap;++use serde::{Deserialize, Serialize};++#[derive(Clone, Debug, Serialize, Deserialize)]+#[serde(default)]+pub struct Rules {+    pub track: f64,+    pub clearance: f64,+    /// Per-net clearance overrides (spec "netClearance": {net: mm}), for a net the DRC caught a hair under the rule.+    pub net_clearance: std::collections::BTreeMap<String, f64>,+    pub via: f64,+    pub drill: f64,+    pub via_small: f64,+    pub drill_small: f64,+    pub edge: f64,+    pub stub: f64,+    pub narrow: f64,+    pub escape: f64,+    pub corridor: f64,+    pub ic_zone: f64,+    pub channel: f64,+    pub planes: BTreeMap<String, String>,+    pub wide: BTreeMap<String, Vec<f64>>,+    pub mid: BTreeMap<String, Vec<f64>>,+    pub hole_to_hole: f64,+    pub via_search_mm: f64,+    pub kelvin: BTreeMap<String, String>,+}++impl Default for Rules {+    fn default() -> Rules {+        Rules {+            track: 0.25, clearance: 0.2, net_clearance: Default::default(), via: 0.8, drill: 0.4, via_small: 0.6, drill_small: 0.3, edge: 0.5,+            stub: 0.2, narrow: 0.5, escape: 0.45, corridor: 0.5, ic_zone: 2.0, channel: 2.5,+            planes: BTreeMap::new(), wide: BTreeMap::new(), mid: BTreeMap::new(), hole_to_hole: 0.25, via_search_mm: 6.0, kelvin: BTreeMap::new(),+        }+    }+}++impl Rules {+    /// The router rules from a aiflow spec (planes, wideNets, midNets, kelvin, clearance, copper sizes).+    pub fn from_spec(spec: &serde_json::Value) -> Rules {+        let mut r = Rules::default();+        let get_map = |k: &str| -> BTreeMap<String, String> {+            spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_str()?.to_string()))).collect()).unwrap_or_default()+        };+        let get_widths = |k: &str| -> BTreeMap<String, Vec<f64>> {+            spec.get(k).and_then(|v| v.as_object()).map(|o| o.iter().filter_map(|(a, b)| Some((a.clone(), b.as_array()?.iter().filter_map(|x| x.as_f64()).collect()))).collect()).unwrap_or_default()+        };+        r.planes = get_map("planes");+        r.kelvin = get_map("kelvin");+        r.wide = get_widths("wideNets");+        r.mid = get_widths("midNets");+        let num = |k: &str| spec.get(k).and_then(|v| v.as_f64());+        if let Some(v) = num("clearance") { r.clearance = v; }+        if let Some(m) = spec.get("netClearance").and_then(|v| v.as_object()) {+            for (k, v) in m { if let Some(x) = v.as_f64() { r.net_clearance.insert(k.clone(), x); } }+        }+        if let Some(v) = num("edgeClearance") { r.edge = v; }+        if let Some(v) = num("track") { r.track = v; }+        if let Some(v) = num("viaSignal") { r.via_small = v; }+        if let Some(v) = num("drillSignal") { r.drill_small = v; }+        if let Some(v) = num("viaPower") { r.via = v; }+        if let Some(v) = num("drillPower") { r.drill = v; }+        r+    }+}
crates/aiflow-run/Cargo.tomladded+10
@@ -0,0 +1,10 @@+[package]+name = "aiflow-run"+version.workspace = true+edition.workspace = true+license.workspace = true+description = "adom-aiflow: run"++[dependencies]+serde = { workspace = true }+serde_json = { workspace = true }
crates/aiflow-run/src/lib.rsadded+437
@@ -0,0 +1,437 @@+//! The run manifest: one JSON file per run carrying the clock, the plan, the decisions, every+//! stage's start and end, the outcomes, the capture markers and the summary. Every command reads+//! and writes it, so the comparison between engines is what the file says.+use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++pub const STAGES: [&str; 9] = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"];++pub fn now() -> String {+    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    iso(t)+}++/// Seconds since the epoch as an ISO 8601 UTC timestamp.+pub fn iso(t: u64) -> String {+    let days = t / 86400;+    let rem = t % 86400;+    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);+    // civil from days (Howard Hinnant's algorithm)+    let z = days as i64 + 719468;+    let era = if z >= 0 { z } else { z - 146096 } / 146097;+    let doe = (z - era * 146097) as u64;+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+    let y = yoe as i64 + era * 400;+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+    let mp = (5 * doy + 2) / 153;+    let d = doy - (153 * mp + 2) / 5 + 1;+    let mth = if mp < 10 { mp + 3 } else { mp - 9 };+    let y = if mth <= 2 { y + 1 } else { y };+    format!("{y:04}-{mth:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")+}++/// ISO 8601 UTC timestamp to seconds since the epoch (whole seconds).+pub fn parse(ts: &str) -> Option<u64> {+    let b = ts.as_bytes();+    if b.len() < 19 {+        return None;+    }+    let n = |a: usize, l: usize| ts.get(a..a + l)?.parse::<i64>().ok();+    let (y, mo, d, h, mi, s) = (n(0, 4)?, n(5, 2)?, n(8, 2)?, n(11, 2)?, n(14, 2)?, n(17, 2)?);+    let y2 = if mo <= 2 { y - 1 } else { y };+    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;+    let yoe = y2 - era * 400;+    let mp = if mo > 2 { mo - 3 } else { mo + 9 };+    let doy = (153 * mp + 2) / 5 + d - 1;+    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;+    let days = era * 146097 + doe - 719468;+    Some((days * 86400 + h * 3600 + mi * 60 + s) as u64)+}++pub struct Run {+    pub dir: PathBuf,+    pub data: Value,+}++impl Run {+    pub fn open(dir: &Path) -> Option<Run> {+        let f = dir.join("run.json");+        let text = std::fs::read_to_string(&f).ok()?;+        Some(Run { dir: dir.to_path_buf(), data: serde_json::from_str(&text).ok()? })+    }++    pub fn create(dir: &Path, board: &str, spec: &str, engine: &str, prompt_time: &str, ai_thread: &str, target: Option<&str>, remote_board: Option<&str>) -> Result<Run, String> {+        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;+        let r = Run { dir: dir.to_path_buf(), data: json!({+            "version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": board, "spec": spec,+            "target": target, "remoteBoard": remote_board,+            "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},+            "plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [board],+            "outcomes": {}, "summary": null,+            "currentStep": null, "steps": {}, "turns": [], "delivery": null,+        }) };+        r.save()?;+        r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": board, "spec": spec, "promptTime": prompt_time, "target": target, "remoteBoard": remote_board}));+        Ok(r)+    }++    /// Append one event to run.jsonl, the append-only ledger: one line per event, never rewritten.+    /// run.json is the derived state; when the two disagree, the ledger is the record.+    pub fn log(&self, event: &str, mut fields: Value) {+        fields["t"] = json!(now());+        fields["event"] = json!(event);+        let line = fields.to_string() + "\n";+        let f = self.dir.join("run.jsonl");+        if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {+            use std::io::Write;+            let _ = fh.write_all(line.as_bytes());+        }+    }++    /// Every event in run.jsonl, in order.+    pub fn ledger(&self) -> Vec<Value> {+        std::fs::read_to_string(self.dir.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()+    }++    /// Write run.json. Two commands can run at once (a landing in the background while the AI+    /// declares a step or sets the page), so the write merges with what is on disk: a top-level+    /// key this process never had is kept from disk, and the ledger-like arrays (turns, markers,+    /// captures) are unioned by identity so neither writer drops the other's entries.+    pub fn save(&self) -> Result<(), String> {+        std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;+        let mut data = self.data.clone();+        if let Some(disk) = std::fs::read_to_string(self.dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {+            if let (Some(d), Some(m)) = (disk.as_object(), data.as_object_mut()) {+                for (k, v) in d {+                    if !m.contains_key(k) {+                        m.insert(k.clone(), v.clone());+                    }+                }+                for (key, id) in [("turns", "n"), ("markers", "t"), ("captures", "start")] {+                    let mine = m.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();+                    let theirs = d.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();+                    let ident = |e: &Value| format!("{}|{}", e[id], e["label"].as_str().or(e["recordingId"].as_str()).or(e["command"].as_str()).unwrap_or(""));+                    let mut merged: Vec<Value> = Vec::new();+                    let mut seen: Vec<String> = Vec::new();+                    for e in theirs.iter().chain(mine.iter()) {+                        let k = ident(e);+                        if let Some(pos) = seen.iter().position(|x| *x == k) {+                            // the later writer's version wins (it may carry the end / file / stop of the same entry)+                            merged[pos] = e.clone();+                        } else {+                            seen.push(k);+                            merged.push(e.clone());+                        }+                    }+                    merged.sort_by(|a, b| a[id].to_string().cmp(&b[id].to_string()));+                    if key == "turns" { merged.sort_by_key(|e| e["n"].as_u64().unwrap_or(0)); }+                    m.insert(key.to_string(), Value::Array(merged));+                }+            }+        }+        let tmp = self.dir.join("run.json.tmp");+        std::fs::write(&tmp, serde_json::to_string_pretty(&data).unwrap()).map_err(|e| e.to_string())?;+        std::fs::rename(&tmp, self.dir.join("run.json")).map_err(|e| e.to_string())+    }++    pub fn engine_for(&self, stage: &str) -> String {+        if let Some(e) = self.data["decisions"].get(stage).and_then(|v| v.as_str()) {+            return e.to_string();+        }+        self.data["plan"].get("stages").and_then(|s| s.get(stage)).and_then(|s| s.get("default")).and_then(|d| d.as_str()).unwrap_or("binary").to_string()+    }++    pub fn mark(&mut self, label: &str) {+        self.data["markers"].as_array_mut().unwrap().push(json!({"t": now(), "label": label}));+        if !label.starts_with("stage-") && !label.starts_with("step:") {+            self.log("marker", json!({"label": label}));+        }+    }++    pub fn stage_start(&mut self, name: &str, engine: Option<&str>, note: Option<&str>) {+        let engine = engine.map(str::to_string).unwrap_or_else(|| self.engine_for(name));+        let stages = self.data["stages"].as_object_mut().unwrap();+        let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));+        st["runs"].as_array_mut().unwrap().push(json!({"start": now(), "end": null, "engine": engine, "note": note, "result": null}));+        self.log("stage-start", json!({"stage": name, "engine": engine, "note": note}));+        self.mark(&format!("stage-start:{name}"));+    }++    pub fn stage_end(&mut self, name: &str, result: Option<Value>, note: Option<&str>) {+        let engine = self.engine_for(name);+        let stages = self.data["stages"].as_object_mut().unwrap();+        let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));+        let runs = st["runs"].as_array_mut().unwrap();+        if runs.last().map(|r| !r["end"].is_null()).unwrap_or(true) {+            runs.push(json!({"start": now(), "end": null, "engine": engine, "note": null, "result": null}));+        }+        let last = runs.last_mut().unwrap();+        last["end"] = json!(now());+        if let Some(r) = result {+            last["result"] = r;+        }+        if let Some(n) = note {+            last["note"] = json!(n);+        }+        let (res, started) = (last["result"].clone(), last["start"].clone());+        let secs = parse(&now()).unwrap_or(0).saturating_sub(started.as_str().and_then(parse).unwrap_or(0));+        self.log("stage-end", json!({"stage": name, "binarySeconds": secs, "result": res, "note": note}));+        self.mark(&format!("stage-end:{name}"));+    }++    pub fn current_board(&self) -> String {+        self.data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("").to_string()+    }++    pub fn add_board(&mut self, path: &str) {+        self.data["boards"].as_array_mut().unwrap().push(json!(path));+    }++    pub fn outcome(&mut self, key: &str, v: Value) {+        self.log("outcome", json!({"stage": key, "outcome": v}));+        self.data["outcomes"][key] = v;+    }++    pub fn outcomes(&self) -> &Map<String, Value> {+        static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();+        self.data["outcomes"].as_object().unwrap_or_else(|| EMPTY.get_or_init(Map::new))+    }++    // -- the ledger: every command is a turn, the gap before it is the AI's thinking ----------+    /// The step a command belongs to when the AI has not declared one with `step`.+    pub fn infer_step(command: &str) -> &'static str {+        let c = command.trim();+        let first = c.split_whitespace().next().unwrap_or("");+        let second = c.split_whitespace().nth(1).unwrap_or("");+        match (first, second) {+            ("start", _) | ("plan", _) | ("take", _) => "intake",+            ("place", _) => "placement",+            ("route", _) | ("gate", _) => "routing",+            ("land", "moves") => "placement",+            ("land", "route") | ("land", "vias") => "routing",+            ("pour", _) | ("land", "pours") | ("measure", _) => "pours",+            ("analyze", "current") => "current",+            ("analyze", "thermal") => "thermal",+            ("capture", _) => "capture",+            ("finish", _) | ("deliver", _) => "finish",+            _ => "other",+        }+    }++    /// Record the start of a command. The seconds since the previous turn ended (or since the+    /// prompt, for the first turn) are the AI's thinking time and are charged to this turn's step:+    /// the thinking that led to this command. Returns the turn index.+    pub fn turn_begin(&mut self, command: &str) -> usize {+        let t = now();+        let now_s = parse(&t).unwrap_or(0);+        let last_end = self.data["turns"].as_array().and_then(|a| a.last()).and_then(|l| l["end"].as_str().or(l["at"].as_str())).and_then(parse);+        let since = last_end.or_else(|| self.data["clock"]["promptTime"].as_str().and_then(parse)).map(|p| now_s.saturating_sub(p)).unwrap_or(0);+        // a `step X` command is charged to X: the thinking that chose the step belongs to it+        let declared = command.trim().strip_prefix("step ").and_then(|r| r.split_whitespace().next()).map(str::to_string);+        let step = declared.or_else(|| self.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| Run::infer_step(command).to_string());+        let n = self.data["turns"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+        if !self.data["turns"].is_array() {+            self.data["turns"] = json!([]);+        }+        self.data["turns"].as_array_mut().unwrap().push(json!({"n": n, "step": step, "command": command, "at": t, "thinkingSeconds": since, "end": null, "binarySeconds": null, "ok": null}));+        self.log("turn", json!({"n": n, "step": step, "command": command, "thinkingSeconds": since}));+        n - 1+    }++    pub fn turn_end(&mut self, idx: usize, ok: bool, first_line: &str) {+        let t = now();+        if let Some(turn) = self.data["turns"].as_array_mut().and_then(|a| a.get_mut(idx)) {+            let started = turn["at"].as_str().and_then(parse).unwrap_or(0);+            turn["end"] = json!(t);+            turn["binarySeconds"] = json!(parse(&t).unwrap_or(0).saturating_sub(started));+            turn["ok"] = json!(ok);+            turn["said"] = json!(first_line.chars().take(200).collect::<String>());+            let (n, step, bs) = (turn["n"].clone(), turn["step"].clone(), turn["binarySeconds"].clone());+            self.log("turn-end", json!({"n": n, "step": step, "binarySeconds": bs, "ok": ok, "said": first_line.chars().take(200).collect::<String>()}));+        }+    }++    /// The AI declares the step it is working on. `back` marks a return to an earlier step+    /// (rework found by a later step); `why` says what sent it back. Every command until the next+    /// `step` is charged to this step.+    pub fn step_set(&mut self, name: &str, back: bool, why: Option<&str>) {+        let t = now();+        if let Some(prev) = self.data["currentStep"].as_str().map(str::to_string) {+            if let Some(v) = self.data["steps"][&prev]["visits"].as_array_mut().and_then(|a| a.last_mut()) {+                if v["end"].is_null() {+                    v["end"] = json!(t);+                }+            }+        }+        if !self.data["steps"].is_object() {+            self.data["steps"] = json!({});+        }+        let st = self.data["steps"].as_object_mut().unwrap().entry(name.to_string()).or_insert(json!({"visits": []}));+        let n = st["visits"].as_array().map(|a| a.len()).unwrap_or(0) + 1;+        st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": t, "end": null, "back": back, "why": why}));+        self.data["currentStep"] = json!(name);+        self.log("step", json!({"step": name, "visit": n, "back": back, "why": why}));+        self.mark(&format!("step:{name}{}", if back { " (back)" } else { "" }));+    }++    /// Per step: AI thinking seconds, binary seconds, wall seconds, turns, visits, returns, and+    /// the longest single think. Wall = thinking + binary, so the sum over steps is the whole run.+    pub fn step_report(&self) -> Map<String, Value> {+        let mut m: Map<String, Value> = Map::new();+        if let Some(rep) = self.step_report_timeline() {+            return rep;+        }+        // the ledger is the record: rebuild the turns from run.jsonl when it exists+        let ledger = self.ledger();+        let mut turns: Vec<Value> = Vec::new();+        if !ledger.is_empty() {+            for e in &ledger {+                match e["event"].as_str() {+                    Some("turn") => turns.push(json!({"n": e["n"], "step": e["step"], "thinkingSeconds": e["thinkingSeconds"], "binarySeconds": 0})),+                    Some("turn-end") => {+                        if let Some(t) = turns.iter_mut().rev().find(|t| t["n"] == e["n"]) {+                            t["binarySeconds"] = e["binarySeconds"].clone();+                        }+                    }+                    _ => {}+                }+            }+        } else {+            turns = self.data["turns"].as_array().cloned().unwrap_or_default();+        }+        for t in turns {+            let step = t["step"].as_str().unwrap_or("other").to_string();+            let e = m.entry(step).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+            let th = t["thinkingSeconds"].as_u64().unwrap_or(0);+            let bi = t["binarySeconds"].as_u64().unwrap_or(0);+            e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + th);+            e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + bi);+            e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));+            e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);+            if th > e["longestThinkSeconds"].as_u64().unwrap_or(0) {+                e["longestThinkSeconds"] = json!(th);+            }+        }+        for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {+            let visits = st["visits"].as_array().cloned().unwrap_or_default();+            let e = m.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+            e["visits"] = json!(visits.len());+            e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());+            e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());+        }+        m+    }+++    /// The per-step report from the ledger's timeline: every second between the prompt and the+    /// last event is either tool time (at least one turn running) or thinking (no turn running),+    /// charged to the step current at that moment. Overlapping turns (a landing in the background+    /// while the AI declares the next step) count once, so the steps sum to the wall clock.+    pub fn step_report_timeline(&self) -> Option<Map<String, Value>> {+        let led = self.ledger();+        if led.is_empty() {+            return None;+        }+        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;+        // turns as intervals, and step changes as (time, step)+        let mut turns: Vec<(u64, u64, String, u64)> = Vec::new(); // start, end, step, n+        let mut open: Vec<(u64, String, u64)> = Vec::new();+        let mut steps: Vec<(u64, String)> = vec![(t0, "intake".to_string())];+        let mut last = t0;+        for e in &led {+            let t = e["t"].as_str().and_then(parse).unwrap_or(0);+            last = last.max(t);+            match e["event"].as_str() {+                Some("turn") => open.push((t, e["step"].as_str().unwrap_or("other").to_string(), e["n"].as_u64().unwrap_or(0))),+                Some("turn-end") => {+                    let n = e["n"].as_u64().unwrap_or(0);+                    if let Some(pos) = open.iter().position(|o| o.2 == n) {+                        let (s, st, _) = open.remove(pos);+                        turns.push((s, t.max(s), st, n));+                    }+                }+                Some("step") => steps.push((t, e["step"].as_str().unwrap_or("other").to_string())),+                _ => {}+            }+        }+        let 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);+        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 mut t = t0;+        while t < end {+            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) };+            let e = acc.entry(step.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+            if running.is_empty() {+                e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + 1);+                if think_run.0 == step { think_run.1 += 1; } else { think_run = (step.clone(), 1); }+                let l = longest.entry(step.clone()).or_insert(0);+                if think_run.1 > *l { *l = think_run.1; }+            } else {+                e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + 1);+                think_run = (String::new(), 0);+            }+            e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));+            t += 1;+        }+        for (_, _, st, _) in &turns {+            let e = acc.entry(st.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+            e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);+        }+        for (st, l) in longest { if let Some(e) = acc.get_mut(&st) { e["longestThinkSeconds"] = json!(l); } }+        for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {+            let visits = st["visits"].as_array().cloned().unwrap_or_default();+            let e = acc.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));+            e["visits"] = json!(visits.len());+            e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());+            e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());+        }+        Some(acc)+    }++    /// Minutes from the prompt to the delivery (the AI's "done, here is your video"), if delivered.+    pub fn delivered_minutes(&self) -> Option<f64> {+        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;+        let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;+        Some((t1.saturating_sub(t0)) as f64 / 60.0)+    }++    pub fn elapsed_minutes(&self) -> f64 {+        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);+        let t1 = self.data["clock"]["finishTime"].as_str().and_then(parse).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));+        ((t1 as f64 - t0 as f64) / 6.0).round() / 10.0+    }++    pub fn stage_minutes(&self) -> Map<String, Value> {+        let mut out = Map::new();+        if let Some(st) = self.data["stages"].as_object() {+            for (name, s) in st {+                let mut tot = 0.0;+                for r in s["runs"].as_array().cloned().unwrap_or_default() {+                    if let (Some(a), Some(b)) = (r["start"].as_str().and_then(parse), r["end"].as_str().and_then(parse)) {+                        tot += (b as f64 - a as f64) / 60.0;+                    }+                }+                out.insert(name.clone(), json!((tot * 10.0).round() / 10.0));+            }+        }+        out+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn iso_round_trips() {+        assert_eq!(iso(0), "1970-01-01T00:00:00Z");+        let t = 1_789_299_600; // some 2026 second+        assert_eq!(parse(&iso(t)), Some(t));+        assert_eq!(parse("2026-09-13T15:38:04Z").map(iso).as_deref(), Some("2026-09-13T15:38:04Z"));+    }+}
install.sh

  
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]"-  }-}\ No newline at end of file+  ]+}
uninstall.sh