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.3
6e27c4b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
//! Paths along one net's copper on a board: pads, track pieces, vias and filled zone islands as a
//! graph, and the shortest path between two pads on it. This is how the decoupling review measures
//! the routed path from a cap's supply pad to its assigned pin (the plan applied to the board, plus
//! the copper already there), never a straight line, and never across zone polygons that no fill
//! joins. Lengths are along track centrelines; a stretch through a filled island counts the straight
//! distance across it and is flagged, since that copper has no single path.
use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap};
use aiflow_board::{geom, Board, Pt};
use serde_json::{json, Value};
use crate::decoupling::Resolved;
#[derive(Clone, Copy, PartialEq, Debug)]
enum Kind {
Pad,
Point,
Via,
}
/// One net's copper as a graph.
pub struct NetGraph {
pos: Vec<Pt>,
/// None: both layers (a via, a through-hole pad).
layer: Vec<Option<usize>>,
kind: Vec<Kind>,
label: Vec<String>,
/// (to, mm, through a zone fill)
adj: Vec<Vec<(usize, f64, bool)>>,
pad_node: HashMap<String, usize>,
/// The net has zones, and whether any is filled in this board.
pub zones: usize,
pub filled_islands: usize,
}
/// A path between two pads.
#[derive(Clone, Debug)]
pub struct Path {
pub length_mm: f64,
pub vias: usize,
/// Other pads of the net the path passes through (the rail tree).
pub through_pads: Vec<String>,
pub through_fill: bool,
/// Points on the path where other copper of the net leaves it (a T, a rail landing). A path
/// with two or more is a stretch of the rail tree between two landings, not a local connection.
pub branch_points: usize,
}
/// Shortest distances from one pad.
pub struct Dist {
dist: Vec<f64>,
prev: Vec<usize>,
prev_fill: Vec<bool>,
src: usize,
}
const TOL: f64 = 1e-3;
impl NetGraph {
pub fn build(board: &Board, net: &str) -> NetGraph {
let mut g = NetGraph { pos: Vec::new(), layer: Vec::new(), kind: Vec::new(), label: Vec::new(), adj: Vec::new(), pad_node: HashMap::new(), zones: 0, filled_islands: 0 };
let add = |g: &mut NetGraph, p: Pt, l: Option<usize>, k: Kind, s: String| -> usize {
g.pos.push(p);
g.layer.push(l);
g.kind.push(k);
g.label.push(s);
g.adj.push(Vec::new());
g.pos.len() - 1
};
let pads: Vec<&aiflow_board::Pad> = board.pads.iter().filter(|p| p.net == net).collect();
let mut pad_ids = Vec::new();
for p in &pads {
let l = if p.thru || (p.on_f && p.on_b) { None } else if p.on_f { Some(aiflow_board::F_CU) } else { Some(aiflow_board::B_CU) };
let id = add(&mut g, (p.x, p.y), l, Kind::Pad, p.key.clone());
g.pad_node.insert(p.key.clone(), id);
pad_ids.push(id);
}
let tracks: Vec<aiflow_board::Track> = board.tracks().into_iter().filter(|t| t.net == net).collect();
let mut ends = Vec::new(); // (node a, node b, half width)
let mut point_hw: HashMap<usize, f64> = HashMap::new();
// track ends at one spot on one layer are one node (a corner, a joint)
let mut at: HashMap<(i64, i64, usize), usize> = HashMap::new();
for t in &tracks {
let mut node = |g: &mut NetGraph, p: Pt| -> usize {
let key = ((p.0 * 1e4).round() as i64, (p.1 * 1e4).round() as i64, t.layer);
let id = *at.entry(key).or_insert_with(|| add(g, p, Some(t.layer), Kind::Point, String::new()));
let hw = point_hw.entry(id).or_insert(0.0);
*hw = hw.max(t.width / 2.0);
id
};
let a = node(&mut g, t.a);
let b = node(&mut g, t.b);
ends.push((a, b, t.width / 2.0));
}
let vias: Vec<aiflow_board::Via> = board.vias().into_iter().filter(|v| v.net == net).collect();
let mut via_ids = Vec::new();
for v in &vias {
let id = add(&mut g, (v.x, v.y), None, Kind::Via, format!("via ({:.2}, {:.2})", v.x, v.y));
point_hw.insert(id, v.size / 2.0);
via_ids.push(id);
}
// each track piece: its own ends plus every point or via that touches it, chained along it
let feature_nodes: Vec<usize> = point_hw.keys().copied().collect();
for (ti, t) in tracks.iter().enumerate() {
let (a, b, hw) = ends[ti];
let len = (t.b.0 - t.a.0).hypot(t.b.1 - t.a.1);
let (ux, uy) = if len > 1e-9 { ((t.b.0 - t.a.0) / len, (t.b.1 - t.a.1) / len) } else { (0.0, 0.0) };
let mut on: Vec<(f64, usize)> = vec![(0.0, a), (len, b)];
let (x0, x1) = (t.a.0.min(t.b.0) - hw - 1.0, t.a.0.max(t.b.0) + hw + 1.0);
let (y0, y1) = (t.a.1.min(t.b.1) - hw - 1.0, t.a.1.max(t.b.1) + hw + 1.0);
for &n in &feature_nodes {
if n == a || n == b {
continue;
}
if let Some(l) = g.layer[n] {
if l != t.layer {
continue;
}
}
let (x, y) = g.pos[n];
if x < x0 || x > x1 || y < y0 || y > y1 {
continue;
}
let reach = hw + point_hw[&n] + TOL;
if geom::seg_distance(x, y, t.a.0, t.a.1, t.b.0, t.b.1) <= reach {
let s = ((x - t.a.0) * ux + (y - t.a.1) * uy).clamp(0.0, len);
on.push((s, n));
}
}
on.sort_by(|p, q| p.0.partial_cmp(&q.0).unwrap());
for w in on.windows(2) {
g.link(w[0].1, w[1].1, w[1].0 - w[0].0, false);
}
}
// pads: a track end or a via on the pad's copper joins it at its centre
for (pi, p) in pads.iter().enumerate() {
let pn = pad_ids[pi];
for &n in &feature_nodes {
let on_layer = match g.layer[n] { Some(l) => p.thru || p.on_layer(l), None => true };
if !on_layer {
continue;
}
let (x, y) = g.pos[n];
if (x - p.x).abs() > p.half_long + 2.0 || (y - p.y).abs() > p.half_long + 2.0 {
continue;
}
let r = if g.kind[n] == Kind::Via { TOL } else { point_hw[&n] + TOL };
if p.polys.iter().any(|poly| geom::point_poly_distance(x, y, poly) <= r) {
let d = (x - p.x).hypot(y - p.y);
g.link(pn, n, d, false);
}
}
}
// filled zone islands: everything on one island is joined, straight across it
for z in board.zones().iter().filter(|z| z.net == net) {
g.zones += 1;
for (l, poly) in &z.fills {
g.filled_islands += 1;
let mut members = Vec::new();
for n in 0..g.pos.len() {
let on_layer = match g.layer[n] { Some(x) => x == *l, None => true };
if !on_layer {
continue;
}
let (x, y) = g.pos[n];
let inside = if g.kind[n] == Kind::Pad {
let p = pads[pad_ids.iter().position(|&q| q == n).unwrap()];
geom::point_in_poly(x, y, poly) || p.polys.iter().flatten().any(|v| geom::point_in_poly(v.0, v.1, poly))
} else {
geom::point_poly_distance(x, y, poly) <= point_hw.get(&n).copied().unwrap_or(0.0) + TOL
};
if inside {
members.push(n);
}
}
for i in 0..members.len() {
for j in i + 1..members.len() {
let (a, b) = (members[i], members[j]);
let d = (g.pos[a].0 - g.pos[b].0).hypot(g.pos[a].1 - g.pos[b].1);
g.link(a, b, d, true);
}
}
}
}
g
}
fn link(&mut self, a: usize, b: usize, d: f64, fill: bool) {
if a == b {
return;
}
self.adj[a].push((b, d, fill));
self.adj[b].push((a, d, fill));
}
pub fn has_pad(&self, key: &str) -> bool {
self.pad_node.contains_key(key)
}
/// Shortest copper distances from a pad of the net.
pub fn from_pad(&self, key: &str) -> Option<Dist> {
let src = *self.pad_node.get(key)?;
let n = self.pos.len();
let mut dist = vec![f64::INFINITY; n];
let mut prev = vec![usize::MAX; n];
let mut prev_fill = vec![false; n];
let mut heap = BinaryHeap::new();
dist[src] = 0.0;
heap.push(Reverse((0i64, src)));
while let Some(Reverse((dk, u))) = heap.pop() {
if dk > (dist[u] * 1e6) as i64 {
continue;
}
for &(v, w, fill) in &self.adj[u] {
let nd = dist[u] + w;
if nd + 1e-9 < dist[v] {
dist[v] = nd;
prev[v] = u;
prev_fill[v] = fill;
heap.push(Reverse(((nd * 1e6) as i64, v)));
}
}
}
Some(Dist { dist, prev, prev_fill, src })
}
/// The path from the Dist's source pad to this pad, if copper joins them.
pub fn path_to(&self, d: &Dist, key: &str) -> Option<Path> {
let t = *self.pad_node.get(key)?;
if !d.dist[t].is_finite() {
return None;
}
let mut nodes = vec![t];
let mut fill = false;
let mut c = t;
while c != d.src {
fill |= d.prev_fill[c];
c = d.prev[c];
nodes.push(c);
}
nodes.reverse();
let mut vias = 0;
let mut branches = 0;
let mut through = Vec::new();
for k in 1..nodes.len().saturating_sub(1) {
let n = nodes[k];
match self.kind[n] {
Kind::Via => {
// a layer change: the copper before and after the via is on different layers
let before = (0..k).rev().find_map(|q| self.layer[nodes[q]]);
let after = (k + 1..nodes.len()).find_map(|q| self.layer[nodes[q]]);
if let (Some(a), Some(b)) = (before, after) {
if a != b && self.layer[nodes[k - 1]].is_some() && self.layer[nodes[k + 1]].is_some() {
vias += 1;
}
}
}
Kind::Pad => through.push(self.label[n].clone()),
Kind::Point => {}
}
// a landing on the end pads themselves is not a rail branch; other pads' links are not copper runs
let at_end = self.adj[n].iter().any(|e| !e.2 && (e.0 == t || e.0 == d.src));
if self.kind[n] != Kind::Pad && !at_end {
let mut nb: Vec<usize> = self.adj[n].iter().filter(|e| !e.2 && e.0 != n && self.kind[e.0] != Kind::Pad).map(|e| e.0).collect();
nb.sort_unstable();
nb.dedup();
if nb.len() >= 3 {
branches += 1;
}
}
}
Some(Path { length_mm: d.dist[t], vias, through_pads: through, through_fill: fill, branch_points: branches })
}
/// Every pad of the net with its copper distance from the Dist's source.
pub fn pad_distances(&self, d: &Dist) -> Vec<(String, f64)> {
let mut v: Vec<(String, f64)> = self.pad_node.iter().filter(|(_, &n)| n != d.src && d.dist[n].is_finite()).map(|(k, &n)| (k.clone(), d.dist[n])).collect();
v.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap().then(a.0.cmp(&b.0)));
v
}
}
impl Path {
/// Through other pads, or along a stretch of the rail between two landings.
pub fn detour(&self) -> bool {
!self.through_pads.is_empty() || self.branch_points >= 2
}
}
/// Where the zone fills in the measured board came from, and whether they are current.
#[derive(Clone, Debug, Default)]
pub struct FillContext {
/// The fills were refilled natively after the last copper change: a path through them is verified.
pub fills_current: bool,
/// Words for where the fills came from (the saved board file, an approximate raster fill, ...).
pub fill_source: String,
/// Nets with pours planned but not on the board (plan-pours.json not landed and refilled yet).
pub planned_pour_nets: Vec<String>,
}
fn r2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
fn pad_gap(a: &aiflow_board::Pad, b: &aiflow_board::Pad) -> f64 {
let mut best = f64::INFINITY;
for pa in &a.polys {
for pb in &b.polys {
for v in pa {
best = best.min(geom::point_poly_distance(v.0, v.1, pb));
}
for v in pb {
best = best.min(geom::point_poly_distance(v.0, v.1, pa));
}
}
}
if best.is_finite() { best } else { 0.0 }
}
/// The acceptance review of every pair on a board (the plan applied plus the copper already there).
/// Per pair: the routed copper path from the cap supply pad to the assigned pin (length, vias, the
/// other pads it passes), the nearest same-net pin of the same part by copper, the straight pad
/// distance, the ground return by connectivity, and findings against the pair's own budget.
pub fn review(board: &Board, pairs: &[Resolved], ctx: &FillContext) -> Value {
let mut entries = Vec::new();
let (mut warnings, mut errors) = (0usize, 0usize);
let mut graphs: HashMap<String, NetGraph> = HashMap::new();
for p in pairs {
let sev = if p.spec.is_error() { "error" } else { "warning" };
let mut findings: Vec<Value> = Vec::new();
let cap = board.pad_by_key(&p.cap_pad).unwrap();
let pin = board.pad_by_key(&p.pin).unwrap();
let straight = (cap.x - pin.x).hypot(cap.y - pin.y);
let g = graphs.entry(p.net.clone()).or_insert_with(|| NetGraph::build(board, &p.net));
let d = g.from_pad(&p.cap_pad).unwrap();
let path = g.path_to(&d, &p.pin);
let nearest = g.pad_distances(&d).into_iter().find(|(k, _)| board.pad_by_key(k).map(|q| q.reference == pin.reference).unwrap_or(false));
let assigned = match &path {
Some(x) => json!({"connected": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "branchPoints": x.branch_points, "detour": x.detour(), "throughFill": x.through_fill}),
None => json!({"connected": false, "pathMm": Value::Null, "vias": Value::Null, "throughPads": [], "branchPoints": Value::Null, "detour": Value::Null, "throughFill": false}),
};
match &path {
None => findings.push(json!({"severity": sev, "text": format!("{}: no copper joins {} to {} yet", p.label, p.cap_pad, p.pin)})),
Some(x) => {
if !x.through_pads.is_empty() {
findings.push(json!({"severity": sev, "text": format!("{}: the copper path detours through the rail ({}), {:.2} mm against {:.2} mm straight; the cap reaches its pin through other pads", p.label, x.through_pads.join(", "), x.length_mm, straight)}));
} else if x.branch_points >= 2 {
findings.push(json!({"severity": sev, "text": format!("{}: the copper path runs along the rail tree ({} rail branch points on it), {:.2} mm against {:.2} mm straight; the cap and the pin each land on the rail instead of on each other", p.label, x.branch_points, x.length_mm, straight)}));
}
if let Some(ml) = p.spec.max_length_mm {
if x.length_mm > ml + 1e-6 {
findings.push(json!({"severity": sev, "text": format!("{}: copper path {:.2} mm, over maxLengthMm {ml}", p.label, x.length_mm)}));
}
}
if let Some(mv) = p.spec.max_vias {
if x.vias > mv {
findings.push(json!({"severity": sev, "text": format!("{}: {} via(s) on the path, over maxVias {mv}", p.label, x.vias)}));
}
}
}
}
// ground return, by connectivity only
let ground = match (&p.gnd_pad, &p.gnd_net) {
(Some(gp), Some(gn)) => {
let gg = graphs.entry(gn.clone()).or_insert_with(|| NetGraph::build(board, gn));
let gd = gg.from_pad(gp);
let planned = ctx.planned_pour_nets.iter().any(|n| n == gn);
let mut returns = Vec::new();
for rp in &p.return_pins {
let q = board.pad_by_key(rp).unwrap();
let s = r2((q.x - board.pad_by_key(gp).unwrap().x).hypot(q.y - board.pad_by_key(gp).unwrap().y));
let found = gd.as_ref().and_then(|d| gg.path_to(d, rp));
let v = match found {
Some(x) if !x.through_fill => json!({"pin": rp, "status": "copper", "verified": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "straightMm": s}),
Some(x) if ctx.fills_current => json!({"pin": rp, "status": "fill", "verified": true, "pathMm": r2(x.length_mm), "vias": x.vias, "throughPads": x.through_pads, "straightMm": s, "note": format!("through filled zone islands ({}); the length is straight across the fill", ctx.fill_source)}),
Some(x) => json!({"pin": rp, "status": "fill-unverified", "verified": false, "pathMm": r2(x.length_mm), "vias": x.vias, "straightMm": s, "note": format!("joined only through zone fills that are not a native refill of this copper ({}); refill natively (land pours, measure) and review again", ctx.fill_source)}),
None if gg.zones > 0 || planned => json!({"pin": rp, "status": "unverified", "verified": false, "pathMm": Value::Null, "straightMm": s, "note": "only planned or unfilled pours could join these pads; no refilled native fill data, so the return is unverified (the straight distance is not a path)"}),
None => json!({"pin": rp, "status": "open", "verified": false, "pathMm": Value::Null, "straightMm": s, "note": "no copper joins these pads"}),
};
returns.push(v);
}
json!({"gndPad": gp, "net": gn, "returns": returns})
}
_ => Value::Null,
};
for f in &findings {
if f["severity"] == "error" { errors += 1 } else { warnings += 1 }
}
entries.push(json!({
"pair": p.label, "net": p.net, "capPad": p.cap_pad, "pin": p.pin, "severity": sev,
"budget": {"maxLengthMm": p.spec.max_length_mm, "maxVias": p.spec.max_vias},
"assignedPin": assigned,
"nearestSameNetPin": nearest.map(|(k, mm)| json!({"pin": k, "pathMm": r2(mm)})),
"straightMm": r2(straight), "padGapMm": r2(pad_gap(cap, pin)),
"groundReturn": ground, "findings": findings,
}));
}
json!({"pairs": entries.len(), "warnings": warnings, "errors": errors, "entries": entries,
"method": "shortest path along this net's copper (tracks, vias, pads, and filled zone islands straight across) from the cap supply pad to the pin; budgets are the spec's own"})
}