← Commit history
crates/adom-aiflow/Cargo.tomldeleted−24
@@ -1,24 +0,0 @@-[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.rsdeleted−2465
@@ -1,2465 +0,0 @@-//! adom-aiflow: the board loop as one command, with the clock, the negotiation and the finish line.-//! Every reply starts with OK: or ERROR: and carries Hint: lines that depend on this board and run.-use std::collections::BTreeMap;-use std::path::{Path, PathBuf};--use aiflow_board::Board;-use aiflow_bridge::Bridge;-use aiflow_run::{now, Run, STAGES};-use clap::{Parser, Subcommand};-use serde_json::{json, Value};--const VERSION: &str = env!("CARGO_PKG_VERSION");--#[derive(Parser)]-#[command(name = "adom-aiflow", version = VERSION, about = "The board loop as one command: placement, routing, pours, gate, landing, measurement, analysis, finish line")]-struct Cli {-    /// The name of the AI conversation calling (required for state-changing commands)-    #[arg(long, global = true)]-    ai_thread: Option<String>,-    /// Run directory (default ./aiflow-run or $AIFLOW_RUN)-    #[arg(long, global = true)]-    run: Option<PathBuf>,-    #[command(subcommand)]-    cmd: Cmd,-}--#[derive(Subcommand)]-enum Cmd {-    /// Start a run: copies the board, stamps the prompt time-    Start { #[arg(long)] board: String, #[arg(long)] spec: String, #[arg(long)] engine: String, #[arg(long)] prompt_time: Option<String>, #[arg(long)] target: Option<String>, #[arg(long)] remote_board: Option<String>, #[arg(long)] force: bool },-    /// The stages this board needs and who can take each-    Plan,-    /// Record who takes a stage: route=ai pour=binary-    Take { decisions: Vec<String> },-    /// Stamp a stage you do yourself: start or end-    Stage { action: String, name: String, #[arg(long)] note: Option<String>, #[arg(long)] result: Option<String> },-    /// Placement helpers: pack (--wish), check (--moves), land (--moves)-    Place { what: String, #[arg(long)] wish: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value = "moves.json")] out: String, /// milliseconds between parts when landing: each part is its own undo step and its own moment on camera-    #[arg(long, default_value_t = 600)] pause_ms: u64 },-    /// The grid router on the current board-    Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },-    /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)-    Pour { #[arg(long)] plan: Option<String> },-    /// KiCad DRC offline on the routing plan-    Gate { #[arg(long)] plan: Option<String> },-    /// Replay live through the bridge: route, vias, pours, moves-    Land { what: String, #[arg(long)] plan: Option<String>, #[arg(long)] moves: Option<String>, #[arg(long, default_value_t = 600)] pause_ms: u64 },-    /// Filled copper per layer from the live board-    Measure,-    /// current or thermal-    Analyze { what: String, #[arg(long)] plan: Option<String> },-    /// The finish line; refuses until every check passes-    Finish,-    /// Declare the step you are working on (placement, routing, pours, current, thermal, capture, finish, or your own). --back marks a return to an earlier step; --why says what sent you back.-    Step { name: String, #[arg(long)] back: bool, #[arg(long)] why: Option<String> },-    /// The end of the run as the human sees it: the moment you say "done, here is your video". Needs finish first and the video file.-    Deliver { #[arg(long)] video: String, #[arg(long)] message: String, /// deliver even though a clip is flagged as having filmed nothing (say why in --message)-    #[arg(long)] accept_suspect: bool },-    /// The run's own README (the sub-README): steps, times, returns, clips and screenshots so far; --push puts it on a wiki page's repo under docs/runs/<id>/, --refresh reloads it in the human's browser. Runs by itself at every step change and at deliver once a page is set.-    Report { #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool, #[arg(long)] user_machine: Option<String>, /// where the human watches: "browser" (the native browser on --user-machine, through Adom Bridge) or "webview" (a tab in this Hydrogen workspace, for an AI running inside Adom Hydrogen)-    #[arg(long)] surface: Option<String> },-    /// Record an issue or PR your AI filed back on adom/adom-aiflow (or any page) during this run; deliver counts them.-    Giveback { url: String, #[arg(long)] kind: Option<String>, #[arg(long)] note: Option<String> },-    /// Re-cut every clip of the run (the 10x cut and the motion-only action cut with the real-time clock), for runs recorded before a cut existed or after the cut rules improved.-    Recut,-    /// Compose the run's final video from the raw clips: only the frames where something moved, in order, paced so the whole video fits --max-seconds (2 minutes by default); the AI's analysis drawings as fullscreen shots; the narration cut to fit each segment, never the other way round. Replaces the delivered video when the run is already delivered.-    Compose { #[arg(long, default_value = "final.mp4")] out: String, /// the longest the final video may be, in seconds-    #[arg(long, default_value_t = 120.0)] max_seconds: f64, /// seconds a fullscreen analysis drawing stays on screen-    #[arg(long, default_value_t = 5.0)] still_seconds: f64, /// another run to put beside this one: the side-by-side, this run on the left, step by step in flow order, both runs' own clocks and drawings-    #[arg(long)] with: Option<PathBuf>, /// for --with: a narration script (JSON: {"<step>": "words", "drawings:<step>": "words", "drawings:<step>:<n>": "words"}); the words lead and each segment's picture is paced to them, so there is no dead air-    #[arg(long)] script: Option<PathBuf> },-    /// Register a file the AI made during the current step (an analysis drawing, a plot): it goes on the run page under the step and, for images, into the final video as a fullscreen shot at that point of the step.-    Artifact { #[arg(long)] file: String, #[arg(long, default_value = "analysis-image")] kind: String, #[arg(long)] caption: String, #[arg(long)] step: Option<String>, /// an earlier artifact (its file path) this one replaces: the redone drawing takes its place on the page and in the video-    #[arg(long)] replaces: Option<String> },-    /// Snapshot the engine's plan usage (percent used per limit window) into the ledger now; the binary also does this at start, every step, finish and deliver.-    Usage,-    /// Check every footprint's 3D model on the live board (kicad_model_check): the models step. ERROR lists what is unresolved so the AI can fetch or build the models before placement.-    Models,-    /// Walkthroughs on camera: `tour 3d` opens the 3D Viewer and runs the scripted tour as this step's clip; `tour nets` selects each key net (loads, wide nets, Kelvin) so the editor highlights its pours and traces together, frames it, and marks the clip.-    Tour { what: String, #[arg(long)] nets: Option<String>, /// for `tour fields`: the port adom-fields serves on in this container-    #[arg(long, default_value_t = 8874)] port: u16, /// for `tour fields`: the desktop whose pup window is filmed (default: the run's test box)-    #[arg(long)] on: Option<String> },-    /// Print the append-only ledger (run.jsonl): every turn with its thinking and tool seconds, every step visit and return, every stage and outcome, in order.-    Ledger { #[arg(long)] json: bool },-    /// open, start, mark <label>, stop-    Capture { action: String, label: Option<String>, #[arg(long)] reason: Option<String> },-    /// The comparison page: a sub-README for this run and another (`compose --with` first), the side-by-side video first, then the numbers from both ledgers, the AIs' drawings side by side and the links to both run pages; --push puts it on the page under docs/comparisons/<a>-vs-<b>/-    Compare { #[arg(long)] with: PathBuf, #[arg(long)] page: Option<String>, #[arg(long)] push: bool, #[arg(long)] refresh: bool },-    /// Where the run stands-    Status,-    /// A new human prompt on this run (a follow-up: "add the walkthroughs", "recut the video"): the AI's time for it counts from here to its `done`. --at is the paste time when you start late.-    Prompt { #[arg(long)] text: String, #[arg(long)] at: Option<String> },-    /// The AI's answer to the current prompt is complete ("done, here is ..."): the session's clock stops here. `deliver` is the done of the first task.-    Done { #[arg(long)] message: String },-    /// The run as sessions: each human prompt, the AI's active time on it, the idle gaps cut out (a gap over --idle-minutes with no command running is the human away, not the AI thinking).-    Sessions { #[arg(long)] json: bool },-}--static TURN: std::sync::OnceLock<(PathBuf, usize)> = std::sync::OnceLock::new();--/// Close the ledger entry for this command (see Run::turn_begin).-fn close_turn(ok_: bool, first_line: &str) {-    if let Some((dir, idx)) = TURN.get() {-        if let Some(mut r) = Run::open(dir) {-            r.turn_end(*idx, ok_, first_line);-            let _ = r.save();-        }-    }-}--fn ok(msg: &str, hints: &[String]) {-    close_turn(true, msg.lines().next().unwrap_or(""));-    println!("OK: {msg}");-    for h in hints {-        if !h.is_empty() {-            println!("Hint: {h}");-        }-    }-}--fn err(msg: &str, hints: &[String]) -> ! {-    close_turn(false, msg.lines().next().unwrap_or(""));-    println!("ERROR: {msg}");-    for h in hints {-        if !h.is_empty() {-            println!("Hint: {h}");-        }-    }-    std::process::exit(2)-}--fn thread(cli: &Cli) -> String {-    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state and needs --ai-thread <name> (the name of the AI conversation calling)", &["Re-run with --ai-thread \"<your thread name>\".".into()]))-}--fn run_dir(cli: &Cli) -> PathBuf {-    cli.run.clone().unwrap_or_else(|| std::env::var("AIFLOW_RUN").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("./aiflow-run")))-}--fn load_run(cli: &Cli) -> Run {-    let d = run_dir(cli);-    Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()]))-}--fn read_json(p: &str) -> Value {-    serde_json::from_str(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{p}: not JSON: {e}"), &[]))-}--fn spec_of(r: &Run) -> Value {-    read_json(r.data["spec"].as_str().unwrap_or(""))-}--fn pad_layers_of(b: &Board) -> BTreeMap<String, Vec<String>> {-    let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();-    for p in &b.pads {-        if p.thru || p.net.is_empty() {-            continue;-        }-        let l = if p.on_f { "F.Cu" } else { "B.Cu" };-        let e = m.entry(p.net.clone()).or_default();-        if !e.iter().any(|x| x == l) {-            e.push(l.to_string());-        }-    }-    m-}--/// Zone uuids of the board the run started from (the copy `start` made): those are inherited;-/// every other copper or keepout zone on the live board is the flow's own.-fn inherited_zone_ids(r: &Run) -> Vec<String> {-    let Some(p) = r.data["boards"].as_array().and_then(|b| b.first()).and_then(|b| b.as_str()) else { return Vec::new() };-    let text = std::fs::read_to_string(p).unwrap_or_default();-    let mut ids = Vec::new();-    let mut from = 0;-    while let Some(i) = text[from..].find("(zone") {-        let start = from + i;-        let end = text[start..].find("(polygon").map(|e| start + e).unwrap_or(text.len());-        if let Some(u) = text[start..end].find("(uuid \"") {-            let s = start + u + 7;-            if let Some(e) = text[s..].find('"') {-                ids.push(text[s..s + e].to_string());-            }-        }-        from = start + 5;-    }-    ids-}--/// The per-step ledger as text: wall, thinking, binary, turns, visits, returns.-fn step_table(r: &Run) -> String {-    let rep = r.step_report();-    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];-    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();-    for k in rep.keys() {-        if !names.contains(k) {-            names.push(k.clone());-        }-    }-    let mut lines = vec![format!("{:<11} {:>9} {:>9} {:>9} {:>6} {:>7} {:>8}  {}", "step", "wall min", "think min", "tool min", "turns", "visits", "returns", "longest think")];-    let (mut tw, mut tt, mut tb, mut tn) = (0u64, 0u64, 0u64, 0u64);-    for n in names {-        let e = &rep[&n];-        let (w, t, b, k) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0), e["turns"].as_u64().unwrap_or(0));-        tw += w; tt += t; tb += b; tn += k;-        lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6} {:>7} {:>8}  {:.1} min", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, k, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0), e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));-    }-    lines.push(format!("{:<11} {:>9.1} {:>9.1} {:>9.1} {:>6}", "total", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0, tn));-    lines.join("\n")-}----/// Free megabytes on the drive holding the run directory.-fn local_free_mb(dir: &Path) -> Option<u64> {-    let out = std::process::Command::new("df").args(["-Pk", dir.to_str().unwrap_or(".")]).output().ok()?;-    let text = String::from_utf8_lossy(&out.stdout);-    let line = text.lines().last()?;-    line.split_whitespace().nth(3)?.parse::<u64>().ok().map(|k| k / 1024)-}--/// The recording budget for a step: measured window captures run about 8 MB per minute of-/// changing screen (H.264 on the box's GPU encoder, frames only when the window changes), so the-/// budget is the flow's expected minutes for the step at 10 MB per minute, doubled for the pull-/// and the 10x cut. Refuses to start a clip when either side has less than the budget plus 1 GB.-fn clip_budget_mb(step: &str) -> u64 {-    let minutes = match step { "placement" => 30, "routing" => 20, "pours" => 8, "intake" => 5, _ => 3 };-    minutes * 10 * 2-}--fn disk_check(dir: &Path, br: &Bridge, step: &str) -> Result<String, String> {-    let need = clip_budget_mb(step);-    let local = local_free_mb(dir);-    let remote = br.remote_free_mb();-    let floor = need + 1024;-    let mut msgs = Vec::new();-    if let Some(l) = local {-        if l < floor {-            return Err(format!("the run directory's drive has {l} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. Free space (old runs' raw clips, `*.mp4` without the 10x suffix, are the usual culprit) before starting the clip"));-        }-        msgs.push(format!("run drive {l} MB free"));-    }-    if let Some(rm) = remote {-        if rm < floor {-            return Err(format!("the test box's recording drive has {rm} MB free; a {step} clip needs about {need} MB plus a 1 GB floor. ab prunes its recordings folder after 2 days / 40 files / 3 GB, but not mid-run: clear it or free the drive before starting the clip"));-        }-        msgs.push(format!("test box {rm} MB free"));-    }-    Ok(format!("{} for a {step} clip budgeted at {need} MB", msgs.join(", ")))-}---/// The Adom faces, embedded so every caption on every machine is set in them.-fn run_fonts(dir: &Path) -> (String, String) {-    let fd = dir.join("fonts");-    let _ = std::fs::create_dir_all(&fd);-    let files: [(&str, &[u8]); 3] = [("FamiljenGrotesk-Bold.ttf", include_bytes!("../../../assets/fonts/FamiljenGrotesk-Bold.ttf")), ("Satoshi-Regular.ttf", include_bytes!("../../../assets/fonts/Satoshi-Regular.ttf")), ("Satoshi-Medium.ttf", include_bytes!("../../../assets/fonts/Satoshi-Medium.ttf"))];-    for (n, b) in files { let p = fd.join(n); if !p.is_file() { let _ = std::fs::write(&p, b); } }-    (fd.join("FamiljenGrotesk-Bold.ttf").display().to_string(), fd.join("Satoshi-Medium.ttf").display().to_string())-}--/// drawtext-safe text: no quotes, colons and commas escaped, percent doubled.-fn dt(t: &str) -> String {-    t.replace('\\', "").replace('\'', "").replace(':', "\\:").replace(',', "\\,").replace('%', "%%")-}---/// The engine's plan usage, as the providers report it: percent used per limit window. Claude-/// from the Claude Code sign-in (the same call Claude Code's /usage panel makes), Codex from the-/// Codex sign-in (the same call its /status shows). Costs no quota. Returns [{provider, label, pct}].-fn usage_snapshot() -> Vec<Value> {-    let mut out = Vec::new();-    let home = std::env::var("HOME").unwrap_or_default();-    let curl = |url: &str, headers: &[String]| -> Option<Value> {-        let mut c = std::process::Command::new("curl");-        c.args(["-s", "-m", "12", url]);-        for h in headers { c.args(["-H", h]); }-        let o = c.output().ok()?;-        serde_json::from_slice::<Value>(&o.stdout).ok()-    };-    if let Ok(t) = std::fs::read_to_string(format!("{home}/.claude/.credentials.json")) {-        if let Ok(c) = serde_json::from_str::<Value>(&t) {-            let oa = c.get("claudeAiOauth").cloned().unwrap_or(c.clone());-            if let Some(tok) = oa.get("accessToken").and_then(|v| v.as_str()) {-                if let Some(u) = curl("https://api.anthropic.com/api/oauth/usage", &[format!("Authorization: Bearer {tok}"), "anthropic-beta: oauth-2025-04-20".into(), "User-Agent: adom-aiflow".into()]) {-                    for lim in u.get("limits").and_then(|l| l.as_array()).cloned().unwrap_or_default() {-                        let Some(pct) = lim.get("percent").and_then(|p| p.as_f64()) else { continue };-                        let label = match lim.get("kind").and_then(|k| k.as_str()) {-                            Some("session") => "5h".to_string(),-                            Some("weekly_all") => "week".to_string(),-                            Some("weekly_scoped") => lim["scope"]["model"]["display_name"].as_str().unwrap_or("model").to_string(),-                            Some(k) => k.to_string(),-                            None => "limit".to_string(),-                        };-                        out.push(json!({"provider": "claude", "label": label, "pct": pct, "resetsAt": lim.get("resets_at").cloned().unwrap_or(Value::Null)}));-                    }-                    if out.is_empty() {-                        for (label, key) in [("5h", "five_hour"), ("week", "seven_day")] {-                            if let Some(v) = u.get(key).and_then(|x| x.get("utilization")).and_then(|x| x.as_f64()) { out.push(json!({"provider": "claude", "label": label, "pct": v})); }-                        }-                    }-                }-            }-        }-    }-    if let Ok(t) = std::fs::read_to_string(format!("{home}/.codex/auth.json")) {-        if let Ok(a) = serde_json::from_str::<Value>(&t) {-            let tk = a.get("tokens").cloned().unwrap_or(Value::Null);-            if let Some(tok) = tk.get("access_token").and_then(|v| v.as_str()) {-                let acct = tk.get("account_id").and_then(|v| v.as_str()).unwrap_or("");-                if let Some(u) = curl("https://chatgpt.com/backend-api/wham/usage", &[format!("Authorization: Bearer {tok}"), format!("ChatGPT-Account-Id: {acct}"), "User-Agent: adom-aiflow".into(), "Accept: application/json".into()]) {-                    let rl = u.get("rate_limit").cloned().unwrap_or(Value::Null);-                    let label = |w: &Value| -> String { let secs = w.get("limit_window_seconds").and_then(|v| v.as_f64()).unwrap_or(0.0); if secs >= 6.0 * 86400.0 { "week".into() } else if secs >= 4.0 * 3600.0 { "5h".into() } else { format!("{}h", ((secs / 3600.0).floor() as i64).max(1)) } };-                    for key in ["primary_window", "secondary_window"] {-                        if let Some(w) = rl.get(key).filter(|w| w.is_object()) {-                            out.push(json!({"provider": "codex", "label": label(w), "pct": w.get("used_percent").and_then(|v| v.as_f64()).unwrap_or(0.0), "plan": u.get("plan_type").cloned().unwrap_or(Value::Null)}));-                        }-                    }-                }-            }-        }-    }-    out-}--/// Snapshot the plan usage into the ledger and run.json (best effort, never fatal).-fn record_usage(r: &mut Run, when: &str) -> Vec<Value> {-    let snap = usage_snapshot();-    if !snap.is_empty() {-        r.log("usage", json!({"when": when, "limits": snap}));-        if !r.data["usage"].is_array() { r.data["usage"] = json!([]); }-        r.data["usage"].as_array_mut().unwrap().push(json!({"t": now(), "when": when, "limits": snap}));-    }-    snap-}--/// First and last usage per provider and label: what the run cost in plan percent.-fn usage_delta(r: &Run) -> Vec<Value> {-    let snaps = r.data["usage"].as_array().cloned().unwrap_or_default();-    let mut first: std::collections::BTreeMap<String, (String, String, f64)> = Default::default();-    let mut last: std::collections::BTreeMap<String, f64> = Default::default();-    for s in &snaps {-        for l in s["limits"].as_array().cloned().unwrap_or_default() {-            let key = format!("{}:{}", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""));-            let pct = l["pct"].as_f64().unwrap_or(0.0);-            first.entry(key.clone()).or_insert((l["provider"].as_str().unwrap_or("").to_string(), l["label"].as_str().unwrap_or("").to_string(), pct));-            last.insert(key, pct);-        }-    }-    first.iter().map(|(k, (p, l, f))| json!({"provider": p, "label": l, "fromPct": f, "toPct": last[k], "usedPct": ((last[k] - f) * 10.0).round() / 10.0})).collect()-}--fn usage_line(r: &Run) -> String {-    let d = usage_delta(r);-    if d.is_empty() { return "plan usage: no sign-in found to read".into(); }-    format!("plan usage: {}", d.iter().map(|x| format!("{} {} {:.0}% -> {:.0}%", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", "))-}---/// The overlay chain the final video draws on a linear-time (10x) cut: the step chip top left,-/// the engine and board top right, and in the lower right two small tables, "RUN, SINCE YOUR-/// PROMPT" at the far right and "THIS STEP" beside it, headers HR / MIN / SEC over big numbers,-/// the HR column only when the run passed an hour. Same type sizes and row lines, 16 px margins.-/// Drawn after the decimation with t = real seconds inside the clip; `offset` is the clip's start-/// since the prompt. Returns the full -vf string from the 10x cut to the captioned action cut.-/// The overlay filters of the approved layout (step chip top-left, engine and board top-right, the-/// run clock and the step clock lower-right); `offset` is the segment's start on the run clock,-/// `step_offset` its start on the step clock, both in seconds, both advancing with the frame time.-fn overlay_filters(dir: &Path, title: &str, engine_board: &str, offset: f64, step_offset: f64, run_has_hours: bool) -> String {-    let (font_display, font_text) = run_fonts(dir);-    let off = format!("{offset:.0}");-    let m = 16.0; let big = 26.0; let small = 11.0; let lab = 13.0; let pad = 12.0; let gap = 12.0; let colw = 38.0;-    let h_box = pad + 16.0 + 6.0 + 14.0 + 6.0 + big + pad;-    let run_secs = format!("(t+{off})");-    let (run_cols, run_w): (Vec<(String, String)>, f64) = if run_has_hours {-        (vec![("HR".into(), format!("%{{eif\\:{run_secs}/3600\\:d}}")), ("MIN".into(), format!("%{{eif\\:mod({run_secs}/60\\,60)\\:d\\:2}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 3.0 * colw + 2.0 * gap)-    } else {-        (vec![("MIN".into(), format!("%{{eif\\:{run_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({run_secs}\\,60)\\:d\\:2}}"))], 2.0 * colw + gap)-    };-    let run_inner = run_w.max(162.0);-    let step_secs = format!("(t+{step_offset:.0})");-    let step_cols: Vec<(String, String)> = vec![("MIN".into(), format!("%{{eif\\:{step_secs}/60\\:d}}")), ("SEC".into(), format!("%{{eif\\:mod({step_secs}\\,60)\\:d\\:2}}"))];-    let step_inner = (2.0 * colw + gap).max(62.0);-    let mut ov: Vec<String> = Vec::new();-    let run_box_w = run_inner + 2.0 * pad; let step_box_w = step_inner + 2.0 * pad;-    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={run_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w, m + h_box));-    ov.push(format!("drawbox=x=iw-{}:y=ih-{}:w={step_box_w}:h={h_box}:[email protected]:t=fill", m + run_box_w + m + step_box_w, m + h_box));-    let y_lab = format!("h-{}", m + h_box - pad); let y_hdr = format!("h-{}", m + h_box - pad - 16.0 - 6.0); let y_num = format!("h-{}", m + h_box - pad - 16.0 - 6.0 - 14.0 - 6.0);-    let run_left = m + pad + run_inner; let step_left = m + run_box_w + m + pad + step_inner;-    ov.push(format!("drawtext=fontfile={ft}:text='AI TIME, THIS RUN':x=w-{run_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));-    ov.push(format!("drawtext=fontfile={ft}:text='THIS STEP':x=w-{step_left}:y={y_lab}:fontsize={lab}:fontcolor=0xDB8B58", ft = font_text));-    for (k, (hdr, expr)) in run_cols.iter().enumerate() {-        let cx = format!("w-{}+{}", run_left, k as f64 * (colw + gap) + colw / 2.0);-        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));-        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));-    }-    for (k, (hdr, expr)) in step_cols.iter().enumerate() {-        let cx = format!("w-{}+{}", step_left, k as f64 * (colw + gap) + colw / 2.0);-        ov.push(format!("drawtext=fontfile={ft}:text='{hdr}':x={cx}-tw/2:y={y_hdr}:fontsize={small}:fontcolor=0xA0AFBE", ft = font_text));-        ov.push(format!("drawtext=fontfile={fd}:text='{expr}':x={cx}-tw/2:y={y_num}:fontsize={big}:fontcolor=0xFFFFFF", fd = font_display));-    }-    ov.push(format!("drawtext=fontfile={fd}:text='{}':x={m}:y={m}:fontsize=24:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=10", dt(title), fd = font_display));-    ov.push(format!("drawtext=fontfile={ft}:text='{}':x=w-tw-{m}:y={m}:fontsize=15:fontcolor=0xD9E1E8:box=1:[email protected]:boxborderw=8", dt(engine_board), ft = font_text));-    ov.join(",")-}--/// The action cut with overlays: the 10x cut stretched back to real time (so the clocks are-/// honest), near-duplicate frames dropped, the overlays drawn, then the kept frames packed at-/// `rate` frames per second (4 is the pace of one step's clip; compose raises it to fit the budget).-fn overlay_chain(dir: &Path, title: &str, engine_board: &str, offset: f64, run_has_hours: bool, speed: u32, rate: f64) -> String {-    format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,{},setpts=N/{rate:.3}/TB", overlay_filters(dir, title, engine_board, offset, 0.0, run_has_hours))-}--/// Drops frames that are almost entirely black: the window recorder's first frame is one, and the-/// scripted pace can stretch a single frame across seconds of narration. Every frame is tagged with-/// its black fraction (amount=0), and only the ones under 95 percent black pass.-const DROP_BLACK: &str = "blackframe=amount=0:threshold=24,metadata=mode=select:key=lavfi.blackframe.pblack:value=95:function=less";--/// A step's display name for chips and cards.-fn step_shown(step: &str) -> String {-    match step { "3d" => "3D WALKTHROUGH".into(), "nets" => "NET WALKTHROUGH".into(), "models" => "3D MODELS".into(), "current" => "CURRENT ANALYSIS".into(), "thermal" => "THERMAL ANALYSIS".into(), other => other.to_uppercase() }-}---/// Stop the running clip of the current step, pull it, cut it. finish and deliver call this so-/// no recorder outlives the run (a clip left running once recorded nine hours of a static editor).-fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option<String> {-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    let pos = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none())?;-    let rid = caps[pos]["recordingId"].as_str()?.to_string();-    let save_to = std::fs::canonicalize(dir).unwrap_or(dir.to_path_buf()).display().to_string();-    let (p, _) = br.record_stop(&rid, &save_to);-    let cap = &mut r.data["captures"][pos];-    cap["stop"] = json!(now());-    cap["remoteFile"] = json!(p);-    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-    cap["stoppedBy"] = json!(why);-    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-    let local = cap["file"].as_str().map(str::to_string);-    r.log("clip-stop", json!({"step": st, "recordingId": rid, "file": p, "by": why}));-    if let Some(f) = local.clone().filter(|f| Path::new(f).is_file()) { clip_artifacts(r, &st, &f, 10); }-    local-}---/// Sample eight frames spread through a clip; for each, the centre 60 % of the frame (where a-/// painted canvas has content and an unpainted one is one flat colour) is read as 96 x 54 grey-/// pixels and binned; when one bin holds over 80 % of the pixels the frame is flat. More than-/// three quarters flat frames means the clip filmed nothing worth keeping. Returns the reason.-fn blank_check(file: &str) -> Option<String> {-    let secs = media_seconds(file);-    if secs <= 0.0 { return Some("no frames".into()); }-    let n = 8usize;-    let mut flat = 0usize; let mut total = 0usize;-    for k in 0..n {-        let t = secs * (k as f64 + 0.5) / n as f64;-        let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-ss", &format!("{t:.2}"), "-i", file, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "gray", "-"]).output().ok()?;-        let px = out.stdout;-        if px.len() < 1000 { continue; }-        let mut bins = [0usize; 16];-        for b in &px { bins[(*b as usize) >> 4] += 1; }-        total += 1;-        if bins.iter().max().copied().unwrap_or(0) * 100 > px.len() * 80 { flat += 1; }-    }-    if total == 0 { return Some("could not read frames".into()); }-    if flat * 4 > total * 3 { Some(format!("{flat} of {total} sampled frames are one flat colour in the middle of the window (an unpainted or covered canvas)")) } else { None }-}--/// A contact sheet: nine frames spread through the clip on one PNG, so the AI looks at what it-/// filmed before it moves on, and the run page shows it under the clip.-fn contact_sheet(file: &str) -> Option<String> {-    let secs = media_seconds(file);-    if secs <= 0.0 { return None; }-    let out = format!("{}-sheet.png", file.trim_end_matches(".mp4"));-    let fps = 9.0 / secs;-    let ok = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", file, "-vf", &format!("fps={fps:.6},scale=426:-2,tile=3x3:padding=4:margin=4:color=0x0B1420"), "-frames:v", "1", &out]).status().map(|s| s.success()).unwrap_or(false);-    if ok && Path::new(&out).is_file() { Some(out) } else { None }-}--/// Seconds of pure black at the head of a clip (the recorder started before the window painted), up to five.-fn leading_black(file: &str) -> f64 {-    let out = std::process::Command::new("ffmpeg").args(["-v", "info", "-t", "6", "-i", file, "-an", "-vf", "blackdetect=d=0.05:pic_th=0.98:pix_th=0.10", "-f", "null", "-"]).output();-    let Ok(o) = out else { return 0.0 };-    let text = String::from_utf8_lossy(&o.stderr);-    for line in text.lines() {-        if let Some(i) = line.find("black_start:") {-            let rest = &line[i + 12..];-            let start: f64 = rest.split_whitespace().next().and_then(|v| v.parse().ok()).unwrap_or(1.0);-            let end: f64 = line.find("black_end:").and_then(|j| line[j + 10..].split_whitespace().next()).and_then(|v| v.parse().ok()).unwrap_or(0.0);-            if start <= 0.05 && end > 0.0 && end <= 5.0 { return end; }-            break;-        }-    }-    0.0-}--/// The clip's seconds, from ffprobe (0.0 when unknown).-fn media_seconds(path: &str) -> f64 {-    std::process::Command::new("ffprobe").args(["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).trim().parse::<f64>().ok()).unwrap_or(0.0)-}--/// Cut a sped-up copy of a pulled clip next to it (`<name>-10x.mp4`) and record both as artifacts-/// of the step. The window recorder only emits frames on change, so the cut is by timestamp.-fn clip_artifacts(r: &mut Run, step: &Value, file: &str, speed: u32) {-    let dir = r.dir.clone();-    let tag = step.as_str().unwrap_or("step").to_string();-    // the clip's offset from the prompt, so the overlay can show the run's own clock-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    let cap = caps.iter().find(|c| c["file"].as_str() == Some(file) || format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag).cloned().unwrap_or(Value::Null);-    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-    let offset = cap["start"].as_str().and_then(aiflow_run::parse).map(|t| t.saturating_sub(t0)).unwrap_or(0) as f64;-    let (step_name, visit, back) = (cap["step"].as_str().unwrap_or(&tag).to_string(), cap["visit"].as_u64().unwrap_or(1), cap["back"].as_bool() == Some(true));-    let _ = (&step_name, visit, back, offset, &dir);-    let secs = media_seconds(file);-    // the blank check: a clip whose sampled frames are nearly one colour filmed nothing (an-    // unpainted canvas, a covered window); it is flagged, kept out of the video, and said out loud-    let verdict = blank_check(file);-    if let Some(why) = &verdict {-        eprintln!("WARNING clip {} looks blank: {why}", Path::new(file).file_name().and_then(|n| n.to_str()).unwrap_or(""));-        if let Some(c) = r.data["captures"].as_array_mut().and_then(|a| a.iter_mut().find(|c| c["file"].as_str() == Some(file))) { c["suspect"] = json!(why); }-        r.log("clip-suspect", json!({"step": step, "file": file, "why": why}));-    }-    r.log("artifact", json!({"step": step, "kind": "clip", "file": file, "seconds": (secs * 10.0).round() / 10.0, "suspect": verdict}));-    if let Some(sheet) = contact_sheet(file) {-        r.log("artifact", json!({"step": step, "kind": "contact-sheet", "file": sheet}));-        eprintln!("contact sheet (look at it before you go on): {sheet}");-    }-    let out = format!("{}-{speed}x.mp4", file.trim_end_matches(".mp4"));-    // the recorder's first frames are black before the window paints: they are cut off the head-    // of both cuts (never more than five seconds; longer black is a real clip problem, kept)-    let lead = leading_black(file);-    let ss = format!("{lead:.3}");-    let cut = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-ss", &ss, "-i", file, "-an", "-vf", &format!("setpts=PTS/{speed},scale=1280:-2"), "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &out]).status().map(|s| s.success()).unwrap_or(false);-    if lead > 0.0 { r.log("marker", json!({"label": format!("clip {tag}: {lead:.2} s of black recorder startup cut off the head of its cuts")})); }-    if cut {-        let fast = media_seconds(&out);-        r.log("artifact", json!({"step": step, "kind": format!("clip{speed}x"), "file": out, "seconds": (fast * 10.0).round() / 10.0, "speed": speed}));-        eprintln!("clip {:.1} s, {speed}x cut {:.1} s: {out}", secs, fast);-        // the action cut: only the frames that changed, with the real elapsed time burned in.-        // Made from the 10x cut (cheap): stretch its timestamps back to real time so the clock is-        // honest, drop near-duplicate frames, then pack what is left at 30 fps.-        let action = format!("{}-action.mp4", file.trim_end_matches(".mp4"));-        // the action cut stays RAW (no captions): the final video draws its own overlays from the-        // ledger, and a side-by-side of two runs must not paint overlays over overlays-        let vf = format!("setpts=PTS*{speed},{DROP_BLACK},mpdecimate=hi=64*12:lo=64*5:frac=0.33,setpts=N/4/TB");-        let ok_action = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &out, "-an", "-vf", &vf, "-r", "30", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", &action]).status().map(|s| s.success()).unwrap_or(false);-        if ok_action {-            let a = media_seconds(&action);-            r.log("artifact", json!({"step": step, "kind": "clipAction", "file": action, "seconds": (a * 10.0).round() / 10.0, "realSeconds": (secs * 10.0).round() / 10.0}));-            eprintln!("action cut {:.1} s of motion out of {:.1} s: {action}", a, secs);-        }-    }-}---/// What goes into the final video, in run order: a step visit's motion, or a drawing the AI made.-enum Piece { Clip { cap: Value, idx: usize, motion: f64 }, Still { file: String, caption: String, step: String, visit: u64, at: u64 } }--/// Say `tiers` (the fullest first) and keep the first that fits `vlen` seconds; the last tier is-/// said faster when even it runs long. The picture sets the length; the words are cut to it.-fn narrate(workdir: &Path, idx: usize, tiers: &[String], vlen: f64, has_tts: bool) -> Option<(PathBuf, f64, String)> {-    if !has_tts || vlen < 1.5 { return None; }-    let mut best: Option<(PathBuf, f64, String)> = None;-    for (k, words) in tiers.iter().enumerate() {-        let n = words.split_whitespace().count() as f64;-        let last = k + 1 == tiers.len();-        if n > vlen * 2.3 && !last { continue; }-        let rate = if last && n > vlen * 2.3 { "+30%" } else { "+0%" };-        let audio = workdir.join(format!("say-{idx}-{k}.mp3"));-        let ok = std::process::Command::new("adom-tts").args(["say", "--rate", rate, "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);-        if !ok { continue; }-        let alen = media_seconds(audio.to_str().unwrap());-        best = Some((audio, alen, words.clone()));-        if alen <= vlen + 0.05 { break; }-    }-    best-}--/// The first clause of a sentence: up to the first semicolon, colon or comma.-fn first_clause(s: &str) -> String {-    s.split([';', ':', ',']).next().unwrap_or(s).trim().to_string()-}--/// A step's name in words for the narration.-fn step_said(step: &str) -> String {-    step_shown(step).to_lowercase().replace("3d", "3D")-}--/// Compose the final video. Returns (seconds, clip segments, stills, frame rate of motion, narrated).-fn compose_run(r: &mut Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64) -> (f64, usize, usize, f64, bool) {-    let led = r.ledger();-    let mut act: std::collections::BTreeMap<String, (f64, f64)> = Default::default();-    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), (e["realSeconds"].as_f64().unwrap_or(0.0), e["seconds"].as_f64().unwrap_or(0.0))); } }-    let t0 = r.data["clock"]["promptTime"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    // the pieces, in the order they happened: clips by their start, drawings by the moment the AI registered them-    let mut pieces: Vec<(u64, Piece)> = Vec::new();-    for (i, c) in caps.iter().enumerate() {-        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }-        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }-        let visit = c["visit"].as_u64().unwrap_or(1);-        let tag = format!("{step}-{visit}");-        let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-        if !Path::new(&fast).is_file() { continue; }-        let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));-        let motion = act.get(&tag).map(|x| x.1).filter(|m| *m > 0.0).unwrap_or_else(|| media_seconds(&action));-        // a visit with under a second of motion is nothing to watch: it stays on the page, not in the video-        if motion < 1.0 { eprintln!("segment {tag}: {motion:.1} s of motion, left out"); continue; }-        let at = c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        // a clip that ran while the human was away (a recorder left on) is nothing to watch-        let stop = c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(at);-        if r.active_between(at, stop) == 0 && stop.saturating_sub(at) > r.idle_minutes() * 60 { eprintln!("segment {tag}: recorded outside the AI's time, left out"); continue; }-        pieces.push((at, Piece::Clip { cap: c.clone(), idx: i, motion }));-    }-    for e in &standing_artifacts(&led) {-        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        if !Path::new(f).is_file() { continue; }-        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        pieces.push((at, Piece::Still { file: f.to_string(), caption: e["caption"].as_str().unwrap_or("").to_string(), step: e["step"].as_str().unwrap_or("").to_string(), visit: e["visit"].as_u64().unwrap_or(1), at }));-    }-    pieces.sort_by_key(|p| p.0);-    if pieces.is_empty() { err("nothing to compose: no clips with motion and no drawings in this run (run `recut` first)", &[]); }-    // the pace: every kept frame of motion plays at 4 per second when the run fits; faster, evenly, when it does not-    let n_stills = pieces.iter().filter(|p| matches!(p.1, Piece::Still { .. })).count();-    let motion_total: f64 = pieces.iter().map(|p| if let Piece::Clip { motion, .. } = &p.1 { *motion } else { 0.0 }).sum();-    let clip_budget = (max_seconds - n_stills as f64 * still_seconds).max(max_seconds * 0.5);-    let rate = if motion_total > clip_budget { 4.0 * motion_total / clip_budget } else { 4.0 };-    let engine = r.data["engine"].as_str().unwrap_or("").to_string();-    let minutes = r.active_minutes_total();-    let run_has_hours = minutes >= 60.0;-    let board = Path::new(r.data["sourceBoard"].as_str().or(r.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let engine_board = format!("{engine}   ·   {board}");-    let workdir = dir.join("compose");-    let _ = std::fs::remove_dir_all(&workdir);-    let _ = std::fs::create_dir_all(&workdir);-    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);-    let step_does = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };-    let visit_start = |step: &str, visit: u64| -> u64 { caps.iter().find(|c| c["step"].as_str() == Some(step) && c["visit"].as_u64() == Some(visit)).and_then(|c| c["start"].as_str()).and_then(aiflow_run::parse).unwrap_or(0) };-    let fit = "scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=0x0B1420";-    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();-    let (mut n_clips, mut n_stills_done) = (0usize, 0usize);-    for (k, (_, piece)) in pieces.iter().enumerate() {-        let seg = workdir.join(format!("seg-{k}.mp4"));-        let aseg = workdir.join(format!("aud-{k}.m4a"));-        let (vlen, tiers): (f64, Vec<String>) = match piece {-            Piece::Clip { cap: c, idx, motion } => {-                let (f, step, visit) = (c["file"].as_str().unwrap_or(""), c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1));-                let tag = format!("{step}-{visit}");-                let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-                // the run clock is the AI's time: the active seconds from the first prompt to this clip-                let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));-                let offset = r.active_offset(cs) as f64;-                let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });-                let chain = format!("{},{fit}", overlay_chain(dir, &title, &engine_board, offset, run_has_hours, 10, rate));-                let okc = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", &fast, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-                if !okc { eprintln!("segment {tag}: the overlay cut failed; skipped"); continue; }-                let vlen = media_seconds(seg.to_str().unwrap());-                if vlen < 0.5 { continue; }-                let _ = (idx, motion);-                let real = if ce > cs { r.active_between(cs, ce) as f64 } else { act.get(&tag).map(|x| x.0).unwrap_or_else(|| media_seconds(f)) };-                let said = step_said(step);-                let visit_words = if visit > 1 { format!(", visit {visit}") } else { String::new() };-                let why_or_does = c["why"].as_str().map(|w| format!("The AI came back here because {w}")).unwrap_or_else(|| step_does(step));-                let mins = format!("{:.0} minute{} of real time", (real / 60.0).max(1.0), if real >= 90.0 { "s" } else { "" });-                n_clips += 1;-                (vlen, vec![format!("{said}{visit_words}. {why_or_does}. {mins}."), format!("{said}{visit_words}. {}.", first_clause(&why_or_does)), format!("{said}{visit_words}.")])-            }-            Piece::Still { file, caption, step, visit, at } => {-                let offset = r.active_offset(*at) as f64;-                let step_offset = r.active_between(visit_start(step, *visit), *at) as f64;-                let title = format!("{}   ·   the AI's drawing", step_shown(step));-                // the drawing is shown at the clips' scale so the overlays match, then fit to 1080p-                let vf = format!("scale=1280:-2,{},{fit}", overlay_filters(dir, &title, &engine_board, offset, step_offset, run_has_hours));-                let oks = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-loop", "1", "-framerate", "4", "-t", &format!("{still_seconds:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-                if !oks { eprintln!("still {file}: could not be rendered; skipped"); continue; }-                let vlen = media_seconds(seg.to_str().unwrap());-                n_stills_done += 1;-                (vlen, vec![caption.clone(), first_clause(caption), format!("{}, the AI's own drawing.", step_said(step))])-            }-        };-        // the words are cut to the picture: the segment is exactly as long as its motion-        let said = narrate(&workdir, k, &tiers, vlen, has_tts);-        let oka = match &said {-            Some((audio, _, _)) => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),-            None => std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false),-        };-        if let Some((_, alen, words)) = &said { if *alen > vlen + 0.05 { eprintln!("segment {k}: the words ran {alen:.1} s for {vlen:.1} s of picture; cut at the picture: \"{words}\""); } }-        if oka { parts.push((seg, aseg)); }-    }-    if parts.is_empty() { err("nothing to compose: every segment failed to render", &[]); }-    let vlist = workdir.join("video.txt");-    let alist = workdir.join("audio.txt");-    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", v.display())).collect::<String>()).unwrap();-    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.display())).collect::<String>()).unwrap();-    let vcat = workdir.join("video.mp4");-    let acat = workdir.join("audio.m4a");-    let okv = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let oka = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    let okf = okv && oka && std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }-    // the page takes files up to 10 MB: when the cut is bigger, the video is re-encoded once at the-    // bitrate that fits (screen captures are mostly still, so the picture survives it)-    let cap_bytes: u64 = 9_800_000;-    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {-        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);-        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();-        let fitted = workdir.join("fitted.mp4");-        let okr = std::process::Command::new("ffmpeg").args(["-v", "error", "-y", "-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]).status().map(|s| s.success()).unwrap_or(false);-        if okr { let _ = std::fs::copy(&fitted, outp); eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0)); }-    }-    let secs = media_seconds(outp.to_str().unwrap());-    if secs > max_seconds + 2.0 { eprintln!("WARNING the video runs {secs:.0} s, over the {max_seconds:.0} s cap; the pace ({rate:.1} frames per second) did not hold"); }-    (secs, n_clips, n_stills_done, rate, has_tts)-}--/// The AI's artifacts that still stand: an artifact whose file a later `artifact --replaces` names is-/// superseded, as is an earlier event of the same file re-registered later. Returns the ledger events.-fn standing_artifacts(led: &[Value]) -> Vec<Value> {-    let mut out: Vec<Value> = Vec::new();-    for (i, e) in led.iter().enumerate() {-        if e["event"] != "artifact" || e["byAi"].as_bool() != Some(true) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        let superseded = led.iter().skip(i + 1).any(|l| l["event"] == "artifact" && l["byAi"].as_bool() == Some(true) && (l["replaces"].as_str() == Some(f) || l["file"].as_str() == Some(f)));-        if !superseded { out.push(e.clone()); }-    }-    out-}--/// The sessions table for the terminal and the run page.-fn sessions_table(r: &Run) -> String {-    let ss = r.sessions();-    let mut out = String::from("| Session | Prompt at | Done at | AI time min | Idle cut min | What |\n|---|---|---|---|---|---|\n");-    for x in &ss {-        out.push_str(&format!("| {} | {} | {} | {:.1} | {:.0} | {} |\n", x.n, aiflow_run::iso(x.prompt_at), x.done_at.map(aiflow_run::iso).unwrap_or_else(|| if x.open { "(open)".into() } else { "".into() }), x.active_seconds() as f64 / 60.0, x.idle_seconds as f64 / 60.0, x.text.chars().take(90).collect::<String>().replace('|', "/")));-    }-    out.push_str(&format!("| **all** | | | **{:.1}** | | |\n", r.active_minutes_total()));-    out-}--/// One engine's clip of one step visit, ready for the side-by-side.-struct VisitClip { tenx: String, motion: f64, offset: f64, title: String }--/// The 10x cut of a capture, in its own run or, for a run pulled from another machine, by file name in the run directory.-fn tenx_of(run_dir: &Path, file: &str) -> Option<String> {-    let fast = format!("{}-10x.mp4", file.trim_end_matches(".mp4"));-    if Path::new(&fast).is_file() { return Some(fast); }-    let local = run_dir.join(Path::new(&fast).file_name()?);-    if local.is_file() { Some(local.display().to_string()) } else { None }-}--/// A run's visits per step (flow order kept by the caller), with their motion seconds and active-time offsets.-fn visits_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<VisitClip>> {-    let led = r.ledger();-    let mut act: std::collections::BTreeMap<String, f64> = Default::default();-    for e in &led { if e["event"] == "artifact" && e["kind"] == "clipAction" { act.insert(e["step"].as_str().unwrap_or("").to_string(), e["seconds"].as_f64().unwrap_or(0.0)); } }-    let mut out: std::collections::BTreeMap<String, Vec<VisitClip>> = Default::default();-    let mut caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    caps.sort_by(|a, b| a["start"].as_str().unwrap_or("").cmp(b["start"].as_str().unwrap_or("")));-    for c in &caps {-        if c.get("discarded").is_some() || c.get("suspect").is_some() { continue; }-        let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-        // the 3D step's camera is the viewer; an editor clip under that step is the wait before it opened-        if step == "3d" && c["tour"].as_str() != Some("3d") { continue; }-        let visit = c["visit"].as_u64().unwrap_or(1);-        let Some(tenx) = tenx_of(&r.dir, f) else { continue };-        let motion = act.get(&format!("{step}-{visit}")).copied().unwrap_or(0.0);-        if motion < 1.0 { continue; }-        let (cs, ce) = (c["start"].as_str().and_then(aiflow_run::parse).unwrap_or(0), c["stop"].as_str().and_then(aiflow_run::parse).unwrap_or(0));-        if r.active_between(cs, ce) == 0 && ce.saturating_sub(cs) > r.idle_minutes() * 60 { continue; }-        let title = format!("{}{}", step_shown(step), if visit > 1 { format!("   visit {visit}{}", if c["back"].as_bool() == Some(true) { "   rework" } else { "" }) } else { String::new() });-        out.entry(step.to_string()).or_default().push(VisitClip { tenx, motion, offset: r.active_offset(cs) as f64, title });-    }-    out-}--/// A run's standing drawings per step, as (file, caption, active offset).-fn drawings_by_step(r: &Run) -> std::collections::BTreeMap<String, Vec<(String, String, f64)>> {-    let mut out: std::collections::BTreeMap<String, Vec<(String, String, f64)>> = Default::default();-    for e in standing_artifacts(&r.ledger()) {-        if !matches!(e["kind"].as_str(), Some("analysis-image") | Some("image")) { continue; }-        let Some(f) = e["file"].as_str() else { continue };-        let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name().unwrap_or_default()); if l.is_file() { l.display().to_string() } else { continue } };-        let at = e["t"].as_str().and_then(aiflow_run::parse).unwrap_or(0);-        out.entry(e["step"].as_str().unwrap_or("").to_string()).or_default().push((local, e["caption"].as_str().unwrap_or("").to_string(), r.active_offset(at) as f64));-    }-    out-}--fn run_ffmpeg(args: &[&str]) -> bool {-    std::process::Command::new("ffmpeg").arg("-v").arg("error").arg("-y").args(args).status().map(|s| s.success()).unwrap_or(false)-}--/// Two runs side by side, step by step in flow order: the left engine's visits of the step play-/// in its column, the right engine's in its column, both paced to the same seconds (the run with-/// more motion sets the pace; the other plays slower, never faster than its own clips); their own-/// clocks in their own corners; the drawings of both side by side; the words cut to the picture.-fn compose_pair(a: &mut Run, b: &Run, dir: &Path, outp: &Path, max_seconds: f64, still_seconds: f64, script: Option<&serde_json::Map<String, Value>>) -> (f64, usize, usize, bool) {-    let (fd, ft) = run_fonts(dir);-    let workdir = dir.join("compose-pair");-    let _ = std::fs::remove_dir_all(&workdir);-    let _ = std::fs::create_dir_all(&workdir);-    let has_tts = std::process::Command::new("adom-tts").arg("health").output().map(|o| o.status.success()).unwrap_or(false);-    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());-    let board = Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let (va, vb) = (visits_by_step(a), visits_by_step(b));-    let (da, db) = (drawings_by_step(a), drawings_by_step(b));-    let (ra, rb) = (a.step_report(), b.step_report());-    let hours = a.active_minutes_total() >= 60.0 || b.active_minutes_total() >= 60.0;-    // the steps in flow order, then any the runs added-    // the story's order: the build steps, the analyses, the walkthroughs, the finish line last-    let mut steps: Vec<String> = ["intake", "models", "placement", "routing", "pours", "current", "thermal", "fields", "nets", "3d", "finish"].iter().map(|s| s.to_string()).collect();-    for k in va.keys().chain(vb.keys()).chain(da.keys()).chain(db.keys()) { if !steps.contains(k) { steps.insert(steps.len() - 1, k.clone()); } }-    let has = |s: &str| va.contains_key(s) || vb.contains_key(s) || da.contains_key(s) || db.contains_key(s);-    let steps: Vec<String> = steps.into_iter().filter(|s| has(s) && s != "capture").collect();-    // the pace: each step's seconds is the larger of the two runs' motion; scaled evenly to fit the budget-    let frames = |v: Option<&Vec<VisitClip>>| -> f64 { v.map(|x| x.iter().map(|c| c.motion * 4.0).sum()).unwrap_or(0.0) };-    let mut raw: Vec<f64> = Vec::new(); let mut pairs_total = 0usize;-    for s in &steps {-        let (fa, fb) = (frames(va.get(s)), frames(vb.get(s)));-        raw.push(fa.max(fb) / 4.0);-        pairs_total += da.get(s).map(|x| x.len()).unwrap_or(0).max(db.get(s).map(|x| x.len()).unwrap_or(0));-    }-    let raw_sum: f64 = raw.iter().sum();-    let clip_budget = (max_seconds - pairs_total as f64 * still_seconds).max(max_seconds * 0.5);-    let scale = if raw_sum > clip_budget { clip_budget / raw_sum } else { 1.0 };-    let mins = |rep: &serde_json::Map<String, Value>, s: &str| -> (f64, u64, u64) { rep.get(s).map(|e| (e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"].as_u64().unwrap_or(0), e["returns"].as_u64().unwrap_or(0))).unwrap_or((0.0, 0, 0)) };-    let (cw, ch, band) = (960, 540, 110);-    // the bars below the columns: this step's AI minutes per engine against the longest step of either run-    let max_min = steps.iter().map(|s| mins(&ra, s).0.max(mins(&rb, s).0)).fold(1.0f64, f64::max);-    let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-    let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());-    let footer = dt(&format!("Delivered: {na} in {d_a:.0} min of AI time with {ret_a} return{} to earlier steps   ·   {nb} in {d_b:.0} min with {ret_b} return{}", if ret_a == 1 { "" } else { "s" }, if ret_b == 1 { "" } else { "s" }));-    let frame = |title: &str, sub: &str, bars: Option<((f64, u64), (f64, u64))>| -> String {-        let mut f = format!("[0:v][1:v]hstack=inputs=2,pad=1920:1080:0:{band}:color=0x0B1420,drawtext=fontfile={fd}:text='{}':x=(w-tw)/2:y=22:fontsize=40:fontcolor=0xF2E9E1,drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=74:fontsize=20:fontcolor=0xD9E1E8", dt(title), dt(sub));-        if let Some(((ma, va), (mb, vb))) = bars {-            let (x0, wmax, y1, y2, h) = (420.0, 1180.0, 720.0, 800.0, 40.0);-            for (k, (name, m, v, color, y)) in [(&na, ma, va, "0xDB8B58", y1), (&nb, mb, vb, "0x5FB6D6", y2)].into_iter().enumerate() {-                let w = ((m / max_min) * wmax).max(4.0);-                let _ = k;-                f.push_str(&format!(",drawtext=fontfile={ft}:text='{}':x={x0}-tw-24:y={y}+9:fontsize=20:fontcolor=0xF2E9E1", dt(name)));-                f.push_str(&format!(",drawbox=x={x0}:y={y}:w={w:.0}:h={h}:color={color}@0.9:t=fill"));-                f.push_str(&format!(",drawtext=fontfile={fd}:text='{}':x={x0}+{w:.0}+16:y={y}+6:fontsize=26:fontcolor=0xF2E9E1", dt(&format!("{m:.0} min   ·   {v} visit{}", if v == 1 { "" } else { "s" }))));-            }-            f.push_str(&format!(",drawtext=fontfile={ft}:text='THIS STEP, AI TIME':x={x0}:y=684:fontsize=13:fontcolor=0xA0AFBE"));-        }-        f.push_str(&format!(",drawtext=fontfile={ft}:text='{footer}':x=(w-tw)/2:y=1010:fontsize=19:fontcolor=0xD9E1E8"));-        f-    };-    // a clip of any window shape fits the column both ways (a pup window is taller than the editor's)-    let fit_col = format!("scale={cw}:{ch}:force_original_aspect_ratio=decrease,pad={cw}:{ch}:(ow-iw)/2:(oh-ih)/2:color=0x0B1420");-    let mut parts: Vec<(PathBuf, PathBuf)> = Vec::new();-    let (mut n_steps, mut n_pairs) = (0usize, 0usize);-    // with a script the words lead: each step's picture is paced to its narration (slower or faster,-    // never held, never cut short), so the voice never stops and never runs past the picture-    let scripted = script.is_some();-    let say_len = |k: usize, words: &str| -> Option<(PathBuf, f64)> {-        if !has_tts || words.trim().is_empty() { return None; }-        let audio = workdir.join(format!("script-{k}.mp3"));-        let okv = std::process::Command::new("adom-tts").args(["say", "--out", audio.to_str().unwrap(), words]).output().map(|o| o.status.success()).unwrap_or(false);-        if okv { Some((audio.clone(), media_seconds(audio.to_str().unwrap()))) } else { None }-    };-    for (si, step) in steps.iter().enumerate() {-        let script_words = script.and_then(|m| m.get(step.as_str())).and_then(|v| v.as_str()).map(str::to_string);-        let scripted_audio = script_words.as_ref().and_then(|w| say_len(1000 + si, w));-        let t_step = match &scripted_audio { Some((_, alen)) => alen + 0.3, None => raw[si] * scale };-        if scripted && script_words.is_none() { eprintln!("side by side, step {step}: no words in the script; the picture keeps its own pace"); }-        let mut seg_index = parts.len();-        // the two columns of clips-        if t_step >= 0.5 && (raw[si] > 0.0 || scripted_audio.is_some()) {-            let mut cols: Vec<PathBuf> = Vec::new();-            for (side, (run, name, vis)) in [(a as &Run, &na, va.get(step)), (b, &nb, vb.get(step))].into_iter().enumerate() {-                let col = workdir.join(format!("col-{si}-{side}.mp4"));-                let f = frames(vis);-                if f > 0.0 {-                    // scripted: the frames spread over the words (down to one frame every five seconds-                    // for a step with little motion); unscripted: never slower than one a second-                    let rate = if scripted_audio.is_some() { (f / t_step).max(0.2) } else { (f / t_step).max(1.0) };-                    let mut pieces: Vec<PathBuf> = Vec::new();-                    for (k, v) in vis.unwrap().iter().enumerate() {-                        let piece = workdir.join(format!("piece-{si}-{side}-{k}.mp4"));-                        let chain = format!("{},{fit_col}", overlay_chain(dir, &v.title, &format!("{name}   ·   {board}"), v.offset, hours, 10, rate));-                        if run_ffmpeg(&["-i", &v.tenx, "-an", "-vf", &chain, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", piece.to_str().unwrap()]) { pieces.push(piece); }-                    }-                    let list = workdir.join(format!("col-{si}-{side}.txt"));-                    std::fs::write(&list, pieces.iter().map(|p| format!("file '{}'\n", p.display())).collect::<String>()).unwrap();-                    let cat = workdir.join(format!("cat-{si}-{side}.mp4"));-                    let _ = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", list.to_str().unwrap(), "-c", "copy", cat.to_str().unwrap()]);-                    // exactly t_step: the slower column holds its last frame for the rest (a side-by-side has to wait)-                    let _ = run_ffmpeg(&["-i", cat.to_str().unwrap(), "-vf", &format!("tpad=stop_mode=clone:stop_duration={t_step:.2},trim=0:{t_step:.3}"), "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]);-                    let _ = run;-                } else {-                    // no clip on this side: the run's own screenshot of the step, or its latest board shot,-                    // stands in with a caption, so the column is never a dark hole-                    let shot = latest_shot(&run.dir, step);-                    let msg = dt(&format!("{name}: no clip for this step{}", if shot.is_some() { ", its board as filmed last" } else { "" }));-                    let caption = format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=h-th-14:fontsize=20:fontcolor=0xF2E9E1:box=1:[email protected]:boxborderw=8");-                    let okp = match &shot {-                        Some(img) => run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{t_step:.3}"), "-i", img, "-vf", &format!("{fit_col},{caption}"), "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),-                        None => false,-                    };-                    if !okp { let _ = run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={t_step:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{msg}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE"), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]); }-                }-                if col.is_file() { cols.push(col); } else { eprintln!("side by side, step {step}, {name}: its column did not render; the step is left out"); }-            }-            if cols.len() == 2 {-                let (ma, mb) = (mins(&ra, step), mins(&rb, step));-                let seg = workdir.join(format!("seg-{}.mp4", parts.len()));-                let fc = frame(&step_shown(step), &format!("{na} on the left, {nb} on the right; each run's own clocks in its own corner"), Some(((ma.0, ma.1), (mb.0, mb.1))));-                if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {-                    let vlen = media_seconds(seg.to_str().unwrap());-                    let said = step_said(step);-                    let tiers = if step == "finish" {-                        let (d_a, d_b) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-                        let (ret_a, ret_b) = (a.returns_before_delivery(), b.returns_before_delivery());-                        vec![format!("Delivered. {na}: {d_a:.0} minutes of AI time from the prompt to done, {ret_a} returns to earlier steps. {nb}: {d_b:.0} minutes, {ret_b} returns."), format!("Delivered. {na} {d_a:.0} minutes, {nb} {d_b:.0}."), "Delivered.".to_string()]-                    } else {-                        vec![format!("{said}. {na}: {:.0} minutes of AI time in {} visit{}. {nb}: {:.0} minutes in {} visit{}.", ma.0, ma.1, if ma.1 == 1 { "" } else { "s" }, mb.0, mb.1, if mb.1 == 1 { "" } else { "s" }), format!("{said}. {na} {:.0} minutes, {nb} {:.0}.", ma.0, mb.0), format!("{said}.")]-                    };-                    let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));-                    let oka = match &scripted_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };-                    if oka { parts.push((seg, aseg)); n_steps += 1; }-                }-            }-        }-        // the drawings of both, side by side-        let (dra, drb) = (da.get(step).cloned().unwrap_or_default(), db.get(step).cloned().unwrap_or_default());-        for k in 0..dra.len().max(drb.len()) {-            let pair_words = script.and_then(|m| m.get(&format!("drawings:{step}:{k}")).or_else(|| m.get(&format!("drawings:{step}"))).filter(|_| k == 0 || m.contains_key(&format!("drawings:{step}:{k}")))).and_then(|v| v.as_str()).map(str::to_string);-            let pair_audio = pair_words.as_ref().and_then(|w| say_len(2000 + si * 10 + k, w));-            let still_len = match &pair_audio { Some((_, alen)) => alen + 0.3, None => still_seconds };-            let mut cols: Vec<PathBuf> = Vec::new();-            for (side, (name, d)) in [(&na, dra.get(k)), (&nb, drb.get(k))].into_iter().enumerate() {-                let col = workdir.join(format!("still-{si}-{k}-{side}.mp4"));-                let okc = match d {-                    Some((file, _, off)) => {-                        let vf = format!("scale=1280:-2,{},{fit_col}", overlay_filters(dir, &format!("{}   ·   the AI's drawing", step_shown(step)), &format!("{name}   ·   {board}"), *off, 0.0, hours));-                        run_ffmpeg(&["-loop", "1", "-framerate", "4", "-t", &format!("{still_len:.2}"), "-i", file, "-vf", &vf, "-r", "30", "-pix_fmt", "yuv420p", "-an", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()])-                    }-                    None => run_ffmpeg(&["-f", "lavfi", "-i", &format!("color=c=0x0B1420:s={cw}x{ch}:r=30:d={still_len:.3}"), "-vf", &format!("drawtext=fontfile={ft}:text='{}':x=(w-tw)/2:y=(h-th)/2:fontsize=22:fontcolor=0xA0AFBE", dt(&format!("{name}: no drawing for this step"))), "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", col.to_str().unwrap()]),-                };-                if okc { cols.push(col); }-            }-            if cols.len() != 2 { continue; }-            let seg = workdir.join(format!("seg-{}.mp4", parts.len()));-            let (ma, mb) = (mins(&ra, step), mins(&rb, step));-            let fc = frame(&step_shown(step), &format!("the AIs' own drawings: {na} on the left, {nb} on the right"), Some(((ma.0, ma.1), (mb.0, mb.1))));-            if run_ffmpeg(&["-i", cols[0].to_str().unwrap(), "-i", cols[1].to_str().unwrap(), "-filter_complex", &fc, "-r", "30", "-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", seg.to_str().unwrap()]) {-                let vlen = media_seconds(seg.to_str().unwrap());-                let cap = dra.get(k).map(|x| x.1.clone()).or_else(|| drb.get(k).map(|x| x.1.clone())).unwrap_or_default();-                let tiers = vec![format!("The {} drawings, {na} left, {nb} right. {}", step_said(step), first_clause(&cap)), format!("The {} drawings, {na} left, {nb} right.", step_said(step)), format!("{} drawings.", step_said(step))];-                let aseg = workdir.join(format!("aud-{}.m4a", parts.len()));-                let oka = match &pair_audio { Some((audio, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]), None => audio_for(&workdir, parts.len(), &tiers, vlen, has_tts, &aseg) };-                if oka { parts.push((seg, aseg)); n_pairs += 1; }-            }-        }-        let _ = &mut seg_index;-    }-    if parts.is_empty() { err("nothing to compose side by side: no step with motion in either run", &[]); }-    assemble(&workdir, &parts, outp);-    (media_seconds(outp.to_str().unwrap()), n_steps, n_pairs, has_tts)-}--/// The narration for one segment, exactly `vlen` seconds long (silence when there is nothing to say).-fn audio_for(workdir: &Path, k: usize, tiers: &[String], vlen: f64, has_tts: bool, aseg: &Path) -> bool {-    match narrate(workdir, k, tiers, vlen, has_tts) {-        Some((audio, _, _)) => run_ffmpeg(&["-i", audio.to_str().unwrap(), "-af", &format!("apad,atrim=0:{vlen:.3}"), "-c:a", "aac", "-b:a", "128k", aseg.to_str().unwrap()]),-        None => run_ffmpeg(&["-f", "lavfi", "-i", "anullsrc=r=24000:cl=mono", "-t", &format!("{vlen:.3}"), "-c:a", "aac", "-b:a", "64k", aseg.to_str().unwrap()]),-    }-}--/// Concatenate the segments with their narration into the final file, re-encoded under the page's 10 MB cap when needed.-fn assemble(workdir: &Path, parts: &[(PathBuf, PathBuf)], outp: &Path) {-    let vlist = workdir.join("video.txt");-    let alist = workdir.join("audio.txt");-    std::fs::write(&vlist, parts.iter().map(|(v, _)| format!("file '{}'\n", v.display())).collect::<String>()).unwrap();-    std::fs::write(&alist, parts.iter().map(|(_, a)| format!("file '{}'\n", a.display())).collect::<String>()).unwrap();-    let vcat = workdir.join("video.mp4");-    let acat = workdir.join("audio.m4a");-    let okv = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", vlist.to_str().unwrap(), "-c", "copy", vcat.to_str().unwrap()]);-    let oka = run_ffmpeg(&["-f", "concat", "-safe", "0", "-i", alist.to_str().unwrap(), "-c", "copy", acat.to_str().unwrap()]);-    let okf = okv && oka && run_ffmpeg(&["-i", vcat.to_str().unwrap(), "-i", acat.to_str().unwrap(), "-c:v", "copy", "-c:a", "aac", "-shortest", outp.to_str().unwrap()]);-    if !okf { err("ffmpeg could not assemble the video and its narration", &[]); }-    let cap_bytes: u64 = 9_800_000;-    if std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0) > cap_bytes {-        let secs = media_seconds(outp.to_str().unwrap()).max(1.0);-        let kbps = (((cap_bytes as f64 * 8.0 / secs) / 1000.0) - 140.0).max(300.0).floor();-        let fitted = workdir.join("fitted.mp4");-        if run_ffmpeg(&["-i", outp.to_str().unwrap(), "-c:v", "libx264", "-preset", "slow", "-b:v", &format!("{kbps:.0}k"), "-maxrate", &format!("{:.0}k", kbps * 1.3), "-bufsize", &format!("{:.0}k", kbps * 2.0), "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", fitted.to_str().unwrap()]) {-            let _ = std::fs::copy(&fitted, outp);-            eprintln!("the cut was over the page's 10 MB cap; re-encoded at {kbps:.0} kbit/s to {} bytes", std::fs::metadata(outp).map(|m| m.len()).unwrap_or(0));-        }-    }-}--/// The comparison sub-README: this run (left) against another (right). Returns (rel dir, url, files to push).-fn build_comparison(a: &Run, b: &Run, dir: &Path, page: &str) -> (String, String, Vec<String>) {-    let (ida, idb) = (report_id(a), report_id(b));-    let rel = format!("docs/comparisons/{ida}-vs-{idb}");-    let stage = dir.join("report");-    let out = stage.join(&rel);-    let _ = std::fs::remove_dir_all(&out);-    let _ = std::fs::create_dir_all(&out);-    let slug = page.split('/').nth(1).unwrap_or(page);-    let blob = format!("/blob/app/{slug}/{rel}");-    let (na, nb) = (a.data["engine"].as_str().unwrap_or("A").to_string(), b.data["engine"].as_str().unwrap_or("B").to_string());-    // the board by the spec's name when it has one (ESC G431), else by its file-    let board = spec_of(a).get("board").and_then(|v| v.as_str()).map(|v| v.split('(').next().unwrap_or(v).trim().to_string()).filter(|v| !v.is_empty()).unwrap_or_else(|| Path::new(a.data["sourceBoard"].as_str().or(a.data["boards"][0].as_str()).unwrap_or("board")).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string());-    let mut files: Vec<String> = Vec::new();-    let mut md = String::new();-    md.push_str(&format!("# {na} versus {nb} on the {board}\n\n"));-    md.push_str("Two AIs, the same board, the same spec, the same prompt, the same flow (Adom's AI Flow), measured the same way: the AI's own time from the prompt to \"done, here is your video\", every return to an earlier step, every clip, and the analysis each AI did itself. Nobody had put two engines through one measured board flow before; this page is the first pair.\n\n");-    // the video first-    if let Some(v) = a.data["comparison"]["video"].as_str().filter(|v| Path::new(v).is_file()) {-        let stamp: String = a.ledger().iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison").and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());-        let name = format!("comparison-{stamp}.mp4");-        let _ = std::fs::copy(v, out.join(&name)); files.push(format!("{rel}/{name}"));-        md.push_str(&format!("**The side by side, {:.0} s: {na} on the left, {nb} on the right, step by step in the flow's order; each run's own clocks in its own corner (AI time, the idle cut out); both AIs' analysis drawings side by side; each step's AI-minute bars below; the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", a.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));-    }-    // the number-    let (da, db) = (a.delivered_active_minutes().unwrap_or(0.0), b.delivered_active_minutes().unwrap_or(0.0));-    let (ra, rb) = (a.step_report(), b.step_report());-    let turns = |r: &Run| r.data["turns"].as_array().map(|x| x.len()).unwrap_or(0);-    let think = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["thinkingSeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;-    let tool = |rep: &serde_json::Map<String, Value>| rep.values().map(|e| e["binarySeconds"].as_u64().unwrap_or(0)).sum::<u64>() as f64 / 60.0;-    md.push_str("## The number: prompt to \"done, here is your video\"\n\n");-    md.push_str("| | AI time to delivery | Returns to earlier steps | Turns | AI thinking min | Binary min |\n|---|---|---|---|---|---|\n");-    md.push_str(&format!("| **{na}** | **{da:.1} min** | {} | {} | {:.1} | {:.1} |\n", a.returns_before_delivery(), turns(a), think(&ra), tool(&ra)));-    md.push_str(&format!("| **{nb}** | **{db:.1} min** | {} | {} | {:.1} | {:.1} |\n\n", b.returns_before_delivery(), turns(b), think(&rb), tool(&rb)));-    md.push_str("AI time is the sum of the run's sessions, one per human prompt, from the prompt to the AI's done, with every idle gap over 15 minutes (the human away) cut out. Follow-up prompts after delivery (the walkthroughs, the drawings, the re-cuts) are on each run's page under Sessions and are not in the delivery number.\n\n");-    // per step-    let mut steps: Vec<String> = a.data["flow"]["steps"].as_array().map(|x| x.iter().filter_map(|s| s["name"].as_str().map(str::to_string)).collect()).unwrap_or_default();-    for k in ra.keys().chain(rb.keys()) { if !steps.contains(k) { steps.push(k.clone()); } }-    md.push_str(&format!("## Per step, AI time\n\n| Step | {na} | {nb} |\n|---|---|---|\n"));-    for st in &steps {-        let cell = |rep: &serde_json::Map<String, Value>| rep.get(st).map(|e| format!("{:.1} min, {} visit{}{}", e["wallSeconds"].as_u64().unwrap_or(0) as f64 / 60.0, e["visits"], if e["visits"].as_u64() == Some(1) { "" } else { "s" }, if e["returns"].as_u64().unwrap_or(0) > 0 { format!(", {} return{}", e["returns"], if e["returns"].as_u64() == Some(1) { "" } else { "s" }) } else { String::new() })).unwrap_or_else(|| "-".into());-        if ra.contains_key(st) || rb.contains_key(st) { md.push_str(&format!("| {st} | {} | {} |\n", cell(&ra), cell(&rb))); }-    }-    md.push_str("\n");-    // the drawings side by side-    let draw = |r: &Run| -> Vec<(String, String, String)> { standing_artifacts(&r.ledger()).into_iter().filter(|e| matches!(e["kind"].as_str(), Some("analysis-image") | Some("image"))).filter_map(|e| { let f = e["file"].as_str()?; let local = if Path::new(f).is_file() { f.to_string() } else { let l = r.dir.join(Path::new(f).file_name()?); if l.is_file() { l.display().to_string() } else { return None } }; Some((e["step"].as_str().unwrap_or("").to_string(), local, e["caption"].as_str().unwrap_or("").to_string())) }).collect() };-    let (dra, drb) = (draw(a), draw(b));-    if !dra.is_empty() || !drb.is_empty() {-        md.push_str("## The analysis each AI did itself\n\nEach AI solved the current and the heat on its own board and drew it; the pictures are theirs, not the binary's. Left, then right, per step.\n\n");-        for st in &steps {-            let la: Vec<&(String, String, String)> = dra.iter().filter(|d| d.0 == *st).collect();-            let lb: Vec<&(String, String, String)> = drb.iter().filter(|d| d.0 == *st).collect();-            for k in 0..la.len().max(lb.len()) {-                md.push_str("<table><tr>");-                for (side, name, it) in [("a", &na, la.get(k)), ("b", &nb, lb.get(k))] {-                    match it {-                        Some((_, file, cap)) => { let fname = format!("{side}-{st}-{k}.{}", Path::new(file).extension().and_then(|x| x.to_str()).unwrap_or("png")); let _ = std::fs::copy(file, out.join(&fname)); files.push(format!("{rel}/{fname}")); md.push_str(&format!("<td width=\"50%\"><b>{name}: {}</b><br><img src=\"{blob}/{fname}\" width=\"100%\"></td>", cap.replace('|', "/"))); }-                        None => md.push_str(&format!("<td width=\"50%\"><b>{name}: no drawing for this step</b></td>")),-                    }-                }-                md.push_str("</tr></table>\n\n");-            }-        }-    }-    // the links-    md.push_str("## The two runs, in full\n\n");-    md.push_str(&format!("- [{na}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{ida}/README.md): every step's clip, screenshots, sessions, returns, decisions, plan usage, what it gave back.\n"));-    md.push_str(&format!("- [{nb}: the run page](https://wiki.adom.inc/{page}/files/docs/runs/{idb}/README.md): the same for the other engine.\n"));-    md.push_str(&format!("- [The flow](https://wiki.adom.inc/{page}): Adom's AI Flow, the tool both ran.\n\n"));-    md.push_str("*Plan usage on each run page is per account, not per run: every AI thread signed in as that user draws on the same limits.*\n");-    let readme = out.join("README.md");-    std::fs::write(&readme, md).unwrap();-    files.push(format!("{rel}/README.md"));-    (rel.clone(), format!("https://wiki.adom.inc/{page}/files/{rel}/README.md"), files)-}--/// The newest screenshot of a step in a run directory (`shot-<step>-...png`), else the newest screenshot of any step.-fn latest_shot(dir: &Path, step: &str) -> Option<String> {-    let mut shots: Vec<(std::time::SystemTime, PathBuf)> = std::fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).filter(|e| e.file_name().to_string_lossy().starts_with("shot-") && e.file_name().to_string_lossy().ends_with(".png")).filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path()))).collect();-    shots.sort_by(|a, b| b.0.cmp(&a.0));-    let of_step = shots.iter().find(|(_, p)| p.file_name().map(|n| n.to_string_lossy().starts_with(&format!("shot-{step}-"))).unwrap_or(false));-    of_step.or(shots.first()).map(|(_, p)| p.display().to_string())-}--/// The give-back hint: what the AI learned belongs to every Adom user.-fn give_back_hint() -> String {-    "Give it back: if the binary was wrong, rigid or missing a step, or you wrote code that did a step better, file it on the page (`adom-wiki issue create adom/adom-aiflow --title \"...\" --body \"...\"`) or open a PR against its repo with the crate change. adom-aiflow is the token cache of every Adom user's flow: what your run figured out makes the next run cheaper for everyone.".to_string()-}---/// The run id used for its page folder: board stem, engine slug, prompt date.-fn report_id(r: &Run) -> String {-    if let Some(id) = r.data["reportId"].as_str() {-        return id.to_string();-    }-    let src = r.data["sourceBoard"].as_str().or_else(|| r.data["boards"][0].as_str()).unwrap_or("board");-    let board = Path::new(src).file_stem().and_then(|s| s.to_str()).unwrap_or("board").trim_end_matches("-0").to_string();-    let eng: String = r.data["engine"].as_str().unwrap_or("engine").to_lowercase().chars().map(|c| if c.is_alphanumeric() { c } else { '-' }).collect::<String>().split('-').filter(|s| !s.is_empty()).collect::<Vec<_>>().join("-");-    // prompt date and time, so two runs by one engine on one day get their own folders-    let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("");-    let date = pt.chars().take(10).filter(|c| *c != '-').collect::<String>();-    let hhmm = pt.chars().skip(11).take(5).filter(|c| *c != ':').collect::<String>();-    format!("{board}-{eng}-{date}-{hhmm}")-}--/// Build the sub-README and its files under <run>/report/docs/runs/<id>/; returns (staging dir, rel dir, README path).-fn build_report(r: &Run, dir: &Path, page: &str) -> (PathBuf, String, PathBuf) {-    let id = report_id(r);-    let rel = format!("docs/runs/{id}");-    let stage = dir.join("report");-    let out = stage.join(&rel);-    // the staging folder is rebuilt from nothing every time, so an older cut or clip never rides along-    let _ = std::fs::remove_dir_all(&out);-    let _ = std::fs::create_dir_all(&out);-    let slug = page.split('/').nth(1).unwrap_or(page);-    let blob = format!("/blob/app/{slug}/{rel}");-    let led = r.ledger();-    let engine = r.data["engine"].as_str().unwrap_or("");-    let prompt = r.data["clock"]["promptTime"].as_str().unwrap_or("");-    let finished = r.data["clock"]["finishTime"].as_str();-    let delivered = r.data["clock"]["deliveredTime"].as_str();-    let cur = r.data["currentStep"].as_str().unwrap_or("intake");-    let now_min = r.elapsed_minutes();-    let mut md = String::new();-    md.push_str(&format!("# AI Flow run: {} on {}\n\n", engine, Path::new(r.data["boards"][0].as_str().unwrap_or("")).file_stem().and_then(|s| s.to_str()).unwrap_or("the board")));-    let ss = r.sessions();-    let later: f64 = (r.active_minutes_total() - r.delivered_active_minutes().unwrap_or(0.0)).max(0.0);-    let status = match (delivered, finished) {-        (Some(d), _) => format!("**Delivered** at {d}: **{:.1} min of AI time** from the prompt to \"done, here is your video\" ({:.1} min on the wall clock). {}", r.delivered_active_minutes().unwrap_or(0.0), r.delivered_minutes().unwrap_or(0.0), if ss.len() > 1 { format!("Since then {later:.1} min more of AI time over {} follow-up prompt(s), listed under Sessions; none of it is in the delivery number.", ss.len() - 1) } else { String::new() }),-        (None, Some(f)) => format!("**Finished** (qualified board) at {f}, {:.1} min of AI time from the prompt; the video is being cut.", r.active_minutes_total()),-        _ => format!("**Running**: step `{cur}`, {:.1} min of AI time since the prompt ({:.1} min on the wall clock). This page updates at every step change.", r.active_minutes_total(), now_min),-    };-    md.push_str(&format!("{status}\n\nPrompt at {prompt} (UTC). Flow: {}. Every number here is from the run's append-only ledger (run.jsonl). AI time is the time the AI was working on a prompt: from the human's prompt to the AI's done, with any gap over {} min where no command ran (the human away) cut out.\n\n", r.data["flow"]["name"].as_str().unwrap_or("board"), r.idle_minutes()));-    if let Some(d) = r.data["delivery"].as_object() {-        if let Some(v) = d.get("video").and_then(|v| v.as_str()) {-            if Path::new(v).is_file() {-                // a new name for every cut, so a browser that cached the last one shows this one-                let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).filter(|s: &String| !s.is_empty()).unwrap_or_else(|| std::fs::metadata(v).and_then(|m| m.modified()).ok().and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs().to_string()).unwrap_or_else(|| "0".into()));-                let name = format!("final-{stamp}.mp4");-                let _ = std::fs::copy(v, out.join(&name));-                let vs = media_seconds(v);-                md.push_str(&format!("## The video\n\n**The final cut of this run, {vs:.0} s: only the moments something moved, every step in order, the AI's analysis drawings fullscreen, the run and step clocks in the corner, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n"));-            }-        }-    }-    if let Some(v) = r.data["comparison"]["video"].as_str() {-        if Path::new(v).is_file() {-            let stamp: String = led.iter().rev().find(|e| e["event"] == "artifact" && e["kind"] == "video-comparison" && e["file"].as_str() == Some(v)).and_then(|e| e["t"].as_str()).map(|t| t.chars().filter(|c| c.is_ascii_digit()).take(14).collect()).unwrap_or_else(|| "0".into());-            let name = format!("comparison-{stamp}.mp4");-            let _ = std::fs::copy(v, out.join(&name));-            md.push_str(&format!("## Side by side with {}\n\n**{} on the left, {} on the right, step by step in flow order, {:.0} s: each run's own clocks in its own corner, both AIs' drawings side by side, the words cut to the picture.**\n\n<video width=\"100%\" controls><source src=\"{blob}/{name}\" type=\"video/mp4\"></video>\n\n", r.data["comparison"]["withEngine"].as_str().unwrap_or("another run"), engine, r.data["comparison"]["withEngine"].as_str().unwrap_or(""), r.data["comparison"]["seconds"].as_f64().unwrap_or(0.0)));-        }-    }-    // sessions-    md.push_str("## Sessions\n\nOne row per human prompt: the AI's time on it, the idle it did not get charged for.\n\n");-    md.push_str(&sessions_table(r));-    md.push_str("\n");-    // per-step table-    let rep = r.step_report();-    let order = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"];-    let mut names: Vec<String> = order.iter().map(|s| s.to_string()).filter(|n| rep.contains_key(n)).collect();-    for k in rep.keys() { if !names.contains(k) { names.push(k.clone()); } }-    md.push_str("## Per step\n\nAI time only, the idle cut out.\n\n| Step | AI min | AI thinking min | Tool min | Turns | Visits | Returns | Longest think |\n|---|---|---|---|---|---|---|---|\n");-    let (mut tw, mut tt, mut tb) = (0u64, 0u64, 0u64);-    for n in &names {-        let e = &rep[n];-        let (w, t, b) = (e["wallSeconds"].as_u64().unwrap_or(0), e["thinkingSeconds"].as_u64().unwrap_or(0), e["binarySeconds"].as_u64().unwrap_or(0));-        tw += w; tt += t; tb += b;-        md.push_str(&format!("| {} | {:.1} | {:.1} | {:.1} | {} | {} | {} | {:.1} min |\n", n, w as f64 / 60.0, t as f64 / 60.0, b as f64 / 60.0, e["turns"], e["visits"], e["returns"], e["longestThinkSeconds"].as_u64().unwrap_or(0) as f64 / 60.0));-    }-    md.push_str(&format!("| **total** | **{:.1}** | **{:.1}** | **{:.1}** | | | | |\n\n", tw as f64 / 60.0, tt as f64 / 60.0, tb as f64 / 60.0));-    // returns-    let mut returns: Vec<String> = Vec::new();-    for e in &led {-        if e["event"] == "step" && e["back"].as_bool() == Some(true) {-            returns.push(format!("- {} back to **{}** (visit {}): {}", e["t"].as_str().unwrap_or(""), e["step"].as_str().unwrap_or(""), e["visit"], e["why"].as_str().unwrap_or("")));-        }-    }-    if !returns.is_empty() { md.push_str("## Returns to earlier steps\n\n"); md.push_str(&returns.join("\n")); md.push_str("\n\n"); }-    // decisions and markers-    let mut marks: Vec<String> = Vec::new();-    for e in &led { if e["event"] == "marker" { marks.push(format!("- {} {}", e["t"].as_str().unwrap_or(""), e["label"].as_str().unwrap_or(""))); } }-    if !marks.is_empty() { md.push_str("## Decisions along the way\n\n"); md.push_str(&marks.join("\n")); md.push_str("\n\n"); }-    // outcomes-    let o = r.outcomes();-    md.push_str("## Where the board stands\n\n");-    if let Some(x) = o.get("route") { md.push_str(&format!("- routing: {} unrouted after {} passes\n", x["unrouted"], x["passes"])); }-    if let Some(x) = o.get("gate") { md.push_str(&format!("- gate: {} new DRC errors, {} unconnected, {} inherited\n", x["newErrors"], x["unconnected"], x["inheritedErrors"])); }-    if let Some(x) = o.get("land-route") { md.push_str(&format!("- landed live: {} of {} route entries\n", x["accepted"], x["entries"])); }-    if let Some(x) = o.get("land-pours") { md.push_str(&format!("- pours: {} groups, failed {}\n", x["groups"].as_array().map(|a| a.len()).unwrap_or(0), x["failed"])); }-    if let Some(x) = o.get("measure") { md.push_str(&format!("- copper kept (mm2 per layer): {}\n", x["filledAreaByLayerMm2"])); }-    for k in ["analyze-current", "analyze-thermal"] { if let Some(x) = o.get(k) { md.push_str(&format!("- {k}: {}\n", if x["pass"].as_bool() == Some(true) { "pass" } else { "fail" })); } }-    md.push_str("\n");-    // clips and screenshots per visit-    md.push_str("## The steps on camera\n\nOne clip per step visit, sped up 10x, with the editor at the start and the end of the visit. A caption above every clip and screenshot says what it shows.\n\n");-    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-    // clip seconds from the ledger's artifacts-    let mut secs: std::collections::BTreeMap<String, (f64, f64, f64)> = std::collections::BTreeMap::new(); // real, 10x, action-    for e in &led {-        if e["event"] == "artifact" {-            let key = e["step"].as_str().unwrap_or("").to_string();-            let entry = secs.entry(key).or_insert((0.0, 0.0, 0.0));-            match e["kind"].as_str() { Some("clip") => entry.0 = e["seconds"].as_f64().unwrap_or(0.0), Some("clip10x") => entry.1 = e["seconds"].as_f64().unwrap_or(0.0), Some("clipAction") => entry.2 = e["seconds"].as_f64().unwrap_or(0.0), _ => {} }-        }-    }-    let standing = standing_artifacts(&led);-    let what_of = |step: &str| -> String { r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(step))).and_then(|s| s["does"].as_str()).unwrap_or("").to_string() };-    for c in &caps {-        if c.get("discarded").is_some() { continue; }-        let Some(step) = c["step"].as_str() else { continue };-        let visit = c["visit"].as_u64().unwrap_or(1);-        let tag = format!("{step}-{visit}");-        let rework = c["back"].as_bool() == Some(true);-        let title = if visit > 1 { format!("{step}, visit {visit}{}", if rework { " (rework)" } else { "" }) } else { step.to_string() };-        md.push_str(&format!("### {title}\n\n"));-        md.push_str(&format!("What this step does: {}\n\n", what_of(step)));-        if let Some(why) = c["why"].as_str() { md.push_str(&format!("Why the AI came back here: {why}\n\n")); }-        if let Some(sus) = c["suspect"].as_str() { md.push_str(&format!("**This clip filmed nothing usable: {sus}. It is kept as evidence and left out of the video.**\n\n")); }-        let (start, stop) = (c["start"].as_str().unwrap_or(""), c["stop"].as_str().unwrap_or("still running"));-        // the 10x cut next to the raw clip-        let mut have_clip = false;-        if let Some(f) = c["file"].as_str() {-            let action = format!("{}-action.mp4", f.trim_end_matches(".mp4"));-            let fast = format!("{}-10x.mp4", f.trim_end_matches(".mp4"));-            let (real, quick, act) = secs.get(&tag).copied().unwrap_or((0.0, 0.0, 0.0));-            if Path::new(&action).is_file() {-                let _ = std::fs::copy(&action, out.join(format!("{tag}.mp4")));-                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}. Motion only: {:.1} min of real time, {:.0} s of it had something moving; the clock in the corner is the real elapsed time.**\n\n", real / 60.0, act));-                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));-                have_clip = true;-            } else if Path::new(&fast).is_file() {-                let _ = std::fs::copy(&fast, out.join(format!("{tag}.mp4")));-                md.push_str(&format!("**Clip: {title}, the PCB editor from {start} to {stop}, {:.1} min of real time shown at 10x ({:.0} s).**\n\n", real / 60.0, quick));-                md.push_str(&format!("<video width=\"100%\" controls><source src=\"{blob}/{tag}.mp4\" type=\"video/mp4\"></video>\n\n"));-                have_clip = true;-            }-        }-        if !have_clip { md.push_str(&format!("**Clip: {title}, still recording (started {start}); it appears here when the step ends.**\n\n")); }-        if let Some(f) = c["file"].as_str() {-            let sheet = format!("{}-sheet.png", f.trim_end_matches(".mp4"));-            if Path::new(&sheet).is_file() {-                let _ = std::fs::copy(&sheet, out.join(format!("{tag}-sheet.png")));-                md.push_str(&format!("**Contact sheet: nine frames spread through the {title} clip, what the camera saw.**\n\n![nine frames of {title}]({blob}/{tag}-sheet.png)\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" }));-            }-        }-        // what the AI made during this visit: its own analysis drawings, with its caption above each-        for (n, e) in standing.iter().filter(|e| e["step"].as_str() == Some(step) && e["visit"].as_u64() == Some(visit)).enumerate() {-            let Some(f) = e["file"].as_str() else { continue };-            if !Path::new(f).is_file() { continue; }-            let ext = Path::new(f).extension().and_then(|x| x.to_str()).unwrap_or("png");-            let name = format!("{tag}-{}-{n}.{ext}", e["kind"].as_str().unwrap_or("artifact"));-            let _ = std::fs::copy(f, out.join(&name));-            let cap = e["caption"].as_str().unwrap_or("");-            if matches!(ext.to_ascii_lowercase().as_str(), "png" | "jpg" | "jpeg" | "webp") {-                md.push_str(&format!("**The AI's drawing: {cap}**\n\n![{cap}]({blob}/{name})\n\n"));-            } else {-                md.push_str(&format!("**The AI's file: {cap}** [{name}]({blob}/{name})\n\n"));-            }-        }-    }-    if let Some(d) = r.data["delivery"].as_object() {-        md.push_str("## Delivered\n\n");-        md.push_str(&format!("What the AI said when it delivered (the video is at the top of this page):\n\n> {}\n\n", d.get("message").and_then(|m| m.as_str()).unwrap_or("")));-    }-    let ud = usage_delta(r);-    if !ud.is_empty() {-        md.push_str("## Plan usage, the second cost line\n\nPercent of each plan limit used, as the provider reports it, at the start of the run and at its end.\n\n| Provider | Limit | At start | At end | Used by this run |\n|---|---|---|---|---|\n");-        for x in &ud { md.push_str(&format!("| {} | {} | {:.0} % | {:.0} % | {:.1} % |\n", x["provider"].as_str().unwrap_or(""), x["label"].as_str().unwrap_or(""), x["fromPct"].as_f64().unwrap_or(0.0), x["toPct"].as_f64().unwrap_or(0.0), x["usedPct"].as_f64().unwrap_or(0.0))); }-        md.push_str("\n*The limits are per account, not per run: every AI thread signed in as this user draws on the same numbers, so a run made while other threads were working shows their usage too. Read it as an upper bound unless this run was the only thread active.*\n\n");-    }-    let gb = r.data["giveback"].as_array().cloned().unwrap_or_default();-    md.push_str(&format!("## Given back\n\n{} issue(s) or PR(s) filed by this run's AI to make the tool better for every Adom user{}\n", gb.len(), if gb.is_empty() { ".".to_string() } else { ":\n\n".to_string() + &gb.iter().map(|g| format!("- {} {}{}", g["kind"].as_str().unwrap_or("issue"), g["url"].as_str().unwrap_or(""), g["note"].as_str().map(|n| format!(": {n}")).unwrap_or_default())).collect::<Vec<_>>().join("\n") }));-    md.push_str("\nAdom processes PRs within minutes during business hours: a non-stop PR intake runs all day.\n");-    let readme = out.join("README.md");-    let _ = std::fs::write(&readme, md);-    (stage, rel, readme)-}--/// Push the report to the page's repo and reload it in the human's browser; returns the page URL.-fn publish_report(r: &Run, dir: &Path, page: &str, push: bool, refresh: bool, thread: &str) -> (String, Vec<String>) {-    let (stage, rel, _readme) = build_report(r, dir, page);-    // the files viewer renders a sub-README as a page; the bare path serves raw markdown-    let url = format!("https://wiki.adom.inc/{page}/files/{rel}/README.md");-    let mut notes = Vec::new();-    if push {-        // only the files that changed since the last push go up, in batches the gateway takes-        // (about 20 MB each; a 75 MB report in one request came back 502); a failed batch is said-        // out loud and the caller turns it into an ERROR-        let cache_path = dir.join("report").join(".pushed.json");-        let mut cache: serde_json::Map<String, Value> = std::fs::read_to_string(&cache_path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();-        let mut files: Vec<(String, u64, String)> = Vec::new();-        if let Ok(d) = std::fs::read_dir(stage.join(&rel)) {-            for e in d.filter_map(|e| e.ok()) {-                let name = e.file_name().to_string_lossy().to_string();-                if name.starts_with('.') { continue; }-                let size = e.metadata().map(|m| m.len()).unwrap_or(0);-                let sum = std::process::Command::new("sha256sum").arg(e.path()).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().unwrap_or("").to_string()).unwrap_or_default();-                files.push((format!("{rel}/{name}"), size, sum));-            }-        }-        files.sort();-        let changed: Vec<&(String, u64, String)> = files.iter().filter(|(p, _, sum)| cache.get(p).and_then(|v| v.as_str()) != Some(sum.as_str()) || sum.is_empty()).collect();-        let (mut pushed, mut failed) = (0usize, 0usize);-        let mut batches: Vec<Vec<&(String, u64, String)>> = vec![Vec::new()];-        let mut acc = 0u64;-        for f in &changed {-            if acc + f.1 > 20_000_000 && !batches.last().unwrap().is_empty() { batches.push(Vec::new()); acc = 0; }-            batches.last_mut().unwrap().push(f); acc += f.1;-        }-        for batch in batches.iter().filter(|b| !b.is_empty()) {-            let mut cmd = std::process::Command::new("adom-wiki");-            cmd.current_dir(&stage).args(["repo", "push", page, "-m", &format!("aiflow run {}: {}", report_id(r), r.data["currentStep"].as_str().unwrap_or("update")), "--files"]);-            for f in batch { cmd.arg(&f.0); }-            match cmd.output() {-                Ok(o) if o.status.success() && !String::from_utf8_lossy(&o.stdout).contains("502") => { pushed += batch.len(); for f in batch { cache.insert(f.0.clone(), json!(f.2)); } }-                Ok(o) => { failed += batch.len(); notes.push(format!("push failed for {} file(s): {}", batch.len(), String::from_utf8_lossy(&o.stderr).chars().rev().take(160).collect::<String>().chars().rev().collect::<String>())); }-                Err(e) => { failed += batch.len(); notes.push(format!("push failed: {e}")); }-            }-        }-        let _ = std::fs::write(&cache_path, serde_json::to_string(&Value::Object(cache)).unwrap_or_default());-        notes.insert(0, format!("pushed {pushed} changed file(s) to {page}/{rel} ({} unchanged skipped{})", files.len() - changed.len(), if failed > 0 { format!("; {failed} FAILED") } else { String::new() }));-    }-    if refresh && r.data["reportSurface"].as_str() == Some("webview") {-        // a tab in the Hydrogen workspace this AI runs in: the tab's name is the stable handle-        let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-        let ws = std::process::Command::new("adom-cli").args(["hydrogen", "workspace", "get"]).output().ok().and_then(|o| serde_json::from_slice::<Value>(&o.stdout).ok());-        fn leaves(n: &Value, out: &mut Vec<String>) { if let Some(o) = n.as_object() { if o.get("type").and_then(|t| t.as_str()) == Some("leaf") { if let Some(id) = o.get("id").and_then(|i| i.as_str()) { out.push(id.to_string()); } } for v in o.values() { leaves(v, out); } } else if let Some(a) = n.as_array() { for v in a { leaves(v, out); } } }-        let mut ids = Vec::new();-        if let Some(w) = &ws { leaves(&w["root"], &mut ids); }-        let focused = ws.as_ref().and_then(|w| w["focusedPanelId"].as_str()).unwrap_or("").to_string();-        let panel = ids.iter().find(|i| **i != focused).or(ids.first()).cloned();-        match panel {-            Some(pid) => {-                let o = std::process::Command::new("adom-cli").args(["hydrogen", "webview", "open-or-refresh", "--name", "AI Flow run", "--url", &bust, "--panel-id", &pid]).output();-                notes.push(match o { Ok(o) if o.status.success() => "refreshed in the Hydrogen webview tab \"AI Flow run\"".to_string(), Ok(o) => format!("webview: {}", String::from_utf8_lossy(&o.stderr).chars().take(160).collect::<String>()), Err(e) => format!("webview: {e}") });-            }-            None => notes.push("webview: no Hydrogen workspace panel found (is adom-cli hydrogen reachable?)".into()),-        }-    } else if refresh {-        if let Some(m) = r.data["userMachine"].as_str() {-            let br = Bridge { ai_thread: thread.to_string(), target: m.to_string() };-            let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-            let nav = br.call("nbrowser_navigate", &json!({"sessionId": "aiflow-run", "url": bust}));-            if !Bridge::ok(&nav) {-                let op = br.call("nbrowser_open_window", &json!({"sessionId": "aiflow-run", "url": bust, "thread": thread, "purpose": "show the human the live progress page of this AI Flow run"}));-                notes.push(if Bridge::ok(&op) { format!("opened in the browser on {m}") } else { format!("browser on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });-            } else {-                notes.push(format!("refreshed in the browser on {m}"));-            }-            let _ = br.call("nbrowser_flash", &json!({"sessionId": "aiflow-run"}));-        }-    }-    (url, notes)-}--fn board_of(r: &Run) -> Board {-    Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[]))-}--fn bridge_of(r: &Run, cli: &Cli) -> Bridge {-    let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();-    Bridge { ai_thread: thread(cli), target }-}--fn write_json(p: &Path, v: &Value) {-    std::fs::write(p, serde_json::to_string_pretty(v).unwrap()).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[]));-}--/// The command as typed, without the thread and run plumbing.-fn command_line() -> String {-    let args: Vec<String> = std::env::args().skip(1).collect();-    let mut out = Vec::new();-    let mut skip = false;-    for a in args {-        if skip { skip = false; continue; }-        if a == "--ai-thread" || a == "--run" { skip = true; continue; }-        if a.starts_with("--ai-thread=") || a.starts_with("--run=") { continue; }-        out.push(a);-    }-    out.join(" ")-}--fn main() {-    let cli = Cli::parse();-    let dir = run_dir(&cli);-    // every command is a turn in the run's ledger; the gap before it is the AI's thinking time-    if !matches!(cli.cmd, Cmd::Start { .. }) {-        if let Some(mut r) = Run::open(&dir) {-            let idx = r.turn_begin(&command_line());-            let _ = r.save();-            let _ = TURN.set((dir.clone(), idx));-            // the clip guard: a recording that has outrun its step's budget is a mistake, not evidence.-            // Stop it here, on whatever command comes next, before it eats the disk (one once ran-            // nine hours on a static editor because deliver did not stop it).-            if let (Some(target), Some(t)) = (r.data["target"].as_str().map(str::to_string), cli.ai_thread.clone()) {-                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                    let started = caps[pos]["start"].as_str().and_then(aiflow_run::parse).unwrap_or(u64::MAX);-                    let age = aiflow_run::parse(&now()).unwrap_or(0).saturating_sub(started);-                    let step = caps[pos]["step"].as_str().unwrap_or("").to_string();-                    let budget_min = match step.as_str() { "placement" => 40, "routing" => 30, "pours" => 15, "intake" => 10, "models" => 20, _ => 10 } as u64;-                    if age > budget_min * 60 * 2 || age > 3600 {-                        let br = Bridge { ai_thread: t, target };-                        if let Some(mut r2) = Run::open(&dir) {-                            r2.mark(&format!("clip guard: the {step} clip ran {} min, past its {budget_min} min budget twice over; stopped by the guard", age / 60));-                            stop_running_clip(&mut r2, &br, &dir, "guard");-                            let _ = r2.save();-                            eprintln!("clip guard: stopped the {step} clip after {} min (budget {budget_min} min)", age / 60);-                        }-                    }-                }-            }-        }-    }-    match &cli.cmd {-        Cmd::Start { board, spec, engine, prompt_time, target, remote_board, force } => {-            let t = thread(&cli);-            if !Path::new(board).is_file() {-                err(&format!("board not found: {board}"), &[]);-            }-            if !Path::new(spec).is_file() {-                err(&format!("spec not found: {spec}"), &["docs/spec-example.json on the adom-aiflow page is the shape.".into()]);-            }-            if let (Some(r), false) = (Run::open(&dir), *force) {-                err(&format!("a run already exists at {} (engine {}, prompt time {})", dir.display(), r.data["engine"], r.data["clock"]["promptTime"]), &["Pick another --run directory, or --force to start over.".into()]);-            }-            std::fs::create_dir_all(&dir).unwrap();-            let copy = dir.join("board-0.kicad_pcb");-            std::fs::copy(board, &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());-            record_usage(&mut r, "start");-            r.save().unwrap();-            let _ = TURN.set((dir.clone(), idx));-            ok(&format!("run started for engine {engine} at {}; the clock started at the prompt, {pt}", dir.display()), &[format!("Next: adom-aiflow plan --run {} (the stages this board needs and who can take each).", dir.display()), "Then name the run's page once: `report --page <owner/slug> --user-machine <the human's box> --push --refresh` (or `--surface webview` when you run inside Adom Hydrogen and the human watches a workspace tab). From then on the run's own README (steps, times, returns, every step's clip and screenshots) rebuilds, pushes and reloads in the human's browser at every step change, by itself.".into(), "Record the paste time exactly: --prompt-time 2026-09-14T15:04:05Z when you start late.".into()]);-        }-        Cmd::Plan => {-            let mut r = load_run(&cli);-            // the flow is a file: the steps in order, who does each, what the binary offers, what comes later-            let flow: Value = serde_json::from_str(include_str!("../../../flows/board.json")).unwrap();-            r.data["flow"] = flow.clone();-            let spec = spec_of(&r);-            let b = board_of(&r);-            let parked = b.footprints.values().filter(|f| f.x > b.bbox.2).count();-            let fixed = spec.get("fixedRefs").and_then(|f| f.as_array()).map(|a| a.len()).unwrap_or(0);-            let stages = json!({-                "place": {"engines": ["ai"], "default": "ai", "why": format!("{parked} parts parked outside the outline, {fixed} fixed by the interface; the AI places (kicad_placement_state, place pack/check/land help), the binary checks")},-                "route": {"engines": ["binary", "ai"], "default": "binary", "why": "the grid router (escapes, plane stubs, per-net vias, rip-up, Kelvin taps); an engine with its own router says take route=ai"},-                "pour": {"engines": ["binary", "ai"], "default": "binary", "why": "zones from spec.pours: ground both sides, rails, load paths, the shunt return, thermal vias, Kelvin keepouts"},-                "gate": {"engines": ["binary"], "default": "binary", "why": "KiCad DRC offline on the plan; inherited errors separated"},-                "land": {"engines": ["binary"], "default": "binary", "why": "replay through the bridge as native undo steps, DRC before each"},-                "measure": {"engines": ["binary"], "default": "binary", "why": "kicad_zone_state: filled copper per layer, the ablation metric"},-                "analyze-current": {"engines": ["binary"], "default": "binary", "why": "IPC-2221 on the narrowest conductor per loaded net, pours and via capacity"},-                "analyze-thermal": {"engines": ["binary"], "default": "binary", "why": "copper and vias at every hot tab in spec.hot"},-                "finish": {"engines": ["binary"], "default": "binary", "why": "refuses until 0 unconnected, 0 new errors, pours landed, both analyses pass, copper measured"},-            });-            r.data["plan"] = json!({"at": now(), "stages": stages, "board": {"footprints": b.footprints.len(), "nets": b.nets.len(), "parked": parked, "layers": b.copper_layers}});-            r.save().unwrap_or_else(|e| err(&e, &[]));-            let mut lines = vec![format!("plan for {}: {} footprints, {} nets, {} copper layers, {parked} parked", r.data["engine"].as_str().unwrap_or(""), b.footprints.len(), b.nets.len(), b.copper_layers.len())];-            lines.push(format!("flow \"{}\": {}", flow["name"].as_str().unwrap_or(""), flow["scope"].as_str().unwrap_or("")));-            for st in flow["steps"].as_array().cloned().unwrap_or_default() {-                lines.push(format!("  step {:<10} {:<7} {}", st["name"].as_str().unwrap_or(""), st["who"].as_str().unwrap_or(""), st["does"].as_str().unwrap_or("")));-            }-            lines.push(format!("  later: {}", flow["later"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str()).collect::<Vec<_>>().join(", ")).unwrap_or_default()));-            lines.push("stages the binary offers:".into());-            for n in STAGES {-                let s = &r.data["plan"]["stages"][n];-                lines.push(format!("{n:<16} {:<12} {}", s["engines"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join("/")).unwrap_or_default(), s["why"].as_str().unwrap_or("")));-            }-            ok(&lines.join("\n"), &[format!("Take a stage yourself with: adom-aiflow take route=ai --run {} (default: binary where it is offered).", dir.display()), "Every stage stamps the clock; for a stage you take yourself call `stage start <name>` and `stage end <name>` around your own work.".into()]);-        }-        Cmd::Take { decisions } => {-            thread(&cli);-            let mut r = load_run(&cli);-            for kv in decisions {-                let Some((stage, engine)) = kv.split_once('=') else { err(&format!("bad decision {kv}; use stage=engine"), &[]) };-                if !STAGES.contains(&stage) {-                    err(&format!("unknown stage {stage}"), &[format!("Stages: {}", STAGES.join(", "))]);-                }-                r.data["decisions"][stage] = json!(engine);-            }-            r.save().unwrap();-            ok(&format!("decisions recorded: {}", r.data["decisions"]), &["They are part of the run manifest and the comparison chart.".into()]);-        }-        Cmd::Stage { action, name, note, result } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if action == "start" {-                let e = r.engine_for(name);-                r.stage_start(name, Some(&e), note.as_deref());-                r.save().unwrap();-                ok(&format!("stage {name} started at {} by {e}", now()), &[]);-            } else {-                let res = result.as_deref().map(|s| serde_json::from_str(s).unwrap_or(json!(s)));-                r.stage_end(name, res, note.as_deref());-                r.save().unwrap();-                ok(&format!("stage {name} ended at {}", now()), &[format!("Minutes so far per stage: {}; {:.1} min since the prompt.", Value::Object(r.stage_minutes()), r.elapsed_minutes())]);-            }-        }-        Cmd::Place { what, wish, moves, out, pause_ms } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let b = board_of(&r);-            match what.as_str() {-                "pack" => {-                    let w = wish.as_deref().unwrap_or_else(|| err("place pack needs --wish wishes.json", &[]));-                    let wishes = aiflow_place::wishes_from_json(&read_json(w)).unwrap_or_else(|e| err(&e, &[]));-                    let (mv, lines) = aiflow_place::pack(&b, &wishes, 0.05, 0.5);-                    write_json(Path::new(out), &serde_json::to_value(&mv).unwrap());-                    let missing = lines.iter().filter(|l| l.contains("NO SPOT")).count();-                    ok(&format!("legal spots for {} of {} parts:\n{}", mv.len(), wishes.len(), lines.join("\n")), &[format!("Moves written to {out}. Check offline with `place check --moves {out}`, then land with `place land --moves {out}`."), if missing > 0 { "A part with NO SPOT needs a wider radius, a different wish, or a neighbour moved first (order the wishes).".into() } else { String::new() }]);-                }-                "check" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("place check needs --moves moves.json", &[]));-                    let mv = aiflow_place::moves_from_json(&read_json(m)).unwrap_or_else(|e| err(&e, &[]));-                    let (text, lines) = aiflow_place::move_in_text(&b, &mv).unwrap_or_else(|e| err(&e, &[]));-                    let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);-                    let outp = dir.join(format!("board-{n}.kicad_pcb"));-                    std::fs::write(&outp, &text).unwrap();-                    let nb = Board::load(&outp.display().to_string()).unwrap_or_else(|e| err(&e, &[]));-                    let moved: Vec<String> = mv.keys().cloned().collect();-                    let problems = aiflow_place::check(&nb, &moved, 0.05);-                    let drc = aiflow_copper::run_drc(&outp.display().to_string()).unwrap_or_else(|e| err(&format!("DRC on the moved board failed: {e}"), &[]));-                    let s = aiflow_copper::summarize(&drc, None);-                    let base = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);-                    let errors = s["errors"].as_u64().unwrap_or(0);-                    if !problems.is_empty() || errors > base {-                        err(&format!("the moved board is not clean: {} courtyard/outline problems, DRC {errors} errors (baseline {base}): {}", problems.len(), s["errorTypes"]), &[problems.join("; "), format!("Change the wishes and pack again; the copy stays at {} for inspection.", outp.display())]);-                    }-                    r.add_board(&outp.display().to_string());-                    r.save().unwrap();-                    ok(&format!("{}\nmoved board {} is DRC-clean ({errors} errors, all inherited) and is now the run's current board", lines.join("\n"), outp.display()), &["Land the same moves live with `place land --moves ...`.".into()]);-                }-                "land" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("place land needs --moves moves.json", &[]));-                    let br = bridge_of(&r, &cli);-                    let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-                    r.stage_start("place-land", Some("binary"), None);-                    let mv = read_json(m);-                    let t0 = std::time::Instant::now();-                    // one part at a time: the camera sees each part land, not a board that changes at once-                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {-                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });-                    });-                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);-                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);-                    }-                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);-                }-                other => err(&format!("place {other}: use pack, check or land"), &[]),-            }-        }-        Cmd::Route { passes, rip_per_net, rip_global } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if r.engine_for("route") == "ai" {-                err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);-            }-            let spec = spec_of(&r);-            let b = board_of(&r);-            let rules = aiflow_router::Rules::from_spec(&spec);-            r.stage_start("route", Some("binary"), Some(&format!("passes={passes}")));-            let mut router = aiflow_router::Router::new(b, rules, false);-            let (plan, report, failed) = aiflow_router::drive::route_board(&mut router, *passes, *rip_per_net, *rip_global);-            std::fs::write(dir.join("router.log"), router.log_lines.join("\n")).ok();-            let planp = dir.join("plan-route.json");-            write_json(&planp, &json!({"engine": "adom-aiflow router", "nets": plan}));-            write_json(&dir.join("plan-route-report.json"), &report);-            let unrouted = report["unrouted"].as_array().cloned().unwrap_or_default();-            r.stage_end("route", Some(json!({"unrouted": failed, "passes": passes})), None);-            r.outcome("route", json!({"plan": planp.display().to_string(), "unrouted": failed, "detail": unrouted.iter().take(20).cloned().collect::<Vec<_>>()}));-            r.save().unwrap();-            if failed > 0 {-                let mut blockers: Vec<String> = Vec::new();-                for u in &unrouted {-                    if let Some(why) = u.get(1).and_then(|x| x.get(1)).and_then(|w| w.as_str()) {-                        if let Some(rest) = why.strip_prefix("blocked by ") {-                            for b in rest.trim_matches(|c| c == '[' || c == ']').split(", ") {-                                let b = b.trim_matches('"').trim_matches('\'');-                                if !b.is_empty() && !blockers.contains(&b.to_string()) {-                                    blockers.push(b.to_string());-                                }-                            }-                        }-                    }-                }-                let detail: Vec<String> = unrouted.iter().take(6).map(|u| format!("{} at {} ({})", u[0], u[1][0], u[1][1])).collect();-                err(&format!("routing closed {failed} connection(s) short after {passes} passes: {}", detail.join("; ")), &[format!("More passes: --passes {}.", passes + 4), format!("Or go back to placement: the blockers are {:?}; move the parts on those nets, or the test points and indicators in the escape band, with `place pack` and `place check`, then route again.", blockers.iter().take(8).collect::<Vec<_>>()), "Or take the stage yourself: `adom-aiflow take route=ai`.".into()]);-            }-            ok(&format!("routing closed: 0 unrouted after {passes} passes; plan {}", planp.display()), &["Next: adom-aiflow gate (KiCad DRC offline on the plan).".into()]);-        }-        Cmd::Pour { plan } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let spec = spec_of(&r);-            let b = board_of(&r);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));-            let plan_v = planp.as_deref().map(read_json);-            r.stage_start("pour", Some("binary"), None);-            let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });-            let zp = dir.join("plan-pours.json");-            let vp = dir.join("plan-vias.json");-            write_json(&zp, &json!({"zones": zones}));-            write_json(&vp, &json!({"engine": "adom-aiflow pour plan (thermal and stitching vias)", "nets": vias}));-            r.stage_end("pour", Some(json!({"zones": zones.len(), "keepouts": keepouts, "vias": vias.len()})), None);-            r.outcome("pour", json!({"pours": zp.display().to_string(), "vias": vp.display().to_string(), "zones": zones.len(), "keepouts": keepouts, "viaCount": vias.len()}));-            r.save().unwrap();-            let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();-            ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);-        }-        Cmd::Gate { plan } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan to gate", &["Run `adom-aiflow route` or pass --plan yourplan.json.".into()]));-            let b = board_of(&r);-            let plan_v = read_json(&planp);-            r.stage_start("gate", Some("binary"), None);-            let (text, stats) = aiflow_copper::apply(&b, &plan_v, true).unwrap_or_else(|e| err(&format!("plan does not apply to the board: {e}"), &[]));-            let routed = dir.join("board-routed.kicad_pcb");-            std::fs::write(&routed, &text).unwrap();-            let base = aiflow_copper::run_drc(&r.current_board()).unwrap_or_else(|e| err(&e, &[]));-            let data = aiflow_copper::run_drc(&routed.display().to_string()).unwrap_or_else(|e| err(&e, &[]));-            let mut s = aiflow_copper::summarize(&data, Some(&base));-            s["copper"] = stats;-            s["file"] = json!(routed.display().to_string());-            write_json(&dir.join("gate.json"), &s);-            let (newe, unc, inh) = (s["newErrors"].as_u64().unwrap_or(1), s["unconnected"].as_u64().unwrap_or(1), s["inheritedErrors"].as_u64().unwrap_or(0));-            r.stage_end("gate", Some(json!({"newErrors": newe, "unconnected": unc, "inheritedErrors": inh})), None);-            r.outcome("gate", json!({"report": dir.join("gate.json").display().to_string(), "newErrors": newe, "unconnected": unc, "routedBoard": routed.display().to_string(), "plan": planp}));-            r.save().unwrap();-            if newe > 0 || unc > 0 {-                // a clearance a hair under the rule is the grid's rounding against a real pad: name the net and the lever-                let mut hair: Vec<String> = Vec::new();-                for v in s["newErrorList"].as_array().cloned().unwrap_or_default() {-                    if v["type"].as_str() == Some("clearance") {-                        let d = v["description"].as_str().unwrap_or("");-                        let actual = d.split("actual ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());-                        let rule = d.split("clearance ").nth(1).and_then(|t| t.split(' ').next()).and_then(|t| t.parse::<f64>().ok());-                        if let (Some(a), Some(r)) = (actual, rule) {-                            if r - a < 0.02 {-                                for it in v["items"].as_array().cloned().unwrap_or_default() {-                                    if let Some(t) = it.as_str() {-                                        if t.starts_with("Track [") { if let Some(n) = t.split('[').nth(1).and_then(|x| x.split(']').next()) { hair.push(format!("\"{n}\": {:.2}", r + 0.02)); } }-                                    }-                                }-                            }-                        }-                    }-                }-                hair.sort(); hair.dedup();-                let hair_hint = if hair.is_empty() { String::new() } else { format!("Clearance a hair under the rule: the grid's rounding against a real pad. Add \"netClearance\": {{{}}} to the spec and route again; the router keeps that net that much farther from everything.", hair.join(", ")) };-                err(&format!("gate failed: {newe} new DRC errors, {unc} unconnected (inherited {inh})"), &[format!("New errors: {}", s["newErrorTypes"]), format!("Unconnected: {}", s["unconnectedList"].as_array().map(|a| a.iter().take(6).map(|x| x.to_string()).collect::<Vec<_>>().join("; ")).unwrap_or_default()), hair_hint, "Fix the plan (route again, or move parts) and gate again; nothing lands until this passes.".into()]);-            }-            ok(&format!("gate passed: 0 new DRC errors ({inh} inherited), 0 unconnected; copper {}", s["copper"]), &["Next: adom-aiflow land route (then pour, land vias, land pours).".into()]);-        }-        Cmd::Land { what, plan, moves, pause_ms } => {-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            match what.as_str() {-                "moves" => {-                    let m = moves.as_deref().unwrap_or_else(|| err("land moves needs --moves moves.json", &[]));-                    r.stage_start("place-land", Some("binary"), None);-                    let mv = read_json(m);-                    let t0 = std::time::Instant::now();-                    // one part at a time: the camera sees each part land, not a board that changes at once-                    let (replies, accepted, stopped) = br.land_moves(&rb, &mv, *pause_ms, |i, n, reference, rep| {-                        eprintln!("{i}/{n} {reference}: {}", if Bridge::ok(rep) { "placed" } else { rep["errorCode"].as_str().unwrap_or("refused") });-                    });-                    let total = mv.as_object().map(|o| o.len()).unwrap_or(0);-                    r.stage_end("place-land", Some(json!({"accepted": accepted, "parts": total, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome("place-land", json!({"accepted": accepted, "parts": total, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live placement refused at part {} of {total}: {} {}", i + 1, rep["errorCode"], rep["message"].as_str().or(rep["error"].as_str()).unwrap_or("")), &[format!("offendingRefs: {}", rep["offendingRefs"]), "The bridge checks courtyards and the outline exactly; pack again. The parts before it are already placed, so fix the wish and land the rest.".into()]);-                    }-                    ok(&format!("{accepted} part(s) landed live, one at a time, {:.1} s", t0.elapsed().as_secs_f64()), &["Each part is its own undo step and its own moment in the clip.".into()]);-                }-                "route" | "vias" => {-                    let planp = if what == "route" {-                        let gate_ok = r.outcomes().get("gate").and_then(|g| g.get("newErrors")).and_then(|v| v.as_u64()) == Some(0) && r.outcomes().get("gate").and_then(|g| g.get("unconnected")).and_then(|v| v.as_u64()) == Some(0);-                        if !gate_ok && plan.is_none() {-                            err("the gate has not passed for this plan", &["Run `adom-aiflow gate` first; the finish line needs it.".into()]);-                        }-                        plan.clone().or_else(|| r.outcomes().get("gate").and_then(|g| g.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no plan", &[]))-                    } else {-                        r.outcomes().get("pour").and_then(|p| p.get("vias")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no vias plan; run `adom-aiflow pour` first", &[]))-                    };-                    let mut entries: Vec<Value> = read_json(&planp)["nets"].as_array().cloned().unwrap_or_default();-                    let mut already = 0usize;-                    if what == "vias" {-                        // resumable: a via already on the live board at the same place on the same net is not landed twice-                        let live = br.live_vias(&rb);-                        entries.retain(|e| {-                            let net = e["net"].as_str().unwrap_or("");-                            let p = &e["paths"][0][0];-                            let (x, y) = (p[0].as_f64().unwrap_or(f64::NAN), p[1].as_f64().unwrap_or(f64::NAN));-                            let dup = live.iter().any(|(n, lx, ly)| n == net && (lx - x).abs() < 0.05 && (ly - y).abs() < 0.05);-                            if dup { already += 1; }-                            !dup-                        });-                        if already > 0 {-                            eprintln!("{already} vias already on the live board; landing the other {}", entries.len());-                        }-                    }-                    let stage = format!("land-{what}");-                    r.stage_start(&stage, Some("binary"), None);-                    let t0 = std::time::Instant::now();-                    // one trace at a time: a net with several paths lands as several undo steps-                    let (replies, accepted, stopped, traces) = br.land_plan(&rb, &entries, *pause_ms, |i, n, entry, rep| {-                        eprintln!("{i}/{n} {} {}: {} rev {} unconnected {}", rep["net"].as_str().or(entry["net"].as_str()).unwrap_or("?"), entry["width"], if Bridge::ok(rep) { "ok" } else { rep["errorCode"].as_str().unwrap_or("failed") }, rep["revision"].as_str().map(|s| &s[..8.min(s.len())]).unwrap_or("?"), rep["drc"]["unconnected"]);-                    });-                    write_json(&dir.join(format!("land-{what}-log.json")), &json!({"plan": planp, "entries": entries.len(), "traces": traces, "accepted": accepted, "replies": replies, "wallSeconds": t0.elapsed().as_secs_f64()}));-                    r.stage_end(&stage, Some(json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0})), None);-                    r.outcome(&stage, json!({"accepted": accepted, "entries": entries.len(), "traces": traces, "wallSeconds": (t0.elapsed().as_secs_f64() * 10.0).round() / 10.0, "stoppedAt": stopped}));-                    r.save().unwrap();-                    if let Some(i) = stopped {-                        let rep = replies.last().cloned().unwrap_or(Value::Null);-                        err(&format!("live {what} stopped at trace {} of {}: {} {}", i + 1, traces, rep["errorCode"], rep["message"].as_str().unwrap_or("")), &["A drc_rejected means the live board differs from the gated copy; read kicad_routing_state and gate again.".into(), format!("Resume from entry {} once fixed: the log has every reply.", i + 1)]);-                    }-                    ok(&format!("{what} landed live: {accepted} trace(s) of {} net entries, one at a time, one undo step each, {:.1} s", entries.len(), t0.elapsed().as_secs_f64()), &[if what == "route" { "Next: adom-aiflow pour, land vias, land pours.".into() } else { "Next: adom-aiflow land pours.".into() }]);-                }-                "pours" => {-                    let zp = r.outcomes().get("pour").and_then(|p| p.get("pours")).and_then(|p| p.as_str()).map(str::to_string).unwrap_or_else(|| err("no pours plan; run `adom-aiflow pour` first", &[]));-                    let b = board_of(&r);-                    let zones = aiflow_pours::resolve_outline(&b, read_json(&zp)["zones"].as_array().unwrap_or(&Vec::new()));-                    r.stage_start("land-pours", Some("binary"), None);-                    let before = br.zone_state(&rb, true);-                    let inherited = inherited_zone_ids(&r);-                    let results = br.land_zones(&rb, &zones, *pause_ms, &inherited);-                    if let Some((_, rep)) = results.iter().find(|(n, _)| n == "removed-stale") {-                        eprintln!("{}", rep["message"].as_str().unwrap_or(""));-                    }-                    let after = br.zone_state(&rb, true);-                    let failed: Vec<&(String, Value)> = results.iter().filter(|(_, rep)| !Bridge::ok(rep)).collect();-                    r.stage_end("land-pours", Some(json!({"groups": results.len(), "failed": failed.len()})), None);-                    r.outcome("land-pours", json!({"before": before["filledAreaByLayerMm2"], "after": after["filledAreaByLayerMm2"], "coverageAfter": after["filledCoverageByLayerPercent"], "failed": failed.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(), "groups": results.iter().map(|(n, rep)| json!({"net": n, "ok": Bridge::ok(rep), "itemIds": rep["itemIds"], "errorCode": rep["errorCode"]})).collect::<Vec<_>>()}));-                    write_json(&dir.join("land-pours-log.json"), &json!({"before": before, "results": results, "after": after}));-                    r.save().unwrap();-                    if let Some((net, rep)) = failed.first() {-                        let mut types: BTreeMap<String, usize> = BTreeMap::new();-                        for v in rep["violations"].as_array().cloned().unwrap_or_default() {-                            *types.entry(v["type"].as_str().unwrap_or("?").to_string()).or_insert(0) += 1;-                        }-                        err(&format!("pour for {net} refused by the preflight: {} {:?}", rep["errorCode"], types), &["starved_thermal: tighter spokes (thermalGap 0.3, thermalBridge 0.35) or a solid patch at the pad (spec.solidAt). zones_intersect: merge same-net same-priority zones or change a priority. clearance: the polygon reaches another net's copper too closely; shrink it.".into()]);-                    }-                    ok(&format!("pours landed: copper per layer before {} after {}", before["filledAreaByLayerMm2"], after["filledAreaByLayerMm2"]), &["Next: adom-aiflow measure, analyze current, analyze thermal, finish.".into()]);-                }-                other => err(&format!("land {other}: use moves, route, vias or pours"), &[]),-            }-        }-        Cmd::Measure => {-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            r.stage_start("measure", Some("binary"), None);-            let zs = br.zone_state(&rb, true);-            r.stage_end("measure", Some(json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"]})), None);-            write_json(&dir.join("zone-state.json"), &zs);-            r.log("artifact", json!({"step": r.data["currentStep"], "kind": "zone-state", "file": dir.join("zone-state.json").display().to_string()}));-            r.outcome("measure", json!({"filledAreaByLayerMm2": zs["filledAreaByLayerMm2"], "coveragePercent": zs["filledCoverageByLayerPercent"], "zones": zs["zoneCount"]}));-            r.save().unwrap();-            ok(&format!("copper kept per layer (KiCad's fills): {}; coverage {}", zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);-        }-        Cmd::Analyze { what, plan } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));-            let plan_v = read_json(&planp);-            let spec = spec_of(&r);-            let zsp = dir.join("zone-state.json");-            let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };-            let name = format!("analyze-{what}");-            r.stage_start(&name, Some("binary"), None);-            let (passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };-            r.stage_end(&name, Some(json!({"pass": passed})), None);-            r.outcome(&name, json!({"pass": passed, "results": res}));-            r.save().unwrap();-            let lines: Vec<String> = res.iter().map(|x| format!("{} {}: {}", x.get("net").or(x.get("ref")).and_then(|v| v.as_str()).unwrap_or("?"), x["status"].as_str().unwrap_or(""), x.get("how").or(x.get("hint")).and_then(|v| v.as_str()).unwrap_or(""))).collect();-            // the copper this analysis judged: the nets with pours, for the drawing and the walkthrough-            let mut pour_nets: Vec<String> = Vec::new();-            for x in &res {-                let net = x.get("net").or(x.get("tabNet")).and_then(|v| v.as_str()).unwrap_or("");-                if !net.is_empty() && x["pourLayers"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !pour_nets.contains(&net.to_string()) { pour_nets.push(net.to_string()); }-            }-            let draw = match what.as_str() {-                "current" => "Now do the analysis yourself, not only this table: draw the current density on the copper (amps per square millimetre on every loaded net's pours and tracks; a Laplace solve on the filled copper between the source pads and the sink pads is enough, the board file has the filled polygons) as a 1920x1080 PNG with a colour scale, then `artifact --kind analysis-image --file <png> --caption \"...\"`. It becomes a fullscreen shot in the final video and a figure on the run page.".to_string(),-                _ => "Now do the analysis yourself, not only this table, as two drawings: first the heat flow in the copper, layer by layer like the current picture (a multi-layer solve: each hot tab's watts spreading on its layer and going down through its vias to the other layers, watts per millimetre of width, with the vias drawn and each chip's split between its layer, its vias and the bare dielectric); then the temperature rise over the whole board (the same solve read as temperature, the FR4 spreading). Each a 1920x1080 PNG with a colour scale, registered in that order with `artifact --kind analysis-image --file <png> --caption \"...\"` (`--replaces <old png>` when you redo one). They become fullscreen shots in the final video and figures on the run page.".to_string(),-            };-            let fields_hint = "adom-fields does this solve for you and shows it on the board in 3D: `adom-fields analyze --board <current board> --spec <spec> --out fields` (about two minutes), `adom-fields serve --fields fields &`, then under the fields step `tour fields` films the walkthrough; its screenshots are drawings you can register with `artifact`.".to_string();-            let walk = if pour_nets.is_empty() { String::new() } else { format!("Then walk the pours this analysis judged on camera, under this same step: `tour nets --nets {}` (each net lit as a whole, pours and traces together, framed).", pour_nets.join(",")) };-            if !passed {-                err(&format!("{what} analysis failed:\n{}", lines.join("\n")), &["Fix what the failing lines say (pours, vias, widths, or placement), land it, measure again, then analyze again.".into(), draw, walk]);-            }-            ok(&format!("{what} analysis passed:\n{}", lines.join("\n")), &[draw, fields_hint, walk, "0.1 judges tracks by IPC-2221 and pours by presence and vias; 0.2 computes cross-sections through the filled copper.".into()]);-        }-        Cmd::Finish => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            let o = r.outcomes().clone();-            let mut missing = Vec::new();-            let gate = o.get("gate").cloned().unwrap_or(Value::Null);-            if gate["newErrors"].as_u64() != Some(0) || gate["unconnected"].as_u64() != Some(0) {-                missing.push("gate: 0 new DRC errors and 0 unconnected (run gate)".to_string());-            }-            if !o.contains_key("land-route") {-                missing.push("land route".into());-            }-            if o.get("land-pours").map(|p| !p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(true) {-                missing.push("land pours (every planned pour accepted)".into());-            }-            if !o.contains_key("measure") {-                missing.push("measure".into());-            }-            for k in ["analyze-current", "analyze-thermal"] {-                if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {-                    missing.push(format!("{k} (pass)"));-                }-            }-            let mut live = Value::Null;-            if let Some(target) = r.data["target"].as_str() {-                let br = Bridge { ai_thread: t.clone(), target: target.to_string() };-                live = br.validate(r.data["remoteBoard"].as_str().unwrap_or(""));-                let inherited = spec_of(&r).get("inheritedErrors").and_then(|v| v.as_u64()).unwrap_or(0);-                if live["unconnected"].as_u64() != Some(0) || live["errors"].as_u64().unwrap_or(u64::MAX) > inherited {-                    missing.push(format!("live validate: {} unconnected, {} errors (inherited {inherited})", live["unconnected"], live["errors"]));-                }-            }-            if !missing.is_empty() {-                err(&format!("not finished: {}", missing.join("; ")), &["A board with any of these open is not a result. Keep going: the hints of the failing stage say what to change.".into()]);-            }-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let brs = Bridge { ai_thread: t.clone(), target };-                stop_running_clip(&mut r, &brs, &dir, "finish");-            }-            r.data["clock"]["finishTime"] = json!(now());-            record_usage(&mut r, "finish");-            let summary = json!({"engine": r.data["engine"], "promptToFinishMinutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report()), "turns": r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0), "stageMinutes": Value::Object(r.stage_minutes()), "decisions": r.data["decisions"], "copper": o.get("measure"), "gate": o.get("gate"), "live": {"errors": live["errors"], "unconnected": live["unconnected"], "warnings": live["warnings"]}, "current": true, "thermal": true});-            r.data["summary"] = summary;-            r.log("finish", json!({"minutes": r.elapsed_minutes(), "steps": Value::Object(r.step_report())}));-            r.save().unwrap();-            ok(&format!("finished: {} from the prompt to a qualified board, {:.1} min\n{}", r.data["engine"], r.elapsed_minutes(), step_table(&r)), &["The run is not delivered yet: cut the video, then `adom-aiflow deliver --video <mp4> --message \"<what you tell the human>\"`; the clock stops there, not here.".into(), "Add your token and dollar accounting to run.json under tokens and usd; the manifest is the row on the chart.".into(), give_back_hint()]);-        }-        Cmd::Capture { action, label, reason } => {-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let engine = r.data["engine"].as_str().unwrap_or("").to_string();-            match action.as_str() {-                "open" => {-                    let hwnd = br.open_board(&rb, reason.as_deref().unwrap_or(&format!("aiflow run {engine} on the test box"))).unwrap_or_else(|rep| err(&format!("could not open the board: {}", rep.to_string().chars().take(300).collect::<String>()), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"opened": now(), "hwnd": hwnd}));-                    r.save().unwrap();-                    let whole: u64 = ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| clip_budget_mb(s)).sum();-                    let disk = match (local_free_mb(&dir), br.remote_free_mb()) { (l, rm) => format!("recording budget for the whole run about {whole} MB (window capture, H.264 on the box's GPU, frames only when the screen changes); run drive {} MB free, test box {} MB free", l.map(|v| v.to_string()).unwrap_or("?".into()), rm.map(|v| v.to_string()).unwrap_or("?".into())) };-                    let page_hint = if r.data["page"].is_null() { "No page yet for this run's README: `report --page <owner/slug> --user-machine <box> --push --refresh` once, so the human can watch the run progress.".to_string() } else { String::new() };-                    ok(&format!("board open, maximised, zoomed to fit (hwnd {hwnd}); {disk}"), &["Next: declare your first step (`step intake`, `step placement`); its clip starts by itself.".into(), page_hint]);-                }-                "start" => {-                    let why = reason.clone().unwrap_or_else(|| format!("aiflow run {engine}"));-                    // the PCB editor window itself, background-capturable; the monitor only when no editor is open-                    let hwnd = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd());-                    let (rid, kind) = match hwnd {-                        Some(h) => (br.record_window_start(h, &why).unwrap_or_else(|rep| err(&format!("window recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "window", "hwnd": h})),-                        None => (br.record_start(&why).unwrap_or_else(|rep| err(&format!("recorder did not start: {}", rep.to_string().chars().take(300).collect::<String>()), &[])), json!({"kind": "monitor"})),-                    };-                    let mut entry = json!({"recordingId": rid, "start": now()});-                    for (k, v) in kind.as_object().unwrap() { entry[k] = v.clone(); }-                    r.data["captures"].as_array_mut().unwrap().push(entry);-                    r.mark("capture-start");-                    r.save().unwrap();-                    ok(&format!("recording {rid} started at {}", now()), &["Every stage command writes markers; `capture mark <label>` adds your own.".into()]);-                }-                "mark" => {-                    let l = label.clone().unwrap_or_else(|| err("capture mark needs a label", &[]));-                    r.mark(&l);-                    r.save().unwrap();-                    ok(&format!("marker {l} at {}", now()), &[]);-                }-                "stop" => {-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) else { ok("no recording running: nothing to stop (a walkthrough stops its own clip)", &["Next: `done --message ...` when the answer is complete.".into()]); return; };-                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (p, _) = br.record_stop(&rid, &save_to);-                    let cap = &mut r.data["captures"][pos];-                    cap["stop"] = json!(now());-                    cap["remoteFile"] = json!(p);-                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let local = cap["file"].as_str().map(str::to_string);-                    r.mark("capture-stop");-                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {-                        clip_artifacts(&mut r, &st, &f, 10);-                    }-                    r.save().unwrap();-                    ok(&format!("recording stopped; {} pulled into the run", p.unwrap_or_default()), &["Frames arrive only when the screen changes on ab's recorder: the cut states real time from the markers.".into()]);-                }-                "reject" => {-                    // the AI looked at a contact sheet and saw a clip the blank check accepted but a-                    // human would not (stale pixels after a window resize, a covered canvas)-                    let tag = label.clone().unwrap_or_else(|| err("capture reject <step>-<visit> --reason \"...\"", &[]));-                    let why = reason.clone().unwrap_or_else(|| err("say why in --reason", &[]));-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let pos = caps.iter().rposition(|c| format!("{}-{}", c["step"].as_str().unwrap_or(""), c["visit"].as_u64().unwrap_or(1)) == tag || c["file"].as_str().map(|f| f.ends_with(&tag)).unwrap_or(false)).unwrap_or_else(|| err(&format!("no clip {tag} in this run"), &["Tags are <step>-<visit>, e.g. nets-2; or the clip's file name.".into()]));-                    r.data["captures"][pos]["suspect"] = json!(format!("rejected by the AI: {why}"));-                    let st = json!(format!("{}-{}", caps[pos]["step"].as_str().unwrap_or("?"), caps[pos]["visit"].as_u64().unwrap_or(1)));-                    r.log("clip-suspect", json!({"step": st, "file": caps[pos]["file"], "why": why, "by": "ai"}));-                    r.save().unwrap();-                    ok(&format!("clip {tag} rejected: it stays on the page as evidence and out of the video"), &["Re-take the step if the walkthrough matters: `step <name> --back --why ...`.".into()]);-                }-                other => err(&format!("capture {other}: use open, start, mark, stop or reject"), &[]),-            }-        }-        Cmd::Status => {-            let r = load_run(&cli);-            let o = r.outcomes();-            ok(&format!("run {}, engine {}, {:.1} min since the prompt ({})\nstages (min): {}\ndecisions: {}\nroute: {} unrouted; gate: {} new / {} unconnected; pours: {}; measure: {}; current: {}; thermal: {}; finished: {}",-                dir.display(), r.data["engine"], r.elapsed_minutes(), r.data["clock"]["promptTime"], Value::Object(r.stage_minutes()), r.data["decisions"],-                o.get("route").map(|x| x["unrouted"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["newErrors"].to_string()).unwrap_or("-".into()), o.get("gate").map(|x| x["unconnected"].to_string()).unwrap_or("-".into()),-                if o.get("land-pours").map(|p| p["failed"].as_array().map(|a| a.is_empty()).unwrap_or(false)).unwrap_or(false) { "landed" } else { "no" }, o.get("measure").map(|x| x["filledAreaByLayerMm2"].to_string()).unwrap_or("-".into()),-                o.get("analyze-current").map(|x| x["pass"].to_string()).unwrap_or("-".into()), o.get("analyze-thermal").map(|x| x["pass"].to_string()).unwrap_or("-".into()), r.data["clock"]["finishTime"]), &[step_table(&r)]);-        }-        Cmd::Report { page, push, refresh, user_machine, surface } => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            if let Some(p) = page { r.data["page"] = json!(p); }-            if let Some(m) = user_machine { r.data["userMachine"] = json!(m); }-            if let Some(sf) = surface { if !matches!(sf.as_str(), "browser" | "webview") { err("--surface is browser or webview", &[]); } r.data["reportSurface"] = json!(sf); }-            r.save().unwrap();-            let pg = r.data["page"].as_str().map(str::to_string).unwrap_or_else(|| err("no page for this run: `report --page <owner/slug>` once (the wiki page the run belongs to)", &[]));-            if r.data["reportId"].is_null() { let id = report_id(&r); r.data["reportId"] = json!(id); r.save().unwrap(); }-            let (url, notes) = publish_report(&r, &dir, &pg, *push, *refresh, &t);-            if notes.iter().any(|n| n.contains("FAILED") || n.starts_with("push failed")) { err(&format!("report built but NOT published: {}", notes.join("; ")), &["Run `report --push` again: only the files that failed go up (the pushed ones are cached).".into()]); }-            ok(&format!("report built for {pg}: {url}{}", if notes.is_empty() { String::new() } else { format!("; {}", notes.join("; ")) }), &["From now on every `step` and `deliver` rebuilds, pushes and refreshes it by itself.".into()]);-        }-        Cmd::Giveback { url, kind, note } => {-            thread(&cli);-            let mut r = load_run(&cli);-            if !r.data["giveback"].is_array() { r.data["giveback"] = json!([]); }-            r.data["giveback"].as_array_mut().unwrap().push(json!({"url": url, "kind": kind.clone().unwrap_or_else(|| if url.contains("/pulls/") || url.contains("/pr/") { "pr".into() } else { "issue".into() }), "note": note, "at": now()}));-            r.log("giveback", json!({"url": url, "kind": kind, "note": note}));-            r.save().unwrap();-            ok(&format!("given back: {url} ({} so far this run)", r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0)), &["Adom processes PRs within minutes during business hours; the run's summary counts what its AI gave back.".into()]);-        }--        Cmd::Recut => {-            thread(&cli);-            let mut r = load_run(&cli);-            let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-            let mut n = 0;-            for c in &caps {-                let (Some(f), Some(step)) = (c["file"].as_str(), c["step"].as_str()) else { continue };-                if !Path::new(f).is_file() { continue; }-                let tag = json!(format!("{step}-{}", c["visit"].as_u64().unwrap_or(1)));-                clip_artifacts(&mut r, &tag, f, 10);-                n += 1;-            }-            r.mark(&format!("recut: {n} clips re-cut (10x and action) with this binary's rules"));-            r.save().unwrap();-            ok(&format!("{n} clips re-cut; the run page uses the action cuts"), &["Next: adom-aiflow compose --out <mp4>, then report --push --refresh.".into()]);-        }-        Cmd::Compose { out, max_seconds, still_seconds, with, script } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let outp = if Path::new(out).is_absolute() { PathBuf::from(out) } else { dir.join(out) };-            if let Some(other) = with {-                let b = Run::open(other).unwrap_or_else(|| err(&format!("no run at {}", other.display()), &["The other run needs its run.json, run.jsonl and its clips' 10x cuts in that directory.".into()]));-                let script: Option<serde_json::Map<String, Value>> = script.as_ref().map(|p| serde_json::from_str::<Value>(&std::fs::read_to_string(p).unwrap_or_else(|e| err(&format!("script {}: {e}", p.display()), &[]))).ok().and_then(|v| v.as_object().cloned()).unwrap_or_else(|| err("the script must be a JSON object of step name to words", &["{\"placement\": \"...\", \"routing\": \"...\", \"drawings:current\": \"...\"}".into()])));-                let (secs, steps, pairs, has_tts) = compose_pair(&mut r, &b, &dir, &outp, *max_seconds, *still_seconds, script.as_ref());-                r.log("artifact", json!({"step": "finish", "kind": "video-comparison", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "steps": steps, "drawingPairs": pairs, "with": other.display().to_string(), "withEngine": b.data["engine"], "maxSeconds": max_seconds, "narrated": has_tts}));-                r.data["comparison"] = json!({"video": outp.display().to_string(), "with": other.display().to_string(), "withEngine": b.data["engine"], "seconds": (secs * 10.0).round() / 10.0});-                r.save().unwrap();-                ok(&format!("composed {secs:.0} s side by side: {} left, {} right, {steps} steps and {pairs} drawing pair(s){}: {}", r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or(""), if has_tts { ", narrated" } else { ", no narration" }, outp.display()), &["Look at it before you post it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png.".into(), "report --push --refresh puts it on this run's page under the single-run video.".into()]);-                return;-            }-            let (secs, parts, stills, rate, has_tts) = compose_run(&mut r, &dir, &outp, *max_seconds, *still_seconds);-            r.log("artifact", json!({"step": "finish", "kind": "video", "file": outp.display().to_string(), "seconds": (secs * 10.0).round() / 10.0, "segments": parts, "stills": stills, "rate": rate, "maxSeconds": max_seconds, "narrated": has_tts}));-            if r.data["delivery"].is_object() {-                r.data["delivery"]["video"] = json!(outp.display().to_string());-                r.mark("compose: the delivered video replaced by the composed cut (motion only, the ledger's overlays, the AI's drawings, narration cut to the picture); the clock of record unchanged");-            }-            r.save().unwrap();-            let minutes = r.active_minutes_total();-            ok(&format!("composed {secs:.0} s from {parts} step visits and {stills} drawing(s){} for {}, {minutes:.1} min of AI time, motion at {rate:.1} frames per second: {}", if has_tts { ", narrated" } else { ", no narration (adom-tts not reachable)" }, r.data["engine"].as_str().unwrap_or(""), outp.display()), &["Look at it before you deliver it: ffmpeg -i <mp4> -vf fps=1/10,scale=320:-2,tile=4x3 sheet.png gives one page of it.".into(), "Next: report --push --refresh so the run page carries it; deliver --video <this file> if the run is not delivered yet.".into()]);-        }-        Cmd::Artifact { file, kind, caption, step, replaces } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let src = PathBuf::from(file);-            if !src.is_file() { err(&format!("no such file: {file}"), &["Make the drawing first (a PNG at 1920x1080 reads best fullscreen), then register it.".into()]); }-            let step = step.clone().or_else(|| r.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| err("no current step", &["`step <name>` first: an artifact belongs to a step visit.".into()]));-            let visit = r.data["steps"][&step]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-            // the file lives in the run directory so the page and the video can find it later-            let canon = std::fs::canonicalize(&src).unwrap_or(src.clone());-            let rdir = std::fs::canonicalize(&dir).unwrap_or(dir.clone());-            let dest = if canon.starts_with(&rdir) { canon } else { let d = dir.join(src.file_name().unwrap_or_default()); let _ = std::fs::copy(&src, &d); std::fs::canonicalize(&d).unwrap_or(d) };-            let is_image = matches!(dest.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()).as_deref(), Some("png") | Some("jpg") | Some("jpeg") | Some("webp"));-            let replaced = replaces.as_ref().map(|p| std::fs::canonicalize(p).unwrap_or(PathBuf::from(p)).display().to_string());-            r.log("artifact", json!({"step": step, "visit": visit, "kind": kind, "file": dest.display().to_string(), "caption": caption, "byAi": true, "replaces": replaced}));-            r.mark(&format!("artifact {kind} for {step}-{visit}: {caption}"));-            r.save().unwrap();-            ok(&format!("{kind} registered for {step} (visit {visit}): {}", dest.display()), &[if is_image { "It goes on the run page under this step with your caption above it, and into the final video as a fullscreen shot at this point of the step (the one place the video leaves the EDA window).".to_string() } else { "It goes on the run page under this step with your caption; only images go into the video.".to_string() }, "Next: if this was an analysis, walk the copper it judged on camera under this same step: `tour nets --nets <the pour nets>`.".into()]);-        }-        Cmd::Compare { with, page, push, refresh } => {-            let t = thread(&cli);-            let r = load_run(&cli);-            let b = Run::open(with).unwrap_or_else(|| err(&format!("no run at {}", with.display()), &[]));-            let page = page.clone().or_else(|| r.data["page"].as_str().map(str::to_string)).unwrap_or_else(|| err("no page: --page <owner/slug>", &[]));-            let (rel, url, files) = build_comparison(&r, &b, &dir, &page);-            let mut notes = Vec::new();-            if *push {-                let stage = dir.join("report");-                let mut cmd = std::process::Command::new("adom-wiki");-                cmd.current_dir(&stage).args(["repo", "push", &page, "-m", &format!("aiflow comparison {}: {} versus {}", rel, r.data["engine"].as_str().unwrap_or(""), b.data["engine"].as_str().unwrap_or("")), "--files"]);-                for f in &files { cmd.arg(f); }-                match cmd.output() { Ok(o) if o.status.success() => notes.push(format!("pushed {} files", files.len())), Ok(o) => err(&format!("push failed: {}", String::from_utf8_lossy(&o.stderr).chars().rev().take(200).collect::<String>().chars().rev().collect::<String>()), &["Run it again: the files are still staged.".into()]), Err(e) => err(&format!("push failed: {e}"), &[]) }-            }-            if *refresh {-                if let Some(m) = r.data["userMachine"].as_str() {-                    let br = Bridge { ai_thread: t.clone(), target: m.to_string() };-                    let bust = format!("{url}?v={}", now().replace([':', '-'], ""));-                    let op = br.call("pup_open_window", &json!({"sessionId": format!("aiflow-compare-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>()), "url": bust, "reason": "show the human the comparison page"}));-                    notes.push(if Bridge::ok(&op) { format!("opened in pup on {m}") } else { format!("pup on {m}: {}", op["errorCode"].as_str().unwrap_or("no answer")) });-                }-            }-            ok(&format!("comparison page built: {url}{}", if notes.is_empty() { String::new() } else { format!(" ({})", notes.join("; ")) }), &["Link it from the page's README so people find it; the two run pages are linked from it.".into()]);-        }-        Cmd::Prompt { text, at } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let at = at.clone().unwrap_or_else(now);-            if aiflow_run::parse(&at).is_none() { err(&format!("--at {at} is not a time (RFC 3339, e.g. 2026-09-14T15:04:05Z)"), &[]); }-            let n = r.sessions().len() + 1;-            r.log("prompt", json!({"n": n, "text": text, "at": at}));-            r.data["clock"]["lastPromptTime"] = json!(at);-            r.save().unwrap();-            ok(&format!("prompt {n} at {at}: the AI's time on it counts from here"), &["Say `done --message \"...\"` the moment your answer to this prompt is complete; the clock of this session stops there, and what sits idle after it is not charged to you.".into(), "Every step, clip and drawing you make until then belongs to this session on the run page.".into()]);-        }-        Cmd::Done { message } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let ss = r.sessions();-            let Some(cur) = ss.last() else { err("no session to close: the run has no prompt", &[]) };-            if cur.done_at.is_some() && !cur.open { err(&format!("session {} is already done", cur.n), &["A new human prompt is `prompt --text \"...\"`; then `done` closes that one.".into()]); }-            let t = now();-            r.log("done", json!({"n": cur.n, "message": message}));-            r.save().unwrap();-            let ss = r.sessions();-            let cur = ss.last().unwrap();-            ok(&format!("session {} done at {t}: {:.1} min of AI time on it ({:.0} min idle cut out); {:.1} min of AI time on the run so far", cur.n, cur.active_seconds() as f64 / 60.0, cur.idle_seconds as f64 / 60.0, r.active_minutes_total()), &["report --push --refresh puts the session on the run page.".into()]);-        }-        Cmd::Sessions { json: as_json } => {-            thread(&cli);-            let r = load_run(&cli);-            let ss = r.sessions();-            if *as_json {-                println!("{}", serde_json::to_string_pretty(&ss.iter().map(|x| json!({"n": x.n, "promptAt": aiflow_run::iso(x.prompt_at), "doneAt": x.done_at.map(aiflow_run::iso), "text": x.text, "activeMinutes": (x.active_seconds() as f64 / 6.0).round() / 10.0, "idleMinutes": (x.idle_seconds as f64 / 6.0).round() / 10.0, "spans": x.spans.iter().map(|(a, b)| json!([aiflow_run::iso(*a), aiflow_run::iso(*b)])).collect::<Vec<_>>(), "open": x.open})).collect::<Vec<_>>()).unwrap());-                return;-            }-            println!("{}", sessions_table(&r));-            ok(&format!("{} session(s), {:.1} min of AI time in all; idle threshold {} min", ss.len(), r.active_minutes_total(), r.idle_minutes()), &["A session without a `done` ends at its last event; say `done` when an answer is complete so nothing idle is charged.".into()]);-        }-        Cmd::Usage => {-            thread(&cli);-            let mut r = load_run(&cli);-            let snap = record_usage(&mut r, "manual");-            r.save().unwrap();-            if snap.is_empty() { err("no plan usage readable: no Claude Code (~/.claude/.credentials.json) or Codex (~/.codex/auth.json) sign-in in this container, or the provider did not answer", &["The run still counts time; usage is the second cost line and it needs a sign-in the binary can read.".into()]); }-            ok(&format!("{}", snap.iter().map(|l| format!("{} {}: {:.0}% used", l["provider"].as_str().unwrap_or(""), l["label"].as_str().unwrap_or(""), l["pct"].as_f64().unwrap_or(0.0))).collect::<Vec<_>>().join(", ")), &[usage_line(&r)]);-        }--        Cmd::Models => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            let br = bridge_of(&r, &cli);-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let _ = t;-            r.stage_start("models", Some("binary"), None);-            let v = br.call("kicad_model_check", &json!({"filePath": rb}));-            let d = v.get("data").cloned().unwrap_or(v.clone());-            let counts = d["counts"].clone();-            let unresolved = counts["unresolvedModel"].as_u64().unwrap_or(0) + counts["missingModel"].as_u64().unwrap_or(0);-            let mut missing: Vec<String> = Vec::new();-            for f in d["footprintsWithWarnings"].as_array().cloned().unwrap_or_default() {-                let fp = f["footprint"].as_str().unwrap_or("").to_string();-                let refs = f["references"].as_array().map(|a| a.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(",")).unwrap_or_default();-                let models: Vec<String> = f["models"].as_array().cloned().unwrap_or_default().iter().filter(|m| m["exists"].as_bool() != Some(true)).filter_map(|m| m["reference"].as_str().map(|s| s.to_string())).collect();-                if !models.is_empty() { missing.push(format!("{fp}{}: {}", if refs.is_empty() { String::new() } else { format!(" ({refs})") }, models.join(", "))); }-            }-            r.stage_end("models", Some(json!({"counts": counts, "unresolved": unresolved})), None);-            r.outcome("models", json!({"counts": counts, "unresolved": unresolved, "missing": missing}));-            r.log("artifact", json!({"step": "models", "kind": "model-check", "counts": counts}));-            r.save().unwrap();-            if !Bridge::ok(&v) && d["counts"].is_null() {-                err(&format!("model check did not answer: {}", v["errorCode"].as_str().unwrap_or("?")), &["The run needs a live board on the test box (capture open) for kicad_model_check.".into()]);-            }-            if unresolved > 0 {-                err(&format!("{unresolved} footprint model(s) unresolved on the board ({} with a model, {} expect none):\n{}", counts["withModel"], counts["noModelExpected"], missing.iter().take(40).map(|m| format!("  {m}")).collect::<Vec<_>>().join("\n")), &["Every part the viewer shows as a bare footprint is a missing 3D model. Fetch the vendor STEP (adom-parts-search, chip-fetcher), build one (chip-thumbnailer, adom-footprint), or fix the path in the footprint; then `models` again. A board is not delivered with bare footprints.".into()]);-            }-            ok(&format!("every footprint has its 3D model ({} with a model, {} expect none)", counts["withModel"], counts["noModelExpected"]), &["Next: step placement.".into()]);-        }-        Cmd::Tour { what, nets, port, on } => {-            let t = thread(&cli);-            let mut r = load_run(&cli);-            let br = match on { Some(box_name) if what == "fields" => Bridge { ai_thread: t.clone(), target: box_name.clone() }, _ => bridge_of(&r, &cli) };-            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();-            let editor = if what == "fields" { 0 } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()).unwrap_or_else(|| err("no PCB editor open on the test box", &["capture open first.".into()])) };-            match what.as_str() {-                "3d" => {-                    r.stage_start("tour-3d", Some("binary"), None);-                    // the viewer, its own window, is this step's camera: swap the running clip onto it-                    let op = br.call("kicad_open_3d_viewer", &json!({"editor": "pcb", "pcbHwnd": editor}));-                    std::thread::sleep(std::time::Duration::from_secs(6));-                    let find_viewer = || -> Option<i64> {-                        let w = br.call("kicad_window_info", &json!({}));-                        let d = w.get("data").cloned().unwrap_or(w.clone());-                        d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())-                    };-                    let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));-                    // a viewer opened in the background never paints its GL canvas (a white window with a-                    // black bar, "last render time 1 ms"): show it once, maximised, and wait until the-                    // bridge's screenshot says the canvas rendered, or refuse rather than film a blank-                    let _ = br.call("desktop_ui_window", &json!({"hwnd": viewer, "action": "maximize", "reason": "aiflow 3D walkthrough: the viewer fills the monitor on the test box"}));-                    let _ = br.call("desktop_bring_to_front", &json!({"hwnd": viewer, "reason": "aiflow 3D walkthrough: the viewer must paint before it is filmed"}));-                    let mut painted = false;-                    for _ in 0..12 {-                        std::thread::sleep(std::time::Duration::from_secs(3));-                        let sh = br.call("kicad_screenshot_all", &json!({}));-                        let d = sh.get("data").cloned().unwrap_or(sh.clone());-                        if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {-                            painted = true;-                            break;-                        }-                    }-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    // the editor clip of this step visit is not the walkthrough: discard it, the viewer is the camera-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("3d").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("3d")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        r.data["captures"][pos]["discarded"] = json!("editor clip before the viewer opened; the viewer clip is this visit's camera");-                    }-                    if !painted {-                        let _ = br.call("kicad_close_3d_viewer", &json!({}));-                        r.log("clip-refused", json!({"step": step, "visit": visit, "why": "the 3D viewer's canvas never painted"}));-                        r.save().unwrap();-                        err("the 3D Viewer's canvas never painted (a white window), so nothing was filmed", &["Look at kicad_screenshot_all on the test box: a GPU-less host needs kicad_enable_software_opengl; a hidden window needs to be shown once.".into()]);-                    }-                    let rid = br.record_window_start(viewer, &format!("aiflow 3D walkthrough")).unwrap_or_else(|rep| err(&format!("viewer recording did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": viewer, "step": step, "visit": visit, "back": false, "tour": "3d"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "3D Viewer"}));-                    r.save().unwrap();-                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-start"));-                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-start", "file": pth})); }-                    let tour = br.call("kicad_3d_view", &json!({"action": "tour", "pauseMs": 700}));-                    let cmds = tour["commands"].as_u64().unwrap_or(0);-                    std::thread::sleep(std::time::Duration::from_secs(2));-                    let shot = br.screenshot_window(viewer, &save_to, &format!("shot-{step}-{visit}-3d-end"));-                    if let Some(pth) = shot { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "3d-end", "file": pth})); }-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);-                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));-                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }-                    }-                    let _ = br.call("kicad_close_3d_viewer", &json!({}));-                    r.stage_end("tour-3d", Some(json!({"commands": cmds, "viewer": viewer})), None);-                    r.outcome("tour-3d", json!({"commands": cmds}));-                    r.save().unwrap();-                    if !Bridge::ok(&tour) { err(&format!("the 3D tour did not run: {}", tour["errorCode"].as_str().unwrap_or("?")), &["kicad_3d_view {action:\"actions\"} lists what the viewer answers to.".into()]); }-                    ok(&format!("3D walkthrough recorded: {cmds} view commands on the viewer, no mouse, clip and screenshots in the run"), &["Next: step nets, or step capture / finish.".into()]);-                }-                "nets" => {-                    let spec = spec_of(&r);-                    let mut list: Vec<String> = nets.as_deref().map(|n| n.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();-                    if list.is_empty() {-                        for k in spec.get("loads").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }-                        for k in spec.get("wideNets").and_then(|l| l.as_object()).map(|o| o.keys().cloned().collect::<Vec<_>>()).unwrap_or_default() { if !list.contains(&k) { list.push(k); } }-                        for (src, dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {-                            let b = board_of(&r);-                            for key in [src.clone(), dst.as_str().unwrap_or("").to_string()] { if let Some(p) = b.pad_by_key(&key) { if !list.contains(&p.net) { list.push(p.net.clone()); } } }-                        }-                    }-                    if list.is_empty() { err("no nets to tour: the spec has no loads, wideNets or kelvin, and --nets was not given", &[]); }-                    r.stage_start("tour-nets", Some("binary"), None);-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("nets").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("nets")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    let mut toured: Vec<Value> = Vec::new();-                    let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));-                    std::thread::sleep(std::time::Duration::from_millis(1500));-                    for net in &list {-                        let sel = br.call("kicad_select_net", &json!({"filePath": rb, "net": net}));-                        if !Bridge::ok(&sel) || sel["selected"].as_u64().unwrap_or(0) == 0 {-                            let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));-                            r.stage_end("tour-nets", Some(json!({"failed": net, "error": sel["errorCode"]})), None);-                            r.save().unwrap();-                            err(&format!("net {net}: {} selected ({}); the walkthrough would film a static board, so it stops here", sel["selected"].as_u64().unwrap_or(0), sel["errorCode"].as_str().unwrap_or("no error, nothing matched")), &["A loaded net with nothing to select means the editor the bridge talks to is not this run's board: with two boards open (another run's), kicad_routing_state {filePath} answers board_mismatch. Close the other board or open this one alone, then `tour nets` again.".into()]);-                        }-                        r.mark(&format!("net {net}: {} items highlighted", sel["selected"]));-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Selected Objects"}));-                        std::thread::sleep(std::time::Duration::from_millis(2600));-                        let safe: String = net.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();-                        if let Some(pth) = br.screenshot_window(editor, &save_to, &format!("shot-{step}-{visit}-net-{safe}")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "net": net, "file": pth})); }-                        let _ = br.call("kicad_send_key", &json!({"hwnd": editor, "menu": "Zoom to Fit"}));-                        std::thread::sleep(std::time::Duration::from_millis(1200));-                        toured.push(json!({"net": net, "selected": sel["selected"], "byKind": sel["byKind"]}));-                    }-                    let _ = br.call("kicad_select_clear", &json!({"filePath": rb}));-                    r.stage_end("tour-nets", Some(json!({"nets": toured.len()})), None);-                    r.outcome("tour-nets", json!({"nets": toured}));-                    r.save().unwrap();-                    ok(&format!("net walkthrough recorded: {} nets, each selected as a whole (pours, tracks, vias, pads) and framed: {}", toured.len(), toured.iter().map(|t| format!("{} ({})", t["net"].as_str().unwrap_or(""), t["selected"])).collect::<Vec<_>>().join(", ")), &["The clip of this step is the walkthrough; markers name each net at its moment.".into()]);-                }-                "fields" => {-                    // the fields step: adom-fields solved the board and serves it; a pup window on the test-                    // box shows it, the recorder films that window while `adom-fields tour` drives it-                    let health = std::process::Command::new("adom-fields").args(["health", "--port", &port.to_string()]).output().map(|o| o.status.success()).unwrap_or(false);-                    if !health { err(&format!("adom-fields is not serving on port {port}"), &["Solve and serve first: `adom-fields analyze --board <the current board> --spec <spec> --out fields` then `adom-fields serve --fields fields --port ".to_string() + &port.to_string() + " &` (adom-wiki pkg install adom/adom-fields if it is missing).".into()]); }-                    let proxy = std::env::var("VSCODE_PROXY_URI").ok().map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or_else(|| err("no VSCODE_PROXY_URI in this container: the test box cannot reach the app", &["Run inside an Adom container (the proxy URL is how a browser outside reaches a port inside), or set VSCODE_PROXY_URI to https://<slug>.adom.cloud/proxy/{{port}}/.".into()]));-                    r.stage_start("tour-fields", Some("binary"), None);-                    // pup's browser on a cold box takes longer than one relay call: warm it, wait until it is ready-                    let _ = br.call("pup_prewarm", &json!({"reason": "aiflow fields walkthrough: warming pup on the test box"}));-                    for _ in 0..30 {-                        let rd = br.call("pup_readiness", &json!({}));-                        if rd["ready"].as_bool() == Some(true) { break; }-                        std::thread::sleep(std::time::Duration::from_secs(3));-                    }-                    // a fresh pup session every time: a session whose browser died answers browser_gone forever-                    let sid = format!("aiflow-fields-{}", now().chars().filter(|c| c.is_ascii_digit()).skip(8).collect::<String>());-                    let op = br.call("pup_open_window", &json!({"sessionId": sid, "url": proxy, "width": 1600, "height": 1000, "reason": "aiflow fields walkthrough: the AI's current and heat analysis on camera"}));-                    let open_err = if Bridge::ok(&op) { None } else { Some(op["errorCode"].as_str().unwrap_or("?").to_string()) };-                    // pup can take a minute to bring a browser up on a cold box: the window is polled for, not assumed-                    let mut hwnd: Option<i64> = None;-                    for _ in 0..40 {-                        std::thread::sleep(std::time::Duration::from_secs(2));-                        let lw = br.call("desktop_list_windows", &json!({}));-                        // the relay wraps some answers as a JSON string under `output`-                        let inner: Value = lw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);-                        let d = lw.get("data").cloned().or_else(|| inner.get("data").cloned()).unwrap_or(lw.clone());-                        let wins = d["windows"].as_array().cloned().or_else(|| lw["windows"].as_array().cloned()).or_else(|| inner["windows"].as_array().cloned()).unwrap_or_default();-                        // the app's window, not a wiki page about it: the app titles itself exactly "Adom Fields"-                        hwnd = wins.iter().find(|w| w["title"].as_str().map(|t| (t == "Adom Fields" || t.starts_with("Adom Fields - ")) && !t.contains("Wiki") && !t.contains("wiki")).unwrap_or(false)).and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())));-                        if hwnd.is_some() { break; }-                    }-                    let Some(win) = hwnd else { let _ = br.call("pup_close_window", &json!({"sessionId": sid})); err(&format!("the app's window did not appear on the test box (no window titled Adom Fields{})", open_err.map(|e| format!("; pup said {e}")).unwrap_or_default()), &["`adom-bridge --target <box> pup_readiness` says whether pup is ready there; a browser_gone means pup's browser died: `pup_close_window` the old sessions or restart pup on the box.".into()]); };-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    let (step, visit) = (r.data["currentStep"].as_str().unwrap_or("fields").to_string(), r.data["steps"][r.data["currentStep"].as_str().unwrap_or("fields")]["visits"].as_array().map(|a| a.len()).unwrap_or(1));-                    // the editor clip of this visit is not the walkthrough: the app window is the camera-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                        let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        r.data["captures"][pos]["discarded"] = json!("editor clip before the fields app opened; the app window is this visit's camera");-                    }-                    std::thread::sleep(std::time::Duration::from_secs(4));-                    let rid = br.record_window_start(win, "aiflow fields walkthrough").unwrap_or_else(|rep| err(&format!("recording of the app window did not start: {}", rep["errorCode"].as_str().unwrap_or("?")), &[]));-                    r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": win, "step": step, "visit": visit, "back": false, "tour": "fields"}));-                    r.log("clip-start", json!({"step": step, "visit": visit, "recordingId": rid, "window": "Adom Fields"}));-                    r.save().unwrap();-                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-start")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-start", "file": pth})); }-                    // no pace of our own: the app holds each beat and drifts the board gently, because-                    // this clip is played back at ten times speed-                    let tour = std::process::Command::new("adom-fields").args(["tour", "--port", &port.to_string()]).output();-                    let tour_ok = tour.as_ref().map(|o| o.status.success()).unwrap_or(false);-                    std::thread::sleep(std::time::Duration::from_secs(2));-                    if let Some(pth) = br.screenshot_window(win, &save_to, &format!("shot-{step}-{visit}-fields-end")) { r.log("artifact", json!({"step": step, "visit": visit, "kind": "screenshot", "when": "fields-end", "file": pth})); }-                    let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                    if let Some(pos) = caps.iter().rposition(|c| c["recordingId"].as_str() == Some(&rid)) {-                        let (p, _) = br.record_stop(&rid, &save_to);-                        r.data["captures"][pos]["stop"] = json!(now());-                        r.data["captures"][pos]["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                        let local = r.data["captures"][pos]["file"].as_str().map(str::to_string);-                        r.log("clip-stop", json!({"step": step, "visit": visit, "recordingId": rid, "file": p}));-                        if let Some(f) = local.filter(|f| Path::new(f).is_file()) { clip_artifacts(&mut r, &json!(format!("{step}-{visit}")), &f, 10); }-                    }-                    let _ = br.call("pup_close_window", &json!({"sessionId": sid}));-                    // the solved numbers ride the run: the issues are what the AI answers for-                    let fields: Value = std::process::Command::new("curl").args(["-s", &format!("http://127.0.0.1:{port}/fields.json")]).output().ok().and_then(|o| serde_json::from_slice(&o.stdout).ok()).unwrap_or(Value::Null);-                    let issues = fields["issues"].as_array().cloned().unwrap_or_default();-                    r.stage_end("tour-fields", Some(json!({"issues": issues.len(), "tour": tour_ok})), None);-                    r.outcome("tour-fields", json!({"issues": issues, "nets": fields["nets"], "chips": fields["chips"], "heat": fields["heat"]}));-                    r.log("artifact", json!({"step": step, "visit": visit, "kind": "fields-summary", "issues": issues.len(), "peakJ": fields["scales"]["jMax"], "peakRiseC": fields["heat"]["peakRiseC"]}));-                    r.save().unwrap();-                    if !tour_ok { err("the app's walkthrough did not run (adom-fields tour failed)", &["`adom-fields tour` by hand shows why; the clip of this visit still exists.".into()]); }-                    ok(&format!("fields walkthrough recorded: {} issue(s) on the board, peak {:.0} A/mm², peak rise {:.0} °C in still air", issues.len(), fields["scales"]["jMax"].as_f64().unwrap_or(0.0), fields["heat"]["peakRiseC"].as_f64().unwrap_or(0.0)), &[if issues.is_empty() { "No issues: every loaded net under 60 A/mm² and every hot tab within its budget.".to_string() } else { format!("The issues are yours to answer before delivery: {}", issues.iter().take(4).map(|i| i["what"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>().join(" | ")) }, "Next: step nets, or step 3d.".into()]);-                }-                other => err(&format!("tour {other}: use 3d, nets or fields"), &[]),-            }-        }-        Cmd::Ledger { json: as_json } => {-            let r = load_run(&cli);-            let led = r.ledger();-            if led.is_empty() {-                err("no run.jsonl in this run (started before the ledger existed)", &["run.json still carries turns; start a fresh run to get the append-only ledger.".into()]);-            }-            if *as_json {-                for e in &led {-                    println!("{e}");-                }-            } else {-                for e in &led {-                    let t = e["t"].as_str().unwrap_or("");-                    match e["event"].as_str().unwrap_or("") {-                        "start" => println!("{t}  start      engine {} prompt {}", e["engine"], e["promptTime"]),-                        "turn" => println!("{t}  turn {:>3}   {:<10} think {:>6.1} min  {}", e["n"], e["step"].as_str().unwrap_or(""), e["thinkingSeconds"].as_f64().unwrap_or(0.0) / 60.0, e["command"].as_str().unwrap_or("")),-                        "turn-end" => println!("{t}  end  {:>3}   {:<10} tool  {:>6.1} min  {} {}", e["n"], e["step"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, if e["ok"].as_bool() == Some(true) { "OK" } else { "ERROR" }, e["said"].as_str().unwrap_or("")),-                        "step" => println!("{t}  step       {}{} visit {}{}", e["step"].as_str().unwrap_or(""), if e["back"].as_bool() == Some(true) { " (back)" } else { "" }, e["visit"], e["why"].as_str().map(|w| format!(": {w}")).unwrap_or_default()),-                        "stage-end" => println!("{t}  stage      {} {:.1} min {}", e["stage"].as_str().unwrap_or(""), e["binarySeconds"].as_f64().unwrap_or(0.0) / 60.0, e["result"].to_string().chars().take(100).collect::<String>()),-                        "marker" => println!("{t}  marker     {}", e["label"].as_str().unwrap_or("")),-                        "finish" | "deliver" => println!("{t}  {}    {}", e["event"].as_str().unwrap_or(""), e["minutes"]),-                        "prompt" => println!("{t}  prompt {:>2}  {}", e["n"], e["text"].as_str().unwrap_or("")),-                        "done" => println!("{t}  done   {:>2}  {}", e["n"], e["message"].as_str().unwrap_or("")),-                        _ => {}-                    }-                }-            }-            println!("{}", step_table(&r));-        }-        Cmd::Step { name, back, why } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let known: Vec<String> = r.data["flow"]["steps"].as_array().map(|a| a.iter().filter_map(|x| x["name"].as_str().map(str::to_string)).collect()).unwrap_or_else(|| ["intake", "placement", "routing", "pours", "current", "thermal", "capture", "finish"].iter().map(|s| s.to_string()).collect());-            let known: Vec<&str> = known.iter().map(String::as_str).collect();-            let prev = r.data["currentStep"].as_str().map(str::to_string);-            record_usage(&mut r, &format!("step:{name}"));-            r.step_set(name, *back, why.as_deref());-            // per-step clips: the previous step's recording stops, this step's starts (window capture of the editor)-            let mut clip_note = String::new();-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let br = Bridge { ai_thread: thread(&cli), target };-                let caps = r.data["captures"].as_array().cloned().unwrap_or_default();-                if let Some(pos) = caps.iter().rposition(|c| c.get("recordingId").is_some() && c.get("file").is_none()) {-                    let rid = caps[pos]["recordingId"].as_str().unwrap().to_string();-                    let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                    // the step's closing screenshot: what the board looked like when the step ended-                    if let Some(h) = caps[pos]["hwnd"].as_i64() {-                        let tag = format!("shot-{}-{}-end", caps[pos]["step"].as_str().unwrap_or("step"), caps[pos]["visit"].as_u64().unwrap_or(1));-                        if let Some(pth) = br.screenshot_window(h, &save_to, &tag) {-                            r.log("artifact", json!({"step": caps[pos]["step"], "visit": caps[pos]["visit"], "kind": "screenshot", "when": "end", "file": pth}));-                        }-                    }-                    let (p, _) = br.record_stop(&rid, &save_to);-                    let cap = &mut r.data["captures"][pos];-                    cap["stop"] = json!(now());-                    cap["remoteFile"] = json!(p);-                    cap["file"] = json!(p.as_ref().map(|x| dir.join(Path::new(x).file_name().unwrap_or_default()).display().to_string()));-                    let st = json!(format!("{}-{}", cap["step"].as_str().unwrap_or("?"), cap["visit"].as_u64().unwrap_or(1)));-                    let local = cap["file"].as_str().map(str::to_string);-                    let (cs, cv, cb) = (cap["step"].clone(), cap["visit"].clone(), cap["back"].clone());-                    r.log("clip-stop", json!({"step": cs, "visit": cv, "back": cb, "recordingId": rid, "file": p}));-                    if let Some(f) = local.filter(|f| Path::new(f).is_file()) {-                        clip_artifacts(&mut r, &st, &f, 10);-                    }-                    clip_note.push_str(&format!("clip for {} stopped and pulled; ", prev.clone().unwrap_or_default()));-                }-                // the fields step films the app's window through `tour fields`: no editor clip for it-                let hwnd = if name == "fields" { None } else { r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))).or_else(|| br.pcb_editor_hwnd()) };-                let disk = disk_check(&dir, &br, name);-                if let Err(e) = &disk {-                    r.log("clip-refused", json!({"step": name, "why": e}));-                    r.save().unwrap();-                    err(&format!("step {name} recorded, but no clip: {e}"), &["The step is charged from now; start its clip once there is room: `step <name>` again, or `capture start`.".into()]);-                }-                match hwnd {-                    Some(h) => match br.record_window_start(h, &format!("aiflow step {name}{}", if *back { " (rework)" } else { "" })) {-                        Ok(rid) => {-                            // every visit is its own clip: placement-1, then placement-2 when a later step sends the AI back-                            let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1);-                            r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h, "step": name, "visit": visit, "back": *back, "why": why}));-                            r.mark(&format!("clip-start:{name}-{visit}"));-                            let save_to = std::fs::canonicalize(&dir).unwrap_or(dir.clone()).display().to_string();-                            if let Some(pth) = br.screenshot_window(h, &save_to, &format!("shot-{name}-{visit}-start")) {-                                r.log("artifact", json!({"step": name, "visit": visit, "kind": "screenshot", "when": "start", "file": pth}));-                            }-                            r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid}));-                            clip_note.push_str(&format!("clip {name}-{visit}{} recording (window {h}; {})", if *back { " (rework)" } else { "" }, disk.as_deref().unwrap_or("")));-                        }-                        Err(rep) if rep["errorCode"].as_str() == Some("window_not_found") => {-                            // the remembered editor window is gone (KiCad closed, the box restarted): look once more, live-                            match br.pcb_editor_hwnd() {-                                Some(h2) if h2 != h => match br.record_window_start(h2, &format!("aiflow step {name}")) {-                                    Ok(rid) => { let visit = r.data["steps"][name]["visits"].as_array().map(|a| a.len()).unwrap_or(1); r.data["captures"].as_array_mut().unwrap().push(json!({"recordingId": rid, "start": now(), "kind": "window", "hwnd": h2, "step": name, "visit": visit, "back": *back, "why": why})); r.log("clip-start", json!({"step": name, "visit": visit, "back": *back, "why": why, "recordingId": rid})); clip_note.push_str(&format!("clip {name}-{visit} recording (the editor moved to window {h2})")); }-                                    Err(rep2) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep2["errorCode"].as_str().unwrap_or("?"))),-                                },-                                _ => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box any more (capture open to bring the board back)")),-                            }-                        }-                        Err(rep) => clip_note.push_str(&format!("no clip for {name}: recorder did not start ({})", rep["errorCode"].as_str().unwrap_or("?"))),-                    },-                    None if name == "fields" => clip_note.push_str("the fields step films the app: `tour fields` brings its own camera"),-                    None => clip_note.push_str(&format!("no clip for {name}: no PCB editor open on the test box (capture open first)")),-                }-            }-            r.save().unwrap();-            let mut report_note = String::new();-            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {-                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));-                report_note = format!("Progress page: {url} ({})", notes.join("; "));-            }-            let record_hint = r.data["flow"]["steps"].as_array().and_then(|a| a.iter().find(|s| s["name"].as_str() == Some(name))).and_then(|s| s["record"].as_str()).map(|t| format!("What this step's clip should show: {t}")).unwrap_or_default();-            let hint = if *back { format!("Return to {name} recorded{}. Every command from here is charged to {name} until the next `step`.", why.as_ref().map(|w| format!(": {w}")).unwrap_or_default()) } else if known.contains(&name.as_str()) { format!("Every command from here is charged to {name} until the next `step`.") } else { format!("{name} is your own step name (the known ones: {}); it is charged like any other.", known.join(", ")) };-            let back_hint = if *back { "If the binary sent you back for its own fault (a wrong refusal, a missing lever, a rigid rule), that is a bug for the page, not a design lesson: file it so the next run does not pay for it.".to_string() } else { String::new() };-            ok(&format!("step {name}{}{}{}", if *back { " (back)" } else { "" }, prev.map(|p| format!(", leaving {p}")).unwrap_or_default(), if clip_note.is_empty() { String::new() } else { format!("; {clip_note}") }), &[hint, record_hint, back_hint, report_note]);-        }-        Cmd::Deliver { video, message, accept_suspect } => {-            thread(&cli);-            let mut r = load_run(&cli);-            let suspects: Vec<String> = r.data["captures"].as_array().cloned().unwrap_or_default().iter().filter(|c| c.get("suspect").is_some() && c.get("discarded").is_none()).map(|c| format!("{}-{}: {}", c["step"].as_str().unwrap_or("?"), c["visit"].as_u64().unwrap_or(1), c["suspect"].as_str().unwrap_or(""))).collect();-            if !suspects.is_empty() && !*accept_suspect {-                err(&format!("not delivered: {} clip(s) filmed nothing usable:\n{}", suspects.len(), suspects.iter().map(|x| format!("  {x}")).collect::<Vec<_>>().join("\n")), &["Look at each clip's contact sheet (<clip>-sheet.png) and re-record the step (`step <name> --back --why ...`), or `deliver --accept-suspect` and say why in the message.".into()]);-            }-            if r.data["clock"]["finishTime"].is_null() {-                err("not finished: `finish` has not passed, so there is nothing to deliver", &["Deliver comes after finish: the qualified board first, then the video and your message.".into()]);-            }-            let vp = Path::new(video);-            if !vp.is_file() {-                err(&format!("video not found: {video}"), &["Cut it first; deliver takes the final MP4 the human will watch.".into()]);-            }-            let bytes = std::fs::metadata(vp).map(|m| m.len()).unwrap_or(0);-            if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                let brs = Bridge { ai_thread: thread(&cli), target };-                stop_running_clip(&mut r, &brs, &dir, "deliver");-            }-            let t = now();-            r.data["clock"]["deliveredTime"] = json!(t);-            record_usage(&mut r, "deliver");-            r.data["summary"]["planUsage"] = json!(usage_delta(&r));-            r.data["delivery"] = json!({"at": t, "video": std::fs::canonicalize(vp).map(|p| p.display().to_string()).unwrap_or(video.clone()), "videoBytes": bytes, "message": message});-            r.data["summary"]["promptToDeliveredMinutes"] = json!(r.delivered_minutes());-            r.data["summary"]["steps"] = Value::Object(r.step_report());-            r.data["summary"]["turns"] = json!(r.data["turns"].as_array().map(|a| a.len()).unwrap_or(0));-            r.data["summary"]["clips"] = json!(r.data["captures"].as_array().map(|a| a.iter().filter(|c| c.get("file").is_some()).map(|c| json!({"step": c["step"], "visit": c["visit"], "back": c["back"], "why": c["why"], "start": c["start"], "stop": c["stop"], "file": c["file"]})).collect::<Vec<_>>()).unwrap_or_default());-            r.log("deliver", json!({"minutes": r.delivered_minutes(), "activeMinutes": r.delivered_active_minutes(), "video": video, "videoBytes": bytes, "message": message, "steps": Value::Object(r.step_report())}));-            r.save().unwrap();-            let gb = r.data["giveback"].as_array().map(|a| a.len()).unwrap_or(0);-            let mut report_note = format!("Given back this run: {gb} issue(s) or PR(s). {}.", usage_line(&r));-            if let Some(pg) = r.data["page"].as_str().map(str::to_string) {-                let (url, notes) = publish_report(&r, &dir, &pg, true, true, &thread(&cli));-                report_note.push_str(&format!(" Run page: {url} ({})", notes.join("; ")));-            }-            ok(&format!("delivered: {} from the prompt to \"done, here is your video\", {:.1} min (finish was at {:.1} min)\n{}", r.data["engine"], r.delivered_minutes().unwrap_or(0.0), r.elapsed_minutes(), step_table(&r)), &["This is the number on the chart. The per-step table is the run's shape: thinking is the AI, binary is the tool, returns are the reworks.".into(), report_note, give_back_hint()]);-        }-    }-}
crates/aiflow-analyze/Cargo.tomldeleted−10
@@ -1,10 +0,0 @@-[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.rsdeleted−191
@@ -1,191 +0,0 @@-//! 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.tomldeleted−10
@@ -1,10 +0,0 @@-[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.rsdeleted−12
@@ -1,12 +0,0 @@-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.rsdeleted−195
@@ -1,195 +0,0 @@-//! 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.rsdeleted−338
@@ -1,338 +0,0 @@-//! 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.rsdeleted−197
@@ -1,197 +0,0 @@-//! 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.rsdeleted−20
@@ -1,20 +0,0 @@-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.tomldeleted−10
@@ -1,10 +0,0 @@-[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.rsdeleted−348
@@ -1,348 +0,0 @@-//! The live half: everything that touches the desktop goes through the Adom Bridge CLI-//! (`adom-bridge --ai-thread ... --target ... <verb> <json>`), so the same etiquette, approval-//! gates and activity log apply as for any AI thread. Capture recipe, landing, measurement,-//! validation.-use std::process::Command;--use serde_json::{json, Value};--/// Shoelace area of a zone entry's `polygon` ([[x, y], ...]).-fn polygon_area_of(z: &Value) -> f64 {-    let pts: Vec<(f64, f64)> = z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|q| Some((q[0].as_f64()?, q[1].as_f64()?))).collect()).unwrap_or_default();-    if pts.len() < 3 {-        return 0.0;-    }-    let mut a = 0.0;-    for i in 0..pts.len() {-        let (x1, y1) = pts[i];-        let (x2, y2) = pts[(i + 1) % pts.len()];-        a += x1 * y2 - x2 * y1;-    }-    (a / 2.0).abs()-}--#[derive(Clone)]-pub struct Bridge {-    pub ai_thread: String,-    pub target: String,-}--impl Bridge {-    pub fn call(&self, verb: &str, args: &Value) -> Value {-        let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();-        match out {-            Ok(o) => {-                let text = String::from_utf8_lossy(&o.stdout);-                serde_json::from_str(&text).unwrap_or_else(|_| json!({"status": "parse_error", "raw": text.chars().take(400).collect::<String>()}))-            }-            Err(e) => json!({"status": "error", "error": format!("adom-bridge not runnable: {e}")}),-        }-    }--    pub fn ok(v: &Value) -> bool {-        v.get("success").and_then(|s| s.as_bool()) == Some(true) || (v.get("status").and_then(|s| s.as_str()) == Some("ok") && v.get("success").is_none())-    }--    /// The live board's revision. KiCad answers nothing while its UI thread fills zones after a-    /// commit, and the bridge may report itself busy while a previous verb's refill finishes; the-    /// read is retried for up to about two minutes. The last reply is kept for the log.-    pub fn revision(&self, board: &str) -> Option<String> {-        self.revision_with_reply(board).0-    }--    pub fn revision_with_reply(&self, board: &str) -> (Option<String>, Value) {-        let mut last = Value::Null;-        for attempt in 0..20 {-            let r = self.call("kicad_routing_state", &json!({"filePath": board}));-            if let Some(rev) = r.get("revision").and_then(|r| r.as_str()) {-                return (Some(rev.to_string()), r);-            }-            eprintln!("revision read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());-            last = r;-            std::thread::sleep(std::time::Duration::from_secs(6));-        }-        (None, last)-    }--    // -- capture recipe --------------------------------------------------------------------    pub fn open_board(&self, remote_board: &str, reason: &str) -> Result<i64, Value> {-        let r = self.call("kicad_open_board", &json!({"filePath": remote_board, "foreground": true, "foregroundReason": reason}));-        let Some(hwnd) = r.get("hwnd").and_then(|h| h.as_i64()) else { return Err(r) };-        self.call("desktop_ui_window", &json!({"hwnd": hwnd, "action": "maximize", "reason": "aiflow capture: fill the monitor"}));-        std::thread::sleep(std::time::Duration::from_secs(40));-        self.call("desktop_bring_to_front", &json!({"hwnd": hwnd, "reason": "aiflow capture: keep the PCB editor on screen on the test box"}));-        std::thread::sleep(std::time::Duration::from_secs(2));-        self.call("kicad_send_key", &json!({"hwnd": hwnd, "key": "ctrl+home"}));-        std::thread::sleep(std::time::Duration::from_secs(3));-        Ok(hwnd)-    }--    /// Record ONE window (Windows Graphics Capture, background-capturable): the take survives-    /// anything another thread or an updater puts in front of KiCad on a shared test box, and it-    /// never needs the foreground, so nobody at the box is disturbed.-    pub fn record_window_start(&self, hwnd: i64, reason: &str) -> Result<String, Value> {-        // a hard cap on every recording: no clip may outlive an hour, whatever the AI forgets-        let r = self.call("desktop_record_window_start", &json!({"hwnd": hwnd, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));-        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)-    }--    /// A background screenshot of one window (owned popups included), copied into `save_dir`-    /// under `name`; returns the local path.-    pub fn screenshot_window(&self, hwnd: i64, save_dir: &str, name: &str) -> Option<String> {-        let r = self.call("desktop_screenshot_window", &json!({"hwnd": hwnd}));-        let d = r.get("data").cloned().unwrap_or(r.clone());-        let shots = d.get("screenshots").and_then(|s| s.as_array()).cloned().unwrap_or_else(|| vec![d.clone()]);-        let local = shots.iter().find_map(|s| s.get("localPath").or_else(|| s.get("savedTo")).and_then(|v| v.as_str()).map(str::to_string))?;-        let dest = format!("{save_dir}/{name}.png");-        std::fs::copy(&local, &dest).ok()?;-        Some(dest)-    }--    /// The PCB editor's window handle, if KiCad has one open.-    pub fn pcb_editor_hwnd(&self) -> Option<i64> {-        let r = self.call("kicad_window_info", &json!({}));-        let d = r.get("data").cloned().unwrap_or(r.clone());-        d.get("editors").and_then(|e| e.as_array()).and_then(|a| a.iter().find(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor"))).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())-    }--    pub fn record_start(&self, reason: &str) -> Result<String, Value> {-        let r = self.call("desktop_record_start", &json!({"monitor": 0, "audio": false, "fps": 30, "reason": reason, "maxDurationMs": 3_600_000}));-        r.get("recordingId").or_else(|| r.get("id")).and_then(|v| v.as_str()).map(str::to_string).ok_or(r)-    }--    pub fn record_stop(&self, recording_id: &str, save_to: &str) -> (Option<String>, Value) {-        let r = self.call("desktop_record_stop", &json!({"recordingId": recording_id}));-        let mut p = r.get("filePath").or_else(|| r.get("path")).and_then(|v| v.as_str()).map(str::to_string);-        if p.is_none() {-            if let Some(out) = r.get("output").and_then(|o| o.as_str()) {-                if let Ok(v) = serde_json::from_str::<Value>(out) {-                    p = v.get("data").and_then(|d| d.get("path")).and_then(|x| x.as_str()).map(str::to_string);-                }-            }-        }-        let p = p.map(|s| s.replace('\\', "/"));-        if let Some(path) = &p {-            self.call("pull_file", &json!({"filePaths": [path], "saveTo": save_to}));-        }-        (p, r)-    }--    // -- landing -------------------------------------------------------------------------------    /// One kicad_move_footprint per PART, one undo step each, paused between, so the window-    /// recorder catches every part landing in its spot instead of the whole board changing in a-    /// single frame. Returns (replies, accepted, stopped-at).-    pub fn land_moves(&self, board: &str, moves: &Value, pause_ms: u64, mut progress: impl FnMut(usize, usize, &str, &Value)) -> (Vec<Value>, usize, Option<usize>) {-        let items: Vec<(String, Value)> = moves.as_object().map(|o| o.iter().map(|(r, m)| (r.clone(), m.clone())).collect()).unwrap_or_default();-        let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision", "message": "kicad_routing_state gave no revision; is the board open with the IPC API on?"})], 0, Some(0)) };-        let mut replies = Vec::new();-        let mut ok = 0;-        for (i, (reference, m)) in items.iter().enumerate() {-            let refs = json!([{"ref": reference, "x": m["x"], "y": m["y"], "rotation": m.get("rotation").cloned().unwrap_or(json!(0))}]);-            let r = self.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": rev, "refs": refs}));-            progress(i + 1, items.len(), reference, &r);-            if !Bridge::ok(&r) {-                replies.push(r);-                return (replies, ok, Some(i));-            }-            ok += 1;-            match r.get("revision").and_then(|v| v.as_str()) {-                Some(nr) => rev = nr.to_string(),-                None => if let Some(nr) = self.revision(board) { rev = nr },-            }-            replies.push(r);-            std::thread::sleep(std::time::Duration::from_millis(pause_ms));-        }-        (replies, ok, None)-    }--    /// Replay a plan ONE TRACE AT A TIME: an entry with several paths becomes one kicad_route_net-    /// per path, so the camera sees each trace drawn instead of a whole net appearing at once.-    /// Returns (replies, accepted, stopped-at, traces).-    pub fn land_plan(&self, board: &str, entries: &[Value], pause_ms: u64, mut progress: impl FnMut(usize, usize, &Value, &Value)) -> (Vec<Value>, usize, Option<usize>, usize) {-        let traces: Vec<Value> = entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {-            Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<Value>>(),-            _ => vec![e.clone()],-        }).collect();-        let entries: &[Value] = &traces;-        let mut rev = match self.revision(board) { Some(r) => r, None => return (vec![json!({"success": false, "errorCode": "no_revision"})], 0, Some(0), traces.len()) };-        let mut replies = Vec::new();-        let mut ok = 0;-        for (i, e) in entries.iter().enumerate() {-            let mut args = json!({"filePath": board, "expectedRevision": rev, "net": e["net"], "width": e["width"]});-            for k in ["viaSize", "viaDrill", "layer"] {-                if let Some(v) = e.get(k) {-                    args[k] = v.clone();-                }-            }-            if let Some(p) = e.get("paths") { args["paths"] = p.clone(); } else if let Some(p) = e.get("points") { args["points"] = p.clone(); }-            let r = self.call("kicad_route_net", &args);-            let good = Bridge::ok(&r);-            progress(i + 1, entries.len(), e, &r);-            if !good {-                replies.push(r);-                return (replies, ok, Some(i), traces.len());-            }-            ok += 1;-            if let Some(nr) = r.get("revision").and_then(|v| v.as_str()) {-                rev = nr.to_string();-            }-            replies.push(r);-            std::thread::sleep(std::time::Duration::from_millis(pause_ms));-        }-        (replies, ok, None, traces.len())-    }--    /// Land zones grouped by net (one undo step per group, lowest priority first), converging the-    /// live board on the plan: a live zone with a planned zone's name and outline area (within 2 %)-    /// is already landed and is skipped; a live zone carrying one of the plan's names but another-    /// outline is stale (the spec changed) and is removed first. The revision is read fresh before-    /// every group because KiCad's refill after a commit changes the board again after the bridge-    /// has answered.-    pub fn land_zones(&self, board: &str, zones: &[Value], pause_ms: u64, inherited_ids: &[String]) -> Vec<(String, Value)> {-        let live = self.zone_state(board, false);-        let mut out = Vec::new();-        let names: Vec<String> = zones.iter().filter_map(|z| z.get("name").and_then(|n| n.as_str()).map(str::to_string)).collect();-        let mut consumed = vec![false; zones.len()];-        let mut stale: Vec<String> = Vec::new();-        for l in live.get("zones").and_then(|z| z.as_array()).cloned().unwrap_or_default() {-            let (Some(name), Some(id)) = (l.get("name").and_then(|n| n.as_str()), l.get("id").and_then(|i| i.as_str())) else { continue };-            let kind = l.get("type").and_then(|t| t.as_str()).unwrap_or("copper");-            let inherited = inherited_ids.iter().any(|i| i == id);-            // the flow owns every copper or keepout zone the board did not start with; a zone it-            // owns that the plan no longer describes (renamed, reshaped, dropped) is stale-            if inherited || kind == "teardrop" || (name.is_empty() && !names.is_empty() && inherited_ids.is_empty()) {-                continue;-            }-            if !names.iter().any(|n| n == name) {-                stale.push(id.to_string());-                continue;-            }-            let area = l.get("outlineAreaMm2").and_then(|a| a.as_f64()).unwrap_or(0.0);-            let hit = zones.iter().enumerate().position(|(i, z)| !consumed[i] && z.get("name").and_then(|n| n.as_str()) == Some(name) && {-                let pa = polygon_area_of(z);-                (pa - area).abs() <= 0.02 * pa.max(area).max(0.5)-            });-            match hit {-                Some(i) => consumed[i] = true,-                None => stale.push(id.to_string()),-            }-        }-        if !stale.is_empty() {-            let Some(rev) = self.revision(board) else { return vec![("removed-stale".into(), json!({"success": false, "errorCode": "no_revision"}))] };-            let r = self.call("kicad_remove_zone", &json!({"filePath": board, "expectedRevision": rev, "itemIds": stale}));-            let n = stale.len();-            let good = Bridge::ok(&r);-            out.push(("removed-stale".into(), json!({"success": good, "removed": n, "errorCode": r["errorCode"], "message": format!("{n} live zones the flow had landed are no longer in the plan (renamed, reshaped or dropped) and were removed before landing"), "reply": r})));-            if !good {-                return out;-            }-            std::thread::sleep(std::time::Duration::from_millis(pause_ms));-        }-        let skipped = consumed.iter().filter(|c| **c).count();-        if skipped > 0 {-            out.push(("already-landed".into(), json!({"success": true, "skipped": skipped, "message": format!("{skipped} zones were already on the live board (same name and outline) and were not landed again")})));-        }-        let mut groups: Vec<(String, Vec<Value>, u64)> = Vec::new();-        for (i, z) in zones.iter().enumerate() {-            if consumed[i] {-                continue;-            }-            let key = z.get("net").and_then(|n| n.as_str()).unwrap_or("keepout").to_string();-            let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);-            if let Some(g) = groups.iter_mut().find(|g| g.0 == key) {-                g.1.push(z.clone());-                g.2 = g.2.min(prio);-            } else {-                groups.push((key, vec![z.clone()], prio));-            }-        }-        groups.sort_by_key(|g| g.2);-        for (net, zs, _) in groups {-            let mut r = json!({"success": false, "errorCode": "no_revision"});-            for attempt in 0..3 {-                let (rev, last) = self.revision_with_reply(board);-                let Some(rev) = rev else { r["lastReply"] = last; break };-                r = self.call("kicad_add_zone", &json!({"filePath": board, "expectedRevision": rev, "zones": zs}));-                if r.get("errorCode").and_then(|e| e.as_str()) != Some("stale_board") || attempt == 2 {-                    break;-                }-                std::thread::sleep(std::time::Duration::from_millis(1500));-            }-            let good = Bridge::ok(&r);-            out.push((net, r));-            if !good {-                break;-            }-            std::thread::sleep(std::time::Duration::from_millis(pause_ms));-        }-        out-    }--    /// Filled copper per layer. A refill of many zones can outlast the bridge's IPC wait-    /// (ipc_timeout while KiCad's UI thread fills); the fill keeps running, so wait and read again.-    /// Free megabytes on the test box's recording drive (the temp folder's drive), via a shell query.-    pub fn remote_free_mb(&self) -> Option<u64> {-        // shell_execute is human-gated on ab (Tier 2: it asks the person at the box and needs a reason);-        // when it is refused or unanswered this returns None and the check falls back to the run drive-        let r = self.call("shell_execute", &json!({"command": "powershell -NoProfile -Command \"(Get-PSDrive -Name ($env:TEMP.Substring(0,1))).Free\"", "timeoutSeconds": 20, "reason": "aiflow checks the free space on the recording drive before it starts a step's clip, so a run never fills the disk"}));-        let out = r.get("stdout").or_else(|| r.get("output")).and_then(|v| v.as_str()).unwrap_or("");-        out.trim().lines().last().and_then(|l| l.trim().parse::<u64>().ok()).map(|b| b / 1_048_576)-    }--    pub fn zone_state(&self, board: &str, refill: bool) -> Value {-        let mut r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": refill}));-        for attempt in 0..15 {-            if r.get("filledAreaByLayerMm2").is_some() {-                break;-            }-            eprintln!("zone state read {}: {} {}", attempt + 1, r["errorCode"].as_str().or(r["status"].as_str()).unwrap_or("?"), r["error"].as_str().or(r["message"].as_str()).unwrap_or("").chars().take(120).collect::<String>());-            std::thread::sleep(std::time::Duration::from_secs(6));-            r = self.call("kicad_zone_state", &json!({"filePath": board, "refill": false}));-        }-        r-    }--    /// Vias on the live board as (net, x, y).-    pub fn live_vias(&self, board: &str) -> Vec<(String, f64, f64)> {-        let r = self.call("kicad_routing_state", &json!({"filePath": board}));-        r.get("vias").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| Some((v.get("net_name")?.as_str()?.to_string(), v["position"]["x"].as_f64()?, v["position"]["y"].as_f64()?))).collect()).unwrap_or_default()-    }--    pub fn validate(&self, board: &str) -> Value {-        self.call("kicad_routing_validate", &json!({"filePath": board}))-    }--    pub fn status(&self) -> Value {-        self.call("kicad_status", &json!({}))-    }-}--#[cfg(test)]-mod tests {-    use serde_json::json;--    /// The rule behind "one trace at a time": an entry with several paths becomes one call per-    /// path, an entry with one path stays one call, so a plan's call count is its path count.-    fn expand(entries: &[serde_json::Value]) -> Vec<serde_json::Value> {-        entries.iter().flat_map(|e| match e.get("paths").and_then(|p| p.as_array()) {-            Some(ps) if ps.len() > 1 => ps.iter().map(|one| { let mut c = e.clone(); c["paths"] = json!([one]); c }).collect::<Vec<_>>(),-            _ => vec![e.clone()],-        }).collect()-    }--    #[test]-    fn one_call_per_trace() {-        let entries = vec![-            json!({"net": "+VBAT", "width": 1.0, "paths": [["A", [1.0, 1.0], [2.0, 2.0]], ["B", [3.0, 3.0], [4.0, 4.0]], ["C", [5.0, 5.0], [6.0, 6.0]]]}),-            json!({"net": "/SW", "width": 0.2, "paths": [["D", [7.0, 7.0], [8.0, 8.0]]]}),-        ];-        let traces = expand(&entries);-        assert_eq!(traces.len(), 4, "two entries with three and one paths must land as four traces");-        for t in &traces {-            assert_eq!(t["paths"].as_array().unwrap().len(), 1, "every landed call carries exactly one trace");-        }-        assert_eq!(traces[0]["net"], "+VBAT");-        assert_eq!(traces[3]["net"], "/SW");-        assert_eq!(traces[1]["width"], 1.0, "the net's width rides every one of its traces");-    }-}
crates/aiflow-copper/Cargo.tomldeleted−12
@@ -1,12 +0,0 @@-[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.rsdeleted−503
@@ -1,503 +0,0 @@-//! 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.tomldeleted−11
@@ -1,11 +0,0 @@-[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.rsdeleted−250
@@ -1,250 +0,0 @@-//! 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.tomldeleted−11
@@ -1,11 +0,0 @@-[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.rsdeleted−268
@@ -1,268 +0,0 @@-//! 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.tomldeleted−12
@@ -1,12 +0,0 @@-[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.rsdeleted−271
@@ -1,271 +0,0 @@-//! 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.tomldeleted−12
@@ -1,12 +0,0 @@-[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.rsdeleted−16
@@ -1,16 +0,0 @@-//! 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.rsdeleted−1156
@@ -1,1156 +0,0 @@-//! 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.rsdeleted−800
@@ -1,800 +0,0 @@-//! 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.rsdeleted−70
@@ -1,70 +0,0 @@-//! 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.tomldeleted−10
@@ -1,10 +0,0 @@-[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.rsdeleted−577
@@ -1,577 +0,0 @@-//! The run manifest: one JSON file per run carrying the clock, the plan, the decisions, every-//! stage's start and end, the outcomes, the capture markers and the summary. Every command reads-//! and writes it, so the comparison between engines is what the file says.-use std::path::{Path, PathBuf};--use serde_json::{json, Map, Value};--pub const STAGES: [&str; 9] = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"];--pub fn now() -> String {-    let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);-    iso(t)-}--/// Seconds since the epoch as an ISO 8601 UTC timestamp.-pub fn iso(t: u64) -> String {-    let days = t / 86400;-    let rem = t % 86400;-    let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);-    // civil from days (Howard Hinnant's algorithm)-    let z = days as i64 + 719468;-    let era = if z >= 0 { z } else { z - 146096 } / 146097;-    let doe = (z - era * 146097) as u64;-    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;-    let y = yoe as i64 + era * 400;-    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);-    let mp = (5 * doy + 2) / 153;-    let d = doy - (153 * mp + 2) / 5 + 1;-    let mth = if mp < 10 { mp + 3 } else { mp - 9 };-    let y = if mth <= 2 { y + 1 } else { y };-    format!("{y:04}-{mth:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")-}--/// ISO 8601 UTC timestamp to seconds since the epoch (whole seconds).-pub fn parse(ts: &str) -> Option<u64> {-    let b = ts.as_bytes();-    if b.len() < 19 {-        return None;-    }-    let n = |a: usize, l: usize| ts.get(a..a + l)?.parse::<i64>().ok();-    let (y, mo, d, h, mi, s) = (n(0, 4)?, n(5, 2)?, n(8, 2)?, n(11, 2)?, n(14, 2)?, n(17, 2)?);-    let y2 = if mo <= 2 { y - 1 } else { y };-    let era = if y2 >= 0 { y2 } else { y2 - 399 } / 400;-    let yoe = y2 - era * 400;-    let mp = if mo > 2 { mo - 3 } else { mo + 9 };-    let doy = (153 * mp + 2) / 5 + d - 1;-    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;-    let days = era * 146097 + doe - 719468;-    Some((days * 86400 + h * 3600 + mi * 60 + s) as u64)-}--/// One human prompt and the AI's work on it.-#[derive(Clone, Debug)]-pub struct Session {-    pub n: usize,-    pub prompt_at: u64,-    pub done_at: Option<u64>,-    pub text: String,-    /// active spans (start, end), idle gaps cut out-    pub spans: Vec<(u64, u64)>,-    pub idle_seconds: u64,-    /// true when the session has no done yet, or work went on after its done-    pub open: bool,-}--impl Session {-    pub fn active_seconds(&self) -> u64 { self.spans.iter().map(|(s, e)| e.saturating_sub(*s)).sum() }-}--pub struct Run {-    pub dir: PathBuf,-    pub data: Value,-}--impl Run {-    pub fn open(dir: &Path) -> Option<Run> {-        let f = dir.join("run.json");-        let text = std::fs::read_to_string(&f).ok()?;-        Some(Run { dir: dir.to_path_buf(), data: serde_json::from_str(&text).ok()? })-    }--    pub fn create(dir: &Path, board: &str, spec: &str, engine: &str, prompt_time: &str, ai_thread: &str, target: Option<&str>, remote_board: Option<&str>) -> Result<Run, String> {-        std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;-        let r = Run { dir: dir.to_path_buf(), data: json!({-            "version": 1, "tool": "adom-aiflow", "engine": engine, "aiThread": ai_thread, "board": board, "spec": spec,-            "target": target, "remoteBoard": remote_board,-            "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": null, "deliveredTime": null},-            "plan": null, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [board],-            "outcomes": {}, "summary": null,-            "currentStep": null, "steps": {}, "turns": [], "delivery": null,-        }) };-        r.save()?;-        r.log("start", json!({"engine": engine, "aiThread": ai_thread, "board": board, "spec": spec, "promptTime": prompt_time, "target": target, "remoteBoard": remote_board}));-        Ok(r)-    }--    /// Append one event to run.jsonl, the append-only ledger: one line per event, never rewritten.-    /// run.json is the derived state; when the two disagree, the ledger is the record.-    pub fn log(&self, event: &str, mut fields: Value) {-        fields["t"] = json!(now());-        fields["event"] = json!(event);-        let line = fields.to_string() + "\n";-        let f = self.dir.join("run.jsonl");-        if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(&f) {-            use std::io::Write;-            let _ = fh.write_all(line.as_bytes());-        }-    }--    /// Every event in run.jsonl, in order.-    pub fn ledger(&self) -> Vec<Value> {-        std::fs::read_to_string(self.dir.join("run.jsonl")).unwrap_or_default().lines().filter_map(|l| serde_json::from_str(l).ok()).collect()-    }--    /// Write run.json. Two commands can run at once (a landing in the background while the AI-    /// declares a step or sets the page), so the write merges with what is on disk: a top-level-    /// key this process never had is kept from disk, and the ledger-like arrays (turns, markers,-    /// captures) are unioned by identity so neither writer drops the other's entries.-    pub fn save(&self) -> Result<(), String> {-        std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;-        let mut data = self.data.clone();-        if let Some(disk) = std::fs::read_to_string(self.dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()) {-            if let (Some(d), Some(m)) = (disk.as_object(), data.as_object_mut()) {-                for (k, v) in d {-                    if !m.contains_key(k) {-                        m.insert(k.clone(), v.clone());-                    }-                }-                for (key, id) in [("turns", "n"), ("markers", "t"), ("captures", "start")] {-                    let mine = m.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();-                    let theirs = d.get(key).and_then(|a| a.as_array()).cloned().unwrap_or_default();-                    let ident = |e: &Value| format!("{}|{}", e[id], e["label"].as_str().or(e["recordingId"].as_str()).or(e["command"].as_str()).unwrap_or(""));-                    let mut merged: Vec<Value> = Vec::new();-                    let mut seen: Vec<String> = Vec::new();-                    for e in theirs.iter().chain(mine.iter()) {-                        let k = ident(e);-                        if let Some(pos) = seen.iter().position(|x| *x == k) {-                            // the later writer's version wins (it may carry the end / file / stop of the same entry)-                            merged[pos] = e.clone();-                        } else {-                            seen.push(k);-                            merged.push(e.clone());-                        }-                    }-                    merged.sort_by(|a, b| a[id].to_string().cmp(&b[id].to_string()));-                    if key == "turns" { merged.sort_by_key(|e| e["n"].as_u64().unwrap_or(0)); }-                    m.insert(key.to_string(), Value::Array(merged));-                }-            }-        }-        let tmp = self.dir.join("run.json.tmp");-        std::fs::write(&tmp, serde_json::to_string_pretty(&data).unwrap()).map_err(|e| e.to_string())?;-        std::fs::rename(&tmp, self.dir.join("run.json")).map_err(|e| e.to_string())-    }--    pub fn engine_for(&self, stage: &str) -> String {-        if let Some(e) = self.data["decisions"].get(stage).and_then(|v| v.as_str()) {-            return e.to_string();-        }-        self.data["plan"].get("stages").and_then(|s| s.get(stage)).and_then(|s| s.get("default")).and_then(|d| d.as_str()).unwrap_or("binary").to_string()-    }--    pub fn mark(&mut self, label: &str) {-        self.data["markers"].as_array_mut().unwrap().push(json!({"t": now(), "label": label}));-        if !label.starts_with("stage-") && !label.starts_with("step:") {-            self.log("marker", json!({"label": label}));-        }-    }--    pub fn stage_start(&mut self, name: &str, engine: Option<&str>, note: Option<&str>) {-        let engine = engine.map(str::to_string).unwrap_or_else(|| self.engine_for(name));-        let stages = self.data["stages"].as_object_mut().unwrap();-        let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));-        st["runs"].as_array_mut().unwrap().push(json!({"start": now(), "end": null, "engine": engine, "note": note, "result": null}));-        self.log("stage-start", json!({"stage": name, "engine": engine, "note": note}));-        self.mark(&format!("stage-start:{name}"));-    }--    pub fn stage_end(&mut self, name: &str, result: Option<Value>, note: Option<&str>) {-        let engine = self.engine_for(name);-        let stages = self.data["stages"].as_object_mut().unwrap();-        let st = stages.entry(name.to_string()).or_insert(json!({"runs": []}));-        let runs = st["runs"].as_array_mut().unwrap();-        if runs.last().map(|r| !r["end"].is_null()).unwrap_or(true) {-            runs.push(json!({"start": now(), "end": null, "engine": engine, "note": null, "result": null}));-        }-        let last = runs.last_mut().unwrap();-        last["end"] = json!(now());-        if let Some(r) = result {-            last["result"] = r;-        }-        if let Some(n) = note {-            last["note"] = json!(n);-        }-        let (res, started) = (last["result"].clone(), last["start"].clone());-        let secs = parse(&now()).unwrap_or(0).saturating_sub(started.as_str().and_then(parse).unwrap_or(0));-        self.log("stage-end", json!({"stage": name, "binarySeconds": secs, "result": res, "note": note}));-        self.mark(&format!("stage-end:{name}"));-    }--    pub fn current_board(&self) -> String {-        self.data["boards"].as_array().and_then(|a| a.last()).and_then(|b| b.as_str()).unwrap_or("").to_string()-    }--    pub fn add_board(&mut self, path: &str) {-        self.data["boards"].as_array_mut().unwrap().push(json!(path));-    }--    pub fn outcome(&mut self, key: &str, v: Value) {-        self.log("outcome", json!({"stage": key, "outcome": v}));-        self.data["outcomes"][key] = v;-    }--    pub fn outcomes(&self) -> &Map<String, Value> {-        static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();-        self.data["outcomes"].as_object().unwrap_or_else(|| EMPTY.get_or_init(Map::new))-    }--    // -- the ledger: every command is a turn, the gap before it is the AI's thinking -----------    /// The step a command belongs to when the AI has not declared one with `step`.-    pub fn infer_step(command: &str) -> &'static str {-        let c = command.trim();-        let first = c.split_whitespace().next().unwrap_or("");-        let second = c.split_whitespace().nth(1).unwrap_or("");-        match (first, second) {-            ("start", _) | ("plan", _) | ("take", _) => "intake",-            ("place", _) => "placement",-            ("route", _) | ("gate", _) => "routing",-            ("land", "moves") => "placement",-            ("land", "route") | ("land", "vias") => "routing",-            ("pour", _) | ("land", "pours") | ("measure", _) => "pours",-            ("analyze", "current") => "current",-            ("analyze", "thermal") => "thermal",-            ("capture", _) => "capture",-            ("finish", _) | ("deliver", _) => "finish",-            _ => "other",-        }-    }--    /// Record the start of a command. The seconds since the previous turn ended (or since the-    /// prompt, for the first turn) are the AI's thinking time and are charged to this turn's step:-    /// the thinking that led to this command. Returns the turn index.-    pub fn turn_begin(&mut self, command: &str) -> usize {-        let t = now();-        let now_s = parse(&t).unwrap_or(0);-        let last_end = self.data["turns"].as_array().and_then(|a| a.last()).and_then(|l| l["end"].as_str().or(l["at"].as_str())).and_then(parse);-        let since = last_end.or_else(|| self.data["clock"]["promptTime"].as_str().and_then(parse)).map(|p| now_s.saturating_sub(p)).unwrap_or(0);-        // a `step X` command is charged to X: the thinking that chose the step belongs to it-        let declared = command.trim().strip_prefix("step ").and_then(|r| r.split_whitespace().next()).map(str::to_string);-        let step = declared.or_else(|| self.data["currentStep"].as_str().map(str::to_string)).unwrap_or_else(|| Run::infer_step(command).to_string());-        let n = self.data["turns"].as_array().map(|a| a.len()).unwrap_or(0) + 1;-        if !self.data["turns"].is_array() {-            self.data["turns"] = json!([]);-        }-        self.data["turns"].as_array_mut().unwrap().push(json!({"n": n, "step": step, "command": command, "at": t, "thinkingSeconds": since, "end": null, "binarySeconds": null, "ok": null}));-        self.log("turn", json!({"n": n, "step": step, "command": command, "thinkingSeconds": since}));-        n - 1-    }--    pub fn turn_end(&mut self, idx: usize, ok: bool, first_line: &str) {-        let t = now();-        if let Some(turn) = self.data["turns"].as_array_mut().and_then(|a| a.get_mut(idx)) {-            let started = turn["at"].as_str().and_then(parse).unwrap_or(0);-            turn["end"] = json!(t);-            turn["binarySeconds"] = json!(parse(&t).unwrap_or(0).saturating_sub(started));-            turn["ok"] = json!(ok);-            turn["said"] = json!(first_line.chars().take(200).collect::<String>());-            let (n, step, bs) = (turn["n"].clone(), turn["step"].clone(), turn["binarySeconds"].clone());-            self.log("turn-end", json!({"n": n, "step": step, "binarySeconds": bs, "ok": ok, "said": first_line.chars().take(200).collect::<String>()}));-        }-    }--    /// The AI declares the step it is working on. `back` marks a return to an earlier step-    /// (rework found by a later step); `why` says what sent it back. Every command until the next-    /// `step` is charged to this step.-    pub fn step_set(&mut self, name: &str, back: bool, why: Option<&str>) {-        let t = now();-        if let Some(prev) = self.data["currentStep"].as_str().map(str::to_string) {-            if let Some(v) = self.data["steps"][&prev]["visits"].as_array_mut().and_then(|a| a.last_mut()) {-                if v["end"].is_null() {-                    v["end"] = json!(t);-                }-            }-        }-        if !self.data["steps"].is_object() {-            self.data["steps"] = json!({});-        }-        let st = self.data["steps"].as_object_mut().unwrap().entry(name.to_string()).or_insert(json!({"visits": []}));-        let n = st["visits"].as_array().map(|a| a.len()).unwrap_or(0) + 1;-        st["visits"].as_array_mut().unwrap().push(json!({"n": n, "at": t, "end": null, "back": back, "why": why}));-        self.data["currentStep"] = json!(name);-        self.log("step", json!({"step": name, "visit": n, "back": back, "why": why}));-        self.mark(&format!("step:{name}{}", if back { " (back)" } else { "" }));-    }--    /// Per step: AI thinking seconds, binary seconds, wall seconds, turns, visits, returns, and-    /// the longest single think. Wall = thinking + binary, so the sum over steps is the whole run.-    pub fn step_report(&self) -> Map<String, Value> {-        let mut m: Map<String, Value> = Map::new();-        if let Some(rep) = self.step_report_timeline() {-            return rep;-        }-        // the ledger is the record: rebuild the turns from run.jsonl when it exists-        let ledger = self.ledger();-        let mut turns: Vec<Value> = Vec::new();-        if !ledger.is_empty() {-            for e in &ledger {-                match e["event"].as_str() {-                    Some("turn") => turns.push(json!({"n": e["n"], "step": e["step"], "thinkingSeconds": e["thinkingSeconds"], "binarySeconds": 0})),-                    Some("turn-end") => {-                        if let Some(t) = turns.iter_mut().rev().find(|t| t["n"] == e["n"]) {-                            t["binarySeconds"] = e["binarySeconds"].clone();-                        }-                    }-                    _ => {}-                }-            }-        } else {-            turns = self.data["turns"].as_array().cloned().unwrap_or_default();-        }-        for t in turns {-            let step = t["step"].as_str().unwrap_or("other").to_string();-            let e = m.entry(step).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));-            let th = t["thinkingSeconds"].as_u64().unwrap_or(0);-            let bi = t["binarySeconds"].as_u64().unwrap_or(0);-            e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + th);-            e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + bi);-            e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));-            e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);-            if th > e["longestThinkSeconds"].as_u64().unwrap_or(0) {-                e["longestThinkSeconds"] = json!(th);-            }-        }-        for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {-            let visits = st["visits"].as_array().cloned().unwrap_or_default();-            let e = m.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));-            e["visits"] = json!(visits.len());-            e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());-            e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());-        }-        m-    }---    /// The per-step report from the ledger's timeline: every second between the prompt and the-    /// last event is either tool time (at least one turn running) or thinking (no turn running),-    /// charged to the step current at that moment. Overlapping turns (a landing in the background-    /// while the AI declares the next step) count once, so the steps sum to the wall clock.-    pub fn step_report_timeline(&self) -> Option<Map<String, Value>> {-        let led = self.ledger();-        if led.is_empty() {-            return None;-        }-        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;-        // turns as intervals, and step changes as (time, step)-        let mut turns: Vec<(u64, u64, String, u64)> = Vec::new(); // start, end, step, n-        let mut open: Vec<(u64, String, u64)> = Vec::new();-        let mut steps: Vec<(u64, String)> = vec![(t0, "intake".to_string())];-        let mut last = t0;-        for e in &led {-            let t = e["t"].as_str().and_then(parse).unwrap_or(0);-            last = last.max(t);-            match e["event"].as_str() {-                Some("turn") => open.push((t, e["step"].as_str().unwrap_or("other").to_string(), e["n"].as_u64().unwrap_or(0))),-                Some("turn-end") => {-                    let n = e["n"].as_u64().unwrap_or(0);-                    if let Some(pos) = open.iter().position(|o| o.2 == n) {-                        let (s, st, _) = open.remove(pos);-                        turns.push((s, t.max(s), st, n));-                    }-                }-                Some("step") => steps.push((t, e["step"].as_str().unwrap_or("other").to_string())),-                _ => {}-            }-        }-        let delivered = self.data["clock"]["deliveredTime"].as_str().and_then(parse);-        let end = delivered.or_else(|| self.data["clock"]["finishTime"].as_str().and_then(parse)).unwrap_or(last).max(last);-        for (s, st, n) in open { turns.push((s, end, st, n)); }-        // second by second (runs are hours, not years)-        let step_at = |t: u64| -> String { steps.iter().rev().find(|(ts, _)| *ts <= t).map(|(_, s)| s.clone()).unwrap_or_else(|| "intake".to_string()) };-        let mut acc: Map<String, Value> = Map::new();-        let mut think_run: (String, u64) = (String::new(), 0);-        let mut longest: std::collections::BTreeMap<String, u64> = Default::default();-        let spans = self.active_spans();-        let mut t = t0;-        while t < end {-            // the human away is not the AI thinking: seconds outside the active spans are skipped-            if !spans.iter().any(|(s, e)| *s <= t && t < *e) { t += 1; continue; }-            let running: Vec<&(u64, u64, String, u64)> = turns.iter().filter(|(s, e, _, _)| *s <= t && t < *e).collect();-            let step = if let Some(r) = running.first() { r.2.clone() } else { step_at(t) };-            // after the delivery, work that happens to run under `finish` (recuts, composes, reports) is-            // follow-up work, listed under Sessions; the finish step's own time ends at the delivery-            if step == "finish" && delivered.map(|d| t >= d).unwrap_or(false) { t += 1; continue; }-            let e = acc.entry(step.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));-            if running.is_empty() {-                e["thinkingSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + 1);-                if think_run.0 == step { think_run.1 += 1; } else { think_run = (step.clone(), 1); }-                let l = longest.entry(step.clone()).or_insert(0);-                if think_run.1 > *l { *l = think_run.1; }-            } else {-                e["binarySeconds"] = json!(e["binarySeconds"].as_u64().unwrap_or(0) + 1);-                think_run = (String::new(), 0);-            }-            e["wallSeconds"] = json!(e["thinkingSeconds"].as_u64().unwrap_or(0) + e["binarySeconds"].as_u64().unwrap_or(0));-            t += 1;-        }-        for (_, _, st, _) in &turns {-            let e = acc.entry(st.clone()).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));-            e["turns"] = json!(e["turns"].as_u64().unwrap_or(0) + 1);-        }-        for (st, l) in longest { if let Some(e) = acc.get_mut(&st) { e["longestThinkSeconds"] = json!(l); } }-        for (name, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {-            let visits = st["visits"].as_array().cloned().unwrap_or_default();-            let e = acc.entry(name).or_insert(json!({"thinkingSeconds": 0, "binarySeconds": 0, "wallSeconds": 0, "turns": 0, "longestThinkSeconds": 0, "visits": 0, "returns": 0}));-            e["visits"] = json!(visits.len());-            e["returns"] = json!(visits.iter().filter(|v| v["back"].as_bool() == Some(true)).count());-            e["why"] = json!(visits.iter().filter_map(|v| v["why"].as_str().map(str::to_string)).collect::<Vec<_>>());-        }-        Some(acc)-    }--    /// The idle threshold in minutes: a gap longer than this between two ledger events, with no-    /// command running, is the human away, not the AI thinking; it is cut out of the AI's time.-    pub fn idle_minutes(&self) -> u64 {-        self.data["idleMinutes"].as_u64().unwrap_or(15)-    }--    /// The run as sessions: one human prompt (the `start` event, then every `prompt`) and the AI's-    /// work on it until its `done` (or `deliver`), as active spans with the idle gaps cut out.-    /// A session without a `done` ends at its last event. The AI's time is the sum of the spans;-    /// the wall clock is not a measure of the AI.-    pub fn sessions(&self) -> Vec<Session> {-        let led = self.ledger();-        let idle = self.idle_minutes() * 60;-        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);-        let times: Vec<u64> = led.iter().map(|e| e["t"].as_str().and_then(parse).unwrap_or(0)).collect();-        // a command runs from its `turn` to its `turn-end`; one that died without a turn-end (an ERROR-        // exit) ran until the next ledger event at most. A gap covered by a running command is not idle.-        let mut running: Vec<(u64, u64)> = Vec::new();-        for (i, e) in led.iter().enumerate() {-            if e["event"] != "turn" { continue; }-            let n = e["n"].as_u64();-            let end = led.iter().enumerate().skip(i + 1).find(|(_, x)| x["event"] == "turn-end" && x["n"].as_u64() == n).map(|(j, _)| times[j]).or_else(|| times.get(i + 1).copied()).unwrap_or(times[i]);-            running.push((times[i], end));-        }-        let covered = |a: u64, b: u64| running.iter().any(|(s, e)| *s <= a && *e >= b);-        let mut out: Vec<Session> = Vec::new();-        let mut cur: Option<Session> = None;-        let mut last_activity: u64 = t0;-        let extend = |s: &mut Session, at: u64| { if let Some(sp) = s.spans.last_mut() { if at > sp.1 { sp.1 = at; } } };-        let open = |out: &mut Vec<Session>, cur: &mut Option<Session>, at: u64, text: String| {-            if let Some(s) = cur.take() { out.push(s); }-            *cur = Some(Session { n: out.len() + 1, prompt_at: at, done_at: None, text, spans: vec![(at, at)], idle_seconds: 0, open: true });-        };-        if t0 > 0 { open(&mut out, &mut cur, t0, self.data["prompt"].as_str().unwrap_or("the first prompt").to_string()); }-        for (i, e) in led.iter().enumerate() {-            let t = times[i];-            let gap_idle = t.saturating_sub(last_activity) > idle && !covered(last_activity, t);-            match e["event"].as_str().unwrap_or("") {-                "start" => { if cur.is_none() { open(&mut out, &mut cur, t0.max(1), "the first prompt".into()); } last_activity = t.max(t0); continue; }-                "prompt" => {-                    let at = e["at"].as_str().and_then(parse).unwrap_or(t);-                    open(&mut out, &mut cur, at, e["text"].as_str().unwrap_or("").to_string());-                    last_activity = at;-                    continue;-                }-                "done" | "deliver" => {-                    if let Some(s) = cur.as_mut() {-                        if s.done_at.is_none() {-                            if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }-                            s.done_at = Some(t); s.open = false;-                        }-                    }-                    last_activity = t;-                    continue;-                }-                _ => {}-            }-            let after_done = cur.as_ref().map(|s| s.done_at.is_some() || (s.text.starts_with("follow-up") && gap_idle)).unwrap_or(false);-            if after_done {-                // work after a done with no prompt mark: a follow-up the AI did not mark; its own row,-                // and every idle gap inside it starts another, since a gap there is most likely a new prompt-                if gap_idle || cur.as_ref().map(|s| !s.open).unwrap_or(true) {-                    open(&mut out, &mut cur, t, "follow-up (no prompt mark; say `prompt --text` next time)".into());-                    last_activity = t;-                    continue;-                }-            }-            let Some(s) = cur.as_mut() else { continue };-            if gap_idle { s.idle_seconds += t - last_activity; s.spans.push((t, t)); } else { extend(s, t); }-            last_activity = last_activity.max(t);-        }-        if let Some(s) = cur.take() { out.push(s); }-        out-    }--    /// Returns to earlier steps before the delivery: the reworks of the task itself, not of follow-ups.-    pub fn returns_before_delivery(&self) -> u64 {-        let cutoff = self.data["clock"]["deliveredTime"].as_str().and_then(parse).unwrap_or(u64::MAX);-        let mut n = 0;-        for (_, st) in self.data["steps"].as_object().cloned().unwrap_or_default() {-            for v in st["visits"].as_array().cloned().unwrap_or_default() {-                if v["back"].as_bool() == Some(true) && v["at"].as_str().or(v["start"].as_str()).and_then(parse).map(|t| t < cutoff).unwrap_or(true) { n += 1; }-            }-        }-        n-    }--    /// Every active span of the run, in order.-    pub fn active_spans(&self) -> Vec<(u64, u64)> {-        self.sessions().into_iter().flat_map(|s| s.spans).collect()-    }--    /// Seconds of AI time between two moments (the overlap of the active spans with [a, b]).-    pub fn active_between(&self, a: u64, b: u64) -> u64 {-        if b <= a { return 0; }-        self.active_spans().iter().map(|(s, e)| { let lo = (*s).max(a); let hi = (*e).min(b); hi.saturating_sub(lo) }).sum()-    }--    /// Seconds of AI time from the first prompt to the moment `t`: the clock the video shows.-    pub fn active_offset(&self, t: u64) -> u64 {-        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);-        self.active_between(t0, t)-    }--    /// Minutes of AI time from the first prompt to the delivery, if delivered: the benchmark number.-    pub fn delivered_active_minutes(&self) -> Option<f64> {-        let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;-        Some(((self.active_offset(t1) as f64) / 6.0).round() / 10.0)-    }--    /// Minutes of AI time over the whole run, every session.-    pub fn active_minutes_total(&self) -> f64 {-        (self.active_spans().iter().map(|(s, e)| e.saturating_sub(*s)).sum::<u64>() as f64 / 6.0).round() / 10.0-    }--    /// Minutes from the prompt to the delivery (the AI's "done, here is your video"), if delivered.-    pub fn delivered_minutes(&self) -> Option<f64> {-        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse)?;-        let t1 = self.data["clock"]["deliveredTime"].as_str().and_then(parse)?;-        Some((t1.saturating_sub(t0)) as f64 / 60.0)-    }--    pub fn elapsed_minutes(&self) -> f64 {-        let t0 = self.data["clock"]["promptTime"].as_str().and_then(parse).unwrap_or(0);-        let t1 = self.data["clock"]["finishTime"].as_str().and_then(parse).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0));-        ((t1 as f64 - t0 as f64) / 6.0).round() / 10.0-    }--    pub fn stage_minutes(&self) -> Map<String, Value> {-        let mut out = Map::new();-        if let Some(st) = self.data["stages"].as_object() {-            for (name, s) in st {-                let mut tot = 0.0;-                for r in s["runs"].as_array().cloned().unwrap_or_default() {-                    if let (Some(a), Some(b)) = (r["start"].as_str().and_then(parse), r["end"].as_str().and_then(parse)) {-                        tot += (b as f64 - a as f64) / 60.0;-                    }-                }-                out.insert(name.clone(), json!((tot * 10.0).round() / 10.0));-            }-        }-        out-    }-}--#[cfg(test)]-mod tests {-    use super::*;-    #[test]-    fn iso_round_trips() {-        assert_eq!(iso(0), "1970-01-01T00:00:00Z");-        let t = 1_789_299_600; // some 2026 second-        assert_eq!(parse(&iso(t)), Some(t));-        assert_eq!(parse("2026-09-13T15:38:04Z").map(iso).as_deref(), Some("2026-09-13T15:38:04Z"));-    }-}