app
AI Flow: Routing
Public Made by Adomby adom
The routing stage of AI Flow: routes a placed KiCad board connection by connection and reports what it made, what it could not and why. A guide, never a gate.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182
//! The standards checker. Reads any .kicad_pcb and reports what breaks the designer's routing rules, with
//! the place. It trusts nothing from the router: it reads the written file from scratch.
use crate::board::*;
use crate::classify::*;
use crate::conn;
use crate::geom::*;
use crate::index::*;
use crate::mask::MaskMap;
use crate::rules::Rules;
use crate::router::build_world;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
#[derive(Clone, Debug)]
pub struct Finding {
pub rule: &'static str,
/// error: breaks a hard rule. warn: a preference or something to read by eye.
pub severity: &'static str,
pub at: (f64, f64),
pub text: String,
}
#[derive(Default, Debug)]
pub struct Score {
pub nets_open: usize,
pub pads_open: usize,
pub length_mm: f64,
pub length_top_mm: f64,
pub length_bottom_mm: f64,
pub length_inner_mm: f64,
pub vias: usize,
pub segments: usize,
pub bends45: usize,
pub bends90: usize,
pub widths: BTreeMap<String, usize>,
pub min_gap_mm: f64,
pub tight_mm: f64,
pub spacing: Spacing,
pub pairs: Vec<PairStat>,
/// Distance from each signal via to its nearest GND via, mm (4.4), sorted.
pub signal_via_gnd_mm: Vec<f64>,
/// Per net: what its route costs against how far apart its pads are.
pub nets: Vec<NetStat>,
pub width_steps: usize,
pub rails: Vec<RailStat>,
pub cap_first: CapFirst,
pub entries: Entries,
/// Each supply pin's own cap, pad edge to pad edge, farthest first.
pub own_cap_mm: Vec<(f64, String)>,
}
/// Every track end on its own pad (4.2): how it comes in.
#[derive(Default, Debug)]
pub struct Entries {
pub total: usize,
pub centre: usize,
pub corner: usize,
pub side: usize,
/// Not square to the edge it crosses.
pub oblique: usize,
}
/// How each supply pin of an IC reaches its net (4.2), walked on the copper.
#[derive(Default, Debug)]
pub struct CapFirst {
pub pins: usize,
/// Its own pad shares copper with the cap's.
pub direct: usize,
/// A track or via path with nothing on the way.
pub cap: usize,
/// It reaches a fill first, which is the rail: v1.3's rings.
pub on_fill: usize,
pub junction: usize,
}
#[derive(Default, Debug)]
pub struct NetStat {
pub name: String,
pub level: &'static str,
pub pads: usize,
pub len_mm: f64,
/// Straight-line length of the shortest tree over its pads.
pub tree_mm: f64,
pub vias: usize,
pub width_steps: usize,
pub fill: bool,
}
/// One power net's record (4.2: main run and branches, found from the copper actually connected).
#[derive(Default, Debug)]
pub struct RailStat {
pub name: String,
pub main_width_mm: f64,
pub main_len_mm: f64,
pub fed_by_fill: bool,
/// Narrower widths and how much of each, the branch analysis he asked for.
pub branches: Vec<(f64, f64)>,
}
/// Track length by spacing to the nearest other-net copper (4.2). 3W is centre to centre, a gap of 2W.
/// Length inside a fine-pitch escape is counted apart, since spacing may tighten there.
#[derive(Default, Debug)]
pub struct Spacing {
pub at_3w_mm: f64,
pub at_2w_mm: f64,
pub under_2w_mm: f64,
pub escape_mm: f64,
}
#[derive(Default, Debug)]
pub struct PairStat {
pub p: String,
pub n: String,
pub len_p_mm: f64,
pub len_n_mm: f64,
/// Length of P with N running parallel on the same layer at the gap, mm.
pub coupled_mm: f64,
pub gap_mm: f64,
pub vias: usize,
}
/// A clearance is broken when it is short by more than this. KiCad flagged 0.1991 against 0.2 mm
/// on the benchmark C, so the tolerance has to sit below 0.9 microns.
const TOL: f64 = 500.0;
/// Segments this short (nm) are a width step or a stray join, not a direction change: the benchmark C has
/// 4 micron jogs where one width meets another. Same length the off-45 check ignores.
const JOG: f64 = 20_000.0;
/// Rules a finished board must pass on geometry alone: `grade` holds the designer's boards to these.
pub const GEOMETRY_RULES: &[&str] = &[
"clearance", "hole-clearance", "hole-to-hole", "keepout", "edge", "width", "width-setup", "via-min", "off-45", "corner-90", "corner-acute", "square-t", "crystal-chain", "via-in-pad", "mask-bridge", "mask-web", "xtal-series-line", "xtal-load-cap-line", "decoupling-pin", "inner-tie-no-fill", "inner-route", "fold-back", "same-net-parallel", "cap-first", "fill-unfed", "rail-floor", "kelvin",
];
fn key(p: P) -> P {
// Endpoints that meet to within 2 microns are one node.
((p.0 + 1000).div_euclid(2000), (p.1 + 1000).div_euclid(2000))
}
/// Angle in degrees of a segment's direction out of a node.
fn out_angle(from: P, to: P) -> f64 {
((to.1 - from.1) as f64).atan2((to.0 - from.0) as f64).to_degrees()
}
fn diff(a: f64, b: f64) -> f64 {
let d = (a - b).rem_euclid(360.0);
d.min(360.0 - d)
}
fn pad_at(board: &Board, p: P, layer: usize, net: NetId) -> Option<usize> {
let v: V = p.into();
board.pads.iter().position(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(v) <= 1.0)
}
/// A package's heat pad: thermal vias belong in it (4.4). Measured on the copper, since a custom
/// pad's anchor can be tiny (U5.10 on the benchmark C: anchor 0.38 mm, pad 2.4 mm).
fn is_exposed(board: &Board, i: usize) -> bool {
let p = &board.pads[i];
let b = p.shape.bbox();
board.footprints[p.fp].pads.len() > 4 && b.2 - b.0 >= 1.0 * NM && b.3 - b.1 >= 1.0 * NM
}
/// Pads against pads: KiCad's DRC holds them to the clearance too, inside a footprint as well, and
/// a pad's copper and hole to the board edge and to other holes.
fn pad_pad_rules(board: &Board, rules: &Rules, world: &crate::search::World, f: &mut Vec<Finding>) {
let reach = rules.max_clearance() as f64;
let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));
for (i, p) in board.pads.iter().enumerate() {
let b = p.shape.bbox();
let bb = (b.0 - reach, b.1 - reach, b.2 + reach, b.3 + reach);
let mut worst: Option<(f64, f64, usize)> = None;
for &l in &p.layers {
world.layers[l].visit(bb, |o| {
let Owner::Pad(j) = o.owner else { return true };
let j = j as usize;
if j <= i || (o.net == p.net && p.net != NO_NET) {
return true;
}
let clr = rules.clearance_for(p.net, o.net) as f64;
let d = p.shape.dist_shape(&o.shape);
if d < clr - TOL && worst.map(|w| d - clr < w.0 - w.1).unwrap_or(true) {
worst = Some((d, clr, j));
}
true
});
}
if let Some((d, clr, j)) = worst {
f.push(Finding { rule: "clearance", severity: "error", at: mm_at(p.center), text: format!("pad {} is {:.3} mm from pad {} (minimum {:.3})", board.pad_name(i), d.max(0.0) / NM, board.pad_name(j), clr / NM) });
}
// Holes of two footprints; holes inside one footprint are its drawing, not the layout's.
if let Some(dr) = p.drill {
let (c, r): (V, f64) = (p.center.into(), dr / 2.0);
let hh = rules.hole_to_hole() as f64;
let span = r + hh + 1.0 * NM;
world.holes.visit((c.0 - span, c.1 - span, c.0 + span, c.1 + span), |o| {
let Owner::Pad(j) = o.owner else { return true };
let j = j as usize;
if j <= i || board.pads[j].fp == p.fp {
return true;
}
let d = o.shape.dist_pt(c) - r;
if d < hh - TOL {
f.push(Finding { rule: "hole-to-hole", severity: rules.severity("hole_to_hole"), at: mm_at(p.center), text: format!("hole of pad {} is {:.3} mm from the hole of pad {} (minimum {:.3})", board.pad_name(i), d.max(0.0) / NM, board.pad_name(j), hh / NM) });
}
true
});
}
}
}
/// An entry shallower than this to the pad's edge is the acute one 4.2 warns about. An octilinear
/// track reaches a square-on pad at 90 or 45, so this needs a rotated pad or an off-45 track.
const ACUTE_ENTRY: f64 = 44.0;
/// The angle in degrees between a track and the pad edge it crosses coming in from `o`, or None for
/// a round pad, whose edge has no direction to be acute to.
fn entry_angle(p: &Pad, o: V, e: V) -> Option<f64> {
if matches!(p.shape, Shape::Circle { .. } | Shape::Multi(_)) {
return None;
}
let c = p.shape.center();
let (ox, oy) = rot(o.0 - c.0, o.1 - c.1, -p.angle);
let (ex, ey) = rot(e.0 - c.0, e.1 - c.1, -p.angle);
let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);
let (dx, dy) = (ex - ox, ey - oy);
let l = (dx * dx + dy * dy).sqrt().max(1.0);
// The track comes in across whichever side it is latest to pass, slab by slab.
let tx = if dx.abs() > 1e-9 { ((if dx > 0.0 { -hw } else { hw }) - ox) / dx } else { f64::NEG_INFINITY };
let ty = if dy.abs() > 1e-9 { ((if dy > 0.0 { -hh } else { hh }) - oy) / dy } else { f64::NEG_INFINITY };
let normal = if tx >= ty { dx } else { dy };
Some((normal.abs() / l).asin().to_degrees())
}
/// Pad entry (4.2): at the pad's centre or a corner, with no acute angle. Pairs may enter at the
/// side to keep their spacing, and a large pad or a rail width has no entry to speak of.
fn pad_entry_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>, s: &mut Score) {
let pair_nets: std::collections::HashSet<&str> = rules.pairs.iter().flat_map(|p| [p.p.as_str(), p.n.as_str()]).collect();
for t in &board.tracks {
if pair_nets.contains(board.net_name(t.net)) || hypot(sub(t.a.into(), t.b.into())) <= JOG {
continue;
}
for (e, o) in [(t.a, t.b), (t.b, t.a)] {
let (ev, ov): (V, V) = (e.into(), o.into());
let Some(i) = board.pads.iter().position(|q| q.net == t.net && q.layers.contains(&t.layer) && q.shape.dist_pt(ev) <= 1.0) else { continue };
let p = &board.pads[i];
if p.shape.dist_pt(ov) <= 1.0 {
continue;
}
let half = t.width as f64 / 2.0;
// Wider than the pad's short side: the track covers the whole entry.
let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);
if t.width as f64 >= 2.0 * hw.min(hh) || matches!(p.shape, Shape::Multi(_)) {
continue;
}
let c = p.shape.center();
let dir = sub(ev, ov);
let len = hypot(dir).max(1.0);
let off = (dir.0 * (c.1 - ov.1) - dir.1 * (c.0 - ov.0)).abs() / len;
let tol = half.max(0.02 * NM);
s.entries.total += 1;
let incidence = entry_angle(p, ov, ev);
if let Some(a) = incidence {
if a < 88.0 {
s.entries.oblique += 1;
}
if a < ACUTE_ENTRY {
f.push(Finding { rule: "pad-entry-acute", severity: "warn", at: (ev.0 / NM, ev.1 / NM), text: format!("{}: enters pad {} at {:.0} degrees to its edge (4.2: no acute angles)", board.net_name(t.net), board.pad_name(i), a) });
}
}
if off <= tol {
s.entries.centre += 1;
continue;
}
let corners = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)];
let at_corner = corners.iter().any(|&(x, y)| {
let (rx, ry) = rot(x, y, p.angle);
pt_seg(V(c.0 + rx, c.1 + ry), ov, V(ev.0 + dir.0 / len * 0.3 * NM, ev.1 + dir.1 / len * 0.3 * NM)) <= tol
});
if at_corner {
s.entries.corner += 1;
continue;
}
s.entries.side += 1;
f.push(Finding { rule: "pad-entry", severity: "warn", at: (ev.0 / NM, ev.1 / NM), text: format!("{}: enters pad {} {:.3} mm off its centre and away from its corners (4.2)", board.net_name(t.net), board.pad_name(i), off / NM) });
}
}
}
/// More of a board's pad entries oblique to the pad's edge than this: the reference boards' worst is 17 of 110
/// (v1.3). Our routes on the same boards ran 30%, 21% and 10% where he has 15%, 6% and 6%.
const OBLIQUE_RATE: f64 = 0.16;
/// Vias (4.4). A pad's own via sits just clear of the pad, about 0.1 mm. A signal via benefits from
/// a GND via close by: his document gives no number, so the distances are recorded, not gated.
fn via_rules(board: &Board, rules: &Rules, infos: &[NetInfo], f: &mut Vec<Finding>, s: &mut Score) {
let gnd: Vec<V> = board.vias.iter().filter(|v| looks_ground(board.net_name(v.net))).map(|v| v.at.into()).collect();
for v in &board.vias {
let c: V = v.at.into();
let r = v.size as f64 / 2.0;
let name = board.net_name(v.net);
for (i, p) in board.pads.iter().enumerate() {
if p.net != v.net || p.layers.is_empty() || p.drill.is_some() {
continue;
}
let d = p.shape.dist_pt(c);
// The land overlaps the pad's edge while its centre stays out: neither in the pad nor
// clear of it, and the hole can sit in the pad's mask opening.
if d > 0.0 && d - r < rules.pad_via_gap as f64 / 2.0 && !is_exposed(board, i) {
f.push(Finding { rule: "via-stand-off", severity: "warn", at: (c.0 / NM, c.1 / NM), text: format!("via on {name} is {:.3} mm from its pad {}; 4.4 puts it about {:.1} mm clear", (d - r) / NM, board.pad_name(i), rules.pad_via_gap as f64 / NM) });
}
}
let level = infos.iter().find(|i| i.net == v.net).map(|i| i.level);
if matches!(level, Some(Level::Pair) | Some(Level::Data) | Some(Level::Crystal) | Some(Level::Kelvin)) {
let near = gnd.iter().map(|g| hypot(sub(*g, c))).fold(f64::INFINITY, f64::min);
if near.is_finite() {
s.signal_via_gnd_mm.push(near / NM);
}
}
}
s.signal_via_gnd_mm.sort_by(|a, b| a.total_cmp(b));
let _ = f;
}
/// Hops (4.2: don't hop to the bottom where the top works): a run off the component layer that
/// starts and ends at vias of one net and comes straight back, under 3 mm in all. A hop that long
/// or shorter crosses at most a track or two; a longer one is routing. Warning only.
fn hop_rule(board: &Board, f: &mut Vec<Finding>) {
let top = 0usize;
let mut by: HashMap<(NetId, usize), Vec<&Track>> = HashMap::new();
for t in &board.tracks {
if t.layer != top {
by.entry((t.net, t.layer)).or_default().push(t);
}
}
for ((net, layer), tracks) in by {
// Group into connected runs.
let n = tracks.len();
let mut uf = conn::Uf::new(n);
for i in 0..n {
for j in i + 1..n {
if key(tracks[i].a) == key(tracks[j].a) || key(tracks[i].a) == key(tracks[j].b) || key(tracks[i].b) == key(tracks[j].a) || key(tracks[i].b) == key(tracks[j].b) {
uf.union(i as u32, j as u32);
}
}
}
let mut runs: HashMap<u32, Vec<&Track>> = HashMap::new();
for (i, t) in tracks.iter().enumerate() {
runs.entry(uf.find(i as u32)).or_default().push(*t);
}
for run in runs.values() {
let len: f64 = run.iter().map(|t| hypot(sub(t.a.into(), t.b.into()))).sum();
let ends: Vec<P> = board.vias.iter().filter(|v| v.net == net && run.iter().any(|t| key(t.a) == key(v.at) || key(t.b) == key(v.at))).map(|v| v.at).collect();
let touches_pad = run.iter().any(|t| board.pads.iter().any(|p| p.net == net && p.layers.contains(&layer) && p.shape.dist_seg(t.a.into(), t.b.into()) <= t.width as f64 / 2.0));
if ends.len() == 2 && !touches_pad && len < 3.0 * NM {
f.push(Finding { rule: "layer-hop", severity: "warn", at: (to_mm(ends[0].0), to_mm(ends[0].1)), text: format!("{}: hops to {} for {:.2} mm between two vias (4.2)", board.net_name(net), board.layers[layer].name, len / NM) });
}
}
}
}
/// Kelvin nets a spec lists (4.2): one dedicated thin trace from the sense pad to its shunt pad, no
/// branch, about 0.25 mm wide, and no via on the ground-side tap. DRC can't see these.
fn kelvin_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>) {
for name in &rules.kelvin {
let Some(&net) = board.net_ids.get(name) else { continue };
let pads: Vec<usize> = (0..board.pads.len()).filter(|&i| board.pads[i].net == net).collect();
let tracks: Vec<&Track> = board.tracks.iter().filter(|t| t.net == net).collect();
if tracks.is_empty() {
continue;
}
// Each sense pad runs its own trace to the shunt pad (benchmark C +3V3_EXT_SENS: Vbus and Vin- each
// have one, side by side), so several pads are fine; a junction out in the open is not.
let mut count: HashMap<P, (usize, P)> = HashMap::new();
for t in &tracks {
for e in [t.a, t.b] {
let c = count.entry(key(e)).or_insert((0, e));
c.0 += 1;
}
}
for (c, e) in count.values() {
let v: V = (*e).into();
let in_copper = board.pads.iter().any(|p| p.net == net && p.shape.dist_pt(v) <= 0.25 * NM);
if *c >= 3 && !in_copper {
f.push(Finding { rule: "kelvin", severity: "error", at: (to_mm(e.0), to_mm(e.1)), text: format!("{name}: a Kelvin tap has no branches (4.2)") });
}
}
for t in &tracks {
if !(mm(0.15)..=mm(0.3)).contains(&t.width) {
f.push(Finding { rule: "kelvin", severity: "warn", at: (to_mm(t.a.0), to_mm(t.a.1)), text: format!("{name}: {:.3} mm wide; a Kelvin tap is a dedicated thin trace, about 0.25 mm", to_mm(t.width)) });
}
}
// The ground-side tap: its shunt's other pad is a ground, or the name says return or GND.
let grounded = looks_ground(name) || pads.iter().any(|&p| {
let fp = &board.footprints[board.pads[p].fp];
fp.pads.len() == 2 && fp.pads.iter().any(|&q| q != p && looks_ground(board.net_name(board.pads[q].net)))
});
if grounded {
if let Some(v) = board.vias.iter().find(|v| v.net == net) {
f.push(Finding { rule: "kelvin", severity: "error", at: (to_mm(v.at.0), to_mm(v.at.1)), text: format!("{name}: a via on the ground-side tap (4.2 forbids it)") });
}
}
}
}
/// Differential pairs (4.2): how much of the length actually runs coupled, at what gap, with what
/// skew and how many vias. Recorded for every pair; a pair with almost none coupled gets a warning.
fn pair_stats(board: &Board, rules: &Rules, f: &mut Vec<Finding>, s: &mut Score) {
for pr in &rules.pairs {
let (Some(&p), Some(&n)) = (board.net_ids.get(&pr.p), board.net_ids.get(&pr.n)) else { continue };
let (tp, tn): (Vec<&Track>, Vec<&Track>) = (board.tracks.iter().filter(|t| t.net == p).collect(), board.tracks.iter().filter(|t| t.net == n).collect());
if tp.is_empty() || tn.is_empty() {
continue;
}
let length = |v: &[&Track]| v.iter().map(|t| hypot(sub(t.a.into(), t.b.into()))).sum::<f64>();
let mut st = PairStat { p: pr.p.clone(), n: pr.n.clone(), len_p_mm: length(&tp) / NM, len_n_mm: length(&tn) / NM, vias: board.vias.iter().filter(|v| v.net == p || v.net == n).count(), ..Default::default() };
// Sample P every 50 microns: coupled where a parallel N segment on the layer lies at a gap
// no more than twice the width of the pair's own, the far limit of edge coupling.
let mut gaps: Vec<f64> = Vec::new();
for t in &tp {
let (a, b): (V, V) = (t.a.into(), t.b.into());
let d = sub(b, a);
let l = hypot(d);
if l <= JOG {
continue;
}
let steps = (l / 50_000.0).ceil() as usize;
for i in 0..steps {
let u = (i as f64 + 0.5) / steps as f64;
let q = V(a.0 + d.0 * u, a.1 + d.1 * u);
let mut best = f64::INFINITY;
for o in &tn {
if o.layer != t.layer {
continue;
}
let e = sub(o.b.into(), o.a.into());
let cross = (e.0 * d.1 - e.1 * d.0).abs() / (hypot(e) * l).max(1.0);
if cross > 0.02 {
continue;
}
let g = pt_seg(q, o.a.into(), o.b.into()) - (t.width + o.width) as f64 / 2.0;
best = best.min(g);
}
if best < 2.0 * t.width as f64 {
st.coupled_mm += l / steps as f64 / NM;
gaps.push(best);
}
}
}
if !gaps.is_empty() {
gaps.sort_by(|a, b| a.total_cmp(b));
st.gap_mm = gaps[gaps.len() / 2] / NM;
}
let at = (to_mm(tp[0].a.0), to_mm(tp[0].a.1));
if st.len_p_mm >= 1.0 && st.coupled_mm < 0.25 * st.len_p_mm {
f.push(Finding { rule: "pair-coupling", severity: "warn", at, text: format!("{} / {}: {:.1} mm of {:.1} mm runs coupled (4.2)", pr.p, pr.n, st.coupled_mm, st.len_p_mm) });
}
s.pairs.push(st);
}
}
/// 3W and 2W spacing (4.2), as length: a statistic, since the rule is a target with exceptions.
fn spacing_stats(board: &Board, rules: &Rules, world: &crate::search::World, s: &mut Score) {
let step = 250_000.0;
for t in &board.tracks {
let (a, b): (V, V) = (t.a.into(), t.b.into());
let d = sub(b, a);
let l = hypot(d);
if l <= JOG {
continue;
}
let w = t.width as f64;
let steps = (l / step).ceil() as usize;
let piece = l / steps as f64 / NM;
for i in 0..steps {
let u = (i as f64 + 0.5) / steps as f64;
let q = V(a.0 + d.0 * u, a.1 + d.1 * u);
if world.escapes.iter().any(|e| q.0 >= e.0 && q.0 <= e.2 && q.1 >= e.1 && q.1 <= e.3) {
s.spacing.escape_mm += piece;
continue;
}
let reach = w / 2.0 + 2.0 * w;
let mut near = f64::INFINITY;
world.layers[t.layer].visit((q.0 - reach, q.1 - reach, q.0 + reach, q.1 + reach), |o| {
if o.net != t.net && !matches!(o.owner, Owner::Zone(_)) {
near = near.min(o.shape.dist_pt(q) - w / 2.0);
}
true
});
// 3W and 2W are centre to centre: 3W leaves a gap of 2W, 2W a gap of 1W (4.2).
if near >= 2.0 * w - 1000.0 {
s.spacing.at_3w_mm += piece;
} else if near >= w - 1000.0 {
s.spacing.at_2w_mm += piece;
} else {
s.spacing.under_2w_mm += piece;
}
}
}
}
/// Solder mask bridges and webs (see mask.rs for what was measured).
fn mask_rules(board: &Board, rules: &Rules, f: &mut Vec<Finding>) {
let map = MaskMap::new(board, rules);
let sev = rules.severity("solder_mask_bridge");
let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));
let side = |l: usize| &board.layers[l].name;
for t in &board.tracks {
if let Some((i, d)) = map.seg_hit(t.net, t.layer, t.a.into(), t.b.into(), t.width as f64 / 2.0) {
f.push(Finding { rule: "mask-bridge", severity: sev, at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(t.net), side(t.layer), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });
}
}
for v in &board.vias {
if let Some((i, d)) = map.via_hit(v.net, v.at.into(), v.size as f64 / 2.0) {
f.push(Finding { rule: "mask-bridge", severity: sev, at: mm_at(v.at), text: format!("via on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(v.net), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });
}
}
for td in &board.teardrops {
if let Some((i, d)) = map.poly_hit(td.net, td.layer, &td.pts) {
let at = td.pts.first().map(|p| (p.0 / NM, p.1 / NM)).unwrap_or((0.0, 0.0));
f.push(Finding { rule: "mask-bridge", severity: sev, at, text: format!("teardrop on {} is {:.3} mm from pad {} ({}): it bridges that pad's mask opening", board.net_name(td.net), d / NM, board.pad_name(i), board.net_name(board.pads[i].net)) });
}
}
// Pads of two footprints: copper in an opening, and the web between two openings.
let web = board.mask.web;
let extra = rules.min.mask_to_copper as f64;
for (i, p) in board.pads.iter().enumerate() {
for (j, q) in board.pads.iter().enumerate().skip(i + 1) {
if p.net == q.net && p.net != NO_NET {
continue;
}
if p.fp == q.fp && board.mask.allow_bridges_in_footprints {
continue;
}
let front = p.mask.0 && q.mask.0;
let back = p.mask.1 && q.mask.1;
if !(front || back) {
continue;
}
let (pb, qb) = (p.shape.bbox(), q.shape.bbox());
let m = p.mask_margin.max(0.0) + q.mask_margin.max(0.0) + web.max(extra);
if pb.0 > qb.2 + m || qb.0 > pb.2 + m || pb.1 > qb.3 + m || qb.1 > pb.3 + m {
continue;
}
let d = p.shape.dist_shape(&q.shape);
let limit = p.mask_margin + q.mask_margin + web;
if d < limit - TOL {
f.push(Finding { rule: "mask-web", severity: rules.severity("solder_mask_bridge"), at: mm_at(p.center), text: format!("mask openings of pads {} and {} leave a web of {:.3} mm (minimum {:.3})", board.pad_name(i), board.pad_name(j), (d - p.mask_margin - q.mask_margin) / NM, web / NM) });
}
}
}
}
pub fn check(board: &Board, rules: &Rules) -> (Vec<Finding>, Score) {
let mut f: Vec<Finding> = Vec::new();
let mut s = Score { min_gap_mm: f64::INFINITY, ..Default::default() };
let mm_at = |p: P| (to_mm(p.0), to_mm(p.1));
// Net ids belong to the parsed file, and a written board is parsed afresh.
let mut bound = rules.clone();
bound.bind(board);
let rules = &bound;
let world = build_world(board, rules);
// Connections.
let infos = classify(board, rules);
let groups = conn::pad_groups(board, &[]);
let planes = board.plane_nets();
for info in &infos {
let Some(g) = groups.get(&info.net) else { continue };
let live: Vec<&Vec<usize>> = g.iter().filter(|grp| grp.iter().any(|p| info.pads.contains(p))).collect();
if live.len() > 1 {
s.nets_open += 1;
s.pads_open += live.len() - 1;
let names: Vec<String> = live.iter().map(|grp| board.pad_name(grp[0])).collect();
let at = board.pads[live[1][0]].center;
f.push(Finding { rule: "unconnected", severity: "error", at: mm_at(at), text: format!("{}: {} separate islands, e.g. {}", board.net_name(info.net), live.len(), names.join(", ")) });
}
}
// 4.2: every track against the current through it, found from the connected copper.
for (name, rail) in &rules.rails {
let Some(net) = board.net_ids.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| *v) else { continue };
if std::env::var_os("ADOM_ROUTE_DROPS").is_some() {
for d in crate::current::rail_drops(board, rules, net, rail) {
eprintln!(" drop {} to {}: {:.3} A, {:.1} mV, {:.2}% of {:.1} V (limit {:.1} mV)", board.net_name(net), d.load, d.amps, d.volts * 1000.0, d.volts / (d.limit / crate::current::DROP_LIMIT) * 100.0, d.limit / crate::current::DROP_LIMIT, d.limit * 1000.0);
}
}
let mut short: std::collections::BTreeMap<(i64, bool), (f64, f64, i64, P)> = Default::default();
for x in crate::current::rate_rail(board, rules, net, rail) {
let t = &board.tracks[x.track];
if t.width + 1000 < x.need {
let e = short.entry((t.width, x.by_drop)).or_insert((0.0, 0.0, 0, t.a));
e.0 += hypot(sub(t.a.into(), t.b.into())) / NM;
if x.amps > e.1 || x.need > e.2 {
e.1 = x.amps.max(e.1);
e.2 = x.need.max(e.2);
e.3 = t.a;
}
}
}
for ((width, by_drop), (len, amps, need, at)) in short {
let why = if by_drop { format!("which needs {:.2} mm to keep the copper's drop to its load under {:.2}% of the rail (the designer's limit)", to_mm(need), crate::current::DROP_LIMIT * 100.0) } else { format!("which needs {:.2} mm (4.2)", to_mm(need)) };
f.push(Finding { rule: "rail-current", severity: "error", at: mm_at(at), text: format!("{}: {:.1} mm at {:.2} mm carries up to {:.3} A, {why}", board.net_name(net), len, to_mm(width), amps) });
}
}
// An exception stays in front of whoever reads the report until they have decided on it.
for ((net, pad), min) in &rules.pad_exit {
let p = &board.pads[*pad as usize];
f.push(Finding { rule: "pad-rule", severity: "warn", at: mm_at(p.center), text: format!("{} may pass pad {} at {:.3} mm under a local .kicad_dru rule (its class asks {:.3}): review it", board.net_name(*net), board.pad_name(*pad as usize), to_mm(*min), to_mm(rules.clearance_for(*net, p.net))) });
}
// A pour is only as connected as its refill leaves it, whatever shape the file saved.
for sp in crate::pour::splits(board, rules, &world) {
let by = if sp.cutters.is_empty() { String::new() } else { format!(", cut by {}", sp.cutters.iter().map(|&n| board.net_name(n)).collect::<Vec<_>>().join(", ")) };
s.nets_open += 1;
s.pads_open += sp.extra;
f.push(Finding { rule: "pour-split", severity: "error", at: (sp.at.0 / NM, sp.at.1 / NM), text: format!("{}: refilling its pour on {} leaves {} extra separate pieces{by}", board.net_name(sp.net), board.layers[sp.layer].name, sp.extra) });
}
// Copper clearance: tracks and vias against everything of another net, on each layer, by the
// two nets' classes. Zones refill around tracks, so they are the DRC's job after refill, not
// this check's. Holes and keep-outs have their own rules below, as KiCad has.
let reach_clr = rules.max_clearance() as f64;
let mut gap_note = |gap: f64, s: &mut Score| {
if gap < s.min_gap_mm {
s.min_gap_mm = gap;
}
};
let hole_clr = rules.hole_clearance() as f64;
let sev_hole = rules.severity("hole_clearance");
let plane_layers = board.plane_layers();
for t in board.tracks.iter() {
let (a, b): (V, V) = (t.a.into(), t.b.into());
let half = t.width as f64 / 2.0;
let reach = half + reach_clr * 2.0;
let bb = (a.0.min(b.0) - reach, a.1.min(b.1) - reach, a.0.max(b.0) + reach, a.1.max(b.1) + reach);
let mut worst: Option<(f64, f64, String)> = None;
world.layers[t.layer].visit(bb, |o| {
if o.net == t.net && t.net != NO_NET {
return true;
}
match o.owner {
Owner::Zone(_) => return true,
Owner::Keepout => {
// A keep-out area has no clearance of its own. A hole is the hole rule's job.
if matches!(o.shape, Shape::Poly { .. }) && o.shape.dist_seg(a, b) <= 0.0 {
f.push(Finding { rule: "keepout", severity: rules.severity("items_not_allowed"), at: mm_at(t.a), text: format!("{} on {} enters a keep-out area", board.net_name(t.net), board.layers[t.layer].name) });
}
return true;
}
_ => {}
}
let local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };
let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };
let clr = (rules.clearance_at(t.net, o.net, pad) as f64).max(local);
let d = o.shape.dist_seg(a, b) - half;
if d < clr * 2.0 {
gap_note(d.max(0.0) / NM, &mut s);
}
if d < clr - TOL && worst.as_ref().map(|w| d - clr < w.0 - w.1).unwrap_or(true) {
let who = match o.owner {
Owner::Pad(i) => format!("pad {}", board.pad_name(i as usize)),
_ => board.net_name(o.net).to_string(),
};
worst = Some((d, clr, who));
}
true
});
if let Some((d, clr, who)) = worst {
f.push(Finding { rule: "clearance", severity: "error", at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from {} (minimum {:.3})", board.net_name(t.net), board.layers[t.layer].name, d.max(0.0) / NM, who, clr / NM) });
}
// Copper to a hole of another net: every layer sees every through hole.
let hr = half + hole_clr + world.max_local + 1.0 * NM;
world.holes.visit((a.0.min(b.0) - hr, a.1.min(b.1) - hr, a.0.max(b.0) + hr, a.1.max(b.1) + hr), |o| {
if (o.net == t.net && t.net != NO_NET) || matches!(o.shape, Shape::Poly { .. }) {
return true;
}
let d = o.shape.dist_seg(a, b) - half;
let hole_local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };
if d < hole_clr.max(hole_local) - TOL {
f.push(Finding { rule: "hole-clearance", severity: sev_hole, at: mm_at(t.a), text: format!("{} on {} is {:.3} mm from a hole (minimum {:.3})", board.net_name(t.net), board.layers[t.layer].name, d.max(0.0) / NM, hole_clr / NM) });
return false;
}
true
});
let e = half + rules.edge as f64;
let mut near = f64::INFINITY;
world.edge.visit((a.0.min(b.0) - e, a.1.min(b.1) - e, a.0.max(b.0) + e, a.1.max(b.1) + e), |o| {
near = near.min(o.shape.dist_seg(a, b));
true
});
if near < e - TOL {
f.push(Finding { rule: "edge", severity: rules.severity("copper_edge_clearance"), at: mm_at(t.a), text: format!("{}: track {:.3} mm from the board edge (minimum {:.3})", board.net_name(t.net), (near - half).max(0.0) / NM, rules.edge as f64 / NM) });
}
// Narrow copper: his 0.15 mm (2.2), and the board setup's own minimum, which KiCad enforces.
if t.width < rules.neck - 1000 {
f.push(Finding { rule: "width", severity: "error", at: mm_at(t.a), text: format!("{}: track {:.3} mm is under the {:.3} mm minimum", board.net_name(t.net), t.width as f64 / NM, rules.neck as f64 / NM) });
}
if rules.min.track > 0 && t.width < rules.min.track - 1000 {
f.push(Finding { rule: "width-setup", severity: rules.severity("track_width"), at: mm_at(t.a), text: format!("{}: track {:.3} mm is under the board setup's {:.3} mm minimum (2.2: the setup should allow the 0.15 mm necks)", board.net_name(t.net), t.width as f64 / NM, rules.min.track as f64 / NM) });
}
*s.widths.entry(format!("{:.3}", t.width as f64 / NM)).or_insert(0) += 1;
let len = hypot(sub(a, b));
s.length_mm += len / NM;
if t.layer == 0 {
s.length_top_mm += len / NM;
} else if t.layer == board.layers.len() - 1 {
s.length_bottom_mm += len / NM;
} else {
s.length_inner_mm += len / NM;
if plane_layers.contains(&t.layer) && t.width > 0 {
f.push(Finding { rule: "inner-route", severity: "error", at: mm_at(t.a), text: format!("{}: track inside the plane layer {}; it cuts the return path (2.2, 4.2)", board.net_name(t.net), board.layers[t.layer].name) });
}
}
s.segments += 1;
// Direction: 0, 45 or 90 degrees only; anything else is an error (4.2).
if len > JOG {
let ang = out_angle(t.a, t.b).rem_euclid(45.0);
let off = ang.min(45.0 - ang);
if off > 0.5 {
f.push(Finding { rule: "off-45", severity: "error", at: mm_at(t.a), text: format!("{}: segment {:.3} mm long is {:.1} degrees off the 45-degree directions", board.net_name(t.net), len / NM, off) });
}
}
}
let hole_hole = rules.hole_to_hole() as f64;
let sev_hh = rules.severity("hole_to_hole");
for (vi, v) in board.vias.iter().enumerate() {
let c: V = v.at.into();
let r = v.size as f64 / 2.0;
let reach = r + reach_clr;
let mut worst: Option<(f64, f64, String)> = None;
let dbg = std::env::var("ADOM_ROUTE_DEBUG_VIA").ok().and_then(|s| { let mut it = s.split(',').filter_map(|x| x.parse::<f64>().ok()); Some((it.next()?, it.next()?)) }).map_or(false, |(x, y)| (to_mm(v.at.0) - x).abs() < 0.02 && (to_mm(v.at.1) - y).abs() < 0.02);
for l in 0..board.layers.len() {
world.layers[l].visit((c.0 - reach, c.1 - reach, c.0 + reach, c.1 + reach), |o| {
if dbg {
eprintln!("debug via {} layer {}: obstacle net {} owner {:?} dist {:.4} mm", board.net_name(v.net), board.layers[l].name, board.net_name(o.net), o.owner, (o.shape.dist_pt(c) - r) / NM);
}
if (o.net == v.net && v.net != NO_NET) || matches!(o.owner, Owner::Zone(_) | Owner::Keepout) {
return true;
}
let local = if let Owner::Pad(i) = o.owner { world.pad_clearance[i as usize] } else { 0.0 };
let pad = if let Owner::Pad(i) = o.owner { Some(i) } else { None };
let clr = (rules.clearance_at(v.net, o.net, pad) as f64).max(local);
let d = o.shape.dist_pt(c) - r;
if d < clr - TOL && worst.as_ref().map(|w| d - clr < w.0 - w.1).unwrap_or(true) {
worst = Some((d, clr, format!("{} on {}", board.net_name(o.net), board.layers[l].name)));
}
true
});
}
if let Some((d, clr, who)) = worst {
f.push(Finding { rule: "clearance", severity: "error", at: mm_at(v.at), text: format!("via on {} is {:.3} mm from {} (minimum {:.3})", board.net_name(v.net), d.max(0.0) / NM, who, clr / NM) });
}
// The via's copper and its hole against every other hole. The hole-to-hole rule covers
// any net (2.2); a same-net via pair is judged on holes only, as KiCad does (R11).
let hd = v.drill as f64 / 2.0;
let span = r + hole_clr.max(hole_hole) + 1.0 * NM;
world.holes.visit((c.0 - span, c.1 - span, c.0 + span, c.1 + span), |o| {
if let Shape::Poly { .. } = o.shape {
if o.shape.dist_pt(c) <= 0.0 {
f.push(Finding { rule: "keepout", severity: rules.severity("items_not_allowed"), at: mm_at(v.at), text: format!("via on {} in a keep-out area", board.net_name(v.net)) });
}
return true;
}
let dc = o.shape.dist_pt(c);
if dc <= 0.0 && matches!(o.owner, Owner::Fixed) && hypot(sub(o.shape.center(), c)) < 1.0 {
return true; // its own hole
}
if o.net != v.net || v.net == NO_NET {
if dc - r < hole_clr - TOL {
f.push(Finding { rule: "hole-clearance", severity: sev_hole, at: mm_at(v.at), text: format!("via on {} is {:.3} mm from a hole (minimum {:.3})", board.net_name(v.net), (dc - r).max(0.0) / NM, hole_clr / NM) });
}
}
if dc - hd < hole_hole - TOL {
f.push(Finding { rule: "hole-to-hole", severity: sev_hh, at: mm_at(v.at), text: format!("via hole on {} is {:.3} mm from another hole (minimum {:.3})", board.net_name(v.net), (dc - hd).max(0.0) / NM, hole_hole / NM) });
}
true
});
let e = r + rules.edge as f64;
let mut near = f64::INFINITY;
world.edge.visit((c.0 - e, c.1 - e, c.0 + e, c.1 + e), |o| {
near = near.min(o.shape.dist_pt(c));
true
});
if near < e - TOL {
f.push(Finding { rule: "edge", severity: rules.severity("copper_edge_clearance"), at: mm_at(v.at), text: format!("{}: via {:.3} mm from the board edge (minimum {:.3})", board.net_name(v.net), (near - r).max(0.0) / NM, rules.edge as f64 / NM) });
}
// KiCad's own limits on a via, from the board setup.
let annular = (v.size - v.drill) / 2;
if v.size < rules.min.via_dia - 1000 || v.drill < rules.min.through_drill - 1000 || annular < rules.min.annular - 1000 {
f.push(Finding { rule: "via-min", severity: "error", at: mm_at(v.at), text: format!("via {:.2}/{:.2} on {} is under the board setup's minimum", v.drill as f64 / NM, v.size as f64 / NM, board.net_name(v.net)) });
}
// 4.4: 0.3/0.6 is the only size; larger on a high-current net "when the current calculation
// calls for it", so a current on record for that net in the spec, not just any 0.4/0.8.
let standard = v.size == rules.via && v.drill == rules.drill;
let earned = v.size > rules.via && rules.has_load(board.net_name(v.net));
if !standard && !earned {
f.push(Finding { rule: "via-size", severity: "warn", at: mm_at(v.at), text: format!("via {:.2}/{:.2} (drill/diameter) on {}: 0.3/0.6 is the only size unless one is specified, or a current on record for the net calls for more (4.4)", v.drill as f64 / NM, v.size as f64 / NM, board.net_name(v.net)) });
}
if !(v.tent.0 && v.tent.1) {
f.push(Finding { rule: "via-tent", severity: "warn", at: mm_at(v.at), text: format!("via on {} is not tented on both sides (4.4)", board.net_name(v.net)) });
}
for (i, p) in board.pads.iter().enumerate() {
if p.layers.is_empty() || p.drill.is_some() {
continue;
}
// The rule is about solder paste: a pad with no paste layer (benchmark C U5.10, a ground bar
// meant to carry vias) is free of it, and a heat pad takes thermal vias on purpose.
if p.paste && p.shape.dist_pt(c) <= 0.0 && !is_exposed(board, i) {
f.push(Finding { rule: "via-in-pad", severity: "error", at: mm_at(v.at), text: format!("via in pad {}: plan for capped vias or keep it off the paste (4.4)", board.pad_name(i)) });
}
}
let _ = vi;
}
crate::placement::crystal_rules(board, &mut f);
crate::placement::decoupling_rules(board, rules, &mut f, &mut s.own_cap_mm);
s.own_cap_mm.sort_by(|a, b| b.0.total_cmp(&a.0));
crate::placement::inner_fill_rule(board, rules, &mut f);
pad_pad_rules(board, rules, &world, &mut f);
mask_rules(board, rules, &mut f);
pad_entry_rules(board, rules, &mut f, &mut s);
if s.entries.total >= 20 && s.entries.oblique as f64 / s.entries.total as f64 > OBLIQUE_RATE {
f.push(Finding { rule: "pad-entry-oblique", severity: "warn", at: (0.0, 0.0), text: format!("{} of {} pad entries come in at an angle to the pad's edge; the reference boards' worst is 15% (4.2: no acute angles, when possible)", s.entries.oblique, s.entries.total) });
}
via_rules(board, rules, &infos, &mut f, &mut s);
hop_rule(board, &mut f);
kelvin_rules(board, rules, &mut f);
pair_stats(board, rules, &mut f, &mut s);
spacing_stats(board, rules, &world, &mut s);
let copper_nodes = crate::quality::Nodes::new(board);
crate::quality::shape_rules(board, &copper_nodes, &mut f);
crate::quality::net_rules(board, rules, &infos, &copper_nodes, &mut f, &mut s);
crate::quality::island_rules(board, &mut f);
s.vias = board.vias.len();
let rail = rules.power_floor - 1000;
// 4.2 allows one 90-degree shape: a stub off a large power rail. Width alone doesn't make a rail
// (never judge current from a width), and two equal sides are a bend in the rail, not a stub.
let powered = |net: NetId| looks_power(board.net_name(net)) || rules.wide.contains_key(board.net_name(net));
// Shape: corners and junctions, per net and layer. A jog of a few microns where two widths meet
// is one node, not two bends, so its ends are merged first.
let mut link: HashMap<(NetId, usize, P), P> = HashMap::new();
fn root(link: &HashMap<(NetId, usize, P), P>, net: NetId, layer: usize, mut k: P) -> P {
while let Some(&n) = link.get(&(net, layer, k)) {
k = n;
}
k
}
for t in &board.tracks {
if hypot(sub(t.a.into(), t.b.into())) <= JOG {
let (x, y) = (root(&link, t.net, t.layer, key(t.a)), root(&link, t.net, t.layer, key(t.b)));
if x != y {
link.insert((t.net, t.layer, x), y);
}
}
}
let rep = |net: NetId, layer: usize, p: P| root(&link, net, layer, key(p));
let mut nodes: HashMap<(NetId, usize, P), Vec<usize>> = HashMap::new();
for (i, t) in board.tracks.iter().enumerate() {
if hypot(sub(t.a.into(), t.b.into())) <= JOG {
continue;
}
nodes.entry((t.net, t.layer, rep(t.net, t.layer, t.a))).or_default().push(i);
nodes.entry((t.net, t.layer, rep(t.net, t.layer, t.b))).or_default().push(i);
}
let far = |t: &Track, n: P| if rep(t.net, t.layer, t.a) == n { (t.a, t.b) } else { (t.b, t.a) };
// Copper of the net's own that hides a corner: a pad (or the quarter millimetre the teardrop
// covers), a via's land, or a same-net fill. No bend exists there.
let hidden = |net: NetId, layer: usize, v: V| {
board.pads.iter().any(|q| q.net == net && q.layers.contains(&layer) && q.shape.dist_pt(v) <= 0.25 * NM)
|| board.vias.iter().any(|w| w.net == net && hypot(sub(w.at.into(), v)) <= w.size as f64 / 2.0)
|| world.zones.iter().any(|(zn, zl, poly)| *zn == net && *zl == layer && in_poly(v, poly))
// Inside a same-net rail wide enough to swallow the corner (benchmark C +3V3_EXT 218.00, 93.26:
// a 0.2 mm stub landing square on the 0.5 mm run, the rail exception in 4.2).
|| powered(net) && board.tracks.iter().any(|o| o.net == net && o.layer == layer && o.width >= rail && key(o.a) != key(to_p(v)) && key(o.b) != key(to_p(v)) && pt_seg(v, o.a.into(), o.b.into()) <= o.width as f64 / 2.0)
};
for ((net, layer, n), list) in &nodes {
let mut dirs: Vec<(f64, usize, i64)> = list
.iter()
.map(|&i| {
let t = &board.tracks[i];
let (a, b) = far(t, *n);
(out_angle(a, b), i, t.width)
})
.collect();
dirs.sort_by(|a, b| a.0.total_cmp(&b.0));
let at_p = far(&board.tracks[list[0]], *n).0;
let v: V = at_p.into();
let in_pad = hidden(*net, *layer, v);
if dirs.len() == 2 {
let between = diff(dirs[0].0, dirs[1].0);
let turn = 180.0 - between;
if turn < 1.0 {
continue;
}
if !in_pad {
if (turn - 45.0).abs() < 3.0 {
s.bends45 += 1;
} else if (turn - 90.0).abs() < 3.0 {
s.bends90 += 1;
let is_rail_stub = powered(*net) && dirs[0].2.max(dirs[1].2) >= rail && dirs[0].2 != dirs[1].2;
if !is_rail_stub {
f.push(Finding { rule: "corner-90", severity: "error", at: mm_at(at_p), text: format!("{}: 90-degree bend on {} (only a stub off a large power rail may)", board.net_name(*net), board.layers[*layer].name) });
}
} else if between < 88.0 && between > 3.0 {
f.push(Finding { rule: "corner-acute", severity: "error", at: mm_at(at_p), text: format!("{}: acute corner of {:.0} degrees on {}", board.net_name(*net), between, board.layers[*layer].name) });
}
}
} else if dirs.len() >= 3 && !in_pad {
// A branch joins as a Y (45/135), never a square T (4.2).
for a in 0..dirs.len() {
for b in a + 1..dirs.len() {
if (diff(dirs[a].0, dirs[b].0) - 180.0).abs() < 2.0 {
for c in 0..dirs.len() {
if c != a && c != b && (diff(dirs[c].0, dirs[a].0) - 90.0).abs() < 3.0 && !(powered(*net) && dirs[a].2 >= rail && dirs[c].2 <= dirs[a].2) {
f.push(Finding { rule: "square-t", severity: "error", at: mm_at(at_p), text: format!("{}: square T on {}; signal branches join as a Y", board.net_name(*net), board.layers[*layer].name) });
}
}
}
}
}
if infos.iter().any(|i| i.net == *net && i.level == Level::Crystal) {
f.push(Finding { rule: "crystal-chain", severity: "error", at: mm_at(at_p), text: format!("{}: junction on a crystal line; XIN/XOUT run as a daisy chain (4.2)", board.net_name(*net)) });
}
}
}
// T junctions onto the middle of a track.
for (i, t) in board.tracks.iter().enumerate() {
for end in [t.a, t.b] {
let e: V = end.into();
if hypot(sub(t.a.into(), t.b.into())) <= JOG || hidden(t.net, t.layer, e) {
continue;
}
for (j, o) in board.tracks.iter().enumerate() {
if i == j || o.net != t.net || o.layer != t.layer {
continue;
}
let n = rep(t.net, t.layer, end);
if rep(o.net, o.layer, o.a) == n || rep(o.net, o.layer, o.b) == n {
continue;
}
if pt_seg(e, o.a.into(), o.b.into()) > 2000.0 {
continue;
}
let (a1, a2) = (out_angle(end, if key(t.a) == key(end) { t.b } else { t.a }), out_angle(o.a, o.b));
let d = diff(a1, a2);
if (d - 90.0).abs() < 3.0 && !(powered(t.net) && o.width >= rail && t.width <= o.width) {
f.push(Finding { rule: "square-t", severity: "error", at: mm_at(end), text: format!("{}: square T onto a track on {}; signal branches join as a Y", board.net_name(t.net), board.layers[t.layer].name) });
}
}
}
}
// A via inside a straight run (4.4: no via inside a trace).
for v in &board.vias {
for l in 0..board.layers.len() {
let n = rep(v.net, l, v.at);
if let Some(list) = nodes.get(&(v.net, l, n)) {
if list.len() == 2 {
let (t1, t2) = (&board.tracks[list[0]], &board.tracks[list[1]]);
let (a1, b1) = far(t1, n);
let (a2, b2) = far(t2, n);
if (diff(out_angle(a1, b1), out_angle(a2, b2)) - 180.0).abs() < 2.0 {
f.push(Finding { rule: "via-in-trace", severity: "warn", at: mm_at(v.at), text: format!("{}: via inside a straight run on {}", board.net_name(v.net), board.layers[l].name) });
}
}
}
}
}
// Plane nets: pads the plane doesn't reach get their own via (4.2). Sharing is reported.
let _ = &planes;
if !s.min_gap_mm.is_finite() {
s.min_gap_mm = 0.0;
}
f.sort_by(|a, b| (a.severity != "error", a.rule).cmp(&(b.severity != "error", b.rule)));
(f, s)
}
fn r2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
pub fn to_json(f: &[Finding], s: &Score) -> Value {
let mut per: BTreeMap<&str, usize> = BTreeMap::new();
for x in f {
*per.entry(x.rule).or_insert(0) += 1;
}
json!({
"errors": f.iter().filter(|x| x.severity == "error").count(),
"warnings": f.iter().filter(|x| x.severity != "error").count(),
"perRule": per,
"score": {
"netsOpen": s.nets_open, "padsOpen": s.pads_open,
"lengthMm": (s.length_mm * 100.0).round() / 100.0,
"topMm": (s.length_top_mm * 100.0).round() / 100.0,
"bottomMm": (s.length_bottom_mm * 100.0).round() / 100.0,
"innerMm": (s.length_inner_mm * 100.0).round() / 100.0,
"vias": s.vias, "segments": s.segments, "bends45": s.bends45, "bends90": s.bends90,
"widths": s.widths, "minGapMm": (s.min_gap_mm * 1000.0).round() / 1000.0,
"spacingMm": {
"at3W": r2(s.spacing.at_3w_mm), "at2W": r2(s.spacing.at_2w_mm), "tighter": r2(s.spacing.under_2w_mm), "inEscape": r2(s.spacing.escape_mm)
},
"pairs": s.pairs.iter().map(|p| json!({"p": p.p, "n": p.n, "lenP": r2(p.len_p_mm), "lenN": r2(p.len_n_mm), "coupled": r2(p.coupled_mm), "gapMm": (p.gap_mm * 1000.0).round() / 1000.0, "vias": p.vias})).collect::<Vec<_>>(),
"signalViaGndMm": s.signal_via_gnd_mm.iter().map(|x| r2(*x)).collect::<Vec<_>>(),
"widthSteps": s.width_steps,
"ownCapMm": s.own_cap_mm.iter().take(5).map(|x| json!([r2(x.0), x.1])).collect::<Vec<_>>(),
"padEntries": {"total": s.entries.total, "centre": s.entries.centre, "corner": s.entries.corner, "side": s.entries.side, "oblique": s.entries.oblique},
"capFirst": {"pins": s.cap_first.pins, "direct": s.cap_first.direct, "cap": s.cap_first.cap, "onFill": s.cap_first.on_fill, "junction": s.cap_first.junction},
"nets": s.nets.iter().map(|n| json!({"net": n.name, "level": n.level, "pads": n.pads, "lenMm": r2(n.len_mm), "treeMm": r2(n.tree_mm), "vias": n.vias, "widthSteps": n.width_steps, "fill": n.fill})).collect::<Vec<_>>(),
"rails": s.rails.iter().map(|n| json!({"net": n.name, "mainWidthMm": n.main_width_mm, "mainLenMm": r2(n.main_len_mm), "fedByFill": n.fed_by_fill, "branches": n.branches})).collect::<Vec<_>>()
},
"findings": f.iter().map(|x| json!({"rule": x.rule, "severity": x.severity, "atMm": [x.at.0, x.at.1], "text": x.text})).collect::<Vec<_>>()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::project;
fn board(extra: &str) -> Board {
let head = r#"(kicad_pcb (layers (0 "F.Cu" signal) (2 "B.Cu" signal)) (setup (pad_to_mask_clearance 0.05))
(gr_rect (start 0 0) (end 30 30) (layer "Edge.Cuts"))
(footprint "x" (layer "F.Cu") (at 10 10) (property "Reference" "R1")
(pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "A") (solder_mask_margin 0.102))
(pad "2" smd rect (at 5 0) (size 1 1) (layers "F.Cu" "F.Mask") (net "B")))"#;
Board::parse(format!("{head}{extra})")).unwrap()
}
fn track(y: f64, net: &str) -> String {
format!(r#"(segment (start 8 {y}) (end 12 {y}) (width 0.2) (layer "F.Cu") (net "{net}"))"#)
}
fn rules(mask_to_copper: f64) -> Rules {
let mut r = Rules::default();
r.min.mask_to_copper = mm(mask_to_copper);
r
}
fn count(f: &[Finding], rule: &str) -> usize {
f.iter().filter(|x| x.rule == rule).count()
}
#[test]
fn mask_bridge_is_margin_plus_mask_to_copper() {
// Pad edge at y 10.5, so the track edge is y - 0.1 - 10.5 from the pad.
let near = board(&track(10.771, "C")); // 0.171: under 0.102 + 0.1, as on the routed PiProbe
let (f, _) = check(&near, &rules(0.1));
assert_eq!(count(&f, "mask-bridge"), 1);
let far = board(&track(10.82, "C")); // 0.22: clear of 0.202
let (f, _) = check(&far, &rules(0.1));
assert_eq!(count(&f, "mask-bridge"), 0);
// With no mask-to-copper setting, as on the benchmark C, only the margin counts.
let (f, _) = check(&near, &rules(0.0));
assert_eq!(count(&f, "mask-bridge"), 0);
// Same net never bridges.
let own = board(&track(10.771, "A"));
let (f, _) = check(&own, &rules(0.1));
assert_eq!(count(&f, "mask-bridge"), 0);
}
#[test]
fn pad_margin_falls_back_to_the_board() {
let b = board("");
assert_eq!(b.pads[0].mask_margin, 0.102 * NM);
assert_eq!(b.pads[1].mask_margin, 0.05 * NM);
}
#[test]
fn a_micro_jog_is_not_a_bend_and_a_real_one_is() {
let jog = r#"(segment (start 2 5) (end 6 5) (width 0.2) (layer "F.Cu") (net "C"))
(segment (start 6 5) (end 6 5.004) (width 0.2) (layer "F.Cu") (net "C"))
(segment (start 6 5.004) (end 10 5.004) (width 0.2) (layer "F.Cu") (net "C"))"#;
let (f, s) = check(&board(jog), &rules(0.0));
assert_eq!(count(&f, "corner-90"), 0);
assert_eq!(s.bends90, 0);
let bend = r#"(segment (start 2 5) (end 6 5) (width 0.2) (layer "F.Cu") (net "C"))
(segment (start 6 5) (end 6 8) (width 0.2) (layer "F.Cu") (net "C"))"#;
let (f, _) = check(&board(bend), &rules(0.0));
assert_eq!(count(&f, "corner-90"), 1);
}
#[test]
fn overlapping_fills_join_a_net() {
// Two pads each reached by their own zone; the zones overlap but neither's first vertex is
// inside the other, which the old one-vertex test missed.
let z = |x0: f64, x1: f64| format!(r#"(zone (net "A") (layer "F.Cu") (polygon (pts (xy {x0} 8) (xy {x1} 8) (xy {x1} 12) (xy {x0} 12))) (filled_polygon (layer "F.Cu") (pts (xy {x0} 8) (xy {x1} 8) (xy {x1} 12) (xy {x0} 12))))"#);
let text = format!(r#"(footprint "y" (layer "F.Cu") (at 20 10) (property "Reference" "R2") (pad "1" smd rect (at 0 0) (size 1 1) (layers "F.Cu") (net "A"))){}{}"#, z(9.0, 15.0), z(14.0, 21.0));
let b = board(&text);
let groups = conn::pad_groups(&b, &[]);
assert_eq!(groups[&b.net_ids["A"]].len(), 1);
}
#[test]
fn hole_rules_fire() {
let hole = r#"(footprint "h" (layer "F.Cu") (at 20 20) (property "Reference" "H1") (pad "" np_thru_hole circle (at 0 0) (size 1 1) (drill 1) (layers "*.Cu" "*.Mask")))"#;
// Track edge 0.2 mm from a 0.5 mm radius hole, against 0.25 mm hole clearance.
let text = format!(r#"{hole}(segment (start 18 20.8) (end 22 20.8) (width 0.2) (layer "F.Cu") (net "C"))"#);
let (f, _) = check(&board(&text), &rules(0.0));
assert_eq!(count(&f, "hole-clearance"), 1);
// Two vias with 0.2 mm between their holes, against 0.25 mm hole to hole, same net.
let vias = r#"(via (at 5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C")) (via (at 5.5 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C"))"#;
let (f, _) = check(&board(vias), &rules(0.0));
assert!(count(&f, "hole-to-hole") >= 1);
// A via 0.1 mm from the edge against 0.3 mm.
let edge = r#"(via (at 0.4 5) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "C"))"#;
let (f, _) = check(&board(edge), &rules(0.0));
assert_eq!(count(&f, "edge"), 1);
}
#[test]
fn entry_angle_reads_the_edge_crossed() {
let pad = |angle: f64| Pad { fp: 0, number: String::new(), net: 1, center: (0, 0), shape: Shape::Poly { pts: vec![V(-NM, -NM / 2.0), V(NM, -NM / 2.0), V(NM, NM / 2.0), V(-NM, NM / 2.0)], r: 0.0 }, layers: vec![0], drill: None, npth: false, size: (2.0 * NM, NM), angle, mask_margin: 0.0, mask: (true, false), paste: true, pintype: String::new(), pinfunction: String::new(), zone_connect: None, local_clearance: 0.0 };
// Straight down onto a 2 x 1 pad: square to its long edge.
let a = entry_angle(&pad(0.0), V(0.0, -3.0 * NM), V(0.0, 0.0)).unwrap();
assert!((a - 90.0).abs() < 0.01);
// In at 45 degrees.
let a = entry_angle(&pad(0.0), V(3.0 * NM, -3.0 * NM), V(0.0, 0.0)).unwrap();
assert!((a - 45.0).abs() < 0.01);
}
#[test]
fn a_kelvin_branch_fails_and_a_clean_tap_does_not() {
let mut r = rules(0.0);
r.kelvin = vec!["K".to_string()];
let clean = r#"(segment (start 2 5) (end 6 5) (width 0.25) (layer "F.Cu") (net "K"))"#;
let (f, _) = check(&board(clean), &r);
assert_eq!(count(&f, "kelvin"), 0);
let branch = r#"(segment (start 2 5) (end 6 5) (width 0.25) (layer "F.Cu") (net "K"))
(segment (start 6 5) (end 9 5) (width 0.25) (layer "F.Cu") (net "K"))
(segment (start 6 5) (end 8 7) (width 0.25) (layer "F.Cu") (net "K"))"#;
let (f, _) = check(&board(branch), &r);
assert_eq!(count(&f, "kelvin"), 1);
}
#[test]
fn the_chart_gives_his_example_and_margin_is_small_boards_only() {
let w = crate::current::conservative_mm(3.0, 20.0, 35.0);
assert!((w - 2.335).abs() < 0.01, "{w}");
let mut r = Rules::default();
r.small_board = false;
assert_eq!(r.rail_width(3.0, true), mm(2.34));
assert_eq!(r.rail_width(0.2, true), mm(0.06));
r.small_board = true;
assert_eq!(r.rail_width(3.0, true), mm(2.92));
assert_eq!(r.rail_width(0.2, true), mm(0.40));
assert_eq!(r.rail_width(0.2, false), mm(0.06));
}
#[test]
fn pad_rule_survives_the_file_and_the_standing_minimum_holds() {
let rule = crate::rules::PadRule { net: "+PS4_SENS".into(), reference: "U3".into(), pad: "11".into(), min: mm(0.225) };
assert_eq!(crate::rules::PadRule::parse(&rule.condition(), rule.min), Some(rule.clone()));
assert_eq!(crate::rules::PadRule::parse("A.NetClass == 'USB'", 1), None);
let dir = std::env::temp_dir().join(format!("adom-route-padrule-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (a, b) = (dir.join("in.kicad_pcb"), dir.join("out.kicad_pcb"));
let path = project::write_dru(a.to_str().unwrap(), b.to_str().unwrap(), std::slice::from_ref(&rule)).unwrap().unwrap();
let mut r = Rules::default();
project::apply_files(&dir.join("missing.kicad_pro"), std::path::Path::new(&path), &mut r);
assert_eq!(r.pad_rules, vec![rule]);
let _ = std::fs::remove_file(&path);
}
#[test]
fn dru_only_raises_limits_and_says_what_it_skips() {
let dir = std::env::temp_dir().join(format!("adom-route-dru-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let dru = dir.join("t.kicad_dru");
std::fs::write(&dru, "(version 1)\n# comment\n(rule a (constraint clearance (min 0.09mm)))\n(rule b (constraint track_width (min 0.127mm)))\n(rule c (condition \"A.Type == 'Via'\") (constraint hole_size (min 0.3mm)))\n").unwrap();
let mut r = Rules::default();
let notes = project::apply_files(&dir.join("missing.kicad_pro"), &dru, &mut r);
// No project: KiCad's defaults (0.2 mm track and clearance) stand, and the rule file's lower
// minimums cannot loosen them.
assert_eq!(r.min.track, mm(0.2));
assert_eq!(r.clearance_for(1, 2), mm(0.2));
assert!(notes.iter().any(|n| n.contains("rule \"c\"") && n.contains("not honoured")));
let _ = std::fs::remove_file(&dru);
}
}