← All Pull Requests

0.1.99: EDA backends and the Altium lane #34

Merged Released in 0.1.99 on 16 Oct 2026 opened by Colby Knox
Merges altium-lane-0.1.99 → main

AI Flow gets an EDA backend interface, and Altium Designer becomes its third lane after KiCad and Fusion 360. Release notes: docs/release-0.1.99.md. Lane reference: docs/altium-lane.md. Interface: docs/eda-backends.md.

Base: 31c66e2de97b4564a300581ee843b38166d30b30 (Publish 0.1.98). HEAD was rechecked immediately before the push. Branch altium-lane-0.1.99, 26 files; the version is bumped to 0.1.99 in Cargo, package, page and the binary.

Scope

  • Eda trait (crates/aiflow-bridge/src/eda.rs) with KicadEda, FusionEda and AltiumEda. Bridge::call dispatches kicad_* verbs through the run's backend, and main.rs asks the backend instead of branching on Fusion.
  • Altium backend (crates/aiflow-bridge/src/altium.rs) and the Altium plan-board renderer (crates/aiflow-board/src/altium.rs).
  • adom-aiflow altium <step>, running tools/altium-project.py on the shared tools/project-common.py. The steps are library, reference, schematic, board, silkscreen, view3d, export and reset.
  • The silkscreen planner moves verbatim out of fusion-project.py into tools/silkscreen-plan.py, shared by both lanes, with the text metric per lane.
  • Lane scripts run from a temp file, because python3 -c hits the 128 KiB argument limit on the 151 KB Fusion lane.
  • kicad-cli-remote targets the run's desktop rather than a fixed office machine.
  • Router: vias keep the net clearance plus the diagonal grid margin.
  • The skill gains a section for a board in Altium Designer.

Explained removals (the preflight flags these)

All five functions moved behind the backend interface with the same behavior:

Removed Now
main.rs fusion_live FusionEda::live_snapshot, called through live_board
lib.rs fusion_hwnd FusionEda::main_window
lib.rs fusion_fit_board FusionEda::fit_board
lib.rs editor_for_board KicadEda::camera_window (same matching and caching)
lib.rs front Bridge::bring_front

tools/fusion-project.py loses 560 lines, which are the planner moved into silkscreen-plan.py. The binary concatenates the two, so the Fusion lane runs the same code.

Checks

  • Production cargo check --workspace --bins passes.
  • cargo test --workspace: every suite passes (125 + 24 + 30 + 14 + … ; the ignored tests are the live ones).
  • CLI against the published 0.1.98 binary: no command is missing, and altium is added.
  • Fusion planner regression: the plan on the crossbar board is identical to before.
  • Every hunk of the preflight review.diff was read, including all deletions.

Native acceptance

  • The Altium round trip (tests/altium_live.rs, ignored by default) passes on Altium Designer 26.11.1. It covers read, move, stale refusal, route plus via, item removal, pours and validate.
  • A full Altium rehearsal of the 12 V to 5 V TPS54202 buck ran on DESKTOP-BAR6S0F, from intake through finish. Export was checked on a separate project.
  • Not yet done: an unattended, context-free AI thread taking the whole Altium lane. That is the recording take that comes next.

Dependency

The Altium lane needs altium-bridge 0.2.47 or later. Its source is on the page (commit 2f3deab) and is waiting to be signed and released. KiCad and Fusion runs do not depend on it.

🤖 Generated with Claude Code

Diff Skip to comments

.gitignoreadded+1
@@ -0,0 +1 @@+target/
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.98"+version = "0.1.99" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.98"+version = "0.1.99" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.98"+version = "0.1.99" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.98"+version = "0.1.99" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+105−79
@@ -140,6 +140,8 @@ enum Cmd {     Pour { #[arg(long)] plan: Option<String> },     /// The Fusion side of a project: `library` (one project library with real 3D and part numbers), `reference` (placement and spec from a reference KiCad board, in the Fusion board's frame), `schematic` (the design started with the fab's rules, the schematic drawn live in groups), `export` (Gerbers, pick and place, BOM). `adom-aiflow fusion <step> --help` for each.     Fusion { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },+    /// The Altium side of a project: `library` (one .SchLib/.PcbLib from the parts' wiki pages, 3D bound, Mouser/DigiKey/LCSC numbers checked), `reference` (placement and spec from a reference KiCad board, in the Altium board's frame), `schematic` (the schematic drawn from the netlist in a project, compiled and checked pin by pin, then the board in the same project), `board` (the board alone, built from the netlist, every pin on its net), `reset`, `view3d`. `adom-aiflow altium <step> --help` for each.+    Altium { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec<String>, #[arg(long)] target: Option<String> },     /// The KiCad lane's export step: Gerbers, drill, pick and place (CPL) and BOM written from the board with the native kicad-cli (ADOM_AIFLOW_KICAD_CLI, else kicad-cli; adom-aiflow-kicad-cli-remote in a container), then re-read against the board (copper layer count, drill hits against vias and drilled pads, CPL and BOM references, sides and top rotations). Records the `export` proof only when every file matches. Fusion: `fusion export`.     Export { /// the board (default: the run's current board)     #[arg(long)] board: Option<String>, /// where the set goes (default: <run>/manufacturing)@@ -284,7 +286,7 @@ fn run_dir(cli: &Cli) -> PathBuf { fn load_run(cli: &Cli) -> Run {     let d = run_dir(cli);     if let Some(r) = Run::open(&d) {-        if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::fusion::set_run_board(b); }+        if let Some(b) = r.data["remoteBoard"].as_str() { aiflow_bridge::eda::set_run_board(b); }         // the run's desktop is where a remote KiCad runs, unless the human named another         if let (Some(t), Err(_)) = (r.data["target"].as_str(), std::env::var("KICAD_REMOTE_TARGET")) { std::env::set_var("KICAD_REMOTE_TARGET", t); }         return r;@@ -1892,25 +1894,15 @@ fn board_of(r: &Run) -> Board {     Board::load(&r.current_board()).unwrap_or_else(|e| err(&e, &[])) } -/// A Fusion run's live board, pulled fresh: `board-live.brd` (Fusion's own XML, fills included,-/// what adom-fields reads) and `board-live.kicad_pcb` (the same board for the offline checks).-/// None for a KiCad run, whose current board file is already the one to analyze.-fn fusion_live(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {-    let doc = aiflow_bridge::fusion::active()?;-    let br = bridge_of(r, cli);-    let mut reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));-    for _ in 0..3 {-        if reply["errorCode"] != "main_thread_busy" && !reply["error"].as_str().unwrap_or("").contains("main thread busy") { break; }-        std::thread::sleep(std::time::Duration::from_secs(5));-        reply = br.raw_call("fusion_board_source", &json!({"expectDocument": doc}));+/// The run's live board pulled fresh through its EDA backend: (the tool's native file, the plan board+/// rendered from it). None when the run's plan board is itself the board of record (KiCad).+fn live_board(r: &Run, cli: &Cli, dir: &std::path::Path) -> Option<(String, String)> {+    let eda = aiflow_bridge::eda::active();+    let t = |v: &str, a: &Value| bridge_of(r, cli).raw_call(v, a);+    match eda.live_snapshot(&t, dir)? {+        Ok(x) => Some(x),+        Err(e) => err(&e, &[]),     }-    let text = reply["source"].as_str().filter(|_| reply["success"].as_bool() == Some(true)).unwrap_or_else(|| err(&format!("could not read the live Fusion board {doc}: {}", reply["error"]), &["Activate the board in Fusion (fusion_show_2d_board), then retry. fusion_board_source needs bridge 1.11.17 or later.".into()]));-    let brd = dir.join("board-live.brd");-    let kicad = dir.join("board-live.kicad_pcb");-    std::fs::write(&brd, text).unwrap_or_else(|e| err(&e.to_string(), &[]));-    let converted = aiflow_board::eagle::to_kicad(text).unwrap_or_else(|e| err(&format!("live Fusion board: {e}"), &[]));-    std::fs::write(&kicad, converted).unwrap_or_else(|e| err(&e.to_string(), &[]));-    Some((brd.display().to_string(), kicad.display().to_string())) }  /// Why a field-screen neck happens, from the board the solve ran on: a via whose copper ring is the@@ -2150,6 +2142,21 @@ fn print_plan_notes(notes: &[String]) {     for n in notes { eprintln!("  {n}"); } } +/// A lane's Python as a file to run: `python3 -c <script>` passes it as ONE argument, and Linux caps one+/// argument at 128 KiB (MAX_ARG_STRLEN), so a lane past that never started ("Argument list too long",+/// measured 2026-10-16 on the 151 KB Fusion lane). Written once per content (the name carries its hash).+fn lane_script(lane: &str, script: &str) -> std::path::PathBuf {+    let dir = std::env::temp_dir().join("adom-aiflow-lanes");+    let _ = std::fs::create_dir_all(&dir);+    let path = dir.join(format!("{lane}-{}.py", &provenance::sha256_hex(script.as_bytes())[..16]));+    if !path.is_file() {+        let tmp = path.with_extension(format!("tmp{}", std::process::id()));+        std::fs::write(&tmp, script).unwrap_or_else(|e| err(&format!("could not write the {lane} lane script: {e}"), &[]));+        let _ = std::fs::rename(&tmp, &path);+    }+    path+}+ fn main() {     let cli = Cli::parse();     // A host stop hook must not open windows, start captures or append timing events.@@ -2165,6 +2172,15 @@ fn main() {         std::process::exit(if report["mayStop"] == true { 0 } else { 2 });     }     let dir = run_dir(&cli);+    // the KiCad DRC gate and kicad-cli steps run a desktop's native KiCad through Adom Bridge when this+    // container has none: the run's own desktop, unless the human named another (a fresh thread never+    // knew to set KICAD_REMOTE_TARGET, and the helper fell back to one office machine)+    if std::env::var("KICAD_REMOTE_TARGET").is_err() {+        if let Some(t) = std::fs::read_to_string(dir.join("run.json")).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok())+            .and_then(|v| v["target"].as_str().or(v["data"]["target"].as_str()).filter(|t| !t.is_empty()).map(String::from)) {+            std::env::set_var("KICAD_REMOTE_TARGET", t);+        }+    }     // All engines share the same default. Server/write children must never open tabs recursively.     if !matches!(cli.cmd, Cmd::Timeline { .. }) {         let ai_thread = cli.ai_thread.clone().or_else(|| Run::open(&dir).and_then(|r|r.data["aiThread"].as_str().map(str::to_owned))).unwrap_or_else(|| "adom-aiflow".into());@@ -2307,7 +2323,8 @@ fn main() {         }         Cmd::Export { board, out } => {             thread(&cli); let mut r = load_run(&cli);-            if sequence::lane(&dir, &r.data) == "fusion" { err("this run is on the Fusion lane", &["`adom-aiflow fusion export` writes and checks the set from the Fusion board and records the same proof.".into()]); }+            let lane = sequence::lane(&dir, &r.data);+            if lane != "kicad" { err(&format!("this run builds in {}: its export is the tool's own", aiflow_bridge::eda::active().name()), &[format!("`adom-aiflow {lane} export` writes and checks the set from the {lane} design (Gerbers, drill, CPL, BOM).")]); }             let board_path = board.clone().unwrap_or_else(|| r.current_board());             let out_dir = out.clone().unwrap_or_else(|| dir.join("manufacturing"));             let kicad = std::env::var("ADOM_AIFLOW_KICAD_CLI").unwrap_or_else(|_| "kicad-cli".into());@@ -2370,19 +2387,15 @@ fn main() {             std::fs::create_dir_all(&dir).unwrap();             let copy = dir.join("board-0.kicad_pcb");             let src_text = std::fs::read_to_string(board).unwrap_or_default();-            if aiflow_board::eagle::is_eagle(&src_text) {-                // the Fusion lane: the plan board is the Fusion export rendered as .kicad_pcb; the live-                // board is the Fusion document named by --remote-board fusion:<doc>-                if !remote_board.as_deref().map(|b| b.starts_with(aiflow_bridge::fusion::PREFIX)).unwrap_or(false) {-                    err("a Fusion board needs --remote-board fusion:<exact board document name> (and --target <box>)", &["Export it with fusion_export_eagle_source {outputPath:\"...brd\"} on the Fusion bridge, pull it here, and name the open board document: --remote-board fusion:Buck-brd.".into()]);-                }-                let text = aiflow_board::eagle::to_kicad(&src_text).unwrap_or_else(|e| err(&e, &[]));+            // the plan board: rendered by the backend --remote-board names (a Fusion export, an Altium+            // read), or the KiCad board copied as it is (eda.rs import_board)+            if let Some(text) = aiflow_bridge::eda::import_board(remote_board.as_deref(), &src_text).unwrap_or_else(|e| err(&e, &[])) {                 std::fs::write(&copy, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));                 // the fab's KiCad rules beside the .brd (`fusion schematic` puts them there) go with the                 // plan board, so the gate checks the fab, not KiCad's defaults                 let dru = std::path::Path::new(board).with_extension("kicad_dru");                 if dru.is_file() { let _ = std::fs::copy(&dru, copy.with_extension("kicad_dru")); }-                aiflow_bridge::fusion::set_run_board(remote_board.as_deref().unwrap_or(""));+                aiflow_bridge::eda::set_run_board(remote_board.as_deref().unwrap_or(""));             } else {                 std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));                 aiflow_copper::copy_project_rules(board, &copy.display().to_string()).unwrap_or_else(|e|err(&e,&[]));@@ -2808,7 +2821,7 @@ fn main() {             }             // a Fusion run routes around what is in Fusion now (copper already landed, a net taken             // up for a refine), not the placement snapshot: plan on a fresh copy of the live board-            if let Some((_, k)) = fusion_live(&r, &cli, &dir) {+            if let Some((_, k)) = live_board(&r, &cli, &dir) {                 let n = r.data["boards"].as_array().map(|a| a.len()).unwrap_or(1);                 let dest = dir.join(format!("board-{n}.kicad_pcb"));                 std::fs::copy(&k, &dest).unwrap_or_else(|e| err(&e.to_string(), &[]));@@ -2889,7 +2902,7 @@ fn main() {             let mut r = load_run(&cli);             let spec = spec_of(&r);             // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)-            let b = fusion_live(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r));+            let b = live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| 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);             // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of@@ -2911,11 +2924,21 @@ fn main() {             // the desktop and the thread             let run = Run::open(&dir);             let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();-            let status = std::process::Command::new("python3").arg("-c").arg(include_str!("../../../tools/fusion-project.py")).args(args)+            let status = std::process::Command::new("python3").arg(lane_script("fusion", &format!("{}\n{}", include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/fusion-project.py")))).args(args)                 .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())                 .status().unwrap_or_else(|e| err(&format!("fusion steps need python3: {e}"), &[]));             std::process::exit(status.code().unwrap_or(1));         }+        Cmd::Altium { args, target } => {+            // the Altium steps: the shared project half (tools/project-common.py) with the Altium half after it+            let run = Run::open(&dir);+            let target = target.clone().or_else(|| run.as_ref().and_then(|r| r.data["target"].as_str().map(str::to_string))).or_else(|| std::env::var("AIFLOW_TARGET").ok()).unwrap_or_default();+            let script = format!("{}\n{}\n{}", include_str!("../../../tools/project-common.py"), include_str!("../../../tools/silkscreen-plan.py"), include_str!("../../../tools/altium-project.py"));+            let status = std::process::Command::new("python3").arg(lane_script("altium", &script)).args(args)+                .env("AIFLOW_RUN", &dir).env("AIFLOW_TARGET", &target).env("AIFLOW_THREAD", thread(&cli)).env("AIFLOW_EXE", std::env::current_exe().map(|p| p.display().to_string()).unwrap_or_default())+                .status().unwrap_or_else(|e| err(&format!("altium steps need python3: {e}"), &[]));+            std::process::exit(status.code().unwrap_or(1));+        }         Cmd::Unroute { net } => {             thread(&cli);             let mut r = load_run(&cli);@@ -3241,14 +3264,14 @@ fn main() {             }             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()]);+            ok(&format!("copper kept per layer ({}'s fills): {}; coverage {}", aiflow_bridge::eda::active().name(), zs["filledAreaByLayerMm2"], zs["filledCoverageByLayerPercent"]), &["Next: adom-aiflow analyze current, analyze thermal.".into()]);         }         Cmd::Analyze { what, plan, fields } => {             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()]));             // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot-            let live = fusion_live(&r, &cli, &dir);+            let live = live_board(&r, &cli, &dir);             let board_now = match &live { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };             let solved_board = live.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());             let plan_v = plan_with_board_copper(&read_json(&planp), &board_now);@@ -3349,7 +3372,7 @@ fn main() {                 missing.push("measure (successful native copper readback)".into());             }             // a Fusion run is judged on the board as it is in Fusion now-            let live_board = fusion_live(&r, &cli, &dir);+            let live_board = live_board(&r, &cli, &dir);             let solved_board = live_board.as_ref().map(|l| l.0.clone()).unwrap_or_else(|| r.current_board());             let board_now = || match &live_board { Some((_, k)) => Board::load(k).unwrap_or_else(|e| err(&e, &[])), None => board_of(&r) };             for k in ["analyze-current", "analyze-thermal"] {@@ -3395,7 +3418,8 @@ fn main() {             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(""));-                if aiflow_bridge::fusion::doc_of(r.data["remoteBoard"].as_str().unwrap_or("")).is_none() {+                let eda = aiflow_bridge::eda::active();+                if eda.caps().file_backed_board {                     let st = br.status();                     let kv = st["kicadVersion"].as_str().or(st["data"]["kicadVersion"].as_str()).unwrap_or("").to_string();                     let text = std::fs::read_to_string(r.current_board()).unwrap_or_default();@@ -3410,7 +3434,11 @@ fn main() {                 // KiCad's full DRC on the live board, warnings included: the silkscreen findings the fab makes real                 // (ink over a mask opening is clipped off, ink on ink, ink past the edge, text under the fab minimum)                 // block whatever severity the project gives them; the routing validate above counts errors only-                let drc = br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"}));+                if !eda.caps().full_drc {+                    eprintln!("finish: {} has no full DRC through its bridge; the silkscreen findings below are not checked (recorded as a gap)", eda.name());+                    r.log("capability-gap", json!({"step": "finish", "missing": "full_drc", "eda": eda.id()}));+                }+                let drc = if eda.caps().full_drc { br.call("kicad_run_drc", &json!({"filePath": r.data["remoteBoard"].as_str().unwrap_or(""), "refillZones": true, "reason": "finish: full DRC, warnings included"})) } else { Value::Null };                 let found: Vec<Value> = drc["summary"]["first"].as_array().or(drc["violations"].as_array()).cloned().unwrap_or_default();                 let silk: Vec<String> = found.iter().filter(|v| v["type"].as_str().map(|t| aiflow_copper::SILK_BLOCKING.contains(&t)).unwrap_or(false))                     .map(|v| format!("{} ({})", v["type"].as_str().unwrap_or(""), v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).collect::<Vec<_>>().join(" / ")).unwrap_or_default())).collect();@@ -3445,10 +3473,8 @@ fn main() {                 for (a, h, t) in thermalvias::short(&spec_f, &board_now(), &acked) { missing.push(format!("heatsink vias: {a} has {h} of {t} (thermal-vias plan, land)")); }             }             {-                let edges = match r.data["remoteBoard"].as_str().and_then(aiflow_bridge::fusion::doc_of) {-                    Some(doc) => { let br = bridge_of(&r, &cli); let src = br.call("fusion_board_source", &json!({"expectDocument": doc})); outline::eagle_edges(src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("")) }-                    None => outline::kicad_edges(&std::fs::read_to_string(r.current_board()).unwrap_or_default()),-                };+                // the outline as the board is now: the live snapshot when the tool has one, else the plan board+                let edges = outline::kicad_edges(&std::fs::read_to_string(live_board.as_ref().map(|l| l.1.clone()).unwrap_or_else(|| r.current_board())).unwrap_or_default());                 let sharp = outline::sharp_corners(&edges);                 if !sharp.is_empty() && r.data["sequence"].is_array() { missing.push(format!("outline: {} sharp corner(s) (outline round)", sharp.len())); }             }@@ -3827,7 +3853,7 @@ fn main() {             }             let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();             // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it-            let b = if action == "plan" { fusion_live(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));+            let b = if action == "plan" { live_board(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));             let sw = switchnode::nets(&spec, &b);             if action == "plan" {                 let (parts, plan) = thermalvias::plan_with_acknowledgements(&spec, &b, &sw, &acked);@@ -3852,7 +3878,7 @@ fn main() {                     if !st.success() { err("landing the heatsink vias stopped (see above)", &["Fix what it says, then `thermal-vias plan` again (the plan counts what landed) and land.".into()]); }                 }                 let r2 = load_run(&cli);-                let live_board = fusion_live(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));+                let live_board = live_board(&r2, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r2));                 let short = thermalvias::short(&spec, &live_board, &acked);                 let mut r = r2;                 r.outcome("thermal-vias", json!({"landed": n, "short": short.iter().map(|(a, h, t)| json!({"ref": a, "have": h, "target": t})).collect::<Vec<_>>(), "acked": acked}));@@ -3865,14 +3891,10 @@ fn main() {             thread(&cli);             let mut r = load_run(&cli);             let rad = radius.or_else(|| spec_soft(&r)["outline"]["cornerRadius"].as_f64()).unwrap_or(1.0);-            let fusion_doc = r.data["remoteBoard"].as_str().and_then(aiflow_bridge::fusion::doc_of);+            let eda = aiflow_bridge::eda::active();             let read = |r: &Run| -> Vec<outline::Edge> {-                if let Some(doc) = &fusion_doc {-                    let br = bridge_of(r, &cli);-                    let src = br.call("fusion_board_source", &json!({"expectDocument": doc}));-                    let text = src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("").to_string();-                    outline::eagle_edges(&text)-                } else { outline::kicad_edges(&std::fs::read_to_string(r.current_board()).unwrap_or_default()) }+                let board = live_board(r, &cli, &dir).map(|l| l.1).unwrap_or_else(|| r.current_board());+                outline::kicad_edges(&std::fs::read_to_string(board).unwrap_or_default())             };             let before = outline::sharp_corners(&read(&r));             if action == "check" || before.is_empty() {@@ -3880,18 +3902,12 @@ fn main() {                 if before.is_empty() { ok("the board outline has no sharp corners", &[]); return; }                 err(&format!("{} sharp outline corner(s): {}", before.len(), before.iter().map(|p| format!("({:.2}, {:.2})", p.0, p.1)).collect::<Vec<_>>().join(", ")), &["`outline round` turns each into an arc (spec outline.cornerRadius, default 1.0 mm).".into()]);             }-            let Some(doc) = fusion_doc.clone() else {-                err("outline round lands live in Fusion only for now; this is a KiCad board", &["Round the Edge.Cuts corners in KiCad (Edit > Fillet, the corner radius), save, then `outline check` and `outline round` again to record it. File the KiCad landing on adom/adom-aiflow.".into()]);-            };             let br = bridge_of(&r, &cli);-            let _ = br.call("fusion_show_2d_board", &json!({"documentName": doc}));-            let mut done = 0;-            for p in &before {-                let cmd = format!("GRID MM; MITER {rad} ({:.4} {:.4});", p.0, p.1);-                let rep = br.call("fusion_electron_run", &json!({"command": cmd}));-                if Bridge::ok(&rep) { done += 1; }-                std::thread::sleep(std::time::Duration::from_millis(500));+            let rep = eda.round_corners(&|v: &str, a: &Value| br.raw_call(v, a), &before, rad);+            if rep["errorCode"].as_str().map(|c| c.starts_with("unsupported_in_")).unwrap_or(false) {+                err(&format!("outline round does not land live in {} yet", eda.name()), &["Round the board-outline corners in the tool (the corner radius), save, then `outline check` and `outline round` again to record it.".into()]);             }+            let done = rep["rounded"].as_u64().unwrap_or(0);             let after = outline::sharp_corners(&read(&r));             r.outcome("outline", json!({"radius": rad, "rounded": done, "sharp": after.len()}));             r.save().unwrap();@@ -4012,17 +4028,25 @@ fn main() {                 }                 std::fs::write(&dru, &marked).unwrap_or_else(|e| err(&e.to_string(), &[]));                 placed.push(dru.display().to_string());+                // a plan board rendered from another tool has no KiCad project: give it one carrying the same+                // fab floors, so the gate's KiCad DRC judges by the fab's rules, not KiCad's defaults+                let pro = Path::new(&cb).with_extension("kicad_pro");+                if !aiflow_bridge::eda::active().caps().file_backed_board && !pro.is_file() {+                    let name = pro.file_name().and_then(|n| n.to_str()).unwrap_or("board.kicad_pro").to_string();+                    std::fs::write(&pro, aiflow_bridge::eda::plan_project_json(&marked, &name)).unwrap_or_else(|e| err(&e.to_string(), &[]));+                    placed.push(pro.display().to_string());+                }             }             // Fusion: the bridge loads the same pack's Fusion rules into the open design             let mut fusion_note = String::new();             let mut fusion_loaded = Value::Null;-            if sequence::lane(&dir, &r.data) == "fusion" {-                if let Some(target) = r.data["target"].as_str().map(str::to_string) {-                    let br = Bridge { ai_thread: thread(&cli), target };-                    let rep = br.call("fusion_set_design_rules", &json!({"fab": slug, "layers": layers}));+            let eda = aiflow_bridge::eda::active();+            if let Some(target) = r.data["target"].as_str().map(str::to_string) {+                let br = Bridge { ai_thread: thread(&cli), target };+                if let Some(rep) = eda.load_design_rules(&|v: &str, a: &Value| br.raw_call(v, a), &slug, layers, &text) {                     let okk = Bridge::ok(&rep);                     fusion_loaded = json!(okk);-                    fusion_note = if okk { format!("; Fusion: {shown} {layers}-layer rules loaded into the open design") } else { format!("; Fusion did not load them ({}): open the design and run `rules use` again", rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };+                    fusion_note = if okk { format!("; {}: {shown} {layers}-layer rules loaded into the open design", eda.name()) } else { format!("; {} did not load them ({}): open the design and run `rules use` again", eda.name(), rep["errorCode"].as_str().or(rep["error"].as_str()).unwrap_or("?")) };                 }             }             // the choice: in the spec when there is one, and in the run (an intake run has no spec yet)@@ -4035,7 +4059,7 @@ fn main() {                 }             }             let mins: Vec<String> = text.lines().filter(|l| l.starts_with("# Min") || l.starts_with("# Copper to") || l.starts_with("# Annular")).map(|l| l.trim_start_matches("# ").to_string()).take(6).collect();-            r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "fusion": fusion_loaded}));+            r.outcome("rules", json!({"profile": shown, "slug": slug, "layers": layers, "kicad": placed, "native": {"eda": eda.id(), "loaded": fusion_loaded}, "fusion": if eda.id() == "fusion" { fusion_loaded.clone() } else { Value::Null }}));             r.save().unwrap();             ok(&format!("design rules: {shown}, {layers} layers{fusion_note}. KiCad rules at {}{replaced_note}", placed.join(", ")), &[                 format!("Tell the human in plain words which rules are now enforced: {}.", if mins.is_empty() { "see the file".into() } else { mins.join("; ") }),@@ -4146,7 +4170,7 @@ fn main() {             let br = bridge_of(&r, &cli);             let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();             let _ = t;-            if !rb.starts_with(aiflow_bridge::fusion::PREFIX) {+            if aiflow_bridge::eda::active().caps().file_backed_board {                 let proof=checked_remote_binding(&r,&br,&rb,&dir).unwrap_or_else(|e|err(&e,&["Use bind-remote-board --remote-board <exact native path> after adopting a different board. Old desktop model results do not qualify the current snapshot.".into()]));                 r.data["remoteBinding"]=proof;             }@@ -4444,7 +4468,7 @@ fn main() {             let prev = r.data["currentStep"].as_str().map(str::to_string);             // a Fusion project simulates the drawn schematic, in front of the human, before the board: placement             // waits until a simulation visit has started after the last schematic visit-            if name == "placement" && !*back && dir.join("fusion").join("project.json").is_file() {+            if name == "placement" && !*back && aiflow_bridge::eda::project_backend(&dir).is_some() {                 let last = |s: &str| r.data["steps"][s]["visits"].as_array().and_then(|a| a.iter().filter_map(|v| v["at"].as_str()).last()).map(str::to_string);                 if let Some(sch) = last("schematic") {                     if last("simulation").map(|sim| sim < sch).unwrap_or(true) {@@ -4519,19 +4543,21 @@ fn main() {                 // the fields step films the app's window through `tour fields`: no editor clip for it                 // a Fusion run films Fusion's own window, the board fitted to it first (a remembered                 // hwnd can be a KiCad window from before the board, and Fusion keeps whatever zoom it had)-                let fusion_run = aiflow_bridge::fusion::active().is_some();-                // a Fusion project before its board exists (library, schematic): film Fusion, never a KiCad window-                let fusion_project = !fusion_run && dir.join("fusion").join("project.json").is_file();-                // the window the step works in comes to the front: Hydrogen for the human-facing steps (the-                // chat, the parts pages, the SPICE dashboard, the fields app), Fusion for the design itself-                if fusion_run || fusion_project {-                    const HYDROGEN_STEPS: [&str; 10] = ["intake", "design", "simulation", "test-plan", "sourcing", "library", "current", "thermal", "fields", "export"];-                    br.front(if HYDROGEN_STEPS.contains(&name.as_str()) { "hydrogen" } else { "fusion" });+                // the window the step works in, from the run's EDA backend: KiCad's PCB editor for this board,+                // a one-window tool (Fusion, Altium) with the board fitted to it, and Hydrogen in front for the+                // human-facing steps (the chat, the parts pages, the SPICE dashboard, the fields app). A project+                // before its board exists (library, schematic) films its tool, never a KiCad window.+                let eda = aiflow_bridge::eda::active();+                let pre_board = aiflow_bridge::eda::project_backend(&dir).filter(|_| eda.id() == "kicad")+                    .map(|b| aiflow_bridge::eda::for_board(&aiflow_bridge::eda::BoardRef::parse(&format!("{b}:"))));+                let cam = pre_board.unwrap_or(eda);+                let tx = |v: &str, a: &Value| br.raw_call(v, a);+                if let Some(h) = cam.hydrogen_for_step(name) {+                    let w = if h { br.hydrogen_hwnd() } else { cam.main_window(&tx) };+                    br.bring_front(w, if h { "Hydrogen" } else { cam.name() });                 }-                let hwnd = if name == "fields" { None }-                    else if fusion_project { br.fusion_hwnd() }-                    else if fusion_run { let h = br.pcb_editor_hwnd(); br.fusion_fit_board(); h }-                    else { let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64()))); br.editor_for_board(r.data["remoteBoard"].as_str().unwrap_or(""), cached) };+                let cached = r.data["captures"].as_array().and_then(|c| c.iter().rev().find_map(|e| e.get("hwnd").and_then(|h| h.as_i64())));+                let hwnd = if name == "fields" { None } else { cam.camera_window(&tx, name, cached) };                 let disk = disk_check(&dir, &br, name);                 if let Err(e) = &disk {                     r.log("clip-refused", json!({"step": name, "why": e}));
crates/adom-aiflow/src/outline.rs+14
@@ -47,6 +47,14 @@ pub fn eagle_edges(src: &str) -> Vec<Edge> { /// The outline of a KiCad board: Edge.Cuts gr_line / gr_arc / gr_rect (a rect is four sharp corners). pub fn kicad_edges(text: &str) -> Vec<Edge> {     let mut out = Vec::new();+    // a gr_poly on Edge.Cuts is a closed chain of straight edges+    for blk in text.split("(gr_poly").skip(1) {+        let end = blk.find("\n  (").unwrap_or(blk.len());+        let blk = &blk[..end];+        if !blk.contains("Edge.Cuts") { continue; }+        let pts: Vec<(f64, f64)> = blk.split("(xy ").skip(1).filter_map(|r| { let v: Vec<f64> = r.split(')').next()?.split_whitespace().filter_map(|x| x.parse().ok()).collect(); if v.len() >= 2 { Some((v[0], v[1])) } else { None } }).collect();+        for k in 0..pts.len() { if pts.len() > 2 { out.push(Edge { a: pts[k], b: pts[(k + 1) % pts.len()], arc: false }); } }+    }     for (tag, arc) in [("(gr_line", false), ("(gr_arc", true), ("(gr_rect", false)] {         for blk in text.split(tag).skip(1) {             let end = blk.find("\n  (").unwrap_or(blk.len().min(600));@@ -68,6 +76,12 @@ pub fn kicad_edges(text: &str) -> Vec<Edge> { mod tests {     use super::*;     #[test]+    fn a_gr_poly_outline_reads_as_straight_edges() {+        let t = "  (gr_poly (pts (xy 0 0) (xy 28 0) (xy 28 20) (xy 0 20)) (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n  (segment)";+        assert_eq!(kicad_edges(t).len(), 4);+        assert_eq!(sharp_corners(&kicad_edges(t)).len(), 4);+    }+    #[test]     fn rectangle_has_four_corners_and_rounded_has_none() {         let src = r#"<wire x1="0" y1="0" x2="30" y2="0" width="0" layer="20"/><wire x1="30" y1="0" x2="30" y2="20" width="0" layer="20"/><wire x1="30" y1="20" x2="0" y2="20" width="0" layer="20"/><wire x1="0" y1="20" x2="0" y2="0" width="0" layer="20"/>"#;         assert_eq!(sharp_corners(&eagle_edges(src)).len(), 4);
crates/adom-aiflow/src/sequence.rs+32−6
@@ -33,13 +33,26 @@ pub struct StepState {  /// Which lane a run is on: a Fusion project or run, else KiCad. pub fn lane(dir: &Path, data: &Value) -> &'static str {-    // the human's own words decide it before any board exists: a brief or requirements that name Fusion-    let said_fusion = ["brief", "requirements"].iter().any(|k| {+    // the run's board names its backend (eda.rs); before a board exists, the backend's project+    // directory does, then the run's explicit `eda`, then the human's own words in the brief or+    // requirements, then the engine name+    if let Some(b) = data["remoteBoard"].as_str().filter(|b| !b.is_empty()) {+        match aiflow_bridge::eda::BoardRef::parse(b) {+            aiflow_bridge::eda::BoardRef::Fusion(_) => return "fusion",+            aiflow_bridge::eda::BoardRef::Altium(_) => return "altium",+            aiflow_bridge::eda::BoardRef::Kicad(_) => {}+        }+    }+    if let Some(p) = aiflow_bridge::eda::project_backend(dir) { return p; }+    match data["eda"].as_str() { Some("fusion") => return "fusion", Some("altium") => return "altium", Some("kicad") => return "kicad", _ => {} }+    let said = |word: &str| ["brief", "requirements"].iter().any(|k| {         data["intake"][k].as_str().or(data[*k].as_str()).and_then(|p| std::fs::read_to_string(p).ok())-            .map(|t| t.to_ascii_lowercase().contains("fusion")).unwrap_or(false)+            .map(|t| t.to_ascii_lowercase().contains(word)).unwrap_or(false)     });-    if said_fusion || dir.join("fusion").join("project.json").is_file() || data["engine"].as_str().map(|e| e.to_ascii_lowercase().contains("fusion")).unwrap_or(false)-        || data["remoteBoard"].as_str().map(|b| b.starts_with("fusion:")).unwrap_or(false) { "fusion" } else { "kicad" }+    let engine = data["engine"].as_str().unwrap_or("").to_ascii_lowercase();+    if said("altium") || engine.contains("altium") { return "altium"; }+    if said("fusion") || engine.contains("fusion") { return "fusion"; }+    "kicad" }  /// The run's name for the timeline: the requirements' title, else the brief's first line.@@ -222,7 +235,7 @@ pub fn timeline(dir: &Path, data: &Value, flow: &Value, seq: &[String], minutes:     let idx = st.iter().position(|s| s.name == current).map(|i| i + 1).unwrap_or(st.iter().filter(|s| s.status == Status::Done).count() + if current.is_empty() && !st.is_empty() { 1 } else { 0 }).min(st.len());     json!({         "title": title(data),-        "engine": if lane(dir, data) == "fusion" { "Fusion" } else { "KiCad" },+        "engine": match lane(dir, data) { "fusion" => "Fusion", "altium" => "Altium", _ => "KiCad" },         "finish": flow["finishLine"][lane(dir, data)].as_str().unwrap_or("Gerbers, BOM and CPL exported"),         "updated": aiflow_run::now(),         "current": if current.is_empty() { Value::Null } else { json!(current) },@@ -245,6 +258,19 @@ pub fn write_timeline(dir: &Path, tl: &Value, page: &str) {     if std::fs::read_to_string(&fav).map(|t| t != FAVICON).unwrap_or(true) { let _ = std::fs::write(&fav, FAVICON); } } +#[cfg(test)]+mod lane_tests {+    use super::*;+    #[test]+    fn the_board_reference_names_the_lane() {+        let d = std::env::temp_dir();+        assert_eq!(lane(&d, &serde_json::json!({"remoteBoard": "altium:C:/b/buck.PcbDoc"})), "altium");+        assert_eq!(lane(&d, &serde_json::json!({"remoteBoard": "fusion:Buck-brd"})), "fusion");+        assert_eq!(lane(&d, &serde_json::json!({"remoteBoard": "C:/b/buck.kicad_pcb"})), "kicad");+        assert_eq!(lane(&d, &serde_json::json!({"eda": "altium"})), "altium");+    }+}+ #[cfg(test)] mod tests {     use super::*;
crates/aiflow-board/src/altium.rsadded+246
@@ -0,0 +1,246 @@+//! The plan board for an Altium board: the Altium bridge's read (`altium_routing_state` plus+//! `altium_pour_state`, saved as `{"source":"altium-bridge","state":...,"pours":...}`) written as the+//! `.kicad_pcb` text every stage here reads. Altium stays the board of record (see the aiflow-bridge+//! Altium backend); this is only the plan copy.+//!+//! The mapping: y is negated (Altium is y up); a component at (x, y, θ) is a footprint at (x, -y, θ)+//! with its pads local to it and their angles absolute; a bottom component's pads are written in board+//! position on B.Cu (footprint angle 0); only copper layers come along; the outline's arcs stay arcs;+//! a pour's fill is Altium's own, each island with its holes slit into it.+use serde_json::Value;+use std::fmt::Write;++fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn is_copper(layer: &str) -> bool { layer == "F.Cu" || layer == "B.Cu" || (layer.starts_with("In") && layer.ends_with(".Cu")) }++fn q(s: &str) -> String { format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) }+fn n(v: f64) -> String { let r = (v * 1e6).round() / 1e6; if r == 0.0 { "0".into() } else { format!("{r}") } }++/// Join each hole into its outline with a zero-width slit, as KiCad's filled polygons are written+/// (one outline per island, no separate holes).+fn fracture(outline: &[(f64, f64)], holes: &[Vec<(f64, f64)>]) -> Vec<(f64, f64)> {+    let mut poly = outline.to_vec();+    for h in holes.iter().filter(|h| h.len() >= 3) {+        let (mut bi, mut bj, mut bd) = (0, 0, f64::MAX);+        for (i, p) in poly.iter().enumerate() {+            for (j, q) in h.iter().enumerate() {+                let d = (p.0 - q.0).hypot(p.1 - q.1);+                if d < bd { bd = d; bi = i; bj = j; }+            }+        }+        let mut out: Vec<(f64, f64)> = poly[..=bi].to_vec();+        out.extend(h[bj..].iter().cloned());+        out.extend(h[..=bj].iter().cloned());+        out.extend(poly[bi..].iter().cloned());+        poly = out;+    }+    poly+}++/// Is this text an Altium bridge board read (`board-live.altium.json`)?+pub fn is_altium(text: &str) -> bool { text.trim_start().starts_with('{') && text.contains("\"source\":\"altium-bridge\"") }++/// The plan board for an Altium board: the bridge's read (`{"source":"altium-bridge", "state":+/// altium_routing_state, "pours": altium_pour_state}`) written as `.kicad_pcb` text, y negated.+pub fn render_plan_board(src: &str) -> Result<String, String> {+    let d: Value = serde_json::from_str(src).map_err(|e| format!("not an Altium board read (JSON): {e}"))?;+    if d["source"].as_str() != Some("altium-bridge") { return Err("not an Altium board read: save altium_routing_state and altium_pour_state as {\"source\":\"altium-bridge\",\"state\":...,\"pours\":...}".into()); }+    let st = &d["state"];+    if st["success"].as_bool() != Some(true) { return Err(format!("the Altium read failed: {}", st["error"])); }+    let yy = |v: f64| -v;+    let arr = |k: &str| st[k].as_array().cloned().unwrap_or_default();++    // nets: numbered from 1 in name order+    let mut names: Vec<String> = st["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();+    for k in ["tracks", "vias", "polygons"] { for o in arr(k) { if let Some(s) = o["net"].as_str().filter(|s| !s.is_empty()) { if !names.iter().any(|x| x == s) { names.push(s.to_string()); } } } }+    names.sort();+    let idx = |s: &str| names.iter().position(|x| x == s).map(|i| i + 1).unwrap_or(0);+    let net_pad = |s: &str| if s.is_empty() { String::new() } else { format!("(net {} {})", idx(s), q(s)) };+    let net_id = |s: &str| format!("(net {})", idx(s));++    let mut inner: Vec<String> = Vec::new();+    for k in ["tracks", "arcs"] { for o in arr(k) { if let Some(l) = o["layer"].as_str().filter(|l| l.starts_with("In")) { if !inner.contains(&l.to_string()) { inner.push(l.to_string()); } } } }+    inner.sort();++    let mut o = String::new();+    o.push_str("(kicad_pcb (version 20241229) (generator \"adom-aiflow-altium\") (generator_version \"1\")\n  (general (thickness 1.6))\n  (paper \"A4\")\n  (layers (0 \"F.Cu\" signal)");+    for (k, l) in inner.iter().enumerate() { let _ = write!(o, " ({} {} signal)", 4 + 2 * k, q(l)); }+    o.push_str(" (2 \"B.Cu\" signal) (9 \"F.Adhes\" user) (11 \"B.Adhes\" user) (13 \"F.Paste\" user) (15 \"B.Paste\" user) (5 \"F.SilkS\" user) (7 \"B.SilkS\" user) (1 \"F.Mask\" user) (3 \"B.Mask\" user) (25 \"Edge.Cuts\" user) (31 \"F.CrtYd\" user) (29 \"B.CrtYd\" user) (35 \"F.Fab\" user) (33 \"B.Fab\" user))\n");+    o.push_str("  (setup (pad_to_mask_clearance 0))\n  (net 0 \"\")\n");+    for (k, s) in names.iter().enumerate() { let _ = writeln!(o, "  (net {} {})", k + 1, q(s)); }++    // footprints: each component with its pads; pad positions are local to the footprint (KiCad+    // frame), pad angles absolute. A bottom component is written with its pads already in board+    // position (footprint angle 0) on B.Cu, as the EAGLE renderer does.+    let pads = arr("pads");+    // the silkscreen as Altium has it (altium_silk_state): each part's designator and comment, and the free+    // overlay texts and lines; a board read without it renders the references hidden, as before+    let silk = &d["silk"];+    let silk_of = |r: &str| silk["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str() == Some(r)).cloned());+    for c in arr("components") {+        let r = c["ref"].as_str().unwrap_or("?");+        let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));+        let bottom = c["side"].as_str() == Some("bottom");+        let (fx, fy, fa, side) = (cx, yy(cy), if bottom { 0.0 } else { rot }, if bottom { "B.Cu" } else { "F.Cu" });+        // a shown text at its box centre, in the footprint's own frame (KiCad keeps it there, angle absolute)+        let local = |t: &Value| -> (f64, f64, f64, f64) {+            let b = &t["box"];+            let (bx, by) = ((f(&b[0]) + f(&b[2])) / 2.0, (f(&b[1]) + f(&b[3])) / 2.0);+            let (dx, dy) = (bx - cx, by - cy);+            let (lx, ly) = if bottom { (dx, -dy) } else { let a = rot.to_radians(); let (ux, uy) = (dx * a.cos() + dy * a.sin(), -dx * a.sin() + dy * a.cos()); (ux, -uy) };+            (lx, ly, f(&t["rotation"]), f(&t["size"]).max(0.3))+        };+        let silk_layer = if bottom { "B.SilkS" } else { "F.SilkS" };+        let sc = silk_of(r);+        let prop = |key: &str, text: &str, t: Option<&Value>, fab: &str| -> String {+            match t.filter(|t| t["shown"].as_bool() == Some(true)) {+                Some(t) => { let (lx, ly, a, s) = local(t); format!("(property {} {} (at {} {} {}) (layer {}) (effects (font (size {} {}) (thickness {}))))", q(key), q(text), n(lx), n(ly), n(a), q(silk_layer), n(s), n(s), n(s / 7.0)) }+                None => format!("(property {} {} (at 0 0 0) (layer {}) (hide yes))", q(key), q(text), q(fab)),+            }+        };+        let _ = writeln!(o, "  (footprint {} (layer {side}) (at {} {} {})\n    {}\n    {}\n    (attr smd)",+            q(&format!("Altium:{}", c["pattern"].as_str().unwrap_or(""))), n(fx), n(fy), n(fa),+            prop("Reference", r, sc.as_ref().map(|s| &s["name"]), "F.SilkS"),+            prop("Value", sc.as_ref().and_then(|s| s["comment"]["text"].as_str()).unwrap_or(""), sc.as_ref().map(|s| &s["comment"]), "F.Fab"));+        for p in pads.iter().filter(|p| p["ref"].as_str() == Some(r)) {+            let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy);+            let (lx, ly) = if bottom { (dx, -dy) } else {+                let a = rot.to_radians();+                let (ux, uy) = (dx * a.cos() + dy * a.sin(), -dx * a.sin() + dy * a.cos());+                (ux, -uy)+            };+            let ang = if bottom { -f(&p["rotation"]) } else { f(&p["rotation"]) };+            let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));+            let drill = f(&p["drill"]);+            let layer = p["layer"].as_str().unwrap_or("F.Cu");+            let pn = net_pad(p["net"].as_str().unwrap_or(""));+            let name = p["name"].as_str().unwrap_or("");+            let (shape, extra) = match p["shape"].as_str().unwrap_or("rect") {+                "round" | "circle" if (sx - sy).abs() < 1e-6 => ("circle", String::new()),+                "round" | "circle" => ("oval", String::new()),+                "roundrect" => ("roundrect", " (roundrect_rratio 0.25)".to_string()),+                _ => ("rect", String::new()),+            };+            if drill > 0.0 {+                let _ = writeln!(o, "    (pad {} thru_hole {shape} (at {} {} {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\"){extra} {pn})", q(name), n(lx), n(ly), n(ang), n(sx), n(sy), n(drill));+            } else {+                let l = if layer == "B.Cu" { "B" } else { "F" };+                let _ = writeln!(o, "    (pad {} smd {shape} (at {} {} {}) (size {} {}) (layers \"{l}.Cu\" \"{l}.Paste\" \"{l}.Mask\"){extra} {pn})", q(name), n(lx), n(ly), n(ang), n(sx), n(sy));+            }+        }+        o.push_str("  )\n");+    }++    // free pads (no component: mounting holes, fiducials): one footprint each, so their copper and+    // holes are obstacles like any pad+    for (k, p) in pads.iter().filter(|p| p["ref"].as_str().unwrap_or("").is_empty()).enumerate() {+        let (sx, sy, drill) = (f(&p["sizeX"]), f(&p["sizeY"]), f(&p["drill"]));+        let pn = net_pad(p["net"].as_str().unwrap_or(""));+        let shape = if (sx - sy).abs() < 1e-6 && matches!(p["shape"].as_str(), Some("round") | Some("circle")) { "circle" } else { "rect" };+        let pad = if drill > 0.0 { format!("(pad {} thru_hole {shape} (at 0 0 {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\") {pn})", q(p["name"].as_str().unwrap_or("")), n(f(&p["rotation"])), n(sx), n(sy), n(drill)) }+                  else { format!("(pad {} smd {shape} (at 0 0 {}) (size {} {}) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\") {pn})", q(p["name"].as_str().unwrap_or("")), n(f(&p["rotation"])), n(sx), n(sy)) };+        let _ = writeln!(o, "  (footprint \"Altium:FreePad\" (layer F.Cu) (at {} {} 0)\n    (property \"Reference\" {} (at 0 0 0) (layer \"F.SilkS\") (hide yes))\n    (attr smd)\n    {pad}\n  )", n(f(&p["x"])), n(yy(f(&p["y"]))), q(&format!("FREE{}", k + 1)));+    }++    // free silkscreen: texts at their box centre (a bottom text mirrored, as it prints), and lines+    for t in silk["texts"].as_array().cloned().unwrap_or_default() {+        let layer = match t["side"].as_str() { Some("top") => "F.SilkS", Some("bottom") => "B.SilkS", _ => continue };+        let b = &t["box"];+        let (bx, by) = ((f(&b[0]) + f(&b[2])) / 2.0, (f(&b[1]) + f(&b[3])) / 2.0);+        let s = f(&t["size"]).max(0.3);+        let mirror = if layer == "B.SilkS" { " (justify mirror)" } else { "" };+        let _ = writeln!(o, "  (gr_text {} (at {} {} {}) (layer \"{layer}\") (effects (font (size {} {}) (thickness {})){mirror}))", q(t["text"].as_str().unwrap_or("")), n(bx), n(yy(by)), n(f(&t["rotation"])), n(s), n(s), n(s / 7.0));+    }+    for l in silk["lines"].as_array().cloned().unwrap_or_default() {+        let layer = match l["side"].as_str() { Some("top") => "F.SilkS", Some("bottom") => "B.SilkS", _ => continue };+        let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer \"{layer}\"))", n(f(&l["x1"])), n(yy(f(&l["y1"]))), n(f(&l["x2"])), n(yy(f(&l["y2"]))), n(f(&l["width"]).max(0.1)));+    }++    // outline: lines and arcs (an Altium arc runs counterclockwise from angle1 to angle2, y up)+    let edges = arr("outlineEdges");+    if edges.is_empty() { return Err("no board outline: define the board shape in Altium first (altium_board_shape)".into()); }+    for e in &edges {+        let (a, b) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));+        if e["kind"].as_str() == Some("arc") {+            let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));+            let (a1, a2) = (f(&e["angle1"]), f(&e["angle2"]));+            let sweep = (a2 - a1).rem_euclid(360.0);+            let m = (a1 + sweep / 2.0).to_radians();+            let (mx, my) = (cx + r * m.cos(), cy + r * m.sin());+            let _ = writeln!(o, "  (gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(mx), n(yy(my)), n(b.0), n(yy(b.1)));+        } else {+            let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(b.0), n(yy(b.1)));+        }+    }++    // copper already on the board+    for t in arr("tracks") {+        let l = t["layer"].as_str().unwrap_or("");+        if !is_copper(l) { continue; }+        let _ = writeln!(o, "  (segment (start {} {}) (end {} {}) (width {}) (layer {}) {})", n(f(&t["start"]["x"])), n(yy(f(&t["start"]["y"]))), n(f(&t["end"]["x"])), n(yy(f(&t["end"]["y"]))), n(f(&t["width"]).max(0.05)), q(l), net_id(t["net"].as_str().unwrap_or("")));+    }+    for a in arr("arcs") {+        let l = a["layer"].as_str().unwrap_or("");+        if !is_copper(l) { continue; }+        let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));+        let (a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));+        let sweep = (a2 - a1).rem_euclid(360.0);+        if sweep < 1e-6 { continue; }+        let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());+        let (s, m, e) = (pt(a1), pt(a1 + sweep / 2.0), pt(a1 + sweep));+        let _ = writeln!(o, "  (arc (start {} {}) (mid {} {}) (end {} {}) (width {}) (layer {}) {})", n(s.0), n(yy(s.1)), n(m.0), n(yy(m.1)), n(e.0), n(yy(e.1)), n(f(&a["width"]).max(0.05)), q(l), net_id(a["net"].as_str().unwrap_or("")));+    }+    for v in arr("vias") {+        let _ = writeln!(o, "  (via (at {} {}) (size {}) (drill {}) (layers \"F.Cu\" \"B.Cu\") {})", n(f(&v["position"]["x"])), n(yy(f(&v["position"]["y"]))), n(f(&v["size"])), n(f(&v["drill"])), net_id(v["net"].as_str().unwrap_or("")));+    }+    // pours: the bridge's pour read (with Altium's own fill) when there is one, else the outlines+    let zones = d["pours"]["zones"].as_array().cloned();+    let pts = |v: &Value| -> Vec<(f64, f64)> { v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect() };+    let xy = |ps: &[(f64, f64)]| ps.iter().map(|p| format!("(xy {} {})", n(p.0), n(yy(p.1)))).collect::<Vec<_>>().join(" ");+    let zone_list: Vec<(String, String, Vec<(f64, f64)>, Vec<Vec<(f64, f64)>>)> = match zones {+        Some(zs) => zs.iter().map(|z| {+            let fills: Vec<Vec<(f64, f64)>> = z["fills"].as_array().cloned().unwrap_or_default().iter()+                .map(|fl| fracture(&pts(&fl["outline"]), &fl["holes"].as_array().cloned().unwrap_or_default().iter().map(|h| pts(h)).collect::<Vec<_>>())).collect();+            (z["net"].as_str().unwrap_or("").to_string(), z["layer"].as_str().unwrap_or("F.Cu").to_string(), pts(&z["outline"]), fills)+        }).collect(),+        None => arr("polygons").iter().map(|g| (g["net"].as_str().unwrap_or("").to_string(), g["layer"].as_str().unwrap_or("F.Cu").to_string(), pts(&g["points"]), Vec::new())).collect(),+    };+    for (net, layer, outline, fills) in zone_list {+        if !is_copper(&layer) || outline.len() < 3 { continue; }+        let _ = write!(o, "  (zone (net {}) (net_name {}) (layer {}) (hatch edge 0.5) (priority 0) (connect_pads (clearance 0.2)) (min_thickness 0.2) (fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n    (polygon (pts {}))", idx(&net), q(&net), q(&layer), xy(&outline));+        for fl in fills.iter().filter(|fl| fl.len() >= 3) { let _ = write!(o, "\n    (filled_polygon (layer {}) (pts {}))", q(&layer), xy(fl)); }+        o.push_str(")\n");+    }+    o.push_str(")\n");+    Ok(o)+}++#[cfg(test)]+mod tests {+    use super::*;++    /// A real board: AIFLOW_ALTIUM_FIXTURE names a saved altium_routing_state reply.+    #[test]+    #[ignore]+    fn renders_a_real_altium_read() {+        let p = std::env::var("AIFLOW_ALTIUM_FIXTURE").expect("AIFLOW_ALTIUM_FIXTURE");+        let st: Value = serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap();+        let src = serde_json::json!({"source": "altium-bridge", "state": st, "pours": null}).to_string();+        let text = render_plan_board(&src).unwrap();+        std::fs::write(std::env::temp_dir().join("altium-real.kicad_pcb"), &text).unwrap();+        let b = crate::Board::from_text("real.kicad_pcb", text).unwrap();+        let n_pads = st["pads"].as_array().unwrap().len();+        eprintln!("pads {} (read {n_pads}), footprints {}, tracks {}, vias {}, zones {}, nets {}", b.pads.len(), b.footprints.len(), b.tracks().len(), b.vias().len(), b.zones().len(), b.nets.len());+        // every pad of a component comes through, where Altium has it+        for p in st["pads"].as_array().unwrap().iter().filter(|p| !p["ref"].as_str().unwrap_or("").is_empty()).take(200) {+            let key = format!("{}.{}", p["ref"].as_str().unwrap(), p["name"].as_str().unwrap());+            let got = b.pads.iter().find(|q| q.key == key && (q.x - p["x"].as_f64().unwrap()).abs() < 1e-3 && (q.y + p["y"].as_f64().unwrap()).abs() < 1e-3);+            assert!(got.is_some(), "pad {key} not at ({}, {})", p["x"], -p["y"].as_f64().unwrap());+        }+    }+    #[test]+    fn a_hole_is_slit_into_its_outline() {+        let p = fracture(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], &[vec![(4.0, 4.0), (6.0, 4.0), (6.0, 6.0), (4.0, 6.0)]]);+        assert_eq!(p.len(), 4 + 4 + 2);+    }+}
crates/aiflow-board/src/lib.rs+3
@@ -1,6 +1,7 @@ //! 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 altium; pub mod eagle; pub mod geom; pub mod sx;@@ -186,6 +187,8 @@ impl Board {         let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;         // a Fusion board (EAGLE source): plan on its .kicad_pcb rendering (see eagle.rs)         if eagle::is_eagle(&text) { return Board::from_text(path, eagle::to_kicad(&text)?); }+        // an Altium board read (the Altium bridge's JSON): plan on its .kicad_pcb rendering (see altium.rs)+        if altium::is_altium(&text) { return Board::from_text(path, altium::render_plan_board(&text)?); }         Board::from_text(path, text)     } 
crates/aiflow-bridge/src/altium.rsadded+467
@@ -0,0 +1,467 @@+//! Altium Designer through the Altium bridge (`altium_*` verbs, Altium bridge 0.2.47+).+//!+//! The board of record is the `.PcbDoc` open in Altium on the box (`altium:<path>`). Every read is a+//! fresh `altium_routing_state` / `altium_pour_state`; every edit is one Altium undo step, guarded by+//! the revision the flow last read and read back by the bridge. Altium is y up; the plan frame is y+//! down, so y is negated here and nowhere else. Rotations are the same number in both (counter-+//! clockwise as seen on screen). Layer names are KiCad's on both sides (the bridge speaks F.Cu/B.Cu).+//!+//! Item ids name the geometry in Altium's frame (`altium:t:x1,y1,x2,y2,layerId`, `altium:v:x,y`), so+//! a single track or via can be taken back: Altium deletes item by item.+use serde_json::{json, Value};++use crate::eda::{BoardRef, Caps, Eda, Transport};++pub struct AltiumEda {+    /// The absolute `.PcbDoc` path on the box.+    pub path: String,+}++fn ok(v: &Value) -> bool { v["success"].as_bool() == Some(true) }++/// the net the walkthrough selected last, for its "Zoom to Selected Objects"+static LAST_NET: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());+fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }+fn flip(p: &Value) -> Value { json!([f(&p[0]), -f(&p[1])]) }++/// A flow waypoint ("R1.1", [x,y], {x,y,layer} or {pad,layer}) as the Altium bridge's (Altium frame).+fn waypoint(w: &Value) -> Value {+    if let Some(pad) = w.as_str() { return json!({"pad": pad}); }+    if w.is_array() { return flip(w); }+    let mut o = serde_json::Map::new();+    if let Some(pad) = w["pad"].as_str() { o.insert("pad".into(), json!(pad)); }+    if let (Some(x), Some(y)) = (w["x"].as_f64(), w["y"].as_f64()) { o.insert("x".into(), json!(x)); o.insert("y".into(), json!(-y)); }+    if let Some(l) = w["layer"].as_str() { o.insert("layer".into(), json!(l)); }+    Value::Object(o)+}++fn is_copper(layer: &str) -> bool { layer == "F.Cu" || layer == "B.Cu" || (layer.starts_with("In") && layer.ends_with(".Cu")) }++fn track_id(t: &Value) -> String {+    format!("altium:t:{},{},{},{},{}", f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), t["layerId"].as_i64().unwrap_or(1))+}+fn via_id(v: &Value) -> String { format!("altium:v:{},{}", f(&v["position"]["x"]), f(&v["position"]["y"])) }++/// The Altium bridge's routing state in the KiCad bridge's kicad_routing_state shape (plan frame).+pub fn to_kicad_state(a: &Value) -> Value {+    if !ok(a) { return a.clone(); }+    let segs: Vec<Value> = a["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| is_copper(t["layer"].as_str().unwrap_or(""))).map(|t| json!({+        "start": {"x": t["start"]["x"], "y": -f(&t["start"]["y"])}, "end": {"x": t["end"]["x"], "y": -f(&t["end"]["y"])},+        "layer": t["layer"], "net_name": t["net"], "width": t["width"], "uuid": track_id(t)})).collect();+    let vias: Vec<Value> = a["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| json!({+        "position": {"x": v["position"]["x"], "y": -f(&v["position"]["y"])}, "net_name": v["net"], "size": v["size"], "drill": v["drill"], "uuid": via_id(v)})).collect();+    let pads: Vec<Value> = a["pads"].as_array().cloned().unwrap_or_default().iter().map(|p| json!({+        "name": format!("{}.{}", p["ref"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or("")), "netName": p["net"].as_str().unwrap_or(""), "x": p["x"], "y": -f(&p["y"])})).collect();+    let elements: Vec<Value> = a["components"].as_array().cloned().unwrap_or_default().iter().map(|c| json!({+        "name": c["ref"], "package": c["pattern"], "has3d": c["has3d"]})).collect();+    json!({"success": true, "revision": a["revision"], "boardId": a["boardId"], "segments": segs, "vias": vias, "pads": pads,+           "unconnected": a["unconnected"], "elements": elements, "source": "altium"})+}++impl AltiumEda {+    fn read(&self, t: &dyn Transport) -> Value { t.send("altium_routing_state", &json!({"expectPath": self.path})) }+    /// The guard every edit carries: the board, and the revision the flow last read.+    fn guard(&self, args: &Value) -> Value {+        let mut g = json!({"expectPath": self.path});+        if let Some(r) = args["expectedRevision"].as_str().filter(|r| !r.is_empty()) { g["expectedRevision"] = json!(r); }+        g+    }+}++impl Eda for AltiumEda {+    fn id(&self) -> &'static str { "altium" }+    fn name(&self) -> &'static str { "Altium" }+    fn board(&self) -> BoardRef { BoardRef::Altium(self.path.clone()) }+    fn caps(&self) -> Caps {+        Caps { full_drc: true, item_removal: true, net_highlight: false, ..Caps::default() }+    }++    fn routing_state(&self, t: &dyn Transport, _a: &Value) -> Value { to_kicad_state(&self.read(t)) }++    fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value {+        if a["refill"].as_bool() == Some(true) {+            let r = t.send("altium_repour", &json!({}));+            if !ok(&r) { return r; }+        }+        let p = t.send("altium_pour_state", &json!({}));+        if !ok(&p) { return p; }+        let st = self.read(t);+        let by = &p["filledAreaByLayerMm2"];+        let zones: Vec<Value> = p["zones"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, z)| {+            let l = z["layer"].as_str().unwrap_or("F.Cu");+            json!({"id": format!("altium-pour-{i}"), "name": format!("{} pour", z["net"].as_str().unwrap_or("")), "net": z["net"], "layer": l, "layers": [l],+                   "type": "copper", "filledMm2": z["filledMm2"], "filled": z["filled"], "fills": [{"layer": l, "areaMm2": z["filledMm2"]}]})+        }).collect();+        json!({"success": true, "revision": st["revision"], "revisionUsable": st["revision"].is_string(),+               "filledAreaByLayerMm2": {"F.Cu": by["F.Cu"].as_f64().unwrap_or(0.0), "B.Cu": by["B.Cu"].as_f64().unwrap_or(0.0)},+               "zones": zones, "unfilled": p["unfilled"], "readbackEvidence": "Altium's own poured regions (altium_pour_state)"})+    }++    fn model_check(&self, t: &dyn Transport, _a: &Value) -> Value {+        let st = self.read(t);+        if !ok(&st) { return st; }+        let comps = st["components"].as_array().cloned().unwrap_or_default();+        let without: Vec<Value> = comps.iter().filter(|c| c["has3d"].as_bool() != Some(true)).map(|c| json!({"footprint": c["pattern"], "references": [c["ref"]], "models": [{"reference": "(no 3D body on the footprint)", "exists": false}]})).collect();+        json!({"success": true, "data": {"counts": {"withModel": comps.len() - without.len(), "missingModel": without.len(), "unresolvedModel": 0, "noModelExpected": 0},+               "footprintsWithWarnings": without, "source": "Altium: each component's 3D bodies"}})+    }++    fn status(&self, t: &dyn Transport) -> Value { t.send("altium_readiness", &json!({})) }++    fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value {+        let parts: Vec<Value> = a["refs"].as_array().cloned().unwrap_or_default().iter()+            .map(|m| json!({"ref": m["ref"], "x": m["x"], "y": -f(&m["y"]), "rotation": f(&m["rotation"])})).collect();+        let mut g = self.guard(a);+        g["parts"] = json!(parts);+        let r = t.send("altium_move_parts", &g);+        json!({"success": r["success"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "results": r["results"], "mismatches": r["mismatches"]})+    }++    fn route_net(&self, t: &dyn Transport, a: &Value) -> Value {+        let mut g = self.guard(a);+        g["net"] = a["net"].clone();+        g["width"] = json!(a["width"].as_f64().unwrap_or(0.25));+        if let Some(l) = a["layer"].as_str() { g["layer"] = json!(l); }+        if let Some(v) = a["viaSize"].as_f64() { g["viaSize"] = json!(v); }+        if let Some(v) = a["viaDrill"].as_f64() { g["viaDrill"] = json!(v); }+        if let Some(ps) = a["paths"].as_array() { g["paths"] = json!(ps.iter().map(|p| json!(p.as_array().cloned().unwrap_or_default().iter().map(waypoint).collect::<Vec<_>>())).collect::<Vec<_>>()); }+        else if let Some(ps) = a["points"].as_array() { g["paths"] = json!([ps.iter().map(waypoint).collect::<Vec<_>>()]); }+        let r = t.send("altium_route_net", &g);+        // the ids of what this edit added: the copper on the net after, less what was there before+        let ids: Vec<String> = if ok(&r) {+            let before = r["state"].is_object();+            let after = to_kicad_state(&r["state"]);+            let _ = before;+            after["segments"].as_array().into_iter().flatten().chain(after["vias"].as_array().into_iter().flatten())+                .filter(|o| o["net_name"] == a["net"]).filter_map(|o| o["uuid"].as_str().map(str::to_string)).collect()+        } else { Vec::new() };+        json!({"success": r["success"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "message": r["_hint"],+               "net": a["net"], "itemIds": ids, "results": r["results"], "drc": {"unconnected": r["unconnected"]}})+    }++    fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value {+        let mut tracks = Vec::new();+        let mut vias = Vec::new();+        for id in a["itemIds"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str()) {+            if let Some(r) = id.strip_prefix("altium:t:") {+                let v: Vec<f64> = r.split(',').filter_map(|x| x.parse().ok()).collect();+                if v.len() == 5 { tracks.push(json!({"start": {"x": v[0], "y": v[1]}, "end": {"x": v[2], "y": v[3]}, "layerId": v[4] as i64})); }+            } else if let Some(r) = id.strip_prefix("altium:v:") {+                let v: Vec<f64> = r.split(',').filter_map(|x| x.parse().ok()).collect();+                if v.len() == 2 { vias.push(json!({"x": v[0], "y": v[1]})); }+            }+        }+        if tracks.is_empty() && vias.is_empty() { return json!({"success": false, "errorCode": "no_item_ids", "error": "no Altium item ids (altium:t:... / altium:v:...) to remove"}); }+        let mut g = self.guard(a);+        g["tracks"] = json!(tracks);+        g["vias"] = json!(vias);+        let r = t.send("altium_remove_items", &g);+        json!({"success": r["success"], "removed": r["applied"], "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"], "results": r["results"]})+    }++    fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value {+        let mut pours = Vec::new();+        let mut cutouts = Vec::new();+        let mut skipped = Vec::new();+        let mut replace: Vec<Value> = Vec::new();+        for z in a["zones"].as_array().cloned().unwrap_or_default() {+            let pts: Vec<Value> = z["polygon"].as_array().or(z["outline"].as_array()).cloned().unwrap_or_default().iter().map(flip).collect();+            let name = z["name"].as_str().unwrap_or("").to_string();+            if z["type"].as_str() == Some("keepout") {+                // a pour keepout is a polygon cutout on each of its copper layers; route and via keepouts+                // are the router's to honour and are not landed+                if z["keepout"]["copperPour"].as_bool() != Some(true) { skipped.push(json!({"name": name, "why": "only pour keepouts are landed in Altium (as polygon cutouts)"})); continue; }+                if pts.len() < 3 { skipped.push(json!({"name": name, "why": "keepout without a polygon"})); continue; }+                for l in z["layers"].as_array().cloned().unwrap_or_default() {+                    if let Some(ln) = l.as_str().filter(|l| is_copper(l)) { cutouts.push(json!({"layer": ln, "points": pts, "name": name})); }+                }+                continue;+            }+            if pts.len() < 3 { skipped.push(json!({"name": name, "why": "no polygon (an outline pour must be resolved to points before landing)"})); continue; }+            let prio = z["priority"].as_i64().unwrap_or(0);+            // KiCad's zone connection: solid joins pads full width, thermal with spokes (none = no connection)+            let connect = match z["connection"].as_str() { Some("solid") => Some("direct"), Some("none") => Some("none"), Some("thermal") => Some("relief"), _ => None };+            for l in z["layers"].as_array().cloned().unwrap_or_default() {+                let Some(ln) = l.as_str().filter(|l| is_copper(l)) else { continue };+                // Altium pours lower order first; KiCad's higher priority wins+                let mut p = json!({"net": z["net"], "layer": ln, "points": pts, "order": (10 - prio).max(1), "name": format!("{name} {ln}")});+                if let Some(c) = connect { p["connect"] = json!(c); }+                if let Some(w) = z["thermalBridge"].as_f64() { p["reliefWidth"] = json!(w); }+                if let Some(g) = z["thermalGap"].as_f64() { p["reliefGap"] = json!(g); }+                if let Some(m) = z["minThickness"].as_f64() { p["minNeck"] = json!(m); }+                pours.push(p);+                let key = json!({"net": z["net"], "layer": ln});+                if !replace.contains(&key) { replace.push(key); }+            }+        }+        // A solid zone inside a larger same-net pour on its layer is KiCad's joint: its pads join that+        // pour full width. Altium pours nothing for a same-net polygon inside another, so the joint+        // rides on the host pour as solidPads (a direct-connect rule for the pads in its box).+        let mut joints = Vec::new();+        let mut keep = Vec::new();+        for (i, p) in pours.iter().enumerate() {+            let host = (p["connect"].as_str() == Some("direct")).then(|| pours.iter().enumerate().find(|(j, q)| *j != i+                && q["net"] == p["net"] && q["layer"] == p["layer"] && area(&q["points"]) > area(&p["points"])+                && p["points"].as_array().map(|a| a.iter().all(|v| inside(v, &q["points"]))).unwrap_or(false))).flatten();+            match host { Some((j, _)) => joints.push((j, bbox(&p["points"]), p["name"].clone())), None => keep.push(i) }+        }+        for (j, b, _) in &joints {+            let sp = pours[*j]["solidPads"].as_array().cloned().unwrap_or_default();+            let mut sp = sp; sp.push(json!(b)); pours[*j]["solidPads"] = json!(sp);+        }+        let folded: Vec<Value> = joints.iter().map(|(j, _, n)| json!({"joint": n, "into": pours[*j]["name"]})).collect();+        let pours: Vec<Value> = keep.iter().map(|&i| pours[i].clone()).collect();+        if pours.is_empty() && cutouts.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Altium"}); }+        // the plan replaces the flow's pours on the same net and layer, and its keepouts (a refine pass pours again)+        let mut g = self.guard(a);+        if !replace.is_empty() { g["pours"] = json!(replace); }+        g["cutouts"] = json!("all");+        let rm = t.send("altium_remove_pours", &g);+        if !ok(&rm) { return rm; }+        let r = t.send("altium_apply_pours", &json!({"expectPath": self.path, "pours": pours, "cutouts": cutouts}));+        json!({"success": r["success"], "mutated": true, "revision": r["revision"], "errorCode": r["errorCode"], "error": r["error"],+               "itemIds": (0..pours.len()).map(|i| format!("altium-pour-{i}")).collect::<Vec<_>>(), "removedIds": [], "deletionVerified": true,+               "zones": r["zones"], "unfilled": r["unfilled"], "skipped": skipped, "cutouts": cutouts.len(), "jointsFolded": folded, "notes": r["notes"], "results": r["results"]})+    }++    fn routing_validate(&self, t: &dyn Transport, _a: &Value) -> Value {+        let r = t.send("altium_routing_drc", &json!({}));+        let (errors, warnings) = split_severity(&r);+        json!({"success": r["success"], "unconnected": r["unconnected"], "errors": errors.len(), "warnings": warnings.len(),+               "clean": errors.is_empty() && r["unconnected"].as_u64().unwrap_or(0) == 0, "drc": r, "warningRules": rule_counts(&warnings), "source": "Altium online DRC"})+    }++    fn full_drc(&self, t: &dyn Transport, _a: &Value) -> Value {+        let r = t.send("altium_routing_drc", &json!({"full": true}));+        let (errors, warnings) = split_severity(&r);+        let v: Vec<Value> = errors.iter().map(|x| json!({"type": x["rule"], "items": [x["description"]], "at": x["at"]})).collect();+        json!({"success": r["success"], "violations": v, "unconnected": r["unconnected"], "errors": errors.len(), "warnings": warnings.len(),+               "warningRules": rule_counts(&warnings), "source": "Altium batch DRC"})+    }++    fn open_board(&self, t: &dyn Transport, _a: &Value) -> Value {+        let r = t.send("altium_show_2d_board", &json!({"filePath": self.path}));+        let h = self.main_window(t);+        json!({"success": ok(&r) && h.is_some(), "hwnd": h, "error": r["error"]})+    }++    fn window_info(&self, t: &dyn Transport, _a: &Value) -> Value {+        let h = self.main_window(t);+        json!({"success": true, "data": {"editors": h.map(|h| vec![json!({"kind": "pcb_editor", "hwnd": h, "title": self.path}), json!({"kind": "3d_viewer", "hwnd": h})]).unwrap_or_default()}})+    }++    fn send_key(&self, t: &dyn Transport, a: &Value) -> Value {+        if a["menu"].as_str() == Some("Zoom to Selected Objects") {+            let net = LAST_NET.lock().map(|n| n.clone()).unwrap_or_default();+            if !net.is_empty() { return t.send("altium_zoom_to", &json!({"net": net, "margin": 1.0})); }+        }+        json!({"success": self.fit_board(t)})+    }++    fn select_net(&self, t: &dyn Transport, a: &Value) -> Value {+        let net = a["net"].as_str().unwrap_or("").to_string();+        let r = t.send("altium_select_net", &json!({"net": net}));+        if ok(&r) { if let Ok(mut n) = LAST_NET.lock() { *n = net; } }+        json!({"success": r["success"], "selected": r["selected"], "byKind": r["byKind"], "errorCode": r["errorCode"], "error": r["error"]})+    }++    fn select_clear(&self, t: &dyn Transport, _a: &Value) -> Value {+        if let Ok(mut n) = LAST_NET.lock() { n.clear(); }+        t.send("altium_select_net", &json!({}))+    }++    fn open_3d(&self, t: &dyn Transport, _a: &Value) -> Value {+        let r = t.send("altium_show_3d_board", &json!({"filePath": self.path}));+        json!({"success": r["success"], "hwnd": self.main_window(t)})+    }+    fn close_3d(&self, t: &dyn Transport, _a: &Value) -> Value { t.send("altium_show_2d_board", &json!({"filePath": self.path})) }+    fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value {+        t.send("altium_animate_view", &json!({"preset": "showcase3d", "pauseMs": a["pauseMs"].as_u64().unwrap_or(700)}))+    }+    fn screenshot_all(&self, t: &dyn Transport, _a: &Value) -> Value {+        // Altium's view paints whenever it is shown; the main window is reported as rendered and marked as assumed+        let h = self.main_window(t);+        json!({"success": true, "data": {"screenshots": h.map(|h| vec![json!({"hwnd": h, "canvas": {"rendered": true, "distinctColors": 64, "assumed": true}})]).unwrap_or_default()}})+    }++    /// Round a rectangular board's corners: the same rectangle with arcs (altium_set_outline). A board+    /// whose outline is not an axis-aligned rectangle refuses; round it in Altium.+    fn round_corners(&self, t: &dyn Transport, corners: &[(f64, f64)], radius: f64) -> Value {+        let st = self.read(t);+        if !ok(&st) { return st; }+        let edges = st["outlineEdges"].as_array().cloned().unwrap_or_default();+        let straight = edges.iter().all(|e| e["kind"].as_str() == Some("line"));+        let pts: Vec<(f64, f64)> = edges.iter().map(|e| (f(&e["start"][0]), f(&e["start"][1]))).collect();+        let axis = edges.iter().all(|e| (f(&e["start"][0]) - f(&e["end"][0])).abs() < 1e-3 || (f(&e["start"][1]) - f(&e["end"][1])).abs() < 1e-3);+        if !straight || edges.len() != 4 || !axis {+            return json!({"success": false, "errorCode": "unsupported_in_altium", "error": "outline round lands live in Altium for a rectangular outline only; this one is not a plain rectangle"});+        }+        let (x0, x1) = (pts.iter().map(|p| p.0).fold(f64::MAX, f64::min), pts.iter().map(|p| p.0).fold(f64::MIN, f64::max));+        let (y0, y1) = (pts.iter().map(|p| p.1).fold(f64::MAX, f64::min), pts.iter().map(|p| p.1).fold(f64::MIN, f64::max));+        let r = t.send("altium_set_outline", &json!({"expectPath": self.path, "expectedRevision": st["revision"], "outline": {"rect": [x0, y0, x1 - x0, y1 - y0], "cornerRadius": radius}}));+        json!({"success": r["success"], "rounded": if ok(&r) { corners.len() } else { 0 }, "error": r["error"], "errorCode": r["errorCode"]})+    }++    /// The fab pack's floors as Altium design rules (altium_set_design_rules).+    fn load_design_rules(&self, t: &dyn Transport, _fab: &str, _layers: u32, dru: &str) -> Option<Value> {+        let mut a = crate::eda::fab_numbers(dru);+        if a.as_object().map(|o| o.is_empty()).unwrap_or(true) { return Some(json!({"success": false, "errorCode": "no_rules", "error": "the fab rule pack names no constraints Altium can load"})); }+        let _ = t.send("altium_show_2d_board", &json!({"filePath": self.path}));+        a["expectPath"] = json!(self.path);+        Some(t.send("altium_set_design_rules", &a))+    }++    fn main_window(&self, t: &dyn Transport) -> Option<i64> {+        let w = t.send("desktop_list_windows", &json!({}));+        let out = w.get("output").and_then(|o| o.as_str()).and_then(|s| serde_json::from_str::<Value>(s).ok()).unwrap_or(w.clone());+        let list = out["data"]["windows"].as_array().or(out["windows"].as_array()).cloned().unwrap_or_default();+        list.iter().find(|x| x["image"].as_str().map(|i| i.eq_ignore_ascii_case("X2.EXE")).unwrap_or(false) && x["kind"].as_str() == Some("normal")+            && x["title"].as_str().map(|t| t.contains("Altium")).unwrap_or(false)).and_then(|x| x["hwnd"].as_i64())+    }++    fn fit_board(&self, t: &dyn Transport) -> bool {+        // Action=Board left the view on one corner (AD 26.11.1, 2026-10-15); All frames the board+        ok(&t.send("altium_run_command", &json!({"command": "PCB:Zoom", "params": "Action=All"})))+    }++    /// `board-live.altium.json` (the bridge's own read, fills included) and `board-live.kicad_pcb`.+    fn live_snapshot(&self, t: &dyn Transport, dir: &std::path::Path) -> Option<Result<(String, String), String>> {+        let st = self.read(t);+        if !ok(&st) { return Some(Err(format!("could not read the live Altium board {}: {} (open it: altium_show_2d_board {{filePath}})", self.path, st["error"]))); }+        let pours = t.send("altium_pour_state", &json!({}));+        // the silkscreen too: finish counts the labels and the field app draws them+        let silk = t.send("altium_silk_state", &json!({}));+        let src = json!({"source": "altium-bridge", "state": st, "pours": if ok(&pours) { pours } else { Value::Null }, "silk": if ok(&silk) { silk } else { Value::Null }});+        let native = dir.join("board-live.altium.json");+        let kicad = dir.join("board-live.kicad_pcb");+        let text = src.to_string();+        let plan = match render_plan_board(&text) { Ok(p) => p, Err(e) => return Some(Err(format!("live Altium board: {e}"))) };+        if let Err(e) = std::fs::write(&native, text).and_then(|_| std::fs::write(&kicad, plan)) { return Some(Err(e.to_string())); }+        // the file the field solver reads is the plan board (adom-fields reads KiCad and EAGLE boards, not+        // the bridge's JSON); the JSON stays beside it as the native record+        Some(Ok((kicad.display().to_string(), kicad.display().to_string())))+    }+}++/// The plan board renderer lives with the board model (aiflow_board::altium).+pub use aiflow_board::altium::{is_altium, render_plan_board};++/// Altium reports every violation alike; KiCad grades them. The silkscreen and mask-sliver rules are+/// KiCad's warnings (silk_overlap, silk_over_copper, ...): reported, not a failed board. Everything else+/// (clearance, short, width, holes, annular ring, edge) is an error.+const WARNING_RULES: [&str; 5] = ["SilkToSolderMaskClearance", "SilkToSilkClearance", "SilkToBoardRegionClearance", "MinimumSolderMaskSliver", "Height"];++fn split_severity(r: &Value) -> (Vec<Value>, Vec<Value>) {+    r["violations"].as_array().cloned().unwrap_or_default().into_iter()+        .partition(|x| !WARNING_RULES.contains(&x["rule"].as_str().unwrap_or("")))+}++fn rule_counts(v: &[Value]) -> Value {+    let mut m = serde_json::Map::new();+    for x in v { let k = x["rule"].as_str().unwrap_or("?").to_string(); let n = m.get(&k).and_then(|c| c.as_u64()).unwrap_or(0); m.insert(k, json!(n + 1)); }+    Value::Object(m)+}++fn pts(v: &Value) -> Vec<(f64, f64)> {+    v.as_array().cloned().unwrap_or_default().iter().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect()+}++fn area(v: &Value) -> f64 {+    let p = pts(v);+    (0..p.len()).map(|i| { let (a, b) = (p[i], p[(i + 1) % p.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0+}++/// a point inside (or on the edge of) a polygon+fn inside(pt: &Value, poly: &Value) -> bool {+    let (x, y) = (pt[0].as_f64().unwrap_or(0.0), pt[1].as_f64().unwrap_or(0.0));+    let p = pts(poly);+    let mut c = false;+    for i in 0..p.len() {+        let (a, b) = (p[i], p[(i + 1) % p.len()]);+        // on the edge counts as inside+        let cross = (b.0 - a.0) * (y - a.1) - (b.1 - a.1) * (x - a.0);+        if cross.abs() < 1e-9 && x >= a.0.min(b.0) - 1e-9 && x <= a.0.max(b.0) + 1e-9 && y >= a.1.min(b.1) - 1e-9 && y <= a.1.max(b.1) + 1e-9 { return true; }+        if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }+    }+    c+}++fn bbox(v: &Value) -> [f64; 4] {+    let p = pts(v);+    let f = |g: fn(f64, f64) -> f64, s: f64, k: usize| p.iter().map(|q| if k == 0 { q.0 } else { q.1 }).fold(s, g);+    [f(f64::min, f64::MAX, 0), f(f64::min, f64::MAX, 1), f(f64::max, f64::MIN, 0), f(f64::max, f64::MIN, 1)]+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::cell::RefCell;++    fn board() -> Value {+        json!({"source": "altium-bridge", "state": {"success": true, "revision": "r1", "boardId": "C:/b.PcbDoc", "nets": ["GND", "VIN"], "unconnected": 1,+            "components": [{"ref": "R1", "x": 10.0, "y": 5.0, "rotation": 90.0, "side": "top", "pattern": "R0402", "has3d": true}],+            "pads": [{"ref": "R1", "name": "1", "x": 10.0, "y": 4.5, "sizeX": 0.5, "sizeY": 0.6, "rotation": 90.0, "layer": "F.Cu", "drill": 0.0, "net": "GND", "shape": "rect"},+                     {"ref": "R1", "name": "2", "x": 10.0, "y": 5.5, "sizeX": 0.5, "sizeY": 0.6, "rotation": 90.0, "layer": "F.Cu", "drill": 0.0, "net": "VIN", "shape": "roundrect"}],+            "tracks": [{"start": {"x": 10.0, "y": 5.5}, "end": {"x": 15.0, "y": 5.5}, "width": 0.3, "layer": "F.Cu", "layerId": 1, "net": "VIN"},+                       {"start": {"x": 0.0, "y": 0.0}, "end": {"x": 1.0, "y": 0.0}, "width": 0.1, "layer": "L57", "layerId": 57, "net": ""}],+            "arcs": [], "vias": [{"position": {"x": 12.0, "y": 3.0}, "size": 0.6, "drill": 0.3, "net": "GND"}], "polygons": [],+            "outlineEdges": [{"kind": "line", "start": [0, 0], "end": [20, 0]}, {"kind": "line", "start": [20, 0], "end": [20, 10]}, {"kind": "line", "start": [20, 10], "end": [0, 10]}, {"kind": "line", "start": [0, 10], "end": [0, 0]}]},+            "pours": {"zones": [{"net": "GND", "layer": "B.Cu", "outline": [[0, 0], [20, 0], [20, 10], [0, 10]], "fills": [{"outline": [[0.5, 0.5], [19.5, 0.5], [19.5, 9.5], [0.5, 9.5]], "holes": [[[5, 5], [6, 5], [6, 6], [5, 6]]]}]}]}})+    }++    #[test]+    fn renders_a_plan_board_the_board_model_reads() {+        let text = render_plan_board(&board().to_string()).unwrap();+        let b = aiflow_board::Board::from_text("altium-test.kicad_pcb", text).expect("the plan board parses");+        // pads land where Altium has them, y negated+        let p = b.pads.iter().find(|p| p.key == "R1.1").unwrap();+        assert!((p.x - 10.0).abs() < 1e-6 && (p.y + 4.5).abs() < 1e-6, "pad at {},{}", p.x, p.y);+        assert_eq!(p.net, "GND");+        // only copper tracks come along; the mechanical-layer line does not+        assert_eq!(b.tracks().len(), 1);+        assert_eq!(b.vias().len(), 1);+        assert_eq!(b.zones().len(), 1);+    }++    #[test]+    fn edits_go_to_altium_in_its_own_frame() {+        let sent = RefCell::new(Vec::new());+        let t = |v: &str, a: &Value| { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true, "revision": "r2", "state": {"success": true, "tracks": [], "vias": []}}) };+        let e = AltiumEda { path: "C:/b.PcbDoc".into() };+        e.move_parts(&t, &json!({"expectedRevision": "r1", "refs": [{"ref": "R1", "x": 10.0, "y": -5.0, "rotation": 90.0}]}));+        e.route_net(&t, &json!({"expectedRevision": "r1", "net": "VIN", "width": 0.3, "paths": [["R1.2", [15.0, -5.5], {"x": 15.0, "y": -7.0, "layer": "B.Cu"}]]}));+        let s = sent.borrow();+        assert_eq!(s[0].0, "altium_move_parts");+        assert_eq!(s[0].1["parts"][0]["y"], json!(5.0));+        assert_eq!(s[0].1["expectPath"], json!("C:/b.PcbDoc"));+        assert_eq!(s[1].0, "altium_route_net");+        assert_eq!(s[1].1["paths"][0][0], json!({"pad": "R1.2"}));+        assert_eq!(s[1].1["paths"][0][1], json!([15.0, 5.5]));+        assert_eq!(s[1].1["paths"][0][2], json!({"x": 15.0, "y": 7.0, "layer": "B.Cu"}));+    }++    #[test]+    fn item_ids_take_back_single_tracks_and_vias() {+        let sent = RefCell::new(Vec::new());+        let t = |v: &str, a: &Value| { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true}) };+        let e = AltiumEda { path: "C:/b.PcbDoc".into() };+        let st = to_kicad_state(&json!({"success": true, "tracks": [{"start": {"x": 1.0, "y": 2.0}, "end": {"x": 3.0, "y": 2.0}, "width": 0.2, "layer": "F.Cu", "layerId": 1, "net": "A"}],+            "vias": [{"position": {"x": 3.0, "y": 2.0}, "size": 0.6, "drill": 0.3, "net": "A"}], "pads": [], "components": []}));+        let ids: Vec<Value> = st["segments"].as_array().unwrap().iter().chain(st["vias"].as_array().unwrap()).map(|o| o["uuid"].clone()).collect();+        e.remove_items(&t, &json!({"itemIds": ids}));+        let s = sent.borrow();+        assert_eq!(s[0].1["tracks"][0], json!({"start": {"x": 1.0, "y": 2.0}, "end": {"x": 3.0, "y": 2.0}, "layerId": 1}));+        assert_eq!(s[0].1["vias"][0], json!({"x": 3.0, "y": 2.0}));+    }+++    #[test]+    fn joint_folds_into_its_host_pour() {+        let sq = |x0: f64, y0: f64, x1: f64, y1: f64| json!([[x0, y0], [x1, y0], [x1, y1], [x0, y1]]);+        assert!(inside(&json!([1.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));+        assert!(inside(&json!([2.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));+        assert!(!inside(&json!([3.0, 1.0]), &sq(0.0, 0.0, 2.0, 2.0)));+        assert_eq!(bbox(&sq(1.0, 2.0, 3.0, 5.0)), [1.0, 2.0, 3.0, 5.0]);+        assert_eq!(area(&sq(0.0, 0.0, 2.0, 3.0)), 6.0);+    }+}
crates/aiflow-bridge/src/eda.rsadded+473
@@ -0,0 +1,473 @@+//! EDA backends: the only code that knows which tool a run's board lives in (see docs/eda-backends.md).+//!+//! A run names its board with a [`BoardRef`]: a `.kicad_pcb` path (KiCad), `fusion:<document>` or+//! `altium:<path>.PcbDoc`. The run's backend answers every board operation the flow makes: live reads,+//! guarded edits, checks, the view and capture verbs. Every call is in the plan frame (mm, y down,+//! F.Cu/B.Cu); a backend converts to its tool's frame itself.+//!+//! The flow still names its operations with the historical `kicad_*` verb names at the call sites;+//! [`dispatch`] maps each one to the backend method, so a call site never branches on the tool.+use serde_json::{json, Value};+use std::sync::{Arc, Mutex};++/// One adom-bridge verb, sent as is (the Adom Bridge CLI).+pub trait Transport {+    fn send(&self, verb: &str, args: &Value) -> Value;+}++impl<F: Fn(&str, &Value) -> Value> Transport for F {+    fn send(&self, verb: &str, args: &Value) -> Value { self(verb, args) }+}++/// Where a run's board lives.+#[derive(Clone, Debug, PartialEq, Eq)]+pub enum BoardRef {+    /// An absolute `.kicad_pcb` path on the box (or none yet).+    Kicad(String),+    /// The exact Fusion board document name.+    Fusion(String),+    /// The absolute `.PcbDoc` path on the box.+    Altium(String),+}++impl BoardRef {+    pub fn parse(s: &str) -> BoardRef {+        if let Some(d) = s.strip_prefix(crate::fusion::PREFIX) { return BoardRef::Fusion(d.to_string()); }+        if let Some(p) = s.strip_prefix(ALTIUM_PREFIX) { return BoardRef::Altium(p.to_string()); }+        BoardRef::Kicad(s.to_string())+    }+    /// The run's stored form (`remoteBoard`).+    pub fn to_ref_string(&self) -> String {+        match self {+            BoardRef::Kicad(p) => p.clone(),+            BoardRef::Fusion(d) => format!("{}{d}", crate::fusion::PREFIX),+            BoardRef::Altium(p) => format!("{ALTIUM_PREFIX}{p}"),+        }+    }+}++pub const ALTIUM_PREFIX: &str = "altium:";++/// What a backend can do. A step that needs a capability the backend lacks records the gap in the+/// run; it never passes silently.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Caps {+    pub live_placement_state: bool,+    pub live_placement_validate: bool,+    /// The tool's own full design-rule check (beyond the live routing check).+    pub full_drc: bool,+    /// Copper can be removed item by item (not only whole nets).+    pub item_removal: bool,+    pub keepout_zones: bool,+    /// The 3D view is its own window (KiCad) rather than a mode of the main window.+    pub separate_3d_window: bool,+    /// The bridge can tell whether a window's canvas has painted.+    pub canvas_paint_probe: bool,+    pub net_highlight: bool,+    /// The native board is a file the flow can pull and hash (remote binding, provenance, verify-pours).+    pub file_backed_board: bool,+}++/// The board operations every backend answers. Defaults refuse with `unsupported_in_<id>`, naming the+/// operation, so a missing piece is loud.+pub trait Eda: Send + Sync {+    fn id(&self) -> &'static str;+    /// The tool's name for the timeline and reports.+    fn name(&self) -> &'static str;+    fn board(&self) -> BoardRef;+    fn caps(&self) -> Caps;++    fn unsupported(&self, op: &str) -> Value {+        json!({"success": false, "errorCode": format!("unsupported_in_{}", self.id()),+               "error": format!("{op} has no {} backend equivalent yet", self.name()),+               "_hint": "File it on adom/adom-aiflow with the step that needed it (docs/eda-backends.md: adding to a backend)."})+    }++    // live reads+    fn routing_state(&self, t: &dyn Transport, args: &Value) -> Value;+    fn placement_state(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("placement_state") }+    fn placement_validate(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("placement_validate") }+    fn zone_state(&self, t: &dyn Transport, args: &Value) -> Value;+    fn model_check(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("model_check") }+    fn status(&self, t: &dyn Transport) -> Value;++    // guarded edits+    fn move_parts(&self, t: &dyn Transport, args: &Value) -> Value;+    fn route_net(&self, t: &dyn Transport, args: &Value) -> Value;+    fn remove_items(&self, t: &dyn Transport, args: &Value) -> Value;+    fn apply_zone_plan(&self, t: &dyn Transport, args: &Value) -> Value;++    // checks+    fn routing_validate(&self, t: &dyn Transport, args: &Value) -> Value;+    fn full_drc(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("full_drc") }++    // view and capture+    fn open_board(&self, t: &dyn Transport, args: &Value) -> Value;+    fn window_info(&self, t: &dyn Transport, args: &Value) -> Value;+    fn send_key(&self, t: &dyn Transport, args: &Value) -> Value;+    fn select_net(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("select_net") }+    fn select_clear(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("select_clear") }+    fn open_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("open_3d") }+    fn close_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("close_3d") }+    fn tour_3d(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("tour_3d") }+    fn screenshot_all(&self, _t: &dyn Transport, _args: &Value) -> Value { self.unsupported("screenshot_all") }+    /// The tool's main window on the box, when it has one to film.+    fn main_window(&self, _t: &dyn Transport) -> Option<i64> { None }+    /// Frame the board in the main window.+    fn fit_board(&self, _t: &dyn Transport) -> bool { false }+    /// The live board, pulled fresh into `dir`: (the tool's native file, the plan board rendered from+    /// it). None when the plan board in the run IS the board of record (KiCad), Some(Err) when the read+    /// failed.+    fn live_snapshot(&self, _t: &dyn Transport, _dir: &std::path::Path) -> Option<Result<(String, String), String>> { None }+    /// Round these sharp outline corners (plan frame) live in the tool.+    fn round_corners(&self, _t: &dyn Transport, _corners: &[(f64, f64)], _radius: f64) -> Value { self.unsupported("round_corners") }+    /// Load a fab's design rules into the open design (the plan board's .kicad_dru is written by the flow+    /// for every backend: the offline gate reads it). None when the tool has nothing to load them into.+    fn load_design_rules(&self, _t: &dyn Transport, _fab: &str, _layers: u32, _dru: &str) -> Option<Value> { None }+    /// The window to film for a step, the board framed in it. `cached` is the last filmed window.+    fn camera_window(&self, t: &dyn Transport, _step: &str, _cached: Option<i64>) -> Option<i64> {+        let h = self.main_window(t);+        self.fit_board(t);+        h+    }+    /// Whether this step's window is Hydrogen (the chat and the webviews) rather than the tool: a tool+    /// that is one main window (Fusion, Altium) yields the screen to Hydrogen for the human-facing steps.+    fn hydrogen_for_step(&self, step: &str) -> Option<bool> {+        const HYDROGEN_STEPS: [&str; 10] = ["intake", "design", "simulation", "test-plan", "sourcing", "library", "current", "thermal", "fields", "export"];+        Some(HYDROGEN_STEPS.contains(&step))+    }+    /// A flow verb this trait does not name yet.+    fn passthrough(&self, _t: &dyn Transport, verb: &str, _args: &Value) -> Value { self.unsupported(verb) }+}++/// Answer one of the flow's board verbs (historically named `kicad_*`) through a backend.+pub fn dispatch(eda: &dyn Eda, t: &dyn Transport, verb: &str, args: &Value) -> Value {+    match verb {+        "kicad_routing_state" => eda.routing_state(t, args),+        "kicad_placement_state" => eda.placement_state(t, args),+        "kicad_placement_validate" => eda.placement_validate(t, args),+        "kicad_zone_state" => eda.zone_state(t, args),+        "kicad_model_check" => eda.model_check(t, args),+        "kicad_status" | "kicad_version" => eda.status(t),+        "kicad_move_footprint" => eda.move_parts(t, args),+        "kicad_route_net" => eda.route_net(t, args),+        "kicad_remove_route" => eda.remove_items(t, args),+        "kicad_apply_zone_plan" => eda.apply_zone_plan(t, args),+        "kicad_routing_validate" => eda.routing_validate(t, args),+        "kicad_run_drc" => eda.full_drc(t, args),+        "kicad_open_board" => eda.open_board(t, args),+        "kicad_window_info" => eda.window_info(t, args),+        "kicad_send_key" => eda.send_key(t, args),+        "kicad_select_net" => eda.select_net(t, args),+        "kicad_select_clear" => eda.select_clear(t, args),+        "kicad_open_3d_viewer" => eda.open_3d(t, args),+        "kicad_close_3d_viewer" => eda.close_3d(t, args),+        "kicad_3d_view" => eda.tour_3d(t, args),+        "kicad_screenshot_all" => eda.screenshot_all(t, args),+        other => eda.passthrough(t, other, args),+    }+}++// -- KiCad ------------------------------------------------------------------------------------++/// KiCad through the KiCad bridge: the flow's verbs are its own, sent as they are.+pub struct KicadEda {+    pub board: String,+}++impl Eda for KicadEda {+    fn id(&self) -> &'static str { "kicad" }+    fn name(&self) -> &'static str { "KiCad" }+    fn board(&self) -> BoardRef { BoardRef::Kicad(self.board.clone()) }+    fn caps(&self) -> Caps {+        Caps { live_placement_state: true, live_placement_validate: true, full_drc: true, item_removal: true, keepout_zones: true,+               separate_3d_window: true, canvas_paint_probe: true, net_highlight: true, file_backed_board: true }+    }+    fn routing_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_routing_state", a) }+    fn placement_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_placement_state", a) }+    fn placement_validate(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_placement_validate", a) }+    fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_zone_state", a) }+    fn model_check(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_model_check", a) }+    fn status(&self, t: &dyn Transport) -> Value { t.send("kicad_status", &json!({})) }+    fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_move_footprint", a) }+    fn route_net(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_route_net", a) }+    fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_remove_route", a) }+    fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_apply_zone_plan", a) }+    fn routing_validate(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_routing_validate", a) }+    fn full_drc(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_run_drc", a) }+    fn open_board(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_open_board", a) }+    fn window_info(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_window_info", a) }+    fn send_key(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_send_key", a) }+    fn select_net(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_select_net", a) }+    fn select_clear(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_select_clear", a) }+    fn open_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_open_3d_viewer", a) }+    fn close_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_close_3d_viewer", a) }+    fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_3d_view", a) }+    fn screenshot_all(&self, t: &dyn Transport, a: &Value) -> Value { t.send("kicad_screenshot_all", a) }+    fn passthrough(&self, t: &dyn Transport, verb: &str, a: &Value) -> Value { t.send(verb, a) }+    /// The PCB editor showing this board (title holds the board's file stem); a cached window that is a+    /// PCB editor of another board is replaced by it. Other cached windows (3D viewer, Fields) are kept.+    fn camera_window(&self, t: &dyn Transport, _step: &str, cached: Option<i64>) -> Option<i64> {+        let r = t.send("kicad_window_info", &json!({}));+        let d = r.get("data").cloned().unwrap_or(r.clone());+        let eds: Vec<Value> = d.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default();+        let pcbs: Vec<&Value> = eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")).collect();+        let stem = std::path::Path::new(&self.board).file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();+        let title_of = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();+        let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| title_of(w).contains(&stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) };+        match cached {+            Some(h) => {+                let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !title_of(w).contains(&stem));+                if other_board { matching.or(Some(h)) } else { Some(h) }+            }+            None => matching.or_else(|| pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())),+        }+    }+    /// KiCad keeps its own windows in front; the flow never swaps to Hydrogen for it.+    fn hydrogen_for_step(&self, _step: &str) -> Option<bool> { None }+}++// -- Fusion -----------------------------------------------------------------------------------++/// Autodesk Fusion through the Fusion bridge (the translation lives in fusion.rs).+pub struct FusionEda {+    pub doc: String,+}++impl FusionEda {+    fn tr(&self, t: &dyn Transport, verb: &str, a: &Value) -> Value {+        crate::fusion::translate(verb, a, &self.doc, &|v: &str, x: &Value| t.send(v, x))+    }+}++impl Eda for FusionEda {+    fn id(&self) -> &'static str { "fusion" }+    fn name(&self) -> &'static str { "Fusion" }+    fn board(&self) -> BoardRef { BoardRef::Fusion(self.doc.clone()) }+    fn caps(&self) -> Caps {+        // Fusion removes copper a whole net at a time, its 3D is a mode of the main window, and a+        // SHOW highlight lasts only while the command stays open+        Caps { net_highlight: true, ..Caps::default() }+    }+    fn routing_state(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_routing_state", a) }+    fn zone_state(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_zone_state", a) }+    fn model_check(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_model_check", a) }+    fn status(&self, t: &dyn Transport) -> Value { self.tr(t, "kicad_status", &json!({})) }+    fn move_parts(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_move_footprint", a) }+    fn route_net(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_route_net", a) }+    fn remove_items(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_remove_route", a) }+    fn apply_zone_plan(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_apply_zone_plan", a) }+    fn routing_validate(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_routing_validate", a) }+    fn open_board(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_open_board", a) }+    fn window_info(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_window_info", a) }+    fn send_key(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_send_key", a) }+    fn select_net(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_select_net", a) }+    fn select_clear(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_select_clear", a) }+    fn open_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_open_3d_viewer", a) }+    fn close_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_close_3d_viewer", a) }+    fn tour_3d(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_3d_view", a) }+    fn screenshot_all(&self, t: &dyn Transport, a: &Value) -> Value { self.tr(t, "kicad_screenshot_all", a) }+    fn main_window(&self, t: &dyn Transport) -> Option<i64> { crate::fusion::main_hwnd(&|v: &str, x: &Value| t.send(v, x)) }+    fn fit_board(&self, t: &dyn Transport) -> bool { crate::fusion::fit_board(&self.doc, &|v: &str, x: &Value| t.send(v, x)) }+    /// `board-live.brd` (Fusion's own XML, fills included, what adom-fields reads) and+    /// `board-live.kicad_pcb` (the same board as the plan board).+    fn live_snapshot(&self, t: &dyn Transport, dir: &std::path::Path) -> Option<Result<(String, String), String>> {+        let ask = || t.send("fusion_board_source", &json!({"expectDocument": self.doc}));+        let mut reply = ask();+        for _ in 0..3 {+            if reply["errorCode"] != "main_thread_busy" && !reply["error"].as_str().unwrap_or("").contains("main thread busy") { break; }+            std::thread::sleep(std::time::Duration::from_secs(5));+            reply = ask();+        }+        let Some(text) = reply["source"].as_str().filter(|_| reply["success"].as_bool() == Some(true)) else {+            return Some(Err(format!("could not read the live Fusion board {}: {} (activate it in Fusion with fusion_show_2d_board; fusion_board_source needs bridge 1.11.17+)", self.doc, reply["error"])));+        };+        let brd = dir.join("board-live.brd");+        let kicad = dir.join("board-live.kicad_pcb");+        let plan = match aiflow_board::eagle::to_kicad(text) { Ok(p) => p, Err(e) => return Some(Err(format!("live Fusion board: {e}"))) };+        if let Err(e) = std::fs::write(&brd, text).and_then(|_| std::fs::write(&kicad, plan)) { return Some(Err(e.to_string())); }+        Some(Ok((brd.display().to_string(), kicad.display().to_string())))+    }+    /// MITER each corner (Fusion is y up: the plan frame's y is negated).+    fn round_corners(&self, t: &dyn Transport, corners: &[(f64, f64)], radius: f64) -> Value {+        let _ = t.send("fusion_show_2d_board", &json!({"documentName": self.doc}));+        let mut done = 0;+        for p in corners {+            let rep = t.send("fusion_electron_run", &json!({"command": format!("GRID MM; MITER {radius} ({:.4} {:.4});", p.0, -p.1)}));+            if rep["success"].as_bool() == Some(true) || (rep["status"].as_str() == Some("ok") && rep.get("success").is_none()) { done += 1; }+            std::thread::sleep(std::time::Duration::from_millis(500));+        }+        json!({"success": done == corners.len(), "rounded": done})+    }+    fn load_design_rules(&self, t: &dyn Transport, fab: &str, layers: u32, _dru: &str) -> Option<Value> {+        Some(t.send("fusion_set_design_rules", &json!({"fab": fab, "layers": layers})))+    }+    fn camera_window(&self, t: &dyn Transport, _step: &str, _cached: Option<i64>) -> Option<i64> {+        let h = self.main_window(t);+        // a project before its board exists has no board document to frame+        if !self.doc.is_empty() { self.fit_board(t); }+        h+    }+}++// -- the run's backend ------------------------------------------------------------------------++/// The backend for a board reference.+pub fn for_board(r: &BoardRef) -> Arc<dyn Eda> {+    match r {+        BoardRef::Kicad(p) => Arc::new(KicadEda { board: p.clone() }),+        BoardRef::Fusion(d) => Arc::new(FusionEda { doc: d.clone() }),+        BoardRef::Altium(p) => Arc::new(crate::altium::AltiumEda { path: p.clone() }),+    }+}++/// A fab rule pack (`.kicad_dru`, the flow's one source of fab numbers) as plain numbers, each+/// constraint's floor (the smallest minimum any of its rules names), in mm, for a backend that loads+/// rules as values: {clearance, trackWidth{min}, hole{min}, via{diameter{min}, drill{min}},+/// annularRing, holeToHole, edgeClearance}.+pub fn fab_numbers(dru: &str) -> Value {+    let mut floor: std::collections::BTreeMap<String, f64> = std::collections::BTreeMap::new();+    for part in dru.split("(constraint ").skip(1) {+        let kind: String = part.chars().take_while(|c| !c.is_whitespace() && *c != ')').collect();+        // a pack may write the hole floor as `hole` or `hole_size`+        let kind = if kind == "hole" { "hole_size".to_string() } else { kind };+        let Some(min) = part.split("(min ").nth(1).and_then(|r| r.split(')').next()) else { continue };+        let v = min.trim();+        let mm = if let Some(m) = v.strip_suffix("mil") { m.parse::<f64>().ok().map(|x| x * 0.0254) } else { v.trim_end_matches("mm").parse::<f64>().ok() };+        if let Some(mm) = mm { let e = floor.entry(kind).or_insert(mm); if mm < *e { *e = mm; } }+    }+    let g = |k: &str| floor.get(k).copied();+    let mut o = serde_json::Map::new();+    if let Some(v) = g("clearance") { o.insert("clearance".into(), json!(v)); }+    if let Some(v) = g("track_width") { o.insert("trackWidth".into(), json!({"min": v})); }+    if let Some(v) = g("hole_size") { o.insert("hole".into(), json!({"min": v})); }+    if g("via_diameter").is_some() || g("hole_size").is_some() {+        o.insert("via".into(), json!({"diameter": {"min": g("via_diameter").unwrap_or(0.45)}, "drill": {"min": g("hole_size").unwrap_or(0.2)}}));+    }+    if let Some(v) = g("annular_width") { o.insert("annularRing".into(), json!(v)); }+    // the floor AI Flow plans holes to when a pack names none (aiflow_pours::hole_to_hole): the tool's rule+    // must agree, or Altium's own 0.254 mm default flags vias the plan placed 0.2 mm apart+    o.insert("holeToHole".into(), json!(g("hole_to_hole").unwrap_or(0.2)));+    if let Some(v) = g("edge_clearance") { o.insert("edgeClearance".into(), json!(v)); }+    Value::Object(o)+}++/// A KiCad project file for a plan board that has none (a Fusion or Altium board rendered as the+/// plan): the fab's floors as KiCad's board-setup minimums and the Default netclass. Without it KiCad's+/// DRC on the plan judges by KiCad's own defaults (0.2 mm clearance, 0.5 mm board edge) while the router+/// and the tool's live rules use the fab's (measured 2026-10-15: an Altium run's gate refused 0.186 mm+/// via-to-track and 0.4 mm via-to-edge copper the fab allows).+pub fn plan_project_json(dru: &str, file_name: &str) -> String {+    let n = fab_numbers(dru);+    let f = |v: &Value, d: f64| v.as_f64().unwrap_or(d);+    let clearance = f(&n["clearance"], 0.2);+    let track = f(&n["trackWidth"]["min"], 0.2);+    let via_d = f(&n["via"]["diameter"]["min"], 0.6);+    let via_h = f(&n["via"]["drill"]["min"], 0.3);+    let edge = f(&n["edgeClearance"], 0.5);+    let hole = f(&n["hole"]["min"], 0.3);+    let h2h = f(&n["holeToHole"], 0.25);+    let ring = f(&n["annularRing"], 0.1);+    let v = json!({+        "board": {"design_settings": {"rules": {+            "min_clearance": clearance, "min_copper_edge_clearance": edge, "min_track_width": track,+            "min_via_diameter": via_d, "min_through_hole_diameter": hole, "min_hole_to_hole": h2h, "min_via_annular_width": ring}}},+        "meta": {"filename": file_name, "version": 1},+        "net_settings": {"classes": [{"name": "Default", "clearance": clearance, "track_width": track.max(0.2), "via_diameter": via_d.max(0.6), "via_drill": via_h.max(0.3),+            "diff_pair_gap": 0.25, "diff_pair_width": 0.2, "diff_pair_via_gap": 0.25, "microvia_diameter": 0.3, "microvia_drill": 0.1,+            "wire_width": 6, "bus_width": 12, "line_style": 0, "priority": 2147483647,+            "pcb_color": "rgba(0, 0, 0, 0.000)", "schematic_color": "rgba(0, 0, 0, 0.000)"}], "meta": {"version": 3}}+    });+    serde_json::to_string_pretty(&v).unwrap_or_default()+}++/// The plan board for `start`: the board file the human gave, rendered by the backend its+/// `--remote-board` names. Ok(None) = a KiCad board, copied as it is.+pub fn import_board(remote: Option<&str>, src_text: &str) -> Result<Option<String>, String> {+    let r = remote.map(BoardRef::parse);+    let eagle = aiflow_board::eagle::is_eagle(src_text);+    match r {+        Some(BoardRef::Fusion(_)) => aiflow_board::eagle::to_kicad(src_text).map(Some),+        _ if eagle => Err("a Fusion board needs --remote-board fusion:<exact board document name> (and --target <box>); export it with fusion_export_eagle_source {outputPath:\"...brd\"}, pull it here, and name the open board document: --remote-board fusion:Buck-brd".into()),+        Some(BoardRef::Altium(_)) => aiflow_board::altium::render_plan_board(src_text).map(Some),+        _ => Ok(None),+    }+}++/// The backend a run's project directory says it is building in before any board exists+/// (`<run>/<backend>/project.json`, written by that backend's project workflow).+pub fn project_backend(dir: &std::path::Path) -> Option<&'static str> {+    ["fusion", "altium"].into_iter().find(|b| dir.join(b).join("project.json").is_file())+}++static RUN: Mutex<Option<Arc<dyn Eda>>> = Mutex::new(None);++/// Set the run's backend from its board reference (`remoteBoard`). A KiCad run with no board yet gets+/// the KiCad backend.+pub fn set_run_board(board: &str) {+    let r = BoardRef::parse(board);+    if let BoardRef::Fusion(_) = r { crate::fusion::set_run_board(board); }+    *RUN.lock().unwrap() = Some(for_board(&r));+}++/// The run's backend: the one set from the run's board, else KiCad.+pub fn active() -> Arc<dyn Eda> {+    RUN.lock().unwrap().clone().unwrap_or_else(|| Arc::new(KicadEda { board: String::new() }))+}++/// The backend a single call is addressed to: a `filePath` naming another tool's board wins (a+/// verb may name its board explicitly), else the run's.+pub fn for_call(args: &Value) -> Arc<dyn Eda> {+    match args["filePath"].as_str().map(BoardRef::parse) {+        Some(r @ BoardRef::Fusion(_)) | Some(r @ BoardRef::Altium(_)) => for_board(&r),+        _ => active(),+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use std::cell::RefCell;++    #[test]+    fn board_refs_parse_and_round_trip() {+        for s in ["C:/b/buck.kicad_pcb", "fusion:Buck-brd", "altium:C:/b/buck.PcbDoc"] {+            assert_eq!(BoardRef::parse(s).to_ref_string(), s);+        }+        assert_eq!(BoardRef::parse("fusion:X"), BoardRef::Fusion("X".into()));+        assert_eq!(BoardRef::parse("altium:C:/x.PcbDoc"), BoardRef::Altium("C:/x.PcbDoc".into()));+    }++    #[test]+    fn kicad_backend_sends_the_verbs_unchanged() {+        let sent = RefCell::new(Vec::new());+        let t = |v: &str, a: &Value| { sent.borrow_mut().push((v.to_string(), a.clone())); json!({"success": true}) };+        let k = KicadEda { board: "C:/b.kicad_pcb".into() };+        for verb in ["kicad_routing_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route", "kicad_apply_zone_plan", "kicad_run_drc", "kicad_select_net"] {+            dispatch(&k, &t, verb, &json!({"filePath": "C:/b.kicad_pcb"}));+        }+        let verbs: Vec<String> = sent.borrow().iter().map(|(v, _)| v.clone()).collect();+        assert_eq!(verbs, ["kicad_routing_state", "kicad_move_footprint", "kicad_route_net", "kicad_remove_route", "kicad_apply_zone_plan", "kicad_run_drc", "kicad_select_net"]);+    }++    #[test]+    fn a_fab_pack_reads_as_its_floors() {+        let dru = "(rule a (constraint track_width (min 0.127mm)))\n(rule b (constraint hole_size (min 0.3mm)))\n(rule c (constraint hole_size (min 0.5mm)))\n(rule d (constraint edge_clearance (min 0.3mm)))\n(rule e (constraint clearance (min 5mil)))";+        let n = fab_numbers(dru);+        assert_eq!(n["trackWidth"]["min"], json!(0.127));+        assert_eq!(n["hole"]["min"], json!(0.3));+        assert_eq!(n["edgeClearance"], json!(0.3));+        assert!((n["clearance"].as_f64().unwrap() - 0.127).abs() < 1e-9);+    }++    #[test]+    fn a_missing_operation_is_loud() {+        let t = |_: &str, _: &Value| json!({"success": true});+        let f = FusionEda { doc: "Buck-brd".into() };+        let r = dispatch(&f, &t, "kicad_run_drc", &json!({}));+        assert_eq!(r["success"], json!(false));+        assert_eq!(r["errorCode"], json!("unsupported_in_fusion"));+        assert!(!f.caps().full_drc);+    }+}
crates/aiflow-bridge/src/lib.rs+11−34
@@ -7,6 +7,8 @@ use std::process::Command;  use serde_json::{json, Value}; +pub mod altium;+pub mod eda; pub mod fusion; pub mod landing; pub use landing::Landing;@@ -118,11 +120,11 @@ pub struct Bridge {  impl Bridge {     pub fn call(&self, verb: &str, args: &Value) -> Value {-        // the Fusion lane: a run whose board is `fusion:<doc>` answers every kicad_* verb through-        // the Fusion bridge (see fusion.rs)+        // every board operation goes to the run's EDA backend (eda.rs; docs/eda-backends.md): the flow's+        // verbs keep their historical kicad_* names, and the backend answers them in its own tool         if verb.starts_with("kicad_") {-            let doc = args["filePath"].as_str().and_then(fusion::doc_of).or_else(fusion::active);-            if let Some(doc) = doc { return fusion::translate(verb, args, &doc, &|v, a| self.raw_call(v, a)); }+            let backend = eda::for_call(args);+            return eda::dispatch(&*backend, &|v: &str, a: &Value| self.raw_call(v, a), verb, args);         }         self.raw_call(verb, args)     }@@ -204,37 +206,12 @@ impl Bridge {         Some(dest)     } -    /// The PCB editor's window handle, if KiCad has one open.-    /// Fusion's main window on the box (a Fusion project films it before the board exists too).-    pub fn fusion_hwnd(&self) -> Option<i64> { fusion::main_hwnd(&|v, a| self.raw_call(v, a)) }+    /// Hydrogen's main window on the box (the human's workspace: chat, webviews).+    pub fn hydrogen_hwnd(&self) -> Option<i64> { fusion::hydrogen_hwnd(&|v, a| self.raw_call(v, a)) } -    /// Bring Fusion or Hydrogen to the front, so the human (and a screen recording) sees the window the-    /// step works in: Fusion for the schematic and the board, Hydrogen for its webviews and the chat.-    pub fn front(&self, which: &str) -> bool {-        let h = if which == "fusion" { self.fusion_hwnd() } else { fusion::hydrogen_hwnd(&|v, a| self.raw_call(v, a)) };-        match h { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {which} for this step")}))["success"].as_bool().unwrap_or(true), None => false }-    }--    /// Frame the live Fusion board by its outline.-    pub fn fusion_fit_board(&self) -> bool { match fusion::active() { Some(d) => fusion::fit_board(&d, &|v, a| self.raw_call(v, a)), None => false } }--    /// The PCB editor showing this board (title holds the board's file stem); a cached window that is a-    /// PCB editor of another board is replaced by it. Other cached windows (3D viewer, Fields) are kept.-    pub fn editor_for_board(&self, board: &str, cached: Option<i64>) -> Option<i64> {-        let r = self.call("kicad_window_info", &json!({}));-        let d = r.get("data").cloned().unwrap_or(r.clone());-        let eds: Vec<Value> = d.get("editors").and_then(|e| e.as_array()).cloned().unwrap_or_default();-        let pcbs: Vec<&Value> = eds.iter().filter(|w| w.get("kind").and_then(|k| k.as_str()) == Some("pcb_editor")).collect();-        let stem = std::path::Path::new(board).file_stem().and_then(|s| s.to_str()).unwrap_or("").to_string();-        let title_of = |w: &Value| w.get("title").and_then(|t| t.as_str()).unwrap_or("").to_string();-        let matching = if stem.is_empty() { None } else { pcbs.iter().find(|w| title_of(w).contains(&stem)).and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64()) };-        match cached {-            Some(h) => {-                let other_board = pcbs.iter().any(|w| w.get("hwnd").and_then(|x| x.as_i64()) == Some(h) && !stem.is_empty() && !title_of(w).contains(&stem));-                if other_board { matching.or(Some(h)) } else { Some(h) }-            }-            None => matching.or_else(|| pcbs.first().and_then(|w| w.get("hwnd")).and_then(|h| h.as_i64())),-        }+    /// Bring one window to the front, named in the reason.+    pub fn bring_front(&self, hwnd: Option<i64>, what: &str) -> bool {+        match hwnd { Some(h) => self.raw_call("desktop_bring_to_front", &serde_json::json!({"hwnd": h, "reason": format!("show {what} for this step")}))["success"].as_bool().unwrap_or(true), None => false }     }      pub fn pcb_editor_hwnd(&self) -> Option<i64> {
crates/aiflow-bridge/tests/altium_live.rsadded+60
@@ -0,0 +1,60 @@+//! Live: the Altium backend through the real Bridge against a SCRATCH board open in Altium on a box.+//! AIFLOW_ALTIUM_LIVE=<absolute .PcbDoc path on the box> AIFLOW_TARGET=<box> cargo test -p aiflow-bridge --test altium_live -- --ignored --nocapture+//! It moves a part and back, routes a short track with a via, removes them, and reads the pours.+use aiflow_bridge::Bridge;+use serde_json::{json, Value};++#[test]+#[ignore]+fn altium_backend_round_trip() {+    let path = std::env::var("AIFLOW_ALTIUM_LIVE").expect("AIFLOW_ALTIUM_LIVE");+    let target = std::env::var("AIFLOW_TARGET").unwrap_or("DESKTOP-BAR6S0F".into());+    let board = format!("altium:{path}");+    aiflow_bridge::eda::set_run_board(&board);+    let br = Bridge { ai_thread: "aiflow-altium-live-test".into(), target };+    let st = br.call("kicad_routing_state", &json!({"filePath": board}));+    assert_eq!(st["success"], json!(true), "{st}");+    let rev = st["revision"].as_str().unwrap().to_string();+    eprintln!("read: {} segments, {} vias, {} pads, unconnected {}", st["segments"].as_array().unwrap().len(), st["vias"].as_array().unwrap().len(), st["pads"].as_array().unwrap().len(), st["unconnected"]);++    // move the first part by +1 mm x (plan frame) and back+    let raw = br.raw_call("altium_routing_state", &json!({"expectPath": path}));+    let c = raw["components"].as_array().unwrap().iter().find(|c| c["side"] == "top").unwrap().clone();+    let (x, y, rot) = (c["x"].as_f64().unwrap(), -c["y"].as_f64().unwrap(), c["rotation"].as_f64().unwrap());+    let m = br.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": rev, "refs": [{"ref": c["ref"], "x": x + 1.0, "y": y, "rotation": rot}]}));+    assert_eq!(m["success"], json!(true), "{m}");+    let back = br.call("kicad_move_footprint", &json!({"filePath": board, "expectedRevision": m["revision"], "refs": [{"ref": c["ref"], "x": x, "y": y, "rotation": rot}]}));+    assert_eq!(back["success"], json!(true), "{back}");++    // a stale revision is refused+    let stale = br.call("kicad_route_net", &json!({"filePath": board, "expectedRevision": "0000", "net": "GND", "paths": [[[0.0, 0.0], [1.0, 0.0]]]}));+    assert_eq!(stale["errorCode"], json!("stale_board"), "{stale}");++    // a short GND track, then a via down to B.Cu, inside the board's lower-left corner (plan frame: y down)+    let pts: Vec<(f64, f64)> = raw["outline"].as_array().unwrap().iter().map(|p| (p[0].as_f64().unwrap(), -p[1].as_f64().unwrap())).collect();+    let (x0, y0) = (pts.iter().map(|p| p.0).fold(f64::MAX, f64::min) + 3.0, pts.iter().map(|p| p.1).fold(f64::MIN, f64::max) - 3.0);+    let rt = br.call("kicad_route_net", &json!({"filePath": board, "expectedRevision": back["revision"], "net": "GND", "width": 0.3, "layer": "F.Cu",+        "paths": [[[x0, y0], [x0 + 2.0, y0], {"x": x0 + 2.0, "y": y0 - 1.0, "layer": "B.Cu"}]]}));+    assert_eq!(rt["success"], json!(true), "{rt}");+    let after = br.call("kicad_routing_state", &json!({"filePath": board}));+    let near = |a: f64, b: f64| (a - b).abs() < 0.002;+    let mine: Vec<Value> = after["segments"].as_array().unwrap().iter().chain(after["vias"].as_array().unwrap()).filter(|o| {+        let s = &o["start"]; let p = &o["position"];+        (s.is_object() && near(s["y"].as_f64().unwrap(), y0) && near(s["x"].as_f64().unwrap(), x0))+        || (s.is_object() && near(s["x"].as_f64().unwrap(), x0 + 2.0) && near(s["y"].as_f64().unwrap(), y0))+        || (p.is_object() && near(p["x"].as_f64().unwrap(), x0 + 2.0) && near(p["y"].as_f64().unwrap(), y0 - 1.0))+    }).map(|o| o["uuid"].clone()).collect();+    eprintln!("landed ids: {mine:?}");+    assert_eq!(mine.len(), 3, "2 tracks + 1 via in the plan frame where they were asked for");+    let rm = br.call("kicad_remove_route", &json!({"filePath": board, "expectedRevision": after["revision"], "itemIds": mine}));+    assert_eq!(rm["success"], json!(true), "{rm}");+    let end = br.call("kicad_routing_state", &json!({"filePath": board}));+    assert_eq!(end["segments"].as_array().unwrap().len(), st["segments"].as_array().unwrap().len());+    assert_eq!(end["vias"].as_array().unwrap().len(), st["vias"].as_array().unwrap().len());++    let z = br.call("kicad_zone_state", &json!({"filePath": board, "refill": false}));+    assert_eq!(z["success"], json!(true), "{z}");+    eprintln!("zones: {}", z["filledAreaByLayerMm2"]);+    let v = br.call("kicad_routing_validate", &json!({"filePath": board}));+    eprintln!("validate: unconnected {} errors {}", v["unconnected"], v["errors"]);+}
crates/aiflow-router/src/lib.rs+4−1
@@ -819,7 +819,10 @@ impl Router {             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));+            // the net's own clearance (spec netClearance, which the gate's hint tells the AI to raise) and+            // the same diagonal grid margin as tracks: with the rule clearance alone a via landed 0.186 mm+            // from another net's track under a 0.2 mm rule, and raising netClearance could not move it+            via_block.or_with(&sub.dilate(&hr, (clr + via / 2.0 + slop() + 0.0707) / 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)) {
docs/altium-lane.mdadded+72
@@ -0,0 +1,72 @@+# The Altium lane++AI Flow runs a board that lives in Altium Designer with the same steps, the same gates and the same+ledger as a KiCad or Fusion board. The binary keeps its offline work in KiCad form (the router, the pour+planner, the KiCad DRC gate, the field solve); every live step lands in Altium through the Altium bridge+(`adom/altium-bridge`, verbs `altium_*`), one edit at a time, each its own undo step, each read back from+the live board before the next one goes. Altium Designer must be installed, licensed and open on the+desktop (`altium_status` says `licensed: true`).++## The whole project, by command++`adom-aiflow altium <step>` does the Altium side of a project the same way every time (`--help` on each):++| step | what it does |+|---|---|+| `altium library --parts <netlist.json> --footprints <folder> --name <Project>` | ONE project library (a `.SchLib` and a `.PcbLib`): each part from its wiki component page (footprint and symbol converted from the page's KiCad files, the STEP bound), MPN, Manufacturer and the Mouser, DigiKey and LCSC numbers checked against the exact MPN. Board-only pads with no MPN (test points) take their footprint from `--footprints`. Run again with new parts and they join the same library; `--only <MPN>` imports one part again. |+| `altium reference --kicad <board.kicad_pcb> --spec <spec.json>` | A reference design's placement and spec coordinates in the Altium board's frame: `moves.json`, `fixed.json`, `spec.json` in `<run>/altium/`. |+| `altium schematic --netlist <netlist.json> --name <Design>` | The schematic drawn from the netlist on Altium's own sheet template (one column per `block`, every connected pin with a wire and a net label, the title block filled), inside a new project (`<Design>.PrjPcb` with the board, the libraries and the manufacturing OutJob), compiled and checked pin by pin against the netlist. Then the board in the same project: the outline, the fixed parts where the spec puts them, every other part parked below the board, every pad on its net, the run's fab rules loaded. Writes `<run>/altium/<Design>.altium.json` for `start`. |+| `altium board --netlist <netlist.json> --name <Design>` | The board alone, as above (no schematic). |+| `altium silkscreen [--title "<board>"]` | The silkscreen planned by the planner the lanes share (`tools/silkscreen-plan.py`) and landed: part names beside their parts, machine contacts framed with their nets on the edge side, values, leaders where a label has to go further out, the board identity on the bottom. Checked on Altium's own measure of every printed label: clear of copper, vias, the board edge and each other. What it cannot fit is listed, never dropped silently. |+| `altium view3d` | The board in Altium's 3D view: a still saved in the run (every part must sit flat on its pads) and a slow orbit for the recording. |+| `altium export` | The manufacturing set from the project's OutJob: Gerbers (zipped), NC drill, pick and place (CSV, mm) and the BOM with every distributor number, plus the JLCPCB BOM and CPL; pulled into `<run>/manufacturing/` and checked against the board (every layer a fab needs, drill hits = vias + drilled pads, every placed part in the pick and place and the BOM, test points out, machine parts DNP). |+| `altium reset` | A refine pass: every track, via and pour cleared from the live board; the parts stay. |++## Starting the board++After `altium schematic`, attach the board to the run (same `--run` as the intake):++```+adom-aiflow start --board <run>/altium/<Design>.altium.json --spec <run>/altium/spec.json \+  --engine <you> --target <box> --remote-board altium:<the .PcbDoc path on the box>+```++- The `.altium.json` is the bridge's read of the board; the run renders it as the plan board+  (`board-0.kicad_pcb`: parts, pads, nets, outline, copper, pours with Altium's own fill). Altium's Y axis+  points up and KiCad's down, so every point is written as (x, -y).+- `--remote-board altium:<path>` routes every live verb the flow sends to the Altium bridge for that+  board. The board must be the current document on the box (`altium_show_2d_board {filePath}`).++## What each step does in Altium++| step | in Altium |+|---|---|+| `rules use` | before the board exists the choice is recorded; `altium schematic` loads it into the new board (`altium_set_design_rules`) |+| `place land` | `altium_move_parts`, read back to 0.001 mm |+| `route` | plans on a fresh read of the live board, so it routes around copper already there |+| `land route` | `altium_route_net`: tracks and vias per net, the arriving segment on its layer, a via where the next point names another layer |+| `thermal-vias land`, `land vias` | `altium_place_vias` |+| `land pours` | `altium_apply_pours`: one polygon per pour and layer with its own connect style (a PolygonConnect rule scoped to that polygon: solid joints for power patches, relief spokes for ground), a small solid zone over a pad becomes a solid joint for that pad inside its host pour, pour keepouts become polygon cutouts; then a Repour All outside the script engine (scoped rules only apply there) |+| `measure` | `altium_pour_state`: Altium's own poured regions, area per layer |+| `analyze`, `finish` | `altium_routing_state` + `altium_pour_state`: the live board saved as `board-live.altium.json` and rendered as `board-live.kicad_pcb`; the field screen and the finish checks judge that board |+| `outline round` | `altium_set_outline` with the corner radius |+| `tour nets` | `altium_select_net`: each net selected as a whole and framed |+| live validate in `finish` | `altium_routing_drc`: Altium's own DRC; unconnected is computed from the copper |++A failing field screen names the neck and its fix. The solve runs on the snapshot `analyze` just wrote:+`analyze current` (it writes `board-live.kicad_pcb`), then `adom-aiflow exec -- adom-fields analyze+--board <run>/board-live.kicad_pcb --spec <spec> --out <run>/fields`, then `analyze current` again.++## Facts about Altium the lane relies on++- Altium evaluates a rule's custom query through its scripting system, so while the bridge's script is+  running every scoped rule matches nothing. Repours that must honour scoped rules (each pour's connect+  style, net-class clearances) run outside the script; `altium_repour` and `altium_apply_pours` do this+  themselves (the bridge stops and restarts its agent for a few seconds).+- A same-net polygon inside another pours nothing in Altium, and Altium drops connected copper islands+  under 2500 sq mil by default: the lane turns joint zones into connect rules and keeps small islands.+- The project's OutJob must belong to the project, or it writes into "Free Documents".+- Altium's stroke font is measured per lane (`TEXT_W`, `TEXT_H` in the planner): a character advances up+  to 0.95 x the height and the stroke (a seventh of the height) pads every side.++On an Altium run every step's clip films Altium's main window, the board fitted to the view first.
docs/eda-backends.mdadded+62
@@ -0,0 +1,62 @@+# EDA backends++AI Flow runs one flow (the same steps, gates, ledger and clock) on any EDA tool. Each tool is a+**backend**: KiCad, Fusion and Altium today; EasyEDA, OrCAD or Adom's own web apps next. A backend+is the only code that knows a tool. Nothing outside `crates/aiflow-bridge/src/eda/` may branch on+which tool a run uses: it asks the run's backend.++## What a backend owns++| Area | Operations |+|---|---|+| Identity | `id` (`kicad`, `fusion`, `altium`), `name` (for the timeline and reports), the board reference it parses (`<path>.kicad_pcb`, `fusion:<doc>`, `altium:<path>.PcbDoc`), `caps()` |+| Live reads | routing state (copper, pads, revision, unconnected), placement state, zone (pour) state, model check, status/version |+| Edits | move parts, route a net (vias at layer changes), remove items, apply a zone plan. Each is one undo step in the tool, guarded by the revision the flow last read, and read back |+| Checks | the live routing check (unconnected + errors), the tool's own full DRC |+| View and capture | open the board, the windows to film, fit the view, highlight a net, the 3D view and its tour, whether the canvas painted |+| Board files | render the tool's native board as the plan board, snapshot the live board for analysis |++## The plan board++The router, the pour planner, the gate and the analysis work on a **plan board**: a+`.kicad_pcb`-format file in the run directory. The format is an intermediate representation, not+the KiCad backend's board: it is open, complete and already parsed by `aiflow-board`. Every+backend renders its live board into it (`render_plan_board`, `live_snapshot`) and lands plans back+through its own edits. For KiCad the render is a copy.++Coordinates in every backend call are the plan frame: millimetres, y down, layers `F.Cu`/`B.Cu`+(`In<n>.Cu` inner). A backend converts to its tool's frame internally (Fusion and Altium are y up).++## Capabilities++A step that needs something a backend cannot do checks `caps()` and records the gap in the run+("skipped: Fusion has no full DRC through the bridge"). It never passes silently. Today's flags:+`live_placement_state`, `live_placement_validate`, `full_drc`, `item_removal` (per item, not just+whole nets), `keepout_zones`, `separate_3d_window`, `canvas_paint_probe`, `net_highlight`,+`file_backed_board` (the native board is a file the flow can hash: remote binding, provenance,+verify-pours, place verify).++## Phases++1. **The seam.** `trait Eda` with KiCad and Fusion behind it; `Bridge` sends every board operation+   through the run's backend. No behaviour change; the tests prove it.+2. **The call sites.** main.rs's lane branches (`fusion::active()`, `fusion_live`, `is_eagle`,+   `sequence::lane()`, the camera picker, finish's provenance and outline reads, `rules use`,+   `models`) become backend calls. `run.data.eda` records the backend explicitly instead of+   guessing it from the brief.+3. **Honest gaps.** Steps that silently pass on a backend without the capability (finish's DRC on+   Fusion, the faked paint probe) record the gap instead.+4. **Altium.** `AltiumEda` on the Altium bridge's live-board verbs (`altium_routing_state`,+   `altium_route_net`, `altium_move_parts`, `altium_remove_items`, `altium_apply_pours`,+   `altium_pour_state`, `altium_routing_drc`, ...) and an Altium plan-board renderer.+5. **Project workflows.** Library, schematic, export, silkscreen and 3D per backend+   (`adom-aiflow fusion ...` today, `adom-aiflow altium ...` next) behind one `adom-aiflow project+   <step>` command, so the flow's steps name the operation and the backend picks the tool.++## Adding a backend++1. Give the tool a bridge with live-board verbs (read with a revision, guarded edits, a check).+2. Add `eda/<tool>.rs` implementing `Eda`, with its frame conversion and its `caps()`.+3. Add its board-reference prefix to `BoardRef::parse` and its renderer to `render_plan_board`.+4. Add its project workflow (library, schematic, export) and its `sequence` in `flows/board.json`.+5. Run the offline translate tests (a scripted transport, see `fusion.rs` tests) and one live run.
docs/release-0.1.99.mdadded+43
@@ -0,0 +1,43 @@+# 0.1.99: EDA backends, and the Altium lane++AI Flow ran KiCad natively and reached Fusion 360 by translating `kicad_*` calls inside the Bridge client. A third tool would have meant a third set of translations scattered through `main.rs`. This release puts every board tool behind one interface, and adds Altium Designer as its third backend.++## EDA backends (docs/eda-backends.md)++- An `Eda` trait in `aiflow-bridge` (board reference, capabilities, live snapshot, outline read and round, design rules, provenance and binding, step camera, board import). `KicadEda`, `FusionEda` and `AltiumEda` implement it, and `Bridge::call` dispatches through the run's backend.+- `main.rs` asks the run's backend instead of branching on the tool. A missing capability, such as a full DRC, is recorded in `finish` as a capability gap rather than passed silently.+- KiCad and Fusion behave as before. The Fusion planner gives an identical plan on the crossbar board, and the workspace tests pass.++## The Altium lane (docs/altium-lane.md)++`adom-aiflow altium <step>` runs the Altium side of a project, and every live step lands through the Altium bridge (`adom/altium-bridge`, `altium_*` verbs). Each edit is its own undo step, and each one is read back before the next goes.++- `library`: one project library. Each part comes from its wiki page, with symbol and footprint converted from the KiCad files, the STEP model bound, and distributor numbers checked against the exact MPN. Passives keep their standard KiCad drawing. `--only <MPN>` re-imports a single part.+- `schematic`: drawn from the netlist on Altium's own sheet template inside a new project (board, libraries, manufacturing OutJob), compiled and checked pin for pin against the netlist. The board follows in the same project with the run's fab rules loaded.+- `board`, `reference`, `reset`, `view3d`.+- `silkscreen`: placed by the planner the lanes now share (`tools/silkscreen-plan.py`, moved from the Fusion lane), and checked against Altium's own measure of every printed label.+- `export`: Gerbers, NC drill, pick and place and BOM, taken from the project's OutJob and checked against the board (layers, drill hits, placed parts, DNP), plus the JLCPCB BOM and CPL.++The flow's steps map onto Altium as follows:++- Placement, routing and vias land with `altium_move_parts`, `altium_route_net` and `altium_place_vias`.+- Pours land with a connect style per pour; small solid zones become solid joints and keepouts become cutouts. They repour outside Altium's script engine, because scoped rules apply only there.+- `outline round`, `tour nets` and the live DRC run in Altium. Silkscreen and solder-mask findings are graded as warnings, the same way KiCad grades them.+- `analyze` and `finish` judge the live Altium board, rendered as the plan board (`board-live.kicad_pcb`).++The Altium lane needs altium-bridge 0.2.47 or later, for the board-edit, pour, schematic, silkscreen and manufacturing verbs.++## Other fixes++- Lane scripts run from a temp file. `python3 -c` caps a script at 128 KiB, so the 151 KB Fusion lane never started ("Argument list too long").+- The KiCad DRC gate runs on the run's own desktop by default, rather than one office machine (`KICAD_REMOTE_TARGET` comes from the run).+- Router (every lane): vias keep the net's own clearance (spec `netClearance`) plus the same diagonal grid margin tracks get. With the rule clearance alone, a via landed 0.186 mm from another net's track under a 0.2 mm rule, and raising `netClearance`, as the gate's hint says, could not move it.+- The hole-to-hole floor is read under either rule name and defaults to the planner's 0.2 mm.+- `adom-fields` is declared as a dependency.++## Validation++- Workspace tests and a production `cargo check --workspace --bins` pass.+- The Altium round trip (`crates/aiflow-bridge/tests/altium_live.rs`: read, move, stale refusal, route and via, item removal, pours, validate) passes on Altium Designer 26.11.1.+- A full rehearsal of the 12 V to 5 V TPS54202 buck in Altium ran end to end: intake through finish, with export checked on a separate project.+- Not yet run on hardware: an unattended, context-free AI thread taking the whole Altium lane. That is the recording take that comes next.
flows/board.json+24
@@ -316,6 +316,29 @@   "screenshots": "every step visit gets two background screenshots of the editor window, at its start and at its end (shot-<step>-<visit>-start.png, shot-<step>-<visit>-end.png), logged as artifacts, so a run's own README has a picture for every step without anyone taking one",   "sequence": {     "_": "The required steps in order, per lane. `step <x>` refuses to skip a required step before x; a return (`step <k> --back`) makes every later required step a redo; finish refuses until the chain is in order and each step's proof is recorded during its last visit.",+    "altium": [+      "intake",+      "rules",+      "design",+      "test-plan",+      "sourcing",+      "library",+      "schematic",+      "simulation",+      "placement",+      "routing",+      "thermal-vias",+      "pours",+      "outline",+      "current",+      "thermal",+      "fields",+      "nets",+      "silkscreen",+      "3d",+      "finish",+      "export"+    ],     "fusion": [       "intake",       "rules",@@ -541,6 +564,7 @@     ]   },   "finishLine": {+    "altium": "Gerbers, BOM and CPL exported",     "fusion": "Gerbers, BOM and CPL exported",     "kicad": "Gerbers, BOM and CPL exported"   }
package.json+6−3
@@ -1,9 +1,9 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.98",+  "version": "0.1.99",   "title": "AI Flow",-  "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",+  "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",   "brief": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.",   "category": "Developer Tools",@@ -28,6 +28,8 @@     "adom-aiflow",     "ai flow",     "aiflow",+    "altium designer board",+    "design a board in altium",     "board flow",     "board loop",     "compare engines on a board",@@ -46,7 +48,8 @@     "Help me contribute an improvement to adom-aiflow through a pull request"   ],   "dependencies": {-    "adom/adom-lbr": "*"+    "adom/adom-lbr": "*",+    "adom/adom-fields": "*"   },   "scripts": {     "install": "./install.sh",
page.json+4−2
@@ -1,9 +1,9 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.98",+  "version": "0.1.99",   "title": "AI Flow",-  "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad today; Altium, Fusion and Adom's own web apps next.",+  "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",   "brief": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.",   "category": "Developer Tools",@@ -28,6 +28,8 @@     "adom-aiflow",     "ai flow",     "aiflow",+    "altium designer board",+    "design a board in altium",     "board flow",     "board loop",     "compare engines on a board",
skills/adom-aiflow/SKILL.md+41−2
@@ -1,7 +1,7 @@ --- name: adom-aiflow description: >--  The board loop as one command for KiCad boards on the Adom KiCad Bridge and for Autodesk Fusion boards on the Fusion bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time.+  The board loop as one command for KiCad boards on the Adom KiCad Bridge, Autodesk Fusion boards on the Fusion bridge and Altium Designer boards on the Altium bridge (one project library with real 3D and part numbers, the schematic drawn live, the board, current and heat refined until it passes, the Gerbers and BOM): start a measured run from the human's prompt, plan the stages and say which ones you take yourself (placement, routing) and which the binary runs (router, pours, gate, landing, measurement, current and thermal analysis), then finish only when the board is 100 percent routed, DRC-clean, poured, analysed and measured. One clock from the prompt to the finish line and one manifest per run, so Claude, Codex and any other engine compare on the same flow. Every command answers OK or ERROR with Hint lines that depend on this board and this run. Trigger words: board in Altium, Altium Designer, build it in Altium, Altium schematic, Altium PCB, Altium project, PcbDoc, board in Fusion, build it in Fusion, Fusion electronics, Fusion library for my project, Fusion schematic, Fusion PCB, aiflow, adom-aiflow, place and route the board, route the board to 100%, copper pours, current density, thermal analysis, finish the board, compare engines on a board, board loop, from placement to fab, kelvin keepouts, ablation copper, measured run, prompt-to-board time. ---  # adom-aiflow: the loop, measured@@ -71,7 +71,8 @@ outline, current, thermal, fields, nets, silkscreen, 3d, finish, export). `step required step before x is not done, and a step is done only when its own command ran during its latest visit (the `proofs` in the flow file: `rules use` for rules, `testplan check` for the test plan, `land moves` for placement, `thermal-vias land`, `outline round`, `tour nets`, `silkscreen-audit --record`-(Fusion: `fusion silkscreen`), `export` (Fusion: `fusion export`) ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it+(Fusion: `fusion silkscreen`; Altium: `altium silkscreen`), `export` (Fusion: `fusion export`; Altium:+`altium export`), `fusion|altium library` and `fusion|altium schematic` ...). Going back is a return: `step <k> --back --why "<what has to change>"`. It is filmed, it draws an arc on the timeline, and every later step becomes a redo that must be done again in order. `finish` refuses until the whole chain is in order. If a step truly does not apply, ask the human; never jump past it.@@ -163,6 +164,44 @@ pages publish Z-up, seated, centred STEPs, checked with `step2glb features`, and every model again the same way. Read the `fusion view3d` still before you call the board done, and fix a wrong part at its component page, not just on this board. +## A board in Altium Designer++When the human asks for a board in Altium ("a 12 V to 5 V buck converter using the TPS54202, in Altium+Designer"), the whole flow below is yours: they should never have to spell out a step. Read+`docs/altium-lane.md` once. Altium must be open and licensed on the desktop (`altium_status`). Keep+Altium windowed beside Hydrogen the same way as Fusion (above), and bring Altium to the front for+everything drawn on the design. The lane's steps are the Fusion lane's, with `altium` in place of+`fusion`:++1. **Intake and the timeline.** `intake --target <box> ...`, `timeline open`.+2. **Design rules.** Ask which fab builds the board; `rules use --profile <pack> [--layers 2]`. Before+   the board exists the choice is recorded; the board `altium schematic` builds carries it.+3. **The circuit** (aiflow-circuit-design), **the test plan** (aiflow-test-plan, `testplan check`),+   **the parts** from their wiki component pages (aiflow-sourcing). The netlist is `netlist.json`:+   `components` (ref, mpn, value, block, page or fp) and `nets` (`{"VIN": ["U1.3", "C1.1"]}`).+4. **One project library in Altium.** `altium library --parts <netlist.json> --footprints <folder of+   .kicad_mod for board-only pads> --name <Project>`.+5. **A reference board**, when the design has one: `altium reference --kicad <board.kicad_pcb> --spec+   <spec.json>` (placement and spec in Altium's frame; use its `spec.json` and `moves.json` from here on).+6. **The schematic, then the board.** `altium schematic --netlist <netlist.json> --name <Design>`: the+   sheet drawn and compiled pin for pin, the board built in the same project with the fab's rules.+   Show the sheet (`altium_show_schematic`).+7. **SPICE**, as on the Fusion lane (`step simulation`, the Adom SPICE dashboard, `evidence add --kind+   simulation`).+8. **Placement, then routing.** `start --board <run>/altium/<Design>.altium.json --spec <spec.json>+   --engine <you> --target <box> --remote-board altium:<the .PcbDoc path>`, then `rules use` again (it+   now loads into the board), `place check --moves` / `place land --moves`, `route`, `gate`, `land route`.+9. **Heatsink vias, pours, rounded corners**: `thermal-vias plan` / `land`, `pour`, `land vias`, `land+   pours`, `outline round`.+10. **Current, heat and fields.** `measure`, `analyze current`, `analyze thermal`; the field solve on the+    snapshot analyze wrote (`adom-fields analyze --board <run>/board-live.kicad_pcb ...`), then analyze+    again; adom-fields in the right pane and `tour fields`. A neck is a return to the step that causes+    it (placement, or a wider net in `wideNets`), on camera.+11. **The pour walkthrough.** `step nets`, `tour nets`.+12. **Silkscreen.** `altium silkscreen --title "<board>"`.+13. **3D, finish, export.** `altium view3d` (read the still), `finish`, then `altium export`; answer+    with each file's path. That is the finish line.+ ## Before the board: intake, sourcing, evidence  The clock starts at the prompt even when there is no board yet.
tools/altium-project.pyadded+569
@@ -0,0 +1,569 @@+"""adom-aiflow altium: the Altium side of a project, done the same way every time (run after project-common).++  library    one project library in Altium (a .SchLib and a .PcbLib): every part from its wiki component+             page, its footprint and symbol converted from the page's KiCad files, its STEP model bound,+             the Mouser, DigiKey and LCSC numbers looked up and checked; later parts join the SAME library+  reference  placement and spec coordinates from a reference KiCad board, in the Altium board's frame+             (the board's lower-left corner at 0,0, y up)+  schematic  the design's schematic drawn in Altium from the netlist (one column per block, every pin+             labelled with its net) inside a project, compiled and checked pin by pin against the+             netlist; then the board, as `board` builds it, in the same project+  board      the design's board built in Altium from the netlist: the outline, the fixed parts where the+             spec puts them, every other part parked below the board for placement, every pad on its net;+             saved, read back pin by pin, and the read written for `adom-aiflow start`+  silkscreen the board's silkscreen planned (the planner the lanes share) and landed in Altium: part names+             beside their parts, machine contacts framed with their nets, leaders, the identity on the+             bottom; then checked on Altium's own measure (clear of copper, vias, the edge, each other)+  export     the manufacturing set from the project's OutJob (Gerbers, NC drill, pick and place, BOM), pulled+             and checked against the board (layers, drill hits, every placed part), plus the JLCPCB BOM/CPL+  reset      clear the live board's copper (tracks, vias, pours) for a refine pass; the parts stay+  view3d     the board in Altium's 3D view and a slow orbit for the recording++Every step prints OK or ERROR with Hint lines and keeps its state in <run>/altium/.+Called by `adom-aiflow altium <step> ...`; AIFLOW_RUN, AIFLOW_TARGET and AIFLOW_THREAD come from it.+"""++init('altium')+++def need(tool, pkg):+    if not shutil.which(tool): err('%s is not installed' % tool, ['adom-wiki pkg install %s, then run this again.' % pkg])+++def lib_paths(st):+    d = box_dir() + '/library'+    name = st.get('library') or 'Project'+    return d, d + '/' + name + '.SchLib', d + '/' + name + '.PcbLib'+++def fp_name(s):+    return re.sub(r'[^A-Za-z0-9_.-]', '-', s).upper()[:60]+++# ---------------------------------------------------------------- library++def cmd_library(a):+    for tool, pkg in (('altium-kicad2altium', 'adom/altium-bridge'), ('altium-symbol-modes', 'adom/altium-bridge'), ('adom-wiki', 'adom/adom-wiki')):+        need(tool, pkg)+    st = load_state()+    name = st.get('library') or a.name or 'Project'+    if not re.fullmatch(r'[A-Za-z0-9_-]{1,40}', name): err('library name: letters, digits, _ and - only')+    st['library'] = name+    src = json.loads(Path(a.parts).read_text())+    comps = src.get('components') if isinstance(src, dict) else src+    if not comps: err('no parts: give --parts <netlist.json> (components[] with ref, mpn, fp) or parts.json [{mpn, page?, footprint?}]')+    cache = json.loads(Path(a.numbers).read_text()) if a.numbers else {}+    refs = st.setdefault('refs', {})+    stage = STATE / 'stage'; shutil.rmtree(stage, ignore_errors=True); stage.mkdir(parents=True)+    bdir, sch_lib, pcb_lib = lib_paths(st)+    added, kept, unverified = [], [], {}+    work = {}+    for c in comps:+        mpn = c.get('mpn')+        if mpn:+            key = fp_name(mpn)+            work.setdefault(key, {'mpn': mpn, 'page': c.get('page') or mpn.lower(), 'refs': []})['refs'].append(c.get('ref'))+        else:+            fpn = (c.get('fp') or '').split(':')[-1]+            hit = next(iter(sorted(Path(a.footprints).rglob(fpn + '.kicad_mod'))), None) if a.footprints and fpn else None+            if not hit: err('%s has no MPN and its footprint %s was not found' % (c.get('ref'), fpn or '?'), ['Give --footprints <folder with %s.kicad_mod>, or an MPN in the netlist.' % fpn])+            key = fp_name(fpn)+            work.setdefault(key, {'footprint': str(hit), 'refs': []})['refs'].append(c.get('ref'))+        refs[c.get('ref')] = key+    only = {fp_name(m) for m in (a.only or [])}+    if only - set(work): err('--only names part(s) not in --parts: %s' % ', '.join(sorted(only - set(work))))+    for key, w in work.items():+        if only and key not in only:+            kept.append(key); continue+        if st['parts'].get(key, {}).get('imported') and not a.again and not only:+            kept.append(key); continue+        spec_fp = stage / (key + '.fp.spec'); spec_sym = stage / (key + '.sym.spec'); files = []+        params = {}+        step = None+        if w.get('footprint'):+            r = run(['altium-kicad2altium', w['footprint'], str(spec_fp), key, 'board-only pad'])+            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (key, (r.stdout + r.stderr)[-300:]))+            files.append(spec_fp)+        else:+            pdir = STATE / 'pages' / key+            page = wiki_page(w['page'], pdir)+            if not page: err('%s: no wiki component page at %s' % (w['mpn'], w['page']), ['Fetch the part first (chip-fetcher publishes a component page), or give its page slug in the netlist: "page": "<owner>/<slug>".'])+            comp = page.get('component', {})+            sym, outline, fp, step = pick_files(pdir, comp)+            if not fp: err('%s: the page has no .kicad_mod footprint' % w['mpn'])+            desc = (page.get('brief') or comp.get('package') or '')[:120].replace('\n', ' ')+            r = run(['altium-kicad2altium', str(fp), str(spec_fp), key, desc])+            if r.returncode != 0 or not spec_fp.is_file(): err('%s: footprint conversion failed: %s' % (w['mpn'], (r.stdout + r.stderr)[-300:]))+            files.append(spec_fp)+            use = outline if (outline and len(set(re.findall(r'\(pad "([^"]+)"', fp.read_text(errors='ignore')))) > 2) else sym+            if use:+                r = run(['altium-symbol-modes', str(use), str(spec_sym), key, desc])+                if r.returncode != 0 or not spec_sym.is_file(): err('%s: symbol conversion failed: %s' % (w['mpn'], (r.stdout + r.stderr)[-300:]))+                files.append(spec_sym)+            if step: files.append(step)+            numbers, notes = lookup_numbers(w['mpn'], comp.get('distributor_links'), cache)+            mfr = comp.get('manufacturer') or ''+            params = {'MPN': w['mpn'], 'Manufacturer': mfr, 'Package': comp.get('package') or ''}+            if numbers.get('mouser'): params['Mouser Part #'] = numbers['mouser']+            if numbers.get('digikey'): params['DigiKey Part #'] = numbers['digikey']+            if numbers.get('lcsc'): params['LCSC Part #'] = numbers['lcsc']+            missing = [k for k in ('mouser', 'digikey', 'lcsc') if not numbers.get(k)]+            if missing and mfr.strip().lower() != 'adom': unverified[w['mpn']] = missing + (['(' + '; '.join(sorted(set(notes))) + ')'] if notes else [])+        send(files, bdir + '/stage')+        imp = {'pcbLib': win(pcb_lib), 'footprintSpec': win(bdir + '/stage/' + spec_fp.name)}+        if spec_sym in files:+            imp['schLib'] = win(sch_lib); imp['symbolSpec'] = win(bdir + '/stage/' + spec_sym.name)+        if step: imp['step'] = win(bdir + '/stage/' + step.name)+        if params: imp['params'] = params+        r = ab('altium_import_part', imp, timeout=900)+        if not r.get('success'): err('%s: altium_import_part failed: %s' % (key, str(r.get('error') or r.get('errorCode'))[:300]), [r.get('_hint')])+        st['parts'][key] = {'imported': True, 'mpn': w.get('mpn'), 'refs': w['refs'], 'symbol': spec_sym in files, 'step': bool(step), 'params': params}+        added.append(key)+        say('part %s: footprint%s%s, refs %s' % (key, ', symbol' if spec_sym in files else '', ', 3D' if step else '', ' '.join(w['refs'])))+        save_state(st)+    st['schLib'], st['pcbLib'] = sch_lib, pcb_lib+    save_state(st)+    prove('library', {'name': name, 'parts': len(st['parts']), 'tool': 'altium'})+    ok('library %s: %d part(s) (%d added, %d kept): %s and %s' % (name, len(st['parts']), len(added), len(kept), pcb_lib, sch_lib),+       ['No distributor number yet: ' + '; '.join('%s %s' % (m, ' '.join(v)) for m, v in unverified.items()) + '. Run `altium library` again when the services are back, or add the numbers to the part\'s wiki page.' if unverified else '',+        'Next: adom-aiflow altium reference --kicad <reference .kicad_pcb> --spec <spec.json> (if there is a reference board), then adom-aiflow altium schematic --netlist <netlist.json> --name <Design> (the schematic, then the board in the same project).'])+++# ---------------------------------------------------------------- reference++def cmd_reference(a):+    moves, fixed, size, moved = reference(a.kicad, a.spec, a.out_dir, 'Altium')+    ok('reference %s: %d part placements, %d fixed parts, outline %.2f x %.2f mm, %d spec coordinate(s) moved into the board frame' % (Path(a.kicad).name, len(moves), len(fixed), size[0], size[1], moved),+       ['Wrote %s/moves.json (place check/land), %s/fixed.json (altium board) and %s/spec.json (use it for start).' % (a.out_dir, a.out_dir, a.out_dir)])+++# ---------------------------------------------------------------- board++def fab_numbers(dru):+    """A KiCad rule pack's floors as altium_set_design_rules takes them (the Rust eda::fab_numbers)."""+    floor = {}+    for part in dru.split('(constraint ')[1:]:+        kind = re.match(r'[^\s)]+', part).group(0)+        m = re.search(r'\(min ([^)]+)\)', part)+        if not m: continue+        v = m.group(1).strip()+        try: mm = float(v[:-3]) * 0.0254 if v.endswith('mil') else float(v.replace('mm', ''))+        except ValueError: continue+        kind = {'hole': 'hole_size'}.get(kind, kind)  # a pack may write either name+        floor[kind] = min(mm, floor.get(kind, mm))+    o = {}+    if 'clearance' in floor: o['clearance'] = floor['clearance']+    if 'track_width' in floor: o['trackWidth'] = {'min': floor['track_width']}+    if 'hole_size' in floor: o['hole'] = {'min': floor['hole_size']}+    if 'via_diameter' in floor or 'hole_size' in floor: o['via'] = {'diameter': {'min': floor.get('via_diameter', 0.45)}, 'drill': {'min': floor.get('hole_size', 0.2)}}+    if 'annular_width' in floor: o['annularRing'] = floor['annular_width']+    # the floor AI Flow plans holes to when a pack names none (aiflow_pours::hole_to_hole): the tool's+    # rule must agree, or Altium's own 0.254 mm default flags vias the plan placed 0.2 mm apart+    o['holeToHole'] = floor.get('hole_to_hole', 0.2)+    if 'edge_clearance' in floor: o['edgeClearance'] = floor['edge_clearance']+    return o+++def run_rules():+    """The fab rule pack `rules use` chose for this run (its text), or None."""+    try:+        d = json.loads((RUN / 'run.json').read_text()); fr = (d.get('data') or d).get('fabRules') or {}+        f = RUN / 'rules' / fr.get('file', '')+        return f.read_text() if fr.get('file') and f.is_file() else None+    except Exception:+        return None+++def build_board(a):+    st = load_state()+    if not st.get('pcbLib'): err('no project library yet', ['Run `adom-aiflow altium library --parts <netlist.json>` first.'])+    net = json.loads(Path(a.netlist).read_text())+    fixed = json.loads(Path(a.fixed).read_text()) if a.fixed else (json.loads((STATE / 'fixed.json').read_text()) if (STATE / 'fixed.json').is_file() else {})+    w, h = (a.outline or fixed.get('outlineMm') or [None, None])+    if not w or not h: err('no board outline size', ['Pass --outline W H (mm), or run `altium reference` first (fixed.json carries it).'])+    refs = st.get('refs', {})+    parts, parked = [], 0+    pos = fixed.get('fixed', {})+    for c in net.get('components', []):+        r = c['ref']+        if r not in refs: err('%s is not in the project library' % r, ['Run `adom-aiflow altium library --parts %s` again: it adds the parts it is missing.' % a.netlist])+        if r in pos:+            p = pos[r]; parts.append({'ref': r, 'footprint': refs[r], 'x': p['x'], 'y': p['y'], 'rotation': p.get('rot', 0), 'comment': c.get('value') or ''})+        else:+            # parked in rows below the board, for the placement step to land+            col, row = parked % 10, parked // 10+            parts.append({'ref': r, 'footprint': refs[r], 'x': 2.0 + col * (w / 10.0), 'y': -6.0 - row * 6.0, 'rotation': 0, 'comment': c.get('value') or ''})+            parked += 1+    pcb = win(box_dir() + '/' + a.name + '.PcbDoc')+    r = ab('altium_build_board', {'pcbPath': pcb, 'libraryPath': win(st['pcbLib']), 'outline': {'rect': [0, 0, w, h], 'cornerRadius': 0},+                                  'parts': parts, 'nets': net.get('nets', {})}, timeout=1800)+    if not r.get('success'): err('the board was not built cleanly: %s' % (r.get('error') or r.get('errorCode')), [r.get('_hint'), 'wrongPins: %s' % json.dumps(r.get('wrongPins'))[:400] if r.get('wrongPins') else '', 'missingParts: %s' % r.get('missingParts') if r.get('missingParts') else ''])+    st['board'] = pcb; save_state(st)+    # the fab's rules, chosen at the rules step (before there was a board), into this board+    dru = run_rules()+    if dru:+        nums = fab_numbers(dru)+        if nums:+            rr = ab('altium_set_design_rules', dict(nums, expectPath=pcb), timeout=600)+            if not rr.get('success'): err('the fab rules did not load into the new board: %s' % (rr.get('error') or rr.get('errorCode') or rr.get('failed')), [rr.get('_hint')])+            say('fab rules loaded into the board: %s' % ', '.join('%s %s' % (k, v) for k, v in nums.items()))+    # park by real size: the build parked on a coarse grid before any footprint's extent was known, and+    # pads that overlap read as connected (the ratsnest undercounts). Left to right, 1.5 mm apart, rows+    # below the board, each part's pads clear of the next.+    rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)+    fixed_refs = set(pos)+    boxes = {}+    for p in rs.get('pads', []):+        if p['ref'] in fixed_refs or not p['ref']: continue+        hx, hy = max(p['sizeX'], p['sizeY']) / 2, max(p['sizeX'], p['sizeY']) / 2+        b = boxes.setdefault(p['ref'], [1e9, 1e9, -1e9, -1e9])+        b[0] = min(b[0], p['x'] - hx); b[1] = min(b[1], p['y'] - hy); b[2] = max(b[2], p['x'] + hx); b[3] = max(b[3], p['y'] + hy)+    comps = {c['ref']: c for c in rs.get('components', [])}+    moves, x, y, row_h, gap = [], 0.0, -3.0, 0.0, 1.5+    for ref in [c['ref'] for c in net.get('components', []) if c['ref'] not in fixed_refs]:+        b = boxes.get(ref); c = comps.get(ref)+        if not b or not c: continue+        bw, bh = b[2] - b[0], b[3] - b[1]+        if x > 0 and x + bw > w * 1.6: x = 0.0; y -= row_h + gap; row_h = 0.0+        # where the origin goes so the pads' box starts at (x, y - bh)+        moves.append({'ref': ref, 'x': round(x + (c['x'] - b[0]), 4), 'y': round(y - bh + (c['y'] - b[1]), 4), 'rotation': c['rotation']})+        x += bw + gap; row_h = max(row_h, bh)+    if moves:+        m = ab('altium_move_parts', {'expectPath': pcb, 'expectedRevision': rs.get('revision'), 'parts': moves}, timeout=900)+        if not m.get('success'): err('re-parking the parts failed: %s' % (m.get('error') or m.get('errorCode')), [m.get('_hint')])+        rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)+    # the board read, for `adom-aiflow start --board <this> --remote-board altium:<pcb>`+    ps = ab('altium_pour_state', {}, timeout=300)+    out = STATE / (a.name + '.altium.json')+    out.write_text(json.dumps({'source': 'altium-bridge', 'state': rs, 'pours': ps if ps.get('success') else None}))+    rules = Path(a.fixed).with_name('board.kicad_dru') if a.fixed else STATE / 'board.kicad_dru'+    # the fab's rules beside the board read, where `start` looks for them (<board file>.kicad_dru)+    if rules.is_file(): shutil.copy(rules, Path(str(out)[:-len('.json')] + '.kicad_dru'))+    prove('board', {'pcb': pcb, 'parts': len(parts), 'parked': parked, 'unconnected': r.get('unconnected')})+    return {'pcb': pcb, 'out': out, 'parts': len(parts), 'parked': parked, 'unconnected': rs.get('unconnected')}+++def cmd_board(a):+    b = build_board(a)+    ok('board %s: %d part(s) (%d fixed, %d parked for placement), every pin on its net, %s ratsnest line(s) to route' % (b['pcb'], b['parts'], b['parts'] - b['parked'], b['parked'], b['unconnected']),+       ['Start the run on it: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb']),+        'Then rules use, place check / land (moves.json from `altium reference`), route, gate, land route, pour, land pours, measure, analyze.'])+++# ---------------------------------------------------------------- schematic++def cmd_schematic(a):+    st = load_state()+    if not st.get('schLib'): err('no project library yet', ['Run `adom-aiflow altium library --parts <netlist.json>` first.'])+    net = json.loads(Path(a.netlist).read_text())+    refs = st.get('refs', {})+    parts, board_only = [], []+    for c in net.get('components', []):+        key = refs.get(c['ref'])+        if not key: err('%s is not in the project library' % c['ref'], ['Run `adom-aiflow altium library --parts %s` again: it adds the parts it is missing.' % a.netlist])+        if not st['parts'].get(key, {}).get('symbol'):+            board_only.append(c['ref']); continue+        parts.append({'ref': c['ref'], 'symbol': key, 'footprint': key, 'block': c.get('block') or '', 'comment': c.get('value') or ''})+    sch = win(box_dir() + '/' + a.name + '.SchDoc'); pcb = win(box_dir() + '/' + a.name + '.PcbDoc')+    say('drawing %d part(s) in %d block(s) on %s' % (len(parts), len({p['block'] for p in parts}), sch))+    r = ab('altium_build_schematic', {'schPath': sch, 'libraryPath': win(st['schLib']), 'pcbLibPath': win(st['pcbLib']), 'pcbPath': pcb,+                                      'parts': parts, 'nets': net.get('nets', {}), 'title': net.get('title') or a.name,+                                      'revision': a.revision, 'drawnBy': 'Adom AI Flow'}, timeout=1800)+    if not r.get('success'):+        err('the schematic does not compile to the netlist: %s' % (r.get('error') or r.get('errorCode') or json.dumps(r.get('mismatches'))[:400]),+            [r.get('_hint'), 'compile errors: %s' % json.dumps(r.get('compileErrors'))[:400] if r.get('compileErrors') else ''])+    st['schematic'] = sch; st['project'] = r.get('project'); save_state(st)+    b = build_board(a)+    # the project opened before its board file existed lists the board twice (one greyed): open it again+    # from its file, now that every document is there, and show the sheet+    if r.get('project'):+        for d in (b['pcb'], sch):+            ab('altium_save', {'filePath': d})+        # closing the project alone leaves a second view of the board open: close every document of it+        for d in (b['pcb'], sch, win(st['schLib']), win(st['pcbLib']), r['project']):+            ab('altium_close_document', {'path': d})+        ab('altium_open_project', {'projectPath': r['project'], 'filePath': r['project']})+        ab('altium_show_schematic', {'filePath': sch})+        ab('altium_run_command', {'command': 'Sch:Zoom', 'params': {'Action': 'All'}})+    prove('schematic', {'sch': sch, 'project': r.get('project'), 'parts': r.get('placed'), 'nets': r.get('compiledNets'), 'boardOnly': board_only, 'pcb': b['pcb']})+    ok('schematic %s: %d part(s) drawn, %d labelled pin(s), compiled to the netlist pin for pin (%d nets); the board followed with %d part(s) (%d parked for placement) in project %s' % (+           sch, r.get('placed') or 0, r.get('labels') or 0, r.get('compiledNets') or 0, b['parts'], b['parked'], r.get('project')),+       ['Board-only parts (no symbol, on the board only): %s.' % ', '.join(board_only) if board_only else '',+        'Look at it: altium_show_schematic {"filePath": "%s"} and a screenshot; every part in its block, every pin labelled with its net.' % sch,+        'Next: simulate it before the board (the Adom SPICE dashboard; record it with `evidence add --kind simulation`).',+        'Then the board: adom-aiflow start --board %s --spec <spec.json> --engine <you> --target %s --remote-board altium:%s' % (b['out'], TARGET, b['pcb'])])+++# ---------------------------------------------------------------- silkscreen++def altium_board_model(rs, ss):+    """The board as the shared planner sees it (silkscreen-plan.py), from routing_state and silk_state:+    pads and bodies as boxes, each part's net, the outline, the vias, and the free overlay items."""+    pads = {}+    for q in rs.get('pads', []):+        rot = int(round(q.get('rotation') or 0)) % 180+        hx, hy = (q['sizeY'] / 2, q['sizeX'] / 2) if rot == 90 else (q['sizeX'] / 2, q['sizeY'] / 2)+        if rot not in (0, 90): hx = hy = max(q['sizeX'], q['sizeY']) / 2+        pads.setdefault(q['ref'], []).append(((q['x'] - hx, q['y'] - hy, q['x'] + hx, q['y'] + hy), q.get('net') or ''))+    els = []+    for c in ss.get('components', []):+        ps = pads.get(c['ref'], [])+        nets = sorted({n for _, n in ps if n})+        body = c.get('body') or [c['x'], c['y'], c['x'], c['y']]+        nm = c.get('name') or {}+        els.append({'name': c['ref'], 'package': c.get('pattern') or '', 'value': (c.get('comment') or {}).get('text') or '',+                    'x': c['x'], 'y': c['y'], 'bottom': c.get('side') == 'bottom', 'pads': [b for b, _ in ps] or [tuple(body)],+                    'body': tuple(body), 'rot': c.get('rotation') or 0, 'homeName': (nm.get('x', c['x']), nm.get('y', c['y'])),+                    'nameAttr': True, 'nameAlign': 'bottom-left', 'nameBox': tuple(nm['box']) if nm.get('shown') and nm.get('box') else None,+                    'net': nets[0] if len(nets) == 1 else ('/'.join(nets) if nets else None)})+    pts = rs.get('outline') or []+    outline = (min(p[0] for p in pts), min(p[1] for p in pts), max(p[0] for p in pts), max(p[1] for p in pts)) if pts else None+    vias = [(v['position']['x'], v['position']['y'], (v.get('size') or 0.6) / 2) for v in rs.get('vias', [])]+    texts = [{'text': t['text'], 'layer': 21 if t['side'] == 'top' else 22, 'box': tuple(t['box']), 'at': (t['x'], t['y'])} for t in ss.get('texts', [])]+    texts += [{'text': None, 'layer': 'wire21', 'box': (l['x1'], l['y1'], l['x2'], l['y2'])} for l in ss.get('lines', []) if l['side'] == 'top']+    return els, outline, vias, texts+++def cmd_silkscreen(a):+    global TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H+    # Altium's stroke font, measured on AD 26.11.1 (2026-10-15): a character advances up to 0.95 x the+    # height, the stroke is a seventh of the height, and Altium's box adds one stroke on every side+    TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H = 0.95, 2 / 7.0, 0.0, 1 + 2 / 7.0+    st = load_state()+    pcb = a.design or st.get('board')+    if not pcb: err('which board? build it with `altium schematic` (or pass --design <.PcbDoc>)')+    ab('altium_show_2d_board', {'filePath': pcb})+    # an earlier pass's own items come off first, so the plan starts from the parts alone+    prev = st.get('silk') or {}+    if a.fresh:+        # every free overlay text and line on the board goes (the lane's own from earlier passes)+        cur = ab('altium_silk_state', {}, timeout=300)+        prev = {'texts': [dict(t['raw'], text=t['text']) for t in cur.get('texts', [])], 'lines': [l['raw'] for l in cur.get('lines', [])]}+    elif any('text' not in t for t in prev.get('texts', [])):+        # handles stored without their text (before 2026-10-16): the text comes from the board+        cur = ab('altium_silk_state', {}, timeout=300)+        by = {(t['raw']['layer'], t['raw']['x'], t['raw']['y']): t['text'] for t in cur.get('texts', [])}+        prev['texts'] = [dict(t, text=t.get('text') or by.get((t['layer'], t['x'], t['y']), '')) for t in prev.get('texts', [])]+    if prev.get('texts') or prev.get('lines'):+        ab('altium_apply_silkscreen', {'remove': prev}, timeout=600)+        st['silk'] = {}; save_state(st)+    rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)+    ss = ab('altium_silk_state', {}, timeout=300)+    if not rs.get('success') or not ss.get('success'): err('could not read the board: %s' % (rs.get('error') or ss.get('error')), [rs.get('_hint')])+    els, outline, vias, texts = altium_board_model(rs, ss)+    say('planning the silkscreen of %d part(s) on %s' % (len(els), pcb))+    groups, frames, labels, leaders, unresolved = plan_board(els, outline, vias, texts)+    title = a.title or st.get('title') or Path(pcb.replace('\\', '/')).stem+    lines_ = [title, 'adom-aiflow %s' % time.strftime('%Y-%m-%d')]+    spot = identity_spot(els, outline, vias, lines_)+    sw = lambda size: size / 7.0+    names, ftexts, flines = [], [], []+    for L in labels:+        size, rot = L['size'], (90 if L.get('rot') == 'R90' else 0)+        b = L['box']+        x, y = (b[0] + sw(size), b[1] + sw(size)) if rot == 0 else (b[2] - sw(size), b[1] + sw(size))+        if L['kind'] == 'name' and not L.get('leader') and L['text'] == L['ref']:+            names.append({'ref': L['ref'], 'x': round(x, 4), 'y': round(y, 4), 'size': size, 'rotation': rot})+        else:+            ftexts.append({'text': L['text'], 'x': round(x, 4), 'y': round(y, 4), 'size': size, 'rotation': rot})+    pieces_of = {g['ref']: g.get('pieces') for g in groups}+    for ref, f in frames:+        if pieces_of.get(ref):+            for (p_, q_) in pieces_of[ref]: flines.append({'x1': p_[0], 'y1': p_[1], 'x2': q_[0], 'y2': q_[1], 'width': 0.15})+        elif f:+            for (p_, q_) in (((f[0], f[1]), (f[2], f[1])), ((f[2], f[1]), (f[2], f[3])), ((f[2], f[3]), (f[0], f[3])), ((f[0], f[3]), (f[0], f[1]))):+                flines.append({'x1': p_[0], 'y1': p_[1], 'x2': q_[0], 'y2': q_[1], 'width': 0.15})+    for l in leaders:+        (x1, y1), (x2, y2) = l['leader']; flines.append({'x1': x1, 'y1': y1, 'x2': x2, 'y2': y2, 'width': 0.15})+    if spot:+        for k, l in enumerate(lines_):+            cy = spot[1] + (len(lines_) / 2 - k - 0.5) * spot[2] * 1.5+            w = TEXT_W * spot[2] * len(l)+            # bottom text is mirrored: it runs to the left of its location+            ftexts.append({'text': l, 'x': round(spot[0] + w / 2, 4), 'y': round(cy - spot[2] / 2, 4), 'size': spot[2], 'rotation': 0, 'side': 'bottom'})+    # the names this pass does not place stay off the print (a hidden designator), never a stray default+    placed = {n['ref'] for n in names}+    hide = [{'ref': c['ref'], 'x': c['name']['x'], 'y': c['name']['y'], 'size': max(c['name']['size'], 0.5), 'show': False}+            for c in ss.get('components', []) if c['ref'] not in placed and (c.get('name') or {}).get('shown')]+    plan = {'names': names, 'texts': ftexts, 'lines': flines, 'hide': hide, 'unresolved': unresolved, 'identity': {'lines': lines_, 'at': spot}}+    (STATE / 'silkscreen-plan.json').write_text(json.dumps(plan, indent=1))+    if a.dry: ok('silkscreen plan (not landed): %d names, %d texts, %d lines, %d unresolved; %s' % (len(names), len(ftexts), len(flines), len(unresolved), STATE / 'silkscreen-plan.json'))+    r = ab('altium_apply_silkscreen', {'names': names + hide, 'texts': ftexts, 'lines': flines}, timeout=900)+    if not r.get('after'): err('landing the silkscreen failed: %s' % (r.get('error') or r.get('errorCode')), [r.get('_hint')])+    after = r['after']+    # remember this pass's own free items (their exact handles) for the next pass to take off+    def near(t, u): return abs(t['x'] - u['x']) < 0.02 and abs(t['y'] - u['y']) < 0.02 and t['text'] == u['text']+    mine_t = [dict(t['raw'], text=t['text']) for t in after.get('texts', []) if any(near(t, u) for u in ftexts)]+    def same(l, u): return all(abs(l[k] - u[k]) < 0.02 for k in ('x1', 'y1', 'x2', 'y2'))+    mine_l = [l['raw'] for l in after.get('lines', []) if any(same(l, u) for u in flines)]+    st['silk'] = {'texts': mine_t, 'lines': mine_l}; save_state(st)+    # the check, on what Altium measured: every printed label clear of copper, vias, the edge and the others+    copper = [b for e in els for b in e['pads'] if not e['bottom']]+    viab = [(x - r_, y - r_, x + r_, y + r_) for (x, y, r_) in vias]+    shown = [(c['ref'], tuple(c['name']['box'])) for c in after.get('components', []) if c['name'].get('shown') and c['side'] == 'top']+    shown += [(t['text'], tuple(t['box'])) for t in after.get('texts', []) if t['side'] == 'top']+    problems = []+    for i, (n, b) in enumerate(shown):+        if outline and not (b[0] >= outline[0] and b[1] >= outline[1] and b[2] <= outline[2] and b[3] <= outline[3]): problems.append('%s past the board edge' % n)+        # under 0.05 mm is the mask expansion: the fab clips silkscreen out of a pad's opening anyway+        if any(_ov(b, q, -0.05) for q in copper): problems.append('%s on copper' % n)+        if any(_ov(b, v, -0.02) for v in viab): problems.append('%s on a via' % n)+        for n2, b2 in shown[i + 1:]:+            if _ov(b, b2, -0.02): problems.append('%s over %s' % (n, n2))+    failed = [x for x in (r.get('results') or []) if str(x).startswith('err')]+    if problems or failed:+        err('silkscreen landed but does not check out on the board: %s' % '; '.join((problems + failed)[:12]),+            ['The plan is in %s. Move the parts that crowd it (place), or run `altium silkscreen` again after the fix; the proof is recorded only from a clean check.' % (STATE / 'silkscreen-plan.json')])+    prove('silkscreen', {'pcb': pcb, 'names': len(names), 'texts': len(ftexts), 'lines': len(flines), 'frames': len(frames), 'leaders': len(leaders), 'unresolved': unresolved, 'identity': bool(spot), 'checked': len(shown), 'tool': 'altium'})+    ab('altium_run_command', {'command': 'PCB:Zoom', 'params': {'Action': 'All'}})+    ok('silkscreen: %d part names, %d contact frame(s), %d value/net label(s), %d leader(s)%s, board identity on the bottom%s; every printed label checked clear of copper, vias, the edge and the others (%d)' % (+           len(names), len(frames), len([t for t in ftexts if t.get('side') != 'bottom']), len(leaders), (', unresolved: ' + ', '.join(unresolved)) if unresolved else '', '' if spot else ' (no clear spot found)', len(shown)),+       ['Show both faces in Altium (top labels next to their parts; flip the board for the bottom identity), then the 3D view: `altium view3d`.'])+++# ---------------------------------------------------------------- export++def roles_of(st, board_refs):+    """{ref: 'tp' | 'machine'} for the parts a fab must not place: board-only pads and test points+    (bare copper) out of both files; machine contacts and pins (fitted after assembly) DNP."""+    roles = {}+    for ref, key in (st.get('refs') or {}).items():+        if ref not in board_refs: continue+        part = (st.get('parts') or {}).get(key, {})+        if TP_NAME.search(key) or not part.get('symbol'): roles[ref] = 'tp'+        elif MACHINE_NAME.search(key): roles[ref] = 'machine'+    return roles+++def jlc_files(out, name, bom, cpl):+    """JLCPCB's own column names from Altium's BOM and pick and place (already fab-ready)."""+    h, rows = table(bom); hl = [c.strip().lower() for c in h]+    col = lambda *n: next((hl.index(c) for c in n if c in hl), None)+    ci, di, fi, li, dn = col('name', 'comment'), col('designator'), col('footprint'), col('lcsc part #', 'lcsc'), col('dnp')+    with open(out / 'bom-jlcpcb.csv', 'w', newline='', encoding='utf-8') as f:+        w = csv.writer(f); w.writerow(['Comment', 'Designator', 'Footprint', 'LCSC Part #'])+        for r in rows:+            if dn is not None and dn < len(r) and r[dn].strip(): continue+            w.writerow([r[i] if i is not None and i < len(r) else '' for i in (ci, di, fi, li)])+    h, rows = table(cpl); hl = [c.strip().lower() for c in h]+    di, xi, yi, li, ri = col('designator'), col('center-x(mm)', 'center-x'), col('center-y(mm)', 'center-y'), col('layer'), col('rotation')+    with open(out / 'cpl-jlcpcb.csv', 'w', newline='', encoding='utf-8') as f:+        w = csv.writer(f); w.writerow(['Designator', 'Mid X', 'Mid Y', 'Layer', 'Rotation'])+        for r in rows:+            g = lambda i: r[i] if i is not None and i < len(r) else ''+            w.writerow([g(di), g(xi) + 'mm', g(yi) + 'mm', 'Top' if 'top' in g(li).lower() else 'Bottom', g(ri)])+++def cmd_export(a):+    st = load_state()+    prj, pcb = st.get('project'), a.design or st.get('board')+    if not prj or not pcb: err('no Altium project yet', ['Run `adom-aiflow altium schematic` first: the project holds the schematic, the board and the manufacturing OutJob.'])+    out = Path(a.out_dir) if a.out_dir else RUN / 'manufacturing'+    shutil.rmtree(out, ignore_errors=True); out.mkdir(parents=True)+    say('writing the manufacturing set from %s' % prj)+    r = ab('altium_export_manufacturing', {'projectPath': prj, 'pcbPath': pcb}, timeout=900)+    if not r.get('success') or not r.get('files'): err('the export wrote nothing: %s' % (r.get('error') or r.get('errorCode') or 'no files'), [r.get('_hint'), 'dialogs: %s' % json.dumps(r.get('dialogs'))[:300] if r.get('dialogs') else ''])+    files = [f for f in r['files'] if not f.lower().endswith(('.rep', '.extrep', '.rul', '.apr', '.apr_lib', '.drr', '.ldp')) and 'status report' not in f.lower()]+    p = ab('pull_file', {'filePaths': [r['outputDir'] + '\\' + f for f in files], 'saveTo': str(out.resolve())}, timeout=900)+    got = {Path(f.replace('\\', '/')).name for f in files}+    have = {x.name for x in out.iterdir()}+    problems = ['not pulled: %s' % ', '.join(sorted(got - have))] if got - have else []+    ext = {x.suffix.lower() for x in out.iterdir()}+    for what, keys in {'top copper': ('.gtl',), 'bottom copper': ('.gbl',), 'top mask': ('.gts',), 'bottom mask': ('.gbs',),+                       'top silkscreen': ('.gto',), 'outline': ('.gko', '.gm1'), 'drill': ('.txt', '.drl')}.items():+        if not ext & set(keys): problems.append('no %s file' % what)+    # the export leaves the OutJob current: put the board back before reading it+    ab('altium_show_2d_board', {'filePath': pcb})+    rs = ab('altium_routing_state', {'expectPath': pcb}, timeout=300)+    if not rs.get('success') or not rs.get('components'): err('could not read the board to check the files against it: %s' % (rs.get('error') or rs.get('errorCode')), [rs.get('_hint')])+    board_refs = {c['ref'] for c in rs.get('components', [])}+    # drill hits: every via and every drilled pad is one hit in the NC drill file+    want_hits = len(rs.get('vias', [])) + sum(1 for q in rs.get('pads', []) if (q.get('drill') or 0) > 0)+    hits = 0+    for d in out.glob('*.TXT'):+        for line in d.read_text(errors='ignore').splitlines():+            if re.match(r'^[XY][-\d]', line): hits += 1+    if want_hits and hits != want_hits: problems.append('the drill file has %d hit(s), the board %d via(s) and drilled pad(s)' % (hits, want_hits))+    roles = roles_of(st, board_refs)+    cpl = next(iter(out.glob('Pick Place*.csv')), None); bom = next(iter(out.glob('Bill of Materials*.csv')), None)+    if not cpl: problems.append('no pick and place file')+    if not bom: problems.append('no BOM file')+    if cpl and bom:+        fab_rewrite(cpl, roles, True); fab_rewrite(bom, roles, False)+        placed = board_refs - set(roles)+        cpl_refs, bom_refs, bom_all = refs_in(cpl), refs_in(bom, dnp_too=False), refs_in(bom)+        if placed - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted(placed - cpl_refs)))+        if placed - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted(placed - bom_refs)))+        if set(roles) & cpl_refs: problems.append('test points or machine parts still in the pick and place: %s' % ', '.join(sorted(set(roles) & cpl_refs)))+        dnp = sorted(r_ for r_, k in roles.items() if k == 'machine')+        if set(dnp) - bom_all: problems.append('machine parts missing from the BOM as DNP: %s' % ', '.join(sorted(set(dnp) - bom_all)))+        jlc_files(out, Path(pcb).stem, bom, cpl)+    if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the board or the parts\' parameters and run `altium export` again; never hand the human files that do not match the board.'])+    import zipfile+    zp = out / (Path(pcb.replace('\\', '/')).stem + '-gerbers.zip')+    with zipfile.ZipFile(zp, 'w', zipfile.ZIP_DEFLATED) as z:+        for x in sorted(out.iterdir()):+            if x.suffix.lower() in ('.gtl', '.gbl', '.gts', '.gbs', '.gto', '.gbo', '.gtp', '.gbp', '.gko', '.gm1', '.txt') or re.fullmatch(r'\.g\d+|\.gm\d+', x.suffix.lower()):+                z.write(x, x.name)+    missing_numbers = sorted({r_[0] for r_ in [(row[0],) for row in table(out / 'bom-jlcpcb.csv')[1] if len(row) > 3 and not row[3].strip()]}) if (out / 'bom-jlcpcb.csv').is_file() else []+    prove('export', {'dir': str(out.resolve()), 'gerberZip': zp.name, 'drillHits': hits, 'cplRefs': len(refs_in(cpl)), 'bomLines': len(table(bom)[1]), 'tool': 'altium'})+    if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60)+    tps = sorted(r_ for r_, k in roles.items() if k == 'tp'); dnp = sorted(r_ for r_, k in roles.items() if k == 'machine')+    ok('manufacturing files in %s, checked against the board: Gerbers (%s), NC drill (%d hits = vias + drilled pads), pick and place (%d parts), BOM (%d lines), the JLCPCB BOM and CPL' % (+           out, zp.name, hits, len(refs_in(cpl)), len(table(bom)[1])),+       ['No LCSC number on: ' + ', '.join(missing_numbers) + ' (JLCPCB assembly needs one per line).' if missing_numbers else '',+        'Test points and board-only pads are bare copper, out of the BOM and the pick and place: ' + ', '.join(tps) if tps else '',+        'Machine contacts and pins are fitted after assembly: DNP in the BOM, out of the pick and place: ' + ', '.join(dnp) if dnp else ''])+++# ---------------------------------------------------------------- reset / view3d++def cmd_reset(a):+    st = load_state()+    pcb = a.design or st.get('board')+    if not pcb: err('which board? pass --design <.PcbDoc path> or build one with `altium board`')+    ab('altium_show_2d_board', {'filePath': pcb})+    s0 = ab('altium_routing_state', {'expectPath': pcb})+    if not s0.get('success'): err('could not read the board: %s' % s0.get('error'))+    nets = [n for n in s0.get('nets', []) if n]+    r = ab('altium_ripup_nets', {'expectPath': pcb, 'expectedRevision': s0['revision'], 'nets': nets}, timeout=900)+    if not r.get('success'): err('rip-up: %s' % r.get('error'), [r.get('_hint')])+    r2 = ab('altium_remove_pours', {'expectPath': pcb, 'pours': 'all'})+    if not r2.get('success'): err('pours: %s' % r2.get('error'), [r2.get('_hint')])+    ok('copper cleared on %s: %d net(s) ripped up, %d pour(s) removed; the parts did not move' % (pcb, len(nets), r2.get('removed') or 0),+       ['Next: adom-aiflow route (it plans on the live board), gate, land route, pour, land vias, land pours, measure, then analyze again.'])+++def cmd_view3d(a):+    st = load_state()+    pcb = a.design or st.get('board')+    if not pcb: err('which board? pass --design <.PcbDoc path>')+    r = ab('altium_show_3d_board', {'filePath': pcb})+    if not r.get('success'): err('3D view: %s' % r.get('error'), [r.get('_hint')])+    img = (r.get('shot') or {}).get('image')+    if img:+        import base64+        d = RUN / 'altium'; d.mkdir(parents=True, exist_ok=True)+        (d / 'view3d.png').write_bytes(base64.b64decode(img.split(',')[-1]))+    t = ab('altium_animate_view', {'preset': 'showcase3d', 'pauseMs': int(a.pause * 1000)}, timeout=900)+    prove('tour-3d', {'pcb': pcb, 'tour': bool(t.get('success'))})+    ok('the board in Altium 3D%s; a still is at %s' % (' and a showcase orbit' if t.get('success') else '', RUN / 'altium' / 'view3d.png'),+       ['Look at it: every part must sit flat on its pads as its datasheet drawing shows. A part that does not: fix its model on the wiki page and run `altium library --again`.'])+++def main():+    ap = argparse.ArgumentParser(prog='adom-aiflow altium')+    sub = ap.add_subparsers(dest='cmd', required=True)+    p = sub.add_parser('library'); p.add_argument('--parts', required=True, help='netlist.json (components with ref, mpn, fp) or parts.json [{mpn, page?, footprint?}]'); p.add_argument('--footprints', help='folder of .kicad_mod for board-only parts (no MPN)'); p.add_argument('--name'); p.add_argument('--numbers', help='JSON {mpn: {mouser, digikey, lcsc}} already checked'); p.add_argument('--again', action='store_true', help='import every part again (after a fix on its page)'); p.add_argument('--only', nargs='+', metavar='MPN', help='import just these parts again')+    p = sub.add_parser('reference'); p.add_argument('--kicad', required=True); p.add_argument('--spec', required=True); p.add_argument('--out-dir', default=str(RUN / 'altium'))+    p = sub.add_parser('board'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json')+    p = sub.add_parser('schematic'); p.add_argument('--netlist', required=True); p.add_argument('--name', required=True); p.add_argument('--fixed', help='fixed.json from `altium reference` (default: <run>/altium/fixed.json)'); p.add_argument('--outline', nargs=2, type=float, help='W H in mm when there is no fixed.json'); p.add_argument('--revision', default='A')+    p = sub.add_parser('silkscreen'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--title', help='the board identity on the bottom (default: the design name)'); p.add_argument('--dry', action='store_true', help='plan only'); p.add_argument('--fresh', action='store_true', help='clear every free overlay text and line first (earlier passes left on the board)')+    p = sub.add_parser('export'); p.add_argument('--design', help='the .PcbDoc (default: the project board)'); p.add_argument('--out-dir', help='default <run>/manufacturing')+    p = sub.add_parser('reset'); p.add_argument('--design')+    p = sub.add_parser('view3d'); p.add_argument('--design'); p.add_argument('--pause', type=float, default=0.7)+    a = ap.parse_args()+    {'library': cmd_library, 'reference': cmd_reference, 'schematic': cmd_schematic, 'board': cmd_board, 'export': cmd_export, 'silkscreen': cmd_silkscreen, 'reset': cmd_reset, 'view3d': cmd_view3d}[a.cmd](a)+++main()
tools/fusion-project.py−573
@@ -1100,579 +1100,6 @@ def board_model(text):                 e['nameBox'] = (x0, y0, x0 + w, y0 + h) if '90' not in str(_num(a_, 'rot', 'R0')) else (x - h, y0, x, y0 + w)     return els, outline, vias, texts -def _ov(a, b, g=0.0): return not (a[2] + g <= b[0] or b[2] + g <= a[0] or a[3] + g <= b[1] or b[3] + g <= a[1])--def text_box(x, y, s, size, align='bottom-left'):-    """EAGLE vector text (ratio 15): about 0.85 x size per character, size tall."""-    w, h = 0.85 * size * max(1, len(s)), size-    x0 = x - w / 2 if 'center' in align and not align.endswith('left') and not align.endswith('right') or align == 'center' else (x - w if align.endswith('right') else x)-    if align.startswith('top'): y0 = y - h-    elif align.startswith('center'): y0 = y - h / 2-    else: y0 = y-    if align == 'center': x0 = x - w / 2-    return (x0, y0, x0 + w, y0 + h)--def is_contact(e):-    """edge machine contacts and corner machine pins: both get a framed tag (name, and net when wired)"""-    pk = (e['package'] or '').upper()-    return ('MACHINECONTACT' in pk or 'MACHINEPIN' in pk) and not e['bottom']--def _ring_gaps(f, blockers, width=0.15, clear=0.15):-    """The frame f (stroke centre line) as straight pieces with gaps where it would cross a blocker (pad,-    mask opening or via, inflated by the clearance), the KiCad lane's 'interrupted for clearance' frame.-    Returns (pieces, kept fraction)."""-    x0, y0, x1, y1 = f; h = width / 2 + clear-    sides = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]-    pieces, total, kept = [], 0.0, 0.0-    for (a, b) in sides:-        horiz = abs(a[1] - b[1]) < 1e-9-        lo, hi = (min(a[0], b[0]), max(a[0], b[0])) if horiz else (min(a[1], b[1]), max(a[1], b[1]))-        c = a[1] if horiz else a[0]-        cuts = []-        for bb in blockers:-            if horiz and bb[1] - h < c < bb[3] + h: cuts.append((bb[0] - h, bb[2] + h))-            if not horiz and bb[0] - h < c < bb[2] + h: cuts.append((bb[1] - h, bb[3] + h))-        cuts.sort(); cur = lo; total += hi - lo-        for (u, v) in cuts:-            if v <= cur or u >= hi: continue-            if u > cur + 0.15: pieces.append(((cur, c), (u, c)) if horiz else ((c, cur), (c, u))); kept += u - cur-            cur = max(cur, v)-        if hi > cur + 0.15: pieces.append(((cur, c), (hi, c)) if horiz else ((c, cur), (c, hi))); kept += hi - cur-    return pieces, (kept / total if total else 0)--def plan_frames(els, outline, vias, edge=0.12, width=0.15, gap=0.2, corner_orients=None):-    """Machine-contact groups, the way the KiCad lane draws them (aiflow-silkscreen, contactBorders): one-    printed frame per contact enclosing the contact, its reference and its net, as large as the space-    allows. A column of contacts shares rails (from just inside the board edge to past the widest label)-    and a row height of the pitch less 0.2 mm, so the column reads as a set of tags. A frame is rejected-    when any other part's copper, any via or the board edge comes inside it; that contact then gets its own-    frame (its own width, shifted up or down), and only if nothing fits does it fall back to a frame round-    the pad with the labels outside. Text steps down from 0.8 to 0.5 mm before a frame is given up.-    Returns (groups, unresolved): a group is {'ref', 'frame': (x0, y0, x1, y1) stroke centre line,-    'texts': [label dicts like plan_labels makes]}."""-    if not outline: return [], []-    cs = [e for e in els if is_contact(e) and e['pads']]-    others = [b for e in els if not is_contact(e) for b in e['pads']] + [b for e in els if is_contact(e) for b in e['pads']]-    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]-    cx_b = (outline[0] + outline[2]) / 2-    def pad_box(e): return (min(p[0] for p in e['pads']), min(p[1] for p in e['pads']), max(p[2] for p in e['pads']), max(p[3] for p in e['pads']))-    def tw(t, size): return 0.95 * size * len(t) + 0.1-    def fits(e, fr):-        """the frame fr (outer bounds incl. stroke) holds nothing of anyone else and stays on the board"""-        if fr[0] < outline[0] + edge or fr[2] > outline[2] - edge or fr[1] < outline[1] + edge or fr[3] > outline[3] - edge: return False-        own = pad_box(e)-        for b in others:-            if b == own or (own[0] <= b[0] and b[2] <= own[2] and own[1] <= b[1] and b[3] <= own[3]): continue-            if _ov(fr, b, 0.15): return False-        for v in viab:-            inside_own = own[0] <= (v[0] + v[2]) / 2 <= own[2] and own[1] <= (v[1] + v[3]) / 2 <= own[3]-            if not inside_own and _ov(fr, v, 0.15): return False-        for o in els:-            if o is e or is_contact(o) or o['bottom'] or o['name'] in skip_bodies: continue-            if _ov(fr, o['body'], 0.05): return False-        if any(_ov(fr, ob, 0.2) for (ref_, ob) in placed_outers if ref_ != e['name']): return False-        return True-    skip_bodies = set(); placed_outers = []-    def fit_or_gap(e, f, outer, texts):-        """(ok, pieces): the frame fits whole (pieces None), or breaks around small pads and vias that sit on its-        line (none shut inside, at least 70% of it drawn, no label on them), the aiflow-silkscreen rule"""-        if fits(e, outer): return True, None-        small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]-        small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]-        small += [v for v in viab if _ov(outer, v, 0.3)]-        if not small: return False, None-        if any(f[0] + 0.1 < (b[0] + b[2]) / 2 < f[2] - 0.1 and f[1] + 0.1 < (b[1] + b[3]) / 2 < f[3] - 0.1 for b in small): return False, None-        saved = list(others); saved_v = list(viab)-        others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]-        skip_bodies.update(o['name'] for o in small_owners)-        ok_ = fits(e, outer)-        others[:] = saved; viab[:] = saved_v; skip_bodies.clear()-        pieces, frac = _ring_gaps(f, small)-        if not ok_ or frac < 0.7 or any(_ov(t['box'], b, 0.1) for t in texts for b in small): return False, None-        return True, pieces-    def layout(e, size, rail=None, dy=0.0, pitch=None, mode='side'):-        """(frame, texts) for one contact. 'side': name over net inward of the pad. 'stack': name above the-        pad and net below it, both centred on the pad, for a contact with a big part close beside it."""-        pb = pad_box(e); left_col = (pb[0] + pb[2]) / 2 < cx_b; cy = (pb[1] + pb[3]) / 2 + dy-        if mode == 'stack':-            pcx = (pb[0] + pb[2]) / 2; th = size * 1.1-            nw, vw = tw(e['name'], size), tw(e['net'] or '', size)-            ny0 = pb[3] + 0.12; vy0 = pb[1] - 0.12 - th-            texts = [{'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(pcx - nw / 2, 3), round(ny0, 3), round(pcx + nw / 2, 3), round(ny0 + th, 3)], 'origin': [round(pcx - nw / 2, 3), round(ny0, 3)]}]-            if e['net']:-                texts.append({'ref': e['name'], 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R0', 'box': [round(pcx - vw / 2, 3), round(vy0, 3), round(pcx + vw / 2, 3), round(vy0 + th, 3)], 'origin': [round(pcx - vw / 2, 3), round(vy0, 3)]})-            span = max(nw, vw, pb[2] - pb[0]) / 2-            if left_col: x0 = outline[0] + edge + width / 2; x1 = rail if rail else pcx + span + 0.15-            else: x1 = outline[2] - edge - width / 2; x0 = rail if rail else pcx - span - 0.15-            if left_col and x1 < pcx + span + 0.15 - 0.01: return None-            if not left_col and x0 > pcx - span - 0.15 + 0.01: return None-            if pitch:-                y0, y1 = cy - (pitch - 0.2) / 2, cy + (pitch - 0.2) / 2-            else:-                y0, y1 = vy0 - 0.15, ny0 + th + 0.15-            if y0 > vy0 - 0.08 or y1 < ny0 + th + 0.08: return None-            fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)-            return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts-        nw, vw, th = tw(e['name'], size), tw(e['net'] or '', size), size * 1.1-        need = max(nw, vw)-        if left_col:-            tx0 = pb[2] + gap; x0 = outline[0] + edge + width / 2; x1 = rail if rail else tx0 + need + gap-            if x1 < tx0 + need + gap - 0.01: return None-        else:-            tx1 = pb[0] - gap; x1 = outline[2] - edge - width / 2; x0 = rail if rail else tx1 - need - gap-            if x0 > tx1 - need - gap + 0.01: return None-        half = (pitch - 0.2) / 2 if pitch else max(th + 0.1, (pb[3] - pb[1]) / 2 + 0.15)-        y0, y1 = cy - half, cy + half-        if y1 - y0 < 2 * th + 0.3 or y0 > pb[1] - 0.1 or y1 < pb[3] + 0.1: return None-        texts = []-        for (t, kind, ty) in ((e['name'], 'name', cy + 0.08), (e['net'], 'net', cy - 0.08 - th)):-            if not t: continue-            w = tw(t, size)-            bx0 = tx0 if left_col else tx1 - w-            texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})-        fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)-        return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts-    groups, bad = [], []-    # corner machine pins: one frame shape for all four, mirrored into each corner, so the corners read as a-    # matched set. The frame runs from the board edges past the pin to hold its name over its net beside it-    # (on the side toward the board's middle); the largest text that fits every corner wins.-    corners = [e for e in cs if 'MACHINEPIN' in (e['package'] or '').upper()]-    if corners:-        cy_b = (outline[1] + outline[3]) / 2-        placed_corners = False-        for orient in (corner_orients or ('h', 'v', 'v1')):-          if placed_corners: break-          for size in _sz(0.8, 0.6, 0.5):-              th = size * 1.1-              one = orient == 'v1'  # one line: the net (the reference when the pin is mechanical only)-              lw = max((tw(e['net'] or e['name'], size) if one else max(tw(e['name'], size), tw(e['net'] or '', size))) for e in corners)-              pw = max(pad_box(e)[2] - pad_box(e)[0] for e in corners); ph = max(pad_box(e)[3] - pad_box(e)[1] for e in corners)-              # distance from the edge to the pin's far side, the same for all (the largest)-              dx = max(min(pad_box(e)[2] - outline[0], outline[2] - pad_box(e)[0]) for e in corners)-              dy = max(min(pad_box(e)[3] - outline[1], outline[3] - pad_box(e)[1]) for e in corners)-              if orient == 'h': fw = dx + gap + lw + 0.2; fh = max(dy + 0.2, 2 * th + 0.5)-              else: fw = max(dx + 0.25 - (edge + width / 2), lw + 0.4); fh = dy - (edge + width / 2) + gap + (1 if one else 2) * th + (0.25 if one else 0.3)-              trial = []-              for e in corners:-                  pb = pad_box(e); left = (pb[0] + pb[2]) / 2 < cx_b; low = (pb[1] + pb[3]) / 2 < cy_b-                  x0 = outline[0] + edge + width / 2 if left else outline[2] - edge - width / 2 - fw-                  y0 = outline[1] + edge + width / 2 if low else outline[3] - edge - width / 2 - fh-                  f = (round(x0, 3), round(y0, 3), round(x0 + fw, 3), round(y0 + fh, 3))-                  if orient == 'h':-                      tx = (pb[2] + gap) if left else (pb[0] - gap - lw)-                      tcy = (f[1] + f[3]) / 2-                  else:-                      tx = (pb[0] + pb[2]) / 2 - lw / 2-                      tcy = ((pb[3] + gap + th + 0.05) if low else (pb[1] - gap - th - 0.05)) if not one else ((pb[3] + gap + th / 2) if low else (pb[1] - gap - th / 2))-                  texts = []-                  rows_ = ([(e['net'], 'net', tcy - th / 2)] if e['net'] else [(e['name'], 'name', tcy - th / 2)]) if one else ((e['name'], 'name', tcy + 0.08), (e['net'], 'net', tcy - 0.08 - th))-                  for (t, kind, ty) in rows_:-                      if not t: continue-                      w = tw(t, size); bx0 = tx + (((lw - w) if not left else 0) if orient == 'h' else (lw - w) / 2)-                      texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})-                  outer = (f[0] - width / 2, f[1] - width / 2, f[2] + width / 2, f[3] + width / 2)-                  trial.append((e, f, outer, texts))-              ok_all, gapped = True, {}-              _dbg = os.environ.get('SILK_DEBUG')-              for (e, f, outer, texts) in trial:-                  if fits(e, outer): continue-                  # a frame may break where it would cross a small pad or a via, if the break is short, the-                  # blocker is not shut inside, and nothing else is in the way (aiflow-silkscreen rule)-                  own = pad_box(e)-                  small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]-                  small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]-                  small += [v for v in viab if _ov(outer, v, 0.3)]-                  inside = [b for b in small if f[0] < (b[0] + b[2]) / 2 < f[2] and f[1] < (b[1] + b[3]) / 2 < f[3] and not (b[0] < f[0] or b[2] > f[2] or b[1] < f[1] or b[3] > f[3])]-                  if _dbg: print('corner', orient, size, e['name'], 'small', [round(v,2) for b in small for v in b][:8], 'inside', bool(inside), file=sys.stderr)-                  if inside: ok_all = False; break-                  # without the small blockers, would it fit?-                  saved = list(others); saved_v = list(viab)-                  others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]-                  skip_bodies.update(o['name'] for o in small_owners)-                  clear_rest = fits(e, outer)-                  others[:] = saved; viab[:] = saved_v; skip_bodies.clear()-                  pieces, frac = _ring_gaps(f, small)-                  if _dbg: print('  clear_rest', clear_rest, 'frac', round(frac, 2), 'textclash', any(_ov(tb['box'], b, 0.1) for tb in texts for b in small), file=sys.stderr)-                  if not clear_rest or frac < 0.7 or any(_ov(tb['box'], b, 0.1) for tb in texts for b in small): ok_all = False; break-                  gapped[e['name']] = pieces-              if ok_all:-                  for (e, f, outer, texts) in trial:-                      groups.append({'ref': e['name'], 'frame': f, 'outer': outer, 'texts': texts, 'pieces': gapped.get(e['name'])})-                      placed_outers.append((e['name'], outer))-                  placed_corners = True; break-        if not placed_corners:-            bad += ['%s frame' % e['name'] for e in corners]-        cs = [e for e in cs if e not in corners]-    # columns: contacts on one vertical line share rails and a row height-    cols = []-    for e in cs:-        pb = pad_box(e); x = (pb[0] + pb[2]) / 2-        c = next((c for c in cols if abs(c['x'] - x) < 0.1), None)-        if c: c['m'].append(e)-        else: cols.append({'x': x, 'm': [e]})-    for c in cols:-        m = sorted(c['m'], key=lambda e: pad_box(e)[1])-        ys = [(pad_box(e)[1] + pad_box(e)[3]) / 2 for e in m]-        pitch = min([b - a for a, b in zip(ys, ys[1:]) if b - a > 0.1] or [None]) if len(m) > 1 else None-        if pitch: pitch = min(pitch, 4.0)-        left_col = c['x'] < cx_b-        done = {}-        # rows shrink together (never one alone) until the column clears its neighbours and the corner frames-        for size in _sz(0.8, 0.6, 0.5):-          for mode in ('side', 'stack'):-            for pt in ([pitch - 0.3 * k for k in range(0, 6)] if pitch else [None]):-              if pt is not None and pt < 2 * size * 1.1 + 0.6: break-              # the shared rail: past the widest label of the column at this size-              lays = [layout(e, size, pitch=pt, mode=mode) for e in m]-              if any(l is None for l in lays): continue-              rail = max(l[0][2] for l in lays) if left_col else min(l[0][0] for l in lays)-              shared = [layout(e, size, rail=rail, pitch=pt, mode=mode) for e in m]-              if os.environ.get('SILK_DEBUG'): print('col', [e['name'] for e in m], size, mode, pt, [(e['name'], bool(l), bool(l) and fits(e, l[1]), l and [round(v, 2) for v in l[1]]) for e, l in zip(m, shared)], file=sys.stderr)-              res = [fit_or_gap(e, l[0], l[1], l[2]) if l else (False, None) for e, l in zip(m, shared)]-              if all(r_[0] for r_ in res):-                  for e, l, r_ in zip(m, shared, res): done[e['name']] = l + (r_[1],)-                  break-            if len(done) == len(m): break-          if len(done) == len(m): break-        for e in m:-            if e['name'] in done: continue-            # its own frame: its own width, then shifted off whatever is in the way, then smaller rows-            got = None-            for size in _sz(0.8, 0.6, 0.5):-                for mode in ('side', 'stack'):-                    for pt in ([pitch] if pitch else []) + [None]:-                        for dy in (0.0, -0.3, 0.3, -0.6, 0.6, -0.9, 0.9):-                            l = layout(e, size, dy=dy, pitch=pt, mode=mode)-                            if l:-                                ok_, pc = fit_or_gap(e, l[0], l[1], l[2])-                                if ok_: got = l + (pc,); break-                        if got: break-                    if got: break-                if got: break-            if got: done[e['name']] = got-            else: bad.append('%s frame' % e['name'])-        for e in m:-            if e['name'] in done:-                f, fr, texts, pc = done[e['name']]-                groups.append({'ref': e['name'], 'frame': f, 'outer': fr, 'texts': texts, 'pieces': pc}); placed_outers.append((e['name'], fr))-    # every edge contact's frame the same width (the eye scans the row of tags as one set): the widest that-    # fits all of them, else the narrowest that still holds every label; each keeps its board-edge side-    edge_g = [g for g in groups if 'MACHINEPIN' not in (next(e for e in els if e['name'] == g['ref'])['package'] or '').upper()]-    if len(edge_g) > 1:-        widths = sorted(set(round(g['frame'][2] - g['frame'][0], 3) for g in edge_g), reverse=True)-        def refit(g, nf):-            # the tag's texts, re-sized (0.8, 0.6, 0.5) and re-centred to sit inside the frame nf-            out = []-            for t in g['texts']:-                for size in (t['size'],) + _sz(0.6, 0.5):-                    if size > t['size']: continue-                    w = 0.95 * size * len(t['text']) + 0.1; th = size * 1.1-                    if w <= (nf[2] - nf[0]) - 0.3:-                        cx_ = (t['box'][0] + t['box'][2]) / 2; cy_ = (t['box'][1] + t['box'][3]) / 2-                        cx_ = min(max(cx_, nf[0] + 0.15 + w / 2), nf[2] - 0.15 - w / 2)-                        out.append(dict(t, size=size, box=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3), round(cx_ + w / 2, 3), round(cy_ + th / 2, 3)], origin=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3)]))-                        break-                else:-                    return None-            return out-        for W in widths:-            trial = []-            for g in edge_g:-                f = g['frame']; e = next(x for x in els if x['name'] == g['ref'])-                left = (f[0] + f[2]) / 2 < cx_b-                nf = (f[0], f[1], round(f[0] + W, 3), f[3]) if left else (round(f[2] - W, 3), f[1], f[2], f[3])-                tx = refit(g, nf)-                if tx is None: trial = None; break-                no = (nf[0] - width / 2, nf[1] - width / 2, nf[2] + width / 2, nf[3] + width / 2)-                saved = list(placed_outers); placed_outers[:] = [po for po in placed_outers if po[0] != g['ref']]-                ok_, pc = fit_or_gap(e, nf, no, tx)-                ok_ = ok_ and not any(_ov(t['box'], b, 0.1) for t in tx for b in others if not (e['pads'] and b in e['pads']))-                placed_outers[:] = saved-                if not ok_: trial = None; break-                trial.append((g, nf, no, tx, pc))-            if trial:-                for g, nf, no, tx, pc in trial:-                    g['frame'] = nf; g['outer'] = no; g['texts'] = tx; g['pieces'] = pc-                break-    return groups, bad--def min_text():-    """the fab profile's smallest silkscreen text (mm): 3rdPartyFab 0.5, JLCPCB 0.8 (Adom-tested), else 0.5;-    AIFLOW_MIN_TEXT overrides"""-    if os.environ.get('AIFLOW_MIN_TEXT'): return float(os.environ['AIFLOW_MIN_TEXT'])-    try:-        d = json.loads((RUN / 'run.json').read_text()); slug = str(((d.get('data') or d).get('fabRules') or {}).get('slug') or '')-    except Exception:-        slug = ''-    return 0.8 if 'jlc' in slug.lower() else 0.5--def _sz(*sizes):-    """a text size ladder cut at the fab profile's minimum (never empty: the minimum itself at worst)"""-    m = min_text(); out = tuple(x for x in sizes if x >= m - 1e-9)-    return out or (m,)--def silk_value(v, ref):-    """the value as silkscreen prints it: 10uF 25V -> 10u, 100nF -> 100n, 15uH -> 15u, 68pF C0G -> 68p,-    5.6k -> 5k6, 1M -> 1M; an IC or LED gets none (its reference and the BOM say it)"""-    if not v or ref.startswith(('U', 'IC', 'Q', 'D', 'LED')): return None-    m = re.match(r'^\s*([\d.]+)\s*([pnumkKMG\u00b5]?)\s*(?:F|H|R|ohm|\u03a9)?\b', v)-    if not m: return v.split()[0][:6]-    num, unit = m.group(1), m.group(2).replace('\u00b5', 'u')-    if ref.startswith('R'):-        unit = unit.replace('K', 'k') or 'R'-        return num.replace('.', unit) if '.' in num else num + ('' if unit == 'R' else unit)-    return num + unit--def _seg_boxes(p, q, w=0.15, step=0.1):-    """a straight silk line from p to q as small boxes along it (for collision checks)"""-    n = max(1, int(math.hypot(q[0] - p[0], q[1] - p[1]) / step))-    h = w / 2-    return [(p[0] + (q[0] - p[0]) * i / n - h, p[1] + (q[1] - p[1]) * i / n - h, p[0] + (q[0] - p[0]) * i / n + h, p[1] + (q[1] - p[1]) * i / n + h) for i in range(n + 1)]--def _edge_point(b, toward):-    """the point on box b's edge nearest the point toward"""-    return (min(max(toward[0], b[0]), b[2]), min(max(toward[1], b[1]), b[3]))--def plan_leaders(items, els, outline, vias, taken, edge=0.3, gap=0.15, reach=10.0, sizes=None):-    """Labels with no room beside their part, the way the KiCad lane's leaders do it (silkscreen-leaders):-    the label goes where there is room, within reach of the part, and a thin silk line runs from the label-    to the part's edge. items: [{'ref', 'texts': [(text, kind)], 'target': box, 'frame': bool}], a framed-    item being a contact tag (name over net, framed). The label area, its frame and the line keep clear of-    every pad (but the line may end on the target's own outline), via, label and frame; the nearest spot-    whose line is clear wins, at 1.0 mm text where it fits, stepping down to the fab profile's smallest. Returns (placed, unresolved):-    placed = [{'ref', 'texts': [label dicts], 'frame': box or None, 'leader': [(x, y), (x, y)]}]."""-    pads = [(e, b) for e in els for b in e['pads']]-    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]-    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge)-    bodies = [(e, e['body']) for e in els if not e['bottom']]-    out, bad = [], []-    for it in items:-        tgt = it['target']; tcx, tcy = (tgt[0] + tgt[2]) / 2, (tgt[1] + tgt[3]) / 2-        own = next((e for e in els if e['name'] == it['ref']), None)-        best = None-        sizes = sizes or _sz(1.0, 0.8, 0.6, 0.5)-        # a tag that will not fit framed is tried again without its frame (still name over net, still led)-        for framed in ([True, False] if it.get('frame') else [False]):-          if best: break-          for size in sizes:-              th = size * 1.1-              ws = [0.95 * size * len(t) + 0.1 for t, _ in it['texts']]-              w = max(ws); h = th * len(it['texts']) + 0.1 * (len(it['texts']) - 1)-              pad_ = 0.25 if framed else 0.0-              W, H = w + 2 * pad_, h + 2 * pad_-              cands = []-              r = 1.0-              while r <= reach:-                  for k in range(24):-                      ang = 2 * math.pi * k / 24-                      cx, cy = tcx + r * math.cos(ang), tcy + r * math.sin(ang)-                      cands.append((r, cx - W / 2, cy - H / 2))-                  r += 0.25-              for (r, x0, y0) in cands:-                  area = (x0, y0, x0 + W, y0 + H)-                  if not (area[0] >= inner[0] and area[1] >= inner[1] and area[2] <= inner[2] and area[3] <= inner[3]): continue-                  if any(_ov(area, b, 0.12) for (_, b) in pads) or any(_ov(area, v, 0.12) for v in viab): continue-                  if any(_ov(area, t, gap) for t in taken): continue-                  if any(_ov(area, b, 0.05) for (e, b) in bodies): continue-                  a_ = _edge_point(area, (tcx, tcy)); b_ = _edge_point(tgt, (a_[0], a_[1]))-                  if math.hypot(b_[0] - a_[0], b_[1] - a_[1]) < 0.3: continue-                  segs = _seg_boxes(a_, b_)[2:-2]-                  if any(_ov(sb, b, 0.08) for sb in segs for (e, b) in pads): continue-                  if any(_ov(sb, v, 0.08) for sb in segs for v in viab): continue-                  if any(_ov(sb, t, 0.05) for sb in segs for t in taken): continue-                  best = (size, area, pad_, a_, b_, ws, th, framed); break-              if best: break-        if not best: bad.append('%s leader' % it['ref']); continue-        size, area, pad_, a_, b_, ws, th, framed = best-        texts, y = [], area[3] - pad_ - th-        for (t, kind), w in zip(it['texts'], ws):-            x0 = area[0] + pad_ + (area[2] - area[0] - 2 * pad_ - w) / 2-            texts.append({'ref': it['ref'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(x0, 3), round(y, 3), round(x0 + w, 3), round(y + th, 3)], 'origin': [round(x0, 3), round(y, 3)], 'leader': True})-            y -= th + 0.1-        taken.append(area); taken.extend(_seg_boxes(a_, b_))-        out.append({'ref': it['ref'], 'texts': texts, 'frame': tuple(round(v, 3) for v in area) if framed else None,-                    'leader': [(round(a_[0], 3), round(a_[1], 3)), (round(b_[0], 3), round(b_[1], 3))]})-    return out, bad--def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), skip=()):-    """Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and-    the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;-    a smaller size before giving up; whatever cannot fit is named, never dropped silently."""-    pads = [b for e in els for b in e['pads']]-    bodies = [e['body'] for e in els]-    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]-    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None-    taken, out, unresolved = list(fixed), [], []-    relax = [False]-    def free(bx, own=None):-        if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]): return False-        if any(_ov(bx, p, 0.1) for p in pads): return False-        if not relax[0] and any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False-        if any(_ov(bx, v, 0.05) for v in viab): return False-        if any(_ov(bx, t, gap) for t in taken): return False-        return True-    order = sorted([e for e in els if not e['bottom']], key=lambda e: (not is_contact(e), -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1]))))  # the contacts and their nets first: they are what a probe or a person looks for-    for e in order:-        if e['name'] in skip: continue  # labelled inside its contact frame-        b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2-        texts = [(e['name'], 'name')]-        if is_contact(e) and e.get('net'): texts.append((e['net'], 'net'))-        # every component's value, small, right next to its reference (aiflow-silkscreen: the reference is-        # larger, the value mandatory and visibly smaller); test pads and machine parts carry none-        pk_ = (e['package'] or '').upper()-        sv = silk_value(e['value'], e['name']) if not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None-        if sv: texts.append((sv, 'value'))-        # the reference and its value as one unit first (aiflow-silkscreen: the pair is atomic): the value-        # inline after the reference, or stacked under it, the unit placed where a name would go-        vt = next((t for t, k in texts if k == 'value'), None)-        if vt and not is_contact(e):-            vs = min_text(); pair = None-            for size in _sz(0.8, 0.6):-                nw, nh = 0.95 * size * len(e['name']) + 0.1, size * 1.1-                vw, vh = 0.95 * vs * len(vt) + 0.1, vs * 1.1-                for layout_ in ('inline', 'stack'):-                    w = nw + 0.12 + vw if layout_ == 'inline' else max(nw, vw)-                    h = nh if layout_ == 'inline' else nh + 0.08 + vh-                    cands = []-                    for d in (gap, gap + 0.4, gap + 0.9):-                        for f in (0.0, -0.5, 0.5):-                            cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]-                    for (x0, y0) in cands:-                        if layout_ == 'inline':-                            nb = (x0, y0, x0 + nw, y0 + nh); vb = (x0 + nw + 0.12, y0, x0 + nw + 0.12 + vw, y0 + vh)-                        else:-                            nb = (x0, y0 + vh + 0.08, x0 + nw, y0 + h); vb = (x0, y0, x0 + vw, y0 + vh)-                        if free(nb, own=b) and free(vb, own=b):-                            pair = (size, nb, vb); break-                    if pair: break-                if pair: break-            if pair:-                size, nb, vb = pair-                taken += [nb, vb]-                out.append({'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in nb], 'origin': [round(nb[0], 3), round(nb[1], 3)]})-                out.append({'ref': e['name'], 'kind': 'value', 'text': vt, 'size': vs, 'rot': 'R0', 'box': [round(v, 3) for v in vb], 'origin': [round(vb[0], 3), round(vb[1], 3)]})-                continue-        for txt, kind in texts:-            done = False-            if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):-                continue  # printed by an earlier pass: it stays, and it is already an obstacle-            # part names keep the library's 0.8 mm (a size change by name does not land in Fusion); the new-            # net labels may step down. EAGLE's vector font measured at about 0.95 x size per character.-            # near first, then small: every size is tried close to the part before any farther ring, since a-            # label beside its part at 0.6 mm reads better than one at 0.8 mm on the far side of a neighbour-            if kind == 'value':-                # beside its reference: right after it, under it, over it, before it, at the fab minimum-                nb = next((L['box'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), None)-                vs = min_text(); vh = vs * 1.1; vw = 0.95 * vs * len(txt) + 0.1-                if nb:-                    nrot = next((L['rot'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), 'R0')-                    if nrot == 'R0':-                        cands_v = [(nb[2] + 0.1, nb[1], 'R0'), (nb[0], nb[1] - 0.08 - vh, 'R0'), (nb[0], nb[3] + 0.08, 'R0'), (nb[0] - 0.1 - vw, nb[1], 'R0')]-                    else:-                        cands_v = [(nb[2] + 0.08, nb[1], 'R90'), (nb[0] - 0.08 - vh, nb[1], 'R90'), (nb[0], nb[3] + 0.1, 'R90'), (nb[0], nb[1] - 0.1 - vw, 'R90')]-                    for (x0, y0, rr) in cands_v:-                        w, h = (vw, vh) if rr == 'R0' else (vh, vw)-                        bx = (x0, y0, x0 + w, y0 + h)-                        if free(bx, own=b):-                            taken.append(bx)-                            out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})-                            done = True; break-                if not done:-                    # anywhere close round the part, at the same small size-                    for d in (gap, gap + 0.4, gap + 0.9):-                        for rr in ('R0', 'R90'):-                            w, h = (vw, vh) if rr == 'R0' else (vh, vw)-                            for (x0, y0) in ((cx - w / 2, b[3] + d), (cx - w / 2, b[1] - d - h), (b[2] + d, cy - h / 2), (b[0] - d - w, cy - h / 2)):-                                bx = (x0, y0, x0 + w, y0 + h)-                                if free(bx, own=b):-                                    taken.append(bx)-                                    out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})-                                    done = True; break-                            if done: break-                        if done: break-                if not done: unresolved.append('%s value' % e['name'])-                continue-            for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))):-              if done: break  # a contact's net stays close: far away it reads as a neighbour's-              for size in (_sz(0.8, 0.6) if kind == 'name' else _sz(0.8, 0.6, 0.5)):-                for rot in ('R0', 'R90'):-                    # R0 first (reads left to right); R90 (reads bottom to top) where a part has no room beside it-                    tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1-                    w, h = (tw, th) if rot == 'R0' else (th, tw)-                    cands = []-                    for d in ring_:-                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):-                            sx, sy = f * w, f * h-                            cands += [(cx - w / 2 + sx, b[3] + d), (cx - w / 2 + sx, b[1] - d - h), (b[2] + d, cy - h / 2 + sy), (b[0] - d - w, cy - h / 2 + sy)]-                        cands += [(b[2] + d, b[3] + d), (b[0] - d - w, b[3] + d), (b[2] + d, b[1] - d - h), (b[0] - d - w, b[1] - d - h)]-                    if kind == 'net' and outline:-                        # the edge side first: the side of the contact nearest the outline-                        dists = {'L': cx - outline[0], 'R': outline[2] - cx, 'B': cy - outline[1], 'T': outline[3] - cy}-                        side = min(dists, key=dists.get)-                        # nearest the contact first, with the edge side favoured: a label far from its contact-                        # (on the far side of another part) reads as someone else's-                        def k_(p):-                            mx, my = p[0] + w / 2, p[1] + h / 2-                            on_edge = {'L': mx < cx, 'R': mx > cx, 'B': my < cy, 'T': my > cy}[side]-                            return ((mx - cx) ** 2 + (my - cy) ** 2) ** 0.5 - (1.0 if on_edge else 0.0)-                        cands.sort(key=k_)-                    for (x0, y0) in cands:-                        bx = (x0, y0, x0 + w, y0 + h)-                        if free(bx, own=b):-                            taken.append(bx)-                            out.append({'ref': e['name'], 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})-                            done = True; break-                    if done: break-                if done: break-            if not done: unresolved.append('%s %s' % (e['name'], kind))-    if not relax[0] and unresolved:-        # last resort for what is left: a label may sit over another part's outline (not its copper)-        relax[0] = True-        left = [u for u in unresolved]; unresolved.clear()-        for u in left:-            ref, kind = u.rsplit(' ', 1)-            e = next(x for x in order if x['name'] == ref); b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2-            txt = e['name'] if kind == 'name' else (e.get('net') if kind == 'net' else silk_value(e['value'], e['name'])); done = False-            if not txt: continue-            for size in ((min_text(),) if kind == 'value' else _sz(0.8, 0.6)):-                for rot in ('R0', 'R90'):-                    tw = 0.95 * size * len(txt) + 0.1; th = size * 1.1-                    w, h = (tw, th) if rot == 'R0' else (th, tw)-                    cands = []-                    for d in (gap, gap + 0.4, gap + 0.9, gap + 1.5):-                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):-                            cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]-                    cands.sort(key=lambda p: ((p[0] + w / 2 - cx) ** 2 + (p[1] + h / 2 - cy) ** 2))-                    for (x0, y0) in cands:-                        bx = (x0, y0, x0 + w, y0 + h)-                        if free(bx, own=b):-                            taken.append(bx); out.append({'ref': ref, 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]}); done = True; break-                    if done: break-                if done: break-            if not done: unresolved.append(u)-    return out, unresolved, taken--def identity_spot(els, outline, vias, lines):-    """A clear place on the bottom face for the board's identity (bottom pads, through-holes and vias are-    the obstacles there): (x, y, size), the largest size that fits nearest the centre."""-    if not outline: return None-    obs = [b for e in els for b in e['pads'] if e['bottom']] + [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]-    obs += [b for e in els for b in e['pads'] if (b[2] - b[0]) > 0.9 and abs((b[2] - b[0]) - (b[3] - b[1])) < 0.05]  # round through-hole lands-    cx0, cy0 = (outline[0] + outline[2]) / 2, (outline[1] + outline[3]) / 2-    for size in (1.0, 0.8, 0.7, 0.6):-        w = max(0.95 * size * len(l) for l in lines); h = len(lines) * size * 1.5-        best = None-        for i in range(-30, 31):-            for j in range(-30, 31):-                x, y = cx0 + i * 0.4, cy0 + j * 0.4-                bx = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)-                if bx[0] < outline[0] + 0.5 or bx[2] > outline[2] - 0.5 or bx[1] < outline[1] + 0.5 or bx[3] > outline[3] - 0.5: continue-                if any(_ov(bx, o, 0.1) for o in obs): continue-                d = abs(i) + abs(j)-                if best is None or d < best[0]: best = (d, x, y)-        if best: return (best[1], best[2], size)-    return None- def cmd_silkscreen(a):     st = load_state(); name = a.design or st.get('design')     if not name: err('which design? pass --design <name>')
tools/kicad-cli-remote+16−16
@@ -9,10 +9,10 @@ # The board travels with its sibling .kicad_pro / .kicad_dru so project rules are kept. The # schematic travels with its sub-sheets, .kicad_pro, sym-lib-table and fp-lib-table, and every # ${KIPRJMOD} library the sym-lib-table names, in the same folders relative to the project.-# Env: KICAD_REMOTE_TARGET (default ConfRoomROG), KICAD_REMOTE_CLI (native kicad-cli path on it),+# Env: KICAD_REMOTE_TARGET (adom-aiflow sets the run's desktop; unset: this machine's own desktop), KICAD_REMOTE_CLI (native kicad-cli path on it), #      KICAD_REMOTE_THREAD (ai-thread name for the bridge; default kicad-cli-remote). set -euo pipefail-T="${KICAD_REMOTE_TARGET:-ConfRoomROG}"+T="${KICAD_REMOTE_TARGET:-}" TH="${KICAD_REMOTE_THREAD:-kicad-cli-remote}" # The desktop's native kicad-cli: KICAD_REMOTE_CLI, else what the KiCad bridge reports (install.kicadCli). CLI="${KICAD_REMOTE_CLI:-}"@@ -21,7 +21,7 @@ if [[ "${1:-}" == "--version" ]]; then echo "kicad-cli-remote -> $T (native KiCa  find_cli() {   if [[ -z "$CLI" ]]; then-    CLI=$(adom-bridge --target "$T" kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' --ai-thread "$TH" | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""))')+    CLI=$(adom-bridge ${T:+--target "$T"} kicad_status '{"reason":"find the native kicad-cli for adom-aiflow"}' --ai-thread "$TH" | python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("install") or {}).get("kicadCli",""))')     [[ -n "$CLI" ]] || { echo "kicad-cli-remote: no KiCad found on $T (set KICAD_REMOTE_CLI)" >&2; exit 2; }   fi }@@ -29,11 +29,11 @@ find_tmp() {   # A temp folder on the desktop: KICAD_REMOTE_TMP, else the desktop's %TEMP%.   RTMP="${KICAD_REMOTE_TMP:-}"   if [[ -z "$RTMP" ]]; then-    RTMP=$(adom-bridge --target "$T" shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' --ai-thread "$TH" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("stdout","").strip().replace(chr(92),"/"))')+    RTMP=$(adom-bridge ${T:+--target "$T"} shell_execute '{"command":"echo %TEMP%","reason":"temp folder for an adom-aiflow kicad-cli job"}' --ai-thread "$TH" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("stdout","").strip().replace(chr(92),"/"))')   fi } cleanup_remote() {-  adom-bridge --target "$T" shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" --ai-thread "$TH" >/dev/null || true+  adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the kicad-cli temp folder"}))' "$1")" --ai-thread "$TH" >/dev/null || true }  if [[ "${1:-}" == "sch" && "${2:-}" == "export" && "${3:-}" == "netlist" ]]; then@@ -90,13 +90,13 @@ PY     sub="${line%%$'\t'*}"; list="${line#*$'\t'}"     d="$dest"; [[ -n "$sub" ]] && d="$dest/$sub"     args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":json.loads(sys.argv[2]),"dest":sys.argv[1],"reason":"schematic netlist export for adom-aiflow (native kicad-cli on the desktop)"}))' "$d" "$list")-    adom-bridge --target "$T" send_files "$args" --ai-thread "$TH" >/dev/null+    adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null   done < <(python3 -c 'import json,sys;[print(k+"\t"+json.dumps(v)) for k,v in json.loads(sys.argv[1]).items()]' "$groups")   winsch="$dest/$base.kicad_sch"; winnet="$dest/$base.aiflow.net"   cmd=$(python3 -c 'import json,sys;cli,s,o,f=sys.argv[1:5];c=f"\"{cli}\" sch export netlist --format {f} --output \"{o}\" \"{s}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":180,"reason":"schematic netlist export for adom-aiflow"}))' "$CLI" "$winsch" "$winnet" "$fmt")-  res=$(adom-bridge --target "$T" shell_execute "$cmd" --ai-thread "$TH")+  res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH")   tmp="$(mktemp -d)"-  adom-bridge --target "$T" pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the netlist back"}))' "$winnet" "$tmp")" --ai-thread "$TH" >/dev/null+  adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the netlist back"}))' "$winnet" "$tmp")" --ai-thread "$TH" >/dev/null   if [[ -f "$tmp/$base.aiflow.net" ]]; then     mv "$tmp/$base.aiflow.net" "$out"     cleanup_remote "$dest"@@ -131,23 +131,23 @@ if [[ "${1:-}" == "pcb" && "${2:-}" == "export" && "${3:-}" =~ ^(gerbers|drill|p   files=("$(readlink -f "$board")")   for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done   args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"manufacturing export for adom-aiflow (native kicad-cli on the desktop)"}))' "$dest" "${files[@]}")-  adom-bridge --target "$T" send_files "$args" --ai-thread "$TH" >/dev/null+  adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null   # a folder output keeps its trailing separator, doubled before the closing quote so Windows does not read \" as a quote   rout="$dest/out/"; [[ "$kind" == "pos" ]] && rout="$dest/out/$(basename "$out")"   cmd=$(python3 -c 'import json,sys;cli,b,d,r,k=sys.argv[1:6];x=" ".join(sys.argv[6:]);r=r.replace("/",chr(92));r=r+chr(92) if r.endswith(chr(92)) else r;c=f"mkdir \"{d}/out\" 2>nul & \"{cli}\" pcb export {k} {x} --output \"{r}\" \"{b}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":600,"reason":"manufacturing export for adom-aiflow"}))' "$CLI" "$dest/$base.kicad_pcb" "$dest" "$rout" "$kind" "${pass[@]}")-  res=$(adom-bridge --target "$T" shell_execute "$cmd" --ai-thread "$TH")+  res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH")   if [[ "$kind" == "pos" ]]; then     names=("$(basename "$out")"); local_dir="$(dirname "$out")"   else     local_dir="${out%/}"-    lst=$(adom-bridge --target "$T" shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the exported manufacturing files"}))' "$dest/out")" --ai-thread "$TH")+    lst=$(adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"dir /b \""+sys.argv[1].replace("/","\\")+"\"","reason":"list the exported manufacturing files"}))' "$dest/out")" --ai-thread "$TH")     mapfile -t names < <(echo "$lst" | python3 -c 'import sys,json;[print(l.strip()) for l in json.load(sys.stdin).get("stdout","").splitlines() if l.strip()]')   fi   mkdir -p "$local_dir"   code=0   if [[ ${#names[@]} -eq 0 ]]; then code=1; else     paths=(); for n in "${names[@]}"; do paths+=("$dest/out/$n"); done-    adom-bridge --target "$T" pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the manufacturing files back"}))' "$local_dir" "${paths[@]}")" --ai-thread "$TH" >/dev/null+    adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"saveTo":sys.argv[1],"reason":"bring the manufacturing files back"}))' "$local_dir" "${paths[@]}")" --ai-thread "$TH" >/dev/null     for n in "${names[@]}"; do [[ -f "$local_dir/$n" ]] || code=1; done   fi   cleanup_remote "$dest"@@ -183,15 +183,15 @@ base="$(basename "$board" .kicad_pcb)" files=("$(readlink -f "$board")") for ext in kicad_pro kicad_dru; do f="${board%.kicad_pcb}.$ext"; [[ -f "$f" ]] && files+=("$(readlink -f "$f")"); done args=$(python3 -c 'import json,sys;print(json.dumps({"filePaths":sys.argv[2:],"dest":sys.argv[1],"reason":"offline KiCad DRC for adom-aiflow (native kicad-cli on the desktop)"}))' "$dest" "${files[@]}")-adom-bridge --target "$T" send_files "$args" --ai-thread "$TH" >/dev/null+adom-bridge ${T:+--target "$T"} send_files "$args" --ai-thread "$TH" >/dev/null winboard="$dest/$base.kicad_pcb"; winrep="$dest/$base.drc.$fmt" cmd=$(python3 -c 'import json,sys;cli,b,r,f=sys.argv[1:5];x=" ".join(sys.argv[5:]);c=f"\"{cli}\" pcb drc {x} --format {f} --output \"{r}\" \"{b}\"";print(json.dumps({"command":c.replace("/","\\"),"timeoutSeconds":240,"reason":"offline KiCad DRC for adom-aiflow"}))' "$CLI" "$winboard" "$winrep" "$fmt" "${extra[@]}")-res=$(adom-bridge --target "$T" shell_execute "$cmd" --ai-thread "$TH")+res=$(adom-bridge ${T:+--target "$T"} shell_execute "$cmd" --ai-thread "$TH") tmp="$(mktemp -d)"-adom-bridge --target "$T" pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the DRC report back"}))' "$winrep" "$tmp")" --ai-thread "$TH" >/dev/null+adom-bridge ${T:+--target "$T"} pull_file "$(python3 -c 'import json,sys;print(json.dumps({"filePaths":[sys.argv[1]],"saveTo":sys.argv[2],"reason":"bring the DRC report back"}))' "$winrep" "$tmp")" --ai-thread "$TH" >/dev/null if [[ -f "$tmp/$base.drc.$fmt" ]]; then   mv "$tmp/$base.drc.$fmt" "$out"-  adom-bridge --target "$T" shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the DRC temp folder"}))' "$dest")" --ai-thread "$TH" >/dev/null || true+  adom-bridge ${T:+--target "$T"} shell_execute "$(python3 -c 'import json,sys;print(json.dumps({"command":"rmdir /s /q \""+sys.argv[1].replace("/","\\")+"\"","reason":"clean up the DRC temp folder"}))' "$dest")" --ai-thread "$TH" >/dev/null || true   rmdir "$tmp" 2>/dev/null || true   exit 0 fi
tools/project-common.pyadded+287
@@ -0,0 +1,287 @@+"""project-common: the tool-neutral half of an EDA project workflow (adom-aiflow <tool> <step>).++Each backend's workflow script (tools/<tool>-project.py) is run with this file in front of it, so these+helpers are shared: the run's desktop and thread, one adom-bridge verb, the project state under+<run>/<tool>/, the flow's proof files, wiki component pages, distributor numbers checked against the+exact MPN, and a reference KiCad board re-framed into a board frame (lower-left corner at 0,0, y up).+A script sets TOOL before calling anything that touches state (init(tool)).+"""+import argparse, csv, json, math, os, re, shutil, subprocess, sys, time+from pathlib import Path++RUN = Path(os.environ.get('AIFLOW_RUN', 'aiflow-run'))+TARGET = os.environ.get('AIFLOW_TARGET', '')+if not TARGET:+    # no run yet and no --target: the bridge's primary machine (the desktop this workspace belongs to)+    try:+        _t = json.loads(subprocess.run(['adom-bridge', 'targets'], capture_output=True, text=True, timeout=30).stdout)+        TARGET = _t.get('primaryMachine') or ''+    except Exception:+        TARGET = ''+THREAD = os.environ.get('AIFLOW_THREAD', 'adom-aiflow')+HOME_WIN = os.environ.get('AIFLOW_BOX_DIR', '')  # folder on the box; default under the user's Documents+TOOL = 'project'+STATE = RUN / TOOL+++def init(tool):+    global TOOL, STATE+    TOOL = tool+    STATE = RUN / tool+++def ok(msg, hints=()):+    print('OK: ' + msg)+    for h in hints:+        if h: print('Hint: ' + h)+    sys.exit(0)+++def err(msg, hints=()):+    print('ERROR: ' + msg)+    for h in hints:+        if h: print('Hint: ' + h)+    sys.exit(1)+++def say(msg):+    print(msg, flush=True)+++def run(cmd, timeout=600, check=False):+    p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+    if check and p.returncode != 0:+        err('%s failed: %s' % (cmd[0], (p.stdout + p.stderr).strip()[-400:]))+    return p+++def ab(verb, args, timeout=600):+    """One adom-bridge verb on the run's desktop; the reply with a JSON-string `output` unwrapped."""+    if not TARGET: err('the run has no desktop target', ['Start the run with --target <box> (adom-aiflow start ... --target), or set AIFLOW_TARGET.'])+    a = dict(args); a.setdefault('reason', 'adom-aiflow %s' % TOOL)+    p = run(['adom-bridge', '--target', TARGET, '--ai-thread', THREAD, verb, json.dumps(a)], timeout=timeout)+    try: d = json.loads(p.stdout)+    except Exception: return {'success': False, 'error': (p.stdout + p.stderr)[-400:]}+    o = d.get('output')+    if isinstance(o, str) and o.startswith('{'):+        try: d = json.loads(o)+        except Exception: pass+    # a tool's error box (Altium paints its text, so the picture is the only copy): keep it where the+    # run's AI can look at it, and say where+    shot = d.get('errorShot') if isinstance(d, dict) else None+    if isinstance(shot, str) and len(shot) > 100:+        import base64+        STATE.mkdir(parents=True, exist_ok=True)+        f = STATE / ('errorShot-%s-%d.png' % (verb, int(time.time())))+        f.write_bytes(base64.b64decode(shot.split(',')[-1]))+        d['errorShot'] = str(f)+        d['_hint'] = ((d.get('_hint') or '') + ' The error box is saved at %s: look at it.' % f).strip()+    return d+++def load_state():+    f = STATE / 'project.json'+    return json.loads(f.read_text()) if f.is_file() else {'parts': {}}+++def save_state(st):+    STATE.mkdir(parents=True, exist_ok=True)+    (STATE / 'project.json').write_text(json.dumps(st, indent=1))+++def box_dir():+    """The project's folder on the desktop, under the user's Documents (one per run)."""+    if HOME_WIN: return HOME_WIN.rstrip('/')+    st = load_state()+    if st.get('boxDir'): return st['boxDir']+    r = ab('shell_execute', {'command': 'echo %USERPROFILE%'})+    out = [l.strip() for l in str(r.get('stdout') or r.get('output') or '').splitlines() if l.strip() and '%' not in l]+    home = out[-1] if out else 'C:/Users/Public'+    d = home.replace('\\', '/') + '/Documents/AdomAiFlow/' + RUN.resolve().name+    st['boxDir'] = d; save_state(st)+    return d+++def win(p):+    return p.replace('/', '\\')+++def send(paths, dest):+    r = ab('send_files', {'filePaths': [str(Path(p).resolve()) for p in paths], 'dest': dest, 'destIsFolder': True}, timeout=1800)+    if r.get('success') is False: err('could not copy files to the desktop: %s' % str(r.get('error'))[:300], ['Check `adom-bridge --target %s ping` and retry.' % TARGET])+++def prove(key, data=None):+    """Record that a step's own work ran (the flow's proof, read by `step` and `finish`): <run>/proofs/<key>.json."""+    d = RUN / 'proofs'; d.mkdir(parents=True, exist_ok=True)+    (d / (key + '.json')).write_text(json.dumps({'at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'tool': TOOL, 'data': data or {}}, indent=1))+++def lookup_numbers(mpn, page_links, cache):+    """Mouser, DigiKey and LCSC numbers for one MPN, each checked to belong to that exact MPN.+    The page's own links count; the distributor CLIs fill the gaps; a dead backend is said, never guessed."""+    got = {k: v for k, v in (page_links or {}).items() if k in ('mouser', 'digikey', 'lcsc') and v}+    notes = []+    if mpn in cache:+        for k, v in cache[mpn].items():+            if k in ('mouser', 'digikey', 'lcsc') and v and not got.get(k): got[k] = v+    def want(k): return not got.get(k)+    norm = lambda s: re.sub(r'[^A-Z0-9]', '', str(s).upper())+    for k, tool in (('mouser', 'adom-mouser'), ('digikey', 'adom-digikey')):+        if not want(k) or not shutil.which(tool): continue+        p = run([tool, 'part', mpn], timeout=90)+        if p.returncode != 0 or not p.stdout.strip().startswith(('{', '[')):+            txt = (p.stdout + p.stderr)+            m = re.search(r'HTTP (\d{3})', txt)+            notes.append('%s service %s' % (k, ('down (HTTP %s)' % m.group(1)) if m else 'unreachable')); continue+        try: d = json.loads(p.stdout)+        except Exception: continue+        items = d if isinstance(d, list) else (d.get('parts') or d.get('results') or [d])+        for it in items:+            if not isinstance(it, dict): continue+            m = it.get('mpn') or it.get('ManufacturerPartNumber') or it.get('manufacturerPartNumber')+            pn = it.get('mouserPartNumber') or it.get('MouserPartNumber') or it.get('digikeyPartNumber') or it.get('DigiKeyPartNumber') or it.get('partNumber')+            if m and pn and norm(m) == norm(mpn): got[k] = pn; break+    if want('lcsc') and shutil.which('adom-jlcpcb'):+        p = run(['adom-jlcpcb', 'search', mpn], timeout=90)+        try:+            d = json.loads(p.stdout); items = d if isinstance(d, list) else (d.get('results') or d.get('parts') or [])+            for it in items:+                m = it.get('mpn') or it.get('mfrPart') or it.get('manufacturerPartNumber')+                c = it.get('lcsc') or it.get('lcscPart') or it.get('code')+                if m and c and norm(m) == norm(mpn): got['lcsc'] = c; break+        except Exception:+            notes.append('lcsc service unreachable')+    return got, notes+++def wiki_page(slug, dest):+    """Clone a wiki component page into dest (fresh every time: its numbers may have been added since)."""+    shutil.rmtree(dest, ignore_errors=True)+    r = run(['adom-wiki', 'repo', 'clone', slug, '--dir', str(dest)], timeout=900)+    if r.returncode != 0 or not (dest / 'page.json').is_file():+        return None+    return json.loads((dest / 'page.json').read_text())+++def pick_files(d, comp):+    parts = comp.get('parts', {})+    def first(*names):+        for n in names:+            if n and (d / n).is_file(): return d / n+        return None+    sym = first(parts.get('symbol')) or next(iter(sorted(x for x in d.glob('*.kicad_sym') if 'outline' not in x.name)), None)+    outline = next(iter(sorted(d.glob('*-outline.kicad_sym'))), None)+    fp = first(parts.get('footprint')) or next(iter(sorted(d.glob('*.kicad_mod'))), None)+    step = first(parts.get('model_3d_step'), parts.get('step')) or next(iter(sorted(x for x in d.glob('*.step') if 'unetched' not in x.name and 'uncoloured' not in x.name)), None)+    return sym, outline, fp, step+++def kicad_footprints(text):+    out = {}+    for m in re.finditer(r'\(footprint "([^"]+)".*?\(at ([-\d.]+) ([-\d.]+)(?: ([-\d.]+))?\).*?\(property "Reference" "([^"]+)"', text, re.S):+        out[m.group(5)] = (float(m.group(2)), float(m.group(3)), float(m.group(4) or 0) % 360)+    return out+++def kicad_outline(text):+    xs, ys = [], []+    for m in re.finditer(r'\(gr_(?:line|rect|arc)[^()]*\(start ([-\d.]+) ([-\d.]+)\)\s*(?:\(mid [-\d.]+ [-\d.]+\)\s*)?\(end ([-\d.]+) ([-\d.]+)\).*?\(layer "Edge.Cuts"\)', text, re.S):+        xs += [float(m.group(1)), float(m.group(3))]; ys += [float(m.group(2)), float(m.group(4))]+    if not xs: err('the reference board has no Edge.Cuts outline')+    return min(xs), min(ys), max(xs), max(ys)+++def reference(kicad, spec_path, out_dir, frame_name):+    """A reference KiCad board's placement and the spec's coordinates in the board frame a y-up tool uses+    (the outline's lower-left corner at 0,0). Writes moves.json (the run's plan frame, for place check/land),+    fixed.json (the tool's frame: outline size and fixed parts) and spec.json."""+    text = Path(kicad).read_text(); x0, y0, x1, y1 = kicad_outline(text)+    fps = kicad_footprints(text); spec = json.loads(Path(spec_path).read_text())+    def to_run(x, y): return round(x - x0, 3), round(y - y1, 3)+    fixed = set(spec.get('fixedRefs') or [])+    moves = {r: {'x': to_run(x, y)[0], 'y': to_run(x, y)[1], 'rotation': rot} for r, (x, y, rot) in fps.items() if r not in fixed}+    out = Path(out_dir); out.mkdir(parents=True, exist_ok=True)+    (out / 'moves.json').write_text(json.dumps(moves, indent=1))+    fixed_pos = {r: {'x': round(x - x0, 3), 'y': round(y1 - y, 3), 'rot': rot} for r, (x, y, rot) in fps.items() if r in fixed}+    allpos = {r: {'x': round(x - x0, 3), 'y': round(y1 - y, 3), 'rot': rot} for r, (x, y, rot) in fps.items()}+    (out / 'fixed.json').write_text(json.dumps({'outlineMm': [round(x1 - x0, 3), round(y1 - y0, 3)], 'fixed': fixed_pos, 'all': allpos}, indent=1))+    moved = 0+    def tr(p): return [round(p[0] - x0, 3), round(p[1] - y1, 3)]+    for key in ('pours', 'planeUnder'):+        for z in spec.get(key, []) or []:+            if 'polygon' in z and z['polygon'] and any(abs(v) > 40 for pt in z['polygon'] for v in pt): z['polygon'] = [tr(p) for p in z['polygon']]; moved += 1+    for v in spec.get('stitchVias', []) or []:+        if abs(v.get('x', 0)) > 40 or abs(v.get('y', 0)) > 40: v['x'], v['y'] = tr([v['x'], v['y']]); moved += 1+    spec['frameNote'] = "Coordinates are in the %s board's frame (reference board shifted by (%g, %g))." % (frame_name, -x0, -y1)+    (out / 'spec.json').write_text(json.dumps(spec, indent=1))+    dru = Path(kicad).with_suffix('.kicad_dru')+    if dru.is_file(): shutil.copy(dru, out / 'board.kicad_dru')  # the fab's rules travel with the design+    return moves, fixed_pos, (x1 - x0, y1 - y0), moved+++# ---------------------------------------------------------------- manufacturing files (shared by the lanes)++TP_NAME = re.compile(r'test[ _.-]?(point|pad)|^tp[_-]', re.I)+MACHINE_NAME = re.compile(r'machine[ _-]?(contact|pin)|press[ _-]?fit', re.I)+++def table(path):+    """A CSV as (header, rows): a tool's preamble (Altium writes a title block above the table) and blank+    rows skipped; the header is the first row naming a designator column."""+    rows = [r for r in csv.reader(open(path, newline='', encoding='utf-8-sig', errors='replace')) if any(c.strip() for c in r)]+    for i, r in enumerate(rows):+        if any(c.strip().lower() in ('designator', 'designators', 'refdes', 'references', 'reference', 'parts', 'part') for c in r):+            return r, rows[i + 1:]+    return (rows[0], rows[1:]) if rows else ([], [])+++def fab_rewrite(path, roles, drop_machine=False):+    """Rewrite a BOM or pick-and-place CSV in place: test points out, machine contacts/pins out of the+    CPL (and the fab BOM) or marked DNP in the BOM. A BOM row also names its parts by package and maker, so+    a test point the board source missed is still caught. Returns the refs it changed."""+    head, body = table(path)+    if not head: return set()+    rows = [head] + body+    h = [c.strip().lower() for c in rows[0]]+    di = next((h.index(c) for c in ('designator', 'designators', 'refdes', 'references', 'reference', 'parts', 'part', 'name') if c in h), None)+    if di is None: return set()+    qi = h.index('quantity') if 'quantity' in h else None+    bom = qi is not None or any(c in h for c in ('mpn', 'lcsc', 'lcsc part #'))+    if bom and not drop_machine and 'dnp' not in h: rows[0].append('DNP'); h.append('dnp')+    dn = h.index('dnp') if 'dnp' in h else None+    out, changed = [rows[0]], set()+    for row in rows[1:]:+        if di >= len(row): out.append(row); continue+        refs = [x for x in re.split(r'[ ,;]+', row[di]) if x.strip()]+        sep = ', ' if ',' in row[di] else ' '+        info = ' '.join(row[i] for i, c in enumerate(h) if c in ('package', 'footprint', 'mpn', 'value', 'comment', 'libref') and i < len(row))+        maker = ' '.join(row[i] for i, c in enumerate(h) if c in ('manufacturer', 'mpn') and i < len(row))+        if bom and (TP_NAME.search(info) or re.search(r'pcb copper|board only', maker, re.I)):+            for r in refs: roles.setdefault(r, 'tp')+        elif bom and MACHINE_NAME.search(info):+            for r in refs: roles.setdefault(r, 'machine')+        keep = [r for r in refs if roles.get(r) not in ('tp', 'machine')]+        mach = [r for r in refs if roles.get(r) == 'machine']+        changed |= set(refs) - set(keep)+        def part(rs, dnp):+            nr = list(row) + [''] * (len(h) - len(row)); nr[di] = sep.join(rs)+            if qi is not None: nr[qi] = str(len(rs))+            if dn is not None: nr[dn] = 'DNP' if dnp else ''+            return nr+        if keep: out.append(part(keep, False))+        if mach and bom and not drop_machine: out.append(part(mach, True))+    with open(path, 'w', newline='', encoding='utf-8') as f: csv.writer(f).writerows(out)+    return changed+++def refs_in(path, cols=('designator', 'designators', 'refdes', 'references', 'reference', 'parts', 'part'), dnp_too=True):+    head, body = table(path)+    h = [c.strip().lower() for c in head]+    i = next((h.index(c) for c in cols if c in h), None)+    d = h.index('dnp') if 'dnp' in h else None+    out = set()+    for row in body:+        if not dnp_too and d is not None and d < len(row) and row[d].strip(): continue+        if i is not None and i < len(row): out |= {x.strip() for x in re.split(r'[ ,;]+', row[i]) if x.strip()}+    return out
tools/silkscreen-plan.pyadded+700
@@ -0,0 +1,700 @@+"""silkscreen-plan.py: the silkscreen planner both board lanes share (moved verbatim out of+fusion-project.py, 2026-10-15). It sees the board as plain geometry and answers where each label, contact+frame, leader and the board identity go; reading the board and landing the plan are each lane's own.++  els      [{name, package, value, x, y, bottom, pads: [box], body: box, rot, net, nameBox, ...}] mm, y up+  outline  (x0, y0, x1, y1) or None        vias  [(x, y, r)]        texts  [{text, layer 21|22|'wire21', box}]++Loaded ahead of the lane's own script (`adom-aiflow fusion ...`, `adom-aiflow altium ...`); RUN is the+run directory the lane defines.+"""++import json, math, os, re, sys++# How big a label's text is, the one place the planner measures it: a box TEXT_W x size per character+# plus TEXT_PADK x size plus TEXT_PAD wide, TEXT_H x size tall. The defaults are Fusion's vector text;+# a lane whose tool measures text differently sets its own (Altium's stroke font: see altium-project.py).+TEXT_W, TEXT_PADK, TEXT_PAD, TEXT_H = 0.95, 0.0, 0.1, 1.1++def tw_(t, size): return TEXT_W * size * len(t or '') + TEXT_PADK * size + TEXT_PAD++def _ov(a, b, g=0.0): return not (a[2] + g <= b[0] or b[2] + g <= a[0] or a[3] + g <= b[1] or b[3] + g <= a[1])++def text_box(x, y, s, size, align='bottom-left'):+    """EAGLE vector text (ratio 15): about 0.85 x size per character, size tall."""+    w, h = 0.85 * size * max(1, len(s)), size+    x0 = x - w / 2 if 'center' in align and not align.endswith('left') and not align.endswith('right') or align == 'center' else (x - w if align.endswith('right') else x)+    if align.startswith('top'): y0 = y - h+    elif align.startswith('center'): y0 = y - h / 2+    else: y0 = y+    if align == 'center': x0 = x - w / 2+    return (x0, y0, x0 + w, y0 + h)++def is_contact(e):+    """edge machine contacts and corner machine pins: both get a framed tag (name, and net when wired)"""+    pk = (e['package'] or '').upper()+    return ('MACHINECONTACT' in pk or 'MACHINEPIN' in pk) and not e['bottom']++def _ring_gaps(f, blockers, width=0.15, clear=0.15):+    """The frame f (stroke centre line) as straight pieces with gaps where it would cross a blocker (pad,+    mask opening or via, inflated by the clearance), the KiCad lane's 'interrupted for clearance' frame.+    Returns (pieces, kept fraction)."""+    x0, y0, x1, y1 = f; h = width / 2 + clear+    sides = [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]+    pieces, total, kept = [], 0.0, 0.0+    for (a, b) in sides:+        horiz = abs(a[1] - b[1]) < 1e-9+        lo, hi = (min(a[0], b[0]), max(a[0], b[0])) if horiz else (min(a[1], b[1]), max(a[1], b[1]))+        c = a[1] if horiz else a[0]+        cuts = []+        for bb in blockers:+            if horiz and bb[1] - h < c < bb[3] + h: cuts.append((bb[0] - h, bb[2] + h))+            if not horiz and bb[0] - h < c < bb[2] + h: cuts.append((bb[1] - h, bb[3] + h))+        cuts.sort(); cur = lo; total += hi - lo+        for (u, v) in cuts:+            if v <= cur or u >= hi: continue+            if u > cur + 0.15: pieces.append(((cur, c), (u, c)) if horiz else ((c, cur), (c, u))); kept += u - cur+            cur = max(cur, v)+        if hi > cur + 0.15: pieces.append(((cur, c), (hi, c)) if horiz else ((c, cur), (c, hi))); kept += hi - cur+    return pieces, (kept / total if total else 0)++def plan_frames(els, outline, vias, edge=0.12, width=0.15, gap=0.2, corner_orients=None):+    """Machine-contact groups, the way the KiCad lane draws them (aiflow-silkscreen, contactBorders): one+    printed frame per contact enclosing the contact, its reference and its net, as large as the space+    allows. A column of contacts shares rails (from just inside the board edge to past the widest label)+    and a row height of the pitch less 0.2 mm, so the column reads as a set of tags. A frame is rejected+    when any other part's copper, any via or the board edge comes inside it; that contact then gets its own+    frame (its own width, shifted up or down), and only if nothing fits does it fall back to a frame round+    the pad with the labels outside. Text steps down from 0.8 to 0.5 mm before a frame is given up.+    Returns (groups, unresolved): a group is {'ref', 'frame': (x0, y0, x1, y1) stroke centre line,+    'texts': [label dicts like plan_labels makes]}."""+    if not outline: return [], []+    cs = [e for e in els if is_contact(e) and e['pads']]+    others = [b for e in els if not is_contact(e) for b in e['pads']] + [b for e in els if is_contact(e) for b in e['pads']]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    cx_b = (outline[0] + outline[2]) / 2+    def pad_box(e): return (min(p[0] for p in e['pads']), min(p[1] for p in e['pads']), max(p[2] for p in e['pads']), max(p[3] for p in e['pads']))+    def tw(t, size): return tw_(t, size)+    def fits(e, fr):+        """the frame fr (outer bounds incl. stroke) holds nothing of anyone else and stays on the board"""+        if fr[0] < outline[0] + edge or fr[2] > outline[2] - edge or fr[1] < outline[1] + edge or fr[3] > outline[3] - edge: return False+        own = pad_box(e)+        for b in others:+            if b == own or (own[0] <= b[0] and b[2] <= own[2] and own[1] <= b[1] and b[3] <= own[3]): continue+            if _ov(fr, b, 0.15): return False+        for v in viab:+            inside_own = own[0] <= (v[0] + v[2]) / 2 <= own[2] and own[1] <= (v[1] + v[3]) / 2 <= own[3]+            if not inside_own and _ov(fr, v, 0.15): return False+        for o in els:+            if o is e or is_contact(o) or o['bottom'] or o['name'] in skip_bodies: continue+            if _ov(fr, o['body'], 0.05): return False+        if any(_ov(fr, ob, 0.2) for (ref_, ob) in placed_outers if ref_ != e['name']): return False+        return True+    skip_bodies = set(); placed_outers = []+    def fit_or_gap(e, f, outer, texts):+        """(ok, pieces): the frame fits whole (pieces None), or breaks around small pads and vias that sit on its+        line (none shut inside, at least 70% of it drawn, no label on them), the aiflow-silkscreen rule"""+        if fits(e, outer): return True, None+        small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]+        small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]+        small += [v for v in viab if _ov(outer, v, 0.3)]+        if not small: return False, None+        if any(f[0] + 0.1 < (b[0] + b[2]) / 2 < f[2] - 0.1 and f[1] + 0.1 < (b[1] + b[3]) / 2 < f[3] - 0.1 for b in small): return False, None+        saved = list(others); saved_v = list(viab)+        others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]+        skip_bodies.update(o['name'] for o in small_owners)+        ok_ = fits(e, outer)+        others[:] = saved; viab[:] = saved_v; skip_bodies.clear()+        pieces, frac = _ring_gaps(f, small)+        if not ok_ or frac < 0.7 or any(_ov(t['box'], b, 0.1) for t in texts for b in small): return False, None+        return True, pieces+    def layout(e, size, rail=None, dy=0.0, pitch=None, mode='side'):+        """(frame, texts) for one contact. 'side': name over net inward of the pad. 'stack': name above the+        pad and net below it, both centred on the pad, for a contact with a big part close beside it."""+        pb = pad_box(e); left_col = (pb[0] + pb[2]) / 2 < cx_b; cy = (pb[1] + pb[3]) / 2 + dy+        if mode == 'stack':+            pcx = (pb[0] + pb[2]) / 2; th = size * TEXT_H+            nw, vw = tw(e['name'], size), tw(e['net'] or '', size)+            ny0 = pb[3] + 0.12; vy0 = pb[1] - 0.12 - th+            texts = [{'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(pcx - nw / 2, 3), round(ny0, 3), round(pcx + nw / 2, 3), round(ny0 + th, 3)], 'origin': [round(pcx - nw / 2, 3), round(ny0, 3)]}]+            if e['net']:+                texts.append({'ref': e['name'], 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R0', 'box': [round(pcx - vw / 2, 3), round(vy0, 3), round(pcx + vw / 2, 3), round(vy0 + th, 3)], 'origin': [round(pcx - vw / 2, 3), round(vy0, 3)]})+            span = max(nw, vw, pb[2] - pb[0]) / 2+            if left_col: x0 = outline[0] + edge + width / 2; x1 = rail if rail else pcx + span + 0.15+            else: x1 = outline[2] - edge - width / 2; x0 = rail if rail else pcx - span - 0.15+            if left_col and x1 < pcx + span + 0.15 - 0.01: return None+            if not left_col and x0 > pcx - span - 0.15 + 0.01: return None+            if pitch:+                y0, y1 = cy - (pitch - 0.2) / 2, cy + (pitch - 0.2) / 2+            else:+                y0, y1 = vy0 - 0.15, ny0 + th + 0.15+            if y0 > vy0 - 0.08 or y1 < ny0 + th + 0.08: return None+            fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)+            return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts+        nw, vw, th = tw(e['name'], size), tw(e['net'] or '', size), size * TEXT_H+        need = max(nw, vw)+        if left_col:+            tx0 = pb[2] + gap; x0 = outline[0] + edge + width / 2; x1 = rail if rail else tx0 + need + gap+            if x1 < tx0 + need + gap - 0.01: return None+        else:+            tx1 = pb[0] - gap; x1 = outline[2] - edge - width / 2; x0 = rail if rail else tx1 - need - gap+            if x0 > tx1 - need - gap + 0.01: return None+        half = (pitch - 0.2) / 2 if pitch else max(th + 0.1, (pb[3] - pb[1]) / 2 + 0.15)+        y0, y1 = cy - half, cy + half+        if y1 - y0 < 2 * th + 0.3 or y0 > pb[1] - 0.1 or y1 < pb[3] + 0.1: return None+        texts = []+        for (t, kind, ty) in ((e['name'], 'name', cy + 0.08), (e['net'], 'net', cy - 0.08 - th)):+            if not t: continue+            w = tw(t, size)+            bx0 = tx0 if left_col else tx1 - w+            texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})+        fr = (x0 - width / 2, y0 - width / 2, x1 + width / 2, y1 + width / 2)+        return (round(x0, 3), round(y0, 3), round(x1, 3), round(y1, 3)), fr, texts+    groups, bad = [], []+    # corner machine pins: one frame shape for all four, mirrored into each corner, so the corners read as a+    # matched set. The frame runs from the board edges past the pin to hold its name over its net beside it+    # (on the side toward the board's middle); the largest text that fits every corner wins.+    corners = [e for e in cs if 'MACHINEPIN' in (e['package'] or '').upper()]+    if corners:+        cy_b = (outline[1] + outline[3]) / 2+        placed_corners = False+        for orient in (corner_orients or ('h', 'v', 'v1')):+          if placed_corners: break+          for size in _sz(0.8, 0.6, 0.5):+              th = size * TEXT_H+              one = orient == 'v1'  # one line: the net (the reference when the pin is mechanical only)+              lw = max((tw(e['net'] or e['name'], size) if one else max(tw(e['name'], size), tw(e['net'] or '', size))) for e in corners)+              pw = max(pad_box(e)[2] - pad_box(e)[0] for e in corners); ph = max(pad_box(e)[3] - pad_box(e)[1] for e in corners)+              # distance from the edge to the pin's far side, the same for all (the largest)+              dx = max(min(pad_box(e)[2] - outline[0], outline[2] - pad_box(e)[0]) for e in corners)+              dy = max(min(pad_box(e)[3] - outline[1], outline[3] - pad_box(e)[1]) for e in corners)+              if orient == 'h': fw = dx + gap + lw + 0.2; fh = max(dy + 0.2, 2 * th + 0.5)+              else: fw = max(dx + 0.25 - (edge + width / 2), lw + 0.4); fh = dy - (edge + width / 2) + gap + (1 if one else 2) * th + (0.25 if one else 0.3)+              trial = []+              for e in corners:+                  pb = pad_box(e); left = (pb[0] + pb[2]) / 2 < cx_b; low = (pb[1] + pb[3]) / 2 < cy_b+                  x0 = outline[0] + edge + width / 2 if left else outline[2] - edge - width / 2 - fw+                  y0 = outline[1] + edge + width / 2 if low else outline[3] - edge - width / 2 - fh+                  f = (round(x0, 3), round(y0, 3), round(x0 + fw, 3), round(y0 + fh, 3))+                  if orient == 'h':+                      tx = (pb[2] + gap) if left else (pb[0] - gap - lw)+                      tcy = (f[1] + f[3]) / 2+                  else:+                      tx = (pb[0] + pb[2]) / 2 - lw / 2+                      tcy = ((pb[3] + gap + th + 0.05) if low else (pb[1] - gap - th - 0.05)) if not one else ((pb[3] + gap + th / 2) if low else (pb[1] - gap - th / 2))+                  texts = []+                  rows_ = ([(e['net'], 'net', tcy - th / 2)] if e['net'] else [(e['name'], 'name', tcy - th / 2)]) if one else ((e['name'], 'name', tcy + 0.08), (e['net'], 'net', tcy - 0.08 - th))+                  for (t, kind, ty) in rows_:+                      if not t: continue+                      w = tw(t, size); bx0 = tx + (((lw - w) if not left else 0) if orient == 'h' else (lw - w) / 2)+                      texts.append({'ref': e['name'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(bx0, 3), round(ty, 3), round(bx0 + w, 3), round(ty + th, 3)], 'origin': [round(bx0, 3), round(ty, 3)]})+                  outer = (f[0] - width / 2, f[1] - width / 2, f[2] + width / 2, f[3] + width / 2)+                  trial.append((e, f, outer, texts))+              ok_all, gapped = True, {}+              _dbg = os.environ.get('SILK_DEBUG')+              for (e, f, outer, texts) in trial:+                  if fits(e, outer): continue+                  # a frame may break where it would cross a small pad or a via, if the break is short, the+                  # blocker is not shut inside, and nothing else is in the way (aiflow-silkscreen rule)+                  own = pad_box(e)+                  small_owners = [o for o in els if o is not e and any((b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3) for b in o['pads'])]+                  small = [b for o in small_owners for b in o['pads'] if (b[2] - b[0]) < 1.2 and (b[3] - b[1]) < 1.2 and _ov(outer, b, 0.3)]+                  small += [v for v in viab if _ov(outer, v, 0.3)]+                  inside = [b for b in small if f[0] < (b[0] + b[2]) / 2 < f[2] and f[1] < (b[1] + b[3]) / 2 < f[3] and not (b[0] < f[0] or b[2] > f[2] or b[1] < f[1] or b[3] > f[3])]+                  if _dbg: print('corner', orient, size, e['name'], 'small', [round(v,2) for b in small for v in b][:8], 'inside', bool(inside), file=sys.stderr)+                  if inside: ok_all = False; break+                  # without the small blockers, would it fit?+                  saved = list(others); saved_v = list(viab)+                  others[:] = [b for b in others if b not in small]; viab[:] = [v for v in viab if v not in small]+                  skip_bodies.update(o['name'] for o in small_owners)+                  clear_rest = fits(e, outer)+                  others[:] = saved; viab[:] = saved_v; skip_bodies.clear()+                  pieces, frac = _ring_gaps(f, small)+                  if _dbg: print('  clear_rest', clear_rest, 'frac', round(frac, 2), 'textclash', any(_ov(tb['box'], b, 0.1) for tb in texts for b in small), file=sys.stderr)+                  if not clear_rest or frac < 0.7 or any(_ov(tb['box'], b, 0.1) for tb in texts for b in small): ok_all = False; break+                  gapped[e['name']] = pieces+              if ok_all:+                  for (e, f, outer, texts) in trial:+                      groups.append({'ref': e['name'], 'frame': f, 'outer': outer, 'texts': texts, 'pieces': gapped.get(e['name'])})+                      placed_outers.append((e['name'], outer))+                  placed_corners = True; break+        if not placed_corners:+            bad += ['%s frame' % e['name'] for e in corners]+        cs = [e for e in cs if e not in corners]+    # columns: contacts on one vertical line share rails and a row height+    cols = []+    for e in cs:+        pb = pad_box(e); x = (pb[0] + pb[2]) / 2+        c = next((c for c in cols if abs(c['x'] - x) < 0.1), None)+        if c: c['m'].append(e)+        else: cols.append({'x': x, 'm': [e]})+    for c in cols:+        m = sorted(c['m'], key=lambda e: pad_box(e)[1])+        ys = [(pad_box(e)[1] + pad_box(e)[3]) / 2 for e in m]+        pitch = min([b - a for a, b in zip(ys, ys[1:]) if b - a > 0.1] or [None]) if len(m) > 1 else None+        if pitch: pitch = min(pitch, 4.0)+        left_col = c['x'] < cx_b+        done = {}+        # rows shrink together (never one alone) until the column clears its neighbours and the corner frames+        for size in _sz(0.8, 0.6, 0.5):+          for mode in ('side', 'stack'):+            for pt in ([pitch - 0.3 * k for k in range(0, 6)] if pitch else [None]):+              if pt is not None and pt < 2 * size * TEXT_H + 0.6: break+              # the shared rail: past the widest label of the column at this size+              lays = [layout(e, size, pitch=pt, mode=mode) for e in m]+              if any(l is None for l in lays): continue+              rail = max(l[0][2] for l in lays) if left_col else min(l[0][0] for l in lays)+              shared = [layout(e, size, rail=rail, pitch=pt, mode=mode) for e in m]+              if os.environ.get('SILK_DEBUG'): print('col', [e['name'] for e in m], size, mode, pt, [(e['name'], bool(l), bool(l) and fits(e, l[1]), l and [round(v, 2) for v in l[1]]) for e, l in zip(m, shared)], file=sys.stderr)+              res = [fit_or_gap(e, l[0], l[1], l[2]) if l else (False, None) for e, l in zip(m, shared)]+              if all(r_[0] for r_ in res):+                  for e, l, r_ in zip(m, shared, res): done[e['name']] = l + (r_[1],)+                  break+            if len(done) == len(m): break+          if len(done) == len(m): break+        for e in m:+            if e['name'] in done: continue+            # its own frame: its own width, then shifted off whatever is in the way, then smaller rows+            got = None+            for size in _sz(0.8, 0.6, 0.5):+                for mode in ('side', 'stack'):+                    for pt in ([pitch] if pitch else []) + [None]:+                        for dy in (0.0, -0.3, 0.3, -0.6, 0.6, -0.9, 0.9):+                            l = layout(e, size, dy=dy, pitch=pt, mode=mode)+                            if l:+                                ok_, pc = fit_or_gap(e, l[0], l[1], l[2])+                                if ok_: got = l + (pc,); break+                        if got: break+                    if got: break+                if got: break+            if got: done[e['name']] = got+            else: bad.append('%s frame' % e['name'])+        for e in m:+            if e['name'] in done:+                f, fr, texts, pc = done[e['name']]+                groups.append({'ref': e['name'], 'frame': f, 'outer': fr, 'texts': texts, 'pieces': pc}); placed_outers.append((e['name'], fr))+    # every edge contact's frame the same width (the eye scans the row of tags as one set): the widest that+    # fits all of them, else the narrowest that still holds every label; each keeps its board-edge side+    edge_g = [g for g in groups if 'MACHINEPIN' not in (next(e for e in els if e['name'] == g['ref'])['package'] or '').upper()]+    if len(edge_g) > 1:+        widths = sorted(set(round(g['frame'][2] - g['frame'][0], 3) for g in edge_g), reverse=True)+        def refit(g, nf):+            # the tag's texts, re-sized (0.8, 0.6, 0.5) and re-centred to sit inside the frame nf+            out = []+            for t in g['texts']:+                for size in (t['size'],) + _sz(0.6, 0.5):+                    if size > t['size']: continue+                    w = tw_(t['text'], size); th = size * TEXT_H+                    if w <= (nf[2] - nf[0]) - 0.3:+                        cx_ = (t['box'][0] + t['box'][2]) / 2; cy_ = (t['box'][1] + t['box'][3]) / 2+                        cx_ = min(max(cx_, nf[0] + 0.15 + w / 2), nf[2] - 0.15 - w / 2)+                        out.append(dict(t, size=size, box=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3), round(cx_ + w / 2, 3), round(cy_ + th / 2, 3)], origin=[round(cx_ - w / 2, 3), round(cy_ - th / 2, 3)]))+                        break+                else:+                    return None+            return out+        for W in widths:+            trial = []+            for g in edge_g:+                f = g['frame']; e = next(x for x in els if x['name'] == g['ref'])+                left = (f[0] + f[2]) / 2 < cx_b+                nf = (f[0], f[1], round(f[0] + W, 3), f[3]) if left else (round(f[2] - W, 3), f[1], f[2], f[3])+                tx = refit(g, nf)+                if tx is None: trial = None; break+                no = (nf[0] - width / 2, nf[1] - width / 2, nf[2] + width / 2, nf[3] + width / 2)+                saved = list(placed_outers); placed_outers[:] = [po for po in placed_outers if po[0] != g['ref']]+                ok_, pc = fit_or_gap(e, nf, no, tx)+                ok_ = ok_ and not any(_ov(t['box'], b, 0.1) for t in tx for b in others if not (e['pads'] and b in e['pads']))+                placed_outers[:] = saved+                if not ok_: trial = None; break+                trial.append((g, nf, no, tx, pc))+            if trial:+                for g, nf, no, tx, pc in trial:+                    g['frame'] = nf; g['outer'] = no; g['texts'] = tx; g['pieces'] = pc+                break+    return groups, bad++def min_text():+    """the fab profile's smallest silkscreen text (mm): 3rdPartyFab 0.5, JLCPCB 0.8 (Adom-tested), else 0.5;+    AIFLOW_MIN_TEXT overrides"""+    if os.environ.get('AIFLOW_MIN_TEXT'): return float(os.environ['AIFLOW_MIN_TEXT'])+    try:+        d = json.loads((RUN / 'run.json').read_text()); slug = str(((d.get('data') or d).get('fabRules') or {}).get('slug') or '')+    except Exception:+        slug = ''+    return 0.8 if 'jlc' in slug.lower() else 0.5++def _sz(*sizes):+    """a text size ladder cut at the fab profile's minimum (never empty: the minimum itself at worst)"""+    m = min_text(); out = tuple(x for x in sizes if x >= m - 1e-9)+    return out or (m,)++def silk_value(v, ref):+    """the value as silkscreen prints it: 10uF 25V -> 10u, 100nF -> 100n, 15uH -> 15u, 68pF C0G -> 68p,+    5.6k -> 5k6, 1M -> 1M; an IC or LED gets none (its reference and the BOM say it)"""+    if not v or ref.startswith(('U', 'IC', 'Q', 'D', 'LED')): return None+    m = re.match(r'^\s*([\d.]+)\s*([pnumkKMG\u00b5]?)\s*(?:F|H|R|ohm|\u03a9)?\b', v)+    if not m: return v.split()[0][:6]+    num, unit = m.group(1), m.group(2).replace('\u00b5', 'u')+    if ref.startswith('R'):+        unit = unit.replace('K', 'k') or 'R'+        return num.replace('.', unit) if '.' in num else num + ('' if unit == 'R' else unit)+    return num + unit++def _seg_boxes(p, q, w=0.15, step=0.1):+    """a straight silk line from p to q as small boxes along it (for collision checks)"""+    n = max(1, int(math.hypot(q[0] - p[0], q[1] - p[1]) / step))+    h = w / 2+    return [(p[0] + (q[0] - p[0]) * i / n - h, p[1] + (q[1] - p[1]) * i / n - h, p[0] + (q[0] - p[0]) * i / n + h, p[1] + (q[1] - p[1]) * i / n + h) for i in range(n + 1)]++def _edge_point(b, toward):+    """the point on box b's edge nearest the point toward"""+    return (min(max(toward[0], b[0]), b[2]), min(max(toward[1], b[1]), b[3]))++def plan_leaders(items, els, outline, vias, taken, edge=0.3, gap=0.15, reach=10.0, sizes=None):+    """Labels with no room beside their part, the way the KiCad lane's leaders do it (silkscreen-leaders):+    the label goes where there is room, within reach of the part, and a thin silk line runs from the label+    to the part's edge. items: [{'ref', 'texts': [(text, kind)], 'target': box, 'frame': bool}], a framed+    item being a contact tag (name over net, framed). The label area, its frame and the line keep clear of+    every pad (but the line may end on the target's own outline), via, label and frame; the nearest spot+    whose line is clear wins, at 1.0 mm text where it fits, stepping down to the fab profile's smallest. Returns (placed, unresolved):+    placed = [{'ref', 'texts': [label dicts], 'frame': box or None, 'leader': [(x, y), (x, y)]}]."""+    pads = [(e, b) for e in els for b in e['pads']]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge)+    bodies = [(e, e['body']) for e in els if not e['bottom']]+    out, bad = [], []+    for it in items:+        tgt = it['target']; tcx, tcy = (tgt[0] + tgt[2]) / 2, (tgt[1] + tgt[3]) / 2+        own = next((e for e in els if e['name'] == it['ref']), None)+        best = None+        sizes = sizes or _sz(1.0, 0.8, 0.6, 0.5)+        # a tag that will not fit framed is tried again without its frame (still name over net, still led)+        for framed in ([True, False] if it.get('frame') else [False]):+          if best: break+          for size in sizes:+              th = size * TEXT_H+              ws = [tw_(t, size) for t, _ in it['texts']]+              w = max(ws); h = th * len(it['texts']) + 0.1 * (len(it['texts']) - 1)+              pad_ = 0.25 if framed else 0.0+              W, H = w + 2 * pad_, h + 2 * pad_+              cands = []+              r = 1.0+              while r <= reach:+                  for k in range(24):+                      ang = 2 * math.pi * k / 24+                      cx, cy = tcx + r * math.cos(ang), tcy + r * math.sin(ang)+                      cands.append((r, cx - W / 2, cy - H / 2))+                  r += 0.25+              for (r, x0, y0) in cands:+                  area = (x0, y0, x0 + W, y0 + H)+                  if not (area[0] >= inner[0] and area[1] >= inner[1] and area[2] <= inner[2] and area[3] <= inner[3]): continue+                  if any(_ov(area, b, 0.12) for (_, b) in pads) or any(_ov(area, v, 0.12) for v in viab): continue+                  if any(_ov(area, t, gap) for t in taken): continue+                  if any(_ov(area, b, 0.05) for (e, b) in bodies): continue+                  a_ = _edge_point(area, (tcx, tcy)); b_ = _edge_point(tgt, (a_[0], a_[1]))+                  if math.hypot(b_[0] - a_[0], b_[1] - a_[1]) < 0.3: continue+                  segs = _seg_boxes(a_, b_)[2:-2]+                  if any(_ov(sb, b, 0.08) for sb in segs for (e, b) in pads): continue+                  if any(_ov(sb, v, 0.08) for sb in segs for v in viab): continue+                  if any(_ov(sb, t, 0.05) for sb in segs for t in taken): continue+                  best = (size, area, pad_, a_, b_, ws, th, framed); break+              if best: break+        if not best: bad.append('%s leader' % it['ref']); continue+        size, area, pad_, a_, b_, ws, th, framed = best+        texts, y = [], area[3] - pad_ - th+        for (t, kind), w in zip(it['texts'], ws):+            x0 = area[0] + pad_ + (area[2] - area[0] - 2 * pad_ - w) / 2+            texts.append({'ref': it['ref'], 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(x0, 3), round(y, 3), round(x0 + w, 3), round(y + th, 3)], 'origin': [round(x0, 3), round(y, 3)], 'leader': True})+            y -= th + 0.1+        taken.append(area); taken.extend(_seg_boxes(a_, b_))+        out.append({'ref': it['ref'], 'texts': texts, 'frame': tuple(round(v, 3) for v in area) if framed else None,+                    'leader': [(round(a_[0], 3), round(a_[1], 3)), (round(b_[0], 3), round(b_[1], 3))]})+    return out, bad++def plan_labels(els, outline, vias, edge=0.3, gap=0.15, fixed=(), existing=(), skip=()):+    """Every part's name next to its body, readable (R0), clear of copper, mask, bodies, the edge and+    the other labels; machine contacts also get their net, on the board-edge side. Biggest parts first;+    a smaller size before giving up; whatever cannot fit is named, never dropped silently."""+    pads = [b for e in els for b in e['pads']]+    bodies = [e['body'] for e in els]+    viab = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    inner = (outline[0] + edge, outline[1] + edge, outline[2] - edge, outline[3] - edge) if outline else None+    taken, out, unresolved = list(fixed), [], []+    relax = [False]+    def free(bx, own=None):+        if inner and not (bx[0] >= inner[0] and bx[1] >= inner[1] and bx[2] <= inner[2] and bx[3] <= inner[3]): return False+        if any(_ov(bx, p, 0.1) for p in pads): return False+        if not relax[0] and any(_ov(bx, b, 0.05) for b in bodies if b is not own): return False+        if any(_ov(bx, v, 0.05) for v in viab): return False+        if any(_ov(bx, t, gap) for t in taken): return False+        return True+    order = sorted([e for e in els if not e['bottom']], key=lambda e: (not is_contact(e), -((e['body'][2] - e['body'][0]) * (e['body'][3] - e['body'][1]))))  # the contacts and their nets first: they are what a probe or a person looks for+    for e in order:+        if e['name'] in skip: continue  # labelled inside its contact frame+        b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2+        texts = [(e['name'], 'name')]+        if is_contact(e) and e.get('net'): texts.append((e['net'], 'net'))+        # every component's value, small, right next to its reference (aiflow-silkscreen: the reference is+        # larger, the value mandatory and visibly smaller); test pads and machine parts carry none+        pk_ = (e['package'] or '').upper()+        sv = silk_value(e['value'], e['name']) if not is_contact(e) and 'TEST' not in pk_ and not e['name'].startswith(('TP', 'MP', 'J')) else None+        if sv: texts.append((sv, 'value'))+        # the reference and its value as one unit first (aiflow-silkscreen: the pair is atomic): the value+        # inline after the reference, or stacked under it, the unit placed where a name would go+        vt = next((t for t, k in texts if k == 'value'), None)+        if vt and not is_contact(e):+            vs = min_text(); pair = None+            for size in _sz(0.8, 0.6):+                nw, nh = tw_(e['name'], size), size * TEXT_H+                vw, vh = tw_(vt, vs), vs * TEXT_H+                for layout_ in ('inline', 'stack'):+                    w = nw + 0.12 + vw if layout_ == 'inline' else max(nw, vw)+                    h = nh if layout_ == 'inline' else nh + 0.08 + vh+                    cands = []+                    for d in (gap, gap + 0.4, gap + 0.9):+                        for f in (0.0, -0.5, 0.5):+                            cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]+                    for (x0, y0) in cands:+                        if layout_ == 'inline':+                            nb = (x0, y0, x0 + nw, y0 + nh); vb = (x0 + nw + 0.12, y0, x0 + nw + 0.12 + vw, y0 + vh)+                        else:+                            nb = (x0, y0 + vh + 0.08, x0 + nw, y0 + h); vb = (x0, y0, x0 + vw, y0 + vh)+                        if free(nb, own=b) and free(vb, own=b):+                            pair = (size, nb, vb); break+                    if pair: break+                if pair: break+            if pair:+                size, nb, vb = pair+                taken += [nb, vb]+                out.append({'ref': e['name'], 'kind': 'name', 'text': e['name'], 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in nb], 'origin': [round(nb[0], 3), round(nb[1], 3)]})+                out.append({'ref': e['name'], 'kind': 'value', 'text': vt, 'size': vs, 'rot': 'R0', 'box': [round(v, 3) for v in vb], 'origin': [round(vb[0], 3), round(vb[1], 3)]})+                continue+        for txt, kind in texts:+            done = False+            if kind == 'net' and any(t['text'] == txt and t['layer'] == 21 and abs((t['box'][0] + t['box'][2]) / 2 - cx) < 4 and abs((t['box'][1] + t['box'][3]) / 2 - cy) < 4 for t in existing):+                continue  # printed by an earlier pass: it stays, and it is already an obstacle+            # part names keep the library's 0.8 mm (a size change by name does not land in Fusion); the new+            # net labels may step down. EAGLE's vector font measured at about 0.95 x size per character.+            # near first, then small: every size is tried close to the part before any farther ring, since a+            # label beside its part at 0.6 mm reads better than one at 0.8 mm on the far side of a neighbour+            if kind == 'value':+                # beside its reference: right after it, under it, over it, before it, at the fab minimum+                nb = next((L['box'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), None)+                vs = min_text(); vh = vs * TEXT_H; vw = tw_(txt, vs)+                if nb:+                    nrot = next((L['rot'] for L in out if L['ref'] == e['name'] and L['kind'] == 'name'), 'R0')+                    if nrot == 'R0':+                        cands_v = [(nb[2] + 0.1, nb[1], 'R0'), (nb[0], nb[1] - 0.08 - vh, 'R0'), (nb[0], nb[3] + 0.08, 'R0'), (nb[0] - 0.1 - vw, nb[1], 'R0')]+                    else:+                        cands_v = [(nb[2] + 0.08, nb[1], 'R90'), (nb[0] - 0.08 - vh, nb[1], 'R90'), (nb[0], nb[3] + 0.1, 'R90'), (nb[0], nb[1] - 0.1 - vw, 'R90')]+                    for (x0, y0, rr) in cands_v:+                        w, h = (vw, vh) if rr == 'R0' else (vh, vw)+                        bx = (x0, y0, x0 + w, y0 + h)+                        if free(bx, own=b):+                            taken.append(bx)+                            out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+                            done = True; break+                if not done:+                    # anywhere close round the part, at the same small size+                    for d in (gap, gap + 0.4, gap + 0.9):+                        for rr in ('R0', 'R90'):+                            w, h = (vw, vh) if rr == 'R0' else (vh, vw)+                            for (x0, y0) in ((cx - w / 2, b[3] + d), (cx - w / 2, b[1] - d - h), (b[2] + d, cy - h / 2), (b[0] - d - w, cy - h / 2)):+                                bx = (x0, y0, x0 + w, y0 + h)+                                if free(bx, own=b):+                                    taken.append(bx)+                                    out.append({'ref': e['name'], 'kind': 'value', 'text': txt, 'size': vs, 'rot': rr, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+                                    done = True; break+                            if done: break+                        if done: break+                if not done: unresolved.append('%s value' % e['name'])+                continue+            for ring_ in (((gap, gap + 0.4, gap + 0.9),) if kind == 'net' else ((gap, gap + 0.4, gap + 0.9), (gap + 1.5, gap + 2.2))):+              if done: break  # a contact's net stays close: far away it reads as a neighbour's+              for size in (_sz(0.8, 0.6) if kind == 'name' else _sz(0.8, 0.6, 0.5)):+                for rot in ('R0', 'R90'):+                    # R0 first (reads left to right); R90 (reads bottom to top) where a part has no room beside it+                    tw = tw_(txt, size); th = size * TEXT_H+                    w, h = (tw, th) if rot == 'R0' else (th, tw)+                    cands = []+                    for d in ring_:+                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):+                            sx, sy = f * w, f * h+                            cands += [(cx - w / 2 + sx, b[3] + d), (cx - w / 2 + sx, b[1] - d - h), (b[2] + d, cy - h / 2 + sy), (b[0] - d - w, cy - h / 2 + sy)]+                        cands += [(b[2] + d, b[3] + d), (b[0] - d - w, b[3] + d), (b[2] + d, b[1] - d - h), (b[0] - d - w, b[1] - d - h)]+                    if kind == 'net' and outline:+                        # the edge side first: the side of the contact nearest the outline+                        dists = {'L': cx - outline[0], 'R': outline[2] - cx, 'B': cy - outline[1], 'T': outline[3] - cy}+                        side = min(dists, key=dists.get)+                        # nearest the contact first, with the edge side favoured: a label far from its contact+                        # (on the far side of another part) reads as someone else's+                        def k_(p):+                            mx, my = p[0] + w / 2, p[1] + h / 2+                            on_edge = {'L': mx < cx, 'R': mx > cx, 'B': my < cy, 'T': my > cy}[side]+                            return ((mx - cx) ** 2 + (my - cy) ** 2) ** 0.5 - (1.0 if on_edge else 0.0)+                        cands.sort(key=k_)+                    for (x0, y0) in cands:+                        bx = (x0, y0, x0 + w, y0 + h)+                        if free(bx, own=b):+                            taken.append(bx)+                            out.append({'ref': e['name'], 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]})+                            done = True; break+                    if done: break+                if done: break+            if not done: unresolved.append('%s %s' % (e['name'], kind))+    if not relax[0] and unresolved:+        # last resort for what is left: a label may sit over another part's outline (not its copper)+        relax[0] = True+        left = [u for u in unresolved]; unresolved.clear()+        for u in left:+            ref, kind = u.rsplit(' ', 1)+            e = next(x for x in order if x['name'] == ref); b = e['body']; cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2+            txt = e['name'] if kind == 'name' else (e.get('net') if kind == 'net' else silk_value(e['value'], e['name'])); done = False+            if not txt: continue+            for size in ((min_text(),) if kind == 'value' else _sz(0.8, 0.6)):+                for rot in ('R0', 'R90'):+                    tw = tw_(txt, size); th = size * TEXT_H+                    w, h = (tw, th) if rot == 'R0' else (th, tw)+                    cands = []+                    for d in (gap, gap + 0.4, gap + 0.9, gap + 1.5):+                        for f in (0.0, -0.5, 0.5, -1.0, 1.0):+                            cands += [(cx - w / 2 + f * w, b[3] + d), (cx - w / 2 + f * w, b[1] - d - h), (b[2] + d, cy - h / 2 + f * h), (b[0] - d - w, cy - h / 2 + f * h)]+                    cands.sort(key=lambda p: ((p[0] + w / 2 - cx) ** 2 + (p[1] + h / 2 - cy) ** 2))+                    for (x0, y0) in cands:+                        bx = (x0, y0, x0 + w, y0 + h)+                        if free(bx, own=b):+                            taken.append(bx); out.append({'ref': ref, 'kind': kind, 'text': txt, 'size': size, 'rot': rot, 'box': [round(v, 3) for v in bx], 'origin': [round(x0, 3), round(y0, 3)]}); done = True; break+                    if done: break+                if done: break+            if not done: unresolved.append(u)+    return out, unresolved, taken++def identity_spot(els, outline, vias, lines):+    """A clear place on the bottom face for the board's identity (bottom pads, through-holes and vias are+    the obstacles there): (x, y, size), the largest size that fits nearest the centre."""+    if not outline: return None+    obs = [b for e in els for b in e['pads'] if e['bottom']] + [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    obs += [b for e in els for b in e['pads'] if (b[2] - b[0]) > 0.9 and abs((b[2] - b[0]) - (b[3] - b[1])) < 0.05]  # round through-hole lands+    cx0, cy0 = (outline[0] + outline[2]) / 2, (outline[1] + outline[3]) / 2+    for size in (1.0, 0.8, 0.7, 0.6):+        w = max(tw_(l, size) - TEXT_PAD for l in lines); h = len(lines) * size * 1.5+        best = None+        for i in range(-30, 31):+            for j in range(-30, 31):+                x, y = cx0 + i * 0.4, cy0 + j * 0.4+                bx = (x - w / 2, y - h / 2, x + w / 2, y + h / 2)+                if bx[0] < outline[0] + 0.5 or bx[2] > outline[2] - 0.5 or bx[1] < outline[1] + 0.5 or bx[3] > outline[3] - 0.5: continue+                if any(_ov(bx, o, 0.1) for o in obs): continue+                d = abs(i) + abs(j)+                if best is None or d < best[0]: best = (d, x, y)+        if best: return (best[1], best[2], size)+    return None+++def plan_board(els, outline, vias, texts):+    """The whole plan from the board's geometry, as `fusion silkscreen` makes it (a copy of its planning+    half; the lanes differ only in how they read the board and land the plan): contact groups with their+    frames, the labels, the leaders and what stayed unresolved."""+    tries = [plan_frames(els, outline, vias, corner_orients=(o,)) for o in ('h', 'v', 'v1')]+    groups, frame_bad = min(tries, key=lambda t: len(t[1]))+    grouped = {g['ref'] for g in groups}+    frames = [(g['ref'], g['frame']) for g in groups]+    fixed = [t['box'] for t in texts if t['layer'] == 21] + [g['outer'] for g in groups]+    labels, unresolved, _ = plan_labels(els, outline, vias, fixed=fixed, existing=texts, skip=grouped)+    labels = [t for g in groups for t in g['texts']] + labels+    unresolved = frame_bad + unresolved+    # what has no room beside its part goes further out with a leader line (contacts as framed tags)+    taken_now = list(fixed) + [L['box'] for L in labels]+    items = []+    for u in list(unresolved):+        ref, kind = u.rsplit(' ', 1)+        e = next((x for x in els if x['name'] == ref), None)+        if not e: continue+        if kind == 'frame':+            items.append({'ref': ref, 'texts': [(e['name'], 'name'), (e['net'], 'net')] if e.get('net') else [(e['name'], 'name')], 'target': e['body'], 'frame': True})+        elif kind == 'name':+            items.append({'ref': ref, 'texts': [(e['name'], 'name')], 'target': e['body'], 'frame': False})+    framed_refs = {it['ref'] for it in items if it['frame']}+    taken_now = list(fixed) + [L['box'] for L in labels]+    leaders, leader_bad = plan_leaders(items, els, outline, vias, taken_now)+    led = {l['ref'] for l in leaders}+    labels = [L for L in labels if not (L['ref'] in framed_refs and L['ref'] in led)]  # a placed tag replaces its outside labels+    done_refs = {(l['ref']) for l in leaders}+    unresolved = [u for u in unresolved if u.rsplit(' ', 1)[0] not in done_refs] + leader_bad+    labels = labels + [t for l in leaders for t in l['texts']]+    # a contact that got neither a frame nor a led tag: its name straight above its pad and its net straight+    # below (or the other way round), each centred on the pad, so the pair reads as that contact's and no+    # other's. Only when both fit; otherwise its earlier spots stay.+    padsall = [(o, b) for o in els for b in o['pads']]+    viab_ = [(x - r, y - r, x + r, y + r) for (x, y, r) in vias]+    for u in [u for u in unresolved if u.endswith(' frame')]:+        ref = u.rsplit(' ', 1)[0]; e = next((x for x in els if x['name'] == ref), None)+        if not e or ref in {l['ref'] for l in leaders}: continue+        pb = (min(q[0] for q in e['pads']), min(q[1] for q in e['pads']), max(q[2] for q in e['pads']), max(q[3] for q in e['pads']))+        pcx = (pb[0] + pb[2]) / 2+        others_l = [L['box'] for L in labels if L['ref'] != ref] + [g['outer'] for g in groups]+        got = None+        for size in _sz(0.8, 0.6, 0.5):+            th = size * 1.1+            for flip in (False, True):+                rows = [(e['name'], 'name'), (e.get('net'), 'net')]+                if flip: rows = rows[::-1]+                cand = []+                for (t, kind), y0 in zip(rows, (pb[3] + 0.12, pb[1] - 0.12 - th)):+                    if not t: continue+                    w = 0.95 * size * len(t) + 0.1+                    cand.append({'ref': ref, 'kind': kind, 'text': t, 'size': size, 'rot': 'R0', 'box': [round(pcx - w / 2, 3), round(y0, 3), round(pcx + w / 2, 3), round(y0 + th, 3)], 'origin': [round(pcx - w / 2, 3), round(y0, 3)]})+                def clear(b):+                    if outline and not (b[0] >= outline[0] + 0.3 and b[1] >= outline[1] + 0.3 and b[2] <= outline[2] - 0.3 and b[3] <= outline[3] - 0.3): return False+                    if any(_ov(b, q, 0.1) for (o, q) in padsall): return False+                    if any(_ov(b, v, 0.1) for v in viab_): return False+                    if any(_ov(b, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): return False+                    return not any(_ov(b, x, 0.12) for x in others_l)+                if cand and all(clear(c['box']) for c in cand): got = cand; break+            if got: break+        if got:+            labels = [L for L in labels if L['ref'] != ref] + got+            continue+        # the name straight above the pad (a name below an unframed contact sits next to its neighbour's net)+        nm = next((L for L in labels if L['ref'] == ref and L['kind'] == 'name'), None)+        if nm and nm['box'][1] < pb[3]:+            others_n = [L['box'] for L in labels if L is not nm] + [g['outer'] for g in groups]+            for size in _sz(0.8, 0.6, 0.5):+                th = size * 1.1; w = 0.95 * size * len(ref) + 0.1+                c = (pcx - w / 2, pb[3] + 0.12, pcx + w / 2, pb[3] + 0.12 + th)+                if outline and c[3] > outline[3] - 0.3: break+                if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue+                if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue+                if any(_ov(c, x, 0.12) for x in others_n): continue+                labels = [L for L in labels if L is not nm] + [{'ref': ref, 'kind': 'name', 'text': ref, 'size': size, 'rot': 'R0', 'box': [round(v, 3) for v in c], 'origin': [round(c[0], 3), round(c[1], 3)]}]+                break+        # the net alone, turned 90 degrees in the strip between the pad and the nearest board edge, when its+        # current spot is far from the pad (more than 1 mm off it reads as a neighbour's)+        nl = next((L for L in labels if L['ref'] == ref and L['kind'] == 'net'), None)+        if not nl or not outline: continue+        bx = nl['box']; far = max(pb[0] - bx[2], bx[0] - pb[2], pb[1] - bx[3], bx[1] - pb[3]) > 1.0+        if not far: continue+        for size in _sz(0.8, 0.6, 0.5):+            th = size * 1.1; w = 0.95 * size * len(e.get('net') or '') + 0.1; pcy = (pb[1] + pb[3]) / 2+            cands = []+            if outline[2] - pb[2] < pb[0] - outline[0]: cands.append((pb[2] + 0.12, pcy - w / 2, pb[2] + 0.12 + th, pcy + w / 2))+            else: cands.append((pb[0] - 0.12 - th, pcy - w / 2, pb[0] - 0.12, pcy + w / 2))+            others_l = [L['box'] for L in labels if L is not nl] + [g['outer'] for g in groups]+            ok_ = None+            for c in cands:+                if c[0] < outline[0] + 0.3 or c[2] > outline[2] - 0.3 or c[1] < outline[1] + 0.3 or c[3] > outline[3] - 0.3: continue+                if any(_ov(c, q, 0.1) for (o, q) in padsall) or any(_ov(c, v, 0.1) for v in viab_): continue+                if any(_ov(c, o['body'], 0.02) for o in els if o is not e and not o['bottom'] and not is_contact(o)): continue+                # a contact's net outranks a test pad's or a small part's name: those give way (off the print)+                yield_ = [L for L in labels if L is not nl and _ov(c, L['box'], 0.12)]+                if any(L['kind'] != 'name' or is_contact(next((x for x in els if x['name'] == L['ref']), e)) for L in yield_): continue+                if any(_ov(c, g['outer'], 0.12) for g in groups): continue+                ok_ = c; break+            if ok_:+                for L in yield_: unresolved.append('%s name' % L['ref'])+                labels = [L for L in labels if L not in yield_]+                labels = [L for L in labels if L is not nl] + [{'ref': ref, 'kind': 'net', 'text': e['net'], 'size': size, 'rot': 'R90', 'box': [round(v, 3) for v in ok_], 'origin': [round(ok_[0], 3), round(ok_[1], 3)]}]+                break+    frames = frames + [(l['ref'], l['frame']) for l in leaders if l['frame']]+    return groups, frames, labels, leaders, unresolved

Comments

No comments yet.

Log in to comment.