← Commit history
crates/adom-aiflow/src/copper_carry.rs+24
@@ -37,6 +37,8 @@ pub fn sha256_file(p: &Path) -> Result<String, String> {  /// Footprint children and top-level items that never carry copper, whatever their layer. const NEVER_COPPER: [&str; 13] = ["model", "descr", "tags", "path", "sheetname", "sheetfile", "embedded_fonts", "embedded_files", "group", "generator", "generator_version", "paper", "title_block"];+/// Footprint flags that only steer the BOM and the pick-and-place file.+const ASSEMBLY_FLAGS: [&str; 3] = ["exclude_from_bom", "exclude_from_pos_files", "dnp"]; /// Items whose relevance depends on their layer (copper or the board edge count). const LAYERED: [&str; 7] = ["property", "dimension", "image", "target", "table", "barcode", "text"]; @@ -88,6 +90,15 @@ fn footprint(fp: &Node) -> Value {         let t = c.tag.as_str();         if NEVER_COPPER.contains(&t) { continue; }         if graphic(t) && !layered_relevant(c) { continue; }+        // BOM and position-file flags (exclude_from_bom, exclude_from_pos_files, dnp) change the+        // assembly files, never copper or DRC: setting them must not discard the copper evidence+        if ASSEMBLY_FLAGS.contains(&t) { continue; }+        if t == "attr" {+            let mut v = vec![json!("attr")];+            for a in atoms(c) { if !ASSEMBLY_FLAGS.contains(&a) { v.push(json!(a)); } }+            parts.push(Value::Array(v).to_string());+            continue;+        }         parts.push(canon(c).to_string());     }     parts.sort();@@ -721,3 +732,16 @@ mod tests {         let _ = std::fs::remove_dir_all(d);     } }++#[cfg(test)]+mod assembly_flag_tests {+    #[test]+    fn bom_and_position_flags_are_not_copper() {+        let a = r#"(kicad_pcb (version 20260101) (footprint "TP" (layer "B.Cu") (at 1 2) (property "Reference" "TP1" (at 0 1) (layer "B.Fab")) (attr smd exclude_from_bom) (pad "1" smd circle (at 0 0) (size 0.5 0.5) (layers "B.Cu"))))"#;+        let b = a.replace("(attr smd exclude_from_bom)", "(attr smd exclude_from_pos_files exclude_from_bom) (dnp yes)");+        assert_eq!(super::canonical_text(a).unwrap(), super::canonical_text(&b).unwrap());+        let c = a.replace("(attr smd exclude_from_bom)", "(attr through_hole exclude_from_bom)");+        assert_ne!(super::canonical_text(a).unwrap(), super::canonical_text(&c).unwrap(), "the mounting type still counts");+    }+}+
crates/adom-aiflow/src/export.rs+19−2
@@ -34,13 +34,30 @@ pub struct Inventory {     pub npth_pad_holes: usize, } +/// The first non-empty property among `names`, in the order of `names`: library footprints often carry an+/// empty `Manufacturer_Part_Number` beside the filled `MPN`, and an empty field must not hide a filled one. fn prop(fp: &sx::Node, names: &[&str]) -> String {-    for p in fp.find_all("property") {-        if let Some(k) = p.atom(1) { if names.iter().any(|n| n.eq_ignore_ascii_case(k)) { return p.atom(2).unwrap_or("").to_string(); } }+    for n in names {+        for p in fp.find_all("property") {+            if let Some(k) = p.atom(1) {+                if n.eq_ignore_ascii_case(k) { let v = p.atom(2).unwrap_or("").trim(); if !v.is_empty() { return v.to_string(); } }+            }+        }     }     String::new() } +#[cfg(test)]+mod prop_tests {+    #[test]+    fn an_empty_alias_does_not_hide_a_filled_one() {+        let fp = aiflow_board::sx::parse(r#"(footprint "C" (property "Reference" "C10") (property "Manufacturer_Part_Number" "") (property "MPN" "CL21A226MAQNNNE"))"#).unwrap();+        assert_eq!(super::prop(&fp, &["MPN", "Manufacturer_Part_Number"]), "CL21A226MAQNNNE");+        assert_eq!(super::prop(&fp, &["Manufacturer_Part_Number", "MPN"]), "CL21A226MAQNNNE");+        assert_eq!(super::prop(&fp, &["Value"]), "");+    }+}+ /// A footprint flag in either spelling: an atom of `(attr ...)` or its own `(flag yes)` node. fn flag(fp: &sx::Node, name: &str) -> bool {     let in_attr = fp.find("attr").map(|a| a.items.iter().any(|it| matches!(it, sx::Item::Atom { text, .. } if text == name))).unwrap_or(false);
crates/adom-aiflow/src/main.rs+29−5
@@ -1336,6 +1336,18 @@ fn stop_running_clip(r: &mut Run, br: &Bridge, dir: &Path, why: &str) -> Option< /// on the footage (issue #48: with no ffprobe every clip read as "no frames" and compose dropped them all). const MEDIA_TOOLS: &[&str] = &["ffprobe", "ffmpeg"]; +/// The macOS KiCad bridge's window screenshots carry no `canvas` analysis: count the distinct colours+/// (4 bits per channel) in the middle 60 % of the saved screenshot, where the 3D canvas is. A white or+/// black unpainted canvas gives one or two; a rendered board gives dozens.+fn screenshot_distinct_colors(path: &str) -> Option<u64> {+    if path.is_empty() || !std::path::Path::new(path).exists() { return None; }+    let out = std::process::Command::new("ffmpeg").args(["-v", "error", "-i", path, "-frames:v", "1", "-vf", "crop=iw*0.6:ih*0.6,scale=96:54", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"]).output().ok()?;+    if !out.status.success() || out.stdout.len() < 3 { return None; }+    let set: std::collections::BTreeSet<u16> = out.stdout.chunks(3).map(|p| ((p[0] as u16 >> 4) << 8) | ((p[1] as u16 >> 4) << 4) | (p[2] as u16 >> 4)).collect();+    Some(set.len() as u64)+}++ /// Err names the first tool of `tools` that cannot be started. fn media_tools_missing(tools: &[&str]) -> Result<(), String> {     for t in tools {@@ -4232,7 +4244,7 @@ fn main() {                 } else { Vec::new() };                 let s = copper_carry::field_screen_with_acks(&r.data,&f,&spec,Path::new(&solved_board),&spec_bytes,what,&live_acks).map(|(n, c)| { if let Some(n) = n { eprintln!("{n}"); } c }).map_err(|e| if e.contains("identity") { format!("{e}: adom-fields analyze --board {solved_board} --spec {} --out {}",r.data["spec"].as_str().unwrap_or(""),dir.join("fields").display()) } else { e });                 // issue #67: the same-input solve with a declared board-sink block stands in for the estimate-                if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }+                if what == "thermal" && s.is_ok() { sink_notes = aiflow_analyze::board_sink::accept_solve(&mut res, &spec, &f); sink_notes.extend(aiflow_analyze::lead_cooling::accept_solve(&mut res, &f)); sink_notes.extend(aiflow_analyze::plane_backed::accept_solve(&mut res, &f)); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }                 // issue #107: the passed solve's rise at each net's narrowest neck judges the whole-net width screen                 if what == "current" && s.is_ok() { aiflow_analyze::solved_ampacity::accept_solve(&mut res, &spec, &f); heuristic_passed = res.iter().all(|x| x["status"] == "pass"); }                 s@@ -5439,7 +5451,7 @@ fn main() {                     let find_viewer = || -> Option<i64> {                         let w = br.call("kicad_window_info", &json!({}));                         let d = w.get("data").cloned().unwrap_or(w.clone());-                        d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer"))).and_then(|e| e["hwnd"].as_i64())+                        d["editors"].as_array().and_then(|a| a.iter().find(|e| e["kind"].as_str() == Some("3d_viewer") || (e["kind"].is_null() && e["title"].as_str().is_some_and(|t| t.starts_with("3D Viewer"))))).and_then(|e| e["hwnd"].as_i64())                     };                     let viewer = op.get("hwnd").and_then(|h| h.as_i64()).or_else(find_viewer).unwrap_or_else(|| err("the 3D Viewer did not open", &["kicad_open_3d_viewer {editor:\"pcb\"} by hand and look at kicad_screenshot_all.".into()]));                     // a viewer opened in the background never paints its GL canvas (a white window with a@@ -5452,7 +5464,7 @@ fn main() {                         std::thread::sleep(std::time::Duration::from_secs(3));                         let sh = br.call("kicad_screenshot_all", &json!({}));                         let d = sh.get("data").cloned().unwrap_or(sh.clone());-                        if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8)).unwrap_or(false) {+                        if d["screenshots"].as_array().map(|a| a.iter().any(|s| s["hwnd"].as_str().map(|h| h == viewer.to_string()).unwrap_or(s["hwnd"].as_i64() == Some(viewer)) && (if s["canvas"].is_object() { s["canvas"]["rendered"].as_bool() == Some(true) && s["canvas"]["distinctColors"].as_u64().unwrap_or(0) >= 8 } else { screenshot_distinct_colors(s["localPath"].as_str().or(s["savedTo"].as_str()).unwrap_or("")).unwrap_or(0) >= 8 }))).unwrap_or(false) {                             painted = true;                             break;                         }@@ -5598,9 +5610,21 @@ fn main() {                         let sessions = lw["sessions"].as_array().or_else(|| inner["sessions"].as_array());                         // Only the session created by this invocation owns the recording target.                         // A title match can select another user's or another run's Fields window.-                        hwnd = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())))-                            .and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))+                        let mine = sessions.and_then(|rows| rows.iter().find(|w| w["sessionId"].as_str() == Some(sid.as_str())));+                        hwnd = mine.and_then(|w| w["hwnd"].as_i64().or_else(|| w["hwnd"].as_str().and_then(|h| h.parse().ok())))                             .filter(|h| *h > 0);+                        // macOS pup lists its sessions without a window handle: this session's window, once it+                        // has its title, is found by the desktop's window list, and only when exactly one+                        // "Adom Fields" window exists (a second one could be another run's)+                        if hwnd.is_none() && mine.is_some_and(|w| w["title"].as_str() == Some("Adom Fields")) {+                            // pup keeps its window off-screen in the background, so off-screen windows are listed too+                            let fw = br.call("desktop_list_windows", &json!({"includeOffscreen": true}));+                            let inner: Value = fw["output"].as_str().and_then(|t| serde_json::from_str(t).ok()).unwrap_or(Value::Null);+                            let rows = inner["data"]["windows"].as_array().or_else(|| inner["windows"].as_array()).cloned().unwrap_or_default();+                            let named: Vec<_> = rows.iter().filter(|w| w["title"].as_str().is_some_and(|t| t.starts_with("Adom Fields"))).collect();+                            let all_ours = sessions.map(|r| r.iter().filter(|w| w["title"].as_str() == Some("Adom Fields")).count()).unwrap_or(0) == 1;+                            if named.len() == 1 && all_ours { hwnd = named[0]["hwnd"].as_i64().filter(|h| *h > 0); }+                        }                         if hwnd.is_some() { break; }                     }                     if hwnd.is_some() { break; }
crates/adom-aiflow/src/programming.rs+117−4
@@ -55,8 +55,8 @@ pub fn targets(b: &Board) -> Vec<(String, String)> { /// The `programming` record: board-release.json beside the board first, then the spec. pub fn record(board_path: &str, spec: &Value) -> Option<Value> {     let rel = Path::new(board_path).parent().unwrap_or(Path::new(".")).join("board-release.json");-    let from_rel = std::fs::read_to_string(rel).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["programming"].clone()).filter(|v| v.is_object());-    from_rel.or_else(|| Some(spec["programming"].clone()).filter(|v| v.is_object()))+    let from_rel = std::fs::read_to_string(rel).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| v["programming"].clone()).filter(|v| v.is_object() || v.is_array());+    from_rel.or_else(|| Some(spec["programming"].clone()).filter(|v| v.is_object() || v.is_array())) }  /// Where the workcell can reach a reference: a machine contact or pin, or a test pad with a label.@@ -118,7 +118,79 @@ pub fn check(board_path: &str, b: &Board, spec: &Value) -> Result<(), Vec<String     if t.is_empty() { return Ok(()); }     let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");     let Some(rec) = record(board_path, spec) else { return Err(vec![format!("no `programming` entry in board-release.json for {names}")]) };-    if rec["interface"] == "usb-serial-jtag" && rec["access"] == "usb-cable" { return cable_usb_check(b, &rec); }+    if let Some(list) = rec.as_array() { return check_list(b, &t, list); }+    check_one(b, &rec)+}++/// The reference(s) a record programs: `targetRef`, or `targetRefs` for one interface repeated per part.+fn refs_of(rec: &Value) -> Vec<String> {+    let mut v: Vec<String> = rec["targetRefs"].as_array().into_iter().flatten().filter_map(|r| r.as_str().map(str::to_string)).collect();+    if let Some(r) = rec["targetRef"].as_str() { v.push(r.to_string()); }+    v+}++/// A board with several programmable parts (four STM32s on SWD and an ESP32 on its own USB): `programming`+/// is a list, each record names the part(s) it programs (`targetRef` / `targetRefs`, whose part number+/// must be its `target`), every programmable part is named by one, and each record passes on its own.+fn check_list(b: &Board, t: &[(String, String)], list: &[Value]) -> Result<(), Vec<String>> {+    let mut p = Vec::new();+    for (i, rec) in list.iter().enumerate() {+        let refs = refs_of(rec);+        if refs.is_empty() { p.push(format!("programming[{i}] names no targetRef / targetRefs")); continue; }+        for r in &refs {+            match t.iter().find(|(x, _)| x == r) {+                Some((_, m)) if Some(m.as_str()) == rec["target"].as_str() => {}+                Some((_, m)) => p.push(format!("programming[{i}]: {r} is {m}, not target {}", rec["target"])),+                None => p.push(format!("programming[{i}]: {r} is not a programmable part on the board")),+            }+            // a per-part check sees the one reference it covers+            let mut one = rec.clone();+            if let Some(o) = one.as_object_mut() { o.remove("targetRefs"); o.insert("targetRef".into(), Value::String(r.clone())); }+            if let Err(e) = check_one(b, &one) { p.extend(e.into_iter().map(|x| format!("programming[{i}] {r}: {x}"))); }+        }+    }+    for (r, m) in t {+        if !list.iter().any(|rec| refs_of(rec).iter().any(|x| x == r)) { p.push(format!("no programming record names {r} {m}")); }+    }+    if p.is_empty() { Ok(()) } else { Err(p) }+}++/// A Tag-Connect footprint (TC2030 / TC2050) is a pad set made for a spring-pin cable, as reachable as a USB+/// connector for a cable: the declared connector pads must carry the SWD nets of the part it programs.+fn tag_connect_check(b: &Board, rec: &Value) -> Result<(), Vec<String>> {+    let mut problems = Vec::new();+    let connector = rec["connector"].as_str().unwrap_or("");+    match b.footprints.get(connector) {+        Some(fp) if fp.lib.contains("Tag-Connect") || fp.lib.contains("TC2030") || fp.lib.contains("TC2050") => {}+        Some(fp) => problems.push(format!("connector {connector} ({}) is not a Tag-Connect footprint", fp.lib.rsplit(':').next().unwrap_or(&fp.lib))),+        None => problems.push(format!("connector {connector} is not on the board")),+    }+    let target_ref = rec["targetRef"].as_str().unwrap_or("");+    if !targets(b).iter().any(|(r, m)| r == target_ref && Some(m.as_str()) == rec["target"].as_str()) {+        problems.push("targetRef/target must match a programmable part on the board".into());+    }+    let mut nets = Vec::new();+    for signal in ["SWDIO", "SWCLK", "NRST", "GND"] {+        let pin = &rec["connectorPins"][signal];+        let net = pin["net"].as_str().unwrap_or("");+        let names = pin["pads"].as_array();+        if net.is_empty() || names.map_or(true, |a| a.is_empty()) { problems.push(format!("connectorPins.{signal} needs nonempty pads and net")); continue; }+        for name in names.unwrap() {+            let pad = name.as_str().unwrap_or("");+            if !b.pads.iter().any(|p| p.reference == connector && p.name == pad && p.net == net) { problems.push(format!("{signal}: {connector}.{pad} does not carry declared net {net}")); }+        }+        // the SWD nets must reach the part it programs (GND aside)+        if signal != "GND" && !b.pads.iter().any(|p| p.reference == target_ref && p.net == net) { problems.push(format!("{signal}: net {net} does not reach {target_ref}")); }+        nets.push(net);+    }+    nets.sort(); nets.dedup();+    if nets.len() != 4 { problems.push("SWDIO, SWCLK, NRST and GND must be four distinct nets".into()); }+    if problems.is_empty() { Ok(()) } else { Err(problems) }+}++fn check_one(b: &Board, rec: &Value) -> Result<(), Vec<String>> {+    if rec["interface"] == "usb-serial-jtag" && rec["access"] == "usb-cable" { return cable_usb_check(b, rec); }+    if rec["interface"] == "swd" && rec["access"] == "tag-connect" { return tag_connect_check(b, rec); }     let mut p = Vec::new();     let iface = rec["interface"].as_str().unwrap_or("");     let pins = rec["pins"].as_object().cloned().unwrap_or_default();@@ -142,7 +214,10 @@ pub fn summary(board_path: &str, b: &Board, spec: &Value, result: &Result<(), Ve     if t.is_empty() { return "programming: no programmable part: programming not required".into(); }     let names = t.iter().map(|(r, m)| format!("{r} {m}")).collect::<Vec<_>>().join(", ");     match result {-        Ok(()) => format!("programming: {names}, interface {} with declared access checked", record(board_path, spec).and_then(|r| r["interface"].as_str().map(str::to_string)).unwrap_or_default()),+        Ok(()) => format!("programming: {names}, interface {} with declared access checked", record(board_path, spec).map(|r| match r.as_array() {+            Some(a) => a.iter().map(|x| format!("{} ({})", x["interface"].as_str().unwrap_or(""), refs_of(x).join(" "))).collect::<Vec<_>>().join(", "),+            None => r["interface"].as_str().unwrap_or("").to_string(),+        }).unwrap_or_default()),         Err(p) => format!("programming: {names}, {} problem(s) (the gate below)", p.len()),     } }@@ -186,6 +261,44 @@ mod tests {     (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 3 "NRST"))) )"#; +    const TC_BOARD: &str = r#"(kicad_pcb (version 20241229)+  (net 0 "") (net 1 "SWDIO1") (net 2 "SWCLK1") (net 3 "NRST1") (net 4 "GND") (net 5 "+3V3") (net 6 "SWDIO2")+  (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts") (width 0.1))+  (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 25 10)+    (property "Reference" "U1" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G474CBT6" (at 0 5) (layer "F.Fab"))+    (pad "34" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 1 "SWDIO1"))+    (pad "37" smd rect (at -3 1) (size 1 0.3) (layers "F.Cu") (net 2 "SWCLK1"))+    (pad "7" smd rect (at -3 2) (size 1 0.3) (layers "F.Cu") (net 3 "NRST1")))+  (footprint "Package_QFP:LQFP-48_7x7mm_P0.5mm" (layer "F.Cu") (at 40 10)+    (property "Reference" "U2" (at 0 -5) (layer "F.SilkS")) (property "Value" "STM32G474CBT6" (at 0 5) (layer "F.Fab"))+    (pad "34" smd rect (at -3 0) (size 1 0.3) (layers "F.Cu") (net 6 "SWDIO2")))+  (footprint "Connector:Tag-Connect_TC2030-IDC-NL_2x03_P1.27mm_Vertical" (layer "F.Cu") (at 8 8)+    (property "Reference" "J7" (at 0 -3) (layer "F.SilkS"))+    (pad "1" connect circle (at 0 0) (size 0.8 0.8) (layers "F.Cu") (net 5 "+3V3"))+    (pad "2" connect circle (at 0 1.27) (size 0.8 0.8) (layers "F.Cu") (net 1 "SWDIO1"))+    (pad "3" connect circle (at 1.27 0) (size 0.8 0.8) (layers "F.Cu") (net 3 "NRST1"))+    (pad "4" connect circle (at 1.27 1.27) (size 0.8 0.8) (layers "F.Cu") (net 2 "SWCLK1"))+    (pad "5" connect circle (at 2.54 0) (size 0.8 0.8) (layers "F.Cu") (net 4 "GND")))+)"#;++    #[test]+    fn a_list_covers_every_part_and_tag_connect_pads_are_checked() {+        let b = Board::from_text("t.kicad_pcb", TC_BOARD.to_string()).unwrap();+        let tc = |r: &str, j: &str| json!({"interface": "swd", "access": "tag-connect", "connector": j, "targetRef": r, "target": "STM32G474CBT6",+            "connectorPins": {"SWDIO": {"pads": ["2"], "net": "SWDIO1"}, "SWCLK": {"pads": ["4"], "net": "SWCLK1"}, "NRST": {"pads": ["3"], "net": "NRST1"}, "GND": {"pads": ["5"], "net": "GND"}}});+        // U1 on its Tag-Connect passes on its own+        assert!(check("t.kicad_pcb", &b, &json!({"programming": [tc("U1", "J7")]})).unwrap_err().iter().all(|e| e.contains("U2")), "only U2 is uncovered");+        // a wrong pad is named+        let mut bad = tc("U1", "J7"); bad["connectorPins"]["SWCLK"]["pads"] = json!(["3"]);+        assert!(check("t.kicad_pcb", &b, &json!({"programming": [bad]})).unwrap_err().iter().any(|e| e.contains("J7.3 does not carry declared net SWCLK1")));+        // U2's SWD nets do not reach J7: refused for U2+        let both = json!({"programming": [tc("U1", "J7"), tc("U2", "J7")]});+        assert!(check("t.kicad_pcb", &b, &both).unwrap_err().iter().any(|e| e.contains("U2") && e.contains("does not reach U2")));+        // a non-Tag-Connect footprint is not a cable landing+        let mut notc = tc("U1", "U2"); notc["connector"] = json!("U2");+        assert!(check("t.kicad_pcb", &b, &json!({"programming": [notc]})).unwrap_err().iter().any(|e| e.contains("not a Tag-Connect footprint")));+    }+     #[test]     fn programmable_boards_bring_their_interface_to_the_workcell() {         let b = Board::from_text("/nonexistent/b.kicad_pcb", BOARD.to_string()).unwrap();
crates/adom-aiflow/src/silkproof.rs+18−3
@@ -43,11 +43,15 @@ pub fn proof(audit: &Value, input: &Value, board: &Board) -> Result<Value, Vec<S /// How far a supplied box may fall short of the board's own geometry (mm). const TOL: f64 = 0.05; +/// Every box of the matching obstacles: the schema allows one `box` or a `boxes` list per obstacle. fn boxes_for<'a>(obstacles: &'a [Value], f: impl Fn(&Value) -> bool + 'a) -> impl Iterator<Item = (f64, f64, f64, f64)> + 'a {-    obstacles.iter().filter(move |o| f(o)).filter_map(|o| {-        let b = o["box"].as_array()?;-        let v: Vec<f64> = b.iter().filter_map(|x| x.as_f64()).collect();+    let one = |b: &Value| {+        let v: Vec<f64> = b.as_array()?.iter().filter_map(|x| x.as_f64()).collect();         (v.len() == 4).then(|| (v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])))+    };+    obstacles.iter().filter(move |o| f(o)).flat_map(move |o| match o["boxes"].as_array() {+        Some(a) => a.iter().filter_map(one).collect::<Vec<_>>(),+        None => one(&o["box"]).into_iter().collect(),     }) } @@ -257,6 +261,17 @@ mod tests {         assert!(why.contains("1 of the board's vias"), "{why}");     } +    #[test]+    fn boxes_lists_count_like_single_boxes() {+        // the schema allows "box" | "boxes"; a footprint given as a boxes list (body plus pads) is covered+        let b = Board::from_text("t.kicad_pcb", BOARD.to_string()).unwrap();+        let single = json!({"obstacles": [{"id": "R1", "kind": "body", "box": [0.0, 0.0, 4.0, 4.0]}, {"id": "C1", "kind": "body", "box": [4.0, 0.0, 8.0, 4.0]}, {"id": "v1", "kind": "via", "box": [4.0, 4.0, 6.0, 6.0]}]});+        let listed = json!({"obstacles": [{"id": "R1", "kind": "body", "boxes": [[0.0, 0.0, 2.0, 4.0], [2.0, 0.0, 4.0, 4.0]]}, {"id": "C1", "kind": "body", "boxes": [[4.0, 0.0, 8.0, 4.0]]}, {"id": "v1", "kind": "via", "boxes": [[4.0, 4.0, 6.0, 6.0]]}]});+        let short = |w: Vec<String>| w.iter().any(|x| x.contains("do not cover their own pads") || x.contains("vias have no"));+        assert!(!short(geometry_gaps(&single, &b)), "{:?}", geometry_gaps(&single, &b));+        assert!(!short(geometry_gaps(&listed, &b)), "{:?}", geometry_gaps(&listed, &b));+    }+     #[test]     fn label_boxes_smaller_than_the_board_text_are_refused() {         // R1's reference is 1 mm text at (2, 1); C1's value "1u" at (6, 3); a 0.6 mm square cannot hold either
crates/adom-aiflow/src/testplan.rs+48−8
@@ -27,7 +27,18 @@ pub const REACH: f64 = 50.0; pub fn needs_spot(kind: &str) -> bool { !matches!(kind, "camera") }  pub fn tests_of(v: &Value) -> Vec<Value> {-    v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default()+    let mut list = v.as_array().cloned().or_else(|| v["tests"].as_array().cloned()).unwrap_or_default();+    // a plan-wide probe face ({"probeFace": "bottom", "tests": [...]}) applies to every test without its own+    if let Some(face) = v.get("probeFace").and_then(|f| f.as_str()) {+        for t in list.iter_mut() { if t.get("face").is_none() { t["face"] = json!(face); } }+    }+    list+}++/// The faces a test may be probed from: `face` "top" (the default: the workcell lands from the front),+/// "bottom" (the board is probed from below, e.g. bottom-side test points on a crowded top face) or "both".+pub fn faces_of(t: &Value) -> Vec<bool> {+    match t["face"].as_str().unwrap_or("top") { "bottom" | "back" => vec![false], "both" => vec![true, false], _ => vec![true] } }  /// A netlist's nets as (name, pins), from either shape in use: a list of [name, [pins]] or the Fusion@@ -137,8 +148,11 @@ fn in_box(p: (f64, f64), b: &(f64, f64, f64, f64), grow: f64) -> bool { p.0 > b.  /// Board mode: for each measured net, the best landing (x, y, on which pad) or why there is none. pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<String>) {-    // probes land from the front: only front-side bodies can cover a front pad (issue #46)-    let bodies: Vec<(String, (f64, f64, f64, f64))> = sided_bodies(b).into_iter().filter(|(_, front, _)| *front).map(|(r, _, bb)| (r, bb)).collect();+    // a body covers a pad only on its own side (issue #46): front bodies for front probing, back bodies for+    // a test probed from below (`face`: "bottom")+    let sided = sided_bodies(b);+    let bodies_on = |front: bool| -> Vec<(String, (f64, f64, f64, f64))> { sided.iter().filter(|(_, f, _)| *f == front).map(|(r, _, bb)| (r.clone(), *bb)).collect() };+    let (front_bodies, back_bodies) = (bodies_on(true), bodies_on(false));     let grounds: Vec<(f64, f64, String)> = b.footprints.iter().filter(|(_, fp)| fp.lib.to_ascii_lowercase().contains("machinecontact"))         .flat_map(|(r, _)| b.pads_of(r).into_iter().filter(|p| p.net.eq_ignore_ascii_case("GND")).map(move |p| (p.x, p.y, r.clone()))).collect();     let (mut rows, mut gaps, mut warn) = (Vec::new(), Vec::new(), Vec::new());@@ -147,9 +161,12 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<         let net = t["net"].as_str().unwrap_or("");         let kind = t["kind"].as_str().unwrap_or("dc");         if !needs_spot(kind) { rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "camera"})); continue; }-        let mut best: Option<(f64, f64, String, f64)> = None;+        let mut best: Option<(f64, f64, String, f64, bool)> = None;         let mut why = "no exposed copper on this net".to_string();-        for p in b.pads.iter().filter(|p| p.net == net && p.on_f) {+        let faces = faces_of(t);+        for &front in &faces {+        let bodies = if front { &front_bodies } else { &back_bodies };+        for p in b.pads.iter().filter(|p| p.net == net && if front { p.on_f } else { p.on_b }) {             // sample landing centres inside the pad, shrunk by the landing radius             let (hx, hy) = (p.size.0 / 2.0 - LANDING_R, p.size.1 / 2.0 - LANDING_R);             if hx < -1e-9 || hy < -1e-9 { why = format!("pads too small for a 0.5 mm landing (e.g. {} {:.2}x{:.2} mm)", p.key, p.size.0, p.size.1); continue; }@@ -162,18 +179,19 @@ pub fn check_board(tests: &[Value], b: &Board) -> (Vec<Value>, Vec<String>, Vec<                 if let Some((r, _)) = bodies.iter().find(|(_, bb)| in_box(c, bb, LANDING_R)) {                     why = format!("every landing on {net} is under a part body (e.g. {} under {r})", p.key); continue;                 }-                if b.pads.iter().any(|q| q.net != net && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) {+                if b.pads.iter().any(|q| q.net != net && (if front { q.on_f } else { q.on_b }) && (q.x - c.0).hypot(q.y - c.1) < q.half_long + LANDING_R + SHORT_GAP) {                     why = format!("every landing on {net} is within {SHORT_GAP} mm of another net's pad (short risk)"); continue;                 }                 let reach = grounds.iter().map(|g| (g.0 - c.0).hypot(g.1 - c.1)).fold(f64::MAX, f64::min);                 if !grounds.is_empty() && reach > REACH { why = format!("{net}'s landings are {reach:.0} mm from the nearest ground contact (tether reach {REACH} mm)"); continue; }                 // prefer test points, then the landing nearest a ground contact                 let score = if p.reference.starts_with("TP") { -1000.0 } else { 0.0 } + if reach.is_finite() { reach } else { 0.0 };-                if best.as_ref().map(|b| score < b.3).unwrap_or(true) { best = Some((c.0, c.1, p.key.clone(), score)); }+                if best.as_ref().map(|b| score < b.3).unwrap_or(true) { best = Some((c.0, c.1, p.key.clone(), score, front)); }             } }         }+        }         match best {-            Some((x, y, pad, _)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})),+            Some((x, y, pad, _, front)) => rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": if pad.starts_with("TP") { "test point" } else { "exposed pad" }, "face": if front { "top" } else { "bottom" }, "spot": {"pad": pad, "x": (x * 100.0).round() / 100.0, "y": (y * 100.0).round() / 100.0}})),             None => { if !gaps.contains(&net.to_string()) { gaps.push(net.to_string()); } rows.push(json!({"id": t["id"], "net": net, "kind": kind, "status": "gap", "why": why})); }         }     }@@ -226,6 +244,28 @@ mod tests {         assert_eq!(gaps, vec!["VCC".to_string()]);         assert!(rows[0]["why"].as_str().unwrap().contains("under U2"), "{rows:?}");     }+    #[test]+    fn bottom_pad_probed_from_below() {+        // a back-side test point under a front-side body: a gap from the top, a landing from below+        let b = Board::from_text("t.kicad_pcb", r#"(kicad_pcb (version 20240108) (generator "pcbnew")+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 0 "") (net 1 "VCC")+  (gr_rect (start 0 0) (end 12 12) (layer "Edge.Cuts"))+  (footprint "test:Pad" (layer "B.Cu") (at 5 5) (property "Reference" "TP1")+    (pad "1" smd circle (at 0 0) (size 1 1) (layers "B.Cu") (net 1 "VCC")))+  (footprint "test:Body" (layer "F.Cu") (at 5 5) (property "Reference" "U2")+    (fp_rect (start -3 -3) (end 3 3) (layer "F.Fab"))+    (pad "1" smd rect (at 4 4) (size 0.5 0.5) (layers "F.Cu"))))"#.to_string()).unwrap();+        let top = vec![json!({"id": "v", "net": "VCC", "kind": "dc"})];+        let (_, gaps, _) = check_board(&top, &b);+        assert_eq!(gaps, vec!["VCC".to_string()], "probed from the top, a back pad is no landing");+        let plan = json!({"probeFace": "bottom", "tests": [{"id": "v", "net": "VCC", "kind": "dc"}]});+        let (rows, gaps, _) = check_board(&tests_of(&plan), &b);+        assert!(gaps.is_empty(), "{rows:?}");+        assert_eq!(rows[0]["face"], "bottom");+        assert_eq!(rows[0]["spot"]["pad"], "TP1.1");+    }+     #[test]     fn netlist_as_object_like_the_fusion_steps() {         // the Fusion steps' shape: nets {name: [pins]}; read the same, written back the same
crates/aiflow-analyze/src/lib.rs+5
@@ -10,6 +10,7 @@ pub mod current_acks; pub mod fields_report; pub mod lead_cooling; pub mod package_cooling;+pub mod plane_backed; pub mod pour_cover; pub mod solved_ampacity; use std::collections::BTreeMap;@@ -191,6 +192,8 @@ pub fn analyze_thermal_on_board(plan: &Value, spec: &Value, zone_state: Option<&     for r in results.iter_mut().filter(|r| r["cooling"] == lead_cooling::LEADS) {         if let Err(e) = lead_cooling::check_footprint(board, r["ref"].as_str().unwrap_or("")) { r["status"] = json!("fail"); r["hint"] = json!(e); }     }+    // a tab pour over a solid plane on the next copper layer: the single-layer estimate does not apply+    plane_backed::classify(&mut results, board);     // issue #67: a declared board-face sink, for the parts it names only     if let Err(e) = board_sink::apply(&mut results, spec, board, zone_state) { results.push(json!({"ref": "boardSink", "status": "fail", "hint": e})); }     (results.iter().all(|r| r["status"] == "pass"), results)@@ -238,10 +241,12 @@ fn thermal_with_vias(plan: &Value, spec: &Value, zone_state: Option<&Value>, boa             ok = false;         } else if theta > limit {             r["status"] = json!("fail");+            if theta_limit.is_none() { r["estimateFailed"] = json!(true); }             r["hint"] = json!(format!("{reference}: {area:.0} mm2 of copper on {net} gives about {theta:.0} C/W (limit {limit:.0}): widen the pour or add the other outer layer with vias"));             ok = false;         } else if rise_limit.is_some_and(|l| rise > l) {             r["status"] = json!("fail");+            r["estimateFailed"] = json!(true);             r["hint"] = json!(format!("{reference}: {watts:.2} W into {area:.0} mm2 of {net} copper is about {rise:.0} C above ambient (budget {:.0} C): widen the pour or add the other outer layer with vias", rise_limit.unwrap()));             ok = false;         } else if vias == 0 && layers.len() >= 2 {
crates/aiflow-analyze/src/plane_backed.rsadded+158
@@ -0,0 +1,158 @@+//! A tab part whose tab pour sits over a solid plane on the next copper layer (a 4-layer board with+//! an inner ground plane a prepreg's thickness under the hot loop). The tab estimate in+//! `thermal_with_vias` is the single-layer rule (40 C/W per square inch of the tab net's own+//! copper); it gives no credit for the heat that crosses the thin dielectric into the plane and+//! spreads over the whole board, so on such a board it can read three or four times the solved+//! rise. A part found plane-backed here is not passed on that estimate either: like a lead-cooled+//! part (#116) it waits for the same-input Fields solve, which models the stack, the plane and+//! every neighbour's heat, and which the field screen already holds to the part's rise budget.+use aiflow_board::geom::point_in_poly;+use aiflow_board::{copper_depth, copper_layer, Board, Pt};+use serde_json::{json, Value};++/// The share of the tab pour (the fill islands of the tab net that hold the tab pad) that must have+/// solid copper of another net directly under it on the next copper layer.+pub const MIN_BACKED_FRACTION: f64 = 0.6;+const STEP_MM: f64 = 0.2;++/// The copper layer next to `layer` in the stack, among the board's copper layers.+fn adjacent(board: &Board, layer: usize) -> Option<usize> {+    let d = copper_depth(layer);+    let mut layers: Vec<usize> = board.copper_layers.iter().filter_map(|n| copper_layer(n)).collect();+    layers.sort_by_key(|l| copper_depth(*l));+    let i = layers.iter().position(|l| *l == layer)?;+    if d == 0 { layers.get(i + 1).copied() } else { i.checked_sub(1).map(|j| layers[j]) }+}++fn bbox(poly: &[Pt]) -> (f64, f64, f64, f64) {+    poly.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)))+}++/// Measure how much of `reference`'s tab pour on `net` has another net's solid fill under it.+/// Returns (tab layer name, plane layer name, plane nets, pour mm2, backed mm2), or None when the+/// tab pad is not in a fill of its net on an outer layer.+pub fn measure(board: &Board, reference: &str, net: &str) -> Option<(String, String, Vec<String>, f64, f64)> {+    let fills = board.copper_fills();+    let pads: Vec<_> = board.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+    for layer in [aiflow_board::F_CU, aiflow_board::B_CU] {+        let on: Vec<_> = pads.iter().filter(|p| p.on_copper(layer)).collect();+        if on.is_empty() { continue; }+        let tab: Vec<&Vec<Pt>> = fills.iter().filter(|(n, l, poly)| n == net && *l == layer && on.iter().any(|p| point_in_poly(p.x, p.y, poly))).map(|(_, _, poly)| poly).collect();+        if tab.is_empty() { continue; }+        let adj = adjacent(board, layer)?;+        let plane: Vec<(&str, &Vec<Pt>)> = fills.iter().filter(|(n, l, _)| n != net && !n.is_empty() && *l == adj).map(|(n, _, poly)| (n.as_str(), poly)).collect();+        let (mut inside, mut backed) = (0usize, 0usize);+        let mut nets: Vec<String> = Vec::new();+        for poly in &tab {+            let (x0, y0, x1, y1) = bbox(poly);+            let mut y = y0 + STEP_MM / 2.0;+            while y < y1 {+                let mut x = x0 + STEP_MM / 2.0;+                while x < x1 {+                    if point_in_poly(x, y, poly) {+                        inside += 1;+                        if let Some((n, _)) = plane.iter().find(|(_, q)| point_in_poly(x, y, q)) {+                            backed += 1;+                            if !nets.iter().any(|m| m == n) { nets.push(n.to_string()); }+                        }+                    }+                    x += STEP_MM;+                }+                y += STEP_MM;+            }+        }+        let cell = STEP_MM * STEP_MM;+        let name = |l: usize| board.copper_layers.iter().find(|n| copper_layer(n) == Some(l)).cloned().unwrap_or_default();+        return Some((name(layer), name(adj), nets, inside as f64 * cell, backed as f64 * cell));+    }+    None+}++/// Rows that failed only the single-layer tab estimate and are plane-backed become pending on the+/// Fields solve. Other failures (no pour, no vias between the pour's layers, a theta ceiling the+/// spec set) are left as they are.+pub fn classify(results: &mut [Value], board: &Board) {+    for r in results.iter_mut().filter(|r| r["status"] == "fail" && r["estimateFailed"] == true) {+        let reference = r["ref"].as_str().unwrap_or("").to_string();+        let net = r["tabNet"].as_str().unwrap_or("").to_string();+        let Some((tab_layer, plane_layer, nets, pour, backed)) = measure(board, &reference, &net) else { continue };+        let fraction = if pour > 0.0 { backed / pour } else { 0.0 };+        r["planeBacked"] = json!({"tabLayer": tab_layer, "planeLayer": plane_layer, "planeNets": nets, "tabPourMm2": (pour * 10.0).round() / 10.0, "backedMm2": (backed * 10.0).round() / 10.0, "fraction": (fraction * 100.0).round() / 100.0, "minFraction": MIN_BACKED_FRACTION});+        if fraction >= MIN_BACKED_FRACTION {+            let estimate = r["riseEstimateC"].clone();+            r["status"] = json!("pending-fields");+            r["hint"] = json!(format!("{reference}: {:.0} % of its {net} tab pour on {tab_layer} lies over solid {} on {plane_layer}; the single-layer estimate (+{estimate} C) leaves that plane out, so it passes only on the same-input Fields solve within its budget", fraction * 100.0, nets.join("+")));+        }+    }+}++/// After the same-input field screen passed (which holds every chip to its rise budget): each+/// pending plane-backed row takes its solved rise.+pub fn accept_solve(results: &mut [Value], fields: &Value) -> Vec<String> {+    let mut notes = Vec::new();+    for r in results.iter_mut().filter(|r| r["status"] == "pending-fields" && r["planeBacked"].is_object()) {+        let reference = r["ref"].as_str().unwrap_or("").to_string();+        let row = fields["chips"].as_array().into_iter().flatten().find(|c| c["reference"] == reference.as_str() && c["tabNet"] == r["tabNet"]);+        match row {+            Some(c) => {+                r["status"] = json!("pass");+                r["judgedBy"] = json!("fields-solve");+                r["riseC"] = c["riseC"].clone();+                r["riseMovingC"] = c["riseMovingC"].clone();+                r["how"] = json!(format!("plane-backed tab ({} of the {} pour over {} on {}): the same-input Fields solve puts it at +{} C still air, +{} C moving air, within its budget; the single-layer estimate was +{} C", r["planeBacked"]["fraction"], r["tabNet"].as_str().unwrap_or(""), r["planeBacked"]["planeNets"], r["planeBacked"]["planeLayer"].as_str().unwrap_or(""), c["riseC"], c["riseMovingC"], r["riseEstimateC"]));+                if let Some(o) = r.as_object_mut() { o.remove("hint"); }+                notes.push(format!("{reference}: plane-backed tab, judged by the Fields solve"));+            }+            None => {+                r["status"] = json!("fail");+                r["hint"] = json!(format!("{reference}: the Fields report has no row for it on {}; solve again on this saved board and spec", r["tabNet"].as_str().unwrap_or("")));+            }+        }+    }+    notes+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(plane: bool) -> Board {+        let gnd = if plane { r#"(zone (net 2) (net_name "GND") (layer "In1.Cu") (polygon (pts (xy 0 0) (xy 30 0) (xy 30 20) (xy 0 20))) (filled_polygon (layer "In1.Cu") (pts (xy 0 0) (xy 30 0) (xy 30 20) (xy 0 20))))"# } else { "" };+        Board::from_text("t.kicad_pcb", format!(r#"(kicad_pcb (version 20260101) (layers (0 "F.Cu" signal) (4 "In1.Cu" signal) (6 "In2.Cu" signal) (2 "B.Cu" signal)) (net 0 "") (net 1 "SW") (net 2 "GND")+  (gr_rect (start 0 0) (end 30 20) (layer "Edge.Cuts") (width 0.05))+  (footprint "D" (layer "F.Cu") (at 10 10) (property "Reference" "D1") (pad "3" smd rect (at 0 0) (size 3 3) (layers "F.Cu") (net 1 "SW")))+  (zone (net 1) (net_name "SW") (layer "F.Cu") (polygon (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15))) (filled_polygon (layer "F.Cu") (pts (xy 5 5) (xy 15 5) (xy 15 15) (xy 5 15))))+  {gnd})"#)).unwrap()+    }++    #[test]+    fn plane_under_the_tab_pour_hands_the_part_to_the_solve() {+        let (tab, adj, nets, pour, backed) = measure(&board(true), "D1", "SW").unwrap();+        assert_eq!((tab.as_str(), adj.as_str()), ("F.Cu", "In1.Cu"));+        assert_eq!(nets, vec!["GND".to_string()]);+        assert!((pour - 100.0).abs() < 2.0 && (backed - pour).abs() < 1e-9, "{pour} {backed}");+        let mut rows = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "estimateFailed": true, "riseEstimateC": 200.0})];+        classify(&mut rows, &board(true));+        assert_eq!(rows[0]["status"], "pending-fields");+        // no solve row: it fails; a solve row: it takes the solved rise+        let mut none = rows.clone();+        accept_solve(&mut none, &json!({"chips": []}));+        assert_eq!(none[0]["status"], "fail");+        accept_solve(&mut rows, &json!({"chips": [{"reference": "D1", "tabNet": "SW", "riseC": 60.0, "riseMovingC": 40.0}]}));+        assert_eq!(rows[0]["status"], "pass");+        assert_eq!(rows[0]["judgedBy"], "fields-solve");+    }++    #[test]+    fn no_plane_keeps_the_estimate_failure() {+        let mut rows = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "estimateFailed": true, "riseEstimateC": 200.0})];+        classify(&mut rows, &board(false));+        assert_eq!(rows[0]["status"], "fail");+        assert_eq!(rows[0]["planeBacked"]["fraction"], 0.0);+        // other failures are never reclassified+        let mut other = vec![json!({"ref": "D1", "tabNet": "SW", "status": "fail", "riseEstimateC": 200.0})];+        classify(&mut other, &board(true));+        assert_eq!(other[0]["status"], "fail");+        assert!(other[0]["planeBacked"].is_null());+    }+}
docs/plane-backed-tabs.mdadded+20
@@ -0,0 +1,20 @@+# Plane-backed tabs++`analyze thermal` estimates a tab part with the single-layer rule: about 40 C/W per square inch of the tab+net's own copper. On a multilayer board with a solid plane one prepreg under the hot loop (In1.Cu ground+under a converter's switch node, the layout aiflow-power-layout recommends) that rule leaves out the heat+that crosses the 0.1 to 0.2 mm dielectric into the plane and spreads over the whole board, so it can read+three or four times the solved rise.++A tab part whose single-layer estimate fails is measured on the saved board: the fill islands of its tab+net that hold the tab pad, on the tab's outer layer, are sampled on a 0.2 mm grid against the solid fills+of other nets on the next copper layer. When at least 60 % of the tab pour lies over such a plane, the+row is `pending-fields` with a `planeBacked` record (layers, plane nets, areas, fraction) and passes only+on the same-input Fields solve, which models the stack, the plane and every neighbour's heat and is held+to the part's rise budget by the field screen, exactly as a lead-cooled part (`lead-cooled-parts.md`).+A part with no plane under its pour, a missing pour, a theta ceiling set in the spec, or two pour layers+with no via keeps its failure.++Worked case (fan controller, 2026-11-02): an SBR10U45SP5 freewheel diode at 0.82 W on 107 mm2 of SW+copper read +197 C single-layer; the pour lies 100 % over In1.Cu ground and the Fields solve puts it at++63 C still air, +40 C moving air.
skills/aiflow-molecule/SKILL.md+2
@@ -199,3 +199,5 @@ Record it as `programming` in board-release.json beside the board (or `programmi ```  `interface` is `swd`, `uart-boot` or `usb-dfu`; `pins` maps each interface signal to the reference that carries it; `target` is the MPN being programmed. A board programmed over its own USB connector instead (an ESP32-S3 or C3 with native USB Serial/JTAG) declares `"interface": "usb-serial-jtag", "access": "usb-cable"` with `connector` (the USB connector's reference), `targetRef` and `target` (the ESP32), `boot` (how to force download mode) and `connectorPins` mapping `D+`, `D-`, `GND` and `VBUS` to `{"pads": [...], "net": "..."}`; the check confirms those pads carry those four distinct nets, and no robot contacts are needed. `step schematic` and `step placement` print the Hint; `finish` refuses the board when a programmable part has no `programming` record, a required pin is missing, or a pin is neither a machine contact nor a labelled test pad.++A board with several programmable parts (four MCUs, or an MCU beside an ESP32 on its own USB) records `programming` as a list, one record per interface, each naming the part(s) it programs with `targetRef` or `targetRefs` (their part number must be its `target`); `finish` refuses a programmable part no record names. SWD over a Tag-Connect footprint (TC2030 / TC2050, a pad set made for a spring-pin cable) declares `"access": "tag-connect"` with `connector` (the Tag-Connect footprint), `targetRef`, `target` and `connectorPins` mapping `SWDIO`, `SWCLK`, `NRST` and `GND` to `{"pads": [...], "net": "..."}`; the check confirms the pads carry those nets and that the SWD nets reach the part, like the USB-cable record.
skills/aiflow-test-plan/SKILL.md+5
@@ -32,6 +32,11 @@ Kinds: `dc`, `ripple`, `waveform` need a probe landing; `camera` (an LED, a disp `ground` is the tether's machine contact. Typical for a regulator: the input at the board, the output, the switch node, both inductor ends, feedback, enable, power-good, the bulk caps' ripple, every LED. +Probe face: a test is probed from the top unless it says otherwise. `"face": "bottom"` (or `"back"`) on a+test, or `"probeFace": "bottom"` beside `"tests"` for the whole plan, lands it on bottom-side copper only (a+board whose top is wall-to-wall parts, probed from below); `"both"` accepts either face. `testplan check`+reports the face each landing was found on.+ ## 2. Give every measurement a spot (before the schematic)  `adom-aiflow testplan check --tests tests.json --netlist <netlist.json> --add` maps each measurement to
tools/project-page.py+21
@@ -311,6 +311,26 @@ def normalise_sym_header(text, version):     return re.sub(r"(\(kicad_symbol_lib\s*\(version\s+)(\d+)(\))", lambda m: f"{m.group(1)}{version}{m.group(3)}", text, count=1)  +def unit_names_follow_parent(text):+    """KiCad names a symbol's units <parent>_<unit>_<style> and refuses a library whose units carry another name+    (a wiki symbol 'Lib:C_1nF_0402' renamed to its MPN keeps units 'C_1nF_0402_0_1'). Rename every top-level+    symbol's units to follow the symbol."""+    root = sx_parse(text)+    edits = []+    for l in root[1:]:+        if not (isinstance(l, list) and l[:1] == ["symbol"] and len(l) > 1 and isinstance(l[1], str)):+            continue+        parent = l[1]+        s, e = l.span+        for m in re.finditer(r'\(symbol\s+"([^"]+)_(\d+)_(\d+)"', text[s:e]):+            if m.start() == 0 or m.group(1) == parent:+                continue+            edits.append((s + m.start(1), s + m.end(1), parent))+    for a, b, parent in sorted(edits, reverse=True):+        text = text[:a] + parent + text[b:]+    return text++ def top_symbols(text):     """{name: (start, end)} of the library's top-level symbols."""     root = sx_parse(text)@@ -547,6 +567,7 @@ def pcm_package(bundle, lib, pkgid, version, title, brief, page_url, work, parts         # one header for every symbol: the newest syntax any of them uses, at least KiCad 9's         header = max([SYM_VERSION] + versions)         t = normalise_sym_header(t, header)+        t = unit_names_follow_parent(t)         # the symbols point at the footprints by library nickname: PCM installs this library as PCM_<lib>         t = re.sub(r'(\(property\s+"Footprint"\s+")([^":]*):', lambda mm: f'{mm.group(1)}PCM_{lib}:', t)         open(sp, "w").write(t)