← Commit history

Delete crates/adom-aiflow/src/thermalvias.rs.orig

John Lauer ·e6617478a0 · ·parent 73fcf19
1 file changed −515
crates/adom-aiflow/src/thermalvias.rs.origdeleted−515
@@ -1,515 +0,0 @@-//! 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());-    }-}