app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
Colby Knox
AI Flow 0.2.78: bottom fill under any number; bridge 1.13.87 note
212acc2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635
//! 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::collections::HashMap;
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() }
/// Issue #203 (Zoltán): the board's bottom copper number. 16 on older Fusion boards, 304 (named "Bottom") in
/// Fusion's newer board format. Read from the live board source once per command (`ensure_bottom`);
/// AIFLOW_FUSION_BOTTOM overrides.
static BOTTOM: Mutex<Option<i64>> = Mutex::new(None);
pub fn set_bottom(n: i64) { *BOTTOM.lock().unwrap() = Some(n); }
/// The bottom copper number of a live board source, remembered for this command.
pub fn note_board_source(text: &str) { if let Some(n) = aiflow_board::eagle::bottom_number(text) { set_bottom(n); } }
fn bottom() -> i64 {
if let Some(n) = std::env::var("AIFLOW_FUSION_BOTTOM").ok().and_then(|v| v.trim().parse::<i64>().ok()) { return n; }
BOTTOM.lock().unwrap().unwrap_or(16)
}
/// Read the board's bottom copper number from the live board when this command has not yet.
fn ensure_bottom(doc: &str, call: &dyn Fn(&str, &Value) -> Value) {
if BOTTOM.lock().unwrap().is_some() || std::env::var("AIFLOW_FUSION_BOTTOM").is_ok() { return; }
let r = call("fusion_board_source", &json!({"expectDocument": doc}));
match r["source"].as_str() { Some(t) => note_board_source(t), None => set_bottom(16) }
}
fn is_bottom(n: i64) -> bool { n == bottom() || n == 16 || n == 304 }
/// A flow layer as the bridge takes it: top and bottom by Fusion's number, an inner layer by the
/// standard's name, which the bridge maps through the board's own stack (fusion-bridge 1.13+). None
/// for anything that is not copper.
fn layer_arg(l: &Value) -> Option<Value> {
let text = l.as_str().map(str::to_string).or_else(|| l.as_i64().map(|n| n.to_string()))?;
match text.as_str() {
"F.Cu" | "1" => Some(json!(1)),
"B.Cu" | "16" | "304" => Some(json!(bottom())),
s => {
if let Ok(n) = s.parse::<i64>() { return (2..=15).contains(&n).then(|| json!(n)); }
let n: u32 = s.strip_prefix("In")?.strip_suffix(".Cu")?.parse().ok()?;
(n >= 1).then(|| json!(s))
}
}
}
/// The inner layers a Fusion reply names: Fusion's layer number to the standard's name, from the
/// standard's own items beside the planner's (tracks and zones carry both layer and layerId).
fn layer_names(f: &Value) -> HashMap<i64, String> {
let mut names = HashMap::new();
for key in ["tracks", "zones"] {
for item in f[key].as_array().into_iter().flatten() {
if let (Some(n), Some(l)) = (item["layerId"].as_i64(), item["layer"].as_str()) { names.insert(n, l.to_string()); }
}
}
names
}
/// One copper layer's standard name. A bridge too old to name it gets the bridge's own default.
fn layer_label(n: i64, names: &HashMap<i64, String>) -> String {
match n { 1 => "F.Cu".into(), n if is_bottom(n) => "B.Cu".into(), _ => names.get(&n).cloned().unwrap_or_else(|| format!("In{}.Cu", n - 1)) }
}
/// The id a Fusion pour has in the zone state and the plan board (aiflow_board::eagle::zone_uuid).
fn pour_zone_id(p: &Value) -> String {
aiflow_board::eagle::zone_uuid(p["net"].as_str().unwrap_or(""), p["layer"].as_i64().unwrap_or(1), &outline_points(&p["outline"]))
}
fn outline_points(v: &Value) -> Vec<(f64, f64)> {
v.as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect()
}
/// A pour's layer as the plan sent it, under the name the zone state uses.
fn plan_layer(l: &Value, names: &HashMap<i64, String>) -> String {
l.as_i64().map(|n| layer_label(n, names)).or_else(|| l.as_str().map(str::to_string)).unwrap_or_default()
}
/// The same outline within the bridge's own tolerance, whichever vertex each starts at.
fn same_outline(a: &Value, b: &Value) -> bool {
let sorted = |v: &Value| { let mut p = outline_points(v); p.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); p };
let (a, b) = (sorted(a), sorted(b));
a.len() == b.len() && a.iter().zip(&b).all(|(p, q)| (p.0 - q.0).abs() <= 0.011 && (p.1 - q.1).abs() <= 0.011)
}
/// Every layer a route names that is not copper; these are refused, never drawn on a default layer.
fn bad_route_layers(args: &Value) -> Vec<String> {
let mut out = Vec::new();
let mut look = |l: &Value| { if !l.is_null() && layer_arg(l).is_none() { out.push(l.to_string()); } };
look(&args["layer"]);
for w in args["points"].as_array().into_iter().flatten() { look(&w["layer"]); }
for p in args["paths"].as_array().into_iter().flatten() {
for w in p.as_array().into_iter().flatten() { look(&w["layer"]); }
}
out
}
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_arg(&w["layer"]) { o.insert("layer".into(), 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)
// every copper layer, inner ones by the standard's name (a wire on any other layer is not copper)
let names = layer_names(f);
let segs: Vec<Value> = f["segments"].as_array().cloned().unwrap_or_default().iter().filter(|s| s["layer"].as_i64().is_some_and(|n| (1..=16).contains(&n))).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_label(s["layer"].as_i64().unwrap_or(1), &names), "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();
// fusion-bridge 1.12+ returns the EDA Bridge Standard's pads (name is the pad's own name, ref the
// part) with the planner's "REF.PAD" under key; older bridges put "REF.PAD" in name
let pads: Vec<Value> = f["pads"].as_array().cloned().unwrap_or_default().iter().map(|p| json!({
"name": if p["key"].is_string() { p["key"].clone() } else { p["name"].clone() }, "netName": p["net"].as_str().unwrap_or(""), "x": p["at"][0], "y": -p["at"][1].as_f64().unwrap_or(0.0)})).collect();
// unconnected from the copper itself (1.12+) is right even before Fusion recomputes its ratsnest;
// airwireCount is Fusion's ratsnest as exported
let unconnected = if f["unconnected"].is_u64() { f["unconnected"].clone() } else { f["airwireCount"].clone() };
json!({"success": true, "revision": f["revision"], "boardId": f["boardId"], "segments": segs, "vias": vias, "pads": pads,
"unconnected": unconnected, "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 {
// issue #203: the verbs that name copper layers learn the board's bottom number first
if matches!(verb, "kicad_routing_state" | "kicad_route_net" | "kicad_apply_zone_plan" | "kicad_zone_state" | "kicad_routing_validate") { ensure_bottom(doc, call); }
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 bad = bad_route_layers(args);
if !bad.is_empty() { return json!({"success": false, "errorCode": "unknown_layer", "error": format!("{} is not a copper layer: use F.Cu, In<n>.Cu or B.Cu. Nothing was routed.", bad.join(", "))}); }
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_arg(&args["layer"]) { a["layer"] = 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": if m["eagleRot"].is_string() { m["eagleRot"].clone() } else { json!(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_arg(&l) else {
return json!({"success": false, "errorCode": "unknown_layer", "error": format!("{} wants a pour on {l}, which is not a copper layer: use F.Cu, In<n>.Cu or B.Cu. Nothing was landed.", z["name"].as_str().unwrap_or("A zone"))});
};
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")}));
}
}
let owned: Vec<String> = args["ownedItemIds"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
if pours.is_empty() && owned.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Fusion"}); }
let before = call("fusion_pour_state", &json!({"expectDocument": doc}));
if before["success"].as_bool() != Some(true) { return before; }
let names = layer_names(&before);
let in_plan = |p: &Value| pours.iter().any(|q| q["net"] == p["net"] && plan_layer(&q["layer"], &names) == layer_label(p["layer"].as_i64().unwrap_or(1), &names) && same_outline(&q["points"], &p["outline"]));
let rows = before["pours"].as_array().cloned().unwrap_or_default();
let was_here: Vec<String> = rows.iter().map(pour_zone_id).collect();
let stale: Vec<Value> = rows.iter().filter(|p| !in_plan(p) && owned.contains(&pour_zone_id(p))).cloned().collect();
let stale_ids: Vec<String> = stale.iter().map(pour_zone_id).collect();
// the old pour goes first: Fusion's DELETE picks by a point on the outline, so with a new pour
// sharing that point it can take the new one instead (and the bridge's count still matches)
let mut rev = rev;
let mut id = id;
if !stale.is_empty() {
let targets: Vec<Value> = stale.iter().map(|p| match p["id"].as_str() { Some(i) => json!({"id": i}), None => json!({"net": p["net"], "layer": p["layer"], "points": p["outline"]}) }).collect();
let gone = call("fusion_remove_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": targets}));
if gone["success"].as_bool() != Some(true) {
let why = gone["error"].as_str().unwrap_or("no reason given").to_string();
return json!({"success": false, "mutated": gone["mutated"], "errorCode": gone["errorCode"], "error": format!("{} old pour(s) the plan no longer describes could not be removed, so nothing new was landed: {why}", stale.len()), "removeReply": gone});
}
(id, rev) = match guarded(None) { Ok(x) => x, Err(e) => return e };
}
let mut r = if pours.is_empty() { json!({"success": true, "mutated": !stale.is_empty()}) } else { call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": pours})) };
r["skipped"] = json!(skipped);
if !stale.is_empty() { r["mutated"] = json!(true); r["removedIds"] = json!(stale_ids); }
if r["success"].as_bool() != Some(true) {
// the plan did not land and the old copper is gone: put the old pours back
if !stale.is_empty() {
let back: Vec<Value> = stale.iter().map(|p| json!({"net": p["net"], "layer": p["layer"], "points": p["outline"], "isolate": p["isolate"], "rank": p["rank"], "width": p["width"], "thermals": p["thermals"], "orphans": p["orphans"]})).collect();
let restored = guarded(None).map(|(id, rev)| call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": back})));
let ok = matches!(&restored, Ok(v) if v["success"].as_bool() == Some(true));
if let Some(o) = r.as_object_mut() { if ok { o.remove("removedIds"); } }
r["oldPoursRestored"] = json!(ok);
}
return r;
}
let after = call("fusion_pour_state", &json!({"expectDocument": doc}));
if after["success"].as_bool() != Some(true) { r["note"] = json!("the pours landed, but the pour state could not be read back"); return r; }
let rows = after["pours"].as_array().cloned().unwrap_or_default();
// read the result back by id: the bridge's own check counts pours per net and layer
let still: Vec<&String> = stale_ids.iter().filter(|i| rows.iter().any(|p| pour_zone_id(p) == **i)).collect();
let missing: Vec<&Value> = pours.iter().filter(|q| !rows.iter().any(|p| q["net"] == p["net"] && plan_layer(&q["layer"], &names) == layer_label(p["layer"].as_i64().unwrap_or(1), &names) && same_outline(&q["points"], &p["outline"]))).collect();
if !still.is_empty() || !missing.is_empty() {
return json!({"success": false, "mutated": true, "errorCode": "readback_mismatch", "error": format!("after landing, {} old pour(s) are still on the board and {} planned pour(s) are not", still.len(), missing.len()), "stillThere": still, "missing": missing.iter().map(|q| q["net"].clone()).collect::<Vec<_>>(), "pours": rows});
}
// the run owns what it drew and what it owned already; a pour the board started with stays the board's, even when it matches
r["itemIds"] = json!(rows.iter().filter(|p| in_plan(p)).map(pour_zone_id).filter(|i| !was_here.contains(i) || owned.contains(i)).collect::<Vec<_>>());
if !stale.is_empty() { r["deletionVerified"] = json!(true); }
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 names = layer_names(&r);
// every copper layer's fill, inner ones by the standard's name; top and bottom always reported
let mut filled = serde_json::Map::new();
filled.insert("F.Cu".into(), json!(by["1"].as_f64().unwrap_or(0.0)));
// the bottom under whichever number the reply keys it (the board's own, 16, or Fusion's raw 304; fusion-bridge 1.13.87 reports 16)
let bot: f64 = { let mut keys = vec![bottom().to_string(), "16".to_string(), "304".to_string()]; keys.dedup(); keys.iter().filter_map(|k| by[k.as_str()].as_f64()).sum() };
filled.insert("B.Cu".into(), json!(bot));
for (k, v) in by.as_object().into_iter().flatten() {
if let Ok(n) = k.parse::<i64>() { if n != 1 && !is_bottom(n) { filled.insert(layer_label(n, &names), json!(v.as_f64().unwrap_or(0.0))); } }
}
let zones: Vec<Value> = r["pours"].as_array().cloned().unwrap_or_default().iter().map(|p| { let l = layer_label(p["layer"].as_i64().unwrap_or(1), &names);
// KiCad's zone shape, which measure and the analysis read: type copper, layers[], fills[]
json!({"id": pour_zone_id(p), "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": filled, "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" => {
// only opening the board switches the view; asking for the window (every landing does) must not,
// or each call re-raises the bridge's "Switching to the 2D board" panel over Fusion
if verb == "kicad_open_board" { 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()}}) }
}
// "Zoom to Selected Objects" after a net select: the select already framed the board, and a WINDOW
// now would end the open SHOW and drop the highlight
"kicad_send_key" if args["menu"].as_str() == Some("Zoom to Selected Objects") => json!({"success": true, "note": "framed by the select (Fusion keeps the highlight only while SHOW is open)"}),
"kicad_send_key" => { fit_board(doc, call); json!({"success": true}) }
"kicad_select_net" => {
// Fusion highlights a net with SHOW; what was lit is counted from the board source, the way
// KiCad's select answers, so the walkthrough knows a net with no copper from a lit one
let net = args["net"].as_str().unwrap_or("");
let src = call("fusion_board_source", &json!({"expectDocument": doc}));
let text = src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("").to_string();
let sig = text.split(&format!("<signal name=\"{net}\"")).nth(1).map(|t| t[..t.find("</signal>").unwrap_or(t.len())].to_string()).unwrap_or_default();
let (wires, polys, vias, pads) = (sig.matches("<wire ").count(), sig.matches("<polygon ").count(), sig.matches("<via ").count(), sig.matches("<contactref ").count());
// no closing semicolon: Fusion keeps a SHOW highlight only while the command stays open (checked
// live 2026-10-07; `SHOW 'X';` lit nothing). The next SHOW replaces it, `SHOW;` clears it.
let arg = if net.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+' || c == '.') { net.to_string() } else { format!("'{net}'") };
fit_board(doc, call); // frame first: any later command would end the SHOW and its highlight
let r = call("fusion_electron_run", &json!({"command": format!("SHOW {arg}")}));
json!({"success": r["success"], "selected": wires + polys + vias + pads, "byKind": {"tracks": wires, "zones": polys, "vias": vias, "pads": pads}, "error": r["error"]})
}
"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."}),
}
}
/// Hydrogen's main window on the box (the human's workspace: chat, webviews).
pub fn hydrogen_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("hydrogen.exe") && x["kind"].as_str() == Some("normal")).and_then(|x| x["hwnd"].as_i64())
}
/// Frame the board, not everything: Fusion's own fit includes any stray item off the board (a part not
/// placed yet, a text), which shrinks the board to a thumbnail. The view is set to the board outline
/// (the dimension layer) with a margin; plain fit when the outline cannot be read.
pub fn fit_board(doc: &str, call: &dyn Fn(&str, &Value) -> Value) -> bool {
let src = call("fusion_board_source", &json!({"expectDocument": doc}));
let text = src["source"].as_str().or_else(|| src["data"]["source"].as_str()).unwrap_or("");
let mut xs = Vec::new(); let mut ys = Vec::new();
for w in text.split("<wire ").skip(1) {
let w = &w[..w.find('>').unwrap_or(w.len())];
if !w.contains("layer=\"20\"") { continue; }
let num = |k: &str| w.split(&format!("{k}=\"")).nth(1).and_then(|r| r.split('"').next()).and_then(|v| v.parse::<f64>().ok());
for (a, b) in [("x1", "y1"), ("x2", "y2")] { if let (Some(x), Some(y)) = (num(a), num(b)) { xs.push(x); ys.push(y); } }
}
if xs.is_empty() { let _ = call("fusion_view", &json!({"action": "fit", "space": "2d"})); return false; }
let (x0, x1) = (xs.iter().cloned().fold(f64::MAX, f64::min), xs.iter().cloned().fold(f64::MIN, f64::max));
let (y0, y1) = (ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max));
let m = ((x1 - x0).max(y1 - y0) * 0.12).max(1.0);
let cmd = format!("WINDOW ({:.3}mm {:.3}mm) ({:.3}mm {:.3}mm);", x0 - m, y0 - m, x1 + m, y1 + m);
call("fusion_electron_run", &json!({"command": cmd}))["success"].as_bool().unwrap_or(false)
}
/// Fusion's main window on the test box.
pub 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::*;
/// Issue #142: a bottom part's move carries the MR angle land_moves worked out; a plain number would flip it.
#[test]
fn a_bottom_part_is_sent_its_mirrored_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())); json!({"success": true, "boardId": "B", "revision": "r1"}) };
let _ = translate("kicad_move_footprint", &json!({"expectedRevision": "r1", "refs": [{"ref": "U1", "x": 10.0, "y": -8.0, "rotation": 90.0, "eagleRot": "MR270"}]}), "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": "MR270"}));
}
#[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");
}
/// A bridge stub recording what was sent: a four-layer board with an AGND pour on layer 15 (In2.Cu).
fn four_layer(sent: &std::cell::RefCell<Vec<(String, Value)>>, 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", "vias": [], "pads": [],
"segments": [{"net": "A", "layer": 15, "width": 0.2, "start": [0.0, 1.0], "end": [2.0, 1.0]}, {"net": "A", "layer": 1, "width": 0.2, "start": [0.0, 0.0], "end": [1.0, 0.0]}, {"net": "", "layer": 19, "width": 0.0, "start": [0.0, 0.0], "end": [1.0, 0.0]}],
"tracks": [{"layer": "In2.Cu", "layerId": 15, "net": "A"}]}),
"fusion_pour_state" => json!({"success": true, "revision": "r1", "filledAreaByLayerMm2": {"1": 10.0, "15": 40.5},
"pours": [{"net": "GND", "layer": 1, "filledMm2": 10.0, "filled": true}, {"net": "AGND", "layer": 15, "filledMm2": 40.5, "filled": true}],
"zones": [{"net": "GND", "layer": "F.Cu", "layerId": 1}, {"net": "AGND", "layer": "In2.Cu", "layerId": 15}], "unfilled": []}),
_ => json!({"success": true, "revision": "r2"}),
}
}
#[test]
fn inner_copper_is_measured_and_read_under_its_own_name() {
let sent = std::cell::RefCell::new(Vec::new());
let call = |v: &str, a: &Value| four_layer(&sent, v, a);
let z = translate("kicad_zone_state", &json!({}), "Doc-brd", &call);
assert_eq!(z["filledAreaByLayerMm2"], json!({"F.Cu": 10.0, "B.Cu": 0.0, "In2.Cu": 40.5}), "an inner pour is never counted as top copper");
assert_eq!(z["zones"][1]["layer"], "In2.Cu"); assert_eq!(z["zones"][1]["fills"][0]["layer"], "In2.Cu");
let st = translate("kicad_routing_state", &json!({}), "Doc-brd", &call);
let layers: Vec<&str> = st["segments"].as_array().unwrap().iter().map(|s| s["layer"].as_str().unwrap()).collect();
assert_eq!(layers, ["In2.Cu", "F.Cu"], "inner tracks kept, the airwire layer is not copper");
// a bridge too old to name layers still does not call an inner pour top copper
assert_eq!(layer_label(15, &HashMap::new()), "In14.Cu");
}
#[test]
fn inner_routes_go_to_the_bridge_by_name_and_a_bad_layer_is_refused() {
let sent = std::cell::RefCell::new(Vec::new());
let call = |v: &str, a: &Value| four_layer(&sent, v, a);
let r = translate("kicad_route_net", &json!({"expectedRevision": "r1", "net": "A", "layer": "In1.Cu", "paths": [["R1.1", {"x": 1.0, "y": -1.0, "layer": "In2.Cu"}, {"x": 2.0, "y": -1.0, "layer": "F.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["layer"], "In1.Cu");
assert_eq!(a["paths"][0][1], json!({"x": 1.0, "y": 1.0, "layer": "In2.Cu"})); assert_eq!(a["paths"][0][2]["layer"], 1);
let n = sent.borrow().len();
for bad in [json!({"net": "A", "layer": "F.SilkS"}), json!({"net": "A", "layer": "F.Cu", "points": [{"x": 1.0, "y": 1.0, "layer": "In0.Cu"}]}), json!({"net": "A", "paths": [[{"pad": "R1.1", "layer": "17"}]]})] {
let r = translate("kicad_route_net", &bad, "Doc-brd", &call);
assert_eq!(r["errorCode"], "unknown_layer", "{bad}");
}
assert_eq!(sent.borrow().len(), n, "a refused route sends nothing, not even a default-layer draw");
}
#[test]
fn an_inner_pour_lands_by_name_beside_the_outer_ones() {
let sent = std::cell::RefCell::new(Vec::new());
let landed = std::cell::RefCell::new(Vec::<Value>::new());
let call = |v: &str, a: &Value| {
let r = four_layer(&sent, v, a);
if v == "fusion_apply_pours" {
for p in a["pours"].as_array().into_iter().flatten() { landed.borrow_mut().push(pour_row(p["net"].as_str().unwrap(), if p["layer"] == 1 { 1 } else { 2 }, p["points"].clone())); }
}
if v == "fusion_pour_state" { return json!({"success": true, "pours": landed.borrow().clone(), "zones": [{"layerId": 2, "layer": "In1.Cu"}]}); }
r
};
let square = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);
let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "top", "net": "GND", "layers": ["F.Cu"], "polygon": square}, {"name": "plane", "net": "PGND", "layers": ["In1.Cu"], "polygon": square}]}), "Doc-brd", &call);
assert_eq!(r["success"], true, "{r}");
let (v, a) = sent.borrow().iter().rfind(|(v, _)| v == "fusion_apply_pours").cloned().unwrap();
assert_eq!(v, "fusion_apply_pours");
assert_eq!(a["pours"][0]["layer"], 1); assert_eq!(a["pours"][1]["layer"], "In1.Cu"); assert_eq!(a["pours"][1]["net"], "PGND");
// not copper: refused, and nothing landed
let n = sent.borrow().iter().filter(|(v, _)| v == "fusion_apply_pours").count();
let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "x", "net": "GND", "layers": ["F.SilkS"], "polygon": square}]}), "Doc-brd", &call);
assert_eq!(r["errorCode"], "unknown_layer");
assert_eq!(sent.borrow().iter().filter(|(v, _)| v == "fusion_apply_pours").count(), n);
}
/// fusion-bridge#138: the zone state and the plan board name a pour the same way, and a changed
/// outline removes only the old one the run owned, then lands the new one, and reads both back by id.
fn pour_row(net: &str, layer: i64, outline: Value) -> Value { json!({"net": net, "layer": layer, "outline": outline, "filledMm2": 1.0, "filled": true}) }
#[test]
fn a_changed_pour_replaces_the_old_one_the_run_owns() {
let (old, new, theirs) = (json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]), json!([[0.0, 0.0], [8.0, 0.0], [8.0, 5.0], [0.0, 5.0]]), json!([[20.0, 0.0], [25.0, 0.0], [25.0, 5.0]]));
let rows = std::cell::RefCell::new(vec![pour_row("GND", 1, old.clone()), pour_row("GND", 1, theirs.clone())]);
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"}),
"fusion_pour_state" => json!({"success": true, "revision": "r1", "pours": rows.borrow().clone(), "zones": []}),
"fusion_apply_pours" => { rows.borrow_mut().push(pour_row("GND", 1, a["pours"][0]["points"].clone())); json!({"success": true, "mutated": true}) }
"fusion_remove_pours" => { rows.borrow_mut().remove(0); json!({"success": true, "verified": true, "revision": "r3"}) }
_ => json!({"success": true}),
}
};
let old_id = pour_zone_id(&pour_row("GND", 1, old.clone()));
let flipped: Vec<Value> = new.as_array().unwrap().iter().map(|p| json!([p[0], -p[1].as_f64().unwrap()])).collect();
let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [old_id], "zones": [{"name": "GND pour", "net": "GND", "layers": ["F.Cu"], "polygon": flipped}]}), "Doc-brd", &call);
assert_eq!(r["success"], true, "{r}");
assert_eq!(r["removedIds"], json!([old_id]));
assert_eq!(r["deletionVerified"], true);
assert_eq!(r["itemIds"], json!([pour_zone_id(&pour_row("GND", 1, new.clone()))]));
let verbs: Vec<String> = sent.borrow().iter().map(|(v, _)| v.clone()).filter(|v| v == "fusion_apply_pours" || v == "fusion_remove_pours").collect();
assert_eq!(verbs, ["fusion_remove_pours", "fusion_apply_pours"], "the old pour goes first: DELETE picks by a point the new one may share");
// the pour the board started with is still there, and a run that owns nothing removes nothing
assert_eq!(rows.borrow().len(), 2);
let n = sent.borrow().len();
let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [], "zones": [{"name": "GND pour", "net": "GND", "layers": ["F.Cu"], "polygon": [[30.0, 0.0], [35.0, 0.0], [35.0, -5.0]]}]}), "Doc-brd", &call);
assert!(r.get("removedIds").is_none(), "{r}");
assert!(sent.borrow()[n..].iter().all(|(v, _)| v != "fusion_remove_pours"));
}
#[test]
fn a_pour_the_board_started_with_is_not_claimed_when_the_plan_matches_it() {
let shape = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);
let row = pour_row("GND", 1, shape.clone());
let call = |v: &str, _: &Value| -> Value {
match v {
"fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),
"fusion_pour_state" => json!({"success": true, "revision": "r1", "pours": [row.clone()], "zones": []}),
_ => json!({"success": true, "mutated": false}),
}
};
let flipped: Vec<Value> = shape.as_array().unwrap().iter().map(|p| json!([p[0], -p[1].as_f64().unwrap()])).collect();
let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [], "zones": [{"name": "x", "net": "GND", "layers": ["F.Cu"], "polygon": flipped}]}), "Doc-brd", &call);
assert_eq!(r["itemIds"], json!([]));
}
#[test]
fn a_pour_that_does_not_land_puts_the_old_one_back() {
let old = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]);
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()));
let applies = sent.borrow().iter().filter(|(x, _)| x == "fusion_apply_pours").count();
match v {
"fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),
"fusion_pour_state" => json!({"success": true, "pours": [pour_row("GND", 1, old.clone())], "zones": []}),
"fusion_apply_pours" if applies == 1 => json!({"success": false, "errorCode": "readback_mismatch"}),
_ => json!({"success": true, "verified": true}),
}
};
let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [pour_zone_id(&pour_row("GND", 1, old.clone()))], "zones": [{"name": "x", "net": "GND", "layers": ["F.Cu"], "polygon": [[0.0, 0.0], [8.0, 0.0], [8.0, -5.0]]}]}), "Doc-brd", &call);
assert_eq!(r["errorCode"], "readback_mismatch");
assert_eq!(r["oldPoursRestored"], true);
assert!(r.get("removedIds").is_none(), "{r}");
let last = sent.borrow().iter().rfind(|(v, _)| v == "fusion_apply_pours").cloned().unwrap().1;
assert_eq!(last["pours"][0]["points"], old);
}
/// What the live run found on bridge 1.13.73: the bridge's remove answered verified, and the wrong pour had gone.
#[test]
fn the_result_is_read_back_by_id_not_taken_on_the_bridges_word() {
let (old, new) = (json!([[1.0, 1.0], [19.0, 1.0], [19.0, 9.0], [1.0, 9.0]]), [[1.0, -1.0], [19.0, -1.0], [19.0, -14.0], [1.0, -14.0]]);
let call = |v: &str, _: &Value| -> Value {
match v {
"fusion_routing_state" => json!({"success": true, "boardId": "B", "revision": "r1"}),
// the old pour is still here, the new one is not, whatever the verbs say
"fusion_pour_state" => json!({"success": true, "pours": [pour_row("GND", 16, old.clone())], "zones": []}),
_ => json!({"success": true, "verified": true}),
}
};
let r = translate("kicad_apply_zone_plan", &json!({"ownedItemIds": [pour_zone_id(&pour_row("GND", 16, old.clone()))], "zones": [{"name": "GND", "net": "GND", "layers": ["B.Cu"], "polygon": new}]}), "Doc-brd", &call);
assert_eq!(r["success"], false, "{r}");
assert_eq!(r["errorCode"], "readback_mismatch");
assert_eq!(r["missing"], json!(["GND"]));
assert_eq!(r["stillThere"].as_array().unwrap().len(), 1);
}
#[test]
fn a_pour_keeps_its_id_whichever_vertex_it_starts_at() {
let a = pour_row("GND", 1, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]));
let b = pour_row("GND", 1, json!([[5.0, 5.0], [0.0, 0.0], [5.0, 0.0]]));
assert_eq!(pour_zone_id(&a), pour_zone_id(&b));
assert_ne!(pour_zone_id(&a), pour_zone_id(&pour_row("GND", 16, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]))));
assert_ne!(pour_zone_id(&a), pour_zone_id(&pour_row("VIN", 1, json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]))));
}
#[test]
fn a_pour_the_bridge_refuses_comes_back_as_the_bridges_answer() {
let call = |v: &str, a: &Value| -> Value {
if v == "fusion_apply_pours" { json!({"success": false, "errorCode": "readback_mismatch", "missing": ["PGND"]}) } else { four_layer(&std::cell::RefCell::new(Vec::new()), v, a) }
};
let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "plane", "net": "PGND", "layers": ["In1.Cu"], "polygon": [[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]}]}), "Doc-brd", &call);
assert_eq!(r["success"], false); assert_eq!(r["errorCode"], "readback_mismatch"); assert_eq!(r["missing"][0], "PGND");
}
#[test]
fn standard_board_model_pads_keep_their_ref_pad_name() {
// fusion-bridge 1.12+: name is the pad's own, key is REF.PAD, unconnected comes from the copper
let f = json!({"success": true, "boardId": "B", "revision": "r1", "segments": [], "vias": [], "airwireCount": 0, "unconnected": 7,
"pads": [{"ref": "R1", "name": "1", "key": "R1.1", "net": "A", "at": [5.0, 4.5], "x": 5.0, "y": 4.5}]});
let st = to_kicad_state(&f);
assert_eq!(st["pads"][0]["name"], "R1.1");
assert_eq!(st["unconnected"], 7);
// an older bridge: REF.PAD in name, unconnected from Fusion's airwires
let st = to_kicad_state(&json!({"success": true, "segments": [], "vias": [], "airwireCount": 3, "pads": [{"name": "R1.1", "net": "A", "at": [5.0, 4.5]}]}));
assert_eq!(st["pads"][0]["name"], "R1.1");
assert_eq!(st["unconnected"], 3);
}
/// Issue #203: on a board whose bottom is 304, B.Cu goes to Fusion as 304 and 304 reads back as B.Cu.
#[test]
fn the_bottom_layer_follows_the_board() {
set_bottom(304);
assert_eq!(layer_arg(&json!("B.Cu")), Some(json!(304)));
assert_eq!(layer_label(304, &HashMap::new()), "B.Cu");
assert_eq!(layer_label(16, &HashMap::new()), "B.Cu");
assert_eq!(layer_label(2, &HashMap::new()), "In1.Cu");
set_bottom(16);
assert_eq!(layer_arg(&json!("B.Cu")), Some(json!(16)));
// fusion-bridge 1.13.87 reports the bottom as 16 even on a 304 board; a reply keyed 304 still counts as B.Cu
let r = translate("kicad_zone_state", &json!({}), "d", &|v, _| if v == "fusion_pour_state" { json!({"success": true, "filledAreaByLayerMm2": {"1": 10.0, "304": 25.0}, "pours": []}) } else { json!({"source": "<eagle/>"}) });
assert_eq!(r["filledAreaByLayerMm2"]["B.Cu"], 25.0, "{r}");
assert!(r["filledAreaByLayerMm2"].get("In303.Cu").is_none(), "{r}");
}
}