app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
← Commit history
Delete crates/adom-aiflow/src/thermalvias.rs.orig
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());- }-}