app
AI Flow: Reverse Engineering
Public Made by Adomby adom
Photos of a PCB to a tagged KiCad copy: parts placed, copper and nets with their evidence, a schematic, a measured confidence score, bench tests and a video of the run.
main
Caleb
Add the Rust source, flow definition, assets and install scripts
cf9809b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
//! Connectivity measured on the photos. A pad lying inside a pour outline proves nothing (a GND pour
//! covers pads of every net); what proves it is the clearance ring. Around each pad and via inside a
//! fitted pour, the gap map is sampled on a ring just outside the copper: a continuous gap means
//! isolated, a gap broken by spokes means a thermal connection, no gap means a solid one. Connected
//! items are joined (pads of one through-hole pin on both faces, vias between faces, trace ends on
//! pads) into nets. A via ringed on both faces with no trace is evidence of an inner layer, and the
//! pins beside such vias name the plane.
use crate::kicad_out::{Grid, Pad};
use crate::raster::Plane;
use serde_json::{json, Value};
use std::collections::HashMap;
fn pt(v: &Value) -> (f64, f64) { (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0)) }
pub fn inside(poly: &[(f64, f64)], p: (f64, f64)) -> bool {
let mut c = false;
for i in 0..poly.len() { let (a, b) = (poly[i], poly[(i + 1) % poly.len()]);
if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 { c = !c; } }
c
}
/// Share of a ring at distance `d` mm outside a pad (rectangle or circle) where the gap map is strong.
pub fn ring_share(gap: &Plane, p: &Pad, d: f64, strength: f32) -> f64 {
let (hw, hh) = (p.w / 2.0 + d, p.h / 2.0 + d);
let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
let n = 48; let mut hit = 0;
for k in 0..n {
let t = k as f64 / n as f64 * std::f64::consts::TAU;
let (lx, ly) = if p.round { (hw * t.cos(), hh * t.sin()) } else {
// walk the rectangle perimeter evenly
let per = 4.0 * (hw + hh); let u = t / std::f64::consts::TAU * per;
if u < 2.0 * hw { (-hw + u, -hh) } else if u < 2.0 * hw + 2.0 * hh { (hw, -hh + (u - 2.0 * hw)) } else if u < 4.0 * hw + 2.0 * hh { (hw - (u - 2.0 * hw - 2.0 * hh), hh) } else { (-hw, hh - (u - 4.0 * hw - 2.0 * hh)) }
};
// KiCad screen rotation: x' = x cos + y sin, y' = -x sin + y cos
let (x, y) = (p.x + lx * c + ly * s, p.y - lx * s + ly * c);
let (u, v) = crate::board::to_px(x, y);
// best gap within +-0.12 mm radially, to tolerate fitting error
let mut best = 0f32;
for o in [-0.12, -0.06, 0.0, 0.06, 0.12] { let r = 1.0 + o / (hw.max(hh)); let (uu, vv) = crate::board::to_px(p.x + (x - p.x) * r, p.y + (y - p.y) * r); let _ = (u, v);
if uu >= 0.0 && vv >= 0.0 && uu < gap.w as f64 && vv < gap.h as f64 { best = best.max(gap.sample(uu as f32, vv as f32)); } }
if best >= strength { hit += 1; }
}
hit as f64 / n as f64
}
pub fn classify(share: f64) -> &'static str { if share >= 0.82 { "isolated" } else if share >= 0.25 { "thermal" } else { "solid" } }
struct Uf { p: Vec<usize> }
impl Uf { fn find(&mut self, x: usize) -> usize { let mut r = x; while self.p[r] != r { r = self.p[r]; } let mut y = x; while self.p[y] != r { let n = self.p[y]; self.p[y] = r; y = n; } r }
fn join(&mut self, a: usize, b: usize) { let (a, b) = (self.find(a), self.find(b)); if a != b { self.p[a] = b; } } }
/// Measure every pad and via inside every pour on the photographed faces and join what connects.
/// `gaps` maps "F.Cu"/"B.Cu" to that face's gap map and edge strength.
pub fn extract(pads: &[Pad], copper: &Value, gaps: &HashMap<String, (Plane, f32)>, _g: &Grid) -> Value {
let zones: Vec<Value> = copper["zones"].as_array().cloned().unwrap_or_default();
let vias: Vec<Value> = copper["vias"].as_array().cloned().unwrap_or_default();
let traces: Vec<Value> = copper["traces"].as_array().cloned().unwrap_or_default();
// item index: pads, then zones, vias, traces
let (np, nz, nv) = (pads.len(), zones.len(), vias.len());
let mut uf = Uf { p: (0..np + nz + nv + traces.len()).collect() };
// one pin = one item: pads with the same part and number (through-hole on both faces, split pads) join
let mut by_pin: HashMap<String, usize> = HashMap::new();
for (i, p) in pads.iter().enumerate() { if let Some(j) = by_pin.insert(format!("{}.{}", p.part, p.num), i) { uf.join(i, j); } }
let mut meas = vec![];
for (zi, z) in zones.iter().enumerate() {
let layer = z["layer"].as_str().unwrap_or("");
// only pours with a known net measure joins; a placeholder (unknown-net) pour joins nothing
if z["net"].as_str().unwrap_or("").is_empty() || z["net"].as_str().unwrap_or("").starts_with("UNK") { continue; }
let Some((gap, st)) = gaps.get(layer) else { continue };
let poly: Vec<(f64, f64)> = z["polygon_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();
if poly.len() < 3 { continue; }
let clr = z["clearance_mm"].as_f64().unwrap_or(0.25);
for (i, p) in pads.iter().enumerate() {
// the ring test reads a clearance gap round plated holes; round an SMD pad the mask edge looks the
// same whether the pad is joined or not, so SMD pads are not measured this way
if p.drill <= 0.0 || !p.on(layer) || !inside(&poly, (p.x, p.y)) { continue; }
let share = ring_share(gap, p, clr / 2.0 + 0.02, *st);
let k = classify(share);
if k != "isolated" { uf.join(i, np + zi); }
meas.push(json!({"item": format!("{}.{}", p.part, p.num), "zone": z["id"], "layer": layer, "gapShare": (share * 100.0).round() / 100.0, "connection": k}));
}
for (vi, v) in vias.iter().enumerate() {
let (x, y) = pt(&v["pos_mm"]); if !inside(&poly, (x, y)) { continue; }
let d = v["diameter_mm"].as_f64().unwrap_or(0.6);
let vp = Pad { part: "via".into(), num: vi.to_string(), x, y, w: d, h: d, rot: 0.0, round: true, drill: 0.3, layers: vec!["*.Cu".into()], net: String::new() };
let share = ring_share(gap, &vp, clr / 2.0 + 0.02, *st);
let k = classify(share);
if k != "isolated" { uf.join(np + nz + vi, np + zi); }
meas.push(json!({"item": format!("via:{}", v["id"].as_str().unwrap_or(&vi.to_string())), "zone": z["id"], "layer": layer, "gapShare": (share * 100.0).round() / 100.0, "connection": k}));
}
}
// trace ends on pads and vias
for (ti, t) in traces.iter().enumerate() {
let path: Vec<(f64, f64)> = t["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();
let layer = t["layer"].as_str().unwrap_or("");
for e in [path.first(), path.last()].into_iter().flatten() {
for (i, p) in pads.iter().enumerate() { if p.on(layer) && (e.0 - p.x).abs() <= p.w.max(p.h) / 2.0 + 0.15 && (e.1 - p.y).abs() <= p.w.max(p.h) / 2.0 + 0.15 { uf.join(np + nz + nv + ti, i); } }
for (vi, v) in vias.iter().enumerate() { let (x, y) = pt(&v["pos_mm"]); if crate::geom::dist((x, y), *e) < 0.4 { uf.join(np + nz + nv + ti, np + nz + vi); } }
}
}
// groups -> nets, named by the names the AI gave zones, traces and vias, else N$k
let mut groups: HashMap<usize, Vec<usize>> = HashMap::new();
for i in 0..uf.p.len() { let r = uf.find(i); groups.entry(r).or_default().push(i); }
let mut nets = vec![]; let mut conflicts = vec![];
let mut k = 0;
for (_, items) in groups {
let pins: Vec<String> = items.iter().filter(|i| **i < np).map(|i| format!("{}.{}", pads[*i].part, pads[*i].num)).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
if pins.len() < 2 { continue; }
let mut names: Vec<String> = items.iter().filter_map(|i| {
if *i >= np && *i < np + nz { zones[i - np]["net"].as_str() } else if *i >= np + nz && *i < np + nz + nv { vias[i - np - nz]["net"].as_str() } else if *i >= np + nz + nv { traces[i - np - nz - nv]["net"].as_str() } else { None }
}).filter(|n| !n.is_empty() && !n.starts_with("N$")).map(str::to_string).collect();
names.sort(); names.dedup();
if names.len() > 1 { conflicts.push(json!({"names": names, "pins": pins})); }
k += 1;
let name = names.first().cloned().unwrap_or(format!("N${k}"));
nets.push(json!({"name": name, "pins": pins, "tag": "SEEN", "evidence": ["evidence/connectivity-bottom.jpg"], "why": "joined by copper measured on the photo (pour clearance rings, trace ends, vias)"}));
}
json!({"nets": nets, "measurements": meas, "conflicts": conflicts})
}
/// Planes from vias: a via ringed on both faces with no trace touching it continues on an inner layer;
/// the nearest pins (within `reach` mm) say which net that layer carries.
pub fn plane_evidence(vias: &[Value], pads: &[Pad], pin_net: &HashMap<String, String>, reach: f64) -> Value {
let mut by_net: HashMap<String, Vec<String>> = HashMap::new();
let mut unknown = 0;
for v in vias.iter().filter(|v| v["kind"].as_str() == Some("inner")) {
let (x, y) = pt(&v["pos_mm"]);
let near = pads.iter().filter(|p| crate::geom::dist((p.x, p.y), (x, y)) < reach)
.min_by(|a, b| crate::geom::dist((a.x, a.y), (x, y)).partial_cmp(&crate::geom::dist((b.x, b.y), (x, y))).unwrap());
match near.and_then(|p| pin_net.get(&format!("{}.{}", p.part, p.num))) {
Some(n) => by_net.entry(n.clone()).or_default().push(v["id"].as_str().unwrap_or("").to_string()),
None => unknown += 1,
}
}
let mut v: Vec<(String, Vec<String>)> = by_net.into_iter().collect();
v.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
json!({"byNet": v.iter().map(|(n, vs)| json!({"net": n, "vias": vs.len(), "ids": vs})).collect::<Vec<_>>(), "unattributed": unknown})
}