← Commit history

AI Flow 0.1.65: fab-ready Fusion export (test points out, machine parts DNP); heatsink vias planned on the live board with hole-to-hole; pour stitch vias clear of heatsink vias and holes; testplan reads both netlist shapes; rounded Fusion outline converts to arcs (and the pour inset is a true edge offset); a window lookup no longer switches Fusion's view

John Lauer ·e531987f4d · ·parent 8413bd0
14 files changed +372−44
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -24,7 +24,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.1.64"+version = "0.1.65" dependencies = [  "serde",  "serde_json",@@ -33,7 +33,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.1.64"+version = "0.1.65" dependencies = [  "roxmltree",  "serde",@@ -42,7 +42,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "serde",@@ -51,7 +51,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -61,7 +61,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "serde",@@ -70,7 +70,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "serde",@@ -79,7 +79,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -89,7 +89,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.1.64"+version = "0.1.65" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -99,7 +99,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.1.64"+version = "0.1.65" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.1.64"+version = "0.1.65" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/main.rs+7−3
@@ -2557,11 +2557,14 @@ fn main() {             thread(&cli);             let mut r = load_run(&cli);             let spec = spec_of(&r);-            let b = board_of(&r);+            // a Fusion run pours around what is in Fusion now (the landed copper and heatsink vias)+            let b = fusion_live(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))).unwrap_or_else(|| board_of(&r));             let planp = plan.clone().or_else(|| r.outcomes().get("route").and_then(|o| o.get("plan")).and_then(|p| p.as_str()).map(str::to_string));             let plan_v = planp.as_deref().map(read_json);+            // the heatsink vias planned (landed or not) are holes the stitching vias must keep clear of+            let tv_holes = r.outcomes().get("thermal-vias-plan").and_then(|o| o["plan"].as_str()).filter(|p| std::path::Path::new(p).is_file()).map(|p| aiflow_pours::plan_holes(&read_json(p))).unwrap_or_default();             r.stage_start("pour", Some("binary"), None);-            let (zones, vias, keepouts) = aiflow_pours::build(&b, &spec, plan_v.as_ref()).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });+            let (zones, vias, keepouts) = aiflow_pours::build_with(&b, &spec, plan_v.as_ref(), &tv_holes).unwrap_or_else(|e| { err(&format!("pour plan failed: {e}"), &["spec.pours entries need net, layers and one of outline, polygon or around.".into()]) });             let zp = dir.join("plan-pours.json");             let vp = dir.join("plan-vias.json");             write_json(&zp, &json!({"zones": zones}));@@ -3389,7 +3392,8 @@ fn main() {                 for x in refs.split(',').map(str::trim).filter(|x| !x.is_empty()) { r.data["thermalViaAcks"][x] = json!(w.clone()); }             }             let acked: Vec<String> = r.data["thermalViaAcks"].as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();-            let b = board_of(&r);+            // a Fusion run plans on the live board (the copper landed since the last snapshot), as the finish step reads it+            let b = if action == "plan" { fusion_live(&r, &cli, &dir).map(|l| Board::load(&l.1).unwrap_or_else(|e| err(&e, &[]))) } else { None }.unwrap_or_else(|| board_of(&r));             let sw = switchnode::nets(&spec, &b);             if action == "plan" {                 let (parts, plan) = thermalvias::plan(&spec, &b, &sw);
crates/adom-aiflow/src/outline.rs+12
@@ -74,4 +74,16 @@ mod tests {         let r = r#"<wire x1="1" y1="0" x2="29" y2="0" width="0" layer="20"/><wire x1="29" y1="0" x2="30" y2="1" width="0" layer="20" curve="90"/><wire x1="30" y1="1" x2="30" y2="19" width="0" layer="20"/>"#;         assert!(sharp_corners(&eagle_edges(r)).is_empty());     }+    #[test]+    fn a_rounded_fusion_outline_reads_rounded_on_the_plan_board() {+        // the plan board converted from a MITERed Fusion outline carries arcs, so its edges read rounded too+        let w = |a: (f64, f64), b: (f64, f64), c: f64| format!(r#"<wire x1="{}" y1="{}" x2="{}" y2="{}" width="0" layer="20"{}/>"#, a.0, a.1, b.0, b.1, if c != 0.0 { format!(r#" curve="{c}""#) } else { String::new() });+        let wires = [w((1.0, 0.0), (29.0, 0.0), 0.0), w((29.0, 0.0), (30.0, 1.0), 90.0), w((30.0, 1.0), (30.0, 19.0), 0.0), w((30.0, 19.0), (29.0, 20.0), 90.0), w((29.0, 20.0), (1.0, 20.0), 0.0), w((1.0, 20.0), (0.0, 19.0), 90.0), w((0.0, 19.0), (0.0, 1.0), 0.0), w((0.0, 1.0), (1.0, 0.0), 90.0)].concat();+        let brd = format!(r#"<?xml version="1.0"?><eagle><drawing><board><plain>{wires}</plain><libraries/><elements/><signals/></board></drawing></eagle>"#);+        assert!(sharp_corners(&eagle_edges(&brd)).is_empty());+        let k = aiflow_board::eagle::to_kicad(&brd).unwrap();+        let e = kicad_edges(&k);+        assert_eq!((e.len(), e.iter().filter(|x| x.arc).count()), (8, 4));+        assert!(sharp_corners(&e).is_empty());+    } }
crates/adom-aiflow/src/testplan.rs+33−4
@@ -30,11 +30,19 @@ pub fn tests_of(v: &Value) -> Vec<Value> {     v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default() } +/// A netlist's nets as (name, pins), from either shape in use: a list of [name, [pins]] or the Fusion+/// steps' object {name: [pins]}.+pub fn nets_of(netlist: &Value) -> Vec<(String, Vec<String>)> {+    let pins = |v: &Value| -> Vec<String> { v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() };+    if let Some(o) = netlist["nets"].as_object() { return o.iter().map(|(n, ps)| (n.clone(), pins(ps))).collect(); }+    netlist["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| {+        let a = n.as_array()?; Some((a.first()?.as_str()?.to_string(), a.get(1).map(pins)?))+    }).collect()+}+ /// Netlist mode: (rows, gaps). A gap is a measured net with no test point on it. pub fn check_netlist(tests: &[Value], netlist: &Value) -> (Vec<Value>, Vec<String>, Vec<String>) {-    let nets: Vec<(String, Vec<String>)> = netlist["nets"].as_array().cloned().unwrap_or_default().iter().filter_map(|n| {-        let a = n.as_array()?; Some((a.first()?.as_str()?.to_string(), a.get(1)?.as_array()?.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()))-    }).collect();+    let nets = nets_of(netlist);     let tp_refs: Vec<String> = netlist["components"].as_array().cloned().unwrap_or_default().iter().filter(|c| is_tp(c)).filter_map(|c| c["ref"].as_str().map(str::to_string)).collect();     let (mut rows, mut gaps, mut unknown) = (Vec::new(), Vec::new(), Vec::new());     for t in tests {@@ -66,7 +74,10 @@ pub fn add_test_points(netlist: &mut Value, gaps: &[String]) -> Vec<String> {         let n = (1..).find(|k| !used.contains(k)).unwrap(); used.push(n);         let r = format!("TP{n}");         netlist["components"].as_array_mut().map(|a| a.push(json!({"ref": r, "value": TP_VALUE, "fp": "adom:Adom_TestPoint_D0.5mm", "page": TP_PAGE, "mpn": TP_MPN, "block": "test", "why": format!("probe spot for {net} (test plan)")})));-        if let Some(a) = netlist["nets"].as_array_mut() {+        // written back in the shape the file already uses+        if let Some(o) = netlist["nets"].as_object_mut() {+            o.get_mut(net.as_str()).and_then(|p| p.as_array_mut()).map(|p| p.push(json!(format!("{r}.1"))));+        } else if let Some(a) = netlist["nets"].as_array_mut() {             if let Some(entry) = a.iter_mut().find(|e| e[0].as_str() == Some(net.as_str())) {                 entry[1].as_array_mut().map(|p| p.push(json!(format!("{r}.1"))));             }@@ -176,4 +187,22 @@ mod tests {         let (_, gaps2, _) = check_netlist(&tests, &nl);         assert!(gaps2.is_empty());     }+    #[test]+    fn netlist_as_object_like_the_fusion_steps() {+        // the Fusion steps' shape: nets {name: [pins]}; read the same, written back the same+        let mut nl = json!({"components": [{"ref": "U1"}, {"ref": "TP1", "fp": "TestPoint"}],+            "nets": {"VIN": ["U1.3", "TP1.1"], "SW": ["U1.2"], "GND": ["U1.1"]}});+        let tests = vec![json!({"id": "vin", "net": "VIN", "kind": "dc"}), json!({"id": "sw", "net": "SW", "kind": "waveform"}), json!({"id": "x", "net": "NOPE", "kind": "dc"})];+        let (rows, gaps, unknown) = check_netlist(&tests, &nl);+        assert_eq!(gaps, vec!["SW".to_string()]);+        assert_eq!(unknown, vec!["NOPE".to_string()]);+        assert_eq!(rows[0]["status"], "test point");+        assert_eq!(rows[0]["spot"], "TP1.1");+        assert_eq!(add_test_points(&mut nl, &gaps), vec!["TP2 on SW".to_string()]);+        assert!(nl["nets"].is_object(), "the file keeps its own shape");+        assert_eq!(nl["nets"]["SW"], json!(["U1.2", "TP2.1"]));+        assert!(check_netlist(&tests, &nl).1.is_empty());+        // the list shape still reads the same nets+        assert_eq!(nets_of(&json!({"nets": [["SW", ["U1.2"]]]})), vec![("SW".to_string(), vec!["U1.2".to_string()])]);+    } }
crates/adom-aiflow/src/thermalvias.rs+24−2
@@ -52,6 +52,9 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) {         f && bk     };     let tracks = b.tracks(); let vias = b.vias();+    // holes: drill edge to drill edge at least the hole-to-hole minimum from every via and through-hole pad+    let h2h = aiflow_pours::hole_to_hole(spec);+    let mut holes = aiflow_pours::board_holes(b);     let mut placed: Vec<(f64, f64)> = Vec::new();     let mut parts = Vec::new();     let mut entries = Vec::new();@@ -85,13 +88,16 @@ pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) {         let rr = size / 2.0;         for (_, c) in cands {             if part.planned.len() >= need { break; }-            if !b.outline.is_empty() && (!point_in(&b.outline, c) || b.outline.windows(2).any(|w| seg_dist(c, w[0], w[1]) < rr + edge)) { continue; }+            // every edge of the outline, the closing one too (a rounded corner is many short ones)+            let on = b.outline.len();+            if on > 0 && (!point_in(&b.outline, c) || (0..on).any(|k| seg_dist(c, b.outline[k], b.outline[(k + 1) % on]) < rr + edge)) { continue; }             // clear of other nets' pads and tracks, of every via, and of every part's pads (no via in a pad)             if b.pads.iter().any(|q| (q.x - c.0).hypot(q.y - c.1) < q.half_long + rr + clearance) { continue; }             if tracks.iter().any(|t| t.net != net && seg_dist(c, t.a, t.b) < t.width / 2.0 + rr + clearance) { continue; }             if vias.iter().chain([].iter()).any(|v| (v.x - c.0).hypot(v.y - c.1) < v.size / 2.0 + rr + clearance) { continue; }             if placed.iter().any(|q| (q.0 - c.0).hypot(q.1 - c.1) < size + 0.25) { continue; }-            placed.push(c); part.planned.push(c);+            if !aiflow_pours::hole_clear(c.0, c.1, size, drill, &net, &holes, h2h, clearance) { continue; }+            placed.push(c); part.planned.push(c); holes.push((c.0, c.1, size, drill, net.clone()));             entries.push(json!({"net": net, "paths": [[[c.0, c.1], {"layer": "B.Cu", "x": c.0, "y": c.1}]], "viaSize": size, "viaDrill": drill, "width": size, "why": format!("heatsink via for {reference}")}));         }         if part.existing + part.planned.len() < tgt { part.note = Some(format!("room for {} of {} vias near {reference}: move neighbours or widen its {net} copper", part.existing + part.planned.len(), tgt)); }@@ -126,6 +132,22 @@ mod tests {         assert_eq!(super::target(2.0, 10.0), 16);     }     #[test]+    fn plan_keeps_hole_to_hole_from_existing_holes() {+        // U1's GND pad at (10, 10) with a GND via of another part right beside it and a through-hole pad+        let b = aiflow_board::Board::from_text("fixture", r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 20 20)(layer "Edge.Cuts"))+ (footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "U1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")))+ (footprint "y" (layer "F.Cu") (at 12 10) (property "Reference" "J1") (pad "1" thru_hole circle (at 0 0) (size 1.2 1.2) (drill 0.8) (layers "*.Cu") (net 1 "GND")))+ (via (at 10 8.8) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net 1)))"#.into()).unwrap();+        let spec = serde_json::json!({"hot": {"U1": {"watts": 0.25, "tabNet": "GND"}}, "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"]}], "holeToHoleClearance": 0.8});+        let (parts, _) = super::plan(&spec, &b, &[]);+        let holes = aiflow_pours::board_holes(&b);+        assert!(!parts[0].planned.is_empty());+        for c in &parts[0].planned {+            for h in &holes { assert!((h.0 - c.0).hypot(h.1 - c.1) + 1e-6 >= (0.3 + h.3) / 2.0 + 0.8, "{c:?} too close to the hole at ({}, {})", h.0, h.1); }+            for d in &parts[0].planned { assert!(c == d || (d.0 - c.0).hypot(d.1 - c.1) + 1e-6 >= 0.3 + 0.8, "planned vias {c:?} {d:?} too close"); }+        }+    }+    #[test]     fn real_board_if_present() {         let (Ok(bp), Ok(sp)) = (std::env::var("AIFLOW_TV_BOARD"), std::env::var("AIFLOW_TV_SPEC")) else { return };         let b = aiflow_board::Board::load(&bp).unwrap();
crates/aiflow-board/src/eagle.rs+70−3
@@ -10,7 +10,9 @@ //!   θ + the pad's own rotation; `rot_kicad` then puts every pad exactly where Fusion has it. //! - a mirrored element (on the bottom) is written with its pads already in board position //!   (footprint angle 0, pad angles absolute) on B.Cu, so the plan sees the copper where it is.-//! - layer 1 is F.Cu, layer 16 is B.Cu; the Dimension layer (20) is Edge.Cuts.+//! - layer 1 is F.Cu, layer 16 is B.Cu; the Dimension layer (20) is Edge.Cuts. A curved wire there+//!   (`curve`, what MITER leaves at a rounded corner) is a `gr_arc` through its true midpoint, so a+//!   rounded outline stays one closed loop with the board's real extents. //! - signal wires, vias and Fusion pours (`polygonpour`, with their filled islands) come along, so a //!   partly routed or poured board is planned from where it stands. use std::collections::BTreeMap;@@ -28,6 +30,20 @@ pub fn parse_rot(r: Option<&str>) -> (f64, bool) {  pub fn is_eagle(text: &str) -> bool { text.chars().take(600).collect::<String>().contains("<eagle") } +/// The midpoint of an EAGLE curved wire: `curve` degrees of arc from `a` to `b`, counterclockwise when+/// positive (EAGLE frame, y up). The chord's midpoint for a straight wire.+pub fn arc_mid(a: (f64, f64), b: (f64, f64), curve: f64) -> (f64, f64) {+    let (mx, my) = ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0);+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let (c, t) = (dx.hypot(dy), curve.to_radians());+    if c < 1e-12 || t.abs() < 1e-9 || (t.abs() - std::f64::consts::TAU).abs() < 1e-9 { return (mx, my); }+    // the centre sits on the chord's left for a counterclockwise arc under 180 degrees (tan's sign does the rest)+    let d = (c / 2.0) / (t / 2.0).tan();+    let (cx, cy) = (mx - dy / c * d, my + dx / c * d);+    let (ux, uy, h) = (a.0 - cx, a.1 - cy, t / 2.0);+    (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 plan board for a Fusion board (EAGLE source text).@@ -112,9 +128,29 @@ pub fn to_kicad(text: &str) -> Result<String, String> {     if let Some(plain) = board.children().find(|x| x.has_tag_name("plain")) {         for w in plain.children().filter(|x| x.attribute("layer") == Some("20")) {             match w.tag_name().name() {-                "wire" => { edges += 1; let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2")))); }+                "wire" => {+                    edges += 1;+                    let (a, b, curve) = ((f(&w, "x1"), f(&w, "y1")), (f(&w, "x2"), f(&w, "y2")), f(&w, "curve"));+                    if curve.abs() > 1e-6 {+                        let m = arc_mid(a, b, curve);+                        let _ = writeln!(o, "  (gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(m.0), n(yy(m.1)), n(b.0), n(yy(b.1)));+                    } else { let _ = writeln!(o, "  (gr_line (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (layer \"Edge.Cuts\"))", n(a.0), n(yy(a.1)), n(b.0), n(yy(b.1))); }+                }                 "rectangle" => { edges += 1; let _ = writeln!(o, "  (gr_rect (start {} {}) (end {} {}) (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))", n(f(&w, "x1")), n(yy(f(&w, "y1"))), n(f(&w, "x2")), n(yy(f(&w, "y2")))); }-                "polygon" => { edges += 1; o.push_str("  (gr_poly (pts"); for v in w.children().filter(|x| x.has_tag_name("vertex")) { let _ = write!(o, " (xy {} {})", n(f(&v, "x")), n(yy(f(&v, "y")))); } o.push_str(") (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n"); }+                "polygon" => {+                    // a vertex's `curve` bends the edge to the next vertex: written as points along the arc+                    edges += 1; o.push_str("  (gr_poly (pts");+                    let vs: Vec<((f64, f64), f64)> = w.children().filter(|x| x.has_tag_name("vertex")).map(|v| ((f(&v, "x"), f(&v, "y")), f(&v, "curve"))).collect();+                    for (i, (p, curve)) in vs.iter().enumerate() {+                        let _ = write!(o, " (xy {} {})", n(p.0), n(yy(p.1)));+                        if curve.abs() > 1e-6 {+                            let q = vs[(i + 1) % vs.len()].0;+                            let pts = crate::geom::arc_points(*p, arc_mid(*p, q, *curve), q, 0.25);+                            for a in &pts[1..pts.len() - 1] { let _ = write!(o, " (xy {} {})", n(a.0), n(yy(a.1))); }+                        }+                    }+                    o.push_str(") (stroke (width 0.05) (type solid)) (fill no) (layer \"Edge.Cuts\"))\n");+                }                 _ => {}             }         }@@ -178,4 +214,35 @@ 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");     }+    /// A 30 x 20 board with 1 mm corners as MITER leaves it (straight wires shortened, a curve="90" wire+    /// at each corner), drawn either way round.+    fn rounded(ccw: bool) -> String {+        let mut w = vec![((1.0, 0.0), (29.0, 0.0), 0.0), ((29.0, 0.0), (30.0, 1.0), 90.0), ((30.0, 1.0), (30.0, 19.0), 0.0), ((30.0, 19.0), (29.0, 20.0), 90.0),+                         ((29.0, 20.0), (1.0, 20.0), 0.0), ((1.0, 20.0), (0.0, 19.0), 90.0), ((0.0, 19.0), (0.0, 1.0), 0.0), ((0.0, 1.0), (1.0, 0.0), 90.0)];+        if !ccw { w = w.into_iter().rev().map(|(a, b, c)| (b, a, -c)).collect(); }+        let wires: String = w.iter().map(|(a, b, c)| format!(r#"<wire x1="{}" y1="{}" x2="{}" y2="{}" width="0" layer="20"{}/>"#, a.0, a.1, b.0, b.1, if *c != 0.0 { format!(r#" curve="{c}""#) } else { String::new() })).collect();+        BRD.replace(&BRD[BRD.find("<plain>").unwrap()..BRD.find("</plain>").unwrap()], &format!("<plain>{wires}"))+    }+    #[test]+    fn a_rounded_outline_converts_to_arcs_and_stays_closed() {+        let m = super::arc_mid((29.0, 0.0), (30.0, 1.0), 90.0);+        assert!((m.0 - (29.0 + 0.5f64.sqrt())).abs() < 1e-9 && (m.1 - (1.0 - 0.5f64.sqrt())).abs() < 1e-9, "{m:?}");+        for ccw in [true, false] {+            let text = super::to_kicad(&rounded(ccw)).unwrap();+            assert_eq!(text.matches("(gr_arc").count(), 4); assert_eq!(text.matches("(gr_line").count(), 4);+            let b = crate::Board::from_text("plan.kicad_pcb", text).unwrap();+            // one closed loop (read_outline chains it or fails), the board's true extents, and the area of+            // the rounded rectangle (30 x 20 less 4 - pi, the arcs as 0.35 mm steps), not the 598 of a chord per corner+            let (x0, y0, x1, y1) = b.bbox;+            assert!(x0.abs() < 1e-6 && (y0 + 20.0).abs() < 1e-6 && (x1 - 30.0).abs() < 1e-6 && y1.abs() < 1e-6, "{:?}", b.bbox);+            let area = crate::geom::polygon_area(&b.outline).abs();+            assert!((area - (600.0 - (4.0 - std::f64::consts::PI))).abs() < 0.1, "area {area}");+            // every outline point is on the rounded rectangle: no point outside it, the corners on their circles+            for p in &b.outline {+                let (cx, cy) = (p.0.clamp(1.0, 29.0), p.1.clamp(-19.0, -1.0));+                let d = (p.0 - cx).hypot(p.1 - cy);+                assert!(d < 1.0 + 1e-6 && (p.0 >= 1.0 - 1e-9 && p.0 <= 29.0 + 1e-9 || p.1 >= -19.0 - 1e-9 && p.1 <= -1.0 + 1e-9 || (d - 1.0).abs() < 1e-6), "{p:?} off the outline");+            }+        }+    } }
crates/aiflow-bridge/src/fusion.rs+3−1
@@ -155,7 +155,9 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value         }         "kicad_status" | "kicad_version" => call("fusion_readiness", &json!({})),         "kicad_open_board" | "kicad_window_info" => {-            let _ = call("fusion_show_2d_board", &json!({"documentName": doc}));+            // 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()}}) }
crates/aiflow-pours/src/lib.rs+112−11
@@ -79,8 +79,54 @@ fn clear(x: f64, y: f64, net: &str, blockers: &[(Pt, Pt, String, f64)]) -> bool     !blockers.iter().any(|(a, b, n, need)| n != net && geom::seg_distance(x, y, a.0, a.1, b.0, b.1) < *need) } +/// A hole on the board or already planned: (x, y, copper diameter, drill, net).+pub type Hole = (f64, f64, f64, f64, String);++/// The spec's hole-to-hole minimum (drill edge to drill edge), 0.2 mm when it names none.+pub fn hole_to_hole(spec: &Value) -> f64 {+    spec.get("holeToHoleClearance").or_else(|| spec.get("holeToHole")).and_then(|v| v.as_f64()).unwrap_or(0.2)+}++/// Every hole already on the board: its vias, and its through-hole pads (copper to the pad's long half).+pub fn board_holes(board: &Board) -> Vec<Hole> {+    let mut out: Vec<Hole> = board.vias().into_iter().map(|v| (v.x, v.y, v.size, v.drill, v.net)).collect();+    out.extend(board.pads.iter().filter(|p| p.thru).filter_map(|p| Some((p.x, p.y, 2.0 * p.half_long, p.drill?, p.net.clone()))));+    out+}++/// The vias of a via plan (`{nets: [{net, viaSize, viaDrill, paths: [[[x, y], ...]]}]}`, what+/// `thermal-vias plan` and this planner write) as holes.+pub fn plan_holes(plan: &Value) -> Vec<Hole> {+    let mut out = Vec::new();+    for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {+        let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();+        let (size, drill) = (e.get("viaSize").and_then(|v| v.as_f64()).unwrap_or(0.6), e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3));+        for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {+            for wp in path.as_array().cloned().unwrap_or_default() {+                // a layer-changing waypoint is where the via is+                if let (Some(x), Some(y), true) = (wp.get("x").and_then(|v| v.as_f64()), wp.get("y").and_then(|v| v.as_f64()), wp.get("layer").is_some()) { out.push((x, y, size, drill, net.clone())); }+            }+        }+    }+    out+}++/// A via at (x, y) clear of every hole: drill edge to drill edge at least `h2h`, and copper to copper+/// at least `clearance` from another net's.+pub fn hole_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {+    holes.iter().all(|(hx, hy, hs, hd, hn)| {+        let d = (hx - x).hypot(hy - y);+        d + 1e-6 >= (drill + hd) / 2.0 + h2h && (hn == net || d + 1e-6 >= (size + hs) / 2.0 + clearance)+    })+}+ /// (zones in the kicad_add_zone shape, via plan entries, keepout count) from the spec and the routing plan.-pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> {+pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> { build_with(board, spec, plan, &[]) }++/// `build`, with vias planned elsewhere and not on the board yet (the heatsink vias) as obstacles: every+/// via this plans keeps the hole-to-hole minimum and the clearance from those, from the board's own vias+/// and through-hole pads, and from each other.+pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[Hole]) -> Result<(Vec<Value>, Vec<Value>, usize), String> {     let (x0, y0, x1, y1) = board.bbox;     let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5);     let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);@@ -133,6 +179,9 @@ pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<V         }     }     let blockers = plan.map(plan_blockers).unwrap_or_default();+    let h2h = hole_to_hole(spec);+    let mut holes = board_holes(board);+    holes.extend(planned.iter().cloned());     let mut vias: Vec<Value> = Vec::new();     let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});     for tv in spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {@@ -153,8 +202,9 @@ pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<V                     continue;                 }                 let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);-                if clear(x, y, &net, &blockers) {+                if clear(x, y, &net, &blockers) && hole_clear(x, y, size, drill, &net, &holes, h2h, clearance) {                     vias.push(via_entry(&net, x, y, size, drill));+                    holes.push((x, y, size, drill, net.clone()));                     placed += 1;                 }             }@@ -200,9 +250,9 @@ pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<V                     (x - nx).hypot(y - ny) < vr + 0.1                 });                 let near_mine = mine.iter().any(|m| (m.0 - x).hypot(m.1 - y) < size + 0.3);-                if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) {+                if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && hole_clear(x, y, size, drill, &net, &holes, h2h, clearance) {                     vias.push(via_entry(&net, x, y, size, drill));-                    mine.push((x, y));+                    mine.push((x, y)); holes.push((x, y, size, drill, net.clone()));                     placed += 1;                 }             }@@ -213,8 +263,12 @@ pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<V     }     for sv in spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() {         let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };+        let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));+        // a stitch via on top of a heatsink via (or any hole) is a drill hit: left out, said why+        if !hole_clear(x, y, size, drill, net, &holes, h2h, clearance) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the clearance of a via or hole already there or planned"); continue; }         if clear(x, y, net, &blockers) {-            vias.push(via_entry(net, x, y, sv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4)));+            vias.push(via_entry(net, x, y, size, drill));+            holes.push((x, y, size, drill, net.to_string()));         }     }     let mut keepouts = 0;@@ -297,15 +351,28 @@ pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<V     Ok((zones, vias, keepouts)) } -/// Shrink a convex-ish outline toward its centroid by d.+/// Shrink an outline by d: every edge moved d inward along its normal, each vertex where its two moved+/// edges meet, so straight edges and the short steps of a rounded corner's arc all keep d from the board+/// edge (a pull toward the centroid kept only d times the cosine off a long edge). A spike the meeting+/// point would make at a sharp inner corner is held to the bisector at d. pub fn inset_polygon(poly: &[Pt], d: f64) -> Vec<Pt> {-    if d <= 0.0 {+    let mut p: Vec<Pt> = Vec::new();+    for &q in poly { if p.last().map(|l: &Pt| (l.0 - q.0).hypot(l.1 - q.1) > 1e-9).unwrap_or(true) { p.push(q); } }+    while p.len() > 1 && (p[0].0 - p[p.len() - 1].0).hypot(p[0].1 - p[p.len() - 1].1) < 1e-9 { p.pop(); }+    let n = p.len();+    if d <= 0.0 || n < 3 {         return poly.to_vec();     }-    let n = poly.len() as f64;-    let cx = poly.iter().map(|p| p.0).sum::<f64>() / n;-    let cy = poly.iter().map(|p| p.1).sum::<f64>() / n;-    poly.iter().map(|&(x, y)| { let (vx, vy) = (x - cx, y - cy); let l = vx.hypot(vy).max(1e-9); (x - vx / l * d, y - vy / l * d) }).collect()+    let s = if geom::polygon_area(&p) >= 0.0 { 1.0 } else { -1.0 };+    // the inward unit normal of edge i (p[i] -> p[i+1])+    let nrm = |i: usize| { let (a, b) = (p[i], p[(i + 1) % n]); let l = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); (-(b.1 - a.1) / l * s, (b.0 - a.0) / l * s) };+    (0..n).map(|i| {+        let (n0, n1) = (nrm((i + n - 1) % n), nrm(i));+        let (bx, by) = (n0.0 + n1.0, n0.1 + n1.1);+        let k = 1.0 + n0.0 * n1.0 + n0.1 * n1.1;   // 2 cos^2 of half the turn+        if k < 0.25 { let l = bx.hypot(by).max(1e-12); return (p[i].0 + bx / l * d, p[i].1 + by / l * d); }+        (p[i].0 + bx / k * d, p[i].1 + by / k * d)+    }).collect() }  /// Resolve `outline: true` zones to explicit polygons (the live verb takes points).@@ -394,6 +461,40 @@ pub fn missing_planes(board: &Board, spec: &Value) -> Vec<String> {     missing } +#[cfg(test)]+mod hole_tests {+    use super::*;+    #[test]+    fn inset_keeps_d_from_every_edge_of_a_rounded_outline() {+        // a rounded 30 x 20 outline (1 mm arcs) as the board reads it: every inset vertex d from the outline+        let b = Board::from_text("fixture", "(kicad_pcb (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_line (start 1 0) (end 29 0) (layer \"Edge.Cuts\")) (gr_arc (start 29 0) (mid 29.7071 0.2929) (end 30 1) (layer \"Edge.Cuts\")) (gr_line (start 30 1) (end 30 19) (layer \"Edge.Cuts\")) (gr_arc (start 30 19) (mid 29.7071 19.7071) (end 29 20) (layer \"Edge.Cuts\")) (gr_line (start 29 20) (end 1 20) (layer \"Edge.Cuts\")) (gr_arc (start 1 20) (mid 0.2929 19.7071) (end 0 19) (layer \"Edge.Cuts\")) (gr_line (start 0 19) (end 0 1) (layer \"Edge.Cuts\")) (gr_arc (start 0 1) (mid 0.2929 0.2929) (end 1 0) (layer \"Edge.Cuts\")))".into()).unwrap();+        for o in [b.outline.clone(), b.outline.iter().rev().cloned().collect::<Vec<_>>()] {+            let ins = inset_polygon(&o, 0.3);+            let n = o.len();+            for q in &ins {+                assert!(geom::point_in_poly(q.0, q.1, &o), "{q:?} outside");+                let dmin = (0..n).map(|k| geom::seg_distance(q.0, q.1, o[k].0, o[k].1, o[(k + 1) % n].0, o[(k + 1) % n].1)).fold(f64::MAX, f64::min);+                assert!(dmin > 0.3 - 0.01 && dmin < 0.3 + 0.02, "{q:?} is {dmin} from the edge");+            }+        }+    }+    #[test]+    fn stitch_vias_keep_clear_of_board_and_planned_holes() {+        let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (via (at 5 5) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)))".into()).unwrap();+        // the heatsink plan's via at (10, 10); stitch vias on top of each, a near miss, and one well clear+        let tv = json!({"nets": [{"net": "GND", "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[10.0, 10.0], {"layer": "B.Cu", "x": 10.0, "y": 10.0}]]}]});+        let planned = plan_holes(&tv);+        assert_eq!(planned.len(), 1);+        let spec = json!({"stitchVias": [{"net": "GND", "x": 5.0, "y": 5.0}, {"net": "GND", "x": 10.05, "y": 10.0}, {"net": "GND", "x": 10.5, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});+        let (_, vias, _) = build_with(&b, &spec, None, &planned).unwrap();+        let at: Vec<(f64, f64)> = vias.iter().map(|v| (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap())).collect();+        // 0.4 + 0.3 drills: centres at least 0.35 + 0.2 apart, so 10.5 (0.5 away) is out too+        assert_eq!(at, vec![(15.0, 15.0)]);+        assert!(hole_clear(10.6, 10.0, 0.8, 0.4, "GND", &planned, 0.2, 0.2));+        assert!(!hole_clear(10.6, 10.0, 0.8, 0.4, "VIN", &planned, 0.2, 0.2), "another net also needs copper clearance");+    }+}+ #[cfg(test)] mod plane_tests {  use super::*;
docs/release-0.1.65.mdadded+12
@@ -0,0 +1,12 @@+# AI Flow 0.1.65++Five fixes from a live Fusion run, plus one view fix.++- **Fab-ready export.** `fusion export` leaves the bare-copper test points out of the BOM and the pick and place: any part whose package or part name is a test point, whose MANUFACTURER says PCB copper, or that the library recorded as board only (the 0.5 mm `ADOM_TESTPOINT_D0.5MM` pads among them). The machine contacts and pins (MachineContactMedium, MachinePinMediumShort: press-fit Adom stock fitted after assembly) are marked DNP in bom.csv and left out of the pick and place and the JLCPCB BOM. The files are rewritten in place with clean line ends, the export's own check expects exactly that (every placed part in both files, no test point anywhere, the machine parts only as DNP lines), and the hint names what was left out.+- **Heatsink vias are planned on the live board.** On a Fusion run `thermal-vias plan` reads a fresh copy of the board from Fusion, the same read the finish step uses, instead of the run's last snapshot. Every planned via also keeps the hole-to-hole minimum (spec `holeToHoleClearance` or `holeToHole`, 0.2 mm by default, drill edge to drill edge) from existing vias, through-hole pads and the other planned vias, not only the copper clearance.+- **Stitching vias keep clear of heatsink vias.** `pour` plans on the live Fusion board and treats every via on it, every through-hole pad and every via in the heatsink plan (plan-thermal-vias.json, landed or not) as a hole to keep the hole-to-hole minimum and, for another net, the clearance from. A spec stitch via that would land on one is left out with a message naming it.+- **`testplan check --netlist` reads both netlist shapes.** It accepts `nets` as a list of [name, [pins]] or as the Fusion steps' object {name: [pins]}, and `--add` writes the new test points back in the shape the file already uses.+- **Rounded corners stay rounded on the plan board.** After `outline round`, the Fusion outline holds curved wires (`curve="90"`). The conversion to the plan board wrote each as a straight chord, so the plan's outline was a chamfered board smaller than Fusion's and the copper near a corner was judged against the wrong edge. A curved dimension wire is now a KiCad `gr_arc` through its true midpoint (a curved polygon vertex becomes points along its arc), so the outline is one closed loop with the board's real extents. The `outline: true` pour inset now moves every edge inward along its normal, so straight edges and arcs alike keep the full edge clearance (the old pull toward the centre kept less than that off a long edge), and the heatsink via edge check includes the outline's closing edge.+- **A window lookup no longer switches Fusion's view.** Only `kicad_open_board` shows the 2D board; `kicad_window_info`, which every landing asks, no longer re-raises the bridge's "Switching to the 2D board" panel.++Tested with unit tests for each change (cargo test --workspace: 90 passed) and the export rewrite on a real Fusion BOM and pick and place. Not yet run end to end on a live Fusion board.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.1.64",+  "version": "0.1.65",   "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 today; Altium, Fusion and 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.1.64",+  "version": "0.1.65",   "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 today; Altium, Fusion and 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.",
tools/fusion-project.py+86−7
@@ -1250,6 +1250,68 @@ def cmd_silkscreen(a):  # ---------------------------------------------------------------- export +TP_NAME = re.compile(r'test[ _.-]?(point|pad)|^tp[_-]', re.I)+MACHINE_NAME = re.compile(r'machine[ _-]?(contact|pin)|press[ _-]?fit', re.I)+++def fab_roles(text, st, board_only=()):+    """{ref: 'tp' | 'machine'} for the parts a fab must not place: bare-copper test points (a test point+    package or part, a MANUFACTURER of PCB copper, a part the library recorded as board only) and the+    machine contacts and pins (press-fit Adom stock, fitted after assembly: DNP)."""+    roles = {r: 'tp' for r in board_only}+    only = {m for m, p in (st.get('parts') or {}).items() if p.get('boardOnly')}+    for r, m in (st.get('refs') or {}).items():+        if m in only: roles[r] = 'tp'+    try: root = ET.fromstring(text) if text else None+    except ET.ParseError: root = None+    for e in (root.iter('element') if root is not None else []):+        ref = e.get('name'); attrs = {x.get('name', '').upper(): x.get('value') or '' for x in e.iter('attribute')}+        names = ' '.join([e.get('package') or '', e.get('library') or '', e.get('value') or '', attrs.get('MPN', ''), attrs.get('DEVICESET', '')])+        if TP_NAME.search(names) or re.search(r'pcb copper|board only', attrs.get('MANUFACTURER', ''), re.I) or attrs.get('MPN', '').upper() in {m.upper() for m in only}: roles[ref] = 'tp'+        elif MACHINE_NAME.search(names): roles[ref] = 'machine'+    return roles+++def fab_rewrite(path, roles, drop_machine=False):+    """Rewrite a BOM or pick-and-place CSV in place: test points out, machine contacts/pins out of the+    CPL (and the fab BOM) or marked DNP in the BOM. A BOM row also names its parts by package and maker, so+    a test point the board source missed is still caught. Returns the refs it changed."""+    # blank rows dropped (the bridge writes \r\r\n line ends, which read as an empty row after each line)+    rows = [r for r in csv.reader(open(path, newline='', encoding='utf-8-sig')) if any(c.strip() for c in r)]+    if not rows: return set()+    h = [c.strip().lower() for c in rows[0]]+    di = next((h.index(c) for c in ('designator', 'designators', 'refdes', 'references', 'reference', 'parts', 'part', 'name') if c in h), None)+    if di is None: return set()+    qi = h.index('quantity') if 'quantity' in h else None+    bom = qi is not None or any(c in h for c in ('mpn', 'lcsc', 'comment', 'footprint', 'package'))+    cpl = not bom+    if bom and not drop_machine and 'dnp' not in h: rows[0].append('DNP'); h.append('dnp')+    dn = h.index('dnp') if 'dnp' in h else None+    out, changed = [rows[0]], set()+    for row in rows[1:]:+        if di >= len(row): out.append(row); continue+        refs = [x for x in re.split(r'[ ,;]+', row[di]) if x.strip()]+        sep = ',' if ',' in row[di] else ' '+        info = ' '.join(row[i] for i, c in enumerate(h) if c in ('package', 'footprint', 'mpn', 'value', 'comment') and i < len(row))+        maker = ' '.join(row[i] for i, c in enumerate(h) if c in ('manufacturer', 'mpn') and i < len(row))+        if bom and (TP_NAME.search(info) or re.search(r'pcb copper|board only', maker, re.I)):+            for r in refs: roles.setdefault(r, 'tp')+        elif bom and MACHINE_NAME.search(info):+            for r in refs: roles.setdefault(r, 'machine')+        keep = [r for r in refs if roles.get(r) not in ('tp', 'machine')]+        mach = [r for r in refs if roles.get(r) == 'machine']+        changed |= set(refs) - set(keep)+        def part(rs, dnp):+            nr = list(row) + [''] * (len(h) - len(row)); nr[di] = sep.join(rs)+            if qi is not None: nr[qi] = str(len(rs))+            if dn is not None: nr[dn] = 'DNP' if dnp else ''+            return nr+        if keep: out.append(part(keep, False))+        if mach and bom and not drop_machine: out.append(part(mach, True))+    with open(path, 'w', newline='', encoding='utf-8') as f: csv.writer(f).writerows(out)+    return changed++ def cmd_export(a):     st = load_state(); name = a.design or st.get('design')     if not name: err('which design? pass --design <name>')@@ -1280,27 +1342,44 @@ def cmd_export(a):             if not any(any(k in n for k in keys) for n in names): problems.append('no %s layer in the Gerbers' % what)     src = ab('fusion_board_source', {'expectDocument': name}); text = (src.get('data') or src).get('source') or src.get('source') or ''     board_refs = set(re.findall(r'<element name="([^"]+)"', text))-    def refs_in(path, cols):+    # fab-ready as written: bare-copper test points are not parts (out of both files), the machine contacts+    # and pins are fitted after assembly (DNP in the BOM, out of the pick and place and the fab's BOM)+    roles = fab_roles(text, st, b.get('boardOnly') or ())+    for f_, drop in (('bom.csv', False), ('bom-jlcpcb.csv', True), (name + '-cpl.csv', True)):+        try:+            if (out / f_).is_file(): fab_rewrite(out / f_, roles, drop)+        except Exception as e: problems.append('%s could not be made fab-ready (%s)' % (f_, e))+    tps = sorted(r for r, k in roles.items() if k == 'tp'); dnp = sorted(r for r, k in roles.items() if k == 'machine')+    def refs_in(path, cols, dnp_too=True):         try:             rows = list(csv.reader(open(path, newline='', encoding='utf-8-sig')))             h = [c.strip().lower() for c in rows[0]]; i = next((h.index(c) for c in cols if c in h), None)+            d = h.index('dnp') if 'dnp' in h else None             out_refs = set()             for row in rows[1:]:+                if not dnp_too and d is not None and d < len(row) and row[d].strip(): continue                 if i is not None and i < len(row): out_refs |= {x.strip() for x in re.split(r'[ ,;]+', row[i]) if x.strip()}             return out_refs         except Exception as e:             problems.append('%s unreadable (%s)' % (Path(path).name, e)); return set()-    bom_refs = refs_in(out / 'bom.csv', ('designator', 'designators', 'parts', 'part', 'references', 'reference', 'refdes'))+    bom_cols = ('designator', 'designators', 'parts', 'part', 'references', 'reference', 'refdes')+    bom_all = refs_in(out / 'bom.csv', bom_cols); bom_refs = refs_in(out / 'bom.csv', bom_cols, False)+    jlc_refs = refs_in(out / 'bom-jlcpcb.csv', bom_cols) if (out / 'bom-jlcpcb.csv').is_file() else set()     cpl_refs = refs_in(out / (name + '-cpl.csv'), ('designator', 'refdes', 'part', 'reference', 'name'))-    board_only = set(b.get('boardOnly') or [])-    if board_refs and bom_refs and (board_refs - board_only) - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted((board_refs - board_only) - bom_refs)))-    if board_refs and cpl_refs and (board_refs - board_only) - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted((board_refs - board_only) - cpl_refs)))+    # every part a fab places is in both; test points in neither; machine parts only as DNP lines+    placed = board_refs - set(roles)+    if board_refs and bom_refs and placed - bom_refs: problems.append('BOM is missing %s' % ', '.join(sorted(placed - bom_refs)))+    if board_refs and cpl_refs and placed - cpl_refs: problems.append('pick and place is missing %s' % ', '.join(sorted(placed - cpl_refs)))+    if set(roles) & (cpl_refs | jlc_refs | bom_refs): problems.append('a test point or machine part is still listed to place: %s' % ', '.join(sorted(set(roles) & (cpl_refs | jlc_refs | bom_refs))))+    if set(tps) & bom_all: problems.append('test points still in the BOM: %s' % ', '.join(sorted(set(tps) & bom_all)))+    if bom_all and (set(dnp) & board_refs) - bom_all: problems.append('machine parts missing from the BOM as DNP: %s' % ', '.join(sorted((set(dnp) & board_refs) - bom_all)))     if problems: err('manufacturing files written to %s, but they do not check out: %s' % (out, '; '.join(problems)), ['Fix the export (the CAM job, the parts\' attributes) and run `fusion export` again; never hand the human files that do not match the board.'])     prove('export', {'dir': str(out.resolve()), 'gerberFiles': len(names), 'bomRefs': len(bom_refs), 'cplRefs': len(cpl_refs)})     if os.environ.get('AIFLOW_EXE'): run([os.environ['AIFLOW_EXE'], '--run', str(RUN), 'timeline', 'write'], timeout=60)-    ok('manufacturing files in %s, checked: Gerbers (%d files, %d layers: copper, mask, silkscreen, outline, drill), pick and place (%d parts), BOM (%d lines) and the JLCPCB BOM; every part on the board is in both' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(cpl_refs), len(b.get('lines') or [])),+    ok('manufacturing files in %s, checked: Gerbers (%d files, %d layers: copper, mask, silkscreen, outline, drill), pick and place (%d parts), BOM (%d lines) and the JLCPCB BOM; every part the fab places is in both' % (out, gd.get('fileCount') or 0, gd.get('layerCount') or 0, len(cpl_refs), len(b.get('lines') or [])),        ['Parts without a distributor number: ' + '; '.join('%s: %s' % (k, ', '.join(v)) for k, v in missing.items()) if missing else '',-        'Board-only parts (nothing to buy): ' + ', '.join(b.get('boardOnly') or []) if b.get('boardOnly') else ''])+        'Test points are bare board copper, left out of the BOM and the pick and place: ' + ', '.join(tps) if tps else '',+        'Machine contacts and pins are fitted after assembly: DNP in bom.csv, left out of the pick and place and the JLCPCB BOM: ' + ', '.join(dnp) if dnp else ''])   def cmd_reset(a):