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 #319: kicad-lane: 1.0.72, engine 0.4.0 checks and placement_check
0856e3b
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
//! The EDA Bridge Standard's checks that this bridge computes itself, from the board model
//! (`board_model::build`), the parsed board (`pcb`) and the netlist: net_lengths,
//! check_orientation, find_space, silk_designators, length_tune, auto_classes (the proposal) and
//! pin_pad_check (height_check, thermal_vias and via_fence come from the standard's engine).
//! Pure: no I/O and no KiCad; the verbs read the board and apply.
//!
//! Model coordinates are in the frame the model was built in (the standard's y up, or KiCad's y
//! down); everything here is frame-free except `find_space`, which works on the parsed board (y
//! down) and answers in the frame asked.
use std::collections::{BTreeMap, HashMap};
use serde_json::{json, Value};
use crate::netlist::Netlist;
use crate::pcb::Board;
use crate::placement::{self, Rect};
fn f(v: &Value) -> f64 {
v.as_f64().unwrap_or(0.0)
}
fn r3(x: f64) -> f64 {
(x * 1000.0).round() / 1000.0 + 0.0
}
fn txt(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
/// A point given as `{x, y}` or `[x, y]`.
fn xy(v: &Value) -> (f64, f64) {
match v {
Value::Array(a) => (a.first().map(f).unwrap_or(0.0), a.get(1).map(f).unwrap_or(0.0)),
_ => (f(&v["x"]), f(&v["y"])),
}
}
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 {
((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()
}
fn arr<'a>(v: &'a Value, k: &str) -> impl Iterator<Item = &'a Value> {
v[k].as_array().into_iter().flatten()
}
fn wanted(args: &Value, key: &str) -> Option<Vec<String>> {
args.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_ascii_lowercase).collect())
}
// ---------------------------------------------------------------------------
// net_lengths
// ---------------------------------------------------------------------------
#[derive(Default)]
struct Length {
total: f64,
by_layer: BTreeMap<String, f64>,
vias: usize,
segments: usize,
}
/// Routed length per net: straight tracks and arcs (radius x sweep), by layer, with the vias
/// counted (their barrel is not added). `pairs` are the board's own pairs by name, used when the
/// caller names none.
pub fn net_lengths(model: &Value, args: &Value, pairs: &[(String, String, String)]) -> Value {
let mut nets: BTreeMap<String, Length> = BTreeMap::new();
for t in arr(model, "tracks") {
let net = txt(&t["net"]);
if net.is_empty() {
continue;
}
let l = nets.entry(net.to_string()).or_default();
let len = dist(xy(&t["start"]), xy(&t["end"]));
l.total += len;
*l.by_layer.entry(txt(&t["layer"]).to_string()).or_default() += len;
l.segments += 1;
}
for a in arr(model, "arcs") {
let net = txt(&a["net"]);
if net.is_empty() {
continue;
}
// the model's arcs run counterclockwise from startAngle to endAngle
let sweep = (f(&a["endAngle"]) - f(&a["startAngle"])).rem_euclid(360.0);
let len = f(&a["radius"]) * sweep.to_radians();
let l = nets.entry(net.to_string()).or_default();
l.total += len;
*l.by_layer.entry(txt(&a["layer"]).to_string()).or_default() += len;
l.segments += 1;
}
for v in arr(model, "vias") {
let net = txt(&v["net"]);
if !net.is_empty() {
nets.entry(net.to_string()).or_default().vias += 1;
}
}
let board_nets: Vec<String> = arr(model, "nets").filter_map(|n| n.as_str().or(n["name"].as_str()).map(String::from)).collect();
let find = |name: &str| -> Option<String> { board_nets.iter().find(|n| n.eq_ignore_ascii_case(name)).cloned() };
let length_of = |name: &str| nets.get(name).map(|l| l.total).unwrap_or(0.0);
let row = |name: &str, l: &Length| {
json!({"net": name, "lengthMm": r3(l.total), "byLayer": l.by_layer.iter().map(|(k, v)| (k.clone(), json!(r3(*v)))).collect::<serde_json::Map<_, _>>(), "vias": l.vias, "segments": l.segments})
};
let mut rows: Vec<Value> = Vec::new();
let mut unknown = Vec::new();
let mut unrouted = Vec::new();
match wanted(args, "nets") {
Some(want) => {
for w in &want {
match board_nets.iter().find(|n| n.to_ascii_lowercase() == *w) {
None => unknown.push(json!(w)),
Some(n) => match nets.get(n) {
Some(l) if l.segments > 0 => rows.push(row(n, l)),
_ => unrouted.push(json!(n)),
},
}
}
}
None => rows.extend(nets.iter().filter(|(_, l)| l.segments > 0).map(|(n, l)| row(n, l))),
}
rows.sort_by(|a, b| f(&b["lengthMm"]).partial_cmp(&f(&a["lengthMm"])).unwrap_or(std::cmp::Ordering::Equal));
// pairs: the caller's, else the board's by name
let asked: Vec<(String, String, String)> = match args.get("pairs").and_then(Value::as_array) {
Some(a) => a.iter().map(|p| {
let (pos, neg) = (txt(&p["pos"]).to_string(), txt(&p["neg"]).to_string());
let name = p["name"].as_str().map(String::from).unwrap_or_else(|| format!("{pos}/{neg}"));
(name, pos, neg)
}).collect(),
None => pairs.to_vec(),
};
let mut pair_rows = Vec::new();
for (name, pos, neg) in &asked {
let (Some(p), Some(n)) = (find(pos), find(neg)) else {
pair_rows.push(json!({"name": name, "pos": pos, "neg": neg, "error": "not nets on this board"}));
continue;
};
let (a, b) = (length_of(&p), length_of(&n));
pair_rows.push(json!({"name": name, "pos": p, "neg": n, "posMm": r3(a), "negMm": r3(b), "skewMm": r3((a - b).abs()), "routed": a > 0.0 && b > 0.0}));
}
// matched-length groups
let mut group_rows = Vec::new();
let mut out_of = 0;
for g in arr(args, "groups") {
let tol = g.get("toleranceMm").and_then(Value::as_f64).unwrap_or(0.5);
let members: Vec<(String, f64)> = arr(g, "nets").filter_map(Value::as_str).map(|n| {
let name = find(n).unwrap_or_else(|| n.to_string());
let len = length_of(&name);
(name, len)
}).collect();
let longest = members.iter().cloned().fold((String::new(), f64::MIN), |acc, m| if m.1 > acc.1 { m } else { acc });
let shortest = members.iter().map(|m| m.1).fold(f64::INFINITY, f64::min);
let spread = if members.is_empty() { 0.0 } else { longest.1 - shortest };
let ok = spread <= tol + 1e-9;
if !ok {
out_of += 1;
}
group_rows.push(json!({"nets": members.iter().map(|(n, l)| json!({"net": n, "lengthMm": r3(*l), "shortByMm": r3(longest.1 - l)})).collect::<Vec<_>>(),
"spreadMm": r3(spread), "toleranceMm": tol, "withinTolerance": ok, "longest": longest.0}));
}
json!({"success": true, "nets": rows, "pairs": pair_rows, "groups": group_rows, "groupsOutOfTolerance": out_of,
"unknownNets": unknown, "unroutedNets": unrouted,
"note": "Routed copper only: tracks and arcs end to end on each layer. Vias are counted, their barrel is not added (KiCad's Net Inspector can add it from the stack-up)."})
}
// ---------------------------------------------------------------------------
// check_orientation
// ---------------------------------------------------------------------------
/// Two-pad parts whose airline would be shorter turned by 90, 180 or 270 degrees about their
/// anchor. A net with more than `planeNetPads` pads counts by the distance to its nearest other
/// pad (a plane reaches it), any other by its minimum spanning tree.
pub fn check_orientation(model: &Value, args: &Value) -> Value {
let min_gain = args.get("minImprovementMm").and_then(Value::as_f64).unwrap_or(0.6);
let plane_pads = args.get("planeNetPads").and_then(Value::as_u64).unwrap_or(12) as usize;
let only = wanted(args, "parts");
// pads by net, and by part
let pads: Vec<(String, String, (f64, f64))> = arr(model, "pads").map(|p| (txt(&p["ref"]).to_string(), txt(&p["net"]).to_string(), (f(&p["x"]), f(&p["y"])))).collect();
let mut by_net: HashMap<&str, Vec<usize>> = HashMap::new();
let mut by_ref: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, p) in pads.iter().enumerate() {
by_ref.entry(p.0.as_str()).or_default().push(i);
if !p.1.is_empty() && !p.1.starts_with("unconnected-") {
by_net.entry(p.1.as_str()).or_default().push(i);
}
}
let cost = |moved: &[(usize, (f64, f64))]| -> f64 {
let at = |i: usize| moved.iter().find(|m| m.0 == i).map(|m| m.1).unwrap_or(pads[i].2);
let mut nets: Vec<&str> = moved.iter().map(|m| pads[m.0].1.as_str()).filter(|n| by_net.contains_key(n)).collect();
nets.sort();
nets.dedup();
nets.iter().map(|n| {
let members = &by_net[n];
if members.len() > plane_pads {
moved.iter().filter(|m| pads[m.0].1 == *n).map(|m| {
members.iter().filter(|j| !moved.iter().any(|x| x.0 == **j)).map(|j| dist(m.1, pads[*j].2)).fold(f64::INFINITY, f64::min)
}).filter(|d| d.is_finite()).sum::<f64>()
} else {
placement::mst_length(&members.iter().map(|j| at(*j)).collect::<Vec<_>>())
}
}).sum()
};
let mut checked = 0;
let mut suggestions = Vec::new();
for c in arr(model, "components") {
let r = txt(&c["ref"]);
if let Some(o) = &only {
if !o.contains(&r.to_ascii_lowercase()) {
continue;
}
}
let Some(mine) = by_ref.get(r) else { continue };
if mine.len() != 2 {
continue;
}
checked += 1;
let anchor = (f(&c["x"]), f(&c["y"]));
let rot = f(&c["rotation"]);
let now: Vec<(usize, (f64, f64))> = mine.iter().map(|i| (*i, pads[*i].2)).collect();
let base = cost(&now);
let mut best = (0.0, base);
for turn in [90.0_f64, 180.0, 270.0] {
let (s, co) = turn.to_radians().sin_cos();
// the model's frame: a positive turn is counterclockwise in y up; flip for y down
let s = if model["frame"] == "kicad" { -s } else { s };
let moved: Vec<(usize, (f64, f64))> = mine.iter().map(|i| {
let (dx, dy) = (pads[*i].2 .0 - anchor.0, pads[*i].2 .1 - anchor.1);
(*i, (anchor.0 + dx * co - dy * s, anchor.1 + dx * s + dy * co))
}).collect();
let c2 = cost(&moved);
if c2 < best.1 - 1e-9 {
best = (turn, c2);
}
}
if best.0 != 0.0 && base - best.1 >= min_gain {
suggestions.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "turnBy": best.0,
"airlineNowMm": r3(base), "airlineThenMm": r3(best.1), "savesMm": r3(base - best.1)}));
}
}
suggestions.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));
json!({"success": true, "checked": checked, "suggestions": suggestions, "minImprovementMm": min_gain, "planeNetPads": plane_pads})
}
// ---------------------------------------------------------------------------
// find_space
// ---------------------------------------------------------------------------
/// Distance from a segment to a rectangle (0 when they touch or cross).
fn seg_rect(a: (f64, f64), b: (f64, f64), r: &Rect) -> f64 {
let inside = |p: (f64, f64)| p.0 >= r.min_x && p.0 <= r.max_x && p.1 >= r.min_y && p.1 <= r.max_y;
if inside(a) || inside(b) {
return 0.0;
}
let corners = [(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)];
let mut d = f64::INFINITY;
for i in 0..4 {
let (c, e) = (corners[i], corners[(i + 1) % 4]);
if segments_cross(a, b, c, e) {
return 0.0;
}
d = d.min(point_seg(c, a, b)).min(point_seg(a, c, e)).min(point_seg(b, c, e));
}
d
}
fn point_seg(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 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };
dist(p, (a.0 + t * dx, a.1 + t * dy))
}
fn segments_cross(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool {
let o = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);
let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));
(d1 > 0.0) != (d2 > 0.0) && (d3 > 0.0) != (d4 > 0.0)
}
fn inside_polygon(p: (f64, f64), poly: &[(f64, f64)]) -> bool {
let mut inside = false;
let n = poly.len();
for i in 0..n {
let (a, b) = (poly[i], poly[(i + n - 1) % n]);
if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {
inside = !inside;
}
}
inside
}
/// Free spots for a part near another, or (`check`) the parts that overlap or leave the outline.
/// `outline` is the board outline in KiCad's frame (y down); the reply is in the standard's frame
/// when `y_up`.
pub fn find_space(board: &Board, outline: &[(f64, f64)], args: &Value, y_up: bool) -> Value {
let fy = |y: f64| if y_up { -y } else { y };
let places = placement::placements(board);
if args.get("check").and_then(Value::as_bool).unwrap_or(false) {
let overlaps: Vec<Value> = placement::overlaps(&places).iter().map(|o| json!([o.a, o.b])).collect();
let outside: Vec<&str> = places.iter().filter(|p| p.inside_outline == Some(false)).map(|p| p.reference.as_str()).collect();
return json!({"success": true, "check": true, "overlaps": overlaps, "outsideOutline": outside, "ok": overlaps.is_empty() && outside.is_empty(),
"note": "Courtyards decide (their real outline where the footprint has one, else the box of its pads plus 0.25 mm)."});
}
let clearance = args.get("clearanceMm").and_then(Value::as_f64).unwrap_or(0.25);
let edge = args.get("edgeMm").and_then(Value::as_f64).unwrap_or(0.3);
let radius = args.get("radiusMm").and_then(Value::as_f64).unwrap_or(10.0);
let count = args.get("count").and_then(Value::as_u64).unwrap_or(5) as usize;
let copper = args.get("copper").and_then(Value::as_bool).unwrap_or(true);
let part = args.get("part").and_then(Value::as_str);
let me = part.and_then(|r| places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)));
if part.is_some() && me.is_none() {
return json!({"success": false, "errorCode": "no_such_part", "error": format!("{} is not on this board", part.unwrap_or(""))});
}
// the part's courtyard relative to its anchor, or a box of sizeMm round the spot
let (dx0, dy0, dx1, dy1) = match (me, args.get("sizeMm").and_then(Value::as_array)) {
(Some(p), _) => (p.courtyard.min_x - p.x, p.courtyard.min_y - p.y, p.courtyard.max_x - p.x, p.courtyard.max_y - p.y),
(None, Some(s)) if s.len() == 2 => (-f(&s[0]) / 2.0, -f(&s[1]) / 2.0, f(&s[0]) / 2.0, f(&s[1]) / 2.0),
_ => return json!({"success": false, "errorCode": "bad_args", "error": "name the part to place (part: \"U3\") or its size (sizeMm: [w, h])"}),
};
let side = match args.get("side").and_then(Value::as_str) {
Some("bottom") | Some("B.Cu") => "B.Cu".to_string(),
Some(_) => "F.Cu".to_string(),
None => me.map(|p| p.side.clone()).unwrap_or_else(|| "F.Cu".into()),
};
let centre = match (args.get("near").and_then(Value::as_str), args.get("at")) {
(Some(r), _) => match places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)) {
Some(p) => (p.x, p.y),
None => return json!({"success": false, "errorCode": "no_such_part", "error": format!("{r} is not on this board")}),
},
(None, Some(at)) if !at.is_null() => {
let (x, y) = xy(at);
(x, fy(y))
}
_ => match me {
Some(p) => (p.x, p.y),
None => return json!({"success": false, "errorCode": "bad_args", "error": "near (a designator) or at ({x, y} mm) says where to look"}),
},
};
if outline.len() < 3 {
return json!({"success": false, "errorCode": "no_outline", "error": "the board has no closed outline on Edge.Cuts"});
}
let others: Vec<&placement::Placement> = places.iter().filter(|p| p.side == side && me.map_or(true, |m| m.reference != p.reference)).collect();
// copper on the part's own nets is no obstacle: it is the part's own routing, or the same net
// (numbered nets in older files, names in KiCad 10's)
let own: Vec<(Option<i64>, String)> = me.map(|m| crate::pcb::pads(board).into_iter().filter(|p| p.reference == m.reference && !p.net_name.is_empty())
.map(|p| (p.net, p.net_name)).collect()).unwrap_or_default();
let foreign = |net: Option<i64>, name: Option<&String>| !own.iter().any(|(n, nm)| (net.is_some() && *n == net && net != Some(0)) || name == Some(nm));
let reach = radius + (dx1 - dx0).max(dy1 - dy0) + 2.0;
let near = |a: (f64, f64)| dist(a, centre) <= reach;
let tracks: Vec<((f64, f64), (f64, f64), f64)> = if copper {
board.segments.iter().filter(|s| s.layer.as_deref() == Some(side.as_str()) && foreign(s.net, s.net_name.as_ref())).filter_map(|s| {
let (a, b) = (s.start.as_ref()?, s.end.as_ref()?);
let (a, b) = ((a.x, a.y), (b.x, b.y));
(near(a) || near(b)).then_some((a, b, s.width.unwrap_or(0.2) / 2.0))
}).collect()
} else {
Vec::new()
};
let vias: Vec<((f64, f64), f64)> = if copper {
board.vias.iter().filter(|v| foreign(v.net, v.net_name.as_ref())).filter_map(|v| { let p = v.position.as_ref()?; let a = (p.x, p.y); near(a).then_some((a, v.size.unwrap_or(0.6) / 2.0)) }).collect()
} else {
Vec::new()
};
let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len()])).collect();
let free = |c: (f64, f64)| -> bool {
let r = Rect { min_x: c.0 + dx0, min_y: c.1 + dy0, max_x: c.0 + dx1, max_y: c.1 + dy1 };
let corners = [(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)];
if !corners.iter().all(|p| inside_polygon(*p, outline)) || edges.iter().any(|(a, b)| seg_rect(*a, *b, &r) < edge) {
return false;
}
let grown = r.expand(clearance);
if others.iter().any(|p| p.courtyard.overlaps(&grown)) {
return false;
}
tracks.iter().all(|(a, b, hw)| seg_rect(*a, *b, &r) > hw + clearance) && vias.iter().all(|(p, rad)| seg_rect(*p, *p, &r) > rad + clearance)
};
let step = ((dx1 - dx0).min(dy1 - dy0) / 4.0).clamp(0.1, 0.5);
let n = (radius / step).ceil() as i64;
let mut cands: Vec<(f64, (f64, f64))> = Vec::new();
for i in -n..=n {
for j in -n..=n {
let c = (centre.0 + i as f64 * step, centre.1 + j as f64 * step);
let d = dist(c, centre);
if d <= radius {
cands.push((d, c));
}
}
}
cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
let spread = ((dx1 - dx0).max(dy1 - dy0) / 2.0).max(step * 2.0);
let mut spots: Vec<(f64, (f64, f64))> = Vec::new();
for (d, c) in cands {
if spots.len() >= count {
break;
}
if spots.iter().any(|s| dist(s.1, c) < spread) {
continue;
}
if free(c) {
spots.push((d, c));
}
}
let rot = me.map(|p| p.rotation).unwrap_or(0.0);
json!({"success": true, "part": me.map(|p| p.reference.clone()), "side": if side == "B.Cu" { "bottom" } else { "top" },
"near": {"x": r3(centre.0), "y": r3(fy(centre.1))}, "sizeMm": [r3(dx1 - dx0), r3(dy1 - dy0)],
"spots": spots.iter().map(|(d, c)| {
let (ya, yb) = (fy(c.1 + dy0), fy(c.1 + dy1));
json!({"x": r3(c.0), "y": r3(fy(c.1)), "rotation": rot, "distanceMm": r3(*d), "extent": [r3(c.0 + dx0), r3(ya.min(yb)), r3(c.0 + dx1), r3(ya.max(yb))]})
}).collect::<Vec<_>>(),
"searched": {"radiusMm": radius, "stepMm": r3(step), "clearanceMm": clearance, "edgeMm": edge, "copper": copper}})
}
// ---------------------------------------------------------------------------
// pad distance
// ---------------------------------------------------------------------------
/// Distance from `p` to a pad's copper: its rectangle (turned by its rotation) or circle.
fn pad_distance(p: (f64, f64), pad: &Value, y_down: bool) -> f64 {
let c = (f(&pad["x"]), f(&pad["y"]));
let (w, h) = (f(&pad["sizeX"]), f(&pad["sizeY"]));
if txt(&pad["shape"]) == "circle" {
return dist(p, c) - w.max(h) / 2.0;
}
let (s, co) = f(&pad["rotation"]).to_radians().sin_cos();
let s = if y_down { -s } else { s };
// into the pad's own frame (the inverse turn)
let (dx, dy) = (p.0 - c.0, p.1 - c.1);
let (lx, ly) = (dx * co + dy * s, -dx * s + dy * co);
let (ox, oy) = ((lx.abs() - w / 2.0).max(0.0), (ly.abs() - h / 2.0).max(0.0));
(ox * ox + oy * oy).sqrt()
}
// ---------------------------------------------------------------------------
// silk_designators
// ---------------------------------------------------------------------------
/// A label's box: KiCad's stroke font is about 0.9 of the height wide per character, plus the stroke.
fn label_box(c: (f64, f64), text: &str, size: f64) -> Rect {
let stroke = size * 0.15;
let (w, h) = (text.chars().count().max(1) as f64 * size * 0.9 + stroke, size + stroke);
Rect { min_x: c.0 - w / 2.0, min_y: c.1 - h / 2.0, max_x: c.0 + w / 2.0, max_y: c.1 + h / 2.0 }
}
/// A pad's bounding box, turned by its rotation.
fn pad_box(p: &Value) -> Rect {
let (w, h) = (f(&p["sizeX"]), f(&p["sizeY"]));
let (s, c) = f(&p["rotation"]).to_radians().sin_cos();
let (hx, hy) = ((w / 2.0 * c).abs() + (h / 2.0 * s).abs(), (w / 2.0 * s).abs() + (h / 2.0 * c).abs());
let (x, y) = (f(&p["x"]), f(&p["y"]));
Rect { min_x: x - hx, min_y: y - hy, max_x: x + hx, max_y: y + hy }
}
fn rect_of(v: &Value) -> Option<Rect> {
let a = v.as_array()?;
(a.len() == 4).then(|| Rect { min_x: f(&a[0]).min(f(&a[2])), min_y: f(&a[1]).min(f(&a[3])), max_x: f(&a[0]).max(f(&a[2])), max_y: f(&a[1]).max(f(&a[3])) })
}
/// Where every reference designator goes: one size, upright, beside its own part (above, below,
/// right, left, slid along each side, then the corners, last on its own body when it fits there), clear of every pad by `padClearance`, of every other part's body
/// and of the labels already placed, inside the board. Bigger parts choose first. `silk` is
/// kicad_silk_state and `model` the board model, both in KiCad's frame (y down); `outline` likewise.
pub fn silk_designators(silk: &Value, model: &Value, outline: &[(f64, f64)], args: &Value) -> Value {
let size = args.get("size").and_then(Value::as_f64).unwrap_or(1.0);
let min_size = args.get("minSize").and_then(Value::as_f64).unwrap_or(size).min(size);
let gap = args.get("gap").and_then(Value::as_f64).unwrap_or(0.2);
let pad_clear = args.get("padClearance").and_then(Value::as_f64).unwrap_or(0.15);
let only = wanted(args, "refs");
let pads: Vec<(String, String, Rect)> = arr(model, "pads").map(|p| (txt(&p["ref"]).to_string(), txt(&p["layer"]).to_string(), pad_box(p))).collect();
let on_side = |layer: &str, side: &str| layer == "*.Cu" || (side == "bottom") == layer.starts_with("B.");
let parts: Vec<&Value> = arr(silk, "parts").filter(|p| p["designator"]["id"].is_string()).collect();
// a part's box: its body (fab drawing, else courtyard), else its pads
let body = |p: &Value| -> Option<Rect> {
rect_of(&p["bodyBox"]).or_else(|| {
let r = txt(&p["ref"]);
pads.iter().filter(|q| q.0 == r).map(|q| q.2).reduce(|a, b| Rect { min_x: a.min_x.min(b.min_x), min_y: a.min_y.min(b.min_y), max_x: a.max_x.max(b.max_x), max_y: a.max_y.max(b.max_y) })
})
};
let bodies: Vec<(String, String, Rect)> = parts.iter().filter_map(|p| Some((txt(&p["ref"]).to_string(), txt(&p["side"]).to_string(), body(p)?))).collect();
let on_pads = |r: &Rect, side: &str| -> Vec<Value> {
let grown = r.expand(pad_clear - 1e-9);
pads.iter().filter(|q| on_side(&q.1, side) && q.2.overlaps(&grown)).map(|q| json!({"ref": q.0})).collect()
};
// labels as they stand, on pads or not
let mut before = Vec::new();
for p in &parts {
let d = &p["designator"];
if d["shown"] == false {
continue;
}
let (x, y) = xy(&d["at"]);
let hits = on_pads(&label_box((x, y), txt(&d["text"]), d["heightMm"].as_f64().unwrap_or(1.0)), txt(&p["side"]));
if !hits.is_empty() {
before.push(json!({"ref": p["ref"], "pads": hits.len()}));
}
}
let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len().max(1)])).collect();
let inside = |r: &Rect| outline.len() < 3 || ([(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)].iter().all(|c| inside_polygon(*c, outline))
&& edges.iter().all(|(a, b)| seg_rect(*a, *b, r) >= 0.2));
let mut order: Vec<(&Value, Rect)> = parts.iter().filter(|p| only.as_ref().map_or(true, |o| o.contains(&txt(&p["ref"]).to_ascii_lowercase()))).filter_map(|p| Some((*p, body(p)?))).collect();
order.sort_by(|a, b| (b.1.width() * b.1.height()).partial_cmp(&(a.1.width() * a.1.height())).unwrap_or(std::cmp::Ordering::Equal));
// the silkscreen already there: every line (footprint outlines included) and the board's own texts
let silk_clear = args.get("silkClearance").and_then(Value::as_f64).unwrap_or(0.15);
let lines: Vec<(String, (f64, f64), (f64, f64), f64)> = arr(silk, "lines").map(|l| (txt(&l["side"]).to_string(), (f(&l["x1"]), f(&l["y1"])), (f(&l["x2"]), f(&l["y2"])), f(&l["width"]))).collect();
let texts: Vec<(String, Rect)> = arr(silk, "texts").map(|t| (txt(&t["side"]).to_string(), label_box((f(&t["x"]), f(&t["y"])), txt(&t["text"]), t["size"].as_f64().unwrap_or(1.0)))).collect();
let clear_of_silk = |r: &Rect, side: &str| lines.iter().all(|l| l.0 != side || seg_rect(l.1, l.2, r) >= l.3 / 2.0 + silk_clear)
&& texts.iter().all(|t| t.0 != side || !t.1.expand(silk_clear).overlaps(r));
let mut placed_boxes: Vec<(String, Rect)> = Vec::new();
let (mut planned, mut unplaced) = (Vec::new(), Vec::new());
for (p, b) in order {
let (r, side, text) = (txt(&p["ref"]), txt(&p["side"]), txt(&p["designator"]["text"]));
let (cx, cy) = ((b.min_x + b.max_x) / 2.0, (b.min_y + b.max_y) / 2.0);
let mut found = None;
let mut s = size;
while found.is_none() && s >= min_size - 1e-9 {
let lb = label_box((0.0, 0.0), text, s);
let (hw, hh) = (lb.width() / 2.0, lb.height() / 2.0);
// KiCad's frame is y down: "above" is the smaller y
// centred on each side first, then slid along it to either end, then the corners, then on
// the part's own body (a big IC or connector) when no pad is under it
let (ax, ay) = ((b.width() / 2.0 - hw).max(0.0), (b.height() / 2.0 - hh).max(0.0));
let spots = [("above", cx, b.min_y - gap - hh), ("below", cx, b.max_y + gap + hh), ("right", b.max_x + gap + hw, cy), ("left", b.min_x - gap - hw, cy),
("above", cx - ax, b.min_y - gap - hh), ("above", cx + ax, b.min_y - gap - hh), ("below", cx - ax, b.max_y + gap + hh), ("below", cx + ax, b.max_y + gap + hh),
("right", b.max_x + gap + hw, cy - ay), ("right", b.max_x + gap + hw, cy + ay), ("left", b.min_x - gap - hw, cy - ay), ("left", b.min_x - gap - hw, cy + ay),
("above right", b.max_x + gap + hw, b.min_y - gap - hh), ("above left", b.min_x - gap - hw, b.min_y - gap - hh),
("below right", b.max_x + gap + hw, b.max_y + gap + hh), ("below left", b.min_x - gap - hw, b.max_y + gap + hh),
("on the body", cx, cy)];
for (at, x, y) in spots {
let rect = label_box((x, y), text, s);
if at == "on the body" && !b.contains(&rect) {
continue;
}
let clear = on_pads(&rect, side).is_empty()
&& bodies.iter().all(|o| o.0 == r || o.1 != side || !o.2.overlaps(&rect))
&& placed_boxes.iter().all(|q| !q.1.expand(silk_clear).overlaps(&rect))
&& clear_of_silk(&rect, side)
&& inside(&rect);
if clear {
found = Some((at, x, y, s, rect));
break;
}
}
s = ((s - 0.1) * 1000.0).round() / 1000.0;
}
match found {
Some((at, x, y, s, rect)) => {
placed_boxes.push((r.to_string(), rect));
planned.push(json!({"ref": r, "id": p["designator"]["id"], "layer": p["designator"]["layer"], "x": r3(x), "y": r3(y), "size": s, "rotation": 0.0, "side": side, "at": at}));
}
None => unplaced.push(json!({"ref": r, "why": format!("no spot beside it at {min_size} to {size} mm clear of pads, other parts, labels and the silkscreen")})),
}
}
planned.sort_by_key(|p| placement::ref_key(txt(&p["ref"])));
json!({"planned": planned, "unplaced": unplaced, "onPadsBefore": before, "size": size, "minSize": min_size, "gap": gap, "padClearance": pad_clear, "silkClearance": silk_clear})
}
// ---------------------------------------------------------------------------
// length_tune
// ---------------------------------------------------------------------------
fn routed_length(model: &Value, net: &str) -> Option<f64> {
let v = net_lengths(model, &json!({"nets": [net]}), &[]);
v["nets"][0]["lengthMm"].as_f64().or_else(|| v["unroutedNets"].as_array().filter(|a| !a.is_empty()).map(|_| 0.0))
}
/// The plan to lengthen `net` to `targetMm` (or to `matchTo`'s length): a serpentine of square bumps
/// in its longest straight track that has room on one side, the bumps sized to add exactly the
/// length missing (each adds twice its amplitude), clear of other nets' copper, pads, vias and the
/// edge by `clearance`. `board` gives the tracks' ids, `model` (KiCad's frame) the obstacles.
pub fn length_tune(board: &Board, model: &Value, args: &Value) -> Result<Value, (String, String)> {
let bad = |c: &str, m: String| (c.to_string(), m);
let net = args.get("net").and_then(Value::as_str).ok_or_else(|| bad("missing_arg", "net names the net to lengthen".into()))?;
let before = routed_length(model, net).ok_or_else(|| bad("no_such_net", format!("{net} is not a net on this board")))?;
let target = match (args.get("targetMm").and_then(Value::as_f64), args.get("matchTo").and_then(Value::as_str)) {
(Some(t), _) => t,
(None, Some(m)) => routed_length(model, m).ok_or_else(|| bad("no_such_net", format!("{m} is not a net on this board")))?,
_ => return Err(bad("missing_arg", "targetMm (the length it should have) or matchTo (a net whose length to match)".into())),
};
let missing = target - before;
let base = json!({"net": net, "lengthBeforeMm": r3(before), "targetMm": r3(target)});
if missing <= 0.01 {
let mut v = base;
v["changed"] = json!(false);
v["lengthMm"] = json!(r3(before));
v["errorMm"] = json!(r3(target - before));
v["note"] = json!(if missing < -0.01 { "already longer than the target: shorten it by rerouting (this verb only adds length)" } else { "already at the target" });
return Ok(v);
}
let clr = args.get("clearance").and_then(Value::as_f64).unwrap_or(0.2);
let max_amp = args.get("maxAmplitudeMm").and_then(Value::as_f64).unwrap_or(3.0);
// obstacles: other nets' tracks (by layer), every pad, every via, the outline
let tracks: Vec<(String, String, (f64, f64), (f64, f64), f64)> = arr(model, "tracks").map(|t| (txt(&t["net"]).to_string(), txt(&t["layer"]).to_string(), xy(&t["start"]), xy(&t["end"]), f(&t["width"]))).collect();
let pads: Vec<&Value> = arr(model, "pads").collect();
let vias: Vec<((f64, f64), f64)> = arr(model, "vias").map(|v| (xy(&v["position"]), f(&v["size"]) / 2.0)).collect();
let outline: Vec<(f64, f64)> = arr(model, "outline").map(xy).collect();
let edges: Vec<((f64, f64), (f64, f64))> = (0..outline.len()).map(|i| (outline[i], outline[(i + 1) % outline.len()])).collect();
let mut segs: Vec<&crate::pcb::Segment> = board.segments.iter().filter(|s| s.net_name.as_deref() == Some(net) || board.nets.iter().any(|n| n.number.is_some() && n.number == s.net && n.name == net)).collect();
let len = |s: &&crate::pcb::Segment| match (&s.start, &s.end) { (Some(a), Some(b)) => dist((a.x, a.y), (b.x, b.y)), _ => 0.0 };
segs.sort_by(|a, b| len(b).partial_cmp(&len(a)).unwrap_or(std::cmp::Ordering::Equal));
let mut tried = 0;
for s in segs {
let (Some(a), Some(b), Some(w), Some(layer), Some(id)) = (&s.start, &s.end, s.width, s.layer.as_deref(), s.uuid.as_deref()) else { continue };
let (a, b) = ((a.x, a.y), (b.x, b.y));
let l = dist(a, b);
let pitch = args.get("pitchMm").and_then(Value::as_f64).unwrap_or((4.0 * w).max(4.0 * clr + 2.0 * w));
let margin = (pitch / 2.0).max(2.0 * w);
let usable = l - 2.0 * margin;
let n_max = (usable / pitch).floor() as usize;
if n_max == 0 {
continue;
}
// the fewest bumps whose amplitude stays under the cap (and at least a width, so the legs read as bumps)
let Some(n) = (1..=n_max).find(|n| missing / (2.0 * *n as f64) <= max_amp) else { continue };
let amp = missing / (2.0 * n as f64);
let u = ((b.0 - a.0) / l, (b.1 - a.1) / l);
let s0 = margin + (usable - n as f64 * pitch) / 2.0;
for side in [1.0, -1.0] {
tried += 1;
let nrm = (-u.1 * side, u.0 * side);
let at = |along: f64, up: f64| (a.0 + u.0 * along + nrm.0 * up, a.1 + u.1 * along + nrm.1 * up);
// the swept area: every bump's three new legs, kept clear of other copper by clr
let mut legs: Vec<((f64, f64), (f64, f64))> = Vec::new();
let mut pts = vec![a];
for i in 0..n {
let (xa, xb) = (s0 + i as f64 * pitch + pitch / 4.0, s0 + i as f64 * pitch + 3.0 * pitch / 4.0);
let (p1, p2, p3, p4) = (at(xa, 0.0), at(xa, amp), at(xb, amp), at(xb, 0.0));
legs.extend([(p1, p2), (p2, p3), (p3, p4)]);
pts.extend([p1, p2, p3, p4]);
}
pts.push(b);
let keep = w / 2.0 + clr;
let clear = legs.iter().all(|(p, q)| {
tracks.iter().all(|t| t.0 == net || t.1 != layer || seg_seg(*p, *q, t.2, t.3) >= keep + t.4 / 2.0)
&& pads.iter().all(|pd| txt(&pd["net"]) == net || seg_pad(*p, *q, pd) >= keep)
&& vias.iter().all(|(c, r)| point_seg(*c, *p, *q) >= keep + r)
&& (outline.len() < 3 || (inside_polygon(*p, &outline) && inside_polygon(*q, &outline) && edges.iter().all(|(e0, e1)| seg_seg(*p, *q, *e0, *e1) >= keep)))
});
if clear {
let added = 2.0 * amp * n as f64;
let mut v = base;
v["changed"] = json!(true);
v["lengthMm"] = json!(r3(before + added));
v["errorMm"] = json!(r3(target - before - added));
v["plan"] = json!({"segmentId": id, "layer": layer, "width": w, "bumps": n, "amplitudeMm": r3(amp), "pitchMm": r3(pitch), "addedMm": r3(added),
"side": if side > 0.0 { "left" } else { "right" }, "points": pts.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>()});
return Ok(v);
}
}
}
Err(bad("no_room", format!("no straight track of {net} has room for {} mm of serpentine (tried {tried} side(s); amplitude at most {max_amp} mm, {clr} mm clearance)", r3(missing))))
}
/// Distance between two segments (0 when they cross).
fn seg_seg(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> f64 {
if segments_cross(a, b, c, d) {
return 0.0;
}
point_seg(a, c, d).min(point_seg(b, c, d)).min(point_seg(c, a, b)).min(point_seg(d, a, b))
}
/// Distance from a segment to a pad's copper (its turned rectangle, sampled along the segment).
fn seg_pad(a: (f64, f64), b: (f64, f64), pad: &Value) -> f64 {
let steps = ((dist(a, b) / 0.05).ceil() as usize).clamp(1, 400);
(0..=steps).map(|i| { let t = i as f64 / steps as f64; pad_distance((a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t), pad, true) }).fold(f64::INFINITY, f64::min)
}
// ---------------------------------------------------------------------------
// silkscreen_prep (the text steps)
// ---------------------------------------------------------------------------
/// The board with its Gerbers set to subtract the solder mask from the silkscreen (KiCad's plot
/// option `subtractmaskfromsilk`): the fab output carries no ink on pads. Returns the text and
/// whether it changed.
pub fn subtract_mask_from_silk(text: &str) -> (String, bool) {
if text.contains("(subtractmaskfromsilk yes)") {
return (text.to_string(), false);
}
if text.contains("(subtractmaskfromsilk no)") {
return (text.replacen("(subtractmaskfromsilk no)", "(subtractmaskfromsilk yes)", 1), true);
}
match text.find("(pcbplotparams") {
Some(i) => {
let at = i + "(pcbplotparams".len();
(format!("{} (subtractmaskfromsilk yes){}", &text[..at], &text[at..]), true)
}
None => (text.to_string(), false),
}
}
/// The board without its own silkscreen items (gr_line, gr_rect, gr_text ... on F/B.SilkS) that lie
/// wholly outside the outline. Returns the text and the removed items' uuids.
pub fn silk_outside_board(text: &str, outline: &[(f64, f64)]) -> (String, Vec<String>) {
let Ok(root) = crate::sxedit::parse(text) else { return (text.to_string(), Vec::new()) };
if outline.len() < 3 {
return (text.to_string(), Vec::new());
}
let mut cut: Vec<(usize, usize, String)> = Vec::new();
for k in root.kids.iter().filter(|k| k.tag().map_or(false, |t| t.starts_with("gr_")) && matches!(k.value_of("layer"), Some("F.SilkS" | "B.SilkS"))) {
let mut pts: Vec<(f64, f64)> = Vec::new();
k.walk(&mut |n| {
if matches!(n.tag(), Some("start" | "end" | "mid" | "center" | "at" | "xy")) {
if let (Some(x), Some(y)) = (n.f64_at(1), n.f64_at(2)) {
pts.push((x, y));
}
}
});
if !pts.is_empty() && pts.iter().all(|p| !inside_polygon(*p, outline)) {
cut.push((k.start, k.end, k.uuid().unwrap_or("").to_string()));
}
}
let mut out = text.to_string();
for (a, b, _) in cut.iter().rev() {
let mut a2 = *a;
while a2 > 0 && matches!(out.as_bytes()[a2 - 1], b' ' | b'\t') {
a2 -= 1;
}
if a2 > 0 && out.as_bytes()[a2 - 1] == b'\n' {
a2 -= 1;
}
out.replace_range(a2..*b, "");
}
(out, cut.into_iter().map(|c| c.2).collect())
}
// ---------------------------------------------------------------------------
// auto_classes
// ---------------------------------------------------------------------------
/// The last part of a hierarchical net name, upper case (`/power/+3V3` is `+3V3`).
fn leaf(net: &str) -> String {
net.rsplit('/').next().unwrap_or(net).to_ascii_uppercase()
}
fn tokens(n: &str) -> Vec<String> {
n.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| !t.is_empty()).map(String::from).collect()
}
/// The class a net's name says it belongs to: GND, Power, Clock or HighSpeed.
pub fn class_of_net(net: &str) -> Option<&'static str> {
let n = leaf(net);
let t = tokens(&n);
let has = |words: &[&str]| t.iter().any(|x| words.contains(&x.as_str()));
let starts = |pre: &[&str]| t.iter().any(|x| pre.iter().any(|p| x.starts_with(p)));
if n.starts_with("UNCONNECTED") || n.starts_with("NET-(") {
return None;
}
if has(&["GND", "AGND", "DGND", "PGND", "SGND", "CGND", "GNDA", "GNDD", "GNDPWR", "VSS", "VSSA", "EARTH", "0V"]) || t.iter().any(|x| x.starts_with("GND") && x[3..].chars().all(|c| c.is_ascii_digit())) {
return Some("GND");
}
let volts = |x: &str| {
// 3V3, 5V, 12V, 1V8, V3V3: a number with a V in it
let x = x.strip_prefix('V').filter(|r| r.starts_with(|c: char| c.is_ascii_digit())).unwrap_or(x);
x.starts_with(|c: char| c.is_ascii_digit()) && x.contains('V') && x.chars().all(|c| c.is_ascii_digit() || c == 'V')
};
if n.starts_with(['+', '-']) && n[1..].starts_with(|c: char| c.is_ascii_digit()) || t.iter().any(|x| volts(x))
|| starts(&["VCC", "VDD", "VEE", "VBAT", "VIN", "VBUS", "VSYS", "VOUT", "VREG", "VMOT", "VPP", "VIO", "AVDD", "DVDD", "AVCC", "DVCC", "PVDD", "VCORE", "VLED"])
|| has(&["VM", "PWR", "POWER", "VSUP", "VS", "VDC"])
{
return Some("Power");
}
if has(&["USB", "ETH", "RGMII", "RMII", "MDI", "HDMI", "PCIE", "DDR", "DQS", "MIPI", "CSI", "DSI", "LVDS", "SATA", "SERDES", "TMDS"])
|| starts(&["USB", "ETH", "HDMI", "PCIE", "DDR", "MIPI", "LVDS", "SATA", "RGMII", "TMDS"])
|| t.iter().any(|x| x.starts_with("DQ") && x[2..].chars().all(|c| c.is_ascii_digit()) && x.len() > 2)
{
return Some("HighSpeed");
}
if has(&["CLK", "SCK", "SCLK", "MCLK", "BCLK", "LRCLK", "XTAL", "XTAL1", "XTAL2", "OSC", "XIN", "XOUT", "XI", "XO", "REFCLK", "HSE", "LSE"])
|| t.iter().any(|x| x.ends_with("CLK") || x.starts_with("CLK") || x.starts_with("OSC") || x.starts_with("XTAL"))
{
return Some("Clock");
}
None
}
/// The proposal: the nets of each class, the pairs, and what is left.
pub fn auto_classes(nets: &[String], pairs: &[(String, String, String)]) -> Value {
let mut classes: BTreeMap<&str, Vec<String>> = ["GND", "Power", "Clock", "HighSpeed"].iter().map(|c| (*c, Vec::new())).collect();
let mut rest = Vec::new();
let paired = |n: &str| pairs.iter().any(|(_, p, m)| p == n || m == n);
for n in nets {
if n.is_empty() || n.to_ascii_lowercase().starts_with("unconnected-") {
continue;
}
// a differential pair the names spell is high speed whatever else its name says (USB's bare D+/D-)
match class_of_net(n).or_else(|| paired(n).then_some("HighSpeed")) {
Some(c) => classes.get_mut(c).unwrap().push(n.clone()),
None => rest.push(n.clone()),
}
}
json!({"classes": classes, "pairs": pairs.iter().map(|(n, p, m)| json!({"name": n, "pos": p, "neg": m})).collect::<Vec<_>>(), "unclassified": rest})
}
// ---------------------------------------------------------------------------
// pin_pad_check
// ---------------------------------------------------------------------------
/// Pads that are expected to have no pin: mounting, shield, exposed and thermal pads.
fn exempt_pad(name: &str) -> bool {
let u = name.to_ascii_uppercase();
u.is_empty() || u == "0" || ["MH", "MP", "SH", "EP", "PAD", "TAB"].iter().any(|p| u.starts_with(p))
}
/// Symbol pins (from the netlist: every pin of every symbol, connected or not) against footprint
/// pads (from the board), part by part.
pub fn pin_pad_check(board: &Board, text: &str, nl: &Netlist) -> Value {
let mut pins: BTreeMap<String, Vec<String>> = BTreeMap::new();
for c in &nl.components {
pins.entry(c.reference.clone()).or_default();
}
for n in &nl.nets {
for node in &n.nodes {
pins.entry(node.reference.clone()).or_default().push(node.pin.clone());
}
}
let mut mismatches = Vec::new();
let mut matching = 0;
let mut on_board_only = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for fp in &board.footprints {
let r = fp.reference.as_str();
if r.is_empty() || seen.contains(&r) {
continue;
}
seen.push(r);
let Some(p) = pins.get(r) else {
let board_only = crate::pcb_sync::footprint_span(text, r).map(|(s, e)| text[s..e].contains("board_only")).unwrap_or(false);
if !board_only {
on_board_only.push(json!(r));
}
continue;
};
let mut pads: Vec<String> = fp.pads.iter().map(|p| p.number.clone()).collect();
pads.sort_by_key(|p| placement::ref_key(p));
pads.dedup();
let mut pins_sorted = p.clone();
pins_sorted.sort_by_key(|p| placement::ref_key(p));
pins_sorted.dedup();
let without_pad: Vec<&String> = pins_sorted.iter().filter(|x| !pads.contains(x)).collect();
let without_pin: Vec<&String> = pads.iter().filter(|x| !pins_sorted.contains(x) && !exempt_pad(x)).collect();
if without_pad.is_empty() && without_pin.is_empty() {
matching += 1;
} else {
mismatches.push(json!({"ref": r, "footprint": fp.footprint, "pinsWithoutPad": without_pad, "padsWithoutPin": without_pin, "pins": pins_sorted, "pads": pads}));
}
}
let on_sheets_only: Vec<&String> = pins.keys().filter(|r| !seen.contains(&r.as_str())).collect();
let ok = mismatches.iter().all(|m| m["pinsWithoutPad"].as_array().map_or(true, |a| a.is_empty()));
json!({"success": true, "ok": ok, "matching": matching, "mismatches": mismatches, "onBoardOnly": on_board_only, "onSheetsOnly": on_sheets_only})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lengths_add_tracks_and_arcs_by_layer() {
let m = json!({"nets": ["A", "B", "C"],
"tracks": [{"net": "A", "layer": "F.Cu", "start": {"x": 0, "y": 0}, "end": {"x": 3, "y": 4}}, {"net": "B", "layer": "B.Cu", "start": [0, 0], "end": [0, 2]}],
"arcs": [{"net": "A", "layer": "B.Cu", "radius": 2.0, "startAngle": 350.0, "endAngle": 80.0}],
"vias": [{"net": "A"}]});
let v = net_lengths(&m, &json!({"groups": [{"nets": ["A", "B"], "toleranceMm": 1.0}]}), &[("AB".into(), "A".into(), "B".into())]);
let a = &v["nets"][0];
assert_eq!(a["net"], "A");
// 5 mm straight plus a quarter circle of radius 2 (sweep 350 -> 80 is 90 degrees)
assert!((f(&a["lengthMm"]) - (5.0 + std::f64::consts::PI)).abs() < 0.01, "{v}");
assert_eq!(a["vias"], 1);
assert_eq!(v["pairs"][0]["skewMm"], r3(5.0 + std::f64::consts::PI - 2.0));
assert_eq!(v["groups"][0]["withinTolerance"], false);
assert_eq!(v["groups"][0]["longest"], "A");
let named = net_lengths(&m, &json!({"nets": ["c", "nope"]}), &[]);
assert_eq!(named["unroutedNets"], json!(["C"]));
assert_eq!(named["unknownNets"], json!(["nope"]));
}
#[test]
fn a_turned_resistor_is_suggested_when_it_shortens_the_airline() {
// R1's pads sit on x; its nets go up and down from them: turned 90 they line up
let m = json!({"frame": "standard", "components": [{"ref": "R1", "x": 0, "y": 0, "rotation": 0}],
"pads": [{"ref": "R1", "net": "A", "x": -1, "y": 0}, {"ref": "R1", "net": "B", "x": 1, "y": 0},
{"ref": "U1", "net": "A", "x": 0, "y": 10}, {"ref": "U2", "net": "B", "x": 0, "y": -10}]});
let v = check_orientation(&m, &json!({}));
assert_eq!(v["checked"], 1);
let s = &v["suggestions"][0];
assert!(f(&s["savesMm"]) > 1.0, "{v}");
assert!(s["suggested"] == json!(90.0) || s["suggested"] == json!(270.0), "{v}");
}
#[test]
fn classes_come_from_the_names() {
for (n, c) in [("GND", Some("GND")), ("/power/+3V3", Some("Power")), ("VBAT", Some("Power")), ("+12V", Some("Power")), ("USB_D+", Some("HighSpeed")),
("SPI_SCK", Some("Clock")), ("XTAL_IN", Some("Clock")), ("DDR_DQ3", Some("HighSpeed")), ("LED1", None), ("/DRV_SHA", None), ("AGND", Some("GND"))] {
assert_eq!(class_of_net(n), c, "{n}");
}
}
#[test]
fn designators_land_beside_their_part_and_off_every_pad() {
// R1 is a 2-pad part at the origin; C1 sits right above it, so R1's label goes below
let silk = json!({"parts": [
{"ref": "R1", "side": "top", "bodyBox": [-1.0, -0.5, 1.0, 0.5], "designator": {"id": "u1", "text": "R1", "at": [0.0, 0.0], "heightMm": 1.0, "layer": "F.SilkS", "shown": true}},
{"ref": "C1", "side": "top", "bodyBox": [-1.0, -2.6, 1.0, -1.6], "designator": {"id": "u2", "text": "C1", "at": [0.0, -2.1], "heightMm": 1.0, "layer": "F.SilkS", "shown": true}}]});
let model = json!({"pads": [
{"ref": "R1", "x": -0.8, "y": 0.0, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}, {"ref": "R1", "x": 0.8, "y": 0.0, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"},
{"ref": "C1", "x": -0.8, "y": -2.1, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}, {"ref": "C1", "x": 0.8, "y": -2.1, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu"}]});
let outline = [(-10.0, -10.0), (10.0, -10.0), (10.0, 10.0), (-10.0, 10.0)];
let v = silk_designators(&silk, &model, &outline, &json!({}));
// both labels sit on their own parts' pads now
assert_eq!(v["onPadsBefore"].as_array().unwrap().len(), 2, "{v}");
let r1 = v["planned"].as_array().unwrap().iter().find(|p| p["ref"] == "R1").unwrap().clone();
assert_eq!(r1["at"], "below", "{v}");
assert!(f(&r1["y"]) > 0.5 + 0.2, "{v}");
let c1 = v["planned"].as_array().unwrap().iter().find(|p| p["ref"] == "C1").unwrap().clone();
assert_eq!(c1["at"], "above", "{v}");
assert!(v["unplaced"].as_array().unwrap().is_empty());
// a board that hugs the parts: R1's label still fits, smaller, in the 1.1 mm between the two
// parts; C1's has nowhere to go and is reported, never forced onto pads
let tight = silk_designators(&silk, &model, &[(-1.2, -2.8), (1.2, -2.8), (1.2, 0.7), (-1.2, 0.7)], &json!({"minSize": 0.6}));
assert_eq!((tight["planned"][0]["ref"].as_str(), tight["planned"][0]["size"].as_f64()), (Some("R1"), Some(0.7)), "{tight}");
assert_eq!(tight["unplaced"][0]["ref"], "C1", "{tight}");
}
#[test]
fn a_serpentine_adds_exactly_the_missing_length_clear_of_other_copper() {
let text = std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route/route-fixture.kicad_pcb")).unwrap();
let board = crate::pcb::parse_pcb_text(&text, "f.kicad_pcb").unwrap();
let model = crate::board_model::build(&text, None, &crate::board_model::Opts { y_up: false }).unwrap();
let net = board.segments.iter().filter_map(|s| s.net_name.clone()).next().expect("the fixture has a routed net");
let before = routed_length(&model, &net).unwrap();
let v = length_tune(&board, &model, &json!({"net": net, "targetMm": before + 3.0})).unwrap();
assert_eq!(v["changed"], true, "{v}");
assert!(f(&v["errorMm"]).abs() < 0.01, "{v}");
let pts = v["plan"]["points"].as_array().unwrap();
let drawn: f64 = pts.windows(2).map(|w| dist((f(&w[0][0]), f(&w[0][1])), (f(&w[1][0]), f(&w[1][1])))).sum();
let seg = board.segments.iter().find(|s| s.uuid.as_deref() == v["plan"]["segmentId"].as_str()).unwrap();
let orig = dist((seg.start.as_ref().unwrap().x, seg.start.as_ref().unwrap().y), (seg.end.as_ref().unwrap().x, seg.end.as_ref().unwrap().y));
assert!((drawn - orig - 3.0).abs() < 0.01, "the polyline is the old track plus 3 mm: {drawn} vs {orig}");
assert!(f(&v["plan"]["amplitudeMm"]) <= 3.0);
// already long enough: nothing to do
assert_eq!(length_tune(&board, &model, &json!({"net": net, "targetMm": before - 1.0})).unwrap()["changed"], false);
assert_eq!(length_tune(&board, &model, &json!({"net": "NOPE", "targetMm": 5})).unwrap_err().0, "no_such_net");
}
#[test]
fn silk_prep_sets_the_plot_option_and_drops_silk_off_the_board() {
let text = "(kicad_pcb (setup (pcbplotparams (layerselection 1) (subtractmaskfromsilk no)))\n\t(gr_line (start 0 0) (end 5 0) (layer \"F.SilkS\") (uuid \"in\"))\n\t(gr_text \"x\" (at 50 50) (layer \"F.SilkS\") (uuid \"out\"))\n\t(gr_line (start 60 60) (end 70 60) (layer \"Dwgs.User\") (uuid \"other\"))\n)";
let (t, changed) = subtract_mask_from_silk(text);
assert!(changed && t.contains("(subtractmaskfromsilk yes)"));
assert!(!subtract_mask_from_silk(&t).1, "already set");
let (t2, removed) = silk_outside_board(&t, &[(-1.0, -1.0), (10.0, -1.0), (10.0, 10.0), (-1.0, 10.0)]);
assert_eq!(removed, vec!["out".to_string()]);
assert!(t2.contains("\"in\"") && t2.contains("\"other\"") && !t2.contains("\"out\""));
}
#[test]
fn rect_and_segment_distance() {
let r = Rect { min_x: 0.0, min_y: 0.0, max_x: 2.0, max_y: 1.0 };
assert_eq!(seg_rect((-1.0, 0.5), (3.0, 0.5), &r), 0.0);
assert!((seg_rect((0.0, 3.0), (2.0, 3.0), &r) - 2.0).abs() < 1e-9);
assert!(inside_polygon((1.0, 1.0), &[(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0)]));
}
}