← Commit history
Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.32"+version = "0.2.33" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.32"+version = "0.2.33" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.32"+version = "0.2.33" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.32"+version = "0.2.33" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
SKILL.md+1−1
@@ -355,7 +355,7 @@ The gates read the board and the tools' own output, so a file written to look li - `step <name> --back` only returns to an earlier step. - **Silkscreen audit geometry is the board's (0.1.97).** `silkscreen-audit --record` and `finish` check the supplied obstacle boxes against the saved board: every pad of every footprint must fall inside one of that footprint's obstacle boxes (ids `U1`, `U1:body`, `U1:pad:1`), and every via inside a via/hole box as large as the via. Measure the boxes from the board; placeholder or point-sized boxes are refused. - **Thermal counts the board's vias (0.1.96).** `analyze thermal` counts the vias on each tab net in the saved board as well as the routing plan, so thermal vias landed after routing count once the board is saved.-- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet` (or `cooling: "leads"` for a part with no exposed tab, judged by the Fields solve: docs/lead-cooled-parts.md), `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.+- **Budgets are fixed once placement begins (0.1.95).** The spec's budgets and physical models (`loads`, `hot` watts and `tabNet` (no `tabNet` for a part with no exposed tab: it is lead-cooled, judged by the Fields solve, docs/lead-cooled-parts.md), `hotMaxRiseC`/`maxRiseC`, `switchNodeMaxLengthMm`, `wideNets`/`midNets`, `fab` and copper weights, via/drill power, package cooling) freeze at placement in `<run>/spec-budgets.json`. When a check fails, change the board: more copper, vias, a tighter placement. Every gate refuses a spec whose budgets moved since. Layout keys (`pours`, `thermalVias`, fixed coordinates, `planeUnder`) stay editable. If a budget really is wrong (a misread datasheet value), `adom-aiflow spec-amend --why "<datasheet page and what was misread>"` records the change; amendments are listed in the run ledger and the finish summary.  ## Spec intent checked by the binary (0.2.3) 
bin/adom-aiflow
⋯ 1 unchanged line ⋯
crates/adom-aiflow/src/pourverify.rs+103−11
@@ -224,6 +224,41 @@ fn keepout_clear(board: &Board, actual: &[&Node], z: &Value, label: &str) -> Vec     out } +/// Verification-only native polygon loops, in native order. Never flatten holes into an+/// outer contour, and never send this extension to a bridge that only lands one polygon.+fn planned_loops(z: &Value) -> Result<Vec<Value>, String> {+    let loops = match (z.get("polygon"), z.get("polygons")) {+        (Some(p), None) => vec![p.clone()],+        (None, Some(p)) => p.as_array().filter(|a| !a.is_empty())+            .ok_or("polygons requires a nonempty array of native loops")?.clone(),+        _ => return Err("provide exactly one of polygon or polygons".into()),+    };+    for p in &loops {+        if p.as_array().is_none_or(|a| a.len() < 3 || a.iter().any(|p|+            p.as_array().is_none_or(|p| p.len() != 2 || p.iter().any(|x|+                !x.as_f64().is_some_and(f64::is_finite))))) {+            return Err("finite polygon coordinates required, at least three points per loop".into());+        }+    }+    Ok(loops)+}++fn expected_zone(board: &Board, z: &Value, loops: &[Value]) -> Result<Node, String> {+    let mut single = z.clone();+    single.as_object_mut().ok_or("zone must be an object")?.remove("polygons");+    single["polygon"] = loops[0].clone();+    let mut node = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, &single)?)?;+    node.items.retain(|i| !matches!(i, aiflow_board::sx::Item::Node(n) if n.tag == "polygon"));+    for p in loops {+        let pts = p.as_array().unwrap().iter().map(|p|+            format!("(xy {} {})", p[0].as_f64().unwrap(), p[1].as_f64().unwrap()))+            .collect::<Vec<_>>().join(" ");+        node.items.push(aiflow_board::sx::Item::Node(aiflow_board::sx::parse(+            &format!("(polygon (pts {pts}))"))?));+    }+    Ok(node)+}+ /// `keepouts_landed`: the tool lands keepout zones (KiCad), so each must match a saved zone; otherwise /// each is checked clear of pour copper (`keepout_clear`). pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<Value, String> {@@ -246,6 +281,8 @@ pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<             "net",             "layers",             "polygon",+            "polygons",+            "boardZoneUuid",             "priority",             "connection",             "clearance",@@ -274,16 +311,11 @@ pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<         }) {             return Err(format!("{label}: explicit board copper layers required"));         }-        if z["polygon"].as_array().is_none_or(|a| {-            a.len() < 3-                || a.iter().any(|p| {-                    p.as_array().is_none_or(|p| {-                        p.len() != 2 || p.iter().any(|x| !x.as_f64().is_some_and(f64::is_finite))-                    })-                })-        }) {-            return Err(format!("{label}: finite polygon coordinates required"));+        let loops = planned_loops(z).map_err(|e| format!("{label}: {e}"))?;+        if z.get("boardZoneUuid").is_some_and(|v| !v.as_str().is_some_and(|id| !id.trim().is_empty())) {+            return Err(format!("{label}: boardZoneUuid must be a nonempty native item identity"));         }+         aiflow_pours::check_settings(z).map_err(|e| format!("{label}: {e}"))?;         if !z["priority"].is_null() && z["priority"].as_u64().is_none() {             return Err(format!("{label}: invalid priority"));@@ -301,15 +333,19 @@ pub fn check_with(board: &Board, plan: &Value, keepouts_landed: bool) -> Result<             }         }         if !keepouts_landed && (z["type"] == "keepout" || z.get("keepout").is_some()) {+            if z.get("polygons").is_some() {+                return Err(format!("{label}: native polygon loops require native keepout-zone comparison"));+            }             let bad = keepout_clear(board, &actual, z, label);             cleared.push(json!({"zone": index, "name": label, "ok": bad.is_empty()}));             failed.extend(bad);             continue;         }-        let expected = aiflow_board::sx::parse(&aiflow_pours::zone_sexpr(board, z)?)?;+        let expected = expected_zone(board, z, &loops)?;         let wanted = signature(board, &expected);         let matches: Vec<usize> = (0..actual.len())-            .filter(|i| !used.contains(i) && signatures[*i] == wanted)+            .filter(|i| !used.contains(i) && signatures[*i] == wanted+                && z.get("boardZoneUuid").is_none_or(|id| zone_id(actual[*i]).as_deref() == id.as_str()))             .collect();         if matches.len() != 1 {             // name the closest unused board zone and every field where it differs@@ -444,6 +480,9 @@ pub fn bound(outcome: &Value, board: &Path, spec: &Path) -> bool { /// a landing never silently differs from the plan; write it natively and verify or adopt it. pub fn landable(zones: &[Value]) -> Result<(), String> {     for z in zones {+        if z.get("polygons").is_some() || z.get("boardZoneUuid").is_some() {+            return Err("polygons and boardZoneUuid are verification-only: native loop/identity plans cannot be landed through the single-polygon bridge".into());+        }         if z.get("keepout").is_some() || z["type"] == "keepout" {             continue;         }@@ -762,6 +801,59 @@ mod tests {         (b, p)     }     #[test]+    fn duplicate_geometry_requires_explicit_native_identity_and_full_coverage() {+        let (b, p) = fixture();+        let n=b.root.find("zone").unwrap();+        let id=zone_id(n).unwrap();+        let text=&b.text[n.start..n.end];+        let mut two=b.text[..b.text.len()-1].to_string();+        two.push_str(&text.replace(&id,"second-native-zone"));two.push(')');+        let board=Board::from_text("test",two).unwrap();+        let mut q=p.clone();q["zones"].as_array_mut().unwrap().push(p["zones"][0].clone());+        assert_eq!(check(&board,&q).unwrap()["pass"],false);+        q["zones"][0]["boardZoneUuid"]=json!(id);+        q["zones"][1]["boardZoneUuid"]=json!("second-native-zone");+        assert_eq!(check(&board,&q).unwrap()["pass"],true);+        assert!(landable(q["zones"].as_array().unwrap()).is_err());+        let mut missing=q.clone();missing["zones"].as_array_mut().unwrap().pop();+        assert_eq!(check(&board,&missing).unwrap()["pass"],false);+        q["zones"][1]["polygon"][0][0]=json!(0.1);+        assert_eq!(check(&board,&q).unwrap()["pass"],false);+        q["zones"][1]["boardZoneUuid"]=json!("");+        assert!(check(&board,&q).is_err());+    }+    #[test]+    fn native_hole_loops_are_verified_without_flattening() {+        let (b, mut p) = fixture();+        let hole = "(polygon (pts (xy 1 1) (xy 2 1) (xy 2 2)))";+        let text = b.text.replace("(filled_polygon", &format!("{hole} (filled_polygon"));+        let with_hole = Board::from_text("test", text).unwrap();+        assert_eq!(check(&with_hole, &p).unwrap()["pass"], false);+        let outer = p["zones"][0].as_object_mut().unwrap().remove("polygon").unwrap();+        p["zones"][0]["polygons"] = json!([outer, [[1,1],[2,1],[2,2]]]);+        let r = check(&with_hole, &p).unwrap();+        assert_eq!(r["pass"], true);+        assert_eq!(r["ownershipGranted"], false);+        assert!(landable(p["zones"].as_array().unwrap()).is_err());+        p["zones"][0]["polygons"][1][0][0] = json!(1.1);+        assert_eq!(check(&with_hole, &p).unwrap()["pass"], false);+        p["zones"][0]["polygons"][1] = json!([[1,1],[2,1]]);+        assert!(check(&with_hole, &p).is_err());+        p["zones"][0]["polygons"] = json!([]);+        assert!(check(&with_hole, &p).is_err());+    }+    #[test]+    fn ambiguous_polygon_encodings_are_refused() {+        let (b, mut p) = fixture();+        p["zones"][0]["polygons"] = json!([[[0,0],[5,0],[5,5]]]);+        assert!(check(&b, &p).is_err());+        p["zones"][0].as_object_mut().unwrap().remove("polygon");+        p["zones"][0]["type"] = json!("keepout");+        p["zones"][0]["keepout"] = json!({"copperPour":true});+        assert!(check_with(&b, &p, false).is_err());+        assert!(landable(p["zones"].as_array().unwrap()).is_err());+    }+    #[test]     fn existing_filled_plan_passes_without_ownership() {         let (b, p) = fixture();         let r = check(&b, &p).unwrap();
crates/adom-aiflow/src/thermalvias.rs+27−1
@@ -81,7 +81,8 @@ pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], a         }         f && bk     };-    let tracks = b.tracks(); let vias = b.vias();+    // Planned holes are through vias: inner copper must remain an obstacle too.+    let tracks = b.copper_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);@@ -277,6 +278,31 @@ fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pa  #[cfg(test)] mod tests {+    #[test]+    fn through_thermal_vias_clear_tracks_on_every_inner_layer() {+        let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (net 2 "SIGNAL")+ (layers (0 "F.Cu" signal)(4 "In1.Cu" signal)(6 "In2.Cu" signal)(2 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(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"))))"#;+        let spec = serde_json::json!({"hot":{"U1":{"watts":0.3,"tabNet":"GND"}},+            "clearance":0.2,"viaPower":[0.6,0.3],+            "pours":[{"net":"GND","layers":["F.Cu","B.Cu"],"polygon":[[5,5],[15,5],[15,15],[5,15]]}]});+        let b = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+        let (base, _) = super::plan(&spec, &b, &[]);+        let (x,y) = base[0].planned[0];+        for layer in ["In1.Cu", "In2.Cu"] {+            let mut blocked = text[..text.len()-1].to_string();+            blocked.push_str(&format!("(segment (start {} {})(end {} {})(width 0.25)(layer \"{layer}\")(net 2)))",x-2.0,y,x+2.0,y));+            let b = aiflow_board::Board::from_text("inner-track", blocked).unwrap();+            let (parts, _) = super::plan(&spec, &b, &[]);+            for &p in &parts[0].planned {+                assert!(super::seg_dist(p,(x-2.0,y),(x+2.0,y)) >= 0.3+0.125+0.2,+                    "through via must clear {layer} track: {p:?}");+            }+        }+    }+     #[test]     fn local_regions_reject_remote_disjoint_and_edge_candidates() {         let pours = serde_json::json!([
crates/adom-aiflow/src/thermalvias.rs.origadded+515
@@ -0,0 +1,515 @@+//! Heatsink vias: for every part that dissipates real power (spec.hot: {REF: {watts, tabNet}}), an+//! array of vias beside the pads it dumps heat through, on that net, so the heat reaches the copper on+//! the other side. The count comes from the dissipation and the rise budget; existing vias of the net+//! near the part count toward it. Never on the switch node. The plan is the same shape the pour planner+//! writes, so `land vias --plan` lands it.++use aiflow_board::Board;+use serde_json::{json, Value};++/// Thermal resistance of one plated 0.3 mm via through 1.6 mm FR4, about 100 C/W; the count keeps+/// the vias' share of the rise under half the budget.+pub fn target(watts: f64, max_rise: f64) -> usize {+    ((watts * 100.0 / (max_rise.max(1.0) * 0.5)).ceil() as usize).clamp(6, 16)+}++fn point_in(poly: &[(f64, f64)], p: (f64, f64)) -> bool {+    let mut inside = false;+    let n = poly.len();+    if n < 3 { return true; }+    let mut j = n - 1;+    for i in 0..n {+        let (xi, yi) = poly[i]; let (xj, yj) = poly[j];+        if (yi > p.1) != (yj > p.1) && p.0 < (xj - xi) * (p.1 - yi) / (yj - yi + 1e-12) + xi { inside = !inside; }+        j = i;+    }+    inside+}++fn seg_dist(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 < 1e-12 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    (p.0 - a.0 - t * dx).hypot(p.1 - a.1 - t * dy)+}++/// Planned region coverage is a necessary condition, not proof of native filled+/// connectivity. Keep the entire via inside one region on each outer layer.+fn local_pours(pours: &[Value], net: &str, c: (f64, f64), radius: f64, outline: &[(f64, f64)]) -> bool {+    ["F.Cu", "B.Cu"].iter().all(|layer| pours.iter().any(|p| {+        if p["net"].as_str() != Some(net)+            || !p["layers"].as_array().is_some_and(|ls| ls.iter().any(|l| l.as_str() == Some(*layer))) {+            return false;+        }+        let poly: Vec<_> = match p.get("polygon") {+            Some(value) => match value.as_array().and_then(|points| points.iter().map(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect::<Option<Vec<_>>>()) {+                Some(points) => points,+                None => return false,+            },+            None => outline.to_vec(),+        };+        poly.len() >= 3 && point_in(&poly, c)+            && (0..poly.len()).all(|i| seg_dist(c, poly[i], poly[(i + 1) % poly.len()]) >= radius)+    }))+}++pub struct Part { pub reference: String, pub net: String, pub target: usize, pub existing: usize, pub planned: Vec<(f64, f64)>, pub note: Option<String> }++/// Plan the vias. `poured_both` says whether a net has copper on both outer layers (a via only moves+/// heat where the net has copper on the other side).+pub fn plan(spec: &Value, b: &Board, sw_nets: &[String]) -> (Vec<Part>, Value) {+    plan_with_acknowledgements(spec, b, sw_nets, &[])+}++/// Preserve reviewed targets/counts without proposing optional holes for an+/// explicitly acknowledged reference. Cooling and all downstream gates remain.+pub fn plan_with_acknowledgements(spec: &Value, b: &Board, sw_nets: &[String], acked: &[String]) -> (Vec<Part>, Value) {+    let size = spec["viaPower"].as_array().and_then(|a| a.first()).and_then(|v| v.as_f64()).or(spec["viaPower"].as_f64()).unwrap_or(0.6);+    let drill = spec["viaPower"].as_array().and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or(spec["drillPower"].as_f64()).unwrap_or(0.3);+    // via to other copper: viaClearance (the project's .kicad_dru through with_project_rules), else clearance+    let clearance = aiflow_pours::via_clearance(spec);+    // the silkscreen is an obstacle too (issue #90): a via under a label makes it unreadable+    let silk = aiflow_board::silk::silk_shapes(b);+    let silk_gap = spec["silkClearance"].as_f64().unwrap_or(0.1);+    let edge = spec["edgeClearance"].as_f64().unwrap_or(0.3);+    let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);+    let pours = spec["pours"].as_array().cloned().unwrap_or_default();+    let poured_both = |net: &str| -> bool {+        let mut f = false; let mut bk = false;+        for p in pours.iter().filter(|p| p["net"].as_str() == Some(net)) {+            for l in p["layers"].as_array().cloned().unwrap_or_default() { match l.as_str() { Some("F.Cu") => f = true, Some("B.Cu") => bk = true, _ => {} } }+        }+        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();+    let hot = spec["hot"].as_object().cloned().unwrap_or_default();+    for (reference, h) in hot {+        let watts = h["watts"].as_f64().unwrap_or(0.0);+        // issue #116: a lead-cooled part has no tab to put heatsink vias beside+        if aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) {+            parts.push(Part { reference: reference.clone(), net: aiflow_analyze::lead_cooling::LEADS.into(), target: 0, existing: 0, planned: vec![], note: Some(format!("{reference} is lead-cooled (no exposed tab): no heatsink vias; the Fields solve judges it")) });+            continue;+        }+        let net = h["tabNet"].as_str().unwrap_or("GND").to_string();+        let tgt = target(watts, h["maxRiseC"].as_f64().unwrap_or(max_rise));+        let pads: Vec<_> = b.pads.iter().filter(|p| p.reference == reference && p.net == net).collect();+        let mut part = Part { reference: reference.clone(), net: net.clone(), target: tgt, existing: 0, planned: vec![], note: None };+        if sw_nets.contains(&net) { part.note = Some(format!("{net} is a switch node: no heatsink vias on it")); parts.push(part); continue; }+        if pads.is_empty() { part.note = Some(format!("{reference} has no pad on {net}")); parts.push(part); continue; }+        if !poured_both(&net) { part.note = Some(format!("{net} has no pour on both layers: a via would carry the heat nowhere (pour {net} on B.Cu under {reference}, or ack)")); parts.push(part); continue; }+        // vias of the net already within 3 mm of the part's heat pads+        let near = |x: f64, y: f64| pads.iter().any(|p| (p.x - x).hypot(p.y - y) < 3.0);+        part.existing = vias.iter().filter(|v| v.net == net && near(v.x, v.y)+            && local_pours(&pours, &net, (v.x, v.y), v.size / 2.0, &b.outline)).count();+        if acked.contains(&reference) {+            part.note = Some(format!("reviewed via-count acknowledgement for {reference}: retain {} existing vias; generic target {tgt} remains reported, cooling acceptance is separate", part.existing));+            parts.push(part);+            continue;+        }+        let need = tgt.saturating_sub(part.existing);+        // candidate spots on rings around each heat pad, nearest first+        let mut cands: Vec<(f64, (f64, f64))> = Vec::new();+        for p in &pads {+            for ring in 0..6 {+                let r = p.half_long + size / 2.0 + clearance + 0.1 + ring as f64 * (size + 0.25);+                let n = (2.0 * std::f64::consts::PI * r / (size + 0.3)).floor().max(6.0) as usize;+                for k in 0..n {+                    let a = 2.0 * std::f64::consts::PI * k as f64 / n as f64;+                    cands.push((r, (p.x + r * a.cos(), p.y + r * a.sin())));+                }+            }+        }+        cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());+        let rr = size / 2.0;+        let mut under_silk = 0;+        for (_, c) in cands {+            if part.planned.len() >= need { break; }+            // Use the same neighbourhood as the post-landing count. A remote+            // same-net pour, or a farther ring, cannot satisfy this heat pad.+            if !near(c.0, c.1) || !local_pours(&pours, &net, c, rr, &b.outline) { 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)+            // a pad reaches its corners, not just half its long side: the half_long circle let a via sit+            // 0.145 mm from a pad corner on a 0.15 mm board, which Fusion then refused (issue #74). The+            // half sizes are the pad's own (unrotated) bounding box, so the circle of its half diagonal holds+            // the pad at any rotation; it is centred on that box (polys_centre), not the pad origin, so an+            // offset pad is held too.+            if b.pads.iter().any(|q| (q.polys_centre.0 - c.0).hypot(q.polys_centre.1 - c.1) < q.half_long.hypot(q.half_short) + 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; }+            if !aiflow_pours::hole_clear(c.0, c.1, size, drill, &net, &holes, h2h, clearance) { continue; }+            if aiflow_board::silk::near(&silk, c, rr + silk_gap).is_some() { under_silk += 1; continue; }+            placed.push(c); part.planned.push(c); holes.push((c.0, c.1, size, drill, net.clone()));+            entries.push(json!({"net": net, "ref": reference, "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 {+            let silk_note = if under_silk > 0 { format!("; {under_silk} spot(s) left out under silkscreen text or graphics (silkClearance {silk_gap} mm): move the label to free them") } else { String::new() };+            part.note = Some(format!("room for {} of {} vias within 3 mm of {reference} and inside both planned {net} copper regions: review local copper and cooling; native refill/connectivity still required{silk_note}", part.existing + part.planned.len(), tgt));+        }+        parts.push(part);+    }+    (parts, json!({"engine": "adom-aiflow thermal-vias plan", "nets": entries}))+}++/// The plan without the vias of acknowledged parts (issue #90: `thermal-vias land --ack U3` landed U3's+/// planned vias anyway). An entry names its part in `ref` (older plans only in `why`, "heatsink via for+/// U3"). Returns the plan to land and, per acknowledged part, how many planned vias were left out.+pub fn without_acked(plan: &Value, acked: &[String]) -> (Value, std::collections::BTreeMap<String, usize>) {+    let mut left = std::collections::BTreeMap::new();+    let mut out = plan.clone();+    let owner = |e: &Value| e["ref"].as_str().map(str::to_string).or_else(|| e["why"].as_str().and_then(|w| w.strip_prefix("heatsink via for ")).map(str::to_string));+    if let Some(nets) = plan["nets"].as_array() {+        let kept: Vec<Value> = nets.iter().filter(|e| match owner(e) {+            Some(r) if acked.contains(&r) => { *left.entry(r).or_insert(0) += 1; false }+            _ => true,+        }).cloned().collect();+        out["nets"] = Value::Array(kept);+    }+    (out, left)+}++/// A short, order-independent hash of a closed outline: points rounded to 0.01 mm, wound one way,+/// starting at the smallest point, so the same polygon read back from KiCad hashes the same.+pub fn outline_hash(poly: &[(f64, f64)]) -> String {+    use sha2::{Digest, Sha256};+    let mut pts: Vec<(i64, i64)> = poly.iter().map(|&(x, y)| ((x * 100.0).round() as i64, (y * 100.0).round() as i64)).collect();+    if pts.len() > 1 && pts.first() == pts.last() { pts.pop(); }+    pts.dedup();+    let area: i64 = (0..pts.len()).map(|i| { let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); a.0 * b.1 - b.0 * a.1 }).sum();+    if area < 0 { pts.reverse(); }+    if let Some(k) = (0..pts.len()).min_by_key(|&i| pts[i]) { pts.rotate_left(k); }+    let text: String = pts.iter().map(|(x, y)| format!("{x},{y};")).collect();+    format!("{:x}", Sha256::digest(text.as_bytes()))[..12].to_string()+}++/// Issue #40: the planner trusts spec.pours polygons, which go stale when the board's zones move+/// (a part rotated, a zone redrawn) and then every via reads as outside the copper. Compare each+/// spec pour with an explicit polygon against the board's zone of the same net and layer (the same+/// name first): a WARNING when they differ, telling a pour this flow landed (`landed`: the run's+/// land-pours record passed) that is now stale from one that was never landed. Read-only: nothing+/// is changed or blocked here.+pub fn pour_freshness(spec: &Value, b: &Board, landed: bool) -> Vec<String> {+    let mut zones: Vec<(String, String, Vec<String>, Vec<(f64, f64)>)> = Vec::new();+    for z in b.root.find_all("zone") {+        if z.find("keepout").is_some() { continue; }+        let mut layers: Vec<String> = Vec::new();+        for key in ["layer", "layers"] {+            if let Some(n) = z.find(key) { layers.extend((1..).map_while(|i| n.atom(i)).map(str::to_string)); }+        }+        let outline = z.find("polygon").and_then(|p| p.find("pts")).map(|p| p.find_all("xy").filter_map(|xy| Some((xy.number(1)?, xy.number(2)?))).collect()).unwrap_or_default();+        let name = z.find("name").and_then(|n| n.value()).unwrap_or("").to_string();+        zones.push((b.item_net(z), name, layers, outline));+    }+    let on_layer = |ls: &[String], l: &str| ls.iter().any(|x| x == l || x == "*.Cu" || (x == "F&B.Cu" && (l == "F.Cu" || l == "B.Cu")));+    let mut out = Vec::new();+    for p in spec["pours"].as_array().into_iter().flatten() {+        let Some(net) = p["net"].as_str() else { continue };+        let Some(poly) = p["polygon"].as_array().and_then(|a| a.iter().map(|v| Some((v.get(0)?.as_f64()?, v.get(1)?.as_f64()?))).collect::<Option<Vec<_>>>()) else { continue };+        let name = p["name"].as_str().map(str::to_string).unwrap_or_else(|| format!("{net} pour"));+        let planned = outline_hash(&poly);+        for layer in p["layers"].as_array().into_iter().flatten().filter_map(|l| l.as_str()) {+            let same: Vec<_> = zones.iter().filter(|z| z.0 == net && on_layer(&z.2, layer)).collect();+            let found = same.iter().find(|z| z.1 == name).or(same.first());+            match found {+                None if landed => out.push(format!("WARNING stale pour: {name} ({net} {layer}) was landed by this flow but the board has no {net} zone on {layer} now (planned outline {planned}, board none); `pour` again before trusting the via counts")),+                None => out.push(format!("note: pour {name} ({net} {layer}) is not on the board yet (never landed); vias are counted against the planned outline {planned}")),+                Some(z) => {+                    let actual = outline_hash(&z.3);+                    if actual == planned { continue; }+                    let zname = if z.1.is_empty() { "unnamed".to_string() } else { z.1.clone() };+                    if landed {+                        out.push(format!("WARNING stale pour: {name} ({net} {layer}) was landed by this flow but the board's zone ({zname}) has moved: planned outline {planned}, board outline {actual}; existing/planned via counts use the old outline. Update spec.pours (or `pour` again) and plan again"));+                    } else {+                        out.push(format!("WARNING pour differs from the board: {name} ({net} {layer}) was never landed by this flow, and the board's own zone ({zname}) has another outline: planned outline {planned}, board outline {actual}; the via counts use the planned outline. Update spec.pours to the board's zone if that zone is the copper"));+                    }+                }+            }+        }+    }+    out+}++/// After landing: (reference, have, target) for every hot part short of its target, counted on a saved board.+pub fn short(spec: &Value, b: &Board, acked: &[String]) -> Vec<(String, usize, usize)> {+    let vias: Vec<(String, f64, f64, f64)> = b.vias().into_iter().map(|v| (v.net, v.x, v.y, v.size)).collect();+    short_with(spec, b, &vias, &|_, _| None, acked)+}++/// Issue #101: `short` counted on the live board (a KiCad run: the vias `land vias` just placed are live+/// and not yet in the saved snapshot). Vias come from the live kicad_routing_state; a part's heat pads+/// from the live state too when it reports them (`REF.pad` with net and centre), else from the saved+/// board (landing vias moves no part). The outline is the saved board's.+pub fn short_live(spec: &Value, b: &Board, live: &aiflow_bridge::landing::LiveCopper, acked: &[String]) -> Vec<(String, usize, usize)> {+    let vias: Vec<(String, f64, f64, f64)> = live.vias.iter().map(|v| (v.net.clone(), v.at.0, v.at.1, v.size)).collect();+    let pads = |reference: &str, net: &str| -> Option<Vec<(f64, f64)>> {+        let prefix = format!("{reference}.");+        let p: Vec<(f64, f64)> = live.pads.iter().filter(|(k, (n, _))| k.starts_with(&prefix) && n == net).map(|(_, (_, at))| *at).collect();+        if p.is_empty() { None } else { Some(p) }+    };+    short_with(spec, b, &vias, &pads, acked)+}++fn short_with(spec: &Value, b: &Board, vias: &[(String, f64, f64, f64)], live_pads: &dyn Fn(&str, &str) -> Option<Vec<(f64, f64)>>, acked: &[String]) -> Vec<(String, usize, usize)> {+    let max_rise = spec["hotMaxRiseC"].as_f64().unwrap_or(10.0);+    let pours = spec["pours"].as_array().cloned().unwrap_or_default();+    let mut out = Vec::new();+    for (reference, h) in spec["hot"].as_object().cloned().unwrap_or_default() {+        if acked.contains(&reference) || aiflow_analyze::lead_cooling::declared(&reference, &h).unwrap_or(false) { continue; }+        let net = h["tabNet"].as_str().unwrap_or("GND");+        let tgt = target(h["watts"].as_f64().unwrap_or(0.0), h["maxRiseC"].as_f64().unwrap_or(max_rise));+        let pads: Vec<(f64, f64)> = live_pads(&reference, net).unwrap_or_else(|| b.pads.iter().filter(|p| p.reference == reference && p.net == net).map(|p| (p.x, p.y)).collect());+        if pads.is_empty() { continue; }+        let have = vias.iter().filter(|(vn, x, y, size)| vn == net && pads.iter().any(|p| (p.0 - x).hypot(p.1 - y) < 3.0)+            && local_pours(&pours, net, (*x, *y), size / 2.0, &b.outline)).count();+        if have < tgt { out.push((reference, have, tgt)); }+    }+    out+}++#[cfg(test)]+mod tests {+    #[test]+    fn local_regions_reject_remote_disjoint_and_edge_candidates() {+        let pours = serde_json::json!([+            {"net":"LOAD", "layers":["F.Cu","B.Cu"], "polygon":[[0,0],[4,0],[4,4],[0,4]]}+        ]);+        let p = pours.as_array().unwrap();+        assert!(super::local_pours(p, "LOAD", (2.0, 2.0), 0.3, &[]));+        assert!(!super::local_pours(p, "LOAD", (10.0, 10.0), 0.3, &[]));+        assert!(!super::local_pours(p, "LOAD", (0.1, 2.0), 0.3, &[]));+        assert!(!super::local_pours(p, "OTHER", (2.0, 2.0), 0.3, &[]));+        let disjoint = serde_json::json!([+            {"net":"LOAD","layers":["F.Cu"],"polygon":[[0,0],[4,0],[4,4],[0,4]]},+            {"net":"LOAD","layers":["B.Cu"],"polygon":[[8,8],[12,8],[12,12],[8,12]]}+        ]);+        assert!(!super::local_pours(disjoint.as_array().unwrap(), "LOAD", (2.0, 2.0), 0.3, &[]));+        let malformed = serde_json::json!([{"net":"LOAD","layers":["F.Cu","B.Cu"],"polygon":[[0,0]]}]);+        assert!(!super::local_pours(malformed.as_array().unwrap(), "LOAD", (2.0, 2.0), 0.3, &[]));+    }++    #[test]+    fn planner_and_short_use_same_local_neighbourhood() {+        let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(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"))))"#;+        let b = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+        let mut spec = serde_json::json!({"hot":{"U1":{"watts":2.0,"tabNet":"GND"}},+            "pours":[{"net":"GND","layers":["F.Cu","B.Cu"],"polygon":[[8,8],[12,8],[12,12],[8,12]]}]});+        let (parts, _) = super::plan(&spec, &b, &[]);+        assert!(!parts[0].planned.is_empty());+        let (reviewed, reviewed_plan) = super::plan_with_acknowledgements(&spec, &b, &[], &["U1".into()]);+        assert_eq!(reviewed[0].target, parts[0].target);+        assert_eq!(reviewed[0].existing, parts[0].existing);+        assert!(reviewed[0].planned.is_empty());+        assert!(reviewed_plan["nets"].as_array().unwrap().is_empty());+        assert!(!super::short(&spec, &b, &[]).is_empty());+        assert!(super::short(&spec, &b, &["U1".into()]).is_empty());+        let (unrelated, _) = super::plan_with_acknowledgements(&spec, &b, &[], &["UNKNOWN".into()]);+        assert_eq!(unrelated[0].planned, parts[0].planned);+        let mut landed = text[..text.len()-1].to_string();+        for &(x,y) in &parts[0].planned {+            assert!((x-10.0).hypot(y-10.0) < 3.0);+            landed.push_str(&format!("(via (at {x} {y})(size 0.6)(drill 0.3)(layers \"F.Cu\" \"B.Cu\")(net 1))"));+        }+        landed.push(')');+        let saved = aiflow_board::Board::from_text("landed", landed).unwrap();+        let short = super::short(&spec, &saved, &[]);+        if parts[0].planned.len() < parts[0].target {+            assert_eq!(short[0].1, parts[0].planned.len());+        } else { assert!(short.is_empty()); }+        spec["pours"][0]["polygon"] = serde_json::json!([[20,20],[25,20],[25,25],[20,25]]);+        let (remote, plan) = super::plan(&spec, &saved, &[]);+        assert_eq!(remote[0].existing, 0);+        assert!(plan["nets"].as_array().unwrap().is_empty());+        assert_eq!(super::short(&spec, &saved, &[])[0].1, 0);+    }++    #[test]+    fn short_live_counts_the_vias_on_the_live_board() {+        // issue #101: the saved snapshot has no vias yet; the live routing state has the landed ones+        let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal))+ (gr_rect (start 0 0)(end 30 30)(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"))))"#;+        let saved = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+        let spec = serde_json::json!({"hot":{"U1":{"watts":0.3,"tabNet":"GND"}},+            "pours":[{"net":"GND","layers":["F.Cu","B.Cu"],"polygon":[[5,5],[15,5],[15,15],[5,15]]}]});+        let target = super::target(0.3, 10.0);+        assert_eq!(super::short(&spec, &saved, &[]), vec![("U1".to_string(), 0, target)]);+        let via = |x: f64, y: f64, net: &str| serde_json::json!({"position": {"x": x, "y": y}, "size": 0.6, "drill": 0.3, "net_name": net, "uuid": format!("v{x}{y}")});+        let mut vias: Vec<serde_json::Value> = (0..target).map(|i| via(9.0 + 0.9 * (i % 3) as f64, 11.5 + 0.9 * (i / 3) as f64, "GND")).collect();+        // another net's via and a far one do not count+        vias.push(via(10.0, 8.5, "VIN"));+        vias.push(via(14.5, 14.5, "GND"));+        let state = serde_json::json!({"success": true, "vias": vias, "segments": [], "pads": [{"name": "U1.1", "netName": "GND", "x": 10.0, "y": 10.0}]});+        let live = aiflow_bridge::landing::LiveCopper::from_state(&state);+        assert!(super::short_live(&spec, &saved, &live, &[]).is_empty(), "every landed via counts live");+        // one fewer live via: short by one, counted live+        let mut fewer = state.clone();+        fewer["vias"].as_array_mut().unwrap().remove(0);+        let live = aiflow_bridge::landing::LiveCopper::from_state(&fewer);+        assert_eq!(super::short_live(&spec, &saved, &live, &[]), vec![("U1".to_string(), target - 1, target)]);+        // live pads win over the saved ones when the state reports them (here: U1 elsewhere live)+        let mut moved = state.clone();+        moved["pads"][0]["x"] = serde_json::json!(25.0);+        let live = aiflow_bridge::landing::LiveCopper::from_state(&moved);+        assert_eq!(super::short_live(&spec, &saved, &live, &[])[0].1, 0);+        // no live pads: the saved ones+        let mut no_pads = state.clone();+        no_pads["pads"] = serde_json::json!([]);+        assert!(super::short_live(&spec, &saved, &aiflow_bridge::landing::LiveCopper::from_state(&no_pads), &[]).is_empty());+    }++    fn zone_board(zone_poly: &str) -> aiflow_board::Board {+        aiflow_board::Board::from_text("test.kicad_pcb", format!(r#"(kicad_pcb+  (layers (0 "F.Cu" signal) (31 "B.Cu" signal))+  (net 1 "GND")+  (gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))+  (footprint "t" (layer "F.Cu") (at 10 10) (property "Reference" "D1")+    (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND")))+  (zone (net 1) (net_name "GND") (layers "F.Cu" "B.Cu") (name "GND pour")+    (polygon (pts {zone_poly}))))"#)).unwrap()+    }+    #[test]+    fn stale_pours_are_warned_and_never_landed_ones_told_apart() {+        // issue #40: the spec's pour where the board's zone used to be+        let spec = serde_json::json!({"pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+        // the board's zone is the same polygon, written from another corner and the other way round+        let same = zone_board("(xy 5 15) (xy 15 15) (xy 15 5) (xy 5 5)");+        assert!(super::pour_freshness(&spec, &same, true).is_empty());+        // the zone moved: landed by this flow means stale; otherwise it was never landed+        let moved = zone_board("(xy 20 20) (xy 28 20) (xy 28 28) (xy 20 28)");+        let w = super::pour_freshness(&spec, &moved, true);+        assert_eq!(w.len(), 2, "{w:?}");+        assert!(w[0].starts_with("WARNING stale pour: GND pour (GND F.Cu)"), "{w:?}");+        let (planned, actual) = (super::outline_hash(&[(5.,5.),(15.,5.),(15.,15.),(5.,15.)]), super::outline_hash(&[(20.,20.),(28.,20.),(28.,28.),(20.,28.)]));+        assert!(w[0].contains(&planned) && w[0].contains(&actual) && planned != actual, "{w:?}");+        let n = super::pour_freshness(&spec, &moved, false);+        assert!(n[1].starts_with("WARNING pour differs from the board: GND pour (GND B.Cu) was never landed"), "{n:?}");+        // no zone at all: a note when never landed, a warning when the run says it landed+        let mut none = spec.clone();+        none["pours"][0]["net"] = serde_json::json!("VIN");+        assert!(super::pour_freshness(&none, &same, false)[0].starts_with("note: pour VIN pour (VIN F.Cu) is not on the board yet"));+        assert!(super::pour_freshness(&none, &same, true)[0].starts_with("WARNING stale pour: VIN pour"));+        // the plan itself is unchanged by the check+        let (parts, _) = super::plan(&serde_json::json!({"hot": {"D1": {"watts": 0.5, "tabNet": "GND"}}, "pours": spec["pours"].clone()}), &moved, &[]);+        assert_eq!(parts.len(), 1);+    }+    #[test]+    fn targets_scale_with_power() {+        assert_eq!(super::target(0.25, 10.0), 6);+        assert_eq!(super::target(0.05, 10.0), 6);+        assert_eq!(super::target(2.0, 10.0), 16);+    }+    #[test]+    fn planned_vias_clear_the_corners_of_turned_and_offset_pads() {+        // issue #74: a via 0.145 mm from a pad corner on a 0.15 mm board. Square pads turned 30 degrees+        // ring U1 (their corners reach past half their side), and Q1's pad sits off its footprint origin+        let ring: String = (0..8).map(|k| { let a = k as f64 * std::f64::consts::FRAC_PI_4; format!(r#"(footprint "y" (layer "F.Cu") (at {} {} 30) (property "Reference" "R{k}") (pad "1" smd rect (at 0 0 30) (size 1.6 1.6) (layers "F.Cu") (net 2 "SW")))"#, 10.0 + 3.3 * a.cos(), 10.0 + 3.3 * a.sin()) }).collect();+        let b = aiflow_board::Board::from_text("fixture", r#"(kicad_pcb (net 0 "") (net 1 "GND") (net 2 "SW") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(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")))+ {ring}+ (footprint "z" (layer "F.Cu") (at 10 12) (property "Reference" "Q1") (pad "1" smd custom (at 0 0) (size 0.4 0.4) (layers "F.Cu") (net 2 "SW")+   (primitives (gr_poly (pts (xy -0.2 0.3) (xy 0.6 0.3) (xy 0.6 0.9) (xy -0.2 0.9)) (width 0) (fill yes))))))"#.replace("{ring}", &ring)).unwrap();+        let spec = serde_json::json!({"hot": {"U1": {"watts": 2.0, "tabNet": "GND"}}, "clearance": 0.15,+            "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+        let (parts, _) = super::plan(&spec, &b, &[]);+        assert!(!parts[0].planned.is_empty(), "{:?} {:?}", parts[0].planned, parts[0].note);+        for c in &parts[0].planned {+            for q in b.pads.iter().filter(|q| q.net == "SW") {+                for poly in &q.polys {+                    let gap = (0..poly.len()).map(|k| super::seg_dist(*c, poly[k], poly[(k + 1) % poly.len()])).fold(f64::MAX, f64::min);+                    assert!(!super::point_in(poly, *c) && gap + 1e-6 >= 0.3 + 0.15, "via {c:?} is {gap:.3} mm from {}'s copper", q.key);+                }+            }+        }+    }+    #[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 acknowledged_parts_are_left_out_of_the_landing() {+        // issue #90: `thermal-vias land --ack U3` used to land U3's planned vias anyway+        let plan = serde_json::json!({"nets": [+            {"net": "GND", "ref": "U3", "why": "heatsink via for U3", "paths": []},+            {"net": "GND", "ref": "U3", "why": "heatsink via for U3", "paths": []},+            {"net": "VIN", "ref": "Q1", "why": "heatsink via for Q1", "paths": []},+            {"net": "GND", "why": "heatsink via for U7", "paths": []}]});+        let (kept, left) = super::without_acked(&plan, &["U3".into(), "U7".into()]);+        assert_eq!(kept["nets"].as_array().unwrap().len(), 1);+        assert_eq!(kept["nets"][0]["ref"], "Q1");+        assert_eq!(left.get("U3"), Some(&2));+        assert_eq!(left.get("U7"), Some(&1), "an older plan names the part only in why");+        let (all, none) = super::without_acked(&plan, &[]);+        assert_eq!(all, plan);+        assert!(none.is_empty());+        // the planner names the part on every entry+        let text = r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(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"))))"#;+        let b = aiflow_board::Board::from_text("fixture", text.into()).unwrap();+        let spec = serde_json::json!({"hot": {"U1": {"watts": 2.0, "tabNet": "GND"}}, "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"], "polygon": [[5,5],[15,5],[15,15],[5,15]]}]});+        let (_, p) = super::plan(&spec, &b, &[]);+        assert!(p["nets"].as_array().unwrap().iter().all(|e| e["ref"] == "U1"));+        assert!(super::without_acked(&p, &["U1".into()]).0["nets"].as_array().unwrap().is_empty());+    }+    #[test]+    fn heatsink_vias_keep_clear_of_labels_and_silk_graphics() {+        // issue #90: six vias under U3's own reference and value labels. U3 is a SOT-23-5 with its GND pin+        // at the origin; its reference sits above, its value below, and a silk line runs down its right side+        let board = |silk: &str| aiflow_board::Board::from_text("fixture", format!(r#"(kicad_pcb (net 0 "") (net 1 "GND") (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (gr_rect (start 0 0)(end 30 30)(layer "Edge.Cuts"))+ (footprint "SOT" (layer "F.Cu") (at 10 10) (property "Reference" "U3" (at 0 -2.2 0) (layer "F.SilkS") {silk} (effects (font (size 1 1) (thickness 0.15))))+   (property "Value" "TLV75533" (at 0 2.2 0) (layer "F.SilkS") {silk} (effects (font (size 1 1) (thickness 0.15))))+   (fp_line (start 1.8 -1.5) (end 1.8 1.5) (stroke (width 0.12) (type solid)) (layer "F.SilkS"))+   (pad "2" smd rect (at 0 0) (size 0.6 1.1) (layers "F.Cu") (net 1 "GND"))))"#)).unwrap();+        let spec = serde_json::json!({"hot": {"U3": {"watts": 0.085, "tabNet": "GND"}}, "pours": [{"net": "GND", "layers": ["F.Cu", "B.Cu"]}]});+        let shown = board("");+        let silk = aiflow_board::silk::silk_shapes(&shown);+        assert_eq!(silk.len(), 3, "{silk:?}");+        let (parts, _) = super::plan(&spec, &shown, &[]);+        assert!(!parts[0].planned.is_empty());+        for c in &parts[0].planned {+            for s in &silk { assert!(s.distance(*c) + 1e-6 >= 0.3 + 0.1, "via {c:?} is {:.3} mm from {}", s.distance(*c), s.what()); }+        }+        // hidden labels are no obstacle: the plan may use the spots they took+        let (hidden, _) = super::plan(&spec, &board("(hide yes)"), &[]);+        assert!(hidden[0].planned.len() >= parts[0].planned.len());+        // a wider silkClearance leaves fewer spots, and the note says why when the part comes up short+        let mut wide = spec.clone();+        wide["silkClearance"] = serde_json::json!(3.0);+        let (few, _) = super::plan(&wide, &shown, &[]);+        assert!(few[0].planned.len() < parts[0].planned.len());+        assert!(few[0].note.as_deref().unwrap_or("").contains("under silkscreen"), "{:?}", few[0].note);+    }+    #[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();+        let spec: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sp).unwrap()).unwrap();+        let (parts, plan) = super::plan(&spec, &b, &["SW".into()]);+        for p in parts { println!("PART {} {} target {} existing {} planned {} {:?}", p.reference, p.net, p.target, p.existing, p.planned.len(), p.note); }+        println!("ENTRIES {}", plan["nets"].as_array().unwrap().len());+    }+}
crates/aiflow-analyze/src/lead_cooling.rs+21−18
@@ -9,26 +9,28 @@ use serde_json::{json, Value}; /// The tabNet adom-fields reports for a part it modelled through its leads. pub const LEADS: &str = "leads"; -/// Whether `spec.hot.<reference>` declares lead cooling; Err for a contradictory declaration.+/// Whether `spec.hot.<reference>` is lead-cooled; Err for a contradictory declaration. A part with+/// no tabNet is lead-cooled, exactly as adom-fields models it (Matt's rule in #116: no extra key to+/// remember); `"cooling": "leads"` says the same thing explicitly. pub fn declared(reference: &str, h: &Value) -> Result<bool, String> {-    match &h["cooling"] {-        Value::Null => Ok(false),-        Value::String(s) if s == LEADS => {-            if !(h["tabNet"].is_null() || h["tabNet"] == "") {-                return Err(format!("{reference}: cooling \"leads\" is for a part with no exposed tab, but it also names tabNet {}: keep the tabNet if the part has an exposed pad, else remove it", h["tabNet"]));-            }-            if !h["tabPad"].is_null() { return Err(format!("{reference}: cooling \"leads\" has no tab, so tabPad does not apply")); }-            if !h["tjMaxC"].is_null() {-                let theta = h["thetaJcKPerW"].as_f64().filter(|v| v.is_finite() && *v > 0.0);-                let source = h["thetaSource"].as_str().filter(|s| !s.trim().is_empty());-                if theta.is_none() || source.is_none() || !h["tjMaxC"].as_f64().is_some_and(|v| v.is_finite()) {-                    return Err(format!("{reference}: a junction limit (tjMaxC) on a lead-cooled part needs thetaJcKPerW, the datasheet's junction-to-board figure for this package, and thetaSource naming where it came from"));-                }-            }-            Ok(true)+    let no_tab = h["tabNet"].is_null() || h["tabNet"] == "";+    let explicit = match &h["cooling"] {+        Value::Null => false,+        Value::String(s) if s == LEADS => true,+        other => return Err(format!("{reference}: cooling must be \"leads\" (a part with no exposed tab), not {other}")),+    };+    if !no_tab {+        return if explicit { Err(format!("{reference}: cooling \"leads\" is for a part with no exposed tab, but it also names tabNet {}: keep the tabNet if the part has an exposed pad, else remove it", h["tabNet"])) } else { Ok(false) };+    }+    if explicit && !h["tabPad"].is_null() { return Err(format!("{reference}: cooling \"leads\" has no tab, so tabPad does not apply")); }+    if !h["tjMaxC"].is_null() {+        let theta = h["thetaJcKPerW"].as_f64().filter(|v| v.is_finite() && *v > 0.0);+        let source = h["thetaSource"].as_str().filter(|s| !s.trim().is_empty());+        if theta.is_none() || source.is_none() || !h["tjMaxC"].as_f64().is_some_and(|v| v.is_finite()) {+            return Err(format!("{reference}: a junction limit (tjMaxC) on a lead-cooled part needs thetaJcKPerW, the datasheet's junction-to-board figure for this package, and thetaSource naming where it came from"));         }-        other => Err(format!("{reference}: cooling must be \"leads\" (a part with no exposed tab), not {other}")),     }+    Ok(true) }  /// The copper-estimate row for a declared lead-cooled part: pending until the Fields solve judges it.@@ -94,7 +96,8 @@ mod tests {      #[test]     fn declaration_is_explicit_and_consistent() {-        assert_eq!(declared("D1", &json!({"watts": 0.06})), Ok(false));+        assert_eq!(declared("D1", &json!({"watts": 0.06})), Ok(true), "no tabNet is lead-cooled, as Fields models it");+        assert_eq!(declared("Q1", &json!({"watts": 2, "tabNet": "VCC"})), Ok(false));         assert_eq!(declared("D1", &json!({"watts": 0.06, "cooling": "leads"})), Ok(true));         assert!(declared("D1", &json!({"watts": 0.06, "cooling": "leads", "tabNet": "GND"})).is_err());         assert!(declared("D1", &json!({"watts": 0.06, "cooling": "leads", "tabPad": "3"})).is_err());
crates/aiflow-analyze/src/lib.rs+6−2
@@ -506,8 +506,12 @@ mod field_screen_tests {     fn lead_cooled_sources_need_their_declaration_and_keep_every_limit() {         // issue #116: Fields reports a part with no tabNet as tabNet "leads"         let mut f=thermal_report();f["chips"]=json!([{"reference":"D10","watts":0.07,"tabNet":"leads","riseC":4.0,"riseMovingC":3.0}]);-        let undeclared=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07}}});-        assert!(field_screen(&f,&undeclared,b"board",b"spec","thermal").unwrap_err().contains("cooling \"leads\""));+        // Matt's rule (#116): no tabNet is lead-cooled, with or without the explicit key+        let implicit=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07}}});+        field_screen(&f,&implicit,b"board",b"spec","thermal").unwrap();+        for net in [Value::Null,json!(""),json!("GND")] { let mut bad=f.clone();bad["chips"][0]["tabNet"]=net; assert!(field_screen(&bad,&implicit,b"board",b"spec","thermal").is_err()); }+        let named=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07,"tabNet":"VCC"}}});+        assert!(field_screen(&f,&named,b"board",b"spec","thermal").is_err());         let spec=json!({"hotMaxRiseC":20,"hot":{"D10":{"watts":0.07,"cooling":"leads"}}});         field_screen(&f,&spec,b"board",b"spec","thermal").unwrap();         let mut hot=f.clone();hot["chips"][0]["riseC"]=json!(25.0);
docs/existing-pours.md+10
@@ -46,3 +46,13 @@ The gate is still required (DRC sees the zones), and measure, the current and th The landing-evidence slot records `mode: verified-existing` or `mode: adopted-existing`; neither claims the run landed the copper, and `finish`, `status` and the summary name the mode. Finish checks SHA-256 bindings to the board, spec and plan (a silk-only adoption is accepted only through its recorded copper carry; a zones-only carry never stands in for pours, gate, measurement or Fields evidence). Native DRC, live copper measurement, current and thermal acceptance remain separate requirements. Saved fill presence alone is not proof of native fill freshness, current capacity or thermal performance.  Do not replay `land pours` merely to restore dashboard evidence. That command is a mutation with native ownership rules. Preserve successful creation receipts and never infer ownership from names or geometry.+++### Native zones with multiple loops++For read-only KiCad verification, `polygons` may replace `polygon`: an array of native polygon loops, each an array of at least three finite `[x,y]` points. Preserve native loop and vertex ordering, including cutouts. Providing both forms, no loops, or malformed loops is refused. The verifier compares every loop exactly; it does not infer or repair topology.++This extension is verification-only. `land pours` refuses `polygons` before calling the single-polygon bridge, including keepout zones. Non-native keepout-clearance mode also refuses it because that check cannot represent holes. No ownership is granted by verification; native fill freshness, DRC and source-plan provenance remain separate checks.+++If several saved zones intentionally have identical geometry/settings, a verification-only `boardZoneUuid` may name the exact native item. Geometry, every setting, fill coverage and one-to-one full-board coverage still must match; the ID is only a disambiguator. Missing, empty, stale or repeated identities cannot waive a mismatch or omitted zone. Without an explicit identity, ambiguous matches remain refused. These plans cannot be passed to `land pours`.
docs/lead-cooled-parts.md+4−4
@@ -1,10 +1,10 @@ # Lead-cooled hot parts (issue #116) -Most parts that warm up have no exposed tab: LEDs, inductors, small leaded ICs. Their heat leaves through their leads into the copper they are soldered to. Declare such a part in `spec.hot` with `"cooling": "leads"` and no `tabNet`:+Most parts that warm up have no exposed tab: LEDs, inductors, small leaded ICs. Their heat leaves through their leads into the copper they are soldered to. List such a part in `spec.hot` with its watts and **no `tabNet`**; that is all (0.2.33, Matt's rule). `"cooling": "leads"` says the same thing explicitly and is optional:  ```json "hot": {-  "D10": {"watts": 0.07, "cooling": "leads"},+  "D10": {"watts": 0.07},   "L1":  {"watts": 0.12, "cooling": "leads", "maxRiseC": 20},   "U3":  {"watts": 0.30, "cooling": "leads", "tjMaxC": 125, "thetaJcKPerW": 60, "thetaSource": "datasheet p.6, psiJB"} }@@ -17,6 +17,6 @@ Most parts that warm up have no exposed tab: LEDs, inductors, small leaded ICs.   - It gets no heatsink-via target. - **Junction limit:** a `tjMaxC` on a lead-cooled part needs `thetaJcKPerW` (the datasheet's junction-to-board figure for the package) and `thetaSource`. The screen then checks ambient (`ambientC`, default 25 C) plus the solved rise plus thetaJcKPerW times watts against it. - **Refused:**-  - `cooling: "leads"` together with a `tabNet`, a `tabPad` or `packageCooling`;+  - an explicit `cooling: "leads"` together with a `tabNet`, a `tabPad` or `packageCooling`;   - a footprint with an exposed pad (with three or more pads, one at least three times the median of the others). Give that part its real `tabNet`.-- **Without the declaration:** a hot part with no `tabNet` still fails, and the message names both fixes: the tab net, or `cooling: "leads"`.+- **A part with an exposed tab** still needs its `tabNet`: the exposed-pad check above catches one that was left out.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.32",+  "version": "0.2.33",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.32",+  "version": "0.2.33",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
skills/adom-aiflow/SKILL.md+2−2
@@ -177,8 +177,8 @@ the library, simulation, current, thermal, fields and export, Fusion for everyth    trace. 10. **Heatsink vias.** `thermal-vias plan` (a target per hot part from spec.hot watts and the rise     budget, at least 6) and `thermal-vias land`. A hot part with no exposed tab (an LED, an-    inductor, a small leaded IC) takes `"cooling": "leads"` instead of a tabNet: no via target, and-    the Fields solve judges it (docs/lead-cooled-parts.md).+    inductor, a small leaded IC) is listed with its watts and no tabNet: it is lead-cooled, gets no via+    target, and the Fields solve judges it (docs/lead-cooled-parts.md). 11. **Pours**, then **rounded corners**: `outline round` (1.0 mm default, spec outline.cornerRadius). 12. **Current, heat and fields, in Hydrogen.** `analyze current`, `analyze thermal`, adom-fields in the     right pane with every part ghosted (`ui view --parts ghost --camera iso`, `tour`). A problem here is