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.
AI Flow 0.2.30: 4-layer Fusion boards keep their inner planes (#112) #52
aiflow-lane-0.2.30 → mainAI Flow 0.2.30: 4-layer Fusion boards keep their inner planes (#112, Glenn Jones's regression report).
Base: 5290fce42f5134835f65f3249c7424bc88ab2074.
Cause: the Fusion converter (crates/aiflow-board/src/eagle.rs) only mapped EAGLE layers 1 and 16, so inner layers were skipped. Every published build back to at least 0.2.2x behaves this way; Glenn's run log shows the same "PGND on In1.Cu does not exist" on 2026-10-28. The published 0.2.26 binary converts his board-live.brd as 2-layer too.
Fix: inner_layers() reads the stack order from layerSetup, falling back to the inner layers the copper uses. layer() maps them to In1.Cu, In2.Cu ..., and the header declares them (KiCad id 2n+2).
Checks: Glenn's board-live.brd converts to 4 copper layers, with PGND on In1.Cu, AGND on In2.Cu and all 9 pours, matching his export. New test inner_copper_layers_carry_over_from_fusion. cargo check --workspace --bins clean; cargo test --workspace 502 passed; tools/test_fusion_project.py OK; CLI against 0.2.29: no command or flag missing; pr_preflight no problems.
Not included: landing and measuring inner-layer copper (documented in docs/fusion-lane.md).
Closes #112.
🤖 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.29"+version = "0.2.30" dependencies = [ "aiflow-analyze", "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [ [[package]] name = "aiflow-analyze"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "serde",@@ -35,7 +35,7 @@ dependencies = [ [[package]] name = "aiflow-board"-version = "0.2.29"+version = "0.2.30" dependencies = [ "roxmltree", "serde",@@ -44,7 +44,7 @@ dependencies = [ [[package]] name = "aiflow-bridge"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "serde",@@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "aiflow-copper"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [ [[package]] name = "aiflow-grid"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "serde",@@ -72,7 +72,7 @@ dependencies = [ [[package]] name = "aiflow-place"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "serde",@@ -81,7 +81,7 @@ dependencies = [ [[package]] name = "aiflow-pours"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [ [[package]] name = "aiflow-router"-version = "0.2.29"+version = "0.2.30" dependencies = [ "aiflow-board", "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [ [[package]] name = "aiflow-run"-version = "0.2.29"+version = "0.2.30" dependencies = [ "serde", "serde_json",
Cargo.toml+1−1@@ -14,7 +14,7 @@ members = [ ] [workspace.package]-version = "0.2.29"+version = "0.2.30" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/aiflow-board/src/eagle.rs+67−4@@ -57,7 +57,38 @@ pub fn arc_mid(a: (f64, f64), b: (f64, f64), curve: f64) -> (f64, f64) { (cx + ux * h.cos() - uy * h.sin(), cy + ux * h.sin() + uy * h.cos()) } -fn layer(l: Option<&str>) -> Option<&'static str> { match l { Some("1") => Some("F.Cu"), Some("16") => Some("B.Cu"), _ => None } }+/// The board's inner copper layers in stack order, as EAGLE numbers (2..15): from the `layerSetup` rule+/// ("(1+2*15+16)" is Top, Route2, Route15, Bottom), else every inner layer the board's copper uses, in+/// number order. Issue #112: a 4-layer Fusion board's inner planes were dropped.+fn inner_layers(board: &roxmltree::Node) -> Vec<String> {+ let setup = board.descendants().find(|x| x.has_tag_name("param") && x.attribute("name") == Some("layerSetup")).and_then(|x| x.attribute("value"));+ let mut out: Vec<String> = Vec::new();+ if let Some(v) = setup {+ let mut cur = String::new();+ for ch in v.chars().chain(std::iter::once(' ')) {+ if ch.is_ascii_digit() { cur.push(ch); continue; }+ if let Ok(k) = cur.parse::<u32>() { if (2..=15).contains(&k) && !out.contains(&cur) { out.push(cur.clone()); } }+ cur.clear();+ }+ if !out.is_empty() { return out; }+ }+ let mut used: Vec<u32> = board.descendants().filter(|x| x.has_tag_name("wire") || x.has_tag_name("polygon") || x.has_tag_name("polygonpour"))+ .filter(|x| x.ancestors().any(|a| a.has_tag_name("signal")))+ .filter_map(|x| x.attribute("layer").and_then(|l| l.parse::<u32>().ok())).filter(|k| (2..=15).contains(k)).collect();+ used.sort(); used.dedup();+ 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> {+ match l {+ Some("1") => Some("F.Cu".into()),+ Some("16") => Some("B.Cu".into()),+ Some(x) => inner.iter().position(|i| i == x).map(|k| format!("In{}.Cu", k + 1)),+ None => None,+ }+} /// The KiCad silkscreen layer an EAGLE layer prints on (see the module notes); None when it does not print. pub fn silk_layer(l: &str) -> Option<&'static str> { match l { "21" | "25" => Some("F.SilkS"), "22" | "26" => Some("B.SilkS"), _ => None } }@@ -121,9 +152,12 @@ pub fn to_kicad(text: &str) -> Result<String, String> { let net_id = |name: &str| -> String { format!("(net {})", nets.get(name).copied().unwrap_or(0)) }; let zone_net = |name: &str| -> String { format!("(net {}) (net_name {})", nets.get(name).copied().unwrap_or(0), q(name)) }; + let inner = inner_layers(&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(); 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(&format!(" (layers (0 \"F.Cu\" signal) {inner_decl}(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)); } @@ -230,7 +264,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")) else { continue };+ let Some(l) = layer(w.attribute("layer"), &inner) 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")) {@@ -238,7 +272,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")) else { continue };+ let Some(l) = layer(p.attribute("layer"), &inner) 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; }@@ -343,6 +377,35 @@ mod tests { let z = text.lines().find(|l| l.contains("(zone ")).unwrap(); assert!(z.contains("(connect_pads (clearance 0.2))") && z.contains("(thermal_gap 0.254) (thermal_bridge_width 0.1524)"), "{z}"); }+ /// Issue #112 (Glenn's TI252772 module): a 4-layer Fusion board keeps its inner planes. The stack order+ /// comes from layerSetup ("(1+2*15+16)": Route2 is In1.Cu, Route15 is In2.Cu); without it, the inner+ /// layers the copper uses, in number order. A 2-layer board is unchanged.+ #[test]+ fn inner_copper_layers_carry_over_from_fusion() {+ let pour = |layer: &str, net: &str| format!(r#"<polygonpour layer="{layer}" isolate="0.2" width="0.2" thermals="no"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlineobjects></polygonpour>"#).replace("NET", net);+ let four = |rules: &str| BRD.replace("<plain>", &format!("{rules}<plain>"))+ .replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{}<wire x1="1" y1="2" x2="5" y2="2" width="0.2" layer="15"/>"#, pour("2", "B")))+ .replace(r#"<signal name="A">"#, &format!(r#"<signal name="A">{}"#, pour("15", "A")));+ let rules = r#"<designrules name="x"><param name="layerSetup" value="(1+2*15+16)"/></designrules>"#;+ for brd in [four(rules), four("")] {+ let text = super::to_kicad(&brd).unwrap();+ assert!(text.contains(r#"(layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)"#), "{}", &text[..400]);+ let zones: Vec<&str> = text.lines().filter(|l| l.contains("(zone ")).collect();+ assert!(zones.iter().any(|z| z.contains("(net_name \"B\") (layer In1.Cu)")), "{zones:?}");+ assert!(zones.iter().any(|z| z.contains("(net_name \"A\") (layer In2.Cu)")), "{zones:?}");+ assert!(text.lines().any(|l| l.contains("(segment") && l.contains("(layer In2.Cu)")));+ let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();+ assert_eq!(b.copper_layers, ["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+ }+ // a stack that lists only one inner layer maps it to In1.Cu, and copper on an unlisted layer is left out+ let one = four(r#"<designrules name="x"><param name="layerSetup" value="(1*15+16)"/></designrules>"#);+ let text = super::to_kicad(&one).unwrap();+ assert!(text.contains(r#"(0 "F.Cu" signal) (4 "In1.Cu" signal) (2 "B.Cu" signal)"#));+ assert!(text.lines().any(|l| l.contains("(net_name \"A\") (layer In1.Cu)")));+ // two layers: as before+ let text = super::to_kicad(BRD).unwrap();+ assert!(text.contains(r#"(layers (0 "F.Cu" signal) (2 "B.Cu" signal)"#) && !text.contains("In1.Cu"));+ } /// EAGLE's MRθ: rotate θ counterclockwise, then mirror x. A pad off both axes tells the order apart /// (mirror-then-rotate puts MR90's pad 1 at the opposite corner). #[test]
docs/fusion-lane.md+2@@ -19,6 +19,7 @@ adom-aiflow start --board lanetest.brd --spec spec.json --engine <you> \ 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. EAGLE's `MRθ` is rotate θ, then mirror x, so a bottom part at MR90 turns clockwise as seen from the top.+- Inner copper layers come along (issue #112). A 4-layer board's inner layers become In1.Cu, In2.Cu ... in the order of the board's `layerSetup` rule: `(1+2*15+16)` maps Route2 to In1.Cu and Route15 to In2.Cu. A board with no `layerSetup` uses the inner layers its copper uses, in number order. Their tracks and pours convert like the outer layers, so reference planes on In1.Cu or In2.Cu and a 4-layer rules pack match the board. - Pours keep their pad connection (issue #112). A polygon with thermals off (`CHANGE THERMALS OFF`, `thermals="no"`) becomes a solid-connect zone, `(connect_pads yes (clearance <isolate>))`. Thermals on (Fusion's default) becomes thermal relief. Its gap is the board's Supply rule `slThermalIsolate` (10 mil by default), and its spokes are the pour's `thermalwidth`, else its `width`. In `spec.pours`, describe a Fusion pour the same way: `"connection": "solid"`, or `"thermal"` with `thermalGap` and `thermalBridge`. - Silkscreen text comes along as `gr_text` (issue #74): plain text on tPlace/tNames (21, 25) on F.SilkS, on bPlace/bNames (22, 26) on B.SilkS, each smashed part's displayed NAME where it stands, an unsmashed@@ -161,6 +162,7 @@ decision (a source column and dated stock), which belongs to the design, not the ## What is not in Fusion (yet) - Keepout zones are not landed; pours are drawn clear of them. `verify-pours` (issue #113) therefore does not look for a keepout as a zone on the board. It checks the area clear instead: no saved pour fill of any net on the keepout's layers may lie inside it, and a pour that reaches into it with no saved fill fails, since nothing shows it clear. Only a pour keepout (`copperPour` alone) can be judged this way, so a track or via keepout is refused in words. The proof lists these under `keepoutsCheckedClear`. They are not adoptable zones, so `--adopt` owns only the matched pours. Altium, which cuts pour keepouts out of its polygons, is judged the same way.+- Copper on inner layers is read, but not yet landed or measured. `land route`, `land pours` and `measure` work on F.Cu and B.Cu only; draw inner-layer planes in Fusion yourself. - Removing copper is by whole net (`unroute`), not by single segment. - Arcs (curved wires) in the source are read as straight segments.
docs/release-0.2.30.mdadded+5@@ -0,0 +1,5 @@+# AI Flow 0.2.30 (2026-10-31)++- **4-layer Fusion boards keep their inner planes (#112, reported by Glenn Jones).** The Fusion converter only knew the top and bottom copper layers, so a 4-layer board became a 2-layer KiCad board. Its inner pours and tracks were dropped, its reference planes "did not exist", and the gate refused the 4-layer rules pack. Inner layers now become In1.Cu, In2.Cu ... in the order of the board's `layerSetup` rule (`(1+2*15+16)`: Route2 is In1.Cu, Route15 is In2.Cu), or in number order when there is none. Checked on the TI252772 module: 4 copper layers, with PGND on In1.Cu, AGND on In2.Cu, and all 9 pours.++Not yet: AI Flow reads inner-layer copper but does not land or measure it. `land route`, `land pours` and `measure` stay on F.Cu and B.Cu, and docs/fusion-lane.md says so.
package.json+1−1@@ -1,7 +1,7 @@ { "slug": "adom-aiflow", "type": "app",- "version": "0.2.29",+ "version": "0.2.30", "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.29",+ "version": "0.2.30", "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.