← All Pull Requests

AI Flow 0.2.42: Fusion inner copper layers are measured, read and routed by name #57

Merged Released in 0.2.42 on 2 Nov 2026 opened by Colby Knox
Merges aiflow-lane-0.2.42 → main

Fusion lane, inner copper layers. measure reports each inner layer under its own name instead of counting it as top copper. The routing state keeps inner tracks. Routes go to In.Cu by name, where the layer used to be dropped and the track could land on top copper. A layer that isn't copper is refused. An inner pour in a plan is kept if it's already drawn, and refused in words if not, because fusion_apply_pours still takes top and bottom only.

Base: 368fbe94. Tested: 536 Rust tests and the Python suites pass; no CLI command is missing against 0.2.41. Not run live: this was tested against a stand-in bridge built from fusion-bridge 1.13.56's source, not on a real 4-layer board. Colby asked to release it ahead of that test.

🤖 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.41"+version = "0.2.42" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.41"+version = "0.2.42" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.41"+version = "0.2.42" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.41"+version = "0.2.42" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.41"+version = "0.2.42" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/aiflow-bridge/src/fusion.rs+149−10
@@ -11,6 +11,7 @@ //! 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:";@@ -24,8 +25,50 @@ 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() } -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" } }+/// 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" => Some(json!(16)),+        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(), 16 => "B.Cu".into(), _ => names.get(&n).cloned().unwrap_or_else(|| format!("In{}.Cu", n - 1)) }+}++/// 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.@@ -35,7 +78,7 @@ fn waypoint(w: &Value) -> Value {     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)); }+    if let Some(l) = layer_arg(&w["layer"]) { o.insert("layer".into(), l); }     Value::Object(o) } @@ -49,9 +92,11 @@ 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!({+    // 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_name(s["layer"].as_i64().unwrap_or(1)), "net_name": s["net"], "width": s["width"], "uuid": item_id(&s["net"], 's', i)})).collect();+        "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@@ -79,11 +124,13 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value     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_num(args["layer"].as_str()) { a["layer"] = json!(l); }+            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<_>>()); }@@ -99,7 +146,7 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value         }         "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();+            let mut pours = Vec::new(); let mut vias = Vec::new(); let mut skipped = Vec::new(); let mut inner: Vec<(Value, Value, String)> = 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; }@@ -107,10 +154,32 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value                 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 };+                    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"))});+                    };+                    // fusion_apply_pours draws on top and bottom only: an inner pour is checked below+                    if !matches!(ln.as_i64(), Some(1) | Some(16)) { inner.push((z["name"].clone(), z["net"].clone(), l.as_str().map(str::to_string).unwrap_or_else(|| l.to_string()))); 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 !inner.is_empty() {+                // never dropped in silence: fine when that pour is already on the board, refused when it is not+                let live = call("fusion_pour_state", &json!({"expectDocument": doc}));+                if live["success"].as_bool() != Some(true) { return live; }+                let names = layer_names(&live);+                let on_board = |net: &Value, layer: &str| -> bool {+                    let in_zones = live["zones"].as_array().into_iter().flatten().any(|z| z["net"] == *net && z["layer"].as_str() == Some(layer));+                    in_zones || live["pours"].as_array().into_iter().flatten().any(|p| p["net"] == *net && layer_label(p["layer"].as_i64().unwrap_or(1), &names) == layer)+                };+                let mut missing = Vec::new();+                for (name, net, layer) in &inner {+                    if on_board(net, layer) { skipped.push(json!({"name": name, "why": format!("already on {layer} in Fusion; fusion_apply_pours draws on top and bottom only, so it is kept as drawn")})); }+                    else { missing.push(format!("{} on {layer}", net.as_str().unwrap_or("?"))); }+                }+                if !missing.is_empty() {+                    return json!({"success": false, "errorCode": "inner_pour_unsupported", "error": format!("the Fusion bridge cannot draw a pour on an inner layer yet: {}. Nothing was landed. Draw it in Fusion and land the plan again (a pour already there is kept), or take it out of the plan.", missing.join(", "))});+                }+            }             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);@@ -120,10 +189,18 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value             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));+            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)));+            filled.insert("B.Cu".into(), json!(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))); } }+            }+            let zones: Vec<Value> = r["pours"].as_array().cloned().unwrap_or_default().iter().enumerate().map(|(i, 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": 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"})+            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 };@@ -271,6 +348,68 @@ mod tests {         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_is_kept_when_drawn_and_refused_when_not_never_dropped() {+        let sent = std::cell::RefCell::new(Vec::new());+        let call = |v: &str, a: &Value| four_layer(&sent, v, a);+        let square = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);+        // not on the board: refused in words, nothing landed, not even the outer pour beside it+        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"], false); assert_eq!(r["errorCode"], "inner_pour_unsupported");+        assert!(r["error"].as_str().unwrap().contains("PGND on In1.Cu"), "{r}");+        assert!(!sent.borrow().iter().any(|(v, _)| v == "fusion_apply_pours"));+        // already drawn in Fusion: kept and said so, the outer pour still lands+        let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "top", "net": "GND", "layers": ["F.Cu"], "polygon": square}, {"name": "plane", "net": "AGND", "layers": ["In2.Cu"], "polygon": square}]}), "Doc-brd", &call);+        assert_eq!(r["success"], true);+        assert!(r["skipped"][0]["why"].as_str().unwrap().contains("In2.Cu"), "{r}");+        let (v, a) = sent.borrow().last().cloned().unwrap();+        assert_eq!(v, "fusion_apply_pours"); assert_eq!(a["pours"].as_array().unwrap().len(), 1); assert_eq!(a["pours"][0]["layer"], 1);+        // not copper: refused+        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");+    }     #[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
docs/fusion-lane.md+1−1
@@ -166,7 +166,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.+- Copper on inner layers: `land route` sends tracks to In<n>.Cu by name, the routing state keeps inner tracks, and `measure` reports each inner layer's fill under its own name. `land pours` still can't draw on an inner layer, because `fusion_apply_pours` takes top and bottom only: draw inner planes in Fusion yourself. A plan naming an inner pour keeps it if it's already drawn, and refuses in words if not, before anything lands. - 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.42.mdadded+10
@@ -0,0 +1,10 @@+# AI Flow 0.2.42 (2026-11-02)++Fusion lane, inner copper layers:++- **`measure` no longer counts an inner pour as top copper.** Each inner layer's fill is reported under its own name, such as In2.Cu.+- **Inner tracks stay in the routing state,** named In1.Cu, In2.Cu and so on.+- **A route on an inner layer goes there.** The layer used to be dropped, and the bridge could draw the track on top copper. It now goes to the bridge by name, and a layer that isn't copper is refused before anything is drawn.+- **An inner pour in a plan is never dropped in silence.** If it's already drawn in Fusion, it's kept and the reply says so. If not, the plan is refused in words before anything lands. `fusion_apply_pours` draws on top and bottom only, so inner planes are still drawn by hand.++Not yet run live: this was tested against a stand-in bridge built from the Fusion bridge's 1.13.56 source, not on a real 4-layer board in Fusion. Boards with an inner track now show it in the routing state, which they didn't before.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.41",+  "version": "0.2.42",   "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.41",+  "version": "0.2.42",   "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

No comments yet.

Log in to comment.