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.
main
Colby Knox
AI Flow 0.2.62: fixes #190 #191 #188 #189 #186 #187 and the #177 remote-model remainder
01b74f9
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
//! Pours from the spec, not from anyone's memory of one board: zones (with priorities and
//! connection styles), rule-area keepouts along Kelvin taps, thermal and stitching vias; and the
//! zones as `.kicad_pcb` blocks for the offline copy.
use aiflow_board::{geom, num, quote, Board, NetFormat, Pt};
use serde_json::{json, Value};
pub type Box4 = (f64, f64, f64, f64);
fn pad_box(board: &Board, keys: &[String]) -> Option<Box4> {
let mut pts = Vec::new();
for k in keys {
if let Some((r, n)) = k.split_once('.') {
for p in board.pads.iter().filter(|p| p.reference == r && p.name == n) {
pts.extend(p.polys.iter().flatten().copied());
}
} else {
for p in board.pads.iter().filter(|p| p.reference == *k) {
pts.extend(p.polys.iter().flatten().copied());
}
}
}
if pts.is_empty() { None } else { Some(geom::bbox(&pts)) }
}
fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> Value {
let r = |v: f64| (v * 100.0).round() / 100.0;
json!([[r(x0), r(y0)], [r(x1), r(y0)], [r(x1), r(y1)], [r(x0), r(y1)]])
}
fn strs(v: Option<&Value>) -> Vec<String> {
v.and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
/// Segments of every non-Kelvin path in a routing plan, with net and half width, for via avoidance.
fn plan_blockers(plan: &Value) -> Vec<(Pt, Pt, String, f64)> {
let mut out = Vec::new();
for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);
for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
let mut prev: Option<Pt> = None;
for wp in path.as_array().cloned().unwrap_or_default() {
let pt = if let Some(a) = wp.as_array() { if a.len() == 2 { Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))) } else { None } } else if let Some(o) = wp.as_object() { match (o.get("x").and_then(|v| v.as_f64()), o.get("y").and_then(|v| v.as_f64())) { (Some(x), Some(y)) => Some((x, y)), _ => None } } else { None };
match (wp.is_string(), pt) {
(true, _) => prev = None,
(false, Some(p)) => {
if let Some(q) = prev {
out.push((q, p, net.clone(), w / 2.0 + 0.2 + 0.45));
}
prev = Some(p);
}
_ => {}
}
}
}
}
out
}
/// `p` moved along the segment toward `other` until it is `stop` away from `c` (or onto `other`).
fn pull_back(p: Pt, other: Pt, c: Pt, stop: f64) -> Pt {
let (dx, dy) = (other.0 - p.0, other.1 - p.1);
let n = dx.hypot(dy);
if n < 1e-9 {
return p;
}
let mut t = 0.0;
while t < n {
t = (t + 0.02).min(n);
let q = (p.0 + dx * t / n, p.1 + dy * t / n);
if (q.0 - c.0).hypot(q.1 - c.1) >= stop {
return q;
}
}
other
}
fn clear(x: f64, y: f64, net: &str, blockers: &[(Pt, Pt, String, f64)]) -> bool {
!blockers.iter().any(|(a, b, n, need)| n != net && geom::seg_distance(x, y, a.0, a.1, b.0, b.1) < *need)
}
/// A hole on the board or already planned: (x, y, copper diameter, drill, net).
pub type Hole = (f64, f64, f64, f64, String);
/// The spec's hole-to-hole minimum (drill edge to drill edge), 0.2 mm when it names none.
pub fn hole_to_hole(spec: &Value) -> f64 {
spec.get("holeToHoleClearance").or_else(|| spec.get("holeToHole")).and_then(|v| v.as_f64()).unwrap_or(0.2)
}
/// The copper clearance a planned via keeps from other copper and from other vias: the spec's
/// `viaClearance`, else its `clearance`, else 0.2 mm (`with_project_rules` fills `viaClearance` from
/// the project's .kicad_dru when the spec names neither).
pub fn via_clearance(spec: &Value) -> f64 {
spec.get("viaClearance").or_else(|| spec.get("clearance")).and_then(|v| v.as_f64()).unwrap_or(0.2)
}
/// A `.kicad_dru` minimum (`0.1mm`, `4mil`, `0.1`) in mm.
fn dru_mm(v: &str) -> Option<f64> {
let v = v.trim();
if let Some(m) = v.strip_suffix("mil") { return m.trim().parse::<f64>().ok().map(|x| x * 0.0254); }
if let Some(m) = v.strip_suffix("in") { return m.trim().parse::<f64>().ok().map(|x| x * 25.4); }
v.trim_end_matches("mm").trim().parse::<f64>().ok()
}
/// The minimum a project's `.kicad_dru` sets for `constraint` between two vias: from the rules with no
/// condition or a condition that only names vias (`A.Type == 'Via'`), the LAST such rule in the file,
/// as KiCad lets a later rule override an earlier one. A rule with any other condition (pads, a net
/// class, an area) does not decide via-to-via spacing and is left out. None when no rule sets it.
pub fn dru_via_min(dru: &str, constraint: &str) -> Option<f64> {
let text: String = dru.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");
let root = aiflow_board::sx::parse(&format!("(dru {text})")).ok()?;
let mut found = None;
for rule in root.find_all("rule") {
if let Some(c) = rule.find("condition").and_then(|c| c.value()) {
let mut rest = c.to_ascii_lowercase().replace([' ', '"', '(', ')'], "");
for t in ["a.type=='via'", "b.type=='via'", "&&", "||"] { rest = rest.replace(t, ""); }
if !rest.is_empty() { continue; }
}
for k in rule.find_all("constraint").filter(|k| k.atom(1) == Some(constraint)) {
if let Some(v) = k.find("min").and_then(|m| m.atom(1)).and_then(dru_mm) { found = Some(v); }
}
}
found
}
/// The spec with the project's own via spacing filled in where it names none (issue #90): `viaClearance`
/// from the `.kicad_dru` beside `board_path` (its clearance rule) when the spec has neither
/// `viaClearance` nor `clearance`, and `holeToHoleClearance` from its hole_to_hole rule when the spec
/// has no hole-to-hole key. Without a rules file, or a rule, the defaults stay (0.2 mm each).
pub fn with_project_rules(spec: &Value, board_path: &str) -> Value {
let mut s = spec.clone();
if !s.is_object() { return s; }
let Ok(dru) = std::fs::read_to_string(std::path::Path::new(board_path).with_extension("kicad_dru")) else { return s };
if s.get("viaClearance").is_none() && s.get("clearance").is_none() {
if let Some(v) = dru_via_min(&dru, "clearance") { s["viaClearance"] = json!(v); s["viaClearanceFrom"] = json!("kicad_dru"); }
}
if s.get("holeToHoleClearance").is_none() && s.get("holeToHole").is_none() {
if let Some(v) = dru_via_min(&dru, "hole_to_hole") { s["holeToHoleClearance"] = json!(v); s["holeToHoleFrom"] = json!("kicad_dru"); }
}
s
}
/// The via size and drill a stitch via gets when its entry names no `size` and `drill`: the spec's
/// `viaSignal` (a size or [size, drill], with `drillSignal`), else the board's most common via, else
/// 0.6 / 0.3 mm. The third value says where it came from.
pub fn stitch_via_default(spec: &Value, board: &Board) -> (f64, f64, &'static str) {
let vs = spec.get("viaSignal");
let size = vs.and_then(|v| v.as_f64()).or_else(|| vs.and_then(|v| v.as_array()).and_then(|a| a.first()).and_then(|v| v.as_f64()));
if let Some(size) = size {
let drill = vs.and_then(|v| v.as_array()).and_then(|a| a.get(1)).and_then(|v| v.as_f64()).or_else(|| spec.get("drillSignal").and_then(|v| v.as_f64())).unwrap_or((size / 2.0).min(0.3));
return (size, drill, "spec viaSignal");
}
let mut counts: Vec<((i64, i64), usize)> = Vec::new();
for v in board.vias() {
let k = ((v.size * 1000.0).round() as i64, (v.drill * 1000.0).round() as i64);
match counts.iter_mut().find(|(c, _)| *c == k) { Some(e) => e.1 += 1, None => counts.push((k, 1)) }
}
if let Some(((s, d), _)) = counts.iter().max_by_key(|(_, n)| *n) {
return (*s as f64 / 1000.0, *d as f64 / 1000.0, "the board's most common via");
}
(0.6, 0.3, "default")
}
/// Every hole already on the board: its vias, its through-hole pads (copper to the pad's long half),
/// and its drilled holes with no copper.
pub fn board_holes(board: &Board) -> Vec<Hole> {
let mut out: Vec<Hole> = board.vias().into_iter().map(|v| (v.x, v.y, v.size, v.drill, v.net)).collect();
out.extend(board.pads.iter().filter(|p| p.thru).filter_map(|p| Some((p.x, p.y, 2.0 * p.half_long, p.drill?, p.net.clone()))));
// drilled holes with no copper (mask-only NPTH): the hole is its own extent, no net
out.extend(board.holes.iter().map(|h| (h.x, h.y, h.diameter(), h.diameter(), String::new())));
out
}
/// The vias of a via plan (`{nets: [{net, viaSize, viaDrill, paths: [[[x, y], ...]]}]}`, what
/// `thermal-vias plan` and this planner write) as holes.
pub fn plan_holes(plan: &Value) -> Vec<Hole> {
let mut out = Vec::new();
for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let (size, drill) = (e.get("viaSize").and_then(|v| v.as_f64()).unwrap_or(0.6), e.get("viaDrill").and_then(|v| v.as_f64()).unwrap_or(0.3));
for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
for wp in path.as_array().cloned().unwrap_or_default() {
// a layer-changing waypoint is where the via is
if let (Some(x), Some(y), true) = (wp.get("x").and_then(|v| v.as_f64()), wp.get("y").and_then(|v| v.as_f64()), wp.get("layer").is_some()) { out.push((x, y, size, drill, net.clone())); }
}
}
}
out
}
/// A via at (x, y) clear of every hole: drill edge to drill edge at least `h2h`, and copper to copper
/// at least `clearance` from another net's.
pub fn hole_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {
holes.iter().all(|(hx, hy, hs, hd, hn)| {
let d = (hx - x).hypot(hy - y);
d + 1e-6 >= (drill + hd) / 2.0 + h2h && (hn == net || d + 1e-6 >= (size + hs) / 2.0 + clearance)
})
}
/// `hole_clear`, and also copper to copper at least `clearance` from every via and hole whatever its
/// net: two vias of one net may not overlap or crowd each other either (a 0.8 mm via 0.6 mm from a
/// 0.5 mm in-pad via is a drill and copper clash KiCad's DRC reports; issue #83).
pub fn via_clear(x: f64, y: f64, size: f64, drill: f64, net: &str, holes: &[Hole], h2h: f64, clearance: f64) -> bool {
hole_clear(x, y, size, drill, net, holes, h2h, clearance)
&& holes.iter().all(|(hx, hy, hs, _, _)| (hx - x).hypot(hy - y) + 1e-6 >= (size + hs) / 2.0 + clearance)
}
/// The vias already at a pad: every via of `net` (any size), and every through-hole pad of `net` in
/// the same footprint (a footprint's own in-pad via pads), whose centre lies within the pad's copper
/// box grown by `grow` mm; the pad itself left out.
pub fn vias_at_pad(board: &Board, pad: &aiflow_board::Pad, net: &str, grow: f64) -> usize {
let pts: Vec<Pt> = pad.polys.iter().flatten().copied().collect();
let (x0, y0, x1, y1) = if pts.is_empty() { (pad.x - pad.half_long, pad.y - pad.half_long, pad.x + pad.half_long, pad.y + pad.half_long) } else { geom::bbox(&pts) };
let within = |x: f64, y: f64| x >= x0 - grow && x <= x1 + grow && y >= y0 - grow && y <= y1 + grow;
let vias = board.vias().into_iter().filter(|v| v.net == net && within(v.x, v.y)).count();
let thru = board.pads.iter().filter(|q| q.thru && q.drill.is_some() && q.net == net && q.reference == pad.reference && !std::ptr::eq(*q, pad) && within(q.x, q.y)).count();
vias + thru
}
/// The pad connection and island settings a pour may carry, so a reviewed plan can describe a zone
/// exactly: `connection` thermal (default), solid (or full) or none; `thermalGap` and
/// `thermalBridge` in mm; `islandRemoval` always (default), never or area, with `islandAreaMin` in mm2.
pub fn check_settings(z: &Value) -> Result<(), String> {
if !matches!(z.get("connection").map(|c| c.as_str()), None | Some(Some("thermal" | "solid" | "full" | "none"))) {
return Err(format!("connection {} is not thermal, solid or none", z["connection"]));
}
if !matches!(z.get("islandRemoval").map(|c| c.as_str()), None | Some(Some("always" | "never" | "area"))) {
return Err(format!("islandRemoval {} is not always, never or area", z["islandRemoval"]));
}
for key in ["thermalGap", "thermalBridge", "islandAreaMin", "clearance", "minThickness"] {
if z.get(key).is_some_and(|v| !v.as_f64().is_some_and(|x| x.is_finite() && x >= 0.0)) {
return Err(format!("{key} must be a number of mm (mm2 for islandAreaMin), zero or more"));
}
}
if z.get("islandAreaMin").is_some() && z.get("islandRemoval").and_then(|v| v.as_str()) != Some("area") {
return Err("islandAreaMin applies only with islandRemoval area".into());
}
Ok(())
}
/// (zones in the kicad_add_zone shape, via plan entries, keepout count) from the spec and the routing plan.
pub fn build(board: &Board, spec: &Value, plan: Option<&Value>) -> Result<(Vec<Value>, Vec<Value>, usize), String> { build_with(board, spec, plan, &[]) }
/// `build`, with vias planned elsewhere and not on the board yet (the heatsink vias) as obstacles: every
/// via this plans keeps the hole-to-hole minimum and the clearance from those, from the board's own vias
/// and through-hole pads, and from each other.
pub fn build_with(board: &Board, spec: &Value, plan: Option<&Value>, planned: &[Hole]) -> Result<(Vec<Value>, Vec<Value>, usize), String> {
let (x0, y0, x1, y1) = board.bbox;
let edge = spec.get("edgeClearance").and_then(|v| v.as_f64()).unwrap_or(0.5);
let clearance = spec.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);
let mut zones: Vec<Value> = Vec::new();
// issue #189: a net-less keepout in spec.pours ({"keepout": {"tracks": true, ...}, "polygon": [...], "layers": [...]})
// goes into the plan as the rule area it is, so the plan owns the human's keepouts
let mut spec_keepouts: Vec<Value> = Vec::new();
for e in spec.get("pours").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
if e.get("net").is_none() && e.get("keepout").is_some() {
let poly = e.get("polygon").cloned().ok_or("a keepout in spec.pours needs a polygon ([[x, y], ...])")?;
if poly.as_array().map(|a| a.len()).unwrap_or(0) < 3 { return Err("a keepout in spec.pours needs a polygon of three or more points".into()); }
let layers = e.get("layers").cloned().unwrap_or(json!(["F.Cu", "B.Cu"]));
let name = e.get("name").and_then(|n| n.as_str()).map(str::to_string).unwrap_or_else(|| format!("keepout {}", spec_keepouts.len() + 1));
let k = e["keepout"].clone();
let k = if k.is_object() { k } else { json!({"tracks": true, "vias": true, "copperPour": true}) };
let k = { let mut k = k; if let Some(v) = k.get("pours").cloned() { k["copperPour"] = v; k.as_object_mut().unwrap().remove("pours"); } k };
spec_keepouts.push(json!({"type": "keepout", "layers": layers, "polygon": poly, "keepout": k, "name": name}));
continue;
}
let named = e.get("net").and_then(|n| n.as_str()).ok_or("pour needs net")?.to_string();
// `GND` in the spec is the board's `/GND` when that is the only match (exact names win)
let Some(net) = aiflow_board::netname::resolve(&named, board.nets.keys()) else {
let near = aiflow_board::netname::close(&named, board.nets.keys());
return Err(if near.is_empty() { format!("pour {named}: the board has no pads on that net") } else { format!("pour {named}: the board has no pads on that net; the board has {} (name that in the spec)", near.join(", ")) });
};
let mut z = serde_json::Map::new();
for (k, v) in e.as_object().unwrap() {
if !matches!(k.as_str(), "around" | "margin" | "exclude" | "polygon" | "outline" | "inset") {
z.insert(k.clone(), v.clone());
}
}
z.insert("net".into(), json!(net));
z.entry("clearance").or_insert(json!(clearance));
z.entry("minThickness").or_insert(json!(0.25));
if e.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {
z.insert("outline".into(), json!(true));
z.insert("inset".into(), json!(e.get("inset").and_then(|v| v.as_f64()).unwrap_or(edge)));
} else if let Some(p) = e.get("polygon") {
z.insert("polygon".into(), p.clone());
} else if e.get("around").is_some() {
let keys = strs(e.get("around"));
let bx = pad_box(board, &keys).ok_or_else(|| format!("pour {net}: none of {keys:?} found on the board"))?;
let m = e.get("margin").and_then(|v| v.as_f64()).unwrap_or(0.8);
let mut b = ((bx.0 - m).max(x0 + edge), (bx.1 - m).max(y0 + edge), (bx.2 + m).min(x1 - edge), (bx.3 + m).min(y1 - edge));
for ex in strs(e.get("exclude")) {
if let Some(xb) = pad_box(board, &[ex.clone()]) {
if !(xb.2 < b.0 || xb.0 > b.2 || xb.3 < b.1 || xb.1 > b.3) {
// clip on the side that loses the least
let cuts = [(0usize, xb.2 + 1.0, (xb.2 + 1.0) - b.0), (1, xb.3 + 1.0, (xb.3 + 1.0) - b.1), (2, xb.0 - 1.0, b.2 - (xb.0 - 1.0)), (3, xb.1 - 1.0, b.3 - (xb.1 - 1.0))];
let (side, val, _) = cuts.iter().min_by(|a, c| a.2.partial_cmp(&c.2).unwrap()).unwrap();
match side { 0 => b.0 = *val, 1 => b.1 = *val, 2 => b.2 = *val, _ => b.3 = *val }
}
}
}
z.insert("polygon".into(), rect(b.0, b.1, b.2, b.3));
} else {
return Err(format!("pour {net} needs outline, polygon or around"));
}
z.entry("name").or_insert(json!(format!("{net} pour")));
check_settings(&Value::Object(z.clone())).map_err(|e| format!("pour {net}: {e}"))?;
zones.push(Value::Object(z));
}
for key in strs(spec.get("solidAt")) {
if let Some(p) = board.pad_by_key(&key) {
let layers: Vec<&str> = if p.thru { vec!["F.Cu", "B.Cu"] } else if p.on_f { vec!["F.Cu"] } else { vec!["B.Cu"] };
let r = if p.thru { p.half_long.max(p.half_short) + 0.6 } else { 0.9 };
zones.push(json!({"net": p.net, "layers": layers, "polygon": rect(p.x - r, p.y - r, p.x + r, p.y + r), "priority": 1, "connection": "solid", "clearance": clearance, "minThickness": 0.25, "name": format!("{} solid joint at {key}", p.net)}));
}
}
let blockers = plan.map(plan_blockers).unwrap_or_default();
let h2h = hole_to_hole(spec);
// via to other copper and to other vias: the spec's viaClearance (or the project's .kicad_dru
// through with_project_rules), else its clearance; zones keep `clearance`
let vclr = via_clearance(spec);
// a via ringed outside a small pin keeps clear of the silkscreen (issue #90): text and graphics
let silk = aiflow_board::silk::silk_shapes(board);
let silk_gap = spec.get("silkClearance").and_then(|v| v.as_f64()).unwrap_or(0.1);
let mut holes = board_holes(board);
holes.extend(planned.iter().cloned());
let mut vias: Vec<Value> = Vec::new();
let via_entry = |net: &str, x: f64, y: f64, size: f64, drill: f64| json!({"net": net, "width": 0.5, "viaSize": size, "viaDrill": drill, "paths": [[[x, y], {"x": x, "y": y, "layer": "B.Cu"}]]});
// issue #183: a board with no vias (spec "vias": false, one routed face, or the project's .kicad_dru)
// gets no thermal or stitching vias
let no_vias = spec.get("vias").and_then(|v| v.as_bool()) == Some(false);
let via_asks = spec.get("thermalVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0) + spec.get("stitchVias").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0);
if no_vias && via_asks > 0 { eprintln!("pours: this board has no vias (single-face or vias off): the spec's {via_asks} thermalVias/stitchVias entr{} left out", if via_asks == 1 { "y is" } else { "ies are" }); }
let empty: Vec<Value> = Vec::new();
for tv in if no_vias { empty.clone() } else { spec.get("thermalVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() } {
let Some(key) = tv.get("pad").and_then(|p| p.as_str()) else { continue };
let Some(p) = board.pad_by_key(key) else { continue };
// a footprint can repeat a pad name (an exposed pad and its own in-pad via pads all "29"):
// the heat pad is the largest copper of that name
let p = board.pads.iter().filter(|q| q.reference == p.reference && q.name == p.name).max_by(|a, b| (a.half_long * a.half_short).partial_cmp(&(b.half_long * b.half_short)).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or(p);
let net = tv.get("net").and_then(|n| n.as_str()).unwrap_or(&p.net).to_string();
let want = tv.get("count").and_then(|c| c.as_u64()).unwrap_or(2) as usize;
let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.8), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.4));
let fits_inside = p.half_long - 0.4 > 0.0 && p.half_short - 0.4 > 0.0 && size <= 2.0 * p.half_short;
// the pad's vias already on the board (any size) count toward `count`, as `thermal-vias plan`
// counts them: in the pad for a pad that takes vias, on the ring just outside it for a small pin
let existing = vias_at_pad(board, p, &net, if fits_inside { 0.0 } else { 0.25 + 0.6 + 3.0 * 0.35 });
let n = want.saturating_sub(existing);
if existing > 0 { eprintln!("thermal vias at {key}: {existing} via(s) of {net} already at the pad count toward {want}; planning {n} more"); }
let mut placed = 0;
if fits_inside {
for (dx, dy) in [(-1.2, 0.0), (1.2, 0.0), (-1.2, -1.3), (1.2, -1.3), (-1.2, 1.3), (1.2, 1.3), (0.0, -1.3), (0.0, 1.3), (0.0, 0.0)] {
if placed >= n {
break;
}
let inside = (dx as f64).abs() < p.half_long - 0.4 && (dy as f64).abs() < p.half_short - 0.4;
if !inside && (dx, dy) != (0.0, 0.0) {
continue;
}
let (x, y) = (((p.x + dx) * 100.0).round() / 100.0, ((p.y + dy) * 100.0).round() / 100.0);
if clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) {
vias.push(via_entry(&net, x, y, size, drill));
holes.push((x, y, size, drill, net.clone()));
placed += 1;
}
}
} else {
// a small pin (SOT-23, 0603...) has no room for a via: an open via in the pad wicks the
// solder away. Put small vias in the pour just outside the pad instead, on a ring of
// candidates nearest first, clear of other nets' pads and planned copper and of each other.
let (size, drill) = (tv.get("size").and_then(|v| v.as_f64()).unwrap_or(0.6).min(0.6), tv.get("drill").and_then(|v| v.as_f64()).unwrap_or(0.3).min(0.3));
let vr = size / 2.0;
let mut cands: Vec<Pt> = Vec::new();
for ring in 0..4 {
let off = 0.25 + vr + ring as f64 * 0.35;
for k in 0..16 {
let t = k as f64 * std::f64::consts::PI / 8.0;
let (ex, ey) = (t.cos(), t.sin());
// edge of the pad's box along this direction, in board axes
let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };
let reach = if ex.abs() * hy > ey.abs() * hx { hx / ex.abs().max(1e-9) } else { hy / ey.abs().max(1e-9) };
cands.push((p.x + ex * (reach + off), p.y + ey * (reach + off)));
}
}
let (bx0, by0, bx1, by1) = board.bbox;
let mut under_silk = 0;
// keep clear of the spec's stitch vias and any via already on the board, not only our own
let mut mine: Vec<Pt> = spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default().iter()
.filter_map(|v| v.get("x").and_then(|x| x.as_f64()).zip(v.get("y").and_then(|y| y.as_f64()))).collect();
mine.extend(board.root.find_all("via").filter_map(|v| { let at = v.find("at")?; Some((at.number(1)?, at.number(2)?)) }));
for (cx, cy) in cands {
if placed >= n {
break;
}
let (x, y) = ((cx * 100.0).round() / 100.0, (cy * 100.0).round() / 100.0);
if x - vr < bx0 + edge || x + vr > bx1 - edge || y - vr < by0 + edge || y + vr > by1 - edge {
continue;
}
let near_other_pad = board.pads.iter().any(|q| q.net != net && q.polys.iter().flatten().count() > 0 && {
let (qx0, qy0, qx1, qy1) = geom::bbox(&q.polys.iter().flatten().copied().collect::<Vec<_>>());
let (nx, ny) = (x.clamp(qx0, qx1), y.clamp(qy0, qy1));
(x - nx).hypot(y - ny) < vr + clearance + 0.1
});
let near_own_pad = board.pads.iter().any(|q| q.polys.iter().flatten().count() > 0 && {
let (qx0, qy0, qx1, qy1) = geom::bbox(&q.polys.iter().flatten().copied().collect::<Vec<_>>());
let (nx, ny) = (x.clamp(qx0, qx1), y.clamp(qy0, qy1));
(x - nx).hypot(y - ny) < vr + 0.1
});
let near_mine = mine.iter().any(|m| (m.0 - x).hypot(m.1 - y) < size + 0.3);
if aiflow_board::silk::near(&silk, (x, y), vr + silk_gap).is_some() { under_silk += 1; continue; }
if !near_other_pad && !near_own_pad && !near_mine && clear(x, y, &net, &blockers) && via_clear(x, y, size, drill, &net, &holes, h2h, vclr) {
vias.push(via_entry(&net, x, y, size, drill));
mine.push((x, y)); holes.push((x, y, size, drill, net.clone()));
placed += 1;
}
}
if placed < n && under_silk > 0 { eprintln!("thermal vias at {key}: {under_silk} spot(s) left out under silkscreen text or graphics (spec silkClearance {silk_gap} mm); move the label or accept fewer"); }
}
if placed < n {
eprintln!("thermal vias at {key}: {} of {want} ({existing} already there, {placed} planned); no more room clear of other nets and of the vias and holes already there (add stitchVias by hand with their own size and drill, or accept fewer)", existing + placed);
}
}
// a stitch via with no size and drill takes the spec's signal via, else the board's own (issue #90)
let (dsize, ddrill, dfrom) = stitch_via_default(spec, board);
for sv in if no_vias { empty.clone() } else { spec.get("stitchVias").and_then(|v| v.as_array()).cloned().unwrap_or_default() } {
let (Some(net), Some(x), Some(y)) = (sv.get("net").and_then(|n| n.as_str()), sv.get("x").and_then(|v| v.as_f64()), sv.get("y").and_then(|v| v.as_f64())) else { continue };
let (size, drill) = (sv.get("size").and_then(|v| v.as_f64()).unwrap_or(dsize), sv.get("drill").and_then(|v| v.as_f64()).unwrap_or(ddrill));
let sized = if sv.get("size").is_some() { String::new() } else { format!(" ({size}/{drill} mm from {dfrom}; give the entry \"size\" and \"drill\" in mm for another via)") };
// a stitch via on top of a heatsink via (or any hole) is a drill hit: left out, said why
if !via_clear(x, y, size, drill, net, &holes, h2h, vclr) { eprintln!("stitch via {net} at ({x:.2}, {y:.2}) left out: within the hole-to-hole minimum ({h2h} mm) or the {vclr} mm clearance of a via or hole already there or planned{sized}"); continue; }
if clear(x, y, net, &blockers) {
vias.push(via_entry(net, x, y, size, drill));
holes.push((x, y, size, drill, net.to_string()));
}
}
let mut keepouts = spec_keepouts.len();
zones.extend(spec_keepouts);
// the sense pad of every Kelvin pair (spec.kelvin: {"U6.3": "R32.2"}) gets its own keepout: a
// pour on the same net must never touch it, or the tap measures the pour's drop instead of the
// shunt's (and KiCad's DRC reports the starved thermal relief the keepout along the tap leaves)
for (src, _dst) in spec.get("kelvin").and_then(|k| k.as_object()).cloned().unwrap_or_default() {
if let Some(p) = board.pad_by_key(&src) {
let m = clearance + 0.15;
let (hx, hy) = if p.long_axis.0.abs() >= p.long_axis.1.abs() { (p.half_long, p.half_short) } else { (p.half_short, p.half_long) };
zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(p.x - hx - m, p.y - hy - m, p.x + hx + m, p.y + hy + m), "keepout": {"copperPour": true}, "name": format!("Kelvin sense pad keepout {} ({src})", p.net)}));
keepouts += 1;
}
}
// the power pins the taps land on (spec.kelvin values), with the radius a keepout must stay out of
let kelvin_power_pads: Vec<(f64, f64, f64)> = spec.get("kelvin").and_then(|k| k.as_object()).map(|m| m.values().filter_map(|v| v.as_str()).filter_map(|k| board.pad_by_key(k)).map(|p| (p.x, p.y, p.half_long.max(p.half_short))).collect()).unwrap_or_default();
// every keepout gets a unique name: KiCad Bridge's zone plan refuses duplicates
let mut tap_count: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
if let (Some(plan), true) = (plan, spec.get("kelvin").map(|k| !k.is_null()).unwrap_or(false)) {
for e in plan.get("nets").and_then(|n| n.as_array()).cloned().unwrap_or_default() {
if !e.get("kelvin").and_then(|k| k.as_bool()).unwrap_or(false) {
continue;
}
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
let w = e.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25) / 2.0 + 0.2 + 0.1;
let via_r = e.get("viaSize").and_then(|v| v.as_f64()).unwrap_or(0.8) / 2.0 + 0.2 + 0.1;
let r = |v: f64| (v * 1000.0).round() / 1000.0;
for path in e.get("paths").and_then(|p| p.as_array()).cloned().unwrap_or_default() {
// walk the path on its own layers: it starts on its first pad's layer, and a
// {x, y, layer} waypoint is a via onto that layer. A keepout goes on the layer its
// sense segment runs on (not both), so the other layer's plane stays whole.
let wps = path.as_array().cloned().unwrap_or_default();
let mut layer = wps.first().and_then(|w| w.as_str()).and_then(|k| board.pad_by_key(k)).map(|p| if p.on_f { "F.Cu" } else { "B.Cu" }).unwrap_or("F.Cu").to_string();
let mut prev: Option<Pt> = None;
for wp in &wps {
let (pt, via_to) = if let Some(a) = wp.as_array() {
(if a.len() == 2 { a[0].as_f64().zip(a[1].as_f64()) } else { None }, None)
} else if let Some(o) = wp.as_object() {
(o.get("x").and_then(|v| v.as_f64()).zip(o.get("y").and_then(|v| v.as_f64())), o.get("layer").and_then(|l| l.as_str()).map(str::to_string))
} else if let Some(k) = wp.as_str() {
(board.pad_by_key(k).map(|p| (p.x, p.y)), None)
} else {
(None, None)
};
let Some(b) = pt else { continue };
if let Some(a) = prev {
// stop short of the power pin the tap lands on: that pin must stay joined to
// its pour (it is usually the part's heat path), only the tap is fenced
let (mut a, mut bb) = (a, b);
for d in &kelvin_power_pads {
let stop = d.2 + clearance + 0.3;
if (a.0 - d.0).hypot(a.1 - d.1) < stop { a = pull_back(a, bb, (d.0, d.1), stop); }
if (bb.0 - d.0).hypot(bb.1 - d.1) < stop { bb = pull_back(bb, a, (d.0, d.1), stop); }
}
let ((ax, ay), (bx, by)) = (a, bb);
let (dx, dy) = (bx - ax, by - ay);
let n = dx.hypot(dy);
if n >= 0.05 {
let (ux, uy) = (dx / n, dy / n);
let poly = json!([[r(ax - uy * w), r(ay + ux * w)], [r(bx - uy * w), r(by + ux * w)], [r(bx + uy * w), r(by - ux * w)], [r(ax + uy * w), r(ay - ux * w)]]);
let k = tap_count.entry(net.clone()).or_insert(0);
*k += 1;
zones.push(json!({"type": "keepout", "layers": [layer.clone()], "polygon": poly, "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net} #{k}")}));
keepouts += 1;
}
}
if let Some(to) = via_to {
// a via is copper on both layers: fence it on both, or a same-net plane joins the tap there
let k = tap_count.entry(net.clone()).or_insert(0);
*k += 1;
zones.push(json!({"type": "keepout", "layers": ["F.Cu", "B.Cu"], "polygon": rect(b.0 - via_r, b.1 - via_r, b.0 + via_r, b.1 + via_r), "keepout": {"copperPour": true}, "name": format!("Kelvin tap keepout {net} #{k} (via)")}));
keepouts += 1;
layer = to;
}
prev = Some(b);
}
}
}
}
Ok((zones, vias, keepouts))
}
/// Shrink an outline by d: every edge moved d inward along its normal, each vertex where its two moved
/// edges meet, so straight edges and the short steps of a rounded corner's arc all keep d from the board
/// edge (a pull toward the centroid kept only d times the cosine off a long edge). A spike the meeting
/// point would make at a sharp inner corner is held to the bisector at d.
pub fn inset_polygon(poly: &[Pt], d: f64) -> Vec<Pt> {
let mut p: Vec<Pt> = Vec::new();
for &q in poly { if p.last().map(|l: &Pt| (l.0 - q.0).hypot(l.1 - q.1) > 1e-9).unwrap_or(true) { p.push(q); } }
while p.len() > 1 && (p[0].0 - p[p.len() - 1].0).hypot(p[0].1 - p[p.len() - 1].1) < 1e-9 { p.pop(); }
let n = p.len();
if d <= 0.0 || n < 3 {
return poly.to_vec();
}
let s = if geom::polygon_area(&p) >= 0.0 { 1.0 } else { -1.0 };
// the inward unit normal of edge i (p[i] -> p[i+1])
let nrm = |i: usize| { let (a, b) = (p[i], p[(i + 1) % n]); let l = (b.0 - a.0).hypot(b.1 - a.1).max(1e-12); (-(b.1 - a.1) / l * s, (b.0 - a.0) / l * s) };
(0..n).map(|i| {
let (n0, n1) = (nrm((i + n - 1) % n), nrm(i));
let (bx, by) = (n0.0 + n1.0, n0.1 + n1.1);
let k = 1.0 + n0.0 * n1.0 + n0.1 * n1.1; // 2 cos^2 of half the turn
if k < 0.25 { let l = bx.hypot(by).max(1e-12); return (p[i].0 + bx / l * d, p[i].1 + by / l * d); }
(p[i].0 + bx / k * d, p[i].1 + by / k * d)
}).collect()
}
/// Resolve `outline: true` zones to explicit polygons (the live verb takes points).
pub fn resolve_outline(board: &Board, zones: &[Value]) -> Vec<Value> {
let mut outline = board.outline.clone();
if geom::polygon_area(&outline) < 0.0 {
outline.reverse();
}
zones.iter().map(|z| {
if z.get("outline").and_then(|v| v.as_bool()).unwrap_or(false) {
let inset = z.get("inset").and_then(|v| v.as_f64()).unwrap_or(0.5);
let mut o = z.as_object().unwrap().clone();
o.remove("outline");
o.remove("inset");
let r = |v: f64| (v * 1000.0).round() / 1000.0;
o.insert("polygon".into(), json!(inset_polygon(&outline, inset).iter().map(|(x, y)| json!([r(*x), r(*y)])).collect::<Vec<_>>()));
Value::Object(o)
} else {
z.clone()
}
}).collect()
}
fn poly_of(z: &Value) -> Vec<Pt> {
z.get("polygon").and_then(|p| p.as_array()).map(|a| a.iter().filter_map(|pt| { let q = pt.as_array()?; Some((q.first()?.as_f64()?, q.get(1)?.as_f64()?)) }).collect()).unwrap_or_default()
}
/// The zone (copper pour or keepout) as a `.kicad_pcb` block, the way the bridge writes it.
pub fn zone_sexpr(board: &Board, z: &Value) -> Result<String, String> {
let layers: Vec<String> = strs(z.get("layers"));
let layer_line = if layers.len() == 1 { format!("\t\t(layer \"{}\")\n", layers[0]) } else { format!("\t\t(layers {})\n", layers.iter().map(|l| format!("\"{l}\"")).collect::<Vec<_>>().join(" ")) };
let pts: Vec<String> = poly_of(z).iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
if pts.len() < 3 {
return Err("zone needs a polygon with three or more points".into());
}
let name = z.get("name").and_then(|n| n.as_str()).unwrap_or("zone");
let uuid = format!("bf{:016x}-{:04x}-4000-8000-{:012x}", pts.len() as u64 * 7919 + name.len() as u64, layers.len(), std::process::id());
let keepout = z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some();
if keepout {
let k = z.get("keepout").cloned().unwrap_or(json!({"copperPour": true}));
let f = |n: &str| if k.get(n).and_then(|b| b.as_bool()).unwrap_or(false) { "not_allowed" } else { "allowed" };
return Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(keepout\n\t\t\t(tracks {})\n\t\t\t(vias {})\n\t\t\t(pads {})\n\t\t\t(copperpour {})\n\t\t\t(footprints {})\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",
if board.net_format == NetFormat::Number { "\t\t(net 0)\n\t\t(net_name \"\")\n" } else { "" }, layer_line, uuid, quote(name), f("tracks"), f("vias"), f("pads"), f("copperPour"), f("footprints"), pts.join(" ")));
}
let net = z.get("net").and_then(|n| n.as_str()).ok_or("copper zone needs net")?;
let net_lines = match board.net_format {
NetFormat::Number => format!("{}\t\t(net_name {})\n", board.net_ref_sexpr(net)?, quote(net)),
NetFormat::Name => board.net_ref_sexpr(net)?,
};
let connect = match z.get("connection").and_then(|c| c.as_str()) {
None | Some("thermal") => "",
Some("solid") | Some("full") => " yes",
Some("none") => " no",
Some(other) => return Err(format!("zone connection {other:?}: use thermal, solid or none")),
};
// KiCad's island removal: 0 always (the default, not written), 1 never, 2 below an area
let island = match z.get("islandRemoval").and_then(|c| c.as_str()) {
None | Some("always") => String::new(),
Some("never") => "\t\t\t(island_removal_mode 1)\n".to_string(),
Some("area") => format!("\t\t\t(island_removal_mode 2)\n\t\t\t(island_area_min {})\n", num(z.get("islandAreaMin").and_then(|v| v.as_f64()).unwrap_or(10.0))),
Some(other) => return Err(format!("zone islandRemoval {other:?}: use always, never or area")),
};
let prio = z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0);
let clearance = z.get("clearance").and_then(|v| v.as_f64()).unwrap_or(0.2);
let min_t = z.get("minThickness").and_then(|v| v.as_f64()).unwrap_or(0.25);
let gap = z.get("thermalGap").and_then(|v| v.as_f64()).unwrap_or(0.5);
let bridge = z.get("thermalBridge").and_then(|v| v.as_f64()).unwrap_or(0.5);
Ok(format!("\t(zone\n{}{}\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n{}{}\t\t(min_thickness {})\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap {})\n\t\t\t(thermal_bridge_width {})\n{}\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n\t)",
net_lines, layer_line, uuid, quote(name), if prio > 0 { format!("\t\t(priority {prio})\n") } else { String::new() },
format!("\t\t(connect_pads{connect}\n\t\t\t(clearance {})\n\t\t)\n", num(clearance)),
num(min_t), num(gap), num(bridge), island, pts.join(" ")))
}
/// Summary line per zone for the human.
pub fn describe(z: &Value) -> String {
let layers = strs(z.get("layers")).join("+");
if z.get("type").and_then(|t| t.as_str()) == Some("keepout") || z.get("keepout").is_some() {
return format!("keepout {layers:<12} {}", z.get("name").and_then(|n| n.as_str()).unwrap_or(""));
}
let poly = poly_of(z);
format!("{:<10} {layers:<12} prio {} {} {}", z.get("net").and_then(|n| n.as_str()).unwrap_or("?"), z.get("priority").and_then(|p| p.as_u64()).unwrap_or(0), z.get("connection").and_then(|c| c.as_str()).unwrap_or("thermal"), if z.get("outline").is_some() { "outline".to_string() } else { format!("{:.0} mm2", geom::polygon_area(&poly).abs()) })
}
/// Declared planes must be real copper zones before routing treats their pads as connected by stubs.
pub fn missing_planes(board: &Board, spec: &Value) -> Vec<String> {
let mut missing=Vec::new();
for (net, layer) in spec["planes"].as_object().into_iter().flatten() {
let Some(layer)=layer.as_str() else { missing.push(format!("{net}: plane layer must be a string")); continue };
let found=board.root.find_all("zone").any(|z| {
if z.find("keepout").is_some(){return false}
let zn=z.find("net_name").and_then(|n|n.value()).map(str::to_string).or_else(||z.find("net").and_then(|n|n.value()).map(|n|n.parse::<i64>().ok().and_then(|i|board.net_table.get(&i).cloned()).unwrap_or_else(||n.into())));
let layers=z.find("layers").or_else(||z.find("layer"));
let on_layer=layers.map(|n|(1..=n.items.len()).any(|i|n.atom(i)==Some(layer))).unwrap_or(false);
zn.as_deref()==Some(net.as_str()) && on_layer && z.find("polygon").is_some()
});
if !found{missing.push(format!("{net} on {layer}"))}
}
missing
}
#[cfg(test)]
mod hole_tests {
use super::*;
#[test]
fn inset_keeps_d_from_every_edge_of_a_rounded_outline() {
// a rounded 30 x 20 outline (1 mm arcs) as the board reads it: every inset vertex d from the outline
let b = Board::from_text("fixture", "(kicad_pcb (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_line (start 1 0) (end 29 0) (layer \"Edge.Cuts\")) (gr_arc (start 29 0) (mid 29.7071 0.2929) (end 30 1) (layer \"Edge.Cuts\")) (gr_line (start 30 1) (end 30 19) (layer \"Edge.Cuts\")) (gr_arc (start 30 19) (mid 29.7071 19.7071) (end 29 20) (layer \"Edge.Cuts\")) (gr_line (start 29 20) (end 1 20) (layer \"Edge.Cuts\")) (gr_arc (start 1 20) (mid 0.2929 19.7071) (end 0 19) (layer \"Edge.Cuts\")) (gr_line (start 0 19) (end 0 1) (layer \"Edge.Cuts\")) (gr_arc (start 0 1) (mid 0.2929 0.2929) (end 1 0) (layer \"Edge.Cuts\")))".into()).unwrap();
for o in [b.outline.clone(), b.outline.iter().rev().cloned().collect::<Vec<_>>()] {
let ins = inset_polygon(&o, 0.3);
let n = o.len();
for q in &ins {
assert!(geom::point_in_poly(q.0, q.1, &o), "{q:?} outside");
let dmin = (0..n).map(|k| geom::seg_distance(q.0, q.1, o[k].0, o[k].1, o[(k + 1) % n].0, o[(k + 1) % n].1)).fold(f64::MAX, f64::min);
assert!(dmin > 0.3 - 0.01 && dmin < 0.3 + 0.02, "{q:?} is {dmin} from the edge");
}
}
}
#[test]
fn stitch_vias_keep_clear_of_board_and_planned_holes() {
let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (via (at 5 5) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net 1)))".into()).unwrap();
// the heatsink plan's via at (10, 10); stitch vias on top of each, a near miss, and one well clear
let tv = json!({"nets": [{"net": "GND", "viaSize": 0.6, "viaDrill": 0.3, "paths": [[[10.0, 10.0], {"layer": "B.Cu", "x": 10.0, "y": 10.0}]]}]});
let planned = plan_holes(&tv);
assert_eq!(planned.len(), 1);
let spec = json!({"stitchVias": [{"net": "GND", "x": 5.0, "y": 5.0}, {"net": "GND", "x": 10.05, "y": 10.0}, {"net": "GND", "x": 10.5, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});
let (_, vias, _) = build_with(&b, &spec, None, &planned).unwrap();
let at: Vec<(f64, f64)> = vias.iter().map(|v| (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap())).collect();
// no size given: the board's own 0.6/0.3 via (issue #90); copper to copper needs 0.6 + 0.2, so 10.5 (0.5 away) is out too
assert_eq!(at, vec![(15.0, 15.0)]);
assert!(hole_clear(10.6, 10.0, 0.8, 0.4, "GND", &planned, 0.2, 0.2));
assert!(!hole_clear(10.6, 10.0, 0.8, 0.4, "VIN", &planned, 0.2, 0.2), "another net also needs copper clearance");
}
#[test]
fn pour_settings_reach_the_zone_block() {
let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"R\" (layer \"F.Cu\") (at 5 5) (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();
let spec = json!({"pours": [{"net": "GND", "layers": ["F.Cu"], "polygon": [[0, 0], [10, 0], [10, 10]], "connection": "none", "thermalGap": 0.3, "thermalBridge": 0.4, "islandRemoval": "area", "islandAreaMin": 2.5}]});
let (zones, _, _) = build(&b, &spec, None).unwrap();
assert_eq!(zones[0]["islandRemoval"], "area");
let text = zone_sexpr(&b, &zones[0]).unwrap();
for want in ["(connect_pads no", "(thermal_gap 0.3)", "(thermal_bridge_width 0.4)", "(island_removal_mode 2)", "(island_area_min 2.5)"] {
assert!(text.contains(want), "{want} in {text}");
}
let mut z = zones[0].clone();
z["islandRemoval"] = json!("never");
z.as_object_mut().unwrap().remove("islandAreaMin");
assert!(zone_sexpr(&b, &z).unwrap().contains("(island_removal_mode 1)"));
z["connection"] = json!("solid");
assert!(zone_sexpr(&b, &z).unwrap().contains("(connect_pads yes"));
for bad in [json!({"connection": "spokes"}), json!({"islandRemoval": "sometimes"}), json!({"islandAreaMin": 3}), json!({"thermalGap": -1})] {
let mut spec = spec.clone();
spec["pours"][0]["islandRemoval"] = json!("always");
spec["pours"][0].as_object_mut().unwrap().remove("islandAreaMin");
for (k, v) in bad.as_object().unwrap() { spec["pours"][0][k] = v.clone(); }
assert!(build(&b, &spec, None).is_err(), "{bad}");
}
}
/// A QFN with a 3.4 mm exposed pad U1.29 at (113, 82) on GND carrying 10 in-pad 0.5/0.25 vias
/// (2 x 5, x 112.4 and 113.6), as board vias or as the footprint's own through-hole "29" pads.
fn qfn(as_pads: bool) -> Board {
let ys = [80.8, 81.4, 82.0, 82.6, 83.2];
let mut fp_vias = String::new();
let mut board_vias = String::new();
for x in [112.4, 113.6] {
for y in ys {
if as_pads { fp_vias.push_str(&format!(" (pad \"29\" thru_hole circle (at {} {}) (size 0.5 0.5) (drill 0.25) (layers \"*.Cu\") (net 1 \"GND\"))", ((x - 113.0) * 10.0f64).round() / 10.0, ((y - 82.0) * 10.0f64).round() / 10.0)); }
else { board_vias.push_str(&format!(" (via (at {x} {y}) (size 0.5) (drill 0.25) (layers \"F.Cu\" \"B.Cu\") (net 1))")); }
}
}
Board::from_text("fixture", format!("(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\")){fp_vias}){board_vias})")).unwrap()
}
#[test]
fn thermal_vias_count_and_clear_a_pads_existing_in_pad_vias() {
// issue #83: count 12 planned three 0.8/0.4 vias 0.6 mm from the existing ones, count 10 the same
for as_pads in [false, true] {
let b = qfn(as_pads);
let p = b.pads.iter().find(|p| p.key == "U1.29" && !p.thru).unwrap();
assert_eq!(vias_at_pad(&b, p, "GND", 0.0), 10, "as pads: {as_pads}");
let holes = board_holes(&b);
for count in [10, 12] {
let spec = json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": count}]});
let (_, vias, _) = build(&b, &spec, None).unwrap();
if count == 10 { assert!(vias.is_empty(), "the 10 there already meet count 10: {vias:?}"); }
for v in &vias {
let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());
for (hx, hy, hs, _, _) in &holes {
assert!((hx - x).hypot(hy - y) >= (0.8 + hs) / 2.0 + 0.2 - 1e-6, "planned via at ({x}, {y}) crowds the existing one at ({hx}, {hy})");
}
}
}
}
// the same pad bare: the planner still places its vias
let bare = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 100 70)(end 126 94)(layer \"Edge.Cuts\")) (footprint \"QFN\" (layer \"F.Cu\") (at 113 82) (property \"Reference\" \"U1\") (pad \"29\" smd rect (at 0 0) (size 3.4 3.4) (layers \"F.Cu\") (net 1 \"GND\"))))".into()).unwrap();
let (_, vias, _) = build(&bare, &json!({"thermalVias": [{"pad": "U1.29", "net": "GND", "count": 3}]}), None).unwrap();
assert_eq!(vias.len(), 3);
assert!(!via_clear(111.8, 82.0, 0.8, 0.4, "GND", &[(112.4, 82.0, 0.5, 0.25, "GND".into())], 0.2, 0.2), "same net still keeps via-to-via spacing");
}
#[test]
fn mask_only_npth_is_a_board_hole_for_stitch_vias() {
let b = Board::from_text("fixture", "(kicad_pcb (net 0 \"\") (net 1 \"GND\") (layers (0 \"F.Cu\" signal)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 20 20)(layer \"Edge.Cuts\")) (footprint \"T:USBC\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"J9\") (pad \"\" np_thru_hole circle (at 0 0) (size 0.65 0.65) (drill 0.65) (layers \"*.Mask\"))))".into()).unwrap();
assert!(b.pads.is_empty() && b.holes.len() == 1);
let holes = board_holes(&b);
assert_eq!(holes.len(), 1);
assert!(!hole_clear(10.4, 10.0, 0.6, 0.3, "GND", &holes, 0.25, 0.2), "a via next to the peg keeps hole clearance");
let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 15.0, "y": 15.0}]});
let (_, vias, _) = build_with(&b, &spec, None, &[]).unwrap();
assert_eq!(vias.len(), 1, "the via on the peg is left out");
}
}
#[cfg(test)]
mod plane_tests {
use super::*;
#[test]
fn declaration_without_copper_is_not_a_plane(){
let make=|extra:&str|Board::from_text("fixture",format!("(kicad_pcb (layers (0 \"F.Cu\" signal)(4 \"In1.Cu\" power)(31 \"B.Cu\" signal)) (gr_rect (start 0 0)(end 10 10)(layer \"Edge.Cuts\")) {extra})")).unwrap();
let spec=json!({"planes":{"GND":"In1.Cu"}});
assert_eq!(missing_planes(&make(""),&spec),vec!["GND on In1.Cu"]);
let zone="(zone (net \"GND\") (layer \"In1.Cu\") (polygon (pts (xy 0 0)(xy 10 0)(xy 10 10))))";
assert!(missing_planes(&make(zone),&spec).is_empty());
let keepout=zone.replacen("(zone", "(zone (keepout (copperpour not_allowed))",1);
assert_eq!(missing_planes(&make(&keepout),&spec).len(),1);
}
}
#[cfg(test)]
mod net_name_tests {
use super::*;
fn board() -> Board {
Board::from_text("fixture", r#"(kicad_pcb (layers (0 "F.Cu" signal)(31 "B.Cu" signal)) (net 0 "") (net 1 "/GND")
(gr_rect (start 0 0)(end 20 20)(layer "Edge.Cuts"))
(footprint "R" (layer "F.Cu") (at 10 10) (property "Reference" "R1") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "/GND"))))"#.to_string()).unwrap()
}
#[test]
fn a_net_less_keepout_in_the_spec_goes_into_the_plan() {
// issue #189: the human's keepout over a tether contact, declared in spec.pours
let spec = json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]},
{"name": "ESC tether", "keepout": {"tracks": true, "vias": true, "pours": true}, "polygon": [[2, 2], [4, 2], [4, 4], [2, 4]], "layers": ["F.Cu", "B.Cu"]}]});
let (zones, _, keepouts) = build(&board(), &spec, None).unwrap();
assert_eq!(keepouts, 1);
let k = zones.iter().find(|z| z["type"] == "keepout").unwrap();
assert_eq!(k["name"], "ESC tether");
assert_eq!(k["keepout"], json!({"tracks": true, "vias": true, "copperPour": true}));
assert!(build(&board(), &json!({"pours": [{"keepout": true, "polygon": [[0, 0], [1, 1]]}]}), None).is_err());
}
#[test]
fn spec_gnd_pours_the_board_hierarchical_gnd() {
let (zones, _, _) = build(&board(), &json!({"pours": [{"net": "GND", "outline": true, "layers": ["F.Cu"]}]}), None).unwrap();
assert_eq!(zones[0]["net"], "/GND");
}
#[test]
fn refusal_names_the_close_board_net() {
let e = build(&board(), &json!({"pours": [{"net": "gnd", "outline": true}]}), None).unwrap_err();
assert!(e.contains("no pads on that net") && e.contains("/GND"), "{e}");
let e = build(&board(), &json!({"pours": [{"net": "VOUT", "outline": true}]}), None).unwrap_err();
assert_eq!(e, "pour VOUT: the board has no pads on that net");
}
}
#[cfg(test)]
mod issue90_tests {
use super::*;
#[test]
fn project_rules_fill_via_spacing_the_spec_leaves_out() {
let dru = "(version 1)\n# a comment (with parens)\n(rule \"pads apart\"\n (condition \"A.Type == 'Pad' && B.Type == 'Pad'\")\n (constraint hole_to_hole (min 0.45mm)))\n(rule \"base\"\n (constraint clearance (min 0.15mm))\n (constraint hole_to_hole (min 0.25mm)))\n(rule \"vias\"\n (condition \"A.Type == 'Via' && B.Type == 'Via'\")\n (constraint clearance (min 4mil)))\n(rule \"hv\"\n (condition \"A.NetClass == 'HV'\")\n (constraint clearance (min 1mm)))\n";
// the last rule that decides via to via wins; the pad rule and the net-class rule do not
assert!((dru_via_min(dru, "clearance").unwrap() - 0.1016).abs() < 1e-9);
assert_eq!(dru_via_min(dru, "hole_to_hole"), Some(0.25));
assert_eq!(dru_via_min(dru, "edge_clearance"), None);
let d = std::env::temp_dir().join(format!("aiflow-dru-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let board = d.join("b.kicad_pcb");
let path = board.display().to_string();
// no rules file: the spec as it is, and the defaults
let s = with_project_rules(&json!({}), &path);
assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.2, 0.2));
std::fs::write(d.join("b.kicad_dru"), dru).unwrap();
let s = with_project_rules(&json!({}), &path);
assert!((via_clearance(&s) - 0.1016).abs() < 1e-9 && hole_to_hole(&s) == 0.25, "{s}");
assert_eq!(s["viaClearanceFrom"], "kicad_dru");
// the spec's own numbers win
let s = with_project_rules(&json!({"clearance": 0.3, "holeToHole": 0.5}), &path);
assert_eq!((via_clearance(&s), hole_to_hole(&s)), (0.3, 0.5));
assert!(s.get("viaClearance").is_none());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn stitch_vias_default_to_the_spec_or_the_boards_via_and_honour_size() {
let text = |vias: &str| format!("(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\")) {vias})");
let bare = Board::from_text("f", text("")).unwrap();
assert_eq!(stitch_via_default(&json!({}), &bare), (0.6, 0.3, "default"));
let board = Board::from_text("f", text("(via (at 2 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 4 2) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net 1)) (via (at 6 2) (size 0.8) (drill 0.4) (layers \"F.Cu\" \"B.Cu\") (net 1))")).unwrap();
assert_eq!(stitch_via_default(&json!({}), &board).0, 0.4);
assert_eq!(stitch_via_default(&json!({"viaSignal": [0.45, 0.2]}), &board), (0.45, 0.2, "spec viaSignal"));
// a stitch via with no size takes the board's 0.4 mm via; one with size and drill keeps them
let spec = json!({"stitchVias": [{"net": "GND", "x": 10.0, "y": 10.0}, {"net": "GND", "x": 14.0, "y": 14.0, "size": 0.5, "drill": 0.25}]});
let (_, vias, _) = build(&board, &spec, None).unwrap();
assert_eq!((vias[0]["viaSize"].as_f64(), vias[0]["viaDrill"].as_f64()), (Some(0.4), Some(0.2)));
assert_eq!((vias[1]["viaSize"].as_f64(), vias[1]["viaDrill"].as_f64()), (Some(0.5), Some(0.25)));
// the case in the issue: 0.7 mm from a 0.4 mm via clears with the board's 0.4 mm via, not with 0.8 mm
let near = json!({"stitchVias": [{"net": "GND", "x": 2.0, "y": 2.7}]});
assert_eq!(build(&board, &near, None).unwrap().1.len(), 1);
let mut big = near.clone();
big["stitchVias"][0]["size"] = json!(0.8);
big["stitchVias"][0]["drill"] = json!(0.4);
assert!(build(&board, &big, None).unwrap().1.is_empty());
}
#[test]
fn ring_thermal_vias_keep_clear_of_the_silkscreen() {
// a SOT-23-ish pin on GND with the part's reference printed right beside it
let fp = |label: &str| Board::from_text("f", format!("(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 \"SOT\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"U3\" (at 0 0) (layer \"F.SilkS\") (hide yes)) {label} (pad \"2\" smd rect (at 0 0) (size 0.6 0.7) (layers \"F.Cu\") (net 1 \"GND\"))))")).unwrap();
let spec = json!({"thermalVias": [{"pad": "U3.2", "net": "GND", "count": 4}]});
let free = fp("");
let (_, vias, _) = build(&free, &spec, None).unwrap();
assert_eq!(vias.len(), 4);
let labelled = fp("(fp_text user \"U3\" (at 1.2 0 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15))))");
let silk = aiflow_board::silk::silk_shapes(&labelled);
assert_eq!(silk.len(), 1);
let (_, vias, _) = build(&labelled, &spec, None).unwrap();
assert!(!vias.is_empty());
for v in &vias {
let (x, y) = (v["paths"][0][0][0].as_f64().unwrap(), v["paths"][0][0][1].as_f64().unwrap());
assert!(silk[0].distance((x, y)) >= 0.3 + 0.1 - 1e-6, "via at ({x}, {y}) under the label");
}
}
}