app
AI Flow
Public Made by Adomby adom
Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board.
main
John Lauer
Publish 0.2.59
82daddc
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
//! Plans to copper on a board file, and KiCad's DRC on the result.
//!
//! `apply` writes a routing plan (segments and vias as `kicad_route_net` would land them) into a
//! copy of the board. With a native KiCad (kicad-cli 9+) the DRC refills every zone itself
//! (`--refill-zones`), so the pours are KiCad's own fill; only for the shared headless DRC
//! (`service-kicad pcb drc`, which does not refill zones) does `apply` add a raster fill, so plane
//! vias count as connected there.
//! `run_drc` and `summarize` give the gate its numbers: errors, new versus inherited against a
//! baseline, unconnected items.
use std::collections::{BTreeMap, HashMap};
use std::process::Command;
pub mod single_face;
use aiflow_board::{num, Board, Pt};
use aiflow_grid::{Grid, Mask};
use serde_json::{json, Value};
pub const CLEARANCE: f64 = 0.2;
pub const EDGE_CLEARANCE: f64 = 0.5;
pub const SLOP: f64 = 0.1;
#[derive(Clone, Debug)]
pub struct Seg {
pub x0: f64,
pub y0: f64,
pub x1: f64,
pub y1: f64,
pub layer: String,
pub width: f64,
}
#[derive(Clone, Debug)]
pub struct ViaSpec {
pub x: f64,
pub y: f64,
pub size: f64,
pub drill: f64,
}
fn uuid() -> String {
// v4-shaped from a time and counter seed: unique enough for a gate copy
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) as u64;
let n = N.fetch_add(1, Ordering::Relaxed);
let mut h = t ^ n.rotate_left(17) ^ 0x9E37_79B9_7F4A_7C15;
let mut parts = Vec::new();
for _ in 0..4 {
h ^= h >> 33;
h = h.wrapping_mul(0xff51_afd7_ed55_8ccd);
h ^= h >> 33;
parts.push(h);
}
format!("{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", parts[0] as u32, (parts[1] & 0xffff) as u16, (parts[1] >> 20) as u16 & 0xfff, 0x8000 | ((parts[2] & 0x3fff) as u16), parts[3] & 0xffff_ffff_ffff)
}
fn point(spec: &Value, board: &Board) -> Result<(f64, f64, Option<usize>, Option<String>), String> {
if let Some(key) = spec.as_str() {
let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;
return Ok((board.pads[i].x, board.pads[i].y, Some(i), None));
}
if let Some(o) = spec.as_object() {
let layer = o.get("layer").and_then(|l| l.as_str()).map(str::to_string);
if let Some(key) = o.get("pad").and_then(|p| p.as_str()) {
let i = board.pad_idx(key).ok_or_else(|| format!("unknown pad {key:?}"))?;
return Ok((board.pads[i].x, board.pads[i].y, Some(i), layer));
}
return Ok((o.get("x").and_then(|v| v.as_f64()).ok_or("waypoint needs x")?, o.get("y").and_then(|v| v.as_f64()).ok_or("waypoint needs y")?, None, layer));
}
if let Some(a) = spec.as_array() {
if a.len() == 2 {
return Ok((a[0].as_f64().ok_or("bad x")?, a[1].as_f64().ok_or("bad y")?, None, None));
}
}
Err(format!("bad waypoint {spec}"))
}
/// The pad key of a waypoint (`"J1.MP"` or `{"pad": "J1.MP", ...}`), if it is one.
fn waypoint_key(wp: &Value) -> Option<&str> {
wp.as_str().or_else(|| wp.get("pad").and_then(|p| p.as_str()))
}
/// Issue #93: a key that names two pads (a Hirose FH12's two `MP` pads) is ambiguous. The gate took one
/// pad and the bridge another, so a GND path gated clean landed 10 mm the other way. Every such key is
/// replaced by the coordinates of the pad it means: of the pads with that key, those on the entry's net
/// (all, when none is), the one nearest the path's neighbouring waypoint (the one before, else the
/// one after). The gate and `land route` then both see the same point. Returns one note per change.
pub fn disambiguate_pads(board: &Board, net: &str, pts: &mut [Value], where_: &str) -> Vec<String> {
let mut notes = Vec::new();
let fixed = |wp: &Value| -> Option<Pt> {
if let Some(k) = waypoint_key(wp) { return if board.key_is_ambiguous(k) { None } else { board.pad_by_key(k).map(|p| (p.x, p.y)) }; }
point(wp, board).ok().map(|(x, y, _, _)| (x, y))
};
for i in 0..pts.len() {
let Some(key) = waypoint_key(&pts[i]).map(str::to_string) else { continue };
if !board.key_is_ambiguous(&key) { continue; }
let all = board.pads_with_key(&key);
let on_net: Vec<_> = all.iter().filter(|p| p.net == net).copied().collect();
let cands = if on_net.is_empty() { all } else { on_net };
let near = (0..i).rev().find_map(|k| fixed(&pts[k])).or_else(|| (i + 1..pts.len()).find_map(|k| fixed(&pts[k])));
let pad = match near {
Some(q) => cands.iter().min_by(|a, b| (a.x - q.0).hypot(a.y - q.1).total_cmp(&(b.x - q.0).hypot(b.y - q.1))).copied(),
None => cands.first().copied(),
};
let Some(pad) = pad else { continue };
let (x, y) = (pad.x, pad.y);
let new = match pts[i].as_object() {
Some(o) => {
let mut m = o.clone();
m.remove("pad");
m.insert("x".into(), json!(x));
m.insert("y".into(), json!(y));
Value::Object(m)
}
None => json!([x, y]),
};
notes.push(format!("{where_}: {key} names {} pads; the waypoint is the one at ({}, {}){}", board.pads_with_key(&key).len(), num(x), num(y), if near.is_some() { ", nearest the path's neighbouring point" } else { "" }));
pts[i] = new;
}
notes
}
/// Make a plan say only what it lands, once, so the offline gate and `land route` agree (issue #33).
/// A waypoint at the same point and on the same layer as the one before it is dropped (it would
/// be a zero-length segment the bridge refuses mid-landing); the same point on another layer is a
/// via and stays. A path left with fewer than two waypoints has no geometry and is dropped, and an
/// entry left with no path is dropped. Returns one note per change, naming the net and the path.
/// Waypoints that do not resolve (an unknown pad) are kept as they are for the gate to report.
pub fn normalise_plan(board: &Board, plan: &mut Value) -> Vec<String> {
let mut notes = Vec::new();
let entries = match plan.get_mut("nets").and_then(|n| n.as_array_mut()) {
Some(a) => a,
None => match plan.as_array_mut() { Some(a) => a, None => return notes },
};
let mut keep_entries = Vec::with_capacity(entries.len());
for (ei, mut e) in std::mem::take(entries).into_iter().enumerate() {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("?").to_string();
let entry_layer = e.get("layer").and_then(|l| l.as_str()).map(str::to_string);
let key = if e.get("paths").map(|p| p.is_array()).unwrap_or(false) { "paths" } else if e.get("points").map(|p| p.is_array()).unwrap_or(false) { "points" } else { keep_entries.push(e); continue };
let paths: Vec<Value> = if key == "paths" { e["paths"].as_array().cloned().unwrap_or_default() } else { vec![e["points"].clone()] };
let mut kept_paths = Vec::with_capacity(paths.len());
for (pi, path) in paths.into_iter().enumerate() {
let Some(pts) = path.as_array() else { kept_paths.push(path); continue };
let where_ = if key == "paths" { format!("{net} (entry {}, path {})", ei + 1, pi + 1) } else { format!("{net} (entry {})", ei + 1) };
let mut pts = pts.clone();
notes.extend(disambiguate_pads(board, &net, &mut pts, &where_));
// issue #139: a path that names no layer and starts at (or runs first through) a bottom part's
// pad is on B.Cu; say so on its first waypoint, so `land route` sends the layer the gate saw
if entry_layer.is_none() && !pts.is_empty() {
let res: Vec<Option<(Option<usize>, bool)>> = pts.iter().map(|w| point(w, board).ok().map(|(_, _, pad, ex)| (pad, ex.is_some()))).collect();
if res.iter().all(|r| r.is_some()) && !res[0].unwrap().1 && Board::path_start_layer(res.iter().map(|r| { let (pad, ex) = r.unwrap(); (pad.map(|i| &board.pads[i]), ex) })) == "B.Cu" {
let first = pts[0].clone();
pts[0] = match &first {
Value::String(k) => json!({"pad": k, "layer": "B.Cu"}),
Value::Array(a) => json!({"x": a[0], "y": a[1], "layer": "B.Cu"}),
Value::Object(o) => { let mut m = o.clone(); m.insert("layer".into(), json!("B.Cu")); Value::Object(m) }
_ => first.clone(),
};
notes.push(format!("{where_}: starts on a bottom-side pad with no layer named; set B.Cu on its first waypoint (the pad has no copper on F.Cu)"));
}
}
let pts = &pts;
let mut layer: Option<String> = entry_layer.clone();
let mut prev: Option<(f64, f64)> = None;
let mut out: Vec<Value> = Vec::with_capacity(pts.len());
let mut unresolved = false;
for wp in pts {
let Ok((x, y, _, explicit)) = point(wp, board) else { unresolved = true; out.push(wp.clone()); prev = None; continue };
let cur = layer.clone().or_else(|| explicit.clone()).unwrap_or_else(|| "F.Cu".to_string());
let nxt = explicit.unwrap_or_else(|| cur.clone());
if let Some((px, py)) = prev {
if (px - x).abs() < 1e-6 && (py - y).abs() < 1e-6 && nxt == cur {
notes.push(format!("{where_}: dropped a repeated waypoint at ({}, {}) on {cur}", num(x), num(y)));
continue;
}
}
out.push(wp.clone());
layer = Some(nxt);
prev = Some((x, y));
}
if out.len() < 2 && !unresolved {
notes.push(format!("{where_}: dropped the path, no length left ({} waypoint{})", out.len(), if out.len() == 1 { "" } else { "s" }));
continue;
}
kept_paths.push(Value::Array(out));
}
if kept_paths.is_empty() {
notes.push(format!("{net} (entry {}): dropped the entry, no geometry left", ei + 1));
continue;
}
if key == "paths" { e["paths"] = Value::Array(kept_paths); } else { e["points"] = kept_paths.into_iter().next().unwrap(); }
keep_entries.push(e);
}
*entries = keep_entries;
// issue #183: a plan for a board with no vias changes layer only on a through-hole pad, one path per face
if plan.get("vias").and_then(|v| v.as_bool()) == Some(false) {
notes.extend(single_face::split_at_pads(board, plan));
}
notes
}
/// One plan entry to its segments and vias.
pub fn plan_to_copper(board: &Board, entry: &Value) -> Result<(Vec<Seg>, Vec<ViaSpec>), String> {
let net = entry.get("net").and_then(|n| n.as_str()).ok_or_else(|| format!("entry needs net: {PLAN_SHAPE}"))?;
if !board.nets.contains_key(net) {
return Err(format!("unknown net {net:?}"));
}
let width = entry.get("width").and_then(|w| w.as_f64()).unwrap_or(0.25);
let size = entry.get("viaSize").and_then(|w| w.as_f64()).unwrap_or(0.6);
let drill = entry.get("viaDrill").and_then(|w| w.as_f64()).unwrap_or(0.3);
let paths: Vec<Value> = match entry.get("paths").and_then(|p| p.as_array()) {
Some(p) => p.clone(),
None => vec![entry.get("points").cloned().ok_or_else(|| format!("entry needs paths or points: {PLAN_SHAPE}"))?],
};
let mut segments = Vec::new();
let mut vias = Vec::new();
for path in paths {
let pts = path.as_array().ok_or("path must be a list")?;
if pts.len() < 2 || pts.len() > 512 {
return Err(format!("path needs 2..512 waypoints ({net})"));
}
let resolved: Vec<(f64, f64, Option<usize>, Option<String>)> = pts.iter().map(|p| point(p, board)).collect::<Result<_, _>>()?;
for (_, _, pad, _) in &resolved {
if let Some(i) = pad {
if board.pads[*i].net != net {
return Err(format!("pad {} is on {}, not {}", board.pads[*i].key, board.pads[*i].net, net));
}
}
}
// issue #139: with no layer named, a path from a bottom part's pad starts on B.Cu
let mut layer = entry.get("layer").and_then(|l| l.as_str()).map(str::to_string).or_else(|| resolved[0].3.clone())
.unwrap_or_else(|| Board::path_start_layer(resolved.iter().map(|(_, _, pad, ex)| (pad.map(|i| &board.pads[i]), ex.is_some()))).to_string());
for (i, (x, y, pad, explicit)) in resolved.iter().enumerate() {
let nxt = explicit.clone().unwrap_or_else(|| layer.clone());
if i > 0 {
let (px, py) = (resolved[i - 1].0, resolved[i - 1].1);
if (px, py) != (*x, *y) {
segments.push(Seg { x0: px, y0: py, x1: *x, y1: *y, layer: layer.clone(), width });
}
}
if nxt != layer {
vias.push(ViaSpec { x: *x, y: *y, size, drill });
layer = nxt;
}
if let Some(pi) = pad {
let p = &board.pads[*pi];
let on = if layer == "F.Cu" { p.on_f } else if layer == "B.Cu" { p.on_b } else { p.thru };
if !on {
return Err(format!("pad {} has no copper on {}", p.key, layer));
}
}
}
}
Ok((segments, vias))
}
pub fn segment_sexpr(board: &Board, s: &Seg, net: &str) -> Result<String, String> {
Ok(format!("\t(segment\n\t\t(start {} {})\n\t\t(end {} {})\n\t\t(width {})\n\t\t(layer \"{}\")\n{}\t\t(uuid \"{}\")\n\t)", num(s.x0), num(s.y0), num(s.x1), num(s.y1), num(s.width), s.layer, board.net_ref_sexpr(net)?, uuid()))
}
pub fn via_sexpr(board: &Board, v: &ViaSpec, net: &str) -> Result<String, String> {
Ok(format!("\t(via\n\t\t(at {} {})\n\t\t(size {})\n\t\t(drill {})\n\t\t(layers \"F.Cu\" \"B.Cu\")\n{}\t\t(uuid \"{}\")\n\t)", num(v.x), num(v.y), num(v.size), num(v.drill), board.net_ref_sexpr(net)?, uuid()))
}
/// Insert blocks before the closing paren of the kicad_pcb list.
pub fn splice(text: &str, blocks: &[String]) -> String {
let trimmed = text.trim_end();
let idx = trimmed.rfind(')').unwrap_or(trimmed.len());
let body = text[..idx].trim_end();
let nl = if text.get(..2000).unwrap_or(text).contains("\r\n") { "\r\n" } else { "\n" };
let mut out = String::with_capacity(text.len() + blocks.iter().map(|b| b.len() + 2).sum::<usize>());
out.push_str(body);
out.push_str(nl);
out.push_str(&blocks.iter().map(|b| b.replace('\n', nl)).collect::<Vec<_>>().join(nl));
out.push_str(nl);
out.push(')');
out.push_str(nl);
out
}
// ----------------------------------------------------------------------------- plane raster fill
type Lat = (f64, f64);
fn trace_boundaries(free: &Mask) -> Vec<Vec<Lat>> {
let (w, h) = (free.w as i64, free.h as i64);
let get = |i: i64, j: i64| -> bool { i >= 0 && j >= 0 && i < w && j < h && free.bits[(j * w + i) as usize] };
let mut out: HashMap<(i64, i64), Vec<(i64, i64)>> = HashMap::new();
// lattice points scaled by 2 to stay integral: corner (i-0.5, j-0.5) -> (2i-1, 2j-1)
for j in 0..h {
for i in 0..w {
if !get(i, j) {
continue;
}
let tl = (2 * i - 1, 2 * j - 1);
let tr = (2 * i + 1, 2 * j - 1);
let br = (2 * i + 1, 2 * j + 1);
let bl = (2 * i - 1, 2 * j + 1);
if !get(i, j - 1) { out.entry(tl).or_default().push(tr); }
if !get(i + 1, j) { out.entry(tr).or_default().push(br); }
if !get(i, j + 1) { out.entry(br).or_default().push(bl); }
if !get(i - 1, j) { out.entry(bl).or_default().push(tl); }
}
}
let mut loops = Vec::new();
let mut keys: Vec<(i64, i64)> = out.keys().copied().collect();
keys.sort();
let mut ki = 0;
while !out.is_empty() {
while ki < keys.len() && !out.contains_key(&keys[ki]) {
ki += 1;
}
if ki >= keys.len() {
break;
}
let a = keys[ki];
let b = out.get_mut(&a).unwrap().pop().unwrap();
if out[&a].is_empty() {
out.remove(&a);
}
let mut lp = vec![a, b];
let mut prev = (b.0 - a.0, b.1 - a.1);
loop {
let cur = *lp.last().unwrap();
let Some(cands) = out.get_mut(&cur) else { break };
let nxt = if cands.len() == 1 {
cands.pop().unwrap()
} else {
let right = (-prev.1, prev.0);
let left = (prev.1, -prev.0);
let mut pick = None;
for want in [right, prev, left] {
if let Some(pos) = cands.iter().position(|c| (c.0 - cur.0, c.1 - cur.1) == want) {
pick = Some(cands.remove(pos));
break;
}
}
pick.unwrap_or_else(|| cands.pop().unwrap())
};
if cands.is_empty() {
out.remove(&cur);
}
prev = (nxt.0 - cur.0, nxt.1 - cur.1);
if nxt == lp[0] {
break;
}
lp.push(nxt);
}
// drop collinear vertices
let n = lp.len();
let mut simp = Vec::new();
for k in 0..n {
let p0 = lp[(k + n - 1) % n];
let p1 = lp[k];
let p2 = lp[(k + 1) % n];
if (p1.0 - p0.0) * (p2.1 - p1.1) - (p1.1 - p0.1) * (p2.0 - p1.0) != 0 {
simp.push((p1.0 as f64 / 2.0, p1.1 as f64 / 2.0));
}
}
if simp.len() >= 4 {
loops.push(simp);
}
}
loops
}
fn signed_area(poly: &[Lat]) -> f64 {
aiflow_board::geom::polygon_area(poly)
}
fn leftmost_edge(h: &[Lat]) -> Option<(f64, usize)> {
let n = h.len();
let mut best: Option<(f64, usize)> = None;
for k in 0..n {
let (a, b) = (h[k], h[(k + 1) % n]);
if a.0 == b.0 && best.map(|x| a.0 < x.0).unwrap_or(true) {
best = Some((a.0, k));
}
}
best
}
/// Splice every hole into the outer contour through a horizontal zero-width slit to the left.
fn fracture(outer: &[Lat], holes: &[Vec<Lat>]) -> Vec<Lat> {
let mut poly: Vec<Lat> = outer.to_vec();
let mut order: Vec<usize> = (0..holes.len()).collect();
order.sort_by(|&a, &b| leftmost_edge(&holes[a]).map(|x| x.0).unwrap_or(0.0).partial_cmp(&leftmost_edge(&holes[b]).map(|x| x.0).unwrap_or(0.0)).unwrap());
for hk in order {
let h = &holes[hk];
let Some((x_h, k)) = leftmost_edge(h) else { continue };
let (a, b) = (h[k], h[(k + 1) % h.len()]);
let mut ym = (a.1 + b.1) / 2.0;
if (b.1 - a.1).abs() >= 2.0 {
ym = a.1 + if b.1 > a.1 { 0.5 } else { -0.5 };
}
let start = (x_h, ym);
let mut hole = vec![start];
hole.extend_from_slice(&h[k + 1..]);
hole.extend_from_slice(&h[..k + 1]);
hole.push(start);
let mut hit: Option<(f64, usize)> = None;
for e in 0..poly.len() {
let (p0, p1) = (poly[e], poly[(e + 1) % poly.len()]);
if p0.0 == p1.0 && p0.0 < x_h && p0.1.min(p1.1) < ym && ym < p0.1.max(p1.1) && hit.map(|x| p0.0 > x.0).unwrap_or(true) {
hit = Some((p0.0, e));
}
}
let Some((xe, e)) = hit else { continue };
let joint = (xe, ym);
let mut np = poly[..e + 1].to_vec();
np.push(joint);
np.extend(hole);
np.push(joint);
np.extend_from_slice(&poly[e + 1..]);
poly = np;
}
poly
}
/// One fractured polygon per connected region of the plane, in board mm.
pub fn zone_fill_polygons(board: &Board, grid: &Grid, zone_net: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> Vec<Vec<Pt>> {
let mut free = grid.outline_inside(&board.outline, EDGE_CLEARANCE + SLOP);
let mut block = grid.mask();
let mut by_clearance: BTreeMap<i64, Mask> = BTreeMap::new();
for p in &board.pads {
if !p.thru || p.net == zone_net {
continue;
}
let target = if p.clearance <= CLEARANCE { &mut block } else { by_clearance.entry((p.clearance * 1000.0) as i64).or_insert_with(|| grid.mask()) };
for poly in &p.polys {
grid.poly_mask(poly, target);
}
if let Some(d) = p.drill {
for c in grid.disk_cells((p.x, p.y), d / 2.0, None) {
target.bits[c] = true;
}
}
}
// drilled holes with no copper (mask-only NPTH): the pour keeps its clearance from the hole
for h in &board.holes {
grid.poly_mask(&h.poly, &mut block);
for c in grid.disk_cells((h.x, h.y), h.size.0.min(h.size.1) / 2.0, None) {
block.bits[c] = true;
}
}
for (net, vias) in vias_by_net {
if net == zone_net {
continue;
}
for v in vias {
for c in grid.disk_cells((v.x, v.y), v.size / 2.0, None) {
block.bits[c] = true;
}
}
}
let mut blocked = grid.dilate(&block, (CLEARANCE + SLOP) / grid.res);
for (clr, m) in &by_clearance {
blocked.or_with(&grid.dilate(m, (*clr as f64 / 1000.0 + SLOP) / grid.res));
}
free.and_not(&blocked);
let loops = trace_boundaries(&free);
let outers: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) > 0.0).collect();
let holes: Vec<&Vec<Lat>> = loops.iter().filter(|l| signed_area(l) < 0.0).collect();
let mut polys = Vec::new();
for outer in outers {
let ob = aiflow_board::geom::bbox(outer);
let mut mine: Vec<Vec<Lat>> = Vec::new();
for h in &holes {
let hb = aiflow_board::geom::bbox(h);
if hb.0 < ob.0 || hb.1 < ob.1 || hb.2 > ob.2 || hb.3 > ob.3 {
continue;
}
if aiflow_board::geom::point_in_poly(h[0].0 + 1e-6, h[0].1 + 1e-6, outer) {
mine.push((*h).clone());
}
}
let poly = fracture(outer, &mine);
polys.push(poly.into_iter().map(|(x, y)| (grid.x0 + x * grid.res, grid.y0 + y * grid.res)).collect());
}
polys
}
/// Every top-level zone: (node start, node end, net, layer, connect_pads span).
fn zones_of(board: &Board) -> Vec<(usize, usize, String, Option<String>, Option<(usize, usize, bool)>)> {
let mut out = Vec::new();
for z in board.root.find_all("zone") {
let mut net = z.find("net").and_then(|n| n.value()).unwrap_or("").to_string();
if board.net_format == aiflow_board::NetFormat::Number {
if let Some(k) = z.find("net").and_then(|n| n.atom(1)).and_then(|a| a.parse::<i64>().ok()) {
if let Some(nm) = board.net_table.get(&k) {
net = nm.clone();
}
}
}
if let Some(nn) = z.find("net_name").and_then(|n| n.value()) {
net = nn.to_string();
}
let layer = z.find("layer").and_then(|l| l.value()).map(str::to_string);
let cp = z.find("connect_pads").map(|c| (c.start, c.end, matches!(c.atom(1), Some("yes") | Some("no") | Some("thru_hole_only"))));
out.push((z.start, z.end, net, layer, cp));
}
out
}
/// The board text with a filled_polygon list inside every zone (raster fill), and the counts.
pub fn fill_zones(board: &Board, text: &str, vias_by_net: &HashMap<String, Vec<ViaSpec>>) -> (String, BTreeMap<String, usize>) {
let grid = Grid::new(board.bbox);
let mut edits: Vec<(usize, usize, String)> = Vec::new();
let mut stats = BTreeMap::new();
for (start, end, net, layer, cp) in zones_of(board) {
let (Some(layer), false) = (layer, net.is_empty()) else { continue };
let _ = start;
let polys = zone_fill_polygons(board, &grid, &net, vias_by_net);
let mut blocks = String::new();
for poly in &polys {
let pts: Vec<String> = poly.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
blocks.push_str(&format!("\t\t(filled_polygon\n\t\t\t(layer \"{}\")\n\t\t\t(pts\n\t\t\t\t{}\n\t\t\t)\n\t\t)\n", layer, pts.join(" ")));
}
edits.push((end - 1, end - 1, blocks + "\t"));
if let Some((cs, _, explicit)) = cp {
if !explicit {
edits.push((cs, cs + "(connect_pads".len(), "(connect_pads yes".to_string()));
}
}
stats.insert(net, polys.len());
}
edits.sort_by_key(|e| e.0);
let mut out = String::with_capacity(text.len() + 4096);
let mut pos = 0;
for (at, end, ins) in edits {
out.push_str(&text[pos..at]);
out.push_str(&ins);
pos = end;
}
out.push_str(&text[pos..]);
(out, stats)
}
/// The board text with the plan's copper (and raster fills unless `fill` is false), and stats.
/// What a route plan must look like, for errors that refuse one. docs/plan-schema.md has the rest.
pub const PLAN_SHAPE: &str = "a route plan is {\"nets\": [entry, ...]} or a bare [entry, ...]; each entry is {\"net\": \"VIN\", \"width\": 0.25, \"viaSize\": 0.6, \"viaDrill\": 0.3, \"paths\": [[point, point, ...], ...]} (or one path as \"points\"), and a point is a pad key \"U1.3\", [x, y] in mm, {\"x\": x, \"y\": y, \"layer\": \"B.Cu\"} or {\"pad\": \"U1.3\", \"layer\": \"B.Cu\"}; a layer that differs from the current one drops a through via there (docs/plan-schema.md)";
/// The entries of a route plan: `{"nets": [...]}` or a bare array.
pub fn plan_entries(plan: &Value) -> Result<Vec<Value>, String> {
plan.get("nets").and_then(|n| n.as_array()).cloned().or_else(|| plan.as_array().cloned())
.ok_or_else(|| format!("plan needs nets: {PLAN_SHAPE}"))
}
/// A route plan in its object form: a bare array of entries becomes `{"nets": [...]}`, so every
/// reader of `plan["nets"]` (gate, land route, the via prechecks) sees the same entries.
pub fn normalize_plan(plan: Value) -> Value {
match plan {
Value::Array(a) => json!({"nets": a}),
v => v,
}
}
pub fn apply(board: &Board, plan: &Value, fill: bool) -> Result<(String, Value), String> {
let entries: Vec<Value> = plan_entries(plan)?;
let mut blocks = Vec::new();
let mut vias_by_net: HashMap<String, Vec<ViaSpec>> = HashMap::new();
let (mut n_seg, mut n_via) = (0usize, 0usize);
let mut length = 0.0;
let mut widths: BTreeMap<String, usize> = BTreeMap::new();
let mut nets = std::collections::HashSet::new();
for e in &entries {
let net = e.get("net").and_then(|n| n.as_str()).unwrap_or("").to_string();
nets.insert(net.clone());
let (segs, vias) = plan_to_copper(board, e)?;
for s in &segs {
blocks.push(segment_sexpr(board, s, &net)?);
length += (s.x1 - s.x0).hypot(s.y1 - s.y0);
*widths.entry(num(s.width)).or_insert(0) += 1;
}
for v in &vias {
// issue #167: two paths of one net that change layer at one point are one via, not two
// co-located holes
if vias_by_net.get(&net).is_some_and(|vs| vs.iter().any(|w| (w.x - v.x).abs() < 1e-3 && (w.y - v.y).abs() < 1e-3)) {
continue;
}
blocks.push(via_sexpr(board, v, &net)?);
vias_by_net.entry(net.clone()).or_default().push(v.clone());
n_via += 1;
}
n_seg += segs.len();
}
let mut text = splice(&board.text, &blocks);
let mut stats = json!({"entries": entries.len(), "nets": nets.len(), "segments": n_seg, "vias": n_via, "copperLengthMm": (length * 100.0).round() / 100.0, "segmentsByWidth": widths});
if fill {
let (t, fills) = fill_zones(board, &text, &vias_by_net);
text = t;
stats["zoneFillPolygons"] = json!(fills);
}
Ok((text, stats))
}
// ----------------------------------------------------------------------------- DRC
/// Keep native project constraints beside each board snapshot, under its new basename.
pub fn copy_project_rules(source: &str, destination: &str) -> Result<(), String> {
for ext in ["kicad_pro", "kicad_dru"] {
let src=std::path::Path::new(source).with_extension(ext);
let dst=std::path::Path::new(destination).with_extension(ext);
if src.is_file() && src != dst {
std::fs::copy(&src,&dst).map_err(|e| format!("copy project rules {}: {e}",src.display()))?;
}
}
Ok(())
}
/// The native kicad-cli to use for DRC, when one is usable: `ADOM_AIFLOW_KICAD_CLI` or `kicad-cli`,
/// and only if its `pcb drc` supports `--refill-zones` (KiCad 9+). A stock KiCad 7 `kicad-cli` answers
/// `--version` but has no `pcb drc` and cannot read a KiCad 10 board, so it is not native here.
/// Cached: the probe spawns processes (and may cross Adom Bridge to a desktop).
pub fn native_kicad_cli() -> Option<String> {
static NATIVE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
NATIVE.get_or_init(|| {
let probe = |cli: &str| -> bool {
Command::new(cli).args(["pcb", "drc", "--help"]).output().ok()
.map(|h| format!("{}{}", String::from_utf8_lossy(&h.stdout), String::from_utf8_lossy(&h.stderr)).contains("--refill-zones")).unwrap_or(false)
};
if let Ok(cli) = std::env::var("ADOM_AIFLOW_KICAD_CLI") { return probe(&cli).then_some(cli); }
if probe("kicad-cli") { return Some("kicad-cli".into()); }
// no KiCad here (a container): the run's own desktop runs it through Adom Bridge
(std::env::var("KICAD_REMOTE_TARGET").is_ok() && probe("adom-aiflow-kicad-cli-remote")).then(|| "adom-aiflow-kicad-cli-remote".to_string())
}).clone()
}
/// True when DRC runs on a native KiCad that refills zones itself (so no raster fill is needed).
pub fn native_drc_available() -> bool {
native_kicad_cli().is_some()
}
pub fn run_drc(path: &str) -> Result<Value, String> {
run_drc_with(path, None)
}
/// The argument and environment that ask native KiCad for schematic parity, or None when it cannot run.
/// A local kicad-cli reads `<board>.kicad_sch` beside the board; the remote wrapper sends that file, or
/// the run's schematic (KICAD_REMOTE_SCHEMATIC) under the board's name, with its sub-sheets (#96).
/// No schematic, or the headless service: no parity, and the report's empty `schematic_parity` means not run.
pub fn parity_request(cli: &str, board: &str, schematic: Option<&str>) -> Option<Option<String>> {
let sch = schematic.filter(|s| std::path::Path::new(s).is_file())?;
let remote = std::path::Path::new(cli).file_name().map(|n| n.to_string_lossy().ends_with("kicad-cli-remote")).unwrap_or(false);
if remote { return Some(Some(sch.to_string())); }
std::path::Path::new(board).with_extension("kicad_sch").is_file().then_some(None)
}
/// `run_drc`, with schematic parity when the run records the schematic the board was made from.
pub fn run_drc_with(path: &str, schematic: Option<&str>) -> Result<Value, String> {
let dir = std::env::temp_dir().join(format!("aiflow-drc-{}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let report = dir.join(format!("drc-{}.json", uuid()));
let native = native_kicad_cli();
let has_rules = ["kicad_pro", "kicad_dru"].iter().any(|ext|std::path::Path::new(path).with_extension(ext).is_file());
if has_rules && native.is_none() {
return Err("project rules are present but no native kicad-cli with `pcb drc --refill-zones` was found (a stock KiCad 7 kicad-cli does not count), and service-kicad accepts only the PCB and would lose the rules. Set ADOM_AIFLOW_KICAD_CLI to a KiCad 9+ kicad-cli (in a container with no native KiCad: ADOM_AIFLOW_KICAD_CLI=adom-aiflow-kicad-cli-remote runs a desktop's KiCad through Adom Bridge; KICAD_REMOTE_TARGET picks the desktop) and retry; do not strip the project files to obtain a pass".into());
}
// Native KiCad refills every zone itself, so the copper in the pours is KiCad's own fill,
// current for whatever the board now holds. The raster fill stays only for the headless service.
let out = if let Some(cli) = &native {
let mut c = Command::new(cli);
c.args(["pcb","drc","--refill-zones"]);
if let Some(env) = parity_request(cli, path, schematic) {
c.arg("--schematic-parity");
if let Some(s) = env { c.env("KICAD_REMOTE_SCHEMATIC", s); }
}
c.args(["--format","json","--output",report.to_str().unwrap(),path]).output()
} else {
Command::new("service-kicad").args(["pcb", "drc", "--format", "json", "--out", report.to_str().unwrap(), path]).output()
}.map_err(|e|format!("DRC backend not runnable: {e}"))?;
if !out.status.success() || !report.is_file() {
return Err(format!("KiCad DRC failed: {} {}", clip_text(&String::from_utf8_lossy(&out.stdout), 600), clip_text(&String::from_utf8_lossy(&out.stderr), 600)));
}
let data: Value = serde_json::from_str(&std::fs::read_to_string(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let _ = std::fs::remove_file(&report);
Ok(data)
}
fn fingerprint(v: &Value) -> String {
let mut pos: Vec<String> = v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").map(|p| p.to_string()).unwrap_or_default()).collect()).unwrap_or_default();
pos.sort();
json!([v.get("type"), v.get("severity"), v.get("description"), pos]).to_string()
}
fn count_types(list: &[&Value]) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for v in list {
*m.entry(v.get("type").and_then(|t| t.as_str()).unwrap_or("?").to_string()).or_insert(0) += 1;
}
m
}
/// Silkscreen findings: ink over a mask opening (the fab clips it off), ink on ink, ink past the edge, text under
/// the fab's minimum. Advisories by default (issue #92, John's decision of 2026-10-21): every fab trims ink at the
/// mask openings, and the Altium lane grades the same rules as warnings. They are counted and listed, never a
/// refusal, unless the spec or the fab rules profile opts in to blocking (`fab.silkBlocking: true`), which makes
/// them blocking again whatever severity the project gives them.
pub const SILK_TYPES: &[&str] = &["silk_over_copper", "silk_overlap", "silk_edge_clearance", "text_height", "text_thickness"];
/// The former name of `SILK_TYPES` (before 0.2.19 these always blocked).
pub const SILK_BLOCKING: &[&str] = SILK_TYPES;
/// A silkscreen finding (`SILK_TYPES`).
pub fn is_silk(v: &Value) -> bool { v["type"].as_str().map(|t| SILK_TYPES.contains(&t)).unwrap_or(false) }
/// Blocking with the default silkscreen policy: silkscreen findings are advisories.
pub fn blocking_violation(v: &Value) -> bool { blocking_violation_with(v, false) }
/// Manufacturing constraints remain blocking even when a project downgrades them to warnings. Silkscreen
/// findings block only when `silk_blocking` (the opt-in), at either severity.
pub fn blocking_violation_with(v: &Value, silk_blocking: bool) -> bool {
let graded = v["severity"] == "error" || v["severity"] == "warning";
if is_silk(v) { return silk_blocking && graded; }
v["severity"] == "error" || (v["severity"] == "warning" && matches!(v["type"].as_str(),
Some("hole_to_hole" | "holes_co_located" | "drill_out_of_range" | "annular_width" | "track_width" | "clearance" | "copper_edge_clearance" | "courtyards_overlap" | "courtyard_overlap")))
}
/// One finding as a short line: its type and the items it names.
pub fn finding_line(v: &Value) -> String {
let items = v["items"].as_array().map(|a| a.iter().map(|i| i.as_str().map(str::to_string).unwrap_or_else(|| i["description"].as_str().unwrap_or("").to_string())).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(" / ")).unwrap_or_default();
if items.is_empty() { v["type"].as_str().unwrap_or("?").to_string() } else { format!("{} ({items})", v["type"].as_str().unwrap_or("?")) }
}
/// The gate's summary with the default silkscreen policy (advisories).
pub fn summarize(data: &Value, baseline: Option<&Value>) -> Value { summarize_with(data, baseline, false) }
/// The gate counts errors and manufacturing warnings, matching inherited findings by identity. Silkscreen
/// findings are counted and listed as advisories (`silkAdvisories`) unless `silk_blocking`.
pub fn summarize_with(data: &Value, baseline: Option<&Value>, silk_blocking: bool) -> Value {
let blocking = |v: &Value| blocking_violation_with(v, silk_blocking);
let viol: Vec<&Value> = data.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();
let errors: Vec<&Value> = viol.iter().copied().filter(|v| blocking(v)).collect();
let silk: Vec<&Value> = if silk_blocking { Vec::new() } else { viol.iter().copied().filter(|v| is_silk(v)).collect() };
let warnings_all: Vec<&Value> = viol.iter().copied().filter(|v| v.get("severity").and_then(|s| s.as_str()) == Some("warning")).collect();
let mut new: Vec<&Value> = errors.clone();
let mut inherited: Vec<&Value> = Vec::new();
if let Some(base) = baseline {
let mut existing: HashMap<String, usize> = HashMap::new();
for v in base.get("violations").and_then(|v| v.as_array()).map(|a| a.iter().collect::<Vec<_>>()).unwrap_or_default() {
if blocking(v) {
*existing.entry(fingerprint(v)).or_insert(0) += 1;
}
}
new.clear();
for v in &errors {
let k = fingerprint(v);
if existing.get(&k).copied().unwrap_or(0) > 0 {
*existing.get_mut(&k).unwrap() -= 1;
inherited.push(v);
} else {
new.push(v);
}
}
}
let unconnected: Vec<&Value> = data.get("unconnected_items").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default();
let fill_artifacts = warnings_all.iter().filter(|v| v.get("type").and_then(|t| t.as_str()) == Some("isolated_copper")).count();
let warnings: Vec<&Value> = warnings_all.iter().copied().filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("isolated_copper")).collect();
let desc = |v: &Value| -> Vec<Value> { v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("description").cloned().unwrap_or(Value::Null)).collect()).unwrap_or_default() };
json!({
"blockingIncludesManufacturingWarnings": true, "errors": errors.len(), "newErrors": new.len(), "inheritedErrors": inherited.len(), "warnings": warnings.len(), "unconnected": unconnected.len(),
"rasterFillIslandWarnings": fill_artifacts, "errorTypes": count_types(&errors), "newErrorTypes": count_types(&new), "warningTypes": count_types(&warnings),
"silkBlocking": silk_blocking, "silkAdvisories": silk.len(), "silkAdvisoryTypes": count_types(&silk),
"silkAdvisoryList": silk.iter().take(50).map(|v| finding_line(v)).collect::<Vec<_>>(),
"newErrorList": new.iter().map(|v| json!({"type": v.get("type"), "description": v.get("description"), "items": desc(v), "pos": v.get("items").and_then(|i| i.as_array()).map(|a| a.iter().map(|i| i.get("pos").cloned().unwrap_or(Value::Null)).collect::<Vec<_>>())})).collect::<Vec<_>>(),
"unconnectedList": unconnected.iter().map(|u| desc(u)).collect::<Vec<_>>(),
"kicadVersion": data.get("kicad_version"),
})
}
/// Error text within `n` characters that keeps its beginning (where the cause is) and its end:
/// about 70% from the front, " ... ", the rest from the back (issue #76). Never cuts the front off.
fn clip_text(s: &str, n: usize) -> String {
let s = s.trim();
let count = s.chars().count();
if count <= n { return s.to_string(); }
let head = n * 7 / 10;
let tail = n.saturating_sub(head + 5);
let front: String = s.chars().take(head).collect();
let back: String = s.chars().skip(count - tail).collect();
format!("{front} ... {back}")
}
#[cfg(test)]
mod gate_tests {
use super::*;
#[test]
fn manufacturing_warnings_block_but_library_warnings_do_not() {
let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":1,"y":2}}]});
let library = json!({"type":"lib_footprint_mismatch","severity":"warning","description":"local library differs"});
let candidate=json!({"violations":[hole.clone(),library]});
assert_eq!(summarize(&candidate,None)["newErrors"],1);
assert_eq!(summarize(&candidate,Some(&json!({"violations":[hole]})))["newErrors"],0);
let moved=json!({"violations":[{"type":"hole_to_hole","severity":"warning","description":"too close","items":[{"pos":{"x":2,"y":2}}]}]});
assert_eq!(summarize(&candidate,Some(&moved))["newErrors"],1);
}
#[test]
fn silkscreen_findings_are_advisories_unless_opted_in() {
// issue #92: a test pad under a part's value text. KiCad grades it a warning; the fab clips the ink, so it
// is reported (counted and listed), not a refusal, as on the Altium lane
let over = json!({"type":"silk_over_copper","severity":"warning","description":"Silkscreen clipped by solder mask","items":[{"description":"PCB text '150k'"},{"description":"Pad 1 [VIN] of TP6"}]});
let overlap = json!({"type":"silk_overlap","severity":"error","description":"Silkscreen clearance"});
let library = json!({"type":"lib_footprint_issues","severity":"warning","description":"library not enabled"});
let board = json!({"violations":[over.clone(), overlap.clone(), library.clone()]});
let s = summarize(&board, None);
assert_eq!(s["newErrors"], 0, "{s}");
assert_eq!(s["errors"], 0);
assert_eq!(s["silkAdvisories"], 2);
assert_eq!(s["silkAdvisoryTypes"]["silk_over_copper"], 1);
assert!(s["silkAdvisoryList"][0].as_str().unwrap().contains("Pad 1 [VIN] of TP6"), "{s}");
// the opt-in (fab.silkBlocking) restores the old behaviour, at either severity
let s = summarize_with(&board, None, true);
assert_eq!(s["newErrors"], 2, "{s}");
assert_eq!(s["silkAdvisories"], 0);
assert!(blocking_violation_with(&over, true) && !blocking_violation(&over));
// every other manufacturing check still blocks
let hole = json!({"type":"hole_to_hole","severity":"warning","description":"too close"});
let s = summarize(&json!({"violations":[over, hole, library]}), None);
assert_eq!(s["newErrors"], 1, "{s}");
assert_eq!(s["newErrorTypes"]["hole_to_hole"], 1);
assert_eq!(s["silkAdvisories"], 1);
}
#[test]
fn parity_is_requested_only_when_a_schematic_can_reach_kicad() {
let dir = std::env::temp_dir().join(format!("aiflow-parity-{}", uuid()));
std::fs::create_dir_all(&dir).unwrap();
let sch = dir.join("design.kicad_sch"); std::fs::write(&sch, "(kicad_sch)").unwrap();
let board = dir.join("board-1.kicad_pcb"); let b = board.to_str().unwrap(); let s = sch.to_str().unwrap();
// no recorded schematic, or a missing one: no parity for any backend
assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, None), None);
assert_eq!(parity_request("adom-aiflow-kicad-cli-remote", b, Some("/no/such.kicad_sch")), None);
// the remote wrapper sends the recorded schematic under the board's name
assert_eq!(parity_request("/opt/x/adom-aiflow-kicad-cli-remote", b, Some(s)), Some(Some(s.to_string())));
// a local kicad-cli reads only <board>.kicad_sch beside the board
assert_eq!(parity_request("kicad-cli", b, Some(s)), None);
std::fs::write(board.with_extension("kicad_sch"), "(kicad_sch)").unwrap();
assert_eq!(parity_request("kicad-cli", b, Some(s)), Some(None));
std::fs::remove_dir_all(&dir).ok();
}
}
#[cfg(test)]
mod normalise_tests {
use super::*;
fn board() -> Board {
Board::from_text("test.kicad_pcb", r#"(kicad_pcb
(layers (0 "F.Cu" signal) (31 "B.Cu" signal))
(net 1 "VCC")
(gr_rect (start 0 0) (end 200 200) (layer "Edge.Cuts"))
(footprint "t" (layer "F.Cu") (at 80 100) (property "Reference" "U1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "VCC"))))"#.into()).unwrap()
}
#[test]
fn zero_length_path_is_dropped_before_gate_and_landing() {
// issue #33: this passed the gate and stopped `land route` with zero_length
let b = board();
let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[[88.2, 106.8], [88.2, 106.8]]]},
{"net": "VCC", "width": 0.25, "paths": [[[80.0, 100.0], [80.0, 100.0], [85.0, 100.0]], [[85.0, 100.0], [85.0, 100.0]]]}]});
let notes = normalise_plan(&b, &mut plan);
assert_eq!(plan["nets"].as_array().unwrap().len(), 1, "{plan}");
assert_eq!(plan["nets"][0]["paths"], json!([[[80.0, 100.0], [85.0, 100.0]]]));
assert!(notes.iter().any(|n| n.contains("VCC (entry 1): dropped the entry")), "{notes:?}");
assert!(notes.iter().any(|n| n.contains("entry 2, path 1): dropped a repeated waypoint at (80, 100) on F.Cu")), "{notes:?}");
assert!(notes.iter().any(|n| n.contains("entry 2, path 2): dropped the path")), "{notes:?}");
// what is left applies, and normalising again changes nothing
assert!(apply(&b, &plan, false).is_ok());
assert!(normalise_plan(&b, &mut plan).is_empty());
}
#[test]
fn a_path_starting_on_back_copper_lands_on_back_copper() {
// issue #37: the router exports a start on existing B.Cu copper as {x, y, layer: "B.Cu"};
// the gate copy puts the first segment on B.Cu with no via at the start
let b = board();
let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [[{"x": 84.0, "y": 104.0, "layer": "B.Cu"}, [86.0, 104.0], [86.0, 106.0]]]}]});
assert!(normalise_plan(&b, &mut plan).is_empty());
let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
assert_eq!(segs.len(), 2);
assert!(segs.iter().all(|s| s.layer == "B.Cu"), "{segs:?}");
assert!(vias.is_empty());
}
#[test]
fn coincident_points_on_different_layers_stay_a_via() {
let b = board();
let mut plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]]]}]});
let notes = normalise_plan(&b, &mut plan);
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("(84, 100) on B.Cu"), "{notes:?}");
assert_eq!(plan["nets"][0]["paths"][0].as_array().unwrap().len(), 4);
let (segs, vias) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
assert_eq!((segs.len(), vias.len()), (2, 1));
// a via-only path (same point, two layers) keeps its geometry
let mut via_only = json!({"nets": [{"net": "VCC", "points": [{"x": 90.0, "y": 90.0, "layer": "F.Cu"}, {"x": 90.0, "y": 90.0, "layer": "B.Cu"}]}]});
assert!(normalise_plan(&b, &mut via_only).is_empty());
assert_eq!(via_only["nets"][0]["points"].as_array().unwrap().len(), 2);
}
#[test]
fn two_paths_of_one_net_changing_layer_at_one_point_are_one_via() {
// issue #167: the gate copy had two same-net vias at one point (holes_co_located)
let b = board();
let plan = json!({"nets": [{"net": "VCC", "width": 0.25, "paths": [
["U1.1", [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 104.0]],
[[88.0, 100.0], [84.0, 100.0], {"x": 84.0, "y": 100.0, "layer": "B.Cu"}, [84.0, 96.0]]]}]});
let (text, stats) = apply(&b, &plan, false).unwrap();
assert_eq!(stats["vias"], 1, "{stats}");
assert_eq!(text.matches("(via").count(), 1);
}
}
#[cfg(test)]
mod duplicate_pad_tests {
use super::*;
/// A Hirose FH12-style connector: two mounting pads both numbered MP, 13.3 mm apart, on GND.
fn board() -> Board {
Board::from_text("test.kicad_pcb", r#"(kicad_pcb
(layers (0 "F.Cu" signal) (31 "B.Cu" signal))
(net 1 "GND") (net 2 "OUTA1")
(gr_rect (start 80 170) (end 120 200) (layer "Edge.Cuts"))
(footprint "FH12" (layer "F.Cu") (at 100 189.3) (property "Reference" "J1")
(pad "MP" smd rect (at -6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))
(pad "MP" smd rect (at 6.65 0) (size 1.8 2.2) (layers "F.Cu") (net 1 "GND"))
(pad "1" smd rect (at 0 -1.5) (size 0.3 1) (layers "F.Cu") (net 2 "OUTA1")))
(footprint "R" (layer "F.Cu") (at 90 180) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net 1 "GND"))))"#.into()).unwrap()
}
#[test]
fn a_key_naming_two_pads_becomes_the_nearest_pads_point_for_gate_and_landing() {
let b = board();
assert!(b.key_is_ambiguous("J1.MP") && !b.key_is_ambiguous("J1.1"));
assert_eq!(b.pads_with_key("J1.MP").len(), 2);
// issue #93: the path comes from R1 on the left, so J1.MP is the left pad (93.35, 189.3)
let mut plan = json!({"nets": [{"net": "GND", "width": 0.25, "paths": [["R1.1", [93.35, 185.0], "J1.MP"]]},
{"net": "GND", "width": 0.25, "paths": [[{"pad": "J1.MP", "layer": "F.Cu"}, [110.0, 189.3]]]}]});
let notes = normalise_plan(&b, &mut plan);
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(notes[0].contains("J1.MP names 2 pads; the waypoint is the one at (93.35, 189.3)"), "{notes:?}");
assert_eq!(plan["nets"][0]["paths"][0][2], json!([93.35, 189.3]));
// a starting {pad, layer} keeps its layer; the next point is right of the board centre, so the right pad
let start = &plan["nets"][1]["paths"][0][0];
assert!(start.get("pad").is_none() && start["layer"] == "F.Cu", "{start}");
assert!((start["x"].as_f64().unwrap() - 106.65).abs() < 1e-9);
// the gate copy and the landing read the same points, and again normalising changes nothing
let (segs, _) = plan_to_copper(&b, &plan["nets"][0]).unwrap();
assert!((segs.last().unwrap().x1 - 93.35).abs() < 1e-9);
// `land route` sends these points to kicad_route_net: no key is left for the bridge to pick a pad by
assert!(!plan.to_string().contains("J1.MP"), "{plan}");
assert!(normalise_plan(&b, &mut plan).is_empty());
// an unambiguous key stays a key
let mut plain = json!({"nets": [{"net": "GND", "paths": [["R1.1", [85.0, 180.0]]]}]});
assert!(normalise_plan(&b, &mut plain).is_empty());
assert_eq!(plain["nets"][0]["paths"][0][0], "R1.1");
}
}