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 #310: kicad-lane: 1.0.67, the standard's checks tier (14 verbs) and board report
ad7a3f5
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
//! The copper of each layer as a raster (0.1 mm by default) inside the board outline, from the live board: tracks,
//! arcs, pads, vias and the copper Altium actually poured (its regions minus their holes); an internal plane counts as
//! copper everywhere. From it: how much of each layer is copper and how evenly the stack is balanced (bow and twist),
//! and where signal tracks run over a gap in the copper of the layer next to them (the return current's path).
use serde_json::{json, Value};
use std::collections::BTreeMap;
fn f(v: &Value) -> f64 { v.as_f64().unwrap_or(0.0) }
fn r1(v: f64) -> f64 { (v * 10.0).round() / 10.0 }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
/// A layer's copper as cells of `res` mm from (x0, y0).
#[derive(Clone)]
pub struct Raster { pub x0: f64, pub y0: f64, pub res: f64, pub w: usize, pub h: usize, pub cells: Vec<bool> }
impl Raster {
pub fn new(x0: f64, y0: f64, x1: f64, y1: f64, res: f64) -> Raster {
let w = (((x1 - x0) / res).ceil() as usize).max(1);
let h = (((y1 - y0) / res).ceil() as usize).max(1);
Raster { x0, y0, res, w, h, cells: vec![false; w * h] }
}
fn centre(&self, i: usize, j: usize) -> (f64, f64) { (self.x0 + (i as f64 + 0.5) * self.res, self.y0 + (j as f64 + 0.5) * self.res) }
/// cells whose centre is in [x0, x1] x [y0, y1], clipped
fn span(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> (usize, usize, usize, usize) {
let c = |v: f64, o: f64, n: usize| (((v - o) / self.res - 0.5).max(0.0) as usize).min(n);
let (i0, j0) = (c(x0, self.x0, self.w), c(y0, self.y0, self.h));
let (i1, j1) = ((((x1 - self.x0) / self.res + 0.5).max(0.0) as usize).min(self.w), (((y1 - self.y0) / self.res + 0.5).max(0.0) as usize).min(self.h));
(i0, j0, i1, j1)
}
pub fn at(&self, x: f64, y: f64) -> bool {
let (i, j) = ((x - self.x0) / self.res, (y - self.y0) / self.res);
if i < 0.0 || j < 0.0 || i >= self.w as f64 || j >= self.h as f64 { return false; }
self.cells[j as usize * self.w + i as usize]
}
/// a segment of width w with round ends (a track, an arc's piece, an oval pad)
pub fn capsule(&mut self, ax: f64, ay: f64, bx: f64, by: f64, w: f64) {
let r = w / 2.0;
let (i0, j0, i1, j1) = self.span(ax.min(bx) - r, ay.min(by) - r, ax.max(bx) + r, ay.max(by) + r);
let (dx, dy) = (bx - ax, by - ay);
let l2 = dx * dx + dy * dy;
for j in j0..j1 { for i in i0..i1 {
let (x, y) = self.centre(i, j);
let t = if l2 > 0.0 { (((x - ax) * dx + (y - ay) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 };
let (px, py) = (ax + t * dx - x, ay + t * dy - y);
if px * px + py * py <= r * r { self.cells[j * self.w + i] = true; }
} }
}
/// a rectangle sx by sy centred at (cx, cy), turned `rot` degrees
pub fn rect(&mut self, cx: f64, cy: f64, sx: f64, sy: f64, rot: f64) {
let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());
let e = (sx * sx + sy * sy).sqrt() / 2.0;
let (i0, j0, i1, j1) = self.span(cx - e, cy - e, cx + e, cy + e);
for j in j0..j1 { for i in i0..i1 {
let (x, y) = self.centre(i, j);
let (u, v) = ((x - cx) * c + (y - cy) * s, -(x - cx) * s + (y - cy) * c);
if u.abs() <= sx / 2.0 && v.abs() <= sy / 2.0 { self.cells[j * self.w + i] = true; }
} }
}
/// polygons filled by the even-odd rule (an outline with its holes), set to `on`
pub fn polys(&mut self, rings: &[Vec<(f64, f64)>], on: bool) {
let ys: Vec<f64> = rings.iter().flatten().map(|p| p.1).collect();
if ys.is_empty() { return; }
let (lo, hi) = (ys.iter().cloned().fold(f64::MAX, f64::min), ys.iter().cloned().fold(f64::MIN, f64::max));
let (_, j0, _, j1) = self.span(self.x0, lo, self.x0, hi);
for j in j0..j1 {
let y = self.y0 + (j as f64 + 0.5) * self.res;
let mut xs: Vec<f64> = Vec::new();
for ring in rings {
let n = ring.len();
for k in 0..n {
let (a, b) = (ring[k], ring[(k + 1) % n]);
if (a.1 <= y) != (b.1 <= y) { xs.push(a.0 + (y - a.1) / (b.1 - a.1) * (b.0 - a.0)); }
}
}
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
for p in xs.chunks(2) {
if p.len() < 2 { continue; }
let (i0, _, i1, _) = self.span(p[0], y, p[1], y);
for i in i0..i1 { self.cells[j * self.w + i] = on; }
}
}
}
pub fn and(&mut self, mask: &Raster) { for (c, m) in self.cells.iter_mut().zip(&mask.cells) { *c = *c && *m; } }
pub fn count(&self) -> usize { self.cells.iter().filter(|c| **c).count() }
}
/// The stack's copper layers in order as (board model name, plane?, plane net). The name is the stack entry's `layer`
/// when the bridge gives it, else its place in the copper stack (F.Cu, In<n>.Cu, B.Cu): a plane is named like any other
/// inner layer (standard 0.3.1), never by a tool's id.
pub fn stack_layers(stack: &Value) -> Vec<(String, bool, String)> {
let ls: Vec<Value> = stack["layers"].as_array().cloned().unwrap_or_default();
let n = ls.len();
ls.iter().enumerate().map(|(i, l)| {
let plane = l["kind"] == "plane";
let name = l["layer"].as_str().map(str::to_string).unwrap_or_else(|| if i == 0 { "F.Cu".to_string() } else if i + 1 == n { "B.Cu".to_string() } else { format!("In{i}.Cu") });
(name, plane, if plane { l["net"].as_str().unwrap_or("").to_string() } else { String::new() })
}).collect()
}
/// The outline as a ring in mm (arc edges sampled).
pub(crate) fn outline_ring(st: &Value) -> Vec<(f64, f64)> {
let mut ring = Vec::new();
for e in st["outlineEdges"].as_array().cloned().unwrap_or_default() {
if e["kind"] == "arc" {
let (cx, cy, r) = (f(&e["center"][0]), f(&e["center"][1]), f(&e["radius"]));
let (a1, mut a2) = (f(&e["angle1"]), f(&e["angle2"]));
if a2 <= a1 { a2 += 360.0; }
// every 2 degrees: a chord cuts inside the arc by r(1 - cos 1deg), 3 um on a 20 mm radius (16 points per
// arc put a hatched pour's ends 0.04 mm outside the Bluetooth Sentinel example's edge)
let k = (((a2 - a1) / 2.0).ceil() as usize).max(4);
for q in 0..=k { let t = (a1 + (a2 - a1) * q as f64 / k as f64).to_radians(); ring.push((cx + r * t.cos(), cy + r * t.sin())); }
} else { ring.push((f(&e["start"][0]), f(&e["start"][1]))); }
}
if ring.is_empty() { ring = st["outline"].as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect(); }
ring
}
/// Each layer's copper raster inside the outline. `refs` None: all copper. Some(nets): only the copper a return current
/// can use: pours, planes, those nets' own copper, and net-less copper (Altium lists a hatched pour's tracks with no
/// net: 8777 of the Bluetooth Sentinel example's 9378 tracks). Pure: routing_state, pour_state's zones, the stack.
pub fn rasters(st: &Value, zones: &[Value], layers: &[(String, bool, String)], res: f64, refs: Option<&[String]>) -> (Raster, BTreeMap<String, Raster>) {
let ring = outline_ring(st);
let (x0, x1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = ring.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
let mut board = Raster::new(x0, y0, x1, y1, res);
board.polys(&[ring], true);
let mut out = BTreeMap::new();
for (name, plane, _) in layers {
let mut r = Raster::new(x0, y0, x1, y1, res);
if *plane { r.cells = board.cells.clone(); out.insert(name.clone(), r); continue; }
{
let keep = |n: &Value| match refs { None => true, Some(rs) => { let n = n.as_str().unwrap_or(""); n.is_empty() || rs.iter().any(|r| r.eq_ignore_ascii_case(n)) } };
let on_layer = |l: &Value| l.as_str().map(|s| s == name || s == "*.Cu").unwrap_or(false);
let on = |p: &Value| on_layer(&p["layer"]) && keep(&p["net"]);
for t in st["tracks"].as_array().cloned().unwrap_or_default().iter().filter(|t| on(t)) {
r.capsule(f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]), f(&t["width"]));
}
for a in st["arcs"].as_array().cloned().unwrap_or_default().iter().filter(|a| on(a)) {
let (cx, cy, rad) = (f(&a["center"]["x"]), f(&a["center"]["y"]), f(&a["radius"]));
let (s, mut e) = (f(&a["startAngle"]), f(&a["endAngle"]));
if e <= s { e += 360.0; }
let k = ((e - s) / 10.0).ceil().max(1.0) as usize;
for q in 0..k {
let (t0, t1) = ((s + (e - s) * q as f64 / k as f64).to_radians(), (s + (e - s) * (q + 1) as f64 / k as f64).to_radians());
r.capsule(cx + rad * t0.cos(), cy + rad * t0.sin(), cx + rad * t1.cos(), cy + rad * t1.sin(), f(&a["width"]));
}
}
for p in st["pads"].as_array().cloned().unwrap_or_default().iter().filter(|p| on(p)) {
let (sx, sy) = (f(&p["sizeX"]), f(&p["sizeY"]));
// a custom pad's real copper when the bridge sends it (Fusion's copperPoints); its bounding rectangle otherwise
let outline: Vec<(f64, f64)> = p["copperPoints"].as_array().cloned().unwrap_or_default().iter().map(|q| (f(&q[0]), f(&q[1]))).collect();
if outline.len() >= 3 { r.polys(&[outline], true); continue; }
match p["shape"].as_str() {
Some("round") | Some("circle") if (sx - sy).abs() > 1e-6 => {
let (c, s) = (f(&p["rotation"]).to_radians().cos(), f(&p["rotation"]).to_radians().sin());
let (l, w) = ((sx - sy).abs() / 2.0, sx.min(sy));
let (ux, uy) = if sx > sy { (c * l, s * l) } else { (-s * l, c * l) };
r.capsule(f(&p["x"]) - ux, f(&p["y"]) - uy, f(&p["x"]) + ux, f(&p["y"]) + uy, w);
}
Some("round") | Some("circle") => r.capsule(f(&p["x"]), f(&p["y"]), f(&p["x"]), f(&p["y"]), sx),
_ => r.rect(f(&p["x"]), f(&p["y"]), sx, sy, f(&p["rotation"])),
}
}
for v in st["vias"].as_array().cloned().unwrap_or_default().iter().filter(|v| keep(&v["net"])) {
r.capsule(f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["position"]["x"]), f(&v["position"]["y"]), f(&v["size"]));
}
}
for z in zones.iter().filter(|z| z["layer"].as_str() == Some(name)) {
for fill in z["fills"].as_array().cloned().unwrap_or_default() {
let pts = |v: &Value| v.as_array().cloned().unwrap_or_default().iter().map(|p| (f(&p[0]), f(&p[1]))).collect::<Vec<_>>();
let mut rings = vec![pts(&fill["outline"])];
rings.extend(fill["holes"].as_array().cloned().unwrap_or_default().iter().map(pts));
r.polys(&rings, true);
}
}
r.and(&board);
out.insert(name.clone(), r);
}
(board, out)
}
/// Per layer: copper area and share of the board; per mirrored pair (1 with n, 2 with n-1...): the difference.
pub fn balance(board: &Raster, maps: &BTreeMap<String, Raster>, layers: &[(String, bool, String)]) -> (Vec<Value>, Vec<Value>) {
let cell = board.res * board.res;
let total = board.count() as f64;
let pct = |name: &str| maps.get(name).map(|r| 100.0 * r.count() as f64 / total.max(1.0)).unwrap_or(0.0);
let rows: Vec<Value> = layers.iter().map(|(n, plane, net)| {
let mut o = json!({"layer": n, "copperMm2": r1(maps.get(n).map(|r| r.count()).unwrap_or(0) as f64 * cell), "percent": r1(pct(n))});
if *plane { o["plane"] = json!(net); }
o
}).collect();
let k = layers.len();
let pairs = (0..k / 2).map(|i| {
let (a, b) = (&layers[i].0, &layers[k - 1 - i].0);
let d = (pct(a) - pct(b)).abs();
json!({"layers": [a, b], "percent": [r1(pct(a)), r1(pct(b))], "differencePoints": r1(d), "ok": d <= 20.0})
}).collect();
(rows, pairs)
}
/// Per signal net: track length on each layer, and the length that runs over no reference copper on either
/// neighbouring layer, with where the first gap starts. `skip` nets are the references themselves. Pure.
pub fn return_paths(st: &Value, refs: &BTreeMap<String, Raster>, layers: &[(String, bool, String)], skip: &[String], step: f64) -> Vec<Value> {
let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();
let mut acc: BTreeMap<(String, String), (f64, f64, Option<(f64, f64)>, Vec<(f64, f64)>)> = BTreeMap::new();
for t in st["tracks"].as_array().cloned().unwrap_or_default() {
let (net, layer) = (t["net"].as_str().unwrap_or(""), t["layer"].as_str().unwrap_or(""));
if net.is_empty() || skip.iter().any(|s| s.eq_ignore_ascii_case(net)) { continue; }
let Some(&i) = idx.get(layer) else { continue };
let nb: Vec<&Raster> = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter_map(|l| refs.get(&l.0)).collect();
let (ax, ay, bx, by) = (f(&t["start"]["x"]), f(&t["start"]["y"]), f(&t["end"]["x"]), f(&t["end"]["y"]));
let len = ((bx - ax).powi(2) + (by - ay).powi(2)).sqrt();
let n = (len / step).ceil().max(1.0) as usize;
let e = acc.entry((net.to_string(), layer.to_string())).or_insert((0.0, 0.0, None, Vec::new()));
e.0 += len;
for k in 0..n {
let s = (k as f64 + 0.5) / n as f64;
let (x, y) = (ax + s * (bx - ax), ay + s * (by - ay));
if !nb.iter().any(|r| r.at(x, y)) {
e.1 += len / n as f64;
if e.2.is_none() { e.2 = Some((r3(x), r3(y))); }
e.3.push((x, y));
}
}
}
let mut out: Vec<Value> = acc.into_iter().filter(|(_, v)| v.1 > 0.0).map(|((net, layer), (len, gap, at, pts))| json!({
"net": net, "layer": layer, "lengthMm": r3(len), "unreferencedMm": r3(gap), "percent": r1(100.0 * gap / len.max(1e-9)), "firstGapAt": at.map(|p| json!([p.0, p.1])),
"gapRuns": gap_runs(&pts, 1.0).iter().map(|r| json!([r3(r.0), r3(r.1), r3(r.2), r3(r.3)])).collect::<Vec<_>>()})).collect();
out.sort_by(|a, b| b["unreferencedMm"].as_f64().partial_cmp(&a["unreferencedMm"].as_f64()).unwrap());
out
}
/// The gap sample points grouped into runs: boxes (x0, y0, x1, y1) of points within `join` mm of each other. Pure.
pub fn gap_runs(pts: &[(f64, f64)], join: f64) -> Vec<(f64, f64, f64, f64)> {
let mut runs: Vec<(f64, f64, f64, f64)> = Vec::new();
for &(x, y) in pts {
match runs.iter_mut().find(|r| x >= r.0 - join && x <= r.2 + join && y >= r.1 - join && y <= r.3 + join) {
Some(r) => { r.0 = r.0.min(x); r.1 = r.1.min(y); r.2 = r.2.max(x); r.3 = r.3.max(y); }
None => runs.push((x, y, x, y)),
}
}
// runs that grew into each other merge
let mut merged = true;
while merged {
merged = false;
'outer: for i in 0..runs.len() {
for j in i + 1..runs.len() {
let (a, b) = (runs[i], runs[j]);
if a.0 <= b.2 + join && b.0 <= a.2 + join && a.1 <= b.3 + join && b.1 <= a.3 + join {
runs[i] = (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3));
runs.remove(j);
merged = true;
break 'outer;
}
}
}
}
runs
}
/// A plan that closes each gap: a pour patch `margin` mm round every gap run, on the neighbouring layer that already
/// carries the most reference copper, on that layer's reference net; as apply_pours arguments. Pure.
pub fn fix_plan(rows: &[Value], layers: &[(String, bool, String)], refs: &BTreeMap<String, Raster>, zones: &[Value], skip: &[String], board: (f64, f64, f64, f64), margin: f64) -> Vec<Value> {
let idx: BTreeMap<&str, usize> = layers.iter().enumerate().map(|(i, l)| (l.0.as_str(), i)).collect();
let mut pours = Vec::new();
for row in rows {
let Some(&i) = row["layer"].as_str().and_then(|l| idx.get(l)) else { continue };
// a plane needs no patch (it is copper everywhere but its splits); pick the signal neighbour with most copper
let best = [i.checked_sub(1), Some(i + 1)].iter().flatten().filter_map(|j| layers.get(*j)).filter(|l| !l.1)
.max_by_key(|l| refs.get(&l.0).map(|r| r.count()).unwrap_or(0));
let Some(nb) = best else { continue };
let net = zones.iter().find(|z| z["layer"].as_str() == Some(nb.0.as_str())).and_then(|z| z["net"].as_str()).map(String::from)
.or_else(|| skip.first().cloned());
let Some(net) = net else { continue };
for (k, r) in row["gapRuns"].as_array().cloned().unwrap_or_default().iter().enumerate() {
let (x0, y0) = ((f(&r[0]) - margin).max(board.0 + 0.3), (f(&r[1]) - margin).max(board.1 + 0.3));
let (x1, y1) = ((f(&r[2]) + margin).min(board.2 - 0.3), (f(&r[3]) + margin).min(board.3 - 0.3));
if x1 <= x0 || y1 <= y0 { continue; }
// does the patch overlap the reference copper already on that layer? (one that does not floats)
let joins = refs.get(&nb.0).map(|ras| {
let mut hit = false;
let mut y = y0;
while y <= y1 && !hit { let mut x = x0; while x <= x1 && !hit { hit = ras.at(x, y); x += ras.res; } y += ras.res; }
hit
}).unwrap_or(false);
// removeDead off: a patch that only overlaps the pour it extends touches no pad or track of its net, and
// Altium's dead-copper removal poured it as nothing (0 mm2, measured 2026-10-26)
pours.push(json!({"net": net, "layer": nb.0, "name": format!("Return {} {}", row["net"].as_str().unwrap_or(""), k + 1), "removeDead": false,
"points": [[r3(x0), r3(y0)], [r3(x1), r3(y0)], [r3(x1), r3(y1)], [r3(x0), r3(y1)]], "closes": {"net": row["net"], "layer": row["layer"]}, "joinsExistingCopper": joins}));
}
}
pours
}
/// The board, its poured copper and its stack from the input bundle, or the refusal to answer.
fn gather(input: &Value) -> Result<(Value, Vec<Value>, Vec<(String, bool, String)>), Value> {
let st = crate::input::board(input)?;
if st["outline"].as_array().map(|a| a.len() < 3).unwrap_or(true) { return Err(crate::input::refuse("no_outline", "the board has no outline")); }
let zones = crate::input::pours(input);
let layers = crate::input::stack(input).map(|s| stack_layers(&s)).unwrap_or_default();
if layers.is_empty() { return Err(crate::input::refuse("no_stack", "the input has no stack: pass the layer_stack companion as stack")); }
Ok((st, zones, layers))
}
fn res_of(args: &Value) -> f64 { args.get("resolutionMm").and_then(|v| v.as_f64()).unwrap_or(0.1).clamp(0.02, 1.0) }
/// copper_balance: input {board, stack, pours, args: {resolutionMm?}}
pub fn copper_balance(input: &Value) -> Value {
let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };
let args = &input["args"];
let res = res_of(args);
let (board, maps) = rasters(&st, &zones, &layers, res, None);
let (rows, pairs) = balance(&board, &maps, &layers);
let unpoured: Vec<Value> = st["polygons"].as_array().cloned().unwrap_or_default().iter()
.filter(|g| !zones.iter().any(|z| z["layer"] == g["layer"] && z["net"] == g["net"] && z["filled"] == json!(true)))
.map(|g| json!({"net": g["net"], "layer": g["layer"]})).collect();
let ok = pairs.iter().all(|p| p["ok"] == json!(true));
json!({"success": true, "boardMm2": r1(board.count() as f64 * res * res), "resolutionMm": res, "layers": rows, "pairs": pairs, "balanced": ok,
"poursWithoutRegions": unpoured,
"_hint": if ok { "Mirrored layers carry similar copper: the stack should stay flat through reflow." } else { "A mirrored pair differs by more than 20 points of copper: the board can bow or twist through reflow. Pour the lighter layer (apply_pours, a GND pour or copper thieving), or ask the fab to add thieving on its side." }})
}
/// return_path: input {board, stack, pours, args: {nets?, referenceNets?, resolutionMm?, fixMarginMm?}}
pub fn return_path(input: &Value) -> Value {
let (st, zones, layers) = match gather(input) { Ok(g) => g, Err(e) => return e };
let args = &input["args"];
let res = res_of(args);
// the reference nets: the ones that own pours or planes, unless named
let mut skip: Vec<String> = args.get("referenceNets").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default();
if skip.is_empty() {
skip = st["polygons"].as_array().cloned().unwrap_or_default().iter().filter_map(|g| g["net"].as_str().map(String::from))
.chain(zones.iter().filter_map(|z| z["net"].as_str().map(String::from))).chain(layers.iter().filter(|l| l.1).map(|l| l.2.clone()))
.filter(|n| !n.is_empty()).collect();
skip.sort(); skip.dedup();
}
let (_, refs) = rasters(&st, &zones, &layers, res, Some(&skip));
let mut rows = return_paths(&st, &refs, &layers, &skip, res);
if let Some(want) = args.get("nets").and_then(|v| v.as_array()) {
let w: Vec<String> = want.iter().filter_map(|v| v.as_str()).map(|s| s.to_ascii_lowercase()).collect();
rows.retain(|r| r["net"].as_str().map(|n| w.contains(&n.to_ascii_lowercase())).unwrap_or(false));
}
let no_ref: Vec<&String> = layers.iter().map(|l| &l.0).filter(|n| refs.get(*n).map(|r| r.count() == 0).unwrap_or(true)).collect();
// a plan to close the gaps, for the caller to read and run: nothing is changed here
let bb = crate::geom::outline_bbox(&st).unwrap_or((0.0, 0.0, 0.0, 0.0));
let margin = args.get("fixMarginMm").and_then(|v| v.as_f64()).unwrap_or(1.0);
let patches = fix_plan(&rows, &layers, &refs, &zones, &skip, bb, margin);
let fix = if patches.is_empty() { Value::Null } else {
let pours: Vec<Value> = patches.iter().map(|p| { let mut q = p.clone(); if let Some(o) = q.as_object_mut() { o.remove("closes"); o.remove("joinsExistingCopper"); } q }).collect();
let floating: Vec<Value> = patches.iter().filter(|p| p["joinsExistingCopper"] == json!(false)).map(|p| json!({"name": p["name"], "layer": p["layer"]})).collect();
json!({"verb": "apply_pours", "args": {"pours": pours}, "patches": patches.len(), "closes": patches.iter().map(|p| p["closes"].clone()).collect::<Vec<_>>(),
"floating": floating,
"note": "A plan only: nothing changed. Each patch is a pour of the reference net round one gap run, on the neighbouring layer with the most reference copper. Read it (a patch can cut through other routing's space: the tool pours round it, keeping clearance), then run the verb with args, repour, and run return_path again. floating patches overlap no copper of their net yet: tie each to it with a via (place_vias) or it is an island."})
};
let note = if skip.is_empty() { json!("No net owns a pour or a plane on this board: there is no reference copper, so every signal is reported. Pour GND (apply_pours) or name referenceNets.") } else { Value::Null };
json!({"success": true, "referenceNets": skip, "note": note, "layersWithNoReferenceCopper": no_ref, "nets": rows, "ok": rows.is_empty(), "resolutionMm": res, "fix": fix,
"_hint": if rows.is_empty() { "Every signal track runs over pour or plane copper on a neighbouring layer." } else { "Each row is a net whose tracks on that layer run over no pour or plane on either neighbouring layer for unreferencedMm (from firstGapAt). Its return current detours around the gap: more loop area, more emission and crosstalk. Close the gap (extend the pour: apply_pours), route the net over solid copper, or add stitching capacitors or vias where it changes reference." }})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raster_shapes_have_their_area() {
let mut r = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);
r.rect(5.0, 5.0, 4.0, 2.0, 0.0);
assert!((r.count() as f64 * 0.0025 - 8.0).abs() < 0.3);
let mut c = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);
c.capsule(10.0, 10.0, 10.0, 10.0, 4.0);
assert!((c.count() as f64 * 0.0025 - std::f64::consts::PI * 4.0).abs() < 0.3);
let mut p = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);
p.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)], vec![(2.0, 2.0), (4.0, 2.0), (4.0, 4.0), (2.0, 4.0)]], true);
assert!((p.count() as f64 * 0.0025 - 96.0).abs() < 0.5);
assert!(!p.at(3.0, 3.0) && p.at(6.0, 6.0));
// KiCad's fractured fill: the hole joined to the outline by a zero-width cut, holes empty. Same copper.
let mut k = Raster::new(0.0, 0.0, 20.0, 20.0, 0.05);
k.polys(&[vec![(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 2.0), (2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0), (0.0, 2.0)]], true);
assert_eq!(k.count(), p.count());
assert!(!k.at(3.0, 3.0) && k.at(6.0, 6.0));
}
#[test]
fn a_custom_pad_is_its_copper_points_when_sent() {
let pad = |extra: Value| { let mut p = json!({"ref": "Q1", "name": "5", "x": 5.0, "y": 5.0, "sizeX": 4.0, "sizeY": 4.0, "rotation": 0, "layer": "F.Cu", "net": "D", "shape": "rect"});
if let Value::Object(m) = extra { for (k, v) in m { p[k] = v; } } p };
let area = |p: Value| { let st = json!({"outline": [[0, 0], [10, 0], [10, 10], [0, 10]], "pads": [p]});
let (_, m) = rasters(&st, &[], &[("F.Cu".into(), false, "".into())], 0.05, None); m["F.Cu"].count() as f64 * 0.0025 };
assert!((area(pad(json!({}))) - 16.0).abs() < 0.5);
// an L of copper inside that rectangle: 4x2 + 2x2 = 12 mm2
assert!((area(pad(json!({"copperPoints": [[3, 3], [7, 3], [7, 5], [5, 5], [5, 7], [3, 7]]}))) - 12.0).abs() < 0.5);
}
fn st() -> Value {
json!({"outline": [[0, 0], [20, 0], [20, 10], [0, 10]],
"outlineEdges": [{"kind": "line", "start": [0.0, 0.0]}, {"kind": "line", "start": [20.0, 0.0]}, {"kind": "line", "start": [20.0, 10.0]}, {"kind": "line", "start": [0.0, 10.0]}],
"tracks": [{"net": "SIG", "layer": "F.Cu", "width": 0.2, "start": {"x": 1.0, "y": 5.0}, "end": {"x": 19.0, "y": 5.0}}]})
}
#[test]
fn balance_and_return_path_on_a_split_ground() {
let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];
// GND poured on B.Cu over x 0..8 and 12..20: a 4 mm split under the signal
let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [
{"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}, {"outline": [[12, 0], [20, 0], [20, 10], [12, 10]], "holes": []}]})];
let (board, maps) = rasters(&st(), &zones, &layers, 0.1, None);
let (rows, pairs) = balance(&board, &maps, &layers);
assert!((rows[1]["percent"].as_f64().unwrap() - 80.0).abs() < 1.0);
assert!((rows[0]["percent"].as_f64().unwrap() - 1.8).abs() < 0.5);
assert_eq!(pairs[0]["ok"], json!(false));
let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));
// the signal itself is not reference copper
assert!(!refs["F.Cu"].at(10.0, 5.0));
let rp = return_paths(&st(), &refs, &layers, &["GND".to_string()], 0.1);
assert_eq!(rp.len(), 1);
assert!((rp[0]["unreferencedMm"].as_f64().unwrap() - 4.0).abs() < 0.2);
assert_eq!(rp[0]["firstGapAt"][0].as_f64().unwrap().floor(), 8.0);
}
#[test]
fn stack_names_match_routing_state() {
let s = json!({"layers": [{"name": "Top"}, {"kind": "plane", "plane": 1, "net": "GND"}, {"kind": "signal", "mid": 1}, {"name": "Bottom"}]});
let l = stack_layers(&s);
assert_eq!(l.iter().map(|x| x.0.as_str()).collect::<Vec<_>>(), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);
assert!(l[1].1 && l[1].2 == "GND");
// the bridge's own name wins when it gives one
let named = json!({"layers": [{"layer": "F.Cu"}, {"layer": "In1.Cu", "kind": "plane", "net": "GND", "layerId": 39}, {"layer": "B.Cu"}]});
assert_eq!(stack_layers(&named)[1], ("In1.Cu".to_string(), true, "GND".to_string()));
}
#[test]
fn gap_runs_group_and_the_fix_patches_the_neighbour() {
let pts: Vec<(f64, f64)> = (0..40).map(|k| (8.05 + k as f64 * 0.1, 5.0)).chain((0..5).map(|k| (15.0 + k as f64 * 0.1, 5.0))).collect();
let runs = gap_runs(&pts, 1.0);
assert_eq!(runs.len(), 2);
let layers = vec![("F.Cu".to_string(), false, String::new()), ("B.Cu".to_string(), false, String::new())];
let zones = vec![json!({"layer": "B.Cu", "net": "GND", "filled": true, "fills": [{"outline": [[0, 0], [8, 0], [8, 10], [0, 10]], "holes": []}]})];
let (_, refs) = rasters(&st(), &zones, &layers, 0.1, Some(&["GND".to_string()]));
let rows = vec![json!({"net": "SIG", "layer": "F.Cu", "gapRuns": [[8.05, 5.0, 11.95, 5.0]]})];
let p = fix_plan(&rows, &layers, &refs, &zones, &["GND".to_string()], (0.0, 0.0, 20.0, 10.0), 1.0);
assert_eq!(p.len(), 1);
assert_eq!(p[0]["layer"], "B.Cu");
assert_eq!(p[0]["net"], "GND");
assert_eq!(p[0]["points"][0], json!([7.05, 4.0]));
assert_eq!(p[0]["joinsExistingCopper"], json!(true));
assert_eq!(p[0]["removeDead"], json!(false));
}
}