main
Caleb Publish 0.2.0 cbe04a0 12h ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
//! adom-route: a whole-board PCB router built around the designer's routing standards.

mod board;
mod bus;
mod check;
mod classify;
mod clock;
mod compare;
mod conn;
mod current;
mod geom;
mod fixtures;
mod flow;
mod global;
mod grade;
mod index;
mod mask;
mod placement;
mod pour;
mod power;
mod post;
mod project;
mod quality;
mod render;
mod router;
mod rules;
mod spread;
mod search;
mod sx;
mod write;

use board::Board;
use clap::{Parser, Subcommand};
use rules::{Explicit, Rules};
use std::collections::BTreeMap;

/// The name this binary was built under (`adom-aiflow-routing`; `adom-route` is the same program).
const BIN: &str = env!("CARGO_BIN_NAME");

#[derive(Parser)]
#[command(name = BIN, version, about = "The routing stage of AI Flow: a whole-board PCB router and standards checker that tells the AI what to do next. A guide, never a gate.")]
struct Cli {
    /// The name of the AI conversation calling; it goes in the project's ledger
    #[arg(long, global = true)]
    ai_thread: Option<String>,
    /// Project folder (default: the nearest folder above the current one that has routing-flow/)
    #[arg(long, global = true)]
    project: Option<String>,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Start the routing flow on a placed board: `init --board <B.kicad_pcb> [--spec <spec.json>]`
    Init {
        #[arg(long)]
        board: String,
        #[arg(long)]
        spec: Option<String>,
    },
    /// What to do now: the first step whose file is missing, and what the AI does in it (a suggestion)
    Next,
    /// Every step with its state
    Steps,
    /// The run ledger, one line per command
    Ledger,
    /// Route every open connection and write a new board file (the input is never changed)
    Route {
        /// The board to route (default in a project: the one `init` named)
        board: Option<String>,
        /// Where to write (default in a project: routing-flow/routed.kicad_pcb)
        #[arg(short, long)]
        out: Option<String>,
        /// Board spec JSON (clearance, track, wideNets, impedanceTargets, routePriority, ...)
        #[arg(long)]
        spec: Option<String>,
        #[arg(long)]
        json: bool,
        /// Clearance in mm. Overrides the project's Default class; the output project is updated to match
        #[arg(long)]
        clearance: Option<f64>,
        /// Track width in mm on open runs. Overrides the project; the output project is updated to match
        #[arg(long)]
        track: Option<f64>,
        /// Narrowest track in mm, for necks at fine-pitch parts. Overrides the project's minimum track
        #[arg(long)]
        neck: Option<f64>,
        /// Via diameter in mm. Overrides the project; the output project is updated to match
        #[arg(long)]
        via: Option<f64>,
        /// Via drill in mm. Overrides the project; the output project is updated to match
        #[arg(long)]
        drill: Option<f64>,
        /// Stop searching after this many seconds and write the best result so far. By default it
        /// scales with the board: at least 240 s, plus 2.6 s per connection on larger boards
        #[arg(long)]
        time_limit: Option<f64>,
    },
    /// Fast tests: route a few nets of each fixture's board alone and judge the result
    Fixtures {
        file: String,
        /// Run only the fixtures whose name contains this
        #[arg(long)]
        only: Option<String>,
        /// Keep the stripped and routed boards in this folder
        #[arg(long)]
        keep: Option<String>,
        /// Run exactly this one fixture in this process (what the command starts for each fixture)
        #[arg(long, hide = true)]
        one: Option<String>,
    },
    /// Grade a board against the routing standards
    Check {
        /// The board to grade (default in a project: the routed board, else the one `init` named)
        board: Option<String>,
        #[arg(long)]
        spec: Option<String>,
        #[arg(long)]
        json: bool,
        /// Print every finding, not only the summary
        #[arg(long)]
        all: bool,
    },
    /// Run the checker over the reference boards and compare with an expected-results file
    Grade {
        #[arg(default_value = "bench/grade.json")]
        file: String,
        #[arg(long)]
        json: bool,
    },
    /// Write a copy with all tracks and vias removed (to measure routing against a reference)
    Strip {
        board: String,
        #[arg(short, long)]
        out: String,
    },
    /// Draw the copper to an SVG, optionally a window x0,y0,x1,y1 in mm
    Draw {
        board: String,
        #[arg(short, long)]
        out: String,
        #[arg(long)]
        window: Option<String>,
        /// Draw zones' filled copper as saved, not their outlines
        #[arg(long)]
        fills: bool,
        /// Comma-separated net names to draw at full strength; the rest are dimmed
        #[arg(long)]
        highlight: Option<String>,
        /// Do not name the pads in a window
        #[arg(long)]
        no_labels: bool,
    },
    /// Net by net against a reference board: length, vias, layers, widths, bends, spacing
    Compare {
        board: String,
        reference: String,
        #[arg(long)]
        spec: Option<String>,
        #[arg(long)]
        json: bool,
    },
    /// Show how the board's nets sort into the routing hierarchy
    Plan {
        /// The board to read (default in a project: the one `init` named)
        board: Option<String>,
        #[arg(long)]
        spec: Option<String>,
    },
}

/// The one kind of error: the command could not run. Says what to try next. Everything else a
/// command finds (open connections, rule findings, a failed test) is reported under `OK:` with
/// hints, because the board, the AI and the human decide what to do with it, not this program.
fn fail(msg: impl std::fmt::Display, hint: &str) -> ! {
    println!("ERROR: {msg}");
    if !hint.is_empty() {
        println!("Hint: {hint}");
    }
    std::process::exit(1)
}

fn hint(text: impl std::fmt::Display) {
    println!("Hint: {text}");
}

/// The project the command runs in, if any: `--project`, else the nearest one above `near` or above
/// the current folder. Commands work the same without one; with one they keep their files and a
/// ledger line in `routing-flow/`.
fn find_project(cli_project: &Option<String>, near: Option<&str>) -> Option<flow::Project> {
    if let Some(p) = cli_project {
        return flow::Project::find(std::path::Path::new(p));
    }
    let from_board = near.and_then(|b| std::path::Path::new(b).parent().map(|d| if d.as_os_str().is_empty() { std::path::PathBuf::from(".") } else { d.to_path_buf() })).and_then(|d| flow::Project::find(&d));
    from_board.or_else(|| std::env::current_dir().ok().and_then(|d| flow::Project::find(&d)))
}

fn state_str(p: &Option<flow::Project>, key: &str) -> Option<String> {
    p.as_ref().and_then(|p| p.state[key].as_str().map(String::from)).filter(|s| !s.is_empty())
}

/// A positional board, or the project's; with neither the command cannot run.
fn board_or(given: Option<String>, p: &Option<flow::Project>, verb: &str) -> String {
    given.or_else(|| state_str(p, "board")).unwrap_or_else(|| fail(format!("no board to {verb}"), &format!("Pass one: `{BIN} {verb} <board.kicad_pcb>`, or start a project so every command knows it: `{BIN} init --board <board.kicad_pcb>`.")))
}

fn load(board: &str, spec: &Option<String>) -> (Board, Rules) {
    let (b, r, _) = load_notes(board, spec, Explicit::default());
    (b, r)
}

fn load_notes(board: &str, spec: &Option<String>, named: Explicit) -> (Board, Rules, Vec<String>) {
    let b = Board::read(board).unwrap_or_else(|e| fail(e, "Pass a .kicad_pcb file written by KiCad 9 or 10."));
    let mut r = match spec {
        Some(s) => Rules::load(s).unwrap_or_else(|e| fail(e, "Pass a JSON spec, or leave --spec off to use the standing rules.")),
        None => Rules::default(),
    };
    // What was named on the command line beats the spec, and both beat the project.
    if let Some(x) = named.clearance { r.clearance = x; r.floor = x; r.explicit.clearance = Some(x); }
    if let Some(x) = named.track { r.track = x; r.explicit.track = Some(x); }
    if let Some(x) = named.neck { r.neck = x; r.explicit.neck = Some(x); }
    if let Some(x) = named.via { r.via = x; r.explicit.via = Some(x); }
    if let Some(x) = named.drill { r.drill = x; r.explicit.drill = Some(x); }
    let mut notes = project::apply(board, &mut r);
    r.bind(&b);
    notes.extend(classify::find_pairs(&b, &mut r));
    (b, r, notes)
}

fn write_guard(input: &str, out: &str) {
    if let (Ok(a), Ok(b)) = (std::fs::canonicalize(input), std::fs::canonicalize(out)) {
        if a == b {
            fail("the output path is the input board, and the input is never changed", "Give -o a different file: a new revision beside the old one (4.6).");
        }
    }
    if input == out {
        fail("the output path is the input board, and the input is never changed", "Give -o a different file: a new revision beside the old one (4.6).");
    }
}

fn summary(json: &serde_json::Value) -> String {
    let s = &json["score"];
    format!(
        "{} errors, {} warnings. Open: {} nets ({} joins). Copper: {} mm ({} top, {} inner, {} bottom), {} vias, {} segments, {} 45-degree and {} 90-degree bends, tightest gap {} mm.",
        json["errors"], json["warnings"], s["netsOpen"], s["padsOpen"], s["lengthMm"], s["topMm"], s["innerMm"], s["bottomMm"], s["vias"], s["segments"], s["bends45"], s["bends90"], s["minGapMm"]
    )
}

fn main() {
    // Output cut short by the reader (`... | head`) is not a failure of the command: end quietly.
    let default_hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        if info.to_string().contains("Broken pipe") {
            std::process::exit(0);
        }
        default_hook(info);
    }));
    let cli = Cli::parse();
    let thread = cli.ai_thread.clone().unwrap_or_else(|| "unnamed".to_string());
    let cli_project = cli.project.clone();
    match cli.cmd {
        Cmd::Init { board, spec } => {
            let b = Board::read(&board).unwrap_or_else(|e| fail(e, "Pass a .kicad_pcb file written by KiCad 9 or 10."));
            let abs = |p: &str| std::fs::canonicalize(p).map(|x| x.display().to_string()).unwrap_or_else(|_| p.to_string());
            let dir = cli_project.clone().map(std::path::PathBuf::from).unwrap_or_else(|| std::path::Path::new(&abs(&board)).parent().map(|d| d.to_path_buf()).unwrap_or_else(|| std::path::PathBuf::from(".")));
            let pro = std::path::Path::new(&board).with_extension("kicad_pro");
            let state = serde_json::json!({
                "flow": "routing", "started": flow::now(), "board": abs(&board), "boardSha256": flow::sha256(std::path::Path::new(&board)),
                "spec": spec.as_deref().map(abs), "project": if pro.is_file() { serde_json::json!(abs(&pro.display().to_string())) } else { serde_json::Value::Null },
                "footprints": b.footprints.len(), "tracks": b.tracks.len(), "vias": b.vias.len()});
            let p = flow::Project::create(&dir, state).unwrap_or_else(|e| fail(format!("could not write {}: {e}", dir.join(flow::FLOW_DIR).display()), "Pass --project <a folder you can write to>."));
            p.log(&thread, "init", serde_json::json!({"board": abs(&board), "spec": spec}));
            println!("OK: routing flow started in {}: {} parts, {} tracks and {} vias already on the board.", p.flow_dir.display(), b.footprints.len(), b.tracks.len(), b.vias.len());
            if !pro.is_file() {
                hint(format!("No {} beside the board: rules come from KiCad's defaults (0.2 mm clearance and track, 0.5 mm to the edge). Put the project file there if the design has its own.", pro.display()));
            }
            if spec.is_none() {
                hint("No spec: supply rails have no currents, so they are drawn at the 0.40 mm floor. A spec with each rail's volts and amps lets the power stage size them; see the skill for its fields.");
            }
            if cli.ai_thread.is_none() {
                hint("Pass --ai-thread \"<your conversation's name>\" so the ledger says who ran what.");
            }
            hint(format!("Next: `{BIN} next`."));
        }
        Cmd::Next | Cmd::Steps | Cmd::Ledger => {
            let p = find_project(&cli_project, None).unwrap_or_else(|| fail("no routing-flow/ project here", &format!("Start one: `{BIN} init --board <board.kicad_pcb>`. Or use the verbs on their own: `{BIN} plan|route|check <board>` need no project.")));
            let steps = flow::steps();
            match cli.cmd {
                Cmd::Next => match p.current(&steps) {
                    Some((i, s)) => {
                        println!("OK: step {i} of {}: {}", steps.len(), s.name);
                        hint(&s.todo);
                        hint("This is the suggested order, not a gate: take another route when you have a better one, and say in the ledger or the review what you did and why.");
                    }
                    None => {
                        println!("OK: every step has its file. The routed board is {}.", p.path("routed.kicad_pcb").display());
                        hint("Show the human the board and the review before it goes on; what is still open is theirs to accept.");
                    }
                },
                Cmd::Steps => {
                    let cur = p.current(&steps).map(|(i, _)| i);
                    println!("OK: {} steps in {}", steps.len(), p.flow_dir.display());
                    for (i, s) in steps.iter().enumerate() {
                        println!("  {} {:<8} {}", if p.is_done(s) { "done" } else if cur == Some(i + 1) { "next" } else { "    " }, s.name, s.done.join(", "));
                    }
                }
                _ => {
                    let text = std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default();
                    println!("OK: {} ledger lines in {}", text.lines().count(), p.path("run.jsonl").display());
                    print!("{text}");
                }
            }
        }
        Cmd::Strip { board, out } => {
            write_guard(&board, &out);
            let b = Board::read(&board).unwrap_or_else(|e| fail(e, ""));
            std::fs::write(&out, write::strip(&b)).unwrap_or_else(|e| fail(e, ""));
            println!("OK: wrote {out} without its {} tracks and {} vias.", b.tracks.len(), b.vias.len());
        }
        Cmd::Draw { board, out, window, fills, highlight, no_labels } => {
            let b = Board::read(&board).unwrap_or_else(|e| fail(e, ""));
            let w = window.map(|w| {
                let v: Vec<f64> = w.split(',').filter_map(|x| x.parse().ok()).collect();
                if v.len() != 4 {
                    fail("window must be x0,y0,x1,y1 in mm", "");
                }
                (v[0], v[1], v[2], v[3])
            });
            let view = render::View { fills, highlight: highlight.map(|h| h.split(',').map(String::from).collect()).unwrap_or_default(), labels: w.is_some() && !no_labels };
            std::fs::write(&out, render::svg_view(&b, w, &view)).unwrap_or_else(|e| fail(e, ""));
            println!("OK: wrote {out}.");
        }
        Cmd::Grade { file, json } => {
            let o = grade::run(&file).unwrap_or_else(|e| fail(e, "Pass a grade file such as bench/grade.json."));
            if json {
                println!("{}", serde_json::to_string_pretty(&o.json).unwrap());
            } else {
                for l in &o.lines {
                    println!("{l}");
                }
                println!("OK: graded. {}", if o.ok { "Every board came out as the expected-results file says." } else { "The checker disagrees with the expected-results file on the lines marked FAIL above." });
                if !o.ok {
                    hint("Either the checker changed or the boards did: read the FAIL lines, then fix the rule or update the expected file, whichever is wrong.");
                }
            }
        }
        Cmd::Plan { board, spec } => {
            let proj = find_project(&cli_project, board.as_deref());
            let board = board_or(board, &proj, "plan");
            let spec = spec.or_else(|| state_str(&proj, "spec"));
            let (b, r) = load(&board, &spec);
            let infos = classify::classify(&b, &r);
            let mut by: BTreeMap<classify::Level, Vec<&str>> = BTreeMap::new();
            for i in &infos {
                by.entry(i.level).or_default().push(b.net_name(i.net));
            }
            let plan = router::make_plan(&b, &r);
            let mut out: Vec<String> = Vec::new();
            out.push(format!("OK: {} nets; {} connections to make, {} pairs.", infos.len(), plan.tasks.len(), plan.pairs.len()));
            for (l, v) in by {
                out.push(format!("  {:<18} {}", l.name(), v.join(" ")));
            }
            let fine: Vec<&str> = b.footprints.iter().enumerate().filter(|(i, _)| classify::is_fine_pitch(&b, &r, *i)).map(|(_, f)| f.reference.as_str()).collect();
            out.push(format!("  fine pitch: {}", fine.join(" ")));
            // Why, per net: 4.2 says never to judge current from a name or width alone, so the
            // designer can read what each class was judged from.
            for i in &infos {
                out.push(format!("  net {} = {}{}: {}", b.net_name(i.net), i.level.name(), if i.switching { " (switch node)" } else { "" }, i.why));
                for t in &i.taps {
                    out.push(format!("    Kelvin tap: {}", t.iter().map(|&p| b.pad_name(p)).collect::<Vec<_>>().join(" -> ")));
                }
            }
            let mut zs: BTreeMap<String, usize> = BTreeMap::new();
            for z in &b.zones {
                let names: Vec<&str> = z.layers.iter().map(|&l| b.layers[l].name.as_str()).collect();
                *zs.entry(format!("{} on {} keepout={} fills={}", b.net_name(z.net), names.join("+"), z.keepout_tracks, z.fills.len())).or_insert(0) += 1;
            }
            out.push(format!("  zones: {zs:?}"));
            for (p, n, w, g) in &plan.pairs {
                out.push(format!("  pair {} / {}: {:.4}/{:.3} mm", b.net_name(*p), b.net_name(*n), *w as f64 / geom::NM, *g as f64 / geom::NM));
            }
            println!("{}", out.join("\n"));
            // Supply nets with no current to size them by: said as a hint, since only the designer
            // (or a datasheet) knows the number.
            let unrated: Vec<&str> = infos.iter().filter(|i| i.level == classify::Level::Power && r.rail(b.net_name(i.net)).is_none()).map(|i| b.net_name(i.net)).collect();
            if !unrated.is_empty() {
                hint(format!("{} supply net(s) have no current in the spec and will be drawn at the 0.40 mm floor: {}. A rail entry with volts and amps lets the power stage size each one: a rail the board makes is rated by its regulator, a rail it only receives by the draw of the parts that take it (the prompt, their wiki pages and datasheets). When nothing says, ask the human for the expected draw.", unrated.len(), unrated.iter().take(12).cloned().collect::<Vec<_>>().join(", ")));
            }
            if let Some(p) = &proj {
                let _ = std::fs::write(p.path("plan.txt"), out.join("\n") + "\n");
                p.log(&thread, "plan", serde_json::json!({"board": board, "spec": spec, "nets": infos.len(), "connections": plan.tasks.len(), "pairs": plan.pairs.len(), "unratedRails": unrated.len()}));
                hint(format!("Saved as {}. Next: `{BIN} next`.", p.path("plan.txt").display()));
            }
        }
        Cmd::Compare { board, reference, spec, json } => {
            let (b, r) = load(&board, &spec);
            let (his, _) = load(&reference, &spec);
            let v = compare::report(&b, &his, &r, json);
            if json {
                println!("{}", serde_json::to_string_pretty(&v).unwrap());
            }
        }
        Cmd::Fixtures { file, only, keep, one } => {
            let one_given = one.is_some();
            let ok = match one {
                Some(name) => fixtures::run(&file, Some(&name), keep.as_deref()),
                None => fixtures::run_all(&file, only.as_deref(), keep.as_deref()),
            };
            // The hidden --one child tells its parent by exit code; the command itself only reports.
            if !ok && one_given {
                std::process::exit(1);
            }
            if !ok {
                hint("A FAIL line is a behaviour that changed: read its board in the --keep folder before deciding whether the router or the fixture is wrong.");
            }
        }
        Cmd::Check { board, spec, json, all } => {
            let proj = find_project(&cli_project, board.as_deref());
            let routed = proj.as_ref().map(|p| p.path("routed.kicad_pcb")).filter(|p| p.is_file()).map(|p| p.display().to_string());
            let board = board.or(routed).unwrap_or_else(|| board_or(None, &proj, "check"));
            let spec = spec.or_else(|| state_str(&proj, "spec"));
            let (b, r) = load(&board, &spec);
            let (f, s) = check::check(&b, &r);
            let j = check::to_json(&f, &s);
            let errors = j["errors"].as_u64().unwrap_or(0);
            if json {
                println!("{}", serde_json::to_string_pretty(&j).unwrap());
            } else {
                // The check ran, so it answers OK; what it found follows. A finding is a report with a
                // suggestion, never a verdict: the designer may have drawn that copper on purpose.
                println!("OK: checked {board}: {}", summary(&j));
                println!("  per rule: {}", j["perRule"]);
                println!("  widths: {}", j["score"]["widths"]);
                let show = if all { f.len() } else { 25 };
                for x in f.iter().take(show) {
                    println!("  {:<5} {:<14} ({:.2}, {:.2}) {}", x.severity, x.rule, x.at.0, x.at.1, x.text);
                }
                if f.len() > show {
                    println!("  ... {} more (--all shows them)", f.len() - show);
                }
                if errors != 0 {
                    hint("Each finding names its rule and place; --all lists every one. Fix what is real and check again. A finding on copper the designer drew on purpose is theirs to keep: say so in the review.");
                }
                hint("KiCad's own DRC with zones refilled, against the board's project, is the judge of clearance and of what is really joined; run it on the same file.");
            }
            if let Some(p) = &proj {
                let _ = std::fs::write(p.path("check.json"), serde_json::to_string_pretty(&j).unwrap_or_default() + "\n");
                p.log(&thread, "check", serde_json::json!({"board": board, "errors": errors, "warnings": j["warnings"], "perRule": j["perRule"]}));
                if !json {
                    hint(format!("Saved as {}. Next: `{BIN} next`.", p.path("check.json").display()));
                }
            }
        }
        Cmd::Route { board, out, spec, json, clearance, track, neck, via, drill, time_limit } => {
            let proj = find_project(&cli_project, board.as_deref());
            let board = board_or(board, &proj, "route");
            let spec = spec.or_else(|| state_str(&proj, "spec"));
            let out = out.or_else(|| proj.as_ref().map(|p| p.path("routed.kicad_pcb").display().to_string())).unwrap_or_else(|| fail("nowhere to write the routed board", &format!("Pass -o <routed.kicad_pcb>, or start a project (`{BIN} init --board {board}`) and it goes to routing-flow/routed.kicad_pcb.")));
            write_guard(&board, &out);
            let named = Explicit { clearance: clearance.map(geom::mm), track: track.map(geom::mm), neck: neck.map(geom::mm), via: via.map(geom::mm), drill: drill.map(geom::mm) };
            let (b, mut r, rule_notes) = load_notes(&board, &spec, named);
            // Copper already on the board narrower than the settings allow: the settings may be wrong
            // for this design, or those tracks are. That is the designer's to say. The router leaves
            // them as they are, lays new copper to the settings, and says so at the end.
            let floor = r.min.track.max(0);
            let narrow: Vec<&board::Track> = b.tracks.iter().filter(|t| t.width + 1000 < floor).collect();
            let narrow_hint = narrow.iter().min_by_key(|t| t.width).map(|n| {
                format!(
                    "{} tracks already on the board are narrower than the {:.3} mm minimum (the narrowest {:.3} mm on {} at ({:.2}, {:.2})). They were left as they are and new copper follows the minimum. If the thin ones are right for this design, pass --neck {:.3} and route again; if not, they are for the designer to fix.",
                    narrow.len(), geom::to_mm(floor), geom::to_mm(n.width), b.net_name(n.net), geom::to_mm(n.a.0), geom::to_mm(n.a.1), geom::to_mm(n.width)
                )
            });
            for n in &rule_notes {
                eprintln!("note: {n}");
            }
            let plan = router::make_plan(&b, &r);
            // Large boards need the time: benchmark C reached 278 of 302 at 240 s and 298 at 800 s.
            let joins = plan.tasks.len() + plan.pairs.len() * 2;
            // In work-clock seconds (clock.rs), which are calibrated to the densest benchmark board: a
            // small board does about twice the search work per wall second, so the old 2.6 s a join and
            // 240 s floor, kept as they were, halved every small board's budget (carrier-fc-rp2350a-mini
            // stopped at 62 of 74 with "ran out of budget", 2026-11-10). 5.5 and 505 give back the work
            // those wall-clock limits meant.
            r.time_limit = time_limit.unwrap_or((5.5 * joins as f64).max(505.0));
            let mut rt = router::Router::new(&b, r.clone());
            let o = rt.run(&plan);
            let routes: Vec<&router::Route> = {
                let mut v: Vec<&router::Route> = rt.routes.values().collect();
                v.sort_by_key(|x| x.id);
                v
            };
            let text = write::render(&b, &routes, &r);
            std::fs::write(&out, &text).unwrap_or_else(|e| fail(e, ""));
            let updated = project::write_for_output(&board, &out, &r.explicit).unwrap_or_else(|e| fail(e, ""));
            r.pad_rules = rt.rules.pad_rules.clone();
            if let Some(path) = project::write_dru(&board, &out, &r.pad_rules).unwrap_or_else(|e| fail(e, "")) {
                if !r.pad_rules.is_empty() {
                    println!("  REVIEW: {} local pad rule(s) proposed in {path}", r.pad_rules.len());
                }
            }
            for u in &updated {
                println!("  project updated: {u}");
            }
            // Grade the written file from scratch; the checker shares no state with the router.
            let written = Board::parse(text).unwrap_or_else(|e| fail(e, "The written board did not parse back; this is a bug in the writer."));
            let (f, s) = check::check(&written, &r);
            let j = check::to_json(&f, &s);
            let failed: Vec<String> = o.failed.iter().map(|t| format!("{} ({}): {} to {}", b.net_name(t.net), t.level.name(), b.pad_name(t.a), b.pad_name(t.b))).collect();
            if json {
                let out_json = serde_json::json!({"out": out, "seconds": o.seconds, "failed": failed, "rips": rt.rips, "searches": rt.searches, "nodes": rt.nodes, "notes": rt.notes, "check": j});
                println!("{}", serde_json::to_string_pretty(&out_json).unwrap());
            } else {
                let tasks = plan.tasks.len() + plan.pairs.len() * 2;
                // Route's own outcome: did every connection get made? The written board's grade is
                // reported on the next line, and `check` is what passes or fails a board.
                println!("OK: wrote {out}: {} of {} connections routed in {:.1} s ({} rip-ups).", tasks.saturating_sub(o.failed.len()), tasks, o.seconds, rt.rips);
                println!("  check: {}", summary(&j));
                println!("  per rule: {}", j["perRule"]);
                println!("  widths: {}", j["score"]["widths"]);
                for x in &failed {
                    println!("  unrouted: {x}");
                }
                for n in &rt.notes {
                    println!("  note: {n}");
                }
                for l in &rt.log {
                    println!("  {l}");
                }
                println!("  {}", rt.world.guide.borrow().summary());
                for x in f.iter().filter(|x| x.severity == "error").take(12) {
                    println!("  {:<5} {:<14} ({:.2}, {:.2}) {}", x.severity, x.rule, x.at.0, x.at.1, x.text);
                }
                if !o.failed.is_empty() {
                    hint(format!("{} connection(s) are open; each has its reason in the notes above. Ideas, yours to weigh: move the part whose pad is walled in, give a supply rail its current in the spec so it is planned first, allow more work with --time-limit, or route those few by hand.", o.failed.len()));
                }
                if !r.pad_rules.is_empty() {
                    hint("The local pad rules marked REVIEW let a track pass a pad closer than its class asks, where no track fits otherwise. They are proposals for the human: keep the .kicad_dru beside the board only if they agree.");
                }
                if let Some(h) = &narrow_hint {
                    hint(h);
                }
            }
            if let Some(p) = &proj {
                let tasks = plan.tasks.len() + plan.pairs.len() * 2;
                p.log(&thread, "route", serde_json::json!({"board": board, "out": out, "connections": tasks, "open": o.failed.len(), "workSeconds": o.seconds, "checkErrors": j["errors"], "localRules": r.pad_rules.len()}));
                if !json {
                    hint(format!("Look at it before trusting the numbers: `{BIN} draw {out} -o routed.svg`. Next: `{BIN} next`."));
                }
            }
        }
    }
}