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.
← All Pull Requests
AI Flow 0.2.77: Fusion's newer board format keeps its bottom copper #97
Merges
aiflow-0.2.77 → mainAI Flow 0.2.77: Fusion's newer board format keeps its bottom copper. Closes #203.
- eagle.rs: the bottom copper is the layer the board names "Bottom" (304 in Fusion's newer format, 16 before); tracks and pours on it convert to B.Cu.
- aiflow-bridge fusion.rs: B.Cu goes to Fusion as the board's bottom number (read once per command from the live board; AIFLOW_FUSION_BOTTOM overrides), 304 and 16 read back as B.Cu, the bottom fill is read from it.
- The Fusion bridge's half (304 named In1.Cu, pads on 16 vs tracks on 304, via extent 1-64) is filed on its page.
- Release notes also cover PR #96 (keep the copper).
Tests: 720 workspace tests (new: a 304 board converts its bottom track and pour; B.Cu maps to 304 and back), Fusion and side/pour workflow suites pass; preflight clean.
🤖 Generated with Claude Code
Diff Skip to comments
Cargo.lock+10−10@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-aiflow"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.76"+version = "0.2.77" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.76"+version = "0.2.77" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.76"+version = "0.2.77" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.76"+version = "0.2.77" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow⋯ 1 unchanged line ⋯
crates/aiflow-board/src/eagle.rs+46−6@@ -98,12 +98,29 @@ fn inner_layers(board: &roxmltree::Node) -> Vec<String> { used.into_iter().map(|k| k.to_string()).collect() } -/// An EAGLE copper layer's KiCad name: 1 is F.Cu, 16 is B.Cu, and the inner layers In1.Cu, In2.Cu ... in-/// the board's stack order (`inner_layers`). None for a layer that is not copper on this board.-fn layer(l: Option<&str>, inner: &[String]) -> Option<String> {+/// Issue #203 (Zoltán): the board's bottom copper layer number. Fusion's newer board format numbers it 304+/// (named "Bottom"), with layer 16 left as an empty "Route16"; older boards use 16. The layer the board names+/// "Bottom" decides; 16 when none does.+fn bottom_of(board: &roxmltree::Node) -> String {+ board.document().descendants().filter(|x| x.has_tag_name("layer"))+ .find(|x| x.attribute("name").is_some_and(|n| n.eq_ignore_ascii_case("bottom")) && x.attribute("number").is_some_and(|k| k != "1"))+ .and_then(|x| x.attribute("number")).unwrap_or("16").to_string()+}++/// The bottom copper layer number of a Fusion/EAGLE board file (see `bottom_of`), for the bridge's layer arguments.+pub fn bottom_number(text: &str) -> Option<i64> {+ let doc = roxmltree::Document::parse_with_options(text, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?;+ let board = doc.descendants().find(|x| x.has_tag_name("board"))?;+ bottom_of(&board).parse().ok()+}++/// An EAGLE copper layer's KiCad name: 1 is F.Cu, the board's bottom (16, or 304 in Fusion's newer format) is+/// B.Cu, and the inner layers In1.Cu, In2.Cu ... in the board's stack order (`inner_layers`). None for a layer+/// that is not copper on this board.+fn layer(l: Option<&str>, inner: &[String], bottom: &str) -> Option<String> { match l { Some("1") => Some("F.Cu".into()),- Some("16") => Some("B.Cu".into()),+ Some(x) if x == bottom || x == "16" => Some("B.Cu".into()), Some(x) => inner.iter().position(|i| i == x).map(|k| format!("In{}.Cu", k + 1)), None => None, }@@ -172,6 +189,7 @@ pub fn to_kicad(text: &str) -> Result<String, String> { let zone_net = |name: &str| -> String { format!("(net {}) (net_name {})", nets.get(name).copied().unwrap_or(0), q(name)) }; let inner = inner_layers(&board);+ let bottom = bottom_of(&board); // KiCad numbers inner copper In<n>.Cu as 2n+2, listed between F.Cu and B.Cu let inner_decl: String = (1..=inner.len()).map(|k| format!("({} \"In{k}.Cu\" signal) ", 2 * k + 2)).collect(); let mut o = String::new();@@ -283,7 +301,7 @@ pub fn to_kicad(text: &str) -> Result<String, String> { 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"), &inner) else { continue };+ let Some(l) = layer(w.attribute("layer"), &inner, &bottom) 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_id(name)); } for v in s.children().filter(|x| x.has_tag_name("via")) {@@ -291,7 +309,7 @@ pub fn to_kicad(text: &str) -> Result<String, String> { 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_id(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"), &inner) else { continue };+ let Some(l) = layer(p.attribute("layer"), &inner, &bottom) 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; }@@ -521,4 +539,26 @@ mod tests { } } }+ /// Issue #203 (Zoltán): Fusion's newer board format numbers the bottom copper 304 ("Bottom"), with 16 an+ /// empty "Route16"; its tracks and pours land on B.Cu, not dropped.+ #[test]+ fn the_newer_fusion_format_keeps_its_bottom_copper_on_304() {+ let brd = r#"<?xml version="1.0"?><eagle><drawing><layers>+<layer number="1" name="Top" color="4" fill="1" visible="yes" active="yes"/>+<layer number="16" name="Route16" color="1" fill="1" visible="no" active="no"/>+<layer number="304" name="Bottom" color="1" fill="1" visible="yes" active="yes"/>+<layer number="20" name="Dimension" color="24" fill="1" visible="yes" active="yes"/></layers>+<board><plain><wire x1="0" y1="0" x2="20" y2="0" width="0" layer="20"/><wire x1="20" y1="0" x2="20" y2="20" width="0" layer="20"/><wire x1="20" y1="20" x2="0" y2="20" width="0" layer="20"/><wire x1="0" y1="20" x2="0" y2="0" width="0" layer="20"/></plain>+<designrules name="x"><param name="layerSetup" value="(1*64)"/></designrules>+<signals><signal name="GND"><wire x1="2" y1="2" x2="10" y2="2" width="0.3" layer="304"/><via x="10" y="2" extent="1-64" drill="0.3"/>+<polygonpour width="0.2" layer="304"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="19"/><vertex x="1" y="19"/></polygonoutlineobjects></polygonpour></signal></signals>+</board></drawing></eagle>"#;+ let k = crate::eagle::to_kicad(brd).unwrap();+ assert!(k.contains("(layer B.Cu)") || k.contains("(layer \"B.Cu\")"), "the 304 track is on B.Cu: {k}");+ assert!(k.matches("B.Cu").count() >= 3, "the pour is kept on B.Cu too: {k}");+ assert!(!k.contains("In1.Cu"), "no inner layer is invented");+ assert_eq!(crate::eagle::bottom_number(brd), Some(304));+ assert_eq!(crate::eagle::bottom_number(&brd.replace("number=\"304\" name=\"Bottom\"", "number=\"305\" name=\"Other\"").replace("number=\"16\" name=\"Route16\"", "number=\"16\" name=\"Bottom\"")), Some(16), "an older board's Bottom is 16");+ }+ }
crates/aiflow-bridge/src/eda.rs+1@@ -288,6 +288,7 @@ impl Eda for FusionEda { }; let brd = dir.join("board-live.brd"); let kicad = dir.join("board-live.kicad_pcb");+ crate::fusion::note_board_source(text); // issue #203: the board's bottom copper number let plan = match aiflow_board::eagle::to_kicad(text) { Ok(p) => p, Err(e) => return Some(Err(format!("live Fusion board: {e}"))) }; if let Err(e) = std::fs::write(&brd, text).and_then(|_| std::fs::write(&kicad, plan)) { return Some(Err(e.to_string())); } Some(Ok((brd.display().to_string(), kicad.display().to_string())))
crates/aiflow-bridge/src/fusion.rs+42−4@@ -25,6 +25,30 @@ pub fn doc_of(board: &str) -> Option<String> { board.strip_prefix(PREFIX).map(st 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.@@ -32,7 +56,7 @@ 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" => Some(json!(16)),+ "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()?;@@ -55,7 +79,7 @@ fn layer_names(f: &Value) -> HashMap<i64, String> { /// 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(), 16 => "B.Cu".into(), _ => names.get(&n).cloned().unwrap_or_else(|| format!("In{}.Cu", n - 1)) }+ 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).@@ -133,6 +157,8 @@ pub fn to_kicad_state(f: &Value) -> Value { /// 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> {@@ -240,9 +266,9 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value // 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)));- filled.insert("B.Cu".into(), json!(by["16"].as_f64().unwrap_or(0.0)));+ filled.insert("B.Cu".into(), json!(by[bottom().to_string()].as_f64().or(by["16"].as_f64()).unwrap_or(0.0))); for (k, v) in by.as_object().into_iter().flatten() {- if let Ok(n) = k.parse::<i64>() { if n != 1 && n != 16 { filled.insert(layer_label(n, &names), json!(v.as_f64().unwrap_or(0.0))); } }+ 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[]@@ -588,4 +614,16 @@ mod tests { 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)));+ }+ }
docs/release-0.2.77.mdadded+14@@ -0,0 +1,14 @@+# AI Flow 0.2.77++- **#203 (Zoltán): Fusion's newer board format keeps its bottom copper.** That format numbers the bottom copper+ 304 (named "Bottom"), with layer 16 left as an empty "Route16", and the converter mapped only 16 to B.Cu, so+ every bottom track and pour was dropped: `route` saw no bottom GND plane and routed GND as tracks. The+ converter now takes the layer the board names "Bottom" (16 on older boards). When AI Flow lands work in+ Fusion, B.Cu goes to the board's own bottom number, that number reads back as B.Cu, and the bottom fill is+ read from it; the number is read from the live board once per command (`AIFLOW_FUSION_BOTTOM` overrides).+ Still the Fusion bridge's (filed on its page): its layer stack names 304 "In1.Cu", it reports bottom pads on 16+ while tracks go on 304, and a via with `extent="1-64"` breaks `fusion_routing_state`.+- **Keep the copper (PR #96, Drew).** New docs/keep-copper.md and a pours-step hint: the 3rd party fab ablates+ whatever copper a board does not keep, so pour GND over the free area on both layers, tie each orphan island+ to GND or widen the adjacent trace into the gap, add a via only when an island is worth a hole, and leave+ copper out only where its absence is the feature (a named keepout in spec.pours).
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.76",+ "version": "0.2.77", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.76",+ "version": "0.2.77", "title": "AI Flow", "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.", "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
Comments
Log in to comment.
No comments yet.