← Commit history

Merge PR #371: AI Flow 0.2.27: Fusion pours keep thermals off as solid (#112); Kelvin keepouts checked clear on Fusion (#113) (aiflow-lane-0.2.27 -> main) merge

Colby Knox ·09b1480e7e · ·parents ea74a5e, 62dce33

Showing changes introduced by the merge (against its first parent).

8 files changed +164−16
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.26"+version = "0.2.27" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.26"+version = "0.2.27" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.26"+version = "0.2.27" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.26"+version = "0.2.27" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/adom-aiflow/src/pourverify.rs+111−1
@@ -165,7 +165,68 @@ fn zone_id(n: &Node) -> Option<String> {     n.find("uuid").or_else(|| n.find("tstamp")).and_then(Node::value).filter(|s| !s.is_empty()).map(str::to_string) } +/// The plan against the saved board, keepouts judged the way the run's tool keeps them (see `check_with`). pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {+    check_with(board, plan, aiflow_bridge::eda::active().caps().keepout_zones)+}++fn pts_of(v: &Value) -> Vec<(f64, f64)> {+    v.as_array().into_iter().flatten().filter_map(|p| Some((p[0].as_f64()?, p[1].as_f64()?))).collect()+}++/// True when two simple polygons share area: a vertex of one inside the other, or two edges crossing.+fn overlap(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {+    use aiflow_board::geom::point_in_poly;+    if a.len() < 3 || b.len() < 3 { return false; }+    if a.iter().any(|p| point_in_poly(p.0, p.1, b)) || b.iter().any(|p| point_in_poly(p.0, p.1, a)) { return true; }+    let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    (0..a.len()).any(|i| {+        let (p, q) = (a[i], a[(i + 1) % a.len()]);+        (0..b.len()).any(|j| {+            let (r, t) = (b[j], b[(j + 1) % b.len()]);+            o(p, q, r) * o(p, q, t) < 0.0 && o(r, t, p) * o(r, t, q) < 0.0+        })+    })+}++/// Issue #113: on a tool that does not land keepout zones (Fusion; Altium cuts pour keepouts out of its+/// polygons), a plan keepout is not a zone the board must carry: it is an area no pour copper may enter.+/// Only pour keepouts can be judged that way; any saved fill on its layers inside it fails, and so does+/// a pour whose outline reaches into it with no saved fill to judge.+fn keepout_clear(board: &Board, actual: &[&Node], z: &Value, label: &str) -> Vec<String> {+    let k = z.get("keepout").cloned().unwrap_or(json!({}));+    let others: Vec<&str> = ["tracks", "vias", "pads", "footprints"].into_iter().filter(|key| k[*key] == true).collect();+    if k["copperPour"] != true || !others.is_empty() {+        return vec![format!("{label}: this tool does not land keepout zones, and only a pour keepout (copperPour alone) can be checked clear of copper instead{}",+            if others.is_empty() { String::new() } else { format!("; this one also keeps out {}", others.join(", ")) })];+    }+    let poly = pts_of(&z["polygon"]);+    let mut out = Vec::new();+    for layer in z["layers"].as_array().into_iter().flatten().filter_map(Value::as_str) {+        for (i, n) in actual.iter().enumerate() {+            if n.find("keepout").is_some() { continue; }+            let on = n.find("layer").and_then(Node::value) == Some(layer)+                || n.find("layers").is_some_and(|l| (1..8).filter_map(|j| l.atom(j)).any(|x| x == layer));+            if !on { continue; }+            let net = board.item_net(n);+            let fills: Vec<Vec<(f64, f64)>> = n.find_all("filled_polygon")+                .filter(|p| p.find("layer").and_then(Node::value) == Some(layer))+                .filter_map(|p| p.find("pts"))+                .map(|pts| pts.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect())+                .collect();+            if fills.iter().any(|f| overlap(f, &poly)) {+                out.push(format!("{label}: pour copper of {net} (board zone {i}, {}) on {layer} lies inside the keepout", zone_name(n)));+            } else if fills.is_empty() && overlap(&points(n), &poly) {+                out.push(format!("{label}: board zone {i} ({}, {net}) reaches into the keepout on {layer} and has no saved fill there to show it stays clear", zone_name(n)));+            }+        }+    }+    out+}++/// `keepouts_landed`: the tool lands keepout zones (KiCad), so each must match a saved zone; otherwise+/// each is checked clear of pour copper (`keepout_clear`).+pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<Value, String> {     let zones = plan["zones"]         .as_array()         .filter(|a| !a.is_empty())@@ -177,6 +238,7 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {     let mut groups = Vec::new();     let mut failed = Vec::new();     let mut report = Vec::new();+    let mut cleared = Vec::new();     for (index, z) in resolved.iter().enumerate() {         let label = z["name"].as_str().unwrap_or("unnamed zone");         let allowed = [@@ -238,6 +300,12 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {                 return Err(format!("{label}: unsupported keepout setting"));             }         }+        if !keepouts_landed && (z["type"] == "keepout" || z.get("keepout").is_some()) {+            let bad = keepout_clear(board, &actual, z, label);+            cleared.push(json!({"zone": index, "name": label, "ok": bad.is_empty()}));+            failed.extend(bad);+            continue;+        }         let expected = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, z)?)?;         let wanted = signature(board, &expected);         let matches: Vec<usize> = (0..actual.len())@@ -338,7 +406,8 @@ pub fn check(board: &Board, plan: &Value) -> Result<Value, String> {     }     Ok(         json!({"mode":"verified-existing","pass":failed.is_empty(),"failed":failed,"groups":groups,"differences":report,-        "ownershipGranted":false,"scope":"saved filled polygons and planned zone geometry/settings; native DRC, measured copper and electrical/thermal checks remain separate"}),+        "keepoutsCheckedClear":cleared,+        "ownershipGranted":false,"scope":if keepouts_landed { "saved filled polygons and planned zone geometry/settings; native DRC, measured copper and electrical/thermal checks remain separate" } else { "saved filled polygons and planned zone geometry/settings; keepouts (not landed by this tool) checked clear of saved pour fill; native DRC, measured copper and electrical/thermal checks remain separate" }}),     ) } @@ -817,6 +886,47 @@ mod tests {         p["zones"][0]["layers"] = json!(["B.Cu"]);         assert!(check(&offline(), &p).unwrap()["failed"][0].as_str().unwrap().contains("layer (plan [\"B.Cu\"], board [\"F.Cu\"])"));     }+    /// Issue #113: on a tool that does not land keepout zones (Fusion), a Kelvin keepout is checked+    /// clear of pour copper instead of matched to a zone the board can never carry.+    #[test]+    fn keepouts_a_tool_cannot_land_are_checked_clear_of_pour_copper() {+        let mut p = offline_plan();+        p["zones"][0]["thermalGap"] = json!(0.3);+        p["zones"][0]["thermalBridge"] = json!(0.4);+        let keepout = |x0: f64, y0: f64| json!({"type":"keepout","name":"Kelvin sense pad keepout 5V0 (R5.1)","layers":["F.Cu","B.Cu"],+            "polygon":[[x0,y0],[x0+1.0,y0],[x0+1.0,y0+1.0],[x0,y0+1.0]],"keepout":{"copperPour":true}});+        // clear: outside the zone's fill+        let mut q = p.clone();+        q["zones"].as_array_mut().unwrap().push(keepout(12.0, 12.0));+        let r = check_with(&offline(), &q, false).unwrap();+        assert_eq!(r["pass"], true, "{}", r["failed"]);+        assert_eq!(r["keepoutsCheckedClear"][0]["ok"], true);+        assert_eq!(r["groups"].as_array().unwrap().len(), 1); // only the pour is a matched (adoptable) zone+        // the KiCad lane still requires the keepout zone on the board+        let r = check_with(&offline(), &q, true).unwrap();+        assert_eq!(r["pass"], false);+        assert!(r["failed"][0].as_str().unwrap().contains("Kelvin sense pad keepout"), "{}", r["failed"]);+        // pour fill inside the keepout fails, naming the zone and the layer+        let mut q = p.clone();+        q["zones"].as_array_mut().unwrap().push(keepout(7.0, 2.0));+        let r = check_with(&offline(), &q, false).unwrap();+        assert_eq!(r["pass"], false);+        let line = r["failed"][0].as_str().unwrap();+        assert!(line.contains("pour copper of 5V0") && line.contains("P3 5V0 power") && line.contains("F.Cu"), "{line}");+        // a pour reaching into the keepout with no saved fill cannot show it stays clear+        let unfilled = OFFLINE.replace(r#" (filled_polygon (layer "F.Cu") (pts (xy 1 1) (xy 9 1) (xy 9 9)))"#, "");+        let b = Board::from_text("o", format!("{PREFIX} {unfilled})")).unwrap();+        let r = check_with(&b, &q, false).unwrap();+        assert!(r["failed"].as_array().unwrap().iter().any(|l| l.as_str().unwrap().contains("no saved fill there")), "{}", r["failed"]);+        // a keepout of tracks or vias cannot be judged by fill: refused in words, not passed+        let mut q = p.clone();+        let mut k = keepout(12.0, 12.0);+        k["keepout"]["tracks"] = json!(true);+        q["zones"].as_array_mut().unwrap().push(k);+        let r = check_with(&offline(), &q, false).unwrap();+        assert_eq!(r["pass"], false);+        assert!(r["failed"][0].as_str().unwrap().contains("also keeps out tracks"), "{}", r["failed"]);+    }     #[test]     fn plan_settings_describe_offline_zones_exactly() {         let mut p = offline_plan();
crates/aiflow-board/src/eagle.rs+30−1
@@ -141,6 +141,8 @@ pub fn to_kicad(text: &str) -> Result<String, String> {     let (pad_rv, pad_min, pad_max) = (ratio(param("rvPadTop"), 0.25), len(param("rlMinPadTop"), 0.254), len(param("rlMaxPadTop"), 0.508));     let (via_rv, via_min, via_max) = (ratio(param("rvViaOuter"), 0.25), len(param("rlMinViaOuter"), 0.2032), len(param("rlMaxViaOuter"), 0.508));     let ring = |drill: f64, rv: f64, lo: f64, hi: f64| (drill * rv).max(lo).min(hi);+    // a polygon's thermal gap is the board's Supply rule "thermal isolation" (EAGLE's default 10 mil)+    let thermal_gap = len(param("slThermalIsolate"), 0.254).max(0.05);     let mut silk = String::new();     for e in board.descendants().filter(|x| x.has_tag_name("element")) {         let reference = e.attribute("name").unwrap_or("?").to_string();@@ -241,7 +243,12 @@ pub fn to_kicad(text: &str) -> Result<String, String> {             let outline = if p.has_tag_name("polygon") { verts(p) } else { p.children().find(|x| x.has_tag_name("polygonoutlineobjects")).map(verts).unwrap_or_default() };             if outline.is_empty() { continue; }             let pname = p.attribute("name").map(|v| format!(" (name {})", q(v))).unwrap_or_default();-            let _ = write!(o, "  (zone {} (layer {l}){pname} (hatch edge 0.5) (priority {}) (connect_pads (clearance {})) (min_thickness {}) (fill yes (thermal_gap 0.3) (thermal_bridge_width 0.3))\n    (polygon (pts {outline}))", zone_net(name), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(f(&p, "width").max(0.1)));+            // thermals="no" (CHANGE THERMALS OFF) joins pads solid; EAGLE's default is thermal relief, its+            // gap the board's thermal isolation, its spokes Fusion's thermalwidth, else the polygon's width (issue #112)+            let width = f(&p, "width").max(0.1);+            let spoke = p.attribute("thermalwidth").and_then(|v| v.parse::<f64>().ok()).filter(|v| *v > 0.0).unwrap_or(width);+            let connect = if p.attribute("thermals") == Some("no") { "connect_pads yes" } else { "connect_pads" };+            let _ = write!(o, "  (zone {} (layer {l}){pname} (hatch edge 0.5) (priority {}) ({connect} (clearance {})) (min_thickness {}) (fill yes (thermal_gap {}) (thermal_bridge_width {}))\n    (polygon (pts {outline}))", zone_net(name), p.attribute("rank").and_then(|r| r.parse::<i64>().ok()).map(|r| (7 - r).max(0)).unwrap_or(0), n(f(&p, "isolate").max(0.1)), n(width), n(thermal_gap), n(spoke));             if let Some(fd) = p.children().find(|x| x.has_tag_name("polygonfilldetails")) {                 for sh in fd.children().filter(|x| x.has_tag_name("polygonshape")) {                     if let Some(seg) = sh.children().find(|x| x.has_tag_name("polygonoutlinesegments")) { let _ = write!(o, "\n    (filled_polygon (layer {l}) (pts {}))", verts(seg)); }@@ -314,6 +321,28 @@ mod tests {         assert_eq!((b.bbox.0, b.bbox.2), (0.0, 20.0));         assert_eq!(super::to_fusion_xy(5.0, -4.5), (5.0, 4.5)); assert_eq!(super::to_fusion_rot(-90.0), "R270");     }+    /// Issue #112: a polygon with thermals="no" joins its pads solid; one without keeps thermal relief,+    /// its gap the board's thermal isolation (slThermalIsolate) and its spokes Fusion's thermalwidth+    /// (else the polygon's width).+    #[test]+    fn polygon_thermals_carry_over_from_fusion() {+        let poly = |th: &str| format!(r#"<polygon width="0.3" layer="1" isolate="0.25"{th}><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/><vertex x="1" y="9"/></polygon>"#);+        for (th, rules, want_cp, gap) in [(r#" thermals="no""#, "", "(connect_pads yes (clearance 0.25))", "0.254"),+                                          ("", "", "(connect_pads (clearance 0.25))", "0.254"),+                                          (r#" thermals="yes""#, r#"<designrules name="x"><param name="slThermalIsolate" value="16mil"/></designrules>"#, "(connect_pads (clearance 0.25))", "0.4064")] {+            let brd = BRD.replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{}"#, poly(th))).replace("<plain>", &format!("{rules}<plain>"));+            let text = super::to_kicad(&brd).unwrap();+            let z = text.lines().find(|l| l.contains("(zone ")).unwrap();+            assert!(z.contains(want_cp), "{th}: {z}");+            assert!(z.contains(&format!("(thermal_gap {gap}) (thermal_bridge_width 0.3)")), "{th}: {z}");+            crate::Board::from_text("plan.kicad_pcb", text.clone()).unwrap();+        }+        // a Fusion polygonpour as fusion_board_source writes it: thermalwidth sets the spokes+        let pour = r#"<polygonpour layer="1" isolate="0.2" thermalwidth="0.1524" width="0.2" thermals="yes"><polygonoutlineobjects><vertex x="1" y="1"/><vertex x="19" y="1"/><vertex x="19" y="9"/></polygonoutlineobjects></polygonpour>"#;+        let text = super::to_kicad(&BRD.replace(r#"<signal name="B">"#, &format!(r#"<signal name="B">{pour}"#))).unwrap();+        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}");+    }     /// 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−1
@@ -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.+- 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   part's package NAME through the part's transform. tValues/bValues (27, 28) are not printed and are@@ -153,7 +154,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.+- 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. - 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.27.mdadded+8
@@ -0,0 +1,8 @@+# AI Flow 0.2.27 (2026-10-31)++Two Fusion-lane fixes, both reported by Glenn Jones on the TI252772 board.++- **Fusion pours keep their pad connection (#112).** A Fusion polygon with thermals off (`CHANGE THERMALS OFF`, `thermals="no"`) used to convert to a KiCad zone with thermal relief, so every gate and check judged different copper from the board in Fusion. It now converts to a solid-connect zone, `(connect_pads yes (clearance <isolate>))`. Thermals on stays thermal relief. Its gap is the board's `slThermalIsolate` (10 mil by default), and its spokes are the pour's `thermalwidth`, else its `width`. Checked on the BQ25792 charger board: every solid Fusion pour now converts solid.+- **Kelvin keepouts no longer block `verify-pours --adopt` on Fusion (#113).** Fusion does not land keepout zones, so on Fusion `verify-pours` no longer looks for a keepout as a zone on the board. It checks the area clear: no saved pour fill of any net inside it on its layers. A pour that reaches into it with no saved fill fails. A keepout of tracks or vias, which fill can't judge, is refused in words. Results are listed under `keepoutsCheckedClear`. Only the matched pours are adopted. KiCad runs are unchanged. Altium, which cuts pour keepouts out of its polygons, is judged the same way as Fusion.++Not in this release: on a Fusion run, `verify-pours` still reads the run's current board, not a fresh pull from Fusion (#113's side note), so zones drawn after that board was recorded show as missing. Changing which board the proof binds to touches what `finish` checks, so that change gets its own release.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.26",+  "version": "0.2.27",   "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.26",+  "version": "0.2.27",   "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.",