app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
Colby Knox
Merge PR #343: kicad-lane: 1.0.85, simulate sweeps and notSimulated, land pattern from the engine, engine 0.19.0
7f17c07
12d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
//! Via plans computed from the board model: thermal vias in a part's exposed pad and a via fence along RF tracks or the
//! board edge (and the stitching grid behind board_features). From the Altium bridge's features.rs. Plans only: each
//! answers the vias as place_vias arguments, and the bridge places them.
use serde_json::{json, Value};
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn pt(v: &Value) -> (f64, f64) { (f(&v[0]), f(&v[1])) }
fn inside(poly: &[(f64, f64)], x: f64, y: f64) -> bool {
let mut c = false;
let n = poly.len();
for i in 0..n {
let (a, b) = (poly[i], poly[(i + n - 1) % n]);
if (a.1 > y) != (b.1 > y) && x < (b.0 - a.0) * (y - a.1) / (b.1 - a.1) + a.0 { c = !c; }
}
c
}
fn seg_dist(px: f64, py: 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 == 0.0 { 0.0 } else { (((px - a.0) * dx + (py - a.1) * dy) / l2).clamp(0.0, 1.0) };
((px - a.0 - t * dx).powi(2) + (py - a.1 - t * dy).powi(2)).sqrt()
}
/// Copper that a stitching via on `net` must keep clear of: (kind, geometry, half-width) as segments and discs.
enum Ob { Seg((f64, f64), (f64, f64), f64), Disc(f64, f64, f64) }
fn obstacles(state: &Value, net: &str) -> (Vec<Ob>, Vec<(f64, f64)>) {
// copper on another net. Lines on mechanical and overlay layers are not copper, and net-less copper tracks are a
// hatched pour's pieces (Altium lists them with no net) that re-pour round a new via
let other = |v: &Value| {
let n = v["net"].as_str().unwrap_or("");
let copper = v.get("layer").map(|l| l.as_str().map(|s| s.ends_with(".Cu")).unwrap_or(false)).unwrap_or(true);
let piece = n.is_empty() && v.get("start").or(v.get("center")).is_some();
copper && !piece && !n.eq_ignore_ascii_case(net)
};
let mut ob = Vec::new();
let mut same_vias = Vec::new();
for t in state["tracks"].as_array().cloned().unwrap_or_default() {
if other(&t) { ob.push(Ob::Seg((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0)); }
}
for a in state["arcs"].as_array().cloned().unwrap_or_default() {
if !other(&a) { continue; }
// an arc as short chords
let (cx, cy, r) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));
let (mut a1, a2) = (f(&a["startAngle"]), f(&a["endAngle"]));
let mut span = a2 - a1;
if span <= 0.0 { span += 360.0; }
let n = ((span / 10.0).ceil() as usize).max(1);
let p = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());
for _ in 0..n { let b = a1 + span / n as f64; ob.push(Ob::Seg(p(a1), p(b), f(&a["width"]) / 2.0)); a1 = b; }
}
for v in state["vias"].as_array().cloned().unwrap_or_default() {
let (x, y) = (f(&v["position"]["x"]), f(&v["position"]["y"]));
if other(&v) { ob.push(Ob::Disc(x, y, f(&v["size"]) / 2.0)); } else { same_vias.push((x, y)); }
}
for p in state["pads"].as_array().cloned().unwrap_or_default() {
// a pad on the net still gets clearance: a via in a pad is not stitching. Its circumscribed circle keeps it
// conservative at any rotation.
let r = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;
ob.push(Ob::Disc(f(&p["x"]), f(&p["y"]), r.max(f(&p["drill"]) / 2.0)));
}
(ob, same_vias)
}
/// Where stitching vias for `net` go: a grid at `pitch` inside the outline (or `region`), `edge` mm clear of the
/// board edge, `clearance` mm clear of copper on other nets and of every pad, half a pitch from vias the net already
/// has, at most `max`. Pure, for the tests.
#[allow(clippy::too_many_arguments)]
pub fn plan_stitching(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize) -> Vec<(f64, f64)> {
plan_stitching_with(state, net, pitch, size, clearance, edge, region, max, &[], true, &[])
}
/// plan_stitching, also clear of `extra` discs (x, y, radius: holes and fiducials placed in the same call) and, with
/// `avoid_parts`, out from under every part (the box around its pads, grown by the clearance).
#[allow(clippy::too_many_arguments)]
pub fn plan_stitching_with(state: &Value, net: &str, pitch: f64, size: f64, clearance: f64, edge: f64, region: Option<&[(f64, f64)]>, max: usize,
extra: &[(f64, f64, f64)], avoid_parts: bool, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {
let outline: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();
if outline.len() < 3 || pitch <= 0.0 { return vec![]; }
let area: Vec<(f64, f64)> = region.map(|r| r.to_vec()).unwrap_or_else(|| outline.clone());
let (mut ob, same) = obstacles(state, net);
for (x, y, r) in extra { ob.push(Ob::Disc(*x, *y, *r)); }
// each part's pad box: a via between a chip's pad rows sits under its body
let mut boxes: std::collections::BTreeMap<String, (f64, f64, f64, f64)> = Default::default();
if avoid_parts {
for p in state["pads"].as_array().cloned().unwrap_or_default() {
let Some(r) = p["ref"].as_str().filter(|r| !r.is_empty()) else { continue };
let h = (f(&p["sizeX"]).powi(2) + f(&p["sizeY"]).powi(2)).sqrt() / 2.0;
let (x, y) = (f(&p["x"]), f(&p["y"]));
let b = boxes.entry(r.to_string()).or_insert((f64::MAX, f64::MAX, f64::MIN, f64::MIN));
*b = (b.0.min(x - h), b.1.min(y - h), b.2.max(x + h), b.3.max(y + h));
}
}
let (x0, x1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = area.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
let mut cands = Vec::new();
let mut y = y0 + pitch / 2.0;
while y < y1 {
let mut x = x0 + pitch / 2.0;
while x < x1 { if inside(&area, x, y) { cands.push((x, y)); } x += pitch; }
y += pitch;
}
keep_clear(state, &outline, &cands, pitch / 2.0, size, clearance, edge, max, &ob, &same, &boxes, keepouts)
}
/// The candidate via points that fit: inside the outline, `edge` from it, `clearance` from other copper (`ob`), out of
/// part boxes and keepouts, and `sep` from the net's vias and from each other. Pure.
#[allow(clippy::too_many_arguments)]
fn keep_clear(_state: &Value, outline: &[(f64, f64)], cands: &[(f64, f64)], sep: f64, size: f64, clearance: f64, edge: f64, max: usize, ob: &[Ob],
same: &[(f64, f64)], boxes: &std::collections::BTreeMap<String, (f64, f64, f64, f64)>, keepouts: &[(f64, f64, f64, f64)]) -> Vec<(f64, f64)> {
let r = size / 2.0;
let mut out: Vec<(f64, f64)> = Vec::new();
for &(x, y) in cands {
if out.len() >= max { break; }
let ok = inside(outline, x, y)
&& (0..outline.len()).all(|k| seg_dist(x, y, outline[k], outline[(k + 1) % outline.len()]) >= edge + r)
&& ob.iter().all(|o| match o {
Ob::Seg(a, b, hw) => seg_dist(x, y, *a, *b) - hw - r >= clearance,
Ob::Disc(cx, cy, cr) => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - cr - r >= clearance,
})
&& same.iter().chain(out.iter()).all(|(sx, sy)| ((x - sx).powi(2) + (y - sy).powi(2)).sqrt() >= sep)
&& boxes.values().all(|b| x + r + clearance <= b.0 || x - r - clearance >= b.2 || y + r + clearance <= b.1 || y - r - clearance >= b.3)
&& keepouts.iter().all(|k| x + r <= k.0 || x - r >= k.2 || y + r <= k.1 || y - r >= k.3);
if ok { out.push(((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0)); }
}
out
}
/// Via fence candidates: both sides of every copper track of `nets`, `offset` from its centreline, every `pitch` along
/// it; and with `edge_inset`, along the outline that far inside it. Pure.
pub fn fence_points(state: &Value, nets: &[String], pitch: f64, offset: f64, edge_inset: Option<f64>) -> Vec<(f64, f64)> {
let mut v = Vec::new();
for t in state["tracks"].as_array().cloned().unwrap_or_default() {
let n = t["net"].as_str().unwrap_or("");
if !nets.iter().any(|x| x.eq_ignore_ascii_case(n)) || !t["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false) { continue; }
let (a, b) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])));
let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
if len <= 0.0 { continue; }
let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);
let k = (len / pitch).floor() as usize;
for i in 0..=k {
let s = if k == 0 { len / 2.0 } else { i as f64 * len / k as f64 };
for side in [1.0, -1.0] { v.push((a.0 + ux * s - uy * offset * side, a.1 + uy * s + ux * offset * side)); }
}
}
if let Some(inset) = edge_inset {
let ring = crate::coppermap::outline_ring(state);
let n = ring.len();
// inward is left of each edge on a counterclockwise ring
let area: f64 = (0..n).map(|k| ring[k].0 * ring[(k + 1) % n].1 - ring[(k + 1) % n].0 * ring[k].1).sum();
let sgn = if area > 0.0 { 1.0 } else { -1.0 };
let mut carry: f64 = 0.0;
for k in 0..n {
let (a, b) = (ring[k], ring[(k + 1) % n]);
let len = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt();
if len <= 0.0 { continue; }
let (ux, uy) = ((b.0 - a.0) / len, (b.1 - a.1) / len);
// clear of the corners by the inset, where an inward step from one edge lands on the next
let mut s = carry.max(inset);
while s <= len - inset { v.push((a.0 + ux * s - uy * inset * sgn, a.1 + uy * s + ux * inset * sgn)); s += pitch; }
carry = (s - len).max(0.0);
}
}
v
}
/// The board's preferred via (diameter, drill) in mm. A standard rule gives it as `{kind: "via", sizeMm, drillMm}`; Altium's
/// Routing Via descriptor is read too, e.g. "Routing Via (MinHoleWidth=0.3mm) ... (PreferredHoleWidth=0.3mm) ...
/// (PreferedWidth=0.5mm) (All)" (Altium's own spelling). Pure.
pub fn via_style(rules: &[Value]) -> Option<(f64, f64)> {
if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))
.find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("via")).unwrap_or(false) && r["sizeMm"].as_f64().is_some() && r["drillMm"].as_f64().is_some()) {
return Some((f(&r["sizeMm"]), f(&r["drillMm"])));
}
let num = |d: &str, key: &str| -> Option<f64> {
let i = d.find(&format!("({key}="))? + key.len() + 2;
let s = &d[i..d[i..].find(')')? + i];
let (v, unit) = s.trim_end_matches(char::is_alphabetic).parse::<f64>().ok().map(|v| (v, &s[s.trim_end_matches(char::is_alphabetic).len()..]))?;
Some(if unit.eq_ignore_ascii_case("mil") { v * 0.0254 } else { v })
};
let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Routing Via")).unwrap_or(false)).collect();
// the board-wide rule (scope All), highest priority first
rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));
let d = rs.first()?["descriptor"].as_str()?;
Some((num(d, "PreferedWidth").or(num(d, "PreferredWidth"))?, num(d, "PreferredHoleWidth")?))
}
/// The board's preferred track width in mm. A standard rule gives it as `{kind: "width", preferredMm}` (or `widthMm`);
/// Altium's Width rule descriptor is read too, e.g. "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)":
/// the board-wide one (scope All), highest priority first, its Preferred else its Min. Pure.
pub fn width_style(rules: &[Value]) -> Option<f64> {
if let Some(r) = rules.iter().filter(|r| r["enabled"] != json!(false))
.find(|r| r["kind"].as_str().map(|k| k.eq_ignore_ascii_case("width")).unwrap_or(false)) {
// its preferred width, else its minimum, as Altium's descriptor is read (Fusion sends EAGLE's msWidth as minMm)
if let Some(v) = r["preferredMm"].as_f64().or_else(|| r["widthMm"].as_f64()).or_else(|| r["minMm"].as_f64()) { return Some(v); }
}
let num = |d: &str, key: &str| -> Option<f64> {
let i = d.find(&format!("({key}="))? + key.len() + 2;
let s = &d[i..d[i..].find(')')? + i];
let v: f64 = s.trim_end_matches(char::is_alphabetic).parse().ok()?;
Some(if s.ends_with("mil") { v * 0.0254 } else { v })
};
let mut rs: Vec<&Value> = rules.iter().filter(|r| r["enabled"] != json!(false) && r["descriptor"].as_str().map(|d| d.starts_with("Width")).unwrap_or(false)).collect();
rs.sort_by_key(|r| (r["scope1"].as_str() != Some("All"), r["priority"].as_i64().unwrap_or(99)));
let d = rs.first()?["descriptor"].as_str()?;
num(d, "Preferred").or_else(|| num(d, "Min")).map(|v| (v * 10000.0).round() / 10000.0)
}
/// Thermal via grid points inside a pad (x, y, sx, sy, rotation mm): `pitch` apart, `inset` from the pad's edge plus
/// the via's radius, centred. Pure.
pub fn thermal_grid(x: f64, y: f64, sx: f64, sy: f64, rot: f64, pitch: f64, size: f64, inset: f64) -> Vec<(f64, f64)> {
let (ux, uy) = (sx / 2.0 - inset - size / 2.0, sy / 2.0 - inset - size / 2.0);
if ux < 0.0 || uy < 0.0 { return vec![]; }
let (nx, ny) = ((2.0 * ux / pitch).floor() as i64 + 1, (2.0 * uy / pitch).floor() as i64 + 1);
let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());
let mut out = Vec::new();
for i in 0..nx { for j in 0..ny {
let (lx, ly) = ((i as f64 - (nx - 1) as f64 / 2.0) * pitch, (j as f64 - (ny - 1) as f64 / 2.0) * pitch);
out.push((((x + lx * c - ly * s) * 1000.0).round() / 1000.0, ((y + lx * s + ly * c) * 1000.0).round() / 1000.0));
} }
out
}
/// The via each plan uses: `size`/`drill` from args, else the board's via rule (`rules`), else 0.6/0.3; with where it came from.
fn via_of(input: &Value) -> (f64, f64, &'static str) {
let args = &input["args"];
let style = via_style(&crate::input::rules(input));
let from = if args.get("size").is_some() { "size/drill" } else if style.is_some() { "the board's via rule" } else { "default 0.6/0.3" };
(num(args, "size", style.map(|s| s.0).unwrap_or(0.6)), num(args, "drill", style.map(|s| s.1).unwrap_or(0.3)), from)
}
/// The plan as place_vias arguments.
fn apply_of(pts: &[(f64, f64)], net: &str, size: f64, drill: f64) -> Value {
json!({"verb": "place_vias", "args": {"vias": pts.iter().map(|(x, y)| json!({"net": net, "x": x, "y": y, "size": size, "drill": drill})).collect::<Vec<_>>()}})
}
/// thermal_vias: input {board, rules?, args: {ref, pad?, pitchMm?, size?, drill?, insetMm?}}. A grid of vias inside the part's
/// exposed pad (the one named, else its largest), clear of vias already there. Plans only: `apply` is place_vias' arguments.
pub fn thermal_vias(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let Some(rf) = args["ref"].as_str() else { return crate::input::refuse("bad_args", "ref names the part (e.g. U3)") };
let pads: Vec<Value> = st["pads"].as_array().cloned().unwrap_or_default().into_iter().filter(|p| p["ref"].as_str().map(|r| r.eq_ignore_ascii_case(rf)).unwrap_or(false)).collect();
if pads.is_empty() { return crate::input::refuse("part_not_found", &format!("{rf} has no pads on this board")); }
let pad = match args["pad"].as_str() {
Some(n) => pads.iter().find(|p| p["name"].as_str().map(|x| x.eq_ignore_ascii_case(n)).unwrap_or(false)).cloned(),
None => pads.iter().max_by(|a, b| (f(&a["sizeX"]) * f(&a["sizeY"])).partial_cmp(&(f(&b["sizeX"]) * f(&b["sizeY"]))).unwrap()).cloned(),
};
let Some(pad) = pad else { return json!({"success": false, "errorCode": "no_such_pad", "error": "that pad is not on the part", "pads": pads.iter().map(|p| p["name"].clone()).collect::<Vec<_>>()}) };
let net = pad["net"].as_str().unwrap_or("").to_string();
if net.is_empty() { return crate::input::refuse("pad_has_no_net", &format!("{rf}.{} is on no net: thermal vias would float", pad["name"].as_str().unwrap_or(""))); }
let (size, drill, from) = via_of(input);
let pitch = num(args, "pitchMm", (size + 0.5).max(1.0));
let inset = num(args, "insetMm", 0.1);
let grid = thermal_grid(f(&pad["x"]), f(&pad["y"]), f(&pad["sizeX"]), f(&pad["sizeY"]), f(&pad["rotation"]), pitch, size, inset);
// keep clear of vias already there: the Bluetooth Sentinel's U4 pad had 8, and a grid laid over them broke Altium's
// hole-to-hole rule 8 times (2026-10-26)
let vias: Vec<(f64, f64, f64)> = st["vias"].as_array().cloned().unwrap_or_default().iter().map(|v| (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]))).collect();
let half = f(&pad["sizeX"]).max(f(&pad["sizeY"])) / 2.0;
let existing = vias.iter().filter(|(x, y, _)| (x - f(&pad["x"])).abs() <= half && (y - f(&pad["y"])).abs() <= half).count();
let pts: Vec<(f64, f64)> = grid.iter().cloned().filter(|(x, y)| vias.iter().all(|(vx, vy, vs)| ((x - vx).powi(2) + (y - vy).powi(2)).sqrt() >= size / 2.0 + vs / 2.0 + 0.2)).collect();
let planned = json!({"part": rf, "pad": pad["name"], "net": net, "padMm": [pad["sizeX"], pad["sizeY"]], "vias": pts.len(), "existingVias": existing, "skippedNearExisting": grid.len() - pts.len(), "pitchMm": pitch,
"via": {"sizeMm": size, "drillMm": drill, "from": from}, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});
if pts.is_empty() {
return json!({"success": existing > 0, "errorCode": if existing > 0 { "already_has_vias" } else { "pad_too_small" }, "error": if existing > 0 { "the pad's existing vias leave no room for more at this pitch" } else { "no via fits inside the pad at this size and inset" }, "planned": planned});
}
json!({"success": true, "planned": planned, "apply": apply_of(&pts, &net, size, drill),
"_hint": "A plan only: run place_vias with apply.args. Vias inside the pad carry its heat to the other layers' copper: pour the net on the far side (apply_pours) so they have somewhere to go. Open vias in a pad wick solder: ask the fab to tent or plug them, or use 0.3 mm or smaller drills. run_drc afterwards."})
}
/// via_fence: input {board, rules?, args: {viaNet, nets?, edge?, pitchMm?, freqGHz?, offsetMm?, edgeInsetMm?, size?, drill?,
/// clearance?}}. Vias of viaNet both sides of the named nets' tracks and/or round the board edge, clear of other copper.
/// Plans only: `apply` is place_vias' arguments.
pub fn via_fence(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let Some(via_net) = args["viaNet"].as_str() else { return crate::input::refuse("bad_args", "viaNet is the net the fence ties to, usually GND") };
if !st["nets"].as_array().map(|a| a.iter().any(|n| n.as_str().map(|s| s.eq_ignore_ascii_case(via_net)).unwrap_or(false))).unwrap_or(false) {
return json!({"success": false, "errorCode": "no_such_net", "error": format!("{via_net} is not a net on this board"), "nets": st["nets"]});
}
let nets: Vec<String> = args.get("nets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();
let edge_on = args.get("edge").and_then(|b| b.as_bool()).unwrap_or(false);
if nets.is_empty() && !edge_on { return crate::input::refuse("bad_args", "name nets to fence (e.g. [\"ANT_RF\"]) and/or edge:true for a fence round the board edge"); }
// the via the board's own via rule prefers, so the fence passes its DRC (0.6/0.3 broke the Bluetooth Sentinel
// example's 0.5/0.3 rule on every via)
let (size, drill, from) = via_of(input);
let clearance = num(args, "clearance", 0.2);
// lambda/20 in FR-4 (effective er about 3.4) at the highest frequency, else 2 mm
let pitch = match args.get("freqGHz").and_then(|v| v.as_f64()) { Some(g) if g > 0.0 => (299.79 / g / 3.4f64.sqrt() / 20.0).min(num(args, "pitchMm", f64::MAX)), _ => num(args, "pitchMm", 2.0) };
let widest = st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| nets.iter().any(|n| t["net"].as_str().map(|s| s.eq_ignore_ascii_case(n)).unwrap_or(false))).map(|t| f(&t["width"])).fold(0.0, f64::max);
let offset = num(args, "offsetMm", widest / 2.0 + clearance + size / 2.0 + 0.1);
let inset = if edge_on { Some(num(args, "edgeInsetMm", size / 2.0 + 0.5)) } else { None };
let cands = fence_points(&st, &nets, pitch, offset, inset);
let outline: Vec<(f64, f64)> = crate::coppermap::outline_ring(&st);
let (ob, same) = obstacles(&st, via_net);
let pts = keep_clear(&st, &outline, &cands, pitch * 0.6, size, clearance, 0.3, 5000, &ob, &same, &Default::default(), &[]);
let planned = json!({"vias": pts.len(), "candidates": cands.len(), "via": {"sizeMm": size, "drillMm": drill, "from": from}, "pitchMm": (pitch * 1000.0).round() / 1000.0,
"offsetMm": (offset * 1000.0).round() / 1000.0, "points": pts.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>()});
if pts.is_empty() {
return json!({"success": false, "errorCode": "no_room", "error": "no fence via fits", "planned": planned, "_hint": "Lower clearance or size, change offsetMm, or check the nets have tracks."});
}
json!({"success": true, "planned": planned, "apply": apply_of(&pts, via_net, size, drill),
"_hint": "A plan only: run place_vias with apply.args, then repour so pours join the vias, then run_drc. candidates - vias are the spots where other copper, a pad or the edge was too close."})
}
/// The four corner positions of the outline's bounding box, `inset` mm in.
pub fn corners(state: &Value, inset: f64) -> Vec<(f64, f64)> {
let o: Vec<(f64, f64)> = state["outline"].as_array().cloned().unwrap_or_default().iter().map(pt).collect();
if o.is_empty() { return vec![]; }
let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
vec![(x0 + inset, y0 + inset), (x1 - inset, y0 + inset), (x1 - inset, y1 - inset), (x0 + inset, y1 - inset)]
}
pub fn region_of(v: Option<&Value>) -> Option<Vec<(f64, f64)>> {
let a = v?.as_array()?;
let r: Vec<(f64, f64)> = a.iter().map(pt).collect();
if r.len() >= 3 { Some(r) } else { None }
}
pub fn summary(points: &[(f64, f64)]) -> Value {
json!(points.iter().map(|(x, y)| json!([x, y])).collect::<Vec<_>>())
}
fn num(v: &Value, k: &str, d: f64) -> f64 { v.get(k).and_then(|x| x.as_f64().or_else(|| x.as_str().and_then(|s| s.parse().ok()))).unwrap_or(d) }
#[cfg(test)]
mod tests {
use super::*;
fn board() -> Value {
json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],
"tracks": [{"start": {"x": 0.0, "y": 5.0}, "end": {"x": 20.0, "y": 5.0}, "width": 0.25, "net": "SIG"}],
"arcs": [], "vias": [{"position": {"x": 2.5, "y": 2.5}, "size": 0.6, "net": "GND"}],
"pads": [{"x": 17.5, "y": 7.5, "sizeX": 1.0, "sizeY": 1.0, "drill": 0.0, "net": "GND"}]})
}
#[test]
fn stitching_keeps_clear_of_other_nets_pads_edges_and_existing_vias() {
let p = plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 100);
// the 5 mm grid is 2.5/7.5/12.5/17.5 by 2.5/7.5; (2.5,2.5) has a GND via already, (17.5,7.5) is a pad
assert_eq!(p, vec![(7.5, 2.5), (12.5, 2.5), (17.5, 2.5), (2.5, 7.5), (7.5, 7.5), (12.5, 7.5)]);
// a track of another net through the grid row blocks it
let mut b = board();
b["tracks"][0]["start"]["y"] = json!(2.5);
b["tracks"][0]["end"]["y"] = json!(2.5);
assert!(plan_stitching(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100).iter().all(|p| p.1 != 2.5));
// a region limits it, max caps it
let reg = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, Some(®), 100), vec![(7.5, 2.5), (2.5, 7.5), (7.5, 7.5)]);
assert_eq!(plan_stitching(&board(), "GND", 5.0, 0.6, 0.3, 0.5, None, 2).len(), 2);
}
#[test]
fn stitching_stays_off_holes_and_out_from_under_parts() {
let mut b = board();
// a part U1 whose two pads span x 6..9 at y 7.5: (7.5, 7.5) lies between them, under its body
b["pads"] = json!([{"ref": "U1", "x": 6.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"},
{"ref": "U1", "x": 9.0, "y": 7.5, "sizeX": 0.6, "sizeY": 0.6, "drill": 0.0, "net": "SIG"}]);
let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[(12.5, 2.5, 1.6)], true, &[(15.0, 0.0, 20.0, 10.0)]);
assert!(!p.contains(&(7.5, 7.5)) && !p.contains(&(12.5, 2.5)));
assert!(p.iter().all(|q| q.0 < 15.0), "the keepout at x 15..20 takes no via");
let p = plan_stitching_with(&b, "GND", 5.0, 0.6, 0.3, 0.5, None, 100, &[], false, &[]);
assert!(p.contains(&(7.5, 7.5)));
}
#[test]
fn corner_holes_sit_inset_from_the_outline() {
assert_eq!(corners(&board(), 3.0), vec![(3.0, 3.0), (17.0, 3.0), (17.0, 7.0), (3.0, 7.0)]);
}
#[test]
fn fence_points_line_both_sides_of_a_track_and_the_edge() {
let st = json!({"tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 0.0, "y": 5.0}, "end": {"x": 10.0, "y": 5.0}}],
"outline": [[-5, 0], [15, 0], [15, 10], [-5, 10]],
"outlineEdges": [{"kind": "line", "start": [-5.0, 0.0]}, {"kind": "line", "start": [15.0, 0.0]}, {"kind": "line", "start": [15.0, 10.0]}, {"kind": "line", "start": [-5.0, 10.0]}]});
let p = fence_points(&st, &["RF".to_string()], 2.0, 1.0, None);
assert_eq!(p.len(), 12);
assert!(p.contains(&(0.0, 6.0)) && p.contains(&(10.0, 4.0)));
let e = fence_points(&st, &[], 5.0, 1.0, Some(0.5));
assert!(e.iter().all(|(x, y)| *x >= -4.5 - 1e-9 && *x <= 14.5 + 1e-9 && *y >= 0.5 - 1e-9 && *y <= 9.5 + 1e-9), "{e:?}");
assert!(e.len() >= 10, "{e:?}");
}
#[test]
fn via_style_reads_altiums_descriptor() {
let rules = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (MinHoleWidth=0.3mm) (MaxHoleWidth=0.3mm) (PreferredHoleWidth=0.3mm) (MinWidth=0.5mm) (MaxWidth=0.5mm) (PreferedWidth=0.5mm) (All)"})];
assert_eq!(via_style(&rules), Some((0.5, 0.3)));
let mil = vec![json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=12mil) (PreferedWidth=24mil) (All)"})];
let (s, d) = via_style(&mil).unwrap();
assert!((s - 0.6096).abs() < 1e-9 && (d - 0.3048).abs() < 1e-9);
}
#[test]
fn thermal_grid_fills_an_exposed_pad() {
// a 3.3 mm QFN pad, 0.6 mm vias, 1.1 mm pitch: a 3 x 3 grid centred on the pad
let g = thermal_grid(10.0, 10.0, 3.3, 3.3, 0.0, 1.1, 0.6, 0.1);
assert_eq!(g.len(), 9);
assert!(g.contains(&(10.0, 10.0)) && g.contains(&(8.9, 11.1)));
assert!(thermal_grid(0.0, 0.0, 0.5, 0.5, 0.0, 1.0, 0.6, 0.1).is_empty());
// turned 90 degrees, a long pad's grid turns with it
let r = thermal_grid(0.0, 0.0, 4.0, 1.5, 90.0, 1.1, 0.6, 0.1);
assert!(r.iter().all(|(x, _)| x.abs() < 0.5));
}
#[test]
fn via_style_takes_the_standard_rule_first() {
let rules = vec![json!({"kind": "via", "sizeMm": 0.45, "drillMm": 0.2}),
json!({"enabled": true, "scope1": "All", "priority": 1, "descriptor": "Routing Via (PreferredHoleWidth=0.3mm) (PreferedWidth=0.5mm) (All)"})];
assert_eq!(via_style(&rules), Some((0.45, 0.2)));
}
#[test]
fn the_verbs_answer_place_vias_plans() {
let st = json!({"nets": ["GND", "RF"], "outline": [[0, 0], [30, 0], [30, 20], [0, 20]],
"outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [30.0, 0.0]}, {"kind": "line", "start": [30.0, 20.0]}, {"kind": "line", "start": [0.0, 20.0]}],
"tracks": [{"net": "RF", "layer": "F.Cu", "width": 0.4, "start": {"x": 5.0, "y": 10.0}, "end": {"x": 25.0, "y": 10.0}}], "arcs": [], "vias": [],
"pads": [{"ref": "U1", "name": "EP", "x": 15.0, "y": 4.0, "sizeX": 3.3, "sizeY": 3.3, "rotation": 0.0, "drill": 0.0, "net": "GND", "layer": "F.Cu"}]});
let t = thermal_vias(&json!({"board": st, "rules": [{"kind": "via", "sizeMm": 0.6, "drillMm": 0.3}], "args": {"ref": "U1"}}));
assert_eq!(t["success"], json!(true), "{t}");
assert_eq!(t["planned"]["vias"], 9);
assert_eq!(t["apply"]["verb"], "place_vias");
assert_eq!(t["apply"]["args"]["vias"][0]["net"], "GND");
assert_eq!(t["planned"]["via"]["from"], "the board's via rule");
let v = via_fence(&json!({"board": st, "args": {"viaNet": "GND", "nets": ["RF"], "pitchMm": 2.0}}));
assert_eq!(v["success"], json!(true), "{v}");
assert!(v["planned"]["vias"].as_u64().unwrap() >= 10);
assert_eq!(v["apply"]["args"]["vias"].as_array().unwrap().len() as u64, v["planned"]["vias"].as_u64().unwrap());
assert_eq!(via_fence(&json!({"board": st, "args": {"viaNet": "NOPE", "nets": ["RF"]}}))["errorCode"], "no_such_net");
assert_eq!(thermal_vias(&json!({"board": st, "args": {"ref": "U9"}}))["errorCode"], "part_not_found");
}
#[test]
fn width_style_reads_the_standard_rule_and_altiums_descriptor() {
assert_eq!(width_style(&[json!({"kind": "width", "preferredMm": 0.2})]), Some(0.2));
assert_eq!(width_style(&[json!({"kind": "width", "name": "minimum width (msWidth)", "minMm": 0.15})]), Some(0.15));
let alt = vec![json!({"enabled": true, "scope1": "InNet('VIN')", "priority": 1, "descriptor": "Width Constraint (Min=20mil) (Max=40mil) (Preferred=30mil) (InNet('VIN'))"}),
json!({"enabled": true, "scope1": "All", "priority": 2, "descriptor": "Width Constraint (Min=10mil) (Max=10mil) (Preferred=10mil) (All)"})];
assert_eq!(width_style(&alt), Some(0.254), "the board-wide rule, not a net's");
assert_eq!(width_style(&[json!({"descriptor": "Width Constraint (Min=0.15mm) (Max=1mm) (All)", "scope1": "All"})]), Some(0.15));
assert_eq!(width_style(&[]), None);
}
}