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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239
//! Room on the board: free spots for a part near another (clear of every other part on that side, of that side's
//! copper, and inside the outline), and two-pad parts a quarter turn would shorten the airlines of. From the Altium
//! bridge's design.rs; a part is the box of its pads and, with the bodies companion, of its 3D body.
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
type Rect = (f64, f64, f64, f64);
fn grow(r: Rect, g: f64) -> Rect { (r.0 - g, r.1 - g, r.2 + g, r.3 + g) }
fn hits(a: Rect, b: Rect) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }
fn dist(a: (f64, f64), b: (f64, f64)) -> f64 { ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() }
/// The parts' boxes and sides, with their bodies when the companion came.
fn part_boxes(st: &Value, input: &Value) -> BTreeMap<String, (bool, Rect)> {
let mut bodies: BTreeMap<String, Rect> = BTreeMap::new();
for b in crate::bodies::input_boxes(input).unwrap_or_default() {
let e = bodies.entry(b.part.clone()).or_insert((b.x0, b.y0, b.x1, b.y1));
*e = (e.0.min(b.x0), e.1.min(b.y0), e.2.max(b.x1), e.3.max(b.y1));
}
crate::placement::part_boxes(st, &bodies)
}
/// find_space: input {board, bodies?, args: {near? | at?: {x, y}, part? | sizeMm?: [w, h], side?, clearanceMm?, edgeMm?,
/// count?, radiusMm?, copper?, check?}}. Free spots nearest first, as where the part's origin goes; with check: true,
/// the parts that already overlap or leave the outline.
pub fn find_space(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let s = |k: &str| args[k].as_str().map(String::from);
let ring = crate::coppermap::outline_ring(&st);
let boxes = part_boxes(&st, input);
let parts: Vec<(String, Rect, bool)> = boxes.iter().map(|(r, (b, x))| (r.clone(), *x, *b)).collect();
let clr = args["clearanceMm"].as_f64().unwrap_or(0.25);
let edge = args["edgeMm"].as_f64().unwrap_or(0.3);
let seg = |p: (f64, f64), a: (f64, f64), b: (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))
};
let in_outline = |r: Rect| -> bool {
if ring.len() < 3 { return true; }
[(r.0, r.1), (r.2, r.1), (r.2, r.3), (r.0, r.3)].iter().all(|&c| crate::route::inside(c, &ring) && (0..ring.len()).all(|i| seg(c, ring[i], ring[(i + 1) % ring.len()]) >= edge))
};
if args["check"].as_bool() == Some(true) {
// the same tests placement_check makes (fusion-lane): a part under a raised one (an Adom molecule on pins) whose
// pads stay apart and whose bodies do not meet in 3D is stacked, not overlapping; a part is off the outline by
// its pads' centres, not its box (a round board's edge cuts a box corner off a part well inside it)
let pads = st["pads"].as_array().cloned().unwrap_or_default();
let bodies = crate::bodies::input_boxes(input);
let (mut overlaps, mut stacked) = (Vec::new(), Vec::new());
for i in 0..parts.len() { for j in i + 1..parts.len() {
if parts[i].2 != parts[j].2 || !hits(parts[i].1, parts[j].1) { continue; }
let pair = json!([parts[i].0, parts[j].0]);
match &bodies { Some(bs) if crate::placement::is_stacked(&pads, bs, &parts[i].0, &parts[j].0, 0.0) => stacked.push(pair), _ => overlaps.push(pair) }
} }
let outside: Vec<&String> = parts.iter().filter(|p| {
let own: Vec<&Value> = pads.iter().filter(|q| q["ref"].as_str() == Some(p.0.as_str())).collect();
ring.len() >= 3 && !own.is_empty() && own.iter().any(|q| !crate::route::inside((f(&q["x"]), f(&q["y"])), &ring))
}).map(|p| &p.0).collect();
return json!({"success": true, "overlaps": overlaps, "stacked": stacked, "outsideOutline": outside, "parts": parts.len(), "bodiesUsed": bodies.is_some(),
"_hint": "Overlaps compare part boxes (pads, and 3D bodies when given) on the same side; placement_check adds margins, stacked parts and mounting holes. find_space {near, part} finds a free spot; move_parts moves it."});
}
let comp = |d: &str| st["components"].as_array().and_then(|a| a.iter().find(|c| c["ref"].as_str().map(|r| r.eq_ignore_ascii_case(d)).unwrap_or(false)).cloned());
// what to place: an existing part (its extent and origin) or a size
let me = s("part").and_then(|p| parts.iter().find(|x| x.0.eq_ignore_ascii_case(&p)).cloned());
let (w, h, off, my_bottom) = match (&me, args["sizeMm"].as_array()) {
(Some((d, r, b)), _) => {
let c = comp(d).unwrap_or(Value::Null);
let (ox, oy) = (c["x"].as_f64().unwrap_or((r.0 + r.2) / 2.0), c["y"].as_f64().unwrap_or((r.1 + r.3) / 2.0));
(r.2 - r.0, r.3 - r.1, (ox - (r.0 + r.2) / 2.0, oy - (r.1 + r.3) / 2.0), *b)
}
(None, Some(sz)) if sz.len() == 2 => (f(&sz[0]).max(0.01), f(&sz[1]).max(0.01), (0.0, 0.0), false),
_ => return crate::input::refuse("bad_args", "part (a designator on the board) or sizeMm [w, h] is required"),
};
let bottom = match s("side").as_deref() { Some("bottom") => true, Some("top") => false, _ => my_bottom };
let (cx, cy) = if let Some(n) = s("near") {
match parts.iter().find(|x| x.0.eq_ignore_ascii_case(&n)) { Some((_, r, _)) => ((r.0 + r.2) / 2.0, (r.1 + r.3) / 2.0), None => return crate::input::refuse("no_such_part", &format!("{n} is not on the board")) }
} else if args["at"].is_object() {
(f(&args["at"]["x"]), f(&args["at"]["y"]))
} else { return crate::input::refuse("bad_args", "near (a designator) or at {x, y} is required") };
let mine = me.as_ref().map(|m| m.0.clone()).unwrap_or_default();
let mut blocks: Vec<Rect> = parts.iter().filter(|p| p.2 == bottom && !p.0.eq_ignore_ascii_case(&mine)).map(|p| grow(p.1, clr)).collect();
if args["copper"].as_bool() != Some(false) {
let layer = if bottom { "B.Cu" } else { "F.Cu" };
// net-less tracks are a hatched pour's pieces, which re-pour round a new part (6473 of them on the Bluetooth
// Sentinel left no spot anywhere)
for t in st["tracks"].as_array().into_iter().flatten().filter(|t| t["layer"] == layer && t["net"].as_str().map(|n| !n.is_empty()).unwrap_or(false)) {
let (a, b, hw) = ((f(&t["start"]["x"]), f(&t["start"]["y"])), (f(&t["end"]["x"]), f(&t["end"]["y"])), f(&t["width"]) / 2.0);
blocks.push(grow((a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)), hw + clr));
}
for v in st["vias"].as_array().into_iter().flatten() {
let (x, y, r) = (f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]) / 2.0);
blocks.push(grow((x - r, y - r, x + r, y + r), clr));
}
}
let count = args["count"].as_u64().unwrap_or(5) as usize;
let radius = args["radiusMm"].as_f64().unwrap_or(10.0);
let step = 0.25;
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 (x, y) = (cx + i as f64 * step, cy + j as f64 * step);
let d = dist((x, y), (cx, cy));
if d <= radius { cands.push((d, x, y)); }
} }
cands.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let (mut spots, mut taken): (Vec<Value>, Vec<Rect>) = (Vec::new(), Vec::new());
for (d, x, y) in cands {
let r = (x - w / 2.0, y - h / 2.0, x + w / 2.0, y + h / 2.0);
if blocks.iter().any(|b| hits(r, *b)) || taken.iter().any(|t| hits(r, *t)) || !in_outline(r) { continue; }
spots.push(json!({"x": r3(x + off.0), "y": r3(y + off.1), "distanceMm": r3(d), "extent": [r3(r.0), r3(r.1), r3(r.2), r3(r.3)]}));
taken.push(grow(r, clr));
if spots.len() >= count { break; }
}
if spots.is_empty() { return json!({"success": false, "errorCode": "no_space", "error": format!("no free {} x {} mm spot within {radius} mm", r3(w), r3(h)), "spots": []}); }
json!({"success": true, "spots": spots, "part": if mine.is_empty() { Value::Null } else { json!(mine) }, "sizeMm": [r3(w), r3(h)], "side": if bottom { "bottom" } else { "top" },
"_hint": "spots[].x/y are where the part's origin goes: move_parts {parts:[{ref, x, y, rotation}]}, then repour (pours are not avoided: they clear round the new pads). Each spot keeps clearanceMm from other part boxes on that side, from that side's tracks and vias, and edgeMm from the outline; nearest first. find_space {check: true} lists parts that already overlap."})
}
/// Minimum spanning tree length of points (Prim's, O(n^2): nets are small).
fn mst(pts: &[(f64, f64)]) -> f64 {
if pts.len() < 2 { return 0.0; }
let (mut inside, mut best) = (vec![false; pts.len()], vec![f64::MAX; pts.len()]);
best[0] = 0.0;
let mut total = 0.0;
for _ in 0..pts.len() {
let mut u = usize::MAX;
for i in 0..pts.len() { if !inside[i] && (u == usize::MAX || best[i] < best[u]) { u = i; } }
inside[u] = true;
total += best[u];
for v in 0..pts.len() { if !inside[v] { let d = dist(pts[u], pts[v]); if d < best[v] { best[v] = d; } } }
}
total
}
/// check_orientation: input {board, args: {parts?, minImprovementMm?, planeNetPads?}}. Advisory: each two-pad part's
/// quarter turns scored in place, a routable net by its airline (MST) length with the pad there, a plane-like net
/// (more than planeNetPads pads, GND) by the distance to its nearest same-net pad. Turns that save minImprovementMm or
/// more are reported; nothing moves.
pub fn check_orientation(input: &Value) -> Value {
let st = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = &input["args"];
let min_gain = args["minImprovementMm"].as_f64().unwrap_or(0.6);
let plane_pads = args["planeNetPads"].as_u64().unwrap_or(12) as usize;
let want: Vec<String> = args["parts"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_ascii_uppercase())).collect()).unwrap_or_default();
let pads = st["pads"].as_array().cloned().unwrap_or_default();
let mut net_pads: HashMap<String, Vec<(String, (f64, f64))>> = HashMap::new();
for p in &pads {
let n = p["net"].as_str().unwrap_or("");
if !n.is_empty() { net_pads.entry(n.to_string()).or_default().push((p["ref"].as_str().unwrap_or("").to_string(), (f(&p["x"]), f(&p["y"])))); }
}
let (mut findings, mut checked) = (Vec::new(), 0);
for c in st["components"].as_array().into_iter().flatten() {
let r = c["ref"].as_str().unwrap_or("").to_string();
if !want.is_empty() && !want.contains(&r.to_ascii_uppercase()) { continue; }
let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"].as_str() == Some(&r)).collect();
if mine.len() != 2 { continue; }
checked += 1;
let (cx, cy, rot) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]));
// the airlines with the part turned by delta degrees (counterclockwise, y up)
let score = |delta: f64| -> f64 {
let a = delta.to_radians();
let mut total = 0.0;
for p in &mine {
let net = p["net"].as_str().unwrap_or("");
if net.is_empty() { continue; }
let (px, py) = (f(&p["x"]) - cx, f(&p["y"]) - cy);
let at = (cx + px * a.cos() - py * a.sin(), cy + px * a.sin() + py * a.cos());
let others: Vec<(f64, f64)> = net_pads.get(net).map(|v| v.iter().filter(|(o, _)| o != &r).map(|(_, q)| *q).collect()).unwrap_or_default();
if others.is_empty() { continue; }
total += if others.len() + 1 > plane_pads { others.iter().map(|q| dist(at, *q)).fold(f64::MAX, f64::min) } else { let mut pts = others.clone(); pts.push(at); mst(&pts) };
}
total
};
let now = score(0.0);
let mut best = (0.0, now);
for d in [90.0, 180.0, 270.0] { let sc = score(d); if sc < best.1 - 1e-9 { best = (d, sc); } }
if now - best.1 >= min_gain {
findings.push(json!({"ref": r, "rotation": rot, "suggested": (rot + best.0).rem_euclid(360.0), "airlineNowMm": r3(now), "airlineThenMm": r3(best.1), "savesMm": r3(now - best.1)}));
}
}
findings.sort_by(|a, b| f(&b["savesMm"]).partial_cmp(&f(&a["savesMm"])).unwrap_or(std::cmp::Ordering::Equal));
json!({"success": true, "checked": checked, "suggestions": findings,
"_hint": "Advisory only: each two-pad part's quarter turns scored by airline length (MST of a routable net with the pad there; distance to the nearest pad of a plane-like net such as GND). Turn with move_parts {parts:[{ref, rotation}]} (position kept), then reroute those nets. Routed copper is not considered: on a routed board, treat these as hints."})
}
#[cfg(test)]
mod tests {
use super::*;
fn pad(r: &str, n: &str, x: f64, y: f64, net: &str) -> Value {
json!({"ref": r, "name": n, "x": x, "y": y, "sizeX": 0.6, "sizeY": 0.6, "rotation": 0, "layer": "F.Cu", "drill": 0, "net": net, "shape": "rect"})
}
fn board() -> Value {
// R1 lies along x, but its nets go up and down: a quarter turn shortens both airlines
json!({"outline": [[0, 0], [20, 0], [20, 20], [0, 20]],
"components": [{"ref": "R1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}, {"ref": "U1", "x": 10.0, "y": 15.0, "rotation": 0.0, "side": "top"}, {"ref": "U2", "x": 10.0, "y": 5.0, "rotation": 0.0, "side": "top"}],
"pads": [pad("R1", "1", 9.0, 10.0, "A"), pad("R1", "2", 11.0, 10.0, "B"), pad("U1", "1", 10.0, 15.0, "A"), pad("U1", "2", 11.0, 15.0, "X"), pad("U2", "1", 10.0, 5.0, "B"), pad("U2", "2", 11.0, 5.0, "Y")]})
}
#[test]
fn a_quarter_turn_that_shortens_the_airlines_is_suggested() {
let r = check_orientation(&json!({"board": board(), "args": {}}));
// U1 and U2 have two pads too, but turning them about the pad at their origin gains nothing
assert_eq!((r["checked"].clone(), r["suggestions"].as_array().unwrap().len()), (json!(3), 1), "{r}");
let s = &r["suggestions"][0];
// pad 1 (net A) has to come up to U1: a quarter turn clockwise, 270 counterclockwise
assert_eq!((s["ref"].as_str(), s["suggested"].as_f64()), (Some("R1"), Some(270.0)), "{r}");
assert!(f(&s["savesMm"]) > 0.6);
}
#[test]
fn free_spots_avoid_parts_copper_and_the_edge() {
let mut b = board();
b["tracks"] = json!([{"layer": "F.Cu", "net": "A", "width": 0.25, "start": {"x": 12.0, "y": 0.0}, "end": {"x": 12.0, "y": 20.0}}]);
let r = find_space(&json!({"board": b, "args": {"near": "R1", "sizeMm": [2.0, 1.0], "count": 3}}));
assert_eq!(r["success"], json!(true), "{r}");
for s in r["spots"].as_array().unwrap() {
let e: Vec<f64> = s["extent"].as_array().unwrap().iter().map(f).collect();
// clear of R1's box (8.7..11.3 x 9.7..10.3) by 0.25, of the track at x 12 by 0.125 + 0.25
assert!(e[2] <= 8.7 - 0.25 + 1e-9 || e[0] >= 11.3 + 0.25 - 1e-9 || e[3] <= 9.7 - 0.25 + 1e-9 || e[1] >= 10.3 + 0.25 - 1e-9, "{s}");
assert!(e[2] <= 12.0 - 0.375 + 1e-9 || e[0] >= 12.0 + 0.375 - 1e-9, "{s}");
}
let chk = find_space(&json!({"board": board(), "args": {"check": true}}));
assert_eq!((chk["overlaps"].as_array().unwrap().len(), chk["outsideOutline"].as_array().unwrap().len()), (0, 0));
// a part under a raised molecule: pads apart, bodies apart in height, so stacked, not an overlap (fusion-lane)
let mut m = board();
m["components"].as_array_mut().unwrap().push(json!({"ref": "M1", "x": 10.0, "y": 10.0, "rotation": 0.0, "side": "top"}));
m["pads"].as_array_mut().unwrap().extend([pad("M1", "1", 6.0, 10.0, "P"), pad("M1", "2", 14.0, 10.0, "Q")]);
let bodies = json!({"bodies": [{"ref": "M1", "side": "top", "x0": 5.5, "y0": 8.0, "x1": 14.5, "y1": 12.0, "z0": 3.0, "z1": 4.0},
{"ref": "R1", "side": "top", "x0": 8.7, "y0": 9.7, "x1": 11.3, "y1": 10.3, "z0": 0.0, "z1": 0.5}]});
let s = find_space(&json!({"board": m, "bodies": bodies, "args": {"check": true}}));
assert_eq!((s["overlaps"].as_array().unwrap().len(), s["stacked"].clone(), s["bodiesUsed"].clone()), (0, json!([["M1", "R1"]]), json!(true)), "{s}");
assert_eq!(find_space(&json!({"board": board(), "args": {"near": "Q9", "sizeMm": [1, 1]}}))["errorCode"], "no_such_part");
}
}