← Commit history
Cargo.lock+7
@@ -35,6 +35,7 @@ dependencies = [ name = "aiflow-board" version = "0.1.49" dependencies = [+ "roxmltree",  "serde",  "serde_json", ]@@ -314,6 +315,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" +[[package]]+name = "roxmltree"+version = "0.20.0"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"+ [[package]] name = "serde" version = "1.0.229"
README.md+4
@@ -9,6 +9,10 @@ adom-aiflow --version  > Screenshots and video clips on this page marked **after the first run** are placeholders until the first measured run under Fable 5.1 lands. The comparison numbers are examples of what the ledger produces, not results. +## Boards in Autodesk Fusion too++The same flow runs a board that lives in Autodesk Fusion: the binary plans, routes and gates offline, and every placement, trace, pour and refinement lands live in Fusion through the Fusion bridge, read back from Fusion's own export each time. See [the Fusion lane](docs/fusion-lane.md).+ ## Silkscreen that helps at the bench  > Available in v0.1.26 on the insiders channel. Install with `adom-wiki pkg install adom/adom-aiflow`. The KiCad workflow is demonstrated below; Fusion and Altium adapters still require validation.
SKILL.md+4
@@ -31,6 +31,10 @@ The flow is a file, `flows/board.json`: the steps in order (intake, models, plac 7. **`measure`**: KiCad's filled copper per layer, the ablation metric. **`analyze current`** and **`analyze thermal`**: IPC-2221 on the narrowest conductor of every loaded net, pour and via capacity, copper and vias at every hot tab. A loaded net whose pads all sit on one poured outer layer is carried by that layer's pour; its vias only spread heat. Hot parts take a theta ceiling (`maxThetaCPerW`) or a rise budget (`hotMaxRiseC` spec-wide, `maxRiseC` per part) against the stated watts, so write the dissipation physically (I2R at the hot Rds(on) over the conduction duty, plus switching) and say so in the spec. A FAIL names the net or part and the fix; fix it (pours, vias, widths, or placement), land, measure and analyze again. 8. **`finish`**, then **`deliver --video <mp4> --message "..."`**: `finish` refuses until the gate passed, routing and pours landed, copper measured, both analyses passed, and the live board validates at 0 unconnected with no new errors; and, when they apply, the board still equals the recorded schematic, still conforms to the molecule, still carries the spec's fab rules, and every 3D orientation warning is fixed or acknowledged. Then it stamps the end and writes the summary: minutes from the prompt, minutes per stage, decisions, copper, live DRC. Add your token and dollar accounting to the manifest under `tokens` and `usd`. +## A board in Autodesk Fusion++Start with the board's `.brd` and `--remote-board fusion:<board document name>`: the flow converts it for its offline steps and lands every live step in Fusion through the Fusion bridge (1.11.30 or later), read back each time. `unroute --net <net>` takes a net up to route it again after an analysis. Read `docs/fusion-lane.md` before starting one.+ ## Before the board: intake, sourcing, evidence  The clock starts at the prompt even when there is no board yet.
crates/adom-aiflow/src/checks.rs+8−7
@@ -134,12 +134,13 @@ pub fn compare_netlists(sch: &SchNetlist, b: &Board) -> Value {  /// Pad and drill of each Adom molecule footprint (Adom KiCad Library 1.2.3), by footprint name. pub fn molecule_part(lib: &str) -> Option<(&'static str, f64, f64)> {-    let name = lib.rsplit(':').next().unwrap_or(lib);-    match name {-        "MachinePinMediumShort" | "MachinePinMediumStandard" => Some(("pin", 1.6, 1.2)),-        "MachinePinLargeShort" | "MachinePinLargeStandard" => Some(("pin", 5.2, 3.45)),-        "MachineContactMedium" => Some(("contact", 1.3, 0.78)),-        "MachineContactLarge" => Some(("contact", 4.4, 2.62)),+    // case-insensitive: Fusion writes package names upper-cased (MACHINEPINMEDIUMSHORT)+    let name = lib.rsplit(':').next().unwrap_or(lib).to_ascii_lowercase();+    match name.as_str() {+        "machinepinmediumshort" | "machinepinmediumstandard" => Some(("pin", 1.6, 1.2)),+        "machinepinlargeshort" | "machinepinlargestandard" => Some(("pin", 5.2, 3.45)),+        "machinecontactmedium" => Some(("contact", 1.3, 0.78)),+        "machinecontactlarge" => Some(("contact", 4.4, 2.62)),         _ => None,     } }@@ -239,7 +240,7 @@ pub fn molecule_check(b: &Board, spec: &Value, before: Option<&Board>) -> Findin     // contacts     let mut contacts: Vec<String> = m["contacts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default();     for (r, fp) in &b.footprints {-        if fp.lib.contains("MachineContact") && !contacts.contains(r) { contacts.push(r.clone()); }+        if fp.lib.to_ascii_lowercase().contains("machinecontact") && !contacts.contains(r) { contacts.push(r.clone()); }     }     contacts.sort();     for r in &contacts {
crates/adom-aiflow/src/main.rs+79−9
@@ -101,6 +101,8 @@ enum Cmd {     Route { #[arg(long, default_value_t = 5)] passes: usize, #[arg(long, default_value_t = 8)] rip_per_net: usize, #[arg(long, default_value_t = 400)] rip_global: usize },     /// Pours, keepouts and vias from the spec (and the routing plan for Kelvin keepouts)     Pour { #[arg(long)] plan: Option<String> },+    /// Take nets' tracks and vias back up on the live board (to route them again, wider or another way); pours stay+    Unroute { #[arg(long, required = true)] net: Vec<String> },     /// KiCad DRC offline on the routing plan     Gate { #[arg(long)] plan: Option<String> },     /// Replay live through the bridge: route, vias, pours, moves@@ -203,6 +205,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); } return r; }     Run::open(&d).unwrap_or_else(|| err(&format!("no run at {}", d.display()), &["Start one: adom-aiflow start --board BOARD.kicad_pcb --spec spec.json --engine <name> --ai-thread <name>".into()])) } @@ -1732,6 +1735,27 @@ 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}));+    }+    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()))+}+ fn bridge_of(r: &Run, cli: &Cli) -> Bridge {     let target = r.data["target"].as_str().unwrap_or_else(|| err("the run has no desktop target", &["Start the run with --target <box> --remote-board <path on the box>.".into()])).to_string();     Bridge { ai_thread: thread(cli), target }@@ -1988,8 +2012,20 @@ fn main() {             }             std::fs::create_dir_all(&dir).unwrap();             let copy = dir.join("board-0.kicad_pcb");-            std::fs::copy(board, &copy).unwrap_or_else(|e| err(&format!("copy: {e}"), &[]));-            aiflow_copper::copy_project_rules(board, &copy.display().to_string()).unwrap_or_else(|e|err(&e,&[]));+            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, &[]));+                std::fs::write(&copy, text).unwrap_or_else(|e| err(&format!("write: {e}"), &[]));+                aiflow_bridge::fusion::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,&[]));+            }             let specp = std::fs::canonicalize(spec).map(|p| p.display().to_string()).unwrap_or(spec.clone());             if let Some(mut r) = intake_run {                 let pt = r.data["clock"]["promptTime"].as_str().unwrap_or("").to_string();@@ -2367,6 +2403,17 @@ fn main() {             if r.engine_for("route") == "ai" {                 err("route is taken by the AI in this run (take route=ai)", &["Write your plan in the plan JSON shape and run `adom-aiflow gate --plan yourplan.json`, with `stage start route` / `stage end route` around your work.".into()]);             }+            // 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) {+                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(), &[]));+                aiflow_copper::copy_project_rules(&r.current_board(), &dest.display().to_string()).unwrap_or_else(|e| err(&e, &[]));+                r.add_board(&dest.display().to_string());+                r.save().unwrap();+                eprintln!("routing on the live Fusion board ({})", dest.display());+            }             let spec = spec_of(&r);             let b = board_of(&r);             let missing=aiflow_pours::missing_planes(&b,&spec);@@ -2453,6 +2500,21 @@ fn main() {             let lines: Vec<String> = zones.iter().map(aiflow_pours::describe).collect();             ok(&format!("pours planned: {} zones ({keepouts} keepouts), {} vias\n{}", zones.len(), vias.len(), lines.join("\n")), &["Pours are filled and DRC-checked by kicad_add_zone's preflight when landed (the headless DRC does not refill zones). `land vias` then `land pours`.".into()]);         }+        Cmd::Unroute { net } => {+            thread(&cli);+            let mut r = load_run(&cli);+            let br = bridge_of(&r, &cli);+            let rb = r.data["remoteBoard"].as_str().unwrap_or("").to_string();+            let live = br.live_copper(&rb).unwrap_or_else(|| err("could not read the live board", &["Open the board in its EDA (or fusion_show_2d_board) and retry.".into()]));+            let ids: Vec<String> = live.segments.iter().filter(|s| net.contains(&s.net)).map(|s| s.uuid.clone())+                .chain(live.vias.iter().filter(|v| net.contains(&v.net)).map(|v| v.uuid.clone())).filter(|u| !u.is_empty()).collect();+            if ids.is_empty() { ok(&format!("nothing to unroute: no tracks or vias on {}", net.join(", ")), &["Next: route, gate, land route.".into()]); return; }+            let rep = br.remove_items(&rb, &ids);+            r.log("unroute", json!({"nets": net, "items": ids.len(), "reply": rep}));+            r.save().unwrap();+            if rep["success"].as_bool() != Some(true) { err(&format!("unroute refused: {} {}", rep["errorCode"], rep["error"].as_str().or(rep["message"].as_str()).unwrap_or("")), &["Nothing else was changed. Read the live board state before retrying.".into()]); }+            ok(&format!("unrouted {}: {} track(s) and via(s) taken up, pours kept", net.join(", "), ids.len()), &["Change what needs changing (spec wideNets for a wider track), then route, gate and land route again.".into()]);+        }         Cmd::Gate { plan } => {             thread(&cli);             let mut r = load_run(&cli);@@ -2732,20 +2794,24 @@ fn main() {             thread(&cli);             let mut r = load_run(&cli);             let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string)).unwrap_or_else(|| err("no routing plan", &["Run route, or pass --plan.".into()]));-            let plan_v = plan_with_board_copper(&read_json(&planp), &board_of(&r));+            // a Fusion run analyzes the board as it is in Fusion now, not the placement snapshot+            let live = fusion_live(&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);             let spec = spec_of(&r);             let zsp = dir.join("zone-state.json");             let zs = if zsp.is_file() { Some(read_json(&zsp.display().to_string())) } else { None };             let name = format!("analyze-{what}");             r.stage_start(&name, Some("binary"), None);-            let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_of(&r)))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };+            let (heuristic_passed, res) = match what.as_str() { "current" => aiflow_analyze::analyze_current(&plan_v, &spec, zs.as_ref(), Some(&pad_layers_of(&board_now))), "thermal" => aiflow_analyze::analyze_thermal(&plan_v, &spec, zs.as_ref()), other => err(&format!("analyze {other}: use current or thermal"), &[]) };             let fields_file=fields.clone().unwrap_or_else(||dir.join("fields/fields.json"));             let screen=if !aiflow_analyze::needs_field_screen(&spec,what) { Ok(()) } else if fields_file.is_file() {                 let f=read_json(&fields_file.display().to_string());-                let board_bytes=std::fs::read(r.current_board()).unwrap_or_else(|e|err(&e.to_string(),&[]));+                let board_bytes=std::fs::read(&solved_board).unwrap_or_else(|e|err(&e.to_string(),&[]));                 let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_else(|e|err(&e.to_string(),&[]));-                aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what)-            } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),r.current_board(),r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };+                aiflow_analyze::field_screen(&f,&spec,&board_bytes,&spec_bytes,what).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e })+            } else { Err(format!("no field solve at {}; run adom-fields analyze --board {} --spec {} --out {}, then analyze {what} --fields {}",fields_file.display(),solved_board,r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display(),dir.join("fields/fields.json").display())) };             let passed=heuristic_passed && screen.is_ok();             let field_note=screen.err().unwrap_or_else(||"same-input field screen passed under its stated assumptions".into());             r.stage_end(&name, Some(json!({"pass": passed})), None);@@ -2794,13 +2860,17 @@ fn main() {             if !measurement_usable(o.get("measure")) {                 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 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"] {                 if o.get(k).and_then(|a| a["pass"].as_bool()) != Some(true) {                     missing.push(format!("{k} (pass)"));                 } else {                     if !aiflow_analyze::needs_field_screen(&spec_of(&r),k.trim_start_matches("analyze-")){continue;}                     let report=o[k]["fieldReport"].as_str().and_then(|p|std::fs::read(p).ok()).and_then(|b|serde_json::from_slice::<Value>(&b).ok());-                    let board_bytes=std::fs::read(r.current_board()).unwrap_or_default();+                    let board_bytes=std::fs::read(&solved_board).unwrap_or_default();                     let spec_bytes=std::fs::read(r.data["spec"].as_str().unwrap_or("")).unwrap_or_default();                     if let Some(report)=report {                         if let Err(reason)=aiflow_analyze::field_screen(&report,&spec_of(&r),&board_bytes,&spec_bytes,k.trim_start_matches("analyze-")) { missing.push(format!("{k}: {reason}")); }@@ -2812,7 +2882,7 @@ fn main() {             if let Some(p) = checks::rules_profile(&spec_f) {                 if let Err(e) = checks::rules_check(&p, &r.current_board()) { missing.push(format!("fab rules ({e})")); }             }-            if let Some(f) = molecule_findings(&spec_f, &board_of(&r), Some(&r)) {+            if let Some(f) = molecule_findings(&spec_f, &board_now(), Some(&r)) {                 if !f.errors.is_empty() { missing.push(format!("molecule check ({})", f.errors.join("; "))); }             }             if r.outcomes().contains_key("models-orientation") {
crates/aiflow-board/Cargo.toml+1
@@ -6,5 +6,6 @@ license.workspace = true description = "adom-aiflow: board"  [dependencies]+roxmltree = "0.20" serde = { workspace = true } serde_json = { workspace = true }
crates/aiflow-board/src/eagle.rsadded+177
@@ -0,0 +1,177 @@+//! The Fusion lane's plan board: a Fusion Electronics board, as the EAGLE source Fusion exports+//! (`fusion_export_eagle_source` on the Fusion bridge), written as the `.kicad_pcb` text every stage+//! here reads. Fusion stays the board of record: this file is only the plan copy the router, the+//! pour planner and the gate work on, re-made from a fresh export whenever the live board changes,+//! and everything they decide lands back in Fusion through the Fusion bridge.+//!+//! The mapping, both ways (see `to_fusion_*`):+//! - y is negated (EAGLE is y up, KiCad y down); millimetres throughout.+//! - an element at (x, y, Rθ) is a footprint at (x, -y, θ) with its pads at (px, -py) and pad angle+//!   θ + the pad's own rotation; `rot_kicad` then puts every pad exactly where Fusion has it.+//! - a mirrored element (on the bottom) is written with its pads already in board position+//!   (footprint angle 0, pad angles absolute) on B.Cu, so the plan sees the copper where it is.+//! - layer 1 is F.Cu, layer 16 is B.Cu; the Dimension layer (20) is Edge.Cuts.+//! - signal wires, vias and Fusion pours (`polygonpour`, with their filled islands) come along, so a+//!   partly routed or poured board is planned from where it stands.+use std::collections::BTreeMap;+use std::fmt::Write;++fn f(n: &roxmltree::Node, k: &str) -> f64 { n.attribute(k).and_then(|v| v.parse().ok()).unwrap_or(0.0) }+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}") } }++/// "R90" / "MR180" / "SR45" -> (degrees, mirrored)+pub fn parse_rot(r: Option<&str>) -> (f64, bool) {+    let r = r.unwrap_or("R0");+    (r.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0), r.contains('M'))+}++pub fn is_eagle(text: &str) -> bool { text.chars().take(600).collect::<String>().contains("<eagle") }++fn layer(l: Option<&str>) -> Option<&'static str> { match l { Some("1") => Some("F.Cu"), Some("16") => Some("B.Cu"), _ => None } }++/// The plan board for a Fusion board (EAGLE source text).+pub fn to_kicad(text: &str) -> Result<String, String> {+    let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).map_err(|e| format!("not a readable EAGLE board: {e}"))?;+    let board = doc.descendants().find(|x| x.has_tag_name("board")).ok_or("no <board> in the file: export the BOARD (.brd) from Fusion, not the schematic")?;+    let yy = |v: f64| -v;++    // nets: every signal, numbered from 1 in file order+    let signals: Vec<roxmltree::Node> = board.descendants().filter(|x| x.has_tag_name("signal")).collect();+    let mut nets: BTreeMap<String, usize> = BTreeMap::new();+    let mut order: Vec<String> = Vec::new();+    for s in &signals { let name = s.attribute("name").unwrap_or("").to_string(); if !name.is_empty() && !nets.contains_key(&name) { order.push(name.clone()); nets.insert(name, order.len()); } }+    let mut pad_net: BTreeMap<(String, String), String> = BTreeMap::new();+    for s in &signals { for c in s.children().filter(|x| x.has_tag_name("contactref")) { pad_net.insert((c.attribute("element").unwrap_or("").into(), c.attribute("pad").unwrap_or("").into()), s.attribute("name").unwrap_or("").into()); } }+    let net = |name: &str| -> String { match nets.get(name) { Some(k) => format!("(net {k} {})", q(name)), None => "(net 0 \"\")".into() } };++    let mut o = String::new();+    o.push_str("(kicad_pcb (version 20241229) (generator \"adom-aiflow-fusion\") (generator_version \"1\")\n  (general (thickness 1.6))\n  (paper \"A4\")\n");+    o.push_str("  (layers (0 \"F.Cu\" signal) (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, name) in order.iter().enumerate() { let _ = writeln!(o, "  (net {} {})", k + 1, q(name)); }++    // packages by (library, package)+    let mut packages: BTreeMap<(String, String), roxmltree::Node> = BTreeMap::new();+    for lib in board.descendants().filter(|x| x.has_tag_name("library")) {+        let ln = lib.attribute("name").unwrap_or("").to_string();+        for p in lib.descendants().filter(|x| x.has_tag_name("package")) { packages.insert((ln.clone(), p.attribute("name").unwrap_or("").to_string()), p); }+    }+    // the board's restring rules size every pad and via, as EAGLE/Fusion do: the ring is the drill+    // times the ratio, held between the minimum and the maximum; a larger library diameter wins+    let param = |name: &str| board.descendants().find(|x| x.has_tag_name("param") && x.attribute("name") == Some(name)).and_then(|x| x.attribute("value")).map(str::to_string);+    let len = |v: Option<String>, d: f64| v.and_then(|t| { let t = t.trim(); if let Some(m) = t.strip_suffix("mil") { m.parse::<f64>().ok().map(|x| x * 0.0254) } else { t.trim_end_matches("mm").parse::<f64>().ok() } }).unwrap_or(d);+    let ratio = |v: Option<String>, d: f64| v.and_then(|t| t.trim().parse::<f64>().ok()).unwrap_or(d);+    let (pad_rv, pad_min, pad_max) = (ratio(param("rvPadTop"), 0.25), len(param("rlMinPadTop"), 0.254), len(param("rlMaxPadTop"), 0.508));+    let (via_rv, via_min, via_max) = (ratio(param("rvViaOuter"), 0.25), len(param("rlMinViaOuter"), 0.2032), len(param("rlMaxViaOuter"), 0.508));+    let ring = |drill: f64, rv: f64, lo: f64, hi: f64| (drill * rv).max(lo).min(hi);+    for e in board.descendants().filter(|x| x.has_tag_name("element")) {+        let reference = e.attribute("name").unwrap_or("?").to_string();+        let Some(pkg) = packages.get(&(e.attribute("library").unwrap_or("").to_string(), e.attribute("package").unwrap_or("").to_string())) else { continue };+        let (ex, ey) = (f(&e, "x"), f(&e, "y"));+        let (ea, mirror) = parse_rot(e.attribute("rot"));+        let (fx, fy, fa, side) = (ex, yy(ey), if mirror { 0.0 } else { ea }, if mirror { "B.Cu" } else { "F.Cu" });+        let _ = writeln!(o, "  (footprint {} (layer {side}) (at {} {} {})\n    (property \"Reference\" {} (at 0 0 0) (layer \"F.SilkS\") (hide yes))\n    (property \"Value\" {} (at 0 0 0) (layer \"F.Fab\") (hide yes))\n    (attr smd)",+            q(&format!("{}:{}", e.attribute("library").unwrap_or(""), e.attribute("package").unwrap_or(""))), n(fx), n(fy), n(fa), q(&reference), q(e.attribute("value").unwrap_or("")));+        for p in pkg.children().filter(|x| x.has_tag_name("smd") || x.has_tag_name("pad")) {+            let name = p.attribute("name").unwrap_or("").to_string();+            let (px, py) = (f(&p, "x"), f(&p, "y"));+            let (pa, _) = parse_rot(p.attribute("rot"));+            // local position: plain y negation; a mirrored element's pads go straight to board space+            let (lx, ly, ang) = if mirror {+                let (mx, my) = (-px, py); let a = ea.to_radians();+                let (wx, wy) = (ex + mx * a.cos() - my * a.sin(), ey + mx * a.sin() + my * a.cos());+                (wx - ex, yy(wy) - fy, -(ea + pa))+            } else { (px, -py, ea + pa) };+            let pn = pad_net.get(&(reference.clone(), name.clone())).map(|s| net(s)).unwrap_or_default();+            if p.has_tag_name("smd") {+                let (dx, dy) = (f(&p, "dx"), f(&p, "dy"));+                let on_top = p.attribute("layer").unwrap_or("1") == "1";+                let l = if on_top != mirror { "F" } else { "B" };+                let round = p.attribute("roundness").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);+                let shape = if round >= 100.0 && (dx - dy).abs() < 1e-9 { "circle" } else if round > 0.0 { "roundrect" } else { "rect" };+                let rr = if shape == "roundrect" { format!(" (roundrect_rratio {})", n((round / 200.0).min(0.5))) } else { String::new() };+                let _ = writeln!(o, "    (pad {} smd {shape} (at {} {} {}) (size {} {}) (layers \"{l}.Cu\" \"{l}.Paste\" \"{l}.Mask\"){rr} {pn})", q(&name), n(lx), n(ly), n(ang), n(dx), n(dy));+            } else {+                let drill = f(&p, "drill");+                let dia = f(&p, "diameter").max(drill + 2.0 * ring(drill, pad_rv, pad_min, pad_max));+                let shape = match p.attribute("shape") { Some("square") => "rect", Some("long") | Some("offset") => "oval", _ => "circle" };+                let (sx, sy) = if shape == "oval" { (dia * 2.0, dia) } else { (dia, dia) };+                let _ = writeln!(o, "    (pad {} thru_hole {shape} (at {} {} {}) (size {} {}) (drill {}) (layers \"*.Cu\" \"*.Mask\") {pn})", q(&name), n(lx), n(ly), n(ang), n(sx), n(sy), n(drill));+            }+        }+        o.push_str("  )\n");+    }++    // outline: the Dimension layer+    let mut edges = 0;+    if let Some(plain) = board.children().find(|x| x.has_tag_name("plain")) {+        for w in plain.children().filter(|x| x.attribute("layer") == Some("20")) {+            match w.tag_name().name() {+                "wire" => { edges += 1; let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2")))); }+                "rectangle" => { edges += 1; let _ = writeln!(o, "  (gr_rect (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2")))); }+                "polygon" => { edges += 1; o.push_str("  (gr_poly (pts"); for v in w.children().filter(|x| x.has_tag_name("vertex")) { let _ = write!(o, " (xy {} {})", n(f(&v, "x")), n(yy(f(&v, "y")))); } o.push_str(") (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n"); }+                _ => {}+            }+        }+    }+    if edges == 0 { return Err("no board outline: the Dimension layer (20) is empty; set the board shape in Fusion first".into()); }++    // copper already on the board+    for s in &signals {+        let name = s.attribute("name").unwrap_or("");+        for w in s.children().filter(|x| x.has_tag_name("wire")) {+            let Some(l) = layer(w.attribute("layer")) else { continue };+            let _ = writeln!(o, "  (segment (start {} {}) (end {} {}) (width {}) (layer {l}) {})", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2"))), n(f(&w, "width").max(0.05)), net(name));+        }+        for v in s.children().filter(|x| x.has_tag_name("via")) {+            let drill = f(&v, "drill");+            let _ = writeln!(o, "  (via (at {} {}) (size {}) (drill {}) (layers \"F.Cu\" \"B.Cu\") {})", n(f(&v, "x")), n(yy(f(&v, "y"))), n(f(&v, "diameter").max(drill + 2.0 * ring(drill, via_rv, via_min, via_max))), n(drill), net(name));+        }+        for p in s.children().filter(|x| x.has_tag_name("polygonpour") || x.has_tag_name("polygon")) {+            let Some(l) = layer(p.attribute("layer")) else { continue };+            let verts = |nd: roxmltree::Node| -> String { nd.children().filter(|v| v.has_tag_name("vertex")).map(|v| format!("(xy {} {})", n(f(&v, "x")), n(yy(f(&v, "y"))))).collect::<Vec<_>>().join(" ") };+            let outline = if p.has_tag_name("polygon") { verts(p) } else { p.children().find(|x| x.has_tag_name("polygonoutlineobjects")).map(verts).unwrap_or_default() };+            if outline.is_empty() { continue; }+            let pname = p.attribute("name").map(|v| format!(" (name {})", q(v))).unwrap_or_default();+            let _ = write!(o, "  (zone {} (layer {l}){pname} (hatch edge 0.5) (priority {}) (connect_pads (clearance {})) (min_thickness {}) (fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n    (polygon (pts {outline}))", net(name), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(f(&p, "width").max(0.1)));+            if let Some(fd) = p.children().find(|x| x.has_tag_name("polygonfilldetails")) {+                for sh in fd.children().filter(|x| x.has_tag_name("polygonshape")) {+                    if let Some(seg) = sh.children().find(|x| x.has_tag_name("polygonoutlinesegments")) { let _ = write!(o, "\n    (filled_polygon (layer {l}) (pts {}))", verts(seg)); }+                }+            }+            o.push_str(")\n");+        }+    }+    o.push_str(")\n");+    Ok(o)+}++/// A footprint position in the plan (KiCad frame) as the Fusion MOVE/ROTATE it needs: (x, y, "R<deg>").+pub fn to_fusion_xy(x: f64, y: f64) -> (f64, f64) { (x, -y) }+pub fn to_fusion_rot(deg: f64) -> String { let d = ((deg % 360.0) + 360.0) % 360.0; format!("R{}", n(d)) }++#[cfg(test)]+mod tests {+    const BRD: &str = r#"<?xml version="1.0"?><eagle><drawing><board>+<plain><wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/><wire x1="20" y1="0" x2="20" y2="10" width="0" layer="20"/><wire x1="20" y1="10" x2="0" y2="10" width="0" layer="20"/><wire x1="0" y1="10" x2="0" y2="0" width="0" layer="20"/></plain>+<libraries><library name="L"><packages><package name="R0402"><smd name="1" x="-0.5" y="0" dx="0.5" dy="0.6" layer="1"/><smd name="2" x="0.5" y="0" dx="0.5" dy="0.6" layer="1"/></package></packages></library></libraries>+<elements><element name="R1" library="L" package="R0402" value="10k" x="5" y="5" rot="R90"/><element name="R2" library="L" package="R0402" value="1k" x="10" y="5" rot="MR0"/></elements>+<signals><signal name="A"><contactref element="R1" pad="1"/><contactref element="R2" pad="1"/><wire x1="5" y1="4.5" x2="10.5" y2="5" width="0.25" layer="1"/><via x="8" y="5" drill="0.3"/></signal>+<signal name="B"><contactref element="R1" pad="2"/></signal></signals></board></drawing></eagle>"#;+    #[test]+    fn the_plan_board_puts_every_pad_where_fusion_has_it() {+        let text = super::to_kicad(BRD).unwrap();+        let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();+        // R1 at (5,5) rotated 90: pad 1 (-0.5,0) is at (5, 4.5) in Fusion, (5, -4.5) in the plan+        let p = b.pad_by_key("R1.1").unwrap();+        assert!((p.x - 5.0).abs() < 1e-6 && (p.y + 4.5).abs() < 1e-6, "{} {}", p.x, p.y);+        assert_eq!(p.net, "A");+        // R2 mirrored (bottom): pad 1 (-0.5,0) -> (10.5, 5) in Fusion, on B.Cu+        let p = b.pad_by_key("R2.1").unwrap();+        assert!((p.x - 10.5).abs() < 1e-6 && (p.y + 5.0).abs() < 1e-6 && p.on_b && !p.on_f, "{} {} {} {}", p.x, p.y, p.on_f, p.on_b);+        assert_eq!(b.tracks().len(), 1); assert_eq!(b.vias().len(), 1);+        assert_eq!((b.bbox.0, b.bbox.2), (0.0, 20.0));+        assert_eq!(super::to_fusion_xy(5.0, -4.5), (5.0, 4.5)); assert_eq!(super::to_fusion_rot(-90.0), "R270");+    }+}
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 eagle; pub mod geom; pub mod sx; @@ -183,6 +184,8 @@ pub fn reference_of(fp: &Node) -> Option<String> { impl Board {     pub fn load(path: &str) -> Result<Board, String> {         let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;+        // 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)?); }         Board::from_text(path, text)     } 
crates/aiflow-bridge/src/fusion.rsadded+221
@@ -0,0 +1,221 @@+//! The Fusion lane: a run whose board is live in Autodesk Fusion instead of KiCad.+//!+//! The run names its board `fusion:<exact board document name>` (`start --remote-board fusion:<doc>`).+//! Every `kicad_*` verb the flow sends for that board is answered here by the Fusion bridge's own+//! verbs (fusion_routing_state, fusion_route_net, fusion_move_parts, fusion_place_vias,+//! fusion_apply_pours, fusion_pour_state, fusion_routing_drc, the view and window verbs), with the+//! replies reshaped into the KiCad bridge's, so planning, landing, the ledger, the gates and the+//! capture work unchanged. Fusion is the board of record: every read is a fresh Fusion export, every+//! mutation is guarded by Fusion's board id and revision and read back.+//!+//! Coordinates: the plan is in the KiCad frame (y down, see aiflow_board::eagle); Fusion is y up, so+//! y is negated both ways here and nowhere else. Rotation is the same number in both.+use serde_json::{json, Value};+use std::sync::Mutex;++pub const PREFIX: &str = "fusion:";+static BOARD_ID: Mutex<Option<String>> = Mutex::new(None);+static DOC: Mutex<Option<String>> = Mutex::new(None);++/// The Fusion document a flow board name points at, if it is a Fusion board.+pub fn doc_of(board: &str) -> Option<String> { board.strip_prefix(PREFIX).map(str::to_string) }++/// Set for a Fusion run (so verbs that carry no board, like the window and viewer verbs, go to Fusion too).+pub fn set_run_board(board: &str) { if let Some(d) = doc_of(board) { *DOC.lock().unwrap() = Some(d); } }+pub fn active() -> Option<String> { DOC.lock().unwrap().clone() }++fn layer_num(l: Option<&str>) -> Option<i64> { match l { Some("F.Cu") | Some("1") => Some(1), Some("B.Cu") | Some("16") => Some(16), _ => None } }+fn layer_name(n: i64) -> &'static str { if n == 16 { "B.Cu" } else { "F.Cu" } }+fn flip(p: &Value) -> Value { json!([p[0].as_f64().unwrap_or(0.0), -p[1].as_f64().unwrap_or(0.0)]) }++/// A flow waypoint ("R1.1", [x,y], {x,y,layer} or {pad,layer}) as the Fusion bridge's waypoint.+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) = layer_num(w["layer"].as_str()) { o.insert("layer".into(), json!(l)); }+    Value::Object(o)+}++/// The live board in the KiCad bridge's kicad_routing_state shape (plan frame).+fn item_id(net: &Value, kind: char, i: usize) -> String { format!("fusion:{}:{kind}{i}", net.as_str().unwrap_or("")) }++/// The net an item id names (`fusion:<net>:s3`); nets may hold ':' so split from the right.+fn net_of_id(id: &str) -> Option<String> { id.strip_prefix("fusion:").and_then(|r| r.rsplit_once(':')).map(|(n, _)| n.to_string()) }++pub fn to_kicad_state(f: &Value) -> Value {+    if f["success"].as_bool() != Some(true) { return f.clone(); }+    if let Some(id) = f["boardId"].as_str() { *BOARD_ID.lock().unwrap() = Some(id.to_string()); }+    // item ids name the net: Fusion removes copper a whole net at a time (fusion_ripup_nets)+    let segs: Vec<Value> = f["segments"].as_array().cloned().unwrap_or_default().iter().filter(|s| matches!(s["layer"].as_i64(), Some(1) | Some(16))).enumerate().map(|(i, s)| json!({+        "start": {"x": s["start"][0], "y": -s["start"][1].as_f64().unwrap_or(0.0)}, "end": {"x": s["end"][0], "y": -s["end"][1].as_f64().unwrap_or(0.0)},+        "layer": layer_name(s["layer"].as_i64().unwrap_or(1)), "net_name": s["net"], "width": s["width"], "uuid": item_id(&s["net"], 's', i)})).collect();+    let vias: Vec<Value> = f["vias"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, v)| json!({+        "position": {"x": v["at"][0], "y": -v["at"][1].as_f64().unwrap_or(0.0)}, "net_name": v["net"], "size": v["diameter"], "drill": v["drill"], "uuid": item_id(&v["net"], 'v', i)})).collect();+    let pads: Vec<Value> = f["pads"].as_array().cloned().unwrap_or_default().iter().map(|p| json!({+        "name": p["name"], "netName": p["net"].as_str().unwrap_or(""), "x": p["at"][0], "y": -p["at"][1].as_f64().unwrap_or(0.0)})).collect();+    json!({"success": true, "revision": f["revision"], "boardId": f["boardId"], "segments": segs, "vias": vias, "pads": pads,+           "unconnected": f["airwireCount"], "remainingNets": f["remainingNets"], "elements": f["elements"], "source": "fusion"})+}++/// Answer a kicad_* verb for a Fusion board through the Fusion bridge. `call` sends one verb.+pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value) -> Value) -> Value {+    let state = || call("fusion_routing_state", &json!({"expectDocument": doc}));+    let ids = |st: &Value| (st["boardId"].as_str().unwrap_or("").to_string(), st["revision"].as_str().unwrap_or("").to_string());+    let guarded = |rev: Option<&str>| -> Result<(String, String), Value> {+        let st = state();+        if st["success"].as_bool() != Some(true) { return Err(st); }+        let (id, live) = ids(&st);+        if let Some(r) = rev { if !r.is_empty() && r != live { return Err(json!({"success": false, "errorCode": "stale_board", "error": "the live Fusion board changed since the revision this step read", "revision": live})); } }+        Ok((id, live))+    };+    match verb {+        "kicad_routing_state" => to_kicad_state(&state()),+        "kicad_route_net" => {+            let (id, rev) = match guarded(args["expectedRevision"].as_str()) { Ok(x) => x, Err(e) => return e };+            // clearance is a floor the bridge raises to the board's own rules; without one it+            // assumes 0.2 mm, which refused legal 0.15 mm copper the KiCad gate had passed+            let mut a = json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "net": args["net"], "width": args["width"].as_f64().unwrap_or(0.25), "clearance": args["clearance"].as_f64().unwrap_or(0.0001)});+            if let Some(l) = layer_num(args["layer"].as_str()) { a["layer"] = json!(l); }+            if let Some(v) = args["viaSize"].as_f64() { a["viaDiameter"] = json!(v); }+            if let Some(v) = args["viaDrill"].as_f64() { a["viaDrill"] = json!(v); }+            if let Some(ps) = args["paths"].as_array() { a["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) = args["points"].as_array() { a["points"] = json!(ps.iter().map(waypoint).collect::<Vec<_>>()); }+            let mut r = call("fusion_route_net", &a);+            if r["success"].as_bool() == Some(true) { r["revision"] = r["revision"].clone(); }+            r+        }+        "kicad_move_footprint" => {+            let (id, rev) = match guarded(args["expectedRevision"].as_str()) { Ok(x) => x, Err(e) => return e };+            let parts: Vec<Value> = args["refs"].as_array().cloned().unwrap_or_default().iter().map(|m| json!({"ref": m["ref"], "x": m["x"], "y": -m["y"].as_f64().unwrap_or(0.0), "rotation": m["rotation"].as_f64().unwrap_or(0.0)})).collect();+            call("fusion_move_parts", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "parts": parts}))+        }+        "kicad_apply_zone_plan" => {+            let (id, rev) = match guarded(args["expectedRevision"].as_str()) { Ok(x) => x, Err(e) => return e };+            let mut pours = Vec::new(); let mut vias = Vec::new(); let mut skipped = Vec::new();+            for z in args["zones"].as_array().cloned().unwrap_or_default() {+                if z["type"].as_str() == Some("keepout") { skipped.push(json!({"name": z["name"], "why": "keepout zones are not landed in Fusion; the pours are drawn clear of them"})); continue; }+                if z["kind"].as_str() == Some("via") || z.get("via").is_some() { vias.push(z); continue; }+                let pts: Vec<Value> = z["polygon"].as_array().cloned().unwrap_or_default().iter().map(flip).collect();+                if pts.len() < 3 { skipped.push(json!({"name": z["name"], "why": "no polygon (an outline pour must be resolved to points before landing)"})); continue; }+                let prio = z["priority"].as_i64().unwrap_or(0);+                for l in z["layers"].as_array().cloned().unwrap_or_default() {+                    let Some(ln) = layer_num(l.as_str()) else { continue };+                    pours.push(json!({"net": z["net"], "layer": ln, "points": pts, "isolate": z["clearance"].as_f64().unwrap_or(0.25), "rank": (6 - prio).clamp(1, 6), "width": z["minThickness"].as_f64().unwrap_or(0.25), "thermals": z["connection"].as_str() != Some("solid")}));+                }+            }+            if pours.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Fusion"}); }+            let mut r = call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": pours}));+            r["skipped"] = json!(skipped);+            r+        }+        "kicad_zone_state" => {+            let r = call("fusion_pour_state", &json!({"expectDocument": doc}));+            if r["success"].as_bool() != Some(true) { return r; }+            let by = &r["filledAreaByLayerMm2"];+            let zones: Vec<Value> = r["pours"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, p)| { let l = layer_name(p["layer"].as_i64().unwrap_or(1));+                // KiCad's zone shape, which measure and the analysis read: type copper, layers[], fills[]+                json!({"id": format!("fusion-pour-{i}"), "name": format!("{} pour", p["net"].as_str().unwrap_or("")), "net": p["net"], "layer": l, "layers": [l], "type": "copper", "filledMm2": p["filledMm2"], "filled": p["filled"], "fills": [{"layer": l, "areaMm2": p["filledMm2"]}]}) }).collect();+            json!({"success": true, "revision": r["revision"], "revisionUsable": true, "filledAreaByLayerMm2": {"F.Cu": by["1"].as_f64().unwrap_or(0.0), "B.Cu": by["16"].as_f64().unwrap_or(0.0)}, "zones": zones, "unfilled": r["unfilled"], "readbackEvidence": "Fusion's own fill (polygonfilldetails) from a fresh export"})+        }+        "kicad_routing_validate" => {+            let (id, rev) = match guarded(None) { Ok(x) => x, Err(e) => return e };+            let r = call("fusion_routing_drc", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev}));+            let d = &r["drc"];+            json!({"success": r["success"], "unconnected": d["airwireCount"].as_u64().unwrap_or(r["airwireCount"].as_u64().unwrap_or(0)), "errors": d["errorCount"], "warnings": d["warningCount"], "clean": d["clean"], "drc": d, "source": "Autodesk DRC in Fusion"})+        }+        "kicad_model_check" => {+            let st = state();+            if st["success"].as_bool() != Some(true) { return st; }+            let els = st["elements"].as_array().cloned().unwrap_or_default();+            let with: Vec<&Value> = els.iter().filter(|e| e["has3d"].as_bool() == Some(true)).collect();+            let without: Vec<Value> = els.iter().filter(|e| e["has3d"].as_bool() != Some(true)).map(|e| json!({"footprint": e["package"], "references": [e["name"]], "models": [{"reference": "(no 3D package bound in the library)", "exists": false}]})).collect();+            json!({"success": true, "data": {"counts": {"withModel": with.len(), "missingModel": without.len(), "unresolvedModel": 0, "noModelExpected": 0}, "footprintsWithWarnings": without, "source": "Fusion: each element's bound package3d"}})+        }+        "kicad_remove_route" => {+            // Fusion takes copper up a whole net at a time: the ids must name every track and via+            // of each net they touch, or nothing is removed+            let st = state();+            if st["success"].as_bool() != Some(true) { return st; }+            let (id, live) = ids(&st);+            if let Some(r) = args["expectedRevision"].as_str() { if !r.is_empty() && r != live { return json!({"success": false, "errorCode": "stale_board", "error": "the live Fusion board changed since the revision this step read", "revision": live}); } }+            let want: Vec<String> = args["itemIds"].as_array().cloned().unwrap_or_default().iter().filter_map(|v| v.as_str().map(str::to_string)).collect();+            let mut nets: Vec<String> = want.iter().filter_map(|i| net_of_id(i)).collect();+            nets.sort(); nets.dedup();+            if nets.is_empty() { return json!({"success": false, "errorCode": "no_item_ids", "error": "no Fusion item ids (fusion:<net>:s<n>) to remove"}); }+            let k = to_kicad_state(&st);+            let all: Vec<String> = k["segments"].as_array().into_iter().flatten().chain(k["vias"].as_array().into_iter().flatten())+                .filter(|o| nets.iter().any(|n| o["net_name"].as_str() == Some(n))).filter_map(|o| o["uuid"].as_str().map(str::to_string)).collect();+            let partial: Vec<&String> = all.iter().filter(|i| !want.contains(i)).collect();+            if !partial.is_empty() { return json!({"success": false, "errorCode": "partial_net_unsupported", "error": format!("Fusion removes whole nets; {} item(s) of {:?} were not named", partial.len(), nets), "_hint": "Name every track and via of the net (adom-aiflow unroute --net <net> does), or leave it."}); }+            let r = call("fusion_ripup_nets", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": live, "nets": nets}));+            if r["success"].as_bool() == Some(true) { json!({"success": true, "removed": want.len(), "nets": nets, "revision": r["revision"]}) } else { r }+        }+        "kicad_status" | "kicad_version" => call("fusion_readiness", &json!({})),+        "kicad_open_board" | "kicad_window_info" => {+            let _ = call("fusion_show_2d_board", &json!({"documentName": doc}));+            let hwnd = main_hwnd(call);+            if verb == "kicad_open_board" { json!({"success": hwnd.is_some(), "hwnd": hwnd}) }+            else { json!({"success": true, "data": {"editors": hwnd.map(|h| vec![json!({"kind": "pcb_editor", "hwnd": h}), json!({"kind": "3d_viewer", "hwnd": h})]).unwrap_or_default()}}) }+        }+        "kicad_send_key" => { let _ = call("fusion_view", &json!({"action": "fit", "space": "2d"})); json!({"success": true}) }+        "kicad_select_net" => call("fusion_electron_run", &json!({"command": format!("SHOW '{}'; WINDOW FIT;", args["net"].as_str().unwrap_or(""))})),+        "kicad_select_clear" => call("fusion_electron_run", &json!({"command": "SHOW;"})),+        "kicad_open_3d_viewer" => { let r = call("fusion_show_3d_board", &json!({"documentName": doc})); json!({"success": r["success"], "hwnd": main_hwnd(call)}) }+        "kicad_close_3d_viewer" => call("fusion_show_2d_board", &json!({"documentName": doc})),+        "kicad_3d_view" => {+            // the scripted walkthrough, through Fusion's own camera+            let mut log = Vec::new();+            for (a, extra) in [("iso", json!({})), ("orbit", json!({"degrees": 60})), ("orbit", json!({"degrees": 60})), ("orbit", json!({"degrees": 60})), ("zoom", json!({"factor": 1.8})), ("orbit", json!({"degrees": 90})), ("fit", json!({}))] {+                let mut x = extra.clone(); x["action"] = json!(a);+                log.push(call("fusion_view", &x)["success"].clone());+                std::thread::sleep(std::time::Duration::from_millis(args["pauseMs"].as_u64().unwrap_or(700)));+            }+            json!({"success": true, "steps": log})+        }+        "kicad_screenshot_all" => {+            // Fusion's viewport paints whenever it is shown; report the main window as rendered+            let h = main_hwnd(call);+            json!({"success": true, "data": {"screenshots": h.map(|h| vec![json!({"hwnd": h, "canvas": {"rendered": true, "distinctColors": 64}})]).unwrap_or_default()}})+        }+        other => json!({"success": false, "errorCode": "unsupported_in_fusion", "error": format!("{other} has no Fusion lane equivalent yet"), "_hint": "File it on adom/adom-aiflow with the step that needed it."}),+    }+}++/// Fusion's main window on the test box.+fn main_hwnd(call: &dyn Fn(&str, &Value) -> Value) -> Option<i64> {+    let w = call("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() == Some("Fusion360.exe") && x["kind"].as_str() == Some("normal") && x["title"].as_str().map(|t| t.contains("Autodesk Fusion")).unwrap_or(false)).and_then(|x| x["hwnd"].as_i64())+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn route_net_waypoints_and_moves_flip_y_and_keep_rotation() {+        let sent = std::cell::RefCell::new(Vec::<(String, Value)>::new());+        let call = |v: &str, a: &Value| -> Value {+            sent.borrow_mut().push((v.to_string(), a.clone()));+            match v { "fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1", "segments": [], "vias": [], "pads": [{"name": "R1.1", "net": "A", "at": [5.0, 4.5]}]}), _ => json!({"success": true, "revision": "r2"}) }+        };+        let st = translate("kicad_routing_state", &json!({}), "Doc-brd", &call);+        assert_eq!(st["pads"][0]["y"], json!(-4.5));+        let r = translate("kicad_route_net", &json!({"expectedRevision": "r1", "net": "A", "width": 0.3, "layer": "F.Cu", "paths": [["R1.1", [6.0, -4.5], {"x": 6.0, "y": -2.0, "layer": "B.Cu"}]]}), "Doc-brd", &call);+        assert_eq!(r["success"], true);+        let (v, a) = sent.borrow().last().cloned().unwrap();+        assert_eq!(v, "fusion_route_net");+        assert_eq!(a["paths"][0], json!([{"pad": "R1.1"}, [6.0, 4.5], {"x": 6.0, "y": 2.0, "layer": 16}]));+        assert_eq!(a["boardId"], "B"); assert_eq!(a["layer"], 1);+        let _ = translate("kicad_move_footprint", &json!({"expectedRevision": "r1", "refs": [{"ref": "U1", "x": 10.0, "y": -8.0, "rotation": 90.0}]}), "Doc-brd", &call);+        let (v, a) = sent.borrow().last().cloned().unwrap();+        assert_eq!(v, "fusion_move_parts"); assert_eq!(a["parts"][0], json!({"ref": "U1", "x": 10.0, "y": 8.0, "rotation": 90.0}));+        // a stale revision is refused before anything is sent+        let n = sent.borrow().len();+        let r = translate("kicad_move_footprint", &json!({"expectedRevision": "old", "refs": []}), "Doc-brd", &call);+        assert_eq!(r["errorCode"], "stale_board"); assert_eq!(sent.borrow().len(), n + 1, "only the state read went out");+    }+}
crates/aiflow-bridge/src/landing.rs+3−1
@@ -98,7 +98,9 @@ fn same(a: Pt, b: Pt) -> bool { /// points within SAME_POINT_MM (a segment may run either way). A trace with no copper is never present. pub fn present(t: &Trace, live: &LiveCopper) -> bool {     if t.segs.is_empty() && t.vias.is_empty() { return false; }-    t.segs.iter().all(|(a, b)| live.segments.iter().any(|s| s.net == t.net && ((same(s.a, *a) && same(s.b, *b)) || (same(s.a, *b) && same(s.b, *a)))))+    // as wide as planned too: a net widened after an analysis is not "already there" because+    // its old, narrower track runs along the same points+    t.segs.iter().all(|(a, b)| live.segments.iter().any(|s| s.net == t.net && s.width + 1e-4 >= t.width && ((same(s.a, *a) && same(s.b, *b)) || (same(s.a, *b) && same(s.b, *a)))))         && t.vias.iter().all(|v| live.vias.iter().any(|lv| lv.net == t.net && same(lv.at, *v))) } 
crates/aiflow-bridge/src/lib.rs+12
@@ -7,6 +7,7 @@ use std::process::Command;  use serde_json::{json, Value}; +pub mod fusion; pub mod landing; pub use landing::Landing; @@ -101,6 +102,17 @@ 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)+        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)); }+        }+        self.raw_call(verb, args)+    }++    /// One adom-bridge verb, as is.+    pub fn raw_call(&self, verb: &str, args: &Value) -> Value {         let out = Command::new("adom-bridge").args(["--ai-thread", &self.ai_thread, "--target", &self.target, verb, &args.to_string()]).output();         match out {             Ok(o) => {
docs/fusion-lane.mdadded+121
@@ -0,0 +1,121 @@+# The Fusion lane++AI Flow runs a board that lives in Autodesk Fusion (Fusion Electronics) with the same steps, the+same gates and the same ledger as a KiCad board. The binary keeps its offline work in KiCad form+(the router, the pour planner, the KiCad DRC gate), and every live step lands in Fusion through the+Fusion bridge, one edit at a time, each its own undo step, each read back from a fresh export of the+board before the next one goes.++## Starting a Fusion run++Export the board's EAGLE source from Fusion (`.brd`), then start with the document name on the box:++```+adom-aiflow start --board lanetest.brd --spec spec.json --engine <you> \+  --target <box> --remote-board fusion:<board document name> --prompt-time <UTC of the paste>+```++- A `.brd` is converted to `board-0.kicad_pcb` in the run (parts, pads, nets, outline, tracks, vias,+  pours with Fusion's own fill). Fusion's Y axis points up and KiCad's points down, so every point is+  written as (x, -y); rotations keep their number; a mirrored part (bottom side) lands on B.Cu.+- `--remote-board fusion:<doc>` is required for a `.brd`. From then on every live verb the flow+  sends (`kicad_*`) is answered by the Fusion bridge for that document instead (see the table).+- The Fusion board must be the active document on the box (`fusion_show_2d_board`).++## What each step does in Fusion++| step | in Fusion | needs bridge |+|---|---|---|+| `route` | on a Fusion run, plans on a fresh copy of the live board (`fusion_board_source`), so it routes around copper already in Fusion, not the placement snapshot | 1.11.17 |+| `place land` | `fusion_move_parts`: one MOVE and ROTATE per part, read back to 0.001 mm | 1.11.12 |+| `land route` | `fusion_route_net`: one LINE per segment with straight bends (`SET WIRE_BEND 2`), VIAs at layer changes; the bridge refuses any copper closer than the board's clearance, with pads as their real rectangles | 1.11.16 |+| `land vias` | `fusion_place_vias` | 1.11.12 |+| `land pours` | `fusion_apply_pours`: one POLYGON per pour (isolate, rank from priority, thermals, orphans), RATSNEST fills them; the reply is Fusion's own fill per pour | 1.11.12 |+| `measure` | `fusion_pour_state`: filled area per layer and per net from Fusion's computed fill | 1.11.13 |+| `unroute --net N` | `fusion_ripup_nets`: RIPUP per net; RIPUP hides the net's pour fills, so the bridge shows and refills them and proves the fill is back | 1.11.30 |+| `analyze` / `finish` | `fusion_board_source`: the live board as EAGLE XML, saved as `board-live.brd` (and `board-live.kicad_pcb`); the field screen and the finish checks judge that board | 1.11.17 |+| live validate in `finish` | `fusion_routing_drc`: Autodesk's own DRC; unconnected is Fusion's airwire count | 1.11.12 |++Every mutating call carries the board id and the revision the flow last read. A board changed+behind the flow's back is refused (`stale_board`), never edited blind.++## The board's design rules come from the fab, not from Fusion's defaults++A new Fusion board carries Fusion's default rules: 0.35 mm minimum drill, 1 mm (40 mil) from copper to the+board edge, and a pad restring of 25 % of the drill, at least 10 mil. They refuse a fab's 0.3 mm vias, push+copper a millimetre off the edge, and turn a 1.2 mm drill's 1.6 mm pad into 1.8 mm. Put the fab's rules in+the board before routing (the `<designrules>` block of the `.brd` the design starts from: clearances,+`msDrill`, `mdCopperDimension`, `rvPadTop` with `rlMinPadTop`, `rlMinViaOuter`, `mtCopper`). The converter and+the bridge size every pad and via from these rules the way Fusion does: the ring is the drill times the ratio,+held between the minimum and the maximum, and a larger library diameter wins.++Spec coordinates (pour polygons, `planeUnder` regions, stitch vias) are in the run board's frame. A spec+written for a KiCad board elsewhere on the sheet must be moved into it, or every one of them lands off the+board without an error.++## The fields step on a Fusion board++adom-fields 0.6.0 reads a Fusion `.brd` directly and uses Fusion's computed pour fill, so solve the+board the flow pulled:++```+adom-aiflow analyze current          # pulls the live board to <run>/board-live.brd+adom-fields analyze --board <run>/board-live.brd --spec spec.json --out <run>/fields+adom-aiflow analyze current          # now judged with the solve; then analyze thermal+```++The solve is tied to the exact bytes of `board-live.brd`. Any edit in Fusion after the solve makes+it stale and the error names the command to run again.++## Refining after an analysis++The loop the analysis is there for: when a net fails (a neck carrying too much current, a part+running hot), change the spec and take the old copper up before landing the new.++```+# spec.json: "wideNets": {"VIN": [0.6, 0.4]}+adom-aiflow unroute --net VIN+adom-aiflow route && adom-aiflow gate && adom-aiflow land route+adom-aiflow measure && adom-aiflow analyze current+```++`land route` skips a trace already on the board only when it is on the same net, at the same points+and at least as wide as planned, so a widened net is landed again rather than skipped.++## The bill of materials++`fusion_board_bom` reads the part attributes on the live board (MPN, MANUFACTURER, MOUSER_PN, DIGIKEY_PN,+LCSC_PN, carried from the library through the schematic) and writes `bom.csv`, one line per part number, and+`bom-jlcpcb.csv`. It names any part missing a distributor number. `sourcing check` still wants the sourcing+decision (a source column and dated stock), which belongs to the design, not the board.++## What is not in Fusion (yet)++- Keepout zones are not landed; pours are drawn clear of them.+- Removing copper is by whole net (`unroute`), not by single segment.+- Arcs (curved wires) in the source are read as straight segments.++## The buck molecule in Fusion (2026-10-05)++The 12 V to 5 V, 1 A buck molecule (TPS54202, 28 x 20 mm, 34 parts) built in Fusion from its library to its+Gerbers, on a Windows box:++- **Placement and routing:** 24 parts landed live; routing closed with 0 unrouted; the KiCad gate passed with 0 errors; 48 traces and the vias landed live.+- **Pours:** Fusion filled 466 mm2 on B.Cu and 410 mm2 on F.Cu.+- **First field solve:** SW at 133 A/mm2 at U1's SW pin. A GND track crossed the SW patch, so the patch was cut off from the pin and the full 1 A took a 0.5 mm track.+- **First refine:** a `planeUnder` entry keeps other nets out of the SW patch; SW dropped to 46 A/mm2.+- **Second field solve:** VIN at 62 A/mm2. The narrowest copper was the ring around a 0.8 mm via's 0.4 mm hole.+- **Second refine:** 1.0 mm power vias (`viaPower`). The final solve: 0 issues, peak 51 A/mm2, U1 about 9 C over ambient.+- **Finish:** `finish` passed. The Gerbers came from Fusion's JLCPCB CAM job, and the BOM carried every distributor number.++## Proof run on a test board (2026-10-05)++A four-resistor board, `lanetest`, open in Fusion on a Windows box:++- **Placement:** `place land` landed four parts, and the readback was exact.+- **Routing:** `route` closed with 0 unrouted, and the KiCad DRC gate found 0 errors.+- **Landing:** `land route` landed five traces live.+- **Pours:** `land pours` drew a GND pour on the bottom layer, and Fusion filled 565.8 mm² of it.+- **First analysis:** adom-fields on the live board found VIN at 97 A/mm² through a 0.25 mm track, against a 60 A/mm² limit.+- **Refine:** `unroute --net VIN`, then re-routing VIN at 0.4 mm, re-gating and re-landing it.+- **Second analysis:** the new solve reported 0 issues, and both current and thermal passed.