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
Publish 0.5.0
1984b40
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
//! From traced islands to board objects and measured connectivity. Each island becomes a pour (a zone with its
//! photographed outline and exact copper) or a track (a centre line with a measured width). A pad joins an island
//! when the copper around it is that island and not a clearance gap. A via seen on both faces, or on one face
//! with the other hidden by a part, joins the island it sits in on each face. An island takes the net of the
//! pins it touches, directly; through vias then carry nets to islands that have none. Two different nets
//! meeting at a via are reported and not merged, since a leak in the photo must not short two nets. A via with a
//! closed clearance gap on both faces leads nowhere visible, but that only counts where both photos show vias at all.
use crate::board;
use crate::kicad_out::{Body, Pad};
use crate::raster::{self, Mask};
use crate::trace::Traced;
use serde_json::{json, Value};
use std::collections::{BTreeSet, HashMap};
const MM2: f64 = board::RES * board::RES;
/// Above this share a ring around a pad is a clearance gap: measured on the Driver photos' plated pads.
const GAP_CLOSED: f64 = 0.75;
/// An island more than this share silk has no copper of its own to read. A glint is not silk: it is the lit edge of a track, and a narrow track can be mostly glint.
const PALE_ONLY: f64 = 0.7;
/// Below this area an isolated piece is grain, not copper. A via's ring is about 0.28 mm2, so a ring never falls below it.
const SPECK_MM2: f64 = 0.08;
/// A hole in a pour smaller than this is grain: 40 pixels is 0.025 mm2, less than a clearance around the smallest via.
const PINHOLE_PX: usize = 40;
/// A part's top begins this far inside its body outline, mm. Terminals, leads and solder fillets sit at a body's edge and stand for the
/// pads under them, and an outline in parts.json is only as good as the part was placed: none of that is "on the body".
pub const BODY_INSET_MM: f64 = 0.25;
/// An island with this share of itself inside one part's drawn-in outline is on that part's top: print, a lid's step, a glint.
const ON_BODY: f64 = 0.9;
/// Where copper is read over more than this share of a drawn-in outline, the photo shows board there, not a package top: a connector or
/// shield open in the middle, bare copper that is itself the "part" (a fiducial), or an outline that is not where the part is. Nothing is taken off.
const BODY_OPEN: f64 = 0.25;
/// An island on a part's top has less than this share of itself in the colour of the board's own copper.
const BOARD_COLOURED: f64 = 0.5;
/// A pixel has the board's colour when the mask's own channel stands this share as far above grey in it as in the face's copper. On the CM5 photos
/// copper under the mask stands 0.09 and 0.07 above grey in green, its palest pours 0.03 to 0.045, print and lids 0.005 to 0.011 below.
const BOARD_TINT: f64 = 0.25;
/// A face whose copper stands less than this above neutral grey in every channel has no colour to tell a part's top from: nothing is taken off.
const NO_COLOUR: f64 = 0.01;
/// A branch of a centre line shorter than this share of the local width is a corner of the outline, not copper of its own.
const SPUR_SHARE: f64 = 2.0;
pub struct Face<'a> { pub side: &'a str, pub layer: &'a str, pub t: Traced, /// `board_coloured` of this face's photo
pub board_colour: Mask }
/// The pixels that have the colour of this face's own copper under its mask. The colour is read off the face, not assumed green: the median
/// share of red, green and blue over everything read as copper, and of those the channel that stands furthest above neutral grey is the
/// mask's own. A pixel has the board's colour when that channel stands at least `BOARD_TINT` as far above grey in it. White print, black
/// plastic and a metal lid do not, whatever the mask. On a face whose copper reads grey every pixel counts as board, so colour says nothing there.
pub fn board_coloured(img: &image::RgbImage, labels: &[u32]) -> Mask {
let (w, h) = (img.width() as usize, img.height() as usize);
let share = |p: &image::Rgb<u8>| { let t = (p[0] as f64 + p[1] as f64 + p[2] as f64).max(1.0); [p[0] as f64 / t - 1.0 / 3.0, p[1] as f64 / t - 1.0 / 3.0, p[2] as f64 / t - 1.0 / 3.0] };
let mut ch: [Vec<f64>; 3] = [vec![], vec![], vec![]];
for (i, p) in img.pixels().enumerate().step_by(7) { if labels[i] != 0 { let q = share(p); for k in 0..3 { ch[k].push(q[k]); } } }
let mut d = [0f64; 3];
for k in 0..3 { if !ch[k].is_empty() { ch[k].sort_by(|a, b| a.partial_cmp(b).unwrap()); d[k] = ch[k][ch[k].len() / 2]; } }
let k = (0..3).max_by(|a, b| d[*a].partial_cmp(&d[*b]).unwrap()).unwrap();
if d[k] < NO_COLOUR { return Mask { w, h, d: vec![true; w * h] }; }
Mask { w, h, d: img.pixels().map(|p| share(p)[k] >= BOARD_TINT * d[k]).collect() }
}
/// Islands that lie on a part's own top, with the part: no copper can be seen there, so what was read is print or light on the package.
/// The rule, all three: at least `ON_BODY` of the island is inside the body outline of one part placed on this face, the outline drawn in by
/// `BODY_INSET_MM`; the reader found copper over no more than `BODY_OPEN` of that outline; and less than `BOARD_COLOURED` of the island has
/// the colour of the board's own copper (`coloured`, from `board_coloured`). A two-pad part has no top to speak of between its terminals and
/// is left out. Copper beside a part, under its terminals or reaching in from outside is not inside by that share and stays; so does board
/// seen through a part that is open in the middle, and board inside an outline that parts.json drew too large or in the wrong place.
pub fn on_bodies(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body]) -> Vec<(u32, String)> { on_bodies_at(labels, coloured, w, h, layer, bodies, BODY_INSET_MM, BODY_OPEN) }
/// The same for the candidate pours of `copper regions`, where a part's top is one whole region reaching to its shadow: the outline as drawn,
/// and no limit on how much of it is filled. Board seen through an open part has the board's colour and stays.
pub fn regions_on_bodies(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body]) -> Vec<(u32, String)> { on_bodies_at(labels, coloured, w, h, layer, bodies, 0.0, 1.0) }
fn on_bodies_at(labels: &[u32], coloured: &[bool], w: usize, h: usize, layer: &str, bodies: &[Body], inset: f64, open: f64) -> Vec<(u32, String)> {
let mut total: HashMap<u32, (usize, usize)> = HashMap::new();
let mut out: Vec<(u32, String)> = vec![];
for b in bodies.iter().filter(|b| b.layer == layer && b.pads != 2) {
let px: Vec<(f64, f64)> = b.corners().iter().map(|q| board::to_px(q.0, q.1)).collect();
let (x0, y0, x1, y1) = px.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.0), a.1.min(q.1), a.2.max(q.0), a.3.max(q.1)));
if x1 < 0.0 || y1 < 0.0 { continue; }
let (mut n, mut copper) = (0usize, 0usize); let mut inside: HashMap<u32, usize> = HashMap::new();
for v in y0.max(0.0) as usize..=(y1 as usize).min(h.saturating_sub(1)) { for u in x0.max(0.0) as usize..=(x1 as usize).min(w.saturating_sub(1)) {
let m = board::to_mm(u as f64 + 0.5, v as f64 + 0.5); if !b.holds(m.0, m.1, inset) { continue; }
n += 1; let l = labels[v * w + u]; if l != 0 { copper += 1; *inside.entry(l).or_default() += 1; }
} }
if n == 0 || copper as f64 > open * n as f64 { continue; }
if total.is_empty() { for (i, l) in labels.iter().enumerate() { if *l != 0 { let e = total.entry(*l).or_default(); e.0 += 1; if coloured[i] { e.1 += 1; } } } }
for (l, c) in inside { let (n, board) = total[&l]; if c as f64 >= ON_BODY * n as f64 && (board as f64) < BOARD_COLOURED * n as f64 && !out.iter().any(|o| o.0 == l) { out.push((l, b.part.clone())); } }
}
out.sort();
out
}
/// Take the islands on part tops out of a trace, so nothing later reads or draws them. Returns how many.
pub fn clear_bodies(t: &mut Traced, img: &image::RgbImage, layer: &str, bodies: &[Body]) -> usize {
if !bodies.iter().any(|b| b.layer == layer && b.pads != 2) { return 0; }
let on: std::collections::HashSet<u32> = on_bodies(&t.labels, &board_coloured(img, &t.labels).d, t.w, t.h, layer, bodies).into_iter().map(|o| o.0).collect();
if !on.is_empty() { for l in t.labels.iter_mut() { if on.contains(l) { *l = 0; } } }
on.len()
}
fn ring(p: &Pad, d: f64, n: usize) -> Vec<(f64, 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());
(0..n).map(|k| { let t = k as f64 / n as f64;
let (lx, ly) = if p.round { let a = t * std::f64::consts::TAU; (hw * a.cos(), hh * a.sin()) } else {
let per = 4.0 * (hw + hh); let u = t * 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)) } };
(p.x + lx * c + ly * s, p.y - lx * s + ly * c) }).collect()
}
/// How closed the darkest ring around a pad is: at each radius, the share of what can be seen that is darker than
/// its neighbourhood. A clearance gap is dark all the way round at some radius; thermal spokes break it only a little.
pub fn gap_share(t: &Traced, p: &Pad) -> f64 {
let mut best = 0f64;
for d in [0.08, 0.12, 0.16, 0.20, 0.24, 0.28, 0.32, 0.36, 0.40] {
let (mut dark, mut seen) = (0u32, 0u32);
for q in ring(p, d, 40) {
let (u, v) = board::to_px(q.0, q.1);
if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; }
let i = v as usize * t.w + u as usize;
if t.other.d[i] { continue; }
seen += 1; if t.ratio.d[i] < 0.94 || t.resp.d[i] > 8.0 { dark += 1; }
}
if seen >= 16 { best = best.max(dark as f64 / seen as f64); }
}
best
}
/// The island a pad connects to on this face: the copper around it, when no closed gap stands between them.
pub fn pad_island(t: &Traced, p: &Pad) -> Option<(u32, f64)> {
// the island that covers the pad itself: `padjoin` gives a pad's own area to the copper it reaches, and that says more than a ring round it
{ let mut votes: HashMap<u32, u32> = HashMap::new(); let mut all = 0u32;
for q in ring(p, -0.3 * p.w.min(p.h), 24).into_iter().chain(std::iter::once((p.x, p.y))) { let (u, v) = board::to_px(q.0, q.1); if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; } all += 1; let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *votes.entry(l).or_default() += 1; } }
if let Some((l, c)) = votes.into_iter().max_by_key(|x| (x.1, x.0)) { if c * 2 >= all.max(1) { return Some((l, 0.0)); } } }
let mut totals: HashMap<u32, u32> = HashMap::new();
for d in [0.16, 0.24, 0.32, 0.40, 0.48] { for q in ring(p, d, 40) {
let (u, v) = board::to_px(q.0, q.1);
if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { continue; }
let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *totals.entry(l).or_default() += 1; }
} }
let (l, c) = totals.into_iter().max_by_key(|x| x.1)?;
if c < 12 { return None; }
let g = gap_share(t, p);
// a screw lug is bolted to its pour whatever the ring around it looks like
if g < GAP_CLOSED || (p.drill > 0.0 && p.w * p.h >= 9.0) { Some((l, g)) } else { None }
}
/// The island at a point: the most common label within `r` mm.
pub fn island_at(t: &Traced, x: f64, y: f64, r: f64) -> Option<u32> {
let (cu, cv) = board::to_px(x, y); let rr = (r * board::RES) as i64; let mut votes: HashMap<u32, u32> = HashMap::new();
for dv in -rr..=rr { for du in -rr..=rr { if du * du + dv * dv > rr * rr { continue; }
let (u, v) = (cu as i64 + du, cv as i64 + dv); if u < 0 || v < 0 || u >= t.w as i64 || v >= t.h as i64 { continue; }
let l = t.labels[v as usize * t.w + u as usize]; if l != 0 { *votes.entry(l).or_default() += 1; } } }
votes.into_iter().max_by_key(|x| x.1).map(|x| x.0)
}
fn covered(t: &Traced, x: f64, y: f64) -> bool {
let (u, v) = board::to_px(x, y);
if u < 0.0 || v < 0.0 || u >= t.w as f64 || v >= t.h as f64 { return true; }
t.other.d[v as usize * t.w + u as usize]
}
/// Open polyline simplification.
pub fn rdp(p: &[(f64, f64)], tol: f64) -> Vec<(f64, f64)> {
if p.len() < 3 { return p.to_vec(); }
let (a, b) = (p[0], p[p.len() - 1]); let l = crate::geom::dist(a, b).max(1e-9);
let (mut idx, mut dm) = (0, 0.0);
for (i, q) in p.iter().enumerate().take(p.len() - 1).skip(1) { let d = if crate::geom::dist(a, b) < 1e-9 { crate::geom::dist(a, *q) } else { ((b.0 - a.0) * (a.1 - q.1) - (a.0 - q.0) * (b.1 - a.1)).abs() / l }; if d > dm { dm = d; idx = i; } }
if dm > tol { let mut x = rdp(&p[..=idx], tol); x.pop(); x.extend(rdp(&p[idx..], tol)); x } else { vec![a, b] }
}
/// Walk a thinned mask into chains between line ends and junctions.
pub fn chains(sk: &[bool], w: usize, h: usize) -> Vec<Vec<(usize, usize)>> {
let nb = |i: usize| -> Vec<usize> { let (x, y) = (i % w, i / w); let mut v = vec![]; for dy in -1i64..=1 { for dx in -1i64..=1 { if dx == 0 && dy == 0 { continue; } let (nx, ny) = (x as i64 + dx, y as i64 + dy); if nx >= 0 && ny >= 0 && nx < w as i64 && ny < h as i64 { let j = ny as usize * w + nx as usize; if sk[j] { v.push(j); } } } } v };
let nodes: Vec<usize> = (0..w * h).filter(|i| sk[*i] && nb(*i).len() != 2).collect();
let is_node: std::collections::HashSet<usize> = nodes.iter().copied().collect();
let mut used: std::collections::HashSet<(usize, usize)> = Default::default();
let mut out = vec![];
for &s in &nodes { for f in nb(s) {
if used.contains(&(s, f)) { continue; }
let mut path = vec![s, f]; used.insert((s, f)); used.insert((f, s));
let (mut prev, mut cur) = (s, f);
while !is_node.contains(&cur) {
let Some(next) = nb(cur).into_iter().find(|n| *n != prev && !used.contains(&(cur, *n))) else { break };
used.insert((cur, next)); used.insert((next, cur)); path.push(next); prev = cur; cur = next;
}
out.push(path.into_iter().map(|i| (i % w, i / w)).collect());
} }
out
}
/// `ox`, `oy`: where the sub-image's pixel 0 sits on the board image. A sub-image has a margin, so these can be -1.
fn poly_mm(ring: &[(f64, f64)], ox: f64, oy: f64) -> Vec<(f64, f64)> { ring.iter().map(|p| { let m = board::to_mm(p.0 + ox, p.1 + oy); ((m.0 * 1000.0).round() / 1000.0, (m.1 * 1000.0).round() / 1000.0) }).collect() }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
struct Isl { face: usize, id: u32, net: Option<String>, pins: Vec<usize>, votes: HashMap<String, u32>, lugs: HashMap<String, u32>, mixed: bool, area: usize }
pub struct Options { pub track: f64, pub via: (f64, f64), pub clearance: f64, pub hole_hole: f64, pub edge_clear: f64, /// x0, y0, x1, y1 of the board
pub edge: (f64, f64, f64, f64),
/// whether each face's photo shows vias at all (`domes::face_signature`): a face that does not cannot show one is isolated
pub faces_show_vias: bool,
/// pads whose hole the photo shows open: no pin, so no solder fillet round them
pub open_holes: Vec<(f64, f64)>,
/// the placed parts' bodies (`kicad_out::bodies_of`): what is read on a part's top is not copper
pub bodies: Vec<Body> }
/// A via read from a photo.
pub struct SeenDome { pub x: f64, pub y: f64, pub face: usize, pub d: f64, pub shape: String }
/// A soldered pin's fillet reaches this far beyond its drawn pad on the Driver photos, mm. Its rim lights up like a dome.
const PIN_RIM_MM: f64 = 0.45;
/// The blur of a surface pad's rim, mm.
const PAD_RIM_MM: f64 = 0.15;
/// A dome seen on one face with this share of silk at its centre and on a ring of `PRINT_RING_MM` round it lies in print.
const PRINT_SHARE: f64 = 0.3;
const PRINT_RING_MM: f64 = 0.12;
/// Radius of the ring looked at round a dome that falls on a library land: board there means a via beside the pad.
const OPEN_RING_MM: f64 = 0.2;
/// The photo's edge is unreadable within this distance of the outline, mm.
const PHOTO_EDGE_MM: f64 = 0.1;
/// Everything measured: zones, tracks, vias, groups and what disagreed. `pin_net` is what the AI has inferred so far.
pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &HashMap<String, String>, opt: &Options) -> Value {
// What sits on a part's own top is not copper. It is taken out before anything reads the islands, so it joins no pin, carries no net through a via and is
// not drawn; the overlay shows it as it shows a gap. The accounting keeps each such island as first read, with the part it is on.
let mut on_top: HashMap<(usize, u32), String> = HashMap::new();
for (fi, f) in faces.iter_mut().enumerate() {
let on: HashMap<u32, String> = on_bodies(&f.t.labels, &f.board_colour.d, f.t.w, f.t.h, f.layer, &opt.bodies).into_iter().collect();
if on.is_empty() { continue; }
let t = &mut f.t;
for i in 0..t.labels.len() { if let Some(part) = on.get(&t.labels[i]) { if t.steps.first[i] != 0 { on_top.entry((fi, t.steps.first[i])).or_insert_with(|| format!("on the body of {part}: print or light on a part's top, where no copper can be seen")); } t.labels[i] = 0; t.gap.d[i] = true; } }
}
let mut isl: Vec<Isl> = vec![]; let mut index: HashMap<(usize, u32), usize> = HashMap::new();
for (fi, f) in faces.iter().enumerate() { for r in &f.t.regions { index.insert((fi, r.id), isl.len()); isl.push(Isl { face: fi, id: r.id, net: None, pins: vec![], votes: HashMap::new(), lugs: HashMap::new(), mixed: false, area: r.area }); } }
// pins onto islands, face by face
let mut profile = vec![];
let mut joins = vec![]; let mut pad_on: Vec<Vec<usize>> = vec![vec![]; pads.len()];
// both pads of a two-pad part on one island: the island is two conductors read as one there, and neither pad is joined to it (`faceread::pairs_on_one_island`)
let pad_on_island = |pi: usize, fi: usize| -> Option<u32> { if pads[pi].on(faces[fi].layer) { pad_island(&faces[fi].t, &pads[pi]).map(|x| x.0) } else { None } };
let (mut pair_refused, pair_rows, pairs_seen) = crate::faceread::pairs_on_one_island(pads, faces.len(), &pad_on_island);
// and an island with most of the pins of a large part joins none of them (`faceread::big_parts_on_one_island`)
let (big_refused, big_rows) = crate::faceread::big_parts_on_one_island(pads, faces.len(), &pad_on_island);
pair_refused.extend(big_refused);
for (pi, p) in pads.iter().enumerate() {
for (fi, f) in faces.iter().enumerate() {
if !p.on(f.layer) { continue; }
profile.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "plated": p.drill > 0.0, "net": pin_net.get(&format!("{}.{}", p.part, p.num)), "gap": (gap_share(&f.t, p) * 100.0).round() / 100.0}));
if let Some((l, share)) = pad_island(&f.t, p).filter(|_| !pair_refused.contains(&(pi, fi))) {
let k = index[&(fi, l)]; isl[k].pins.push(pi); pad_on[pi].push(k);
joins.push(json!({"pin": format!("{}.{}", p.part, p.num), "side": f.side, "island": l, "gap": (share * 100.0).round() / 100.0}));
if let Some(n) = pin_net.get(&format!("{}.{}", p.part, p.num)) {
// a large pad says more about its pour than a small one that happens to sit in it
*isl[k].votes.entry(n.clone()).or_default() += (p.w * p.h).ceil().max(1.0) as u32;
// a screw lug is bolted to its pour: nothing else says as much about what the pour carries
if p.drill > 0.0 && p.w * p.h >= 9.0 { *isl[k].lugs.entry(n.clone()).or_default() += 1; }
}
}
}
}
// an island takes the net that clearly dominates the pins it touches
let mut mixed = vec![];
for i in isl.iter_mut() {
if i.lugs.len() == 1 { i.net = i.lugs.keys().next().cloned(); continue; }
let total: u32 = i.votes.values().sum(); if total == 0 { continue; }
let (n, c) = i.votes.iter().max_by_key(|x| (*x.1, x.0.clone())).map(|x| (x.0.clone(), *x.1)).unwrap();
if c as f64 >= 0.7 * total as f64 { i.net = Some(n); } else { i.mixed = true; mixed.push(json!({"side": faces[i.face].side, "island": i.id, "votes": i.votes})); }
}
// vias: seen on both faces, or on one with the other hidden by a part
let (ft, fb) = (faces.iter().position(|f| f.side == "top"), faces.iter().position(|f| f.side == "bottom"));
let tops: Vec<&SeenDome> = domes.iter().filter(|d| Some(d.face) == ft).collect();
let bots: Vec<&SeenDome> = domes.iter().filter(|d| Some(d.face) == fb).collect();
// A dome on one face stands for a via through the board. Where both faces show domes the two are paired.
let mut vias: Vec<(f64, f64, &'static str, f64, String)> = vec![];
let mut used_top = vec![false; tops.len()];
let near = 0.30;
for b in &bots {
let m = tops.iter().enumerate().filter(|(i, t)| !used_top[*i] && crate::geom::dist((b.x, b.y), (t.x, t.y)) < near).min_by(|x, y| crate::geom::dist((b.x, b.y), (x.1.x, x.1.y)).partial_cmp(&crate::geom::dist((b.x, b.y), (y.1.x, y.1.y))).unwrap()).map(|x| x.0);
if let Some(i) = m { used_top[i] = true; vias.push((b.x, b.y, "both-faces", b.d, b.shape.clone())); }
else if ft.map(|f| covered(&faces[f].t, b.x, b.y)).unwrap_or(false) { vias.push((b.x, b.y, "bottom, top hidden by a part", b.d, b.shape.clone())); }
else { vias.push((b.x, b.y, "bottom", b.d, b.shape.clone())); }
}
for (i, t) in tops.iter().enumerate() { if !used_top[i] { let hidden = fb.map(|f| covered(&faces[f].t, t.x, t.y)).unwrap_or(false); vias.push((t.x, t.y, if hidden { "top, bottom hidden" } else { "top" }, t.d, t.shape.clone())); } }
// A via's centre cannot lie on a pad of the face it shows on: dimples of soldered pads and pin bodies look like domes.
// A pad on the far face says nothing: vias stitch up under parts and into their pads.
let on_pad = |x: f64, y: f64, layer: &str| pads.iter().find(|p| { if !p.on(layer) { return false; } let (dx, dy) = (x - p.x, y - p.y); let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin()); let (lx, ly) = (dx * c - dy * s, dx * s + dy * c); let open = opt.open_holes.iter().any(|h| crate::geom::dist(*h, (p.x, p.y)) < 0.01); let m = if p.drill > 0.0 && !open { PIN_RIM_MM } else { PAD_RIM_MM }; if p.round { (lx * lx + ly * ly).sqrt() < p.w.max(p.h) / 2.0 + m } else { lx.abs() < p.w / 2.0 + m && ly.abs() < p.h / 2.0 + m } }).map(|p| format!("{}.{}", p.part, p.num));
let mut uniq: Vec<(f64, f64, &'static str, f64, String)> = vec![];
let pad_side = |seen: &str| if seen.starts_with("top") { "F.Cu" } else { "B.Cu" };
// every dome that does not become a via is kept with the reason, so the drop can be checked against the photo
let mut via_drops = vec![];
let mut drop = |v: &(f64, f64, &'static str, f64, String), why: &str, pad: Option<String>| via_drops.push(json!({"pos_mm": [v.0, v.1], "seen": v.2, "why": why, "pad": pad}));
for v in vias.into_iter() {
// seen on one face only, a dark spot in print or on a part's top is ink or a moulding mark, not a via
if v.2 != "both-faces" {
let face = if v.2.starts_with("top") { ft } else { fb };
let inked = face.map(|f| { let t = &faces[f].t; let n = 12; let hit = (0..=n).filter(|k| { let (dx, dy) = if *k == n { (0.0, 0.0) } else { let a = *k as f64 / n as f64 * std::f64::consts::TAU; (PRINT_RING_MM * a.cos(), PRINT_RING_MM * a.sin()) };
let (u, w_) = board::to_px(v.0 + dx, v.1 + dy); u >= 0.0 && w_ >= 0.0 && (u as usize) < t.w && (w_ as usize) < t.h && t.silk.d[w_ as usize * t.w + u as usize] }).count(); hit as f64 / (n + 1) as f64 }).unwrap_or(0.0);
if inked >= PRINT_SHARE { drop(&v, "in silkscreen print", None); continue; }
if let Some(b) = opt.bodies.iter().find(|b| b.layer == pad_side(v.2) && b.pads != 2 && b.holds(v.0, v.1, BODY_INSET_MM)) { drop(&v, "on a part's top", Some(b.part.clone())); continue; }
}
if let Some(pad) = on_pad(v.0, v.1, pad_side(v.2)) {
// the library's land is drawn generous and a via may hug it: where the photo shows board all round the
// spot it is not a dimple in solder, whatever the land pattern says. A plated pin keeps its wide rim.
let face = if v.2.starts_with("top") { ft } else { fb };
let plated = pads.iter().any(|p| p.drill > 0.0 && format!("{}.{}", p.part, p.num) == pad);
let solder = face.map(|f| { let n = 16; (0..n).filter(|k| { let a = *k as f64 / n as f64 * std::f64::consts::TAU; covered(&faces[f].t, v.0 + OPEN_RING_MM * a.cos(), v.1 + OPEN_RING_MM * a.sin()) }).count() as f64 / n as f64 }).unwrap_or(1.0);
if plated || solder >= 0.5 { drop(&v, "on a pad", Some(pad)); continue; }
}
let m = v.3 / 2.0 + PHOTO_EDGE_MM;
if v.0 - opt.edge.0 < m || opt.edge.2 - v.0 < m || v.1 - opt.edge.1 < m || opt.edge.3 - v.1 < m { drop(&v, "at the photo edge", None); continue; }
// the detector has already merged two looks at one via; this only guards a pair from both faces
if !uniq.iter().any(|u| crate::geom::dist((u.0, u.1), (v.0, v.1)) < 0.25) { uniq.push(v); } else { drop(&v, "second look at a kept via", None); }
}
let vias = uniq;
let dome_total = (tops.len(), bots.len());
// via islands, then nets across vias until nothing changes
let mut via_isl: Vec<Vec<usize>> = vec![];
for v in &vias { let mut here = vec![]; for (fi, f) in faces.iter().enumerate() { if let Some(l) = island_at(&f.t, v.0, v.1, 0.2) { here.push(index[&(fi, l)]); } } via_isl.push(here); }
// Two islands are linked by the vias between them. One via is enough for a track, whose only way off a face
// is a via; two large pours need at least two, so a stray dome on a boundary cannot carry a net across.
let mut links: HashMap<(usize, usize), u32> = HashMap::new();
for here in &via_isl { for a in 0..here.len() { for b in a + 1..here.len() { let k = (here[a].min(here[b]), here[a].max(here[b])); if k.0 != k.1 { *links.entry(k).or_default() += 1; } } } }
let small_area = (3.0 * MM2) as usize;
let strong: Vec<(usize, usize)> = links.iter().filter(|(k, c)| **c >= 2 || isl[k.0].area < small_area || isl[k.1].area < small_area).map(|(k, _)| *k).collect();
for _ in 0..12 {
let mut any = false;
for (a, b) in &strong {
match (isl[*a].net.clone(), isl[*b].net.clone()) {
(Some(n), None) if !isl[*b].mixed => { isl[*b].net = Some(n); any = true; }
(None, Some(n)) if !isl[*a].mixed => { isl[*a].net = Some(n); any = true; }
_ => {}
}
}
if !any { break; }
}
let mut clashes: BTreeSet<String> = BTreeSet::new();
for (a, b) in &strong { if let (Some(x), Some(y)) = (&isl[*a].net, &isl[*b].net) { if x != y { let (p, q) = if x < y { (x, y) } else { (y, x) }; clashes.insert(format!("{p} + {q}")); } } }
let net_of = |k: usize, isl: &[Isl], faces: &[Face]| -> String { isl[k].net.clone().unwrap_or_else(|| format!("UNK_{}_{}", faces[isl[k].face].side, isl[k].id)) };
// pins the islands add or dispute
let (mut pin_adds, mut pin_clash) = (vec![], vec![]);
for (pi, p) in pads.iter().enumerate() {
let pin = format!("{}.{}", p.part, p.num);
for k in &pad_on[pi] { if let Some(n) = &isl[*k].net {
match pin_net.get(&pin) {
None if !p.num.is_empty() => pin_adds.push(json!({"pin": pin, "net": n, "side": faces[isl[*k].face].side, "island": isl[*k].id})),
Some(o) if o != n => pin_clash.push(json!({"pin": pin, "inferred": o, "copper": n, "side": faces[isl[*k].face].side, "island": isl[*k].id})),
_ => {}
} } }
}
// board objects: every island is a pour or a track drawn from its own pixels, or it is classed as not copper with a reason
let (mut zones, mut tracks): (Vec<Value>, Vec<Value>) = (vec![], vec![]);
let mut sizes: Vec<(usize, u32, usize)> = vec![];
for (fi, f) in faces.iter().enumerate() { for r in &f.t.regions { sizes.push((fi, r.id, r.area)); } }
let mut zone_of: HashMap<usize, String> = HashMap::new(); let mut track_of: HashMap<usize, Vec<String>> = HashMap::new();
let mut dropped: HashMap<(usize, u32), String> = HashMap::new();
let mut small = 0usize;
for (fi, f) in faces.iter().enumerate() {
let t = &f.t; let (w, h) = (t.w, t.h);
let by_id: HashMap<u32, &crate::trace::Region> = t.regions.iter().map(|r| (r.id, r)).collect();
let mut px: HashMap<u32, Vec<usize>> = HashMap::new();
for i in 0..w * h { let l = t.labels[i]; if l != 0 { px.entry(l).or_default().push(i); } }
let mut ids: Vec<u32> = px.keys().copied().collect(); ids.sort();
for id in &ids {
let pix = &px[id];
let r = by_id[id]; let k = index[&(fi, *id)];
let area = r.area as f64 / MM2;
let net = net_of(k, &isl, faces);
let unknown = isl[k].net.is_none();
let tag_net = if unknown { json!({"net": "UNKNOWN"}) } else { json!({"net": "INFERRED"}) };
let pale = pix.iter().filter(|i| t.silk.d[**i]).count() as f64 / pix.len().max(1) as f64;
if pale > PALE_ONLY { small += 1; dropped.insert((fi, *id), format!("{:.0}% of it is silk: no copper of its own can be seen", pale * 100.0)); continue; }
if area < SPECK_MM2 && isl[k].pins.is_empty() { small += 1; dropped.insert((fi, *id), format!("speck of {area:.3} mm2 with no neighbour it could join")); continue; }
// a track when thin and long enough to have a line down it; otherwise its exact pixels
let (x0, y0, x1, y1) = (r.x0, r.y0, r.x1, r.y1);
let (sw, sh) = (x1 - x0 + 3, y1 - y0 + 3);
let mut own = Mask::new(sw, sh);
for &i in pix { own.set(i % w - x0 + 1, i / w - y0 + 1, true); }
let mut line: Option<Vec<TrackLine>> = None;
if r.half_width < crate::trace::POUR_HALF { line = track_lines(&own); }
if let Some(cs) = line {
let widths: Vec<f64> = cs.iter().flat_map(|c| c.widths.iter().copied()).collect();
let mut sorted = widths.clone(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = sorted.get(sorted.len() / 2).copied().unwrap_or(opt.track);
for (ci, c) in cs.iter().enumerate() {
let tid = format!("isl-{}-{id}-{ci}", f.side);
track_of.entry(k).or_default().push(tid.clone());
// a pixel's middle, not its corner: without the half pixel every track sat 0.0125 mm off its own island in x and y
let pts: Vec<(f64, f64)> = c.path.iter().map(|q| board::to_mm(q.0 + x0 as f64 - 1.0 + 0.5, q.1 + y0 as f64 - 1.0 + 0.5)).collect();
tracks.push(json!({"id": tid, "layer": f.layer, "net": net, "from": "islands", "width_mm": r3(median / board::RES), "widths_mm": c.widths.iter().map(|v| r3(*v / board::RES)).collect::<Vec<_>>(), "path_mm": pts.iter().map(|p| json!([r3(p.0), r3(p.1)])).collect::<Vec<_>>(), "tag": "MEASURED", "fieldTags": tag_net.clone(), "tests": ["T-pour-nets"], "evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]}));
}
// the track reaches the pads and vias its island joins, where only the part's edge separates them in the photo
let mut ends: Vec<((f64, f64), f64)> = isl[k].pins.iter().map(|pi| ((pads[*pi].x, pads[*pi].y), pads[*pi].w.min(pads[*pi].h) / 2.0)).collect();
for (vi, here) in via_isl.iter().enumerate() { if here.contains(&k) { ends.push(((vias[vi].0, vias[vi].1), opt.via.0 / 2.0)); } }
let edge: Vec<(f64, f64)> = pix.iter().step_by(4).map(|i| board::to_mm((i % w) as f64, (i / w) as f64)).collect();
for (ei, (e, reach)) in ends.iter().enumerate() {
if let Some(q) = edge.iter().min_by(|a, b| crate::geom::dist(**a, *e).partial_cmp(&crate::geom::dist(**b, *e)).unwrap()) {
if crate::geom::dist(*q, *e) - reach < 0.2 { let tid = format!("isl-{}-{id}-stub{ei}", f.side); track_of.entry(k).or_default().push(tid.clone());
let ww = (median / board::RES).clamp(0.05, opt.track);
tracks.push(json!({"id": tid, "layer": f.layer, "net": net, "from": "islands", "width_mm": r3(ww), "widths_mm": [r3(ww), r3(ww)], "path_mm": [[r3(q.0), r3(q.1)], [r3(e.0), r3(e.1)]], "tag": "MEASURED", "fieldTags": tag_net.clone(), "tests": ["T-pour-nets"], "evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]})); }
}
}
} else {
let (bx0, by0, bx1, by1) = (r.x0.saturating_sub(60), r.y0.saturating_sub(60), (r.x1 + 60).min(w - 1), (r.y1 + 60).min(h - 1));
let (sw, sh) = (bx1 - bx0 + 3, by1 - by0 + 3);
let mut sub = Mask::new(sw, sh);
for &i in pix { sub.set(i % w - bx0 + 1, i / w - by0 + 1, true); }
// a pad this island joins is copper the photo cannot show under its part: exactly the pad, no margin
for &pi in &isl[k].pins { let p = &pads[pi]; let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
let (u0, v0) = board::to_px(p.x, p.y); let (hw, hh) = (p.w / 2.0, p.h / 2.0); let e = ((hw.max(hh) * std::f64::consts::SQRT_2 + 0.1) * board::RES) as i64;
for dv in -e..=e { for du in -e..=e { let (mx, my) = (du as f64 / board::RES, dv as f64 / board::RES); let (lx, ly) = (mx * c - my * s, mx * s + my * c);
let hit = if p.round { lx * lx + ly * ly <= hw * hw } else { lx.abs() <= hw && ly.abs() <= hh }; if !hit { continue; }
let (a, b) = (u0 as i64 + du - bx0 as i64 + 1, v0 as i64 + dv - by0 as i64 + 1); if a >= 0 && b >= 0 && a < sw as i64 && b < sh as i64 { sub.set(a as usize, b as usize, true); } } } }
// a pinhole too small to be a clearance is grain
let filled = sub.fill_holes();
let holes = filled.andnot(&sub);
let (hl, hc) = raster::components(&holes);
let mut tiny = vec![false; hc.len() + 1];
for c in &hc { tiny[c.label as usize] = c.area < PINHOLE_PX; }
// a hole holding a dome that touches this pour is the dome and its shadow, not a clearance: a clearance stands clear of its via all round.
// Another island inside a hole keeps it open whatever else it holds.
{ let (mut touch, mut other_inside) = (vec![false; hc.len() + 1], vec![false; hc.len() + 1]);
for v in 1..sh - 1 { for u in 1..sw - 1 { let i = v * sw + u; let l = hl[i] as usize; if l == 0 { continue; } let (bx, by) = (u + bx0 - 1, v + by0 - 1); if bx >= w || by >= h { continue; } let j = by * w + bx;
if t.labels[j] != 0 && t.labels[j] != *id { other_inside[l] = true; }
if t.pale.d[j] && (sub.d[i - 1] || sub.d[i + 1] || sub.d[i - sw] || sub.d[i + sw]) { touch[l] = true; } } }
for c in &hc { let l = c.label as usize; if c.area < crate::trace::SHADOW_PX && touch[l] && !other_inside[l] { tiny[l] = true; } } }
for i in 0..sw * sh { if hl[i] != 0 && tiny[hl[i] as usize] { sub.d[i] = true; } }
let (lab, comps) = raster::components(&sub);
let Some(main) = comps.iter().max_by_key(|c| c.area) else { small += 1; dropped.insert((fi, *id), "nothing left to draw".to_string()); continue };
let outer = crate::geom::simplify(&raster::trace_boundary(&lab, sw, sh, main), 0.5);
if outer.len() < 3 { small += 1; dropped.insert((fi, *id), "no outline to draw".to_string()); continue; }
// the exact copper: runs of image pixels, and the same on the board's 20 px/mm grid for the router's raster
let (ox, oy) = (bx0 as i64 - 1, by0 as i64 - 1);
let mut rle_px: Vec<Value> = vec![]; let mut rle: Vec<Value> = vec![];
for v in 0..sh { let mut row: Vec<i64> = vec![v as i64 + oy]; let mut start: Option<usize> = None;
for u in 0..=sw { let on = u < sw && sub.d[v * sw + u]; match (on, start) { (true, None) => start = Some(u), (false, Some(s0)) => { row.push(s0 as i64 + ox); row.push(u as i64 + ox); start = None; } _ => {} } }
if row.len() > 1 {
if (v as i64 + oy).rem_euclid(2) == 0 { let mut g: Vec<i64> = vec![(v as i64 + oy) / 2]; for c in row[1..].chunks(2) { let (a, b) = (c[0].div_euclid(2), (c[1] + 1).div_euclid(2)); if g.len() > 1 && *g.last().unwrap() >= a { *g.last_mut().unwrap() = b; } else { g.push(a); g.push(b); } } rle.push(json!(g)); }
rle_px.push(json!(row));
} }
let oz = poly_mm(&outer, bx0 as f64 - 1.0, by0 as f64 - 1.0);
let zid = format!("isl-{}-{id}", f.side);
zone_of.insert(k, zid.clone());
let rank = sizes.iter().filter(|s| s.2 > r.area).count() as u64;
zones.push(json!({"id": zid, "layer": f.layer, "net": net, "from": "islands", "polygon_mm": oz.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>(), "rle": rle, "rle_px": rle_px, "areaMm2": (area * 100.0).round() / 100.0,
"connect": "solid", "priority": rank, "min_width_mm": 0.2, "tag": "MEASURED", "fieldTags": tag_net.clone(), "tests": ["T-pour-nets"],
"evidence": [format!("evidence/copper-islands-{}.jpg", f.side)]}));
}
}
}
// Nothing is dropped for making no sense against the rules: what the photo shows is kept and what disagrees is reported.
let path_of = |t: &Value| -> Vec<(f64, f64)> { t["path_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default() };
let seg_d = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy; let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt() };
let mut pad_conflicts = vec![];
for t in &tracks {
let (net, layer, wd) = (t["net"].as_str().unwrap_or("").to_string(), t["layer"].as_str().unwrap_or("").to_string(), t["width_mm"].as_f64().unwrap_or(0.2));
if net.starts_with("UNK") { continue; }
let path = path_of(t);
for p in pads.iter().filter(|p| p.on(&layer) && !p.net.is_empty() && p.net != net) { if path.windows(2).any(|q| seg_d((p.x, p.y), q[0], q[1]) < p.w.min(p.h) / 2.0 + wd / 2.0) { pad_conflicts.push(json!({"track": t["id"], "net": net, "pin": format!("{}.{}", p.part, p.num), "pinNet": p.net})); } }
}
let tracks_dropped = 0usize;
// every island as first read: kept as an item, merged into one, or not copper, with the reason
let accounting = account(&*faces, &zone_of, &track_of, &index, &dropped, &on_top);
// via objects, and inner-layer evidence: a small isolated ring on both faces, joined to no track or pour
let mut via_out = vec![]; let (mut inner, mut ringed) = (0, 0); let mut named = 0;
for (vi, v) in vias.iter().enumerate() {
let here = &via_isl[vi];
// a via takes its net from named copper actually around it: a pour or track on either face, the bottom first
let around: Vec<usize> = here.iter().rev().copied().filter(|k| zone_of.contains_key(k) || track_of.contains_key(k)).collect();
let known: BTreeSet<String> = around.iter().find_map(|k| isl[*k].net.clone()).into_iter().collect();
let net = if let Some(n) = known.iter().next() { named += 1; n.clone() } else if let Some(k) = around.first() { net_of(*k, &isl, faces) } else { String::new() };
// Leading only inward takes a gap closed all the way round on every face, each of which must show vias at all.
// The island test alone reads a dome in a pour as isolated whenever grain fragments the pour round it.
let vp = Pad { part: "via".into(), num: vi.to_string(), x: v.0, y: v.1, w: v.3, h: v.3, rot: 0.0, round: true, drill: 0.0, layers: vec!["*.Cu".into()], net: String::new() };
let closed = here.len() >= 2 && faces.iter().all(|f| gap_share(&f.t, &vp) >= GAP_CLOSED) && here.iter().all(|k| !zone_of.contains_key(k) && !track_of.contains_key(k));
if closed { ringed += 1; }
let isolated = closed && opt.faces_show_vias;
let kind = if isolated { inner += 1; "inner" } else if net.is_empty() { "unknown" } else if net.starts_with("UNK") { "seen" } else { "seen-net" };
via_out.push(json!({"id": format!("v{vi}"), "pos_mm": [r3(v.0), r3(v.1)], "diameter_mm": r3(v.3), "drill_mm": r3(opt.via.1.min((v.3 - 0.1).max(0.1))), "sizeTags": {"diameter": match v.4.as_str() { "ring" => "MEASURED", "arc" => "INFERRED", _ => "DOME_WIDTH" }, "drill": "ASSUMED"}, "shape": v.4, "net": net, "kind": kind, "seen": v.2, "tag": "MEASURED", "fieldTags": if known.len() == 1 { json!({"net": "INFERRED"}) } else { json!({"net": "UNKNOWN"}) }, "tests": ["T-via-nets"]}));
}
// connected groups per net, for the router: what the photo already joins
let mut parent: Vec<usize> = (0..isl.len()).collect();
fn find(p: &mut Vec<usize>, x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let n = p[y]; p[y] = r; y = n; } r }
for (vi, here) in via_isl.iter().enumerate() { let _ = vi; for w2 in here.windows(2) { if isl[w2[0]].net.is_some() && isl[w2[0]].net == isl[w2[1]].net { let (a, b) = (find(&mut parent, w2[0]), find(&mut parent, w2[1])); parent[a] = b; } } }
for pi in 0..pads.len() { for w2 in pad_on[pi].windows(2) { if isl[w2[0]].net.is_some() && isl[w2[0]].net == isl[w2[1]].net { let (a, b) = (find(&mut parent, w2[0]), find(&mut parent, w2[1])); parent[a] = b; } } }
let mut grp: HashMap<usize, (String, BTreeSet<String>, Vec<String>, Vec<String>, Vec<String>)> = HashMap::new();
for k in 0..isl.len() {
let Some(net) = isl[k].net.clone() else { continue };
let rt = find(&mut parent, k);
let e = grp.entry(rt).or_insert_with(|| (net.clone(), BTreeSet::new(), vec![], vec![], vec![]));
for pi in &isl[k].pins { let pin = format!("{}.{}", pads[*pi].part, pads[*pi].num); if pin_net.get(&pin) == Some(&net) || !pin_net.contains_key(&pin) { e.1.insert(pin); } }
if let Some(z) = zone_of.get(&k) { e.2.push(z.clone()); }
if let Some(t) = track_of.get(&k) { e.3.extend(t.iter().cloned()); }
}
for (vi, here) in via_isl.iter().enumerate() { if let Some(k) = here.iter().find(|k| isl[**k].net.is_some()) { let rt = find(&mut parent, *k); if let Some(e) = grp.get_mut(&rt) { if here.iter().all(|q| isl[*q].net.is_none() || isl[*q].net == isl[*k].net) { e.4.push(format!("v{vi}")); } } } }
let groups: Vec<Value> = grp.into_values().filter(|g| !g.1.is_empty() || !g.2.is_empty() || !g.3.is_empty()).map(|g| json!({"net": g.0, "pins": g.1, "zones": g.2, "traces": g.3, "vias": g.4})).collect();
let both = via_out.iter().filter(|v| v["seen"] == json!("both-faces")).count();
let report = json!({"islands": isl.len(), "absorbedDimples": 0, "tooSmallToDraw": small, "pours": zones.len(), "tracks": tracks.len(), "fragmentsDropped": tracks_dropped, "domes": {"top": dome_total.0, "bottom": dome_total.1}, "vias": {"total": via_out.len(), "bothFaces": both, "named": named, "inner": inner, "closedOnEveryFace": ringed, "facesShowVias": opt.faces_show_vias},
"islandsWithNet": isl.iter().filter(|i| i.net.is_some()).count(), "pinsJoined": joins.len(), "pinAdds": pin_adds.len(), "pinClashes": pin_clash.len(), "viaClashes": clashes.len(), "mixedIslands": mixed.len(), "trackPadConflicts": pad_conflicts.len(), "accounting": accounting["totals"]});
json!({"zones": zones, "traces": tracks, "vias": via_out, "groups": groups, "report": report, "pinAdds": pin_adds, "pinClashes": pin_clash, "viaClashes": clashes, "mixed": mixed, "joins": joins, "profile": profile, "accounting": accounting, "trackPadConflicts": pad_conflicts, "viasDropped": via_drops, "padPairsRefused": pair_rows.iter().map(|r| { let mut r = r.clone(); r["side"] = json!(faces[r["face"].as_u64().unwrap_or(0) as usize].side); r }).collect::<Vec<_>>(),
"partPinsRefused": big_rows.iter().map(|r| { let mut r = r.clone(); r["side"] = json!(faces[r["face"].as_u64().unwrap_or(0) as usize].side); r }).collect::<Vec<_>>(),
"padPairsSeen": pairs_seen.iter().enumerate().map(|(fi, n)| json!({"side": faces[fi].side, "parts": n})).collect::<Vec<_>>()})
}
/// One centre line down a thin island: points in sub-image pixels, and the measured width in pixels at each.
pub struct TrackLine { pub path: Vec<(f64, f64)>, pub widths: Vec<f64> }
/// The centre lines of a thin island with its width measured along them. Lightly averaged to take off the skeleton's
/// pixel steps, kept within a quarter of a pixel. None when it has no line to speak of: a blob or a ring.
pub fn track_lines(own: &Mask) -> Option<Vec<TrackLine>> {
let (sw, sh) = (own.w, own.h);
let dt = own.not().distance_to_set();
let mut sk = own.d.clone();
crate::trace::thin(&mut sk, sw, sh);
// the distance to the outside at a strip's middle is (W + 1) / 2 for odd W and W / 2 for even, so 2 d - 1/2 is W on average
let width_at0 = |x: usize, y: usize| (2.0 * dt.d[y * sw + x] as f64 - 0.5).max(1.0);
// grain on a track's edge grows hairs on its skeleton: take off free ends no longer than the copper is wide, until none are left
for _ in 0..4 {
let cs = chains(&sk, sw, sh);
if cs.len() < 2 { break; }
let degree = |x: usize, y: usize, m: &[bool]| { let mut n = 0; for dy in -1i64..=1 { for dx in -1i64..=1 { if dx == 0 && dy == 0 { continue; } let (a, b) = (x as i64 + dx, y as i64 + dy); if a >= 0 && b >= 0 && a < sw as i64 && b < sh as i64 && m[b as usize * sw + a as usize] { n += 1; } } } n };
let mut gone = 0;
for c in &cs {
let (a, b) = (c[0], c[c.len() - 1]);
let (free_a, free_b) = (degree(a.0, a.1, &sk) == 1, degree(b.0, b.1, &sk) == 1);
if free_a == free_b { continue; }
let local = c.iter().map(|q| width_at0(q.0, q.1)).fold(0.0f64, f64::max);
if (c.len() as f64) < (SPUR_SHARE * local).max(6.0) { for (k, q) in c.iter().enumerate() { let end = if free_a { k + 1 == c.len() } else { k == 0 }; if !end { sk[q.1 * sw + q.0] = false; gone += 1; } } }
}
if gone == 0 { break; }
crate::trace::thin(&mut sk, sw, sh);
}
let cs: Vec<Vec<(usize, usize)>> = chains(&sk, sw, sh).into_iter().filter(|c| c.len() >= 4).collect();
if cs.is_empty() { return None; }
let width_at = |x: usize, y: usize| (2.0 * dt.d[y * sw + x] as f64 - 0.5).max(1.0);
let mut out = vec![];
for c in &cs {
let n = c.len(); if n < 2 { continue; }
let mean: Vec<(f64, f64)> = (0..n).map(|i| { let (lo, hi) = (i.saturating_sub(2), (i + 2).min(n - 1)); let (i0, i1) = if i < 2 || i + 2 >= n { (i, i) } else { (lo, hi) }; let m = (i1 - i0 + 1) as f64; let (mut x, mut y) = (0.0, 0.0); for k in i0..=i1 { x += c[k].0 as f64; y += c[k].1 as f64; } (x / m, y / m) }).collect();
let med: Vec<f64> = (0..n).map(|i| { let (lo, hi) = (i.saturating_sub(2), (i + 2).min(n - 1)); let mut v: Vec<f64> = (lo..=hi).map(|k| width_at(c[k].0, c[k].1)).collect(); v.sort_by(|p, q| p.partial_cmp(q).unwrap()); v[v.len() / 2] }).collect();
// keep a vertex when the line or the width changes by a quarter of a pixel
let keep = keep_vertices(&mean, &med, 0.25);
out.push(TrackLine { path: keep.iter().map(|i| mean[*i]).collect(), widths: keep.iter().map(|i| med[*i]).collect() });
}
if out.is_empty() { return None; }
// a thin island so short that its line is no longer than the copper is wide is a blob, drawn by its pixels
let total: f64 = out.iter().map(|t| t.path.windows(2).map(|q| crate::geom::dist(q[0], q[1])).sum::<f64>()).sum();
let wmax = out.iter().flat_map(|t| t.widths.iter().copied()).fold(0.0f64, f64::max);
if total < 2.0 * wmax { return None; }
// the line must explain the copper: a perforated area has a skeleton but is not a track
let explained: f64 = out.iter().map(|t| t.path.windows(2).zip(t.widths.windows(2)).map(|(q, w)| crate::geom::dist(q[0], q[1]) * (w[0] + w[1]) / 2.0).sum::<f64>()).sum();
if out.len() > 30 || (own.count() as f64) > 2.0 * explained { return None; }
Some(out)
}
fn keep_vertices(p: &[(f64, f64)], w: &[f64], tol: f64) -> Vec<usize> {
fn rec(p: &[(f64, f64)], w: &[f64], a: usize, b: usize, tol: f64, out: &mut Vec<usize>) {
if b <= a + 1 { return; }
let (pa, pb) = (p[a], p[b]); let l = crate::geom::dist(pa, pb).max(1e-9);
let (mut idx, mut dm) = (0usize, 0f64);
for i in a + 1..b { let t = (i - a) as f64 / (b - a) as f64; let wd = (w[i] - (w[a] + (w[b] - w[a]) * t)).abs() / 2.0; let d = ((pb.0 - pa.0) * (pa.1 - p[i].1) - (pa.0 - p[i].0) * (pb.1 - pa.1)).abs() / l; let e = d.max(wd); if e > dm { dm = e; idx = i; } }
if dm > tol { rec(p, w, a, idx, tol, out); out.push(idx); rec(p, w, idx, b, tol, out); }
}
let mut out = vec![0]; rec(p, w, 0, p.len() - 1, tol, &mut out); out.push(p.len() - 1); out
}
/// The fate of every island as first read. The biggest first-read contributor to a final island owns it and is "kept"; others were merged into it.
fn account(faces: &[Face], zone_of: &HashMap<usize, String>, track_of: &HashMap<usize, Vec<String>>, index: &HashMap<(usize, u32), usize>, dropped: &HashMap<(usize, u32), String>, on_top: &HashMap<(usize, u32), String>) -> Value {
let (mut rows, mut kept, mut merged, mut not_copper) = (vec![], 0usize, 0usize, 0usize);
let mut per_face = vec![];
for (fi, f) in faces.iter().enumerate() {
let t = &f.t; let first = &t.steps.first; let mut counts: HashMap<(u32, u32), usize> = HashMap::new(); let mut sizes: HashMap<u32, usize> = HashMap::new();
let mut boxes: HashMap<u32, (f64, f64)> = HashMap::new();
for i in 0..t.w * t.h { let a = first[i]; if a == 0 { continue; } *counts.entry((a, t.labels[i])).or_default() += 1; *sizes.entry(a).or_default() += 1; let e = boxes.entry(a).or_insert((0.0, 0.0)); e.0 += (i % t.w) as f64; e.1 += (i / t.w) as f64; }
let mut by_first: HashMap<u32, Vec<(u32, usize)>> = HashMap::new();
for ((a, l), c) in &counts { if *l != 0 { by_first.entry(*a).or_default().push((*l, *c)); } }
// who owns each final island
let mut owner: HashMap<u32, (u32, usize)> = HashMap::new();
for ((a, l), c) in &counts { if *l == 0 { continue; } let e = owner.entry(*l).or_insert((*a, 0)); if *c > e.1 || (*c == e.1 && *a < e.0) { *e = (*a, *c); } }
let mut ids: Vec<u32> = sizes.keys().copied().collect(); ids.sort();
let (mut fk, mut fm, mut fn_) = (0usize, 0usize, 0usize);
for a in ids {
let mut tg: Vec<(u32, usize)> = by_first.remove(&a).unwrap_or_default();
tg.sort_by(|x, y| y.1.cmp(&x.1).then(x.0.cmp(&y.0)));
let n = sizes[&a]; let c = boxes[&a]; let centre = board::to_mm(c.0 / n as f64, c.1 / n as f64);
let items_of = |l: u32| -> Vec<String> { let k = index[&(fi, l)]; let mut v: Vec<String> = vec![]; if let Some(z) = zone_of.get(&k) { v.push(z.clone()); } if let Some(q) = track_of.get(&k) { v.extend(q.iter().cloned()); } v };
let mut row = json!({"side": f.side, "island": a, "areaMm2": r3(n as f64 / MM2), "centreMm": [r3(centre.0), r3(centre.1)]});
let live: Vec<(u32, usize)> = tg.iter().copied().filter(|x| !items_of(x.0).is_empty()).collect();
if live.is_empty() {
fn_ += 1; row["fate"] = json!("not copper");
let why = on_top.get(&(fi, a)).cloned().or_else(|| tg.first().and_then(|x| dropped.get(&(fi, x.0)).cloned())).unwrap_or_else(|| format!("crumb of {n} pixels: too small to read a shape from"));
row["reason"] = json!(why);
} else if owner.get(&live[0].0).map(|o| o.0) == Some(a) {
fk += 1; row["fate"] = json!("kept"); row["items"] = json!(live.iter().flat_map(|x| items_of(x.0)).collect::<Vec<_>>());
if live.len() > 1 { row["note"] = json!("split at a neck: each part is its own conductor"); }
} else {
fm += 1; row["fate"] = json!("merged"); row["items"] = json!(items_of(live[0].0)); row["reason"] = json!("no gap between it and its neighbour: a fragment of the same copper");
}
rows.push(row);
}
kept += fk; merged += fm; not_copper += fn_;
per_face.push(json!({"side": f.side, "read": fk + fm + fn_, "kept": fk, "merged": fm, "notCopper": fn_, "crumbs": t.steps.crumbs, "necksCut": t.steps.necks_cut, "pinchesCut": t.steps.pinches_cut, "joins": t.steps.joined, "joinsRefused": t.steps.refused_joins,
"copperShare": (t.steps.copper_share as f64 * 1000.0).round() / 1000.0, "padsJoined": t.steps.pads_joined, "trackPiecesBridged": t.steps.bridged, "levelsFromEdges": t.steps.levels_from_edges,
"levelsAgainstEdges": t.steps.edge_check.map(|a| json!({"edges": a.edges, "modalStep": a.modal_step, "straddle": a.straddle, "bothCopper": a.both_copper, "bothLaminate": a.both_laminate}))}));
}
let on_parts = rows.iter().filter(|r| r["reason"].as_str().is_some_and(|w| w.starts_with("on the body of "))).count();
json!({"totals": {"read": kept + merged + not_copper, "kept": kept, "merged": merged, "notCopper": not_copper, "onPartBodies": on_parts}, "perFace": per_face, "islands": rows})
}
#[cfg(test)]
mod tests {
use super::*;
fn body(part: &str, layer: &'static str, pads: usize) -> Body { Body { part: part.into(), layer, x: 105.0, y: 105.0, rot: 0.0, bx: (-3.0, -3.0, 3.0, 3.0), pads } }
fn fill(l: &mut [u32], w: usize, id: u32, x0: f64, y0: f64, x1: f64, y1: f64) { let (a, b) = (board::to_px(x0, y0), board::to_px(x1, y1)); for v in a.1 as usize..b.1 as usize { for u in a.0 as usize..b.0 as usize { l[v * w + u] = id; } } }
#[test]
fn print_on_a_chip_is_not_copper_and_what_lies_beside_or_reaches_in_stays() {
// a 6 mm chip at (105, 105) on a 10 mm board
let (w, h) = (400, 400); let mut l = vec![0u32; w * h];
fill(&mut l, w, 1, 104.0, 104.8, 104.5, 105.1); // a letter in the middle of the chip
fill(&mut l, w, 2, 101.6, 104.0, 102.4, 106.0); // a terminal across the chip's edge at x = 102: its pad
fill(&mut l, w, 3, 100.5, 100.5, 101.5, 109.5); // a pour beside the chip
fill(&mut l, w, 4, 106.5, 107.0, 107.5, 109.8); // copper reaching in from outside
fill(&mut l, w, 5, 107.0, 102.3, 107.4, 102.9); // a mark near the corner, still a quarter of a millimetre in
let chip = [body("U5", "F.Cu", 0)];
// nothing on the chip has the board's colour; everything off it does
let mut col = vec![true; w * h]; for (i, v) in l.iter().enumerate() { if *v == 1 || *v == 5 { col[i] = false; } }
assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &chip), vec![(1, "U5".to_string()), (5, "U5".to_string())]);
// the other face has no such part, and a two-pad part has no top to read print on
assert!(on_bodies(&l, &col, w, h, "B.Cu", &chip).is_empty());
assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("C1", "F.Cu", 2)]).is_empty());
// a chip with pads in its footprint is a chip all the same
assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &[body("U1", "F.Cu", 48)]).len(), 2);
// an outline drawn too large takes in board beside the real part: copper there has the board's colour and stays
for (i, v) in l.iter().enumerate() { if *v == 5 { col[i] = true; } }
assert_eq!(on_bodies(&l, &col, w, h, "F.Cu", &chip), vec![(1, "U5".to_string())]);
}
#[test]
fn the_boards_colour_is_read_off_the_face() {
// green mask over copper and laminate, a chip with white print, a warm metal lid; then the same board with a blue mask
for (copper, laminate) in [([90u8, 150, 95], [50u8, 100, 55]), ([70, 95, 160], [40, 55, 110])] {
let (w, h) = (60u32, 20u32);
let img = image::RgbImage::from_fn(w, h, |x, _| image::Rgb(if x < 20 { copper } else if x < 30 { laminate } else if x < 40 { [200, 198, 196] } else if x < 50 { [165, 140, 125] } else { [30, 30, 32] }));
let labels: Vec<u32> = (0..w * h).map(|i| if i % w < 20 { 1 } else if (30..40).contains(&(i % w)) { 2 } else { 0 }).collect();
let m = board_coloured(&img, &labels);
assert!(m.d[5] && m.d[25], "copper and laminate under the mask");
assert!(!m.d[35] && !m.d[45] && !m.d[55], "print, a lid and black plastic");
}
// a board whose copper reads grey: colour cannot tell, so everything counts as board
let grey = image::RgbImage::from_pixel(10, 10, image::Rgb([120, 121, 119]));
assert!(board_coloured(&grey, &vec![1; 100]).d.iter().all(|v| *v));
}
#[test]
fn copper_seen_inside_an_open_part_stays() {
// the same outline round a connector open in the middle: board shows inside it, a pour over a third of it and a track end wholly inside
let (w, h) = (400, 400); let mut l = vec![0u32; w * h];
fill(&mut l, w, 1, 102.5, 102.5, 107.5, 104.5);
fill(&mut l, w, 2, 104.0, 105.5, 104.5, 106.5);
// colour left out of it: say none of it has the board's colour
let col = vec![false; w * h];
assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).is_empty());
// without the pour the track end alone would read as print
let mut only = vec![0u32; w * h]; fill(&mut only, w, 2, 104.0, 105.5, 104.5, 106.5);
assert_eq!(on_bodies(&only, &col, w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).len(), 1);
}
#[test]
fn a_candidate_region_that_is_a_chips_top_is_left_out() {
// the gap lines enclose the whole chip and a little of its shadow; beside it a pour of the board's colour, and one inside an open part
let (w, h) = (400, 400); let mut l = vec![0u32; w * h];
fill(&mut l, w, 1, 101.9, 101.9, 108.1, 108.1);
fill(&mut l, w, 2, 100.2, 100.2, 101.5, 109.8);
let mut col = vec![false; w * h]; for (i, v) in l.iter().enumerate() { if *v == 2 { col[i] = true; } }
assert_eq!(regions_on_bodies(&l, &col, w, h, "F.Cu", &[body("U5", "F.Cu", 0)]), vec![(1, "U5".to_string())]);
assert!(regions_on_bodies(&l, &vec![true; w * h], w, h, "F.Cu", &[body("J1", "F.Cu", 0)]).is_empty(), "board seen through an open part");
// the island rule does not take the same region: filled to the brim, the outline is not a package top to it
assert!(on_bodies(&l, &col, w, h, "F.Cu", &[body("U5", "F.Cu", 0)]).is_empty());
}
#[test]
fn a_turned_part_is_tested_in_its_own_frame() {
// 2.6 by 2.0 mm drawn along x, placed at 90 degrees: it stands 2.6 mm tall on the board
let b = Body { part: "L2".into(), layer: "F.Cu", x: 108.0, y: 123.7, rot: 90.0, bx: (-1.3, -1.0, 1.3, 1.0), pads: 0 };
assert!(b.holds(108.0, 124.9, 0.0)); assert!(!b.holds(109.2, 123.7, 0.0)); assert!(!b.holds(108.0, 124.9, BODY_INSET_MM));
}
#[test]
fn an_island_at_the_photos_edge_keeps_sane_coordinates() {
let p = poly_mm(&[(0.0, 0.0), (41.0, 1.0)], -1.0, -1.0);
assert_eq!(p[0], (99.975, 99.975));
assert_eq!(p[1], (101.0, 100.0));
}
#[test]
fn a_line_with_a_kink_simplifies_to_its_corner() {
let p: Vec<(f64, f64)> = (0..10).map(|i| (i as f64, 0.0)).chain((1..10).map(|i| (9.0, i as f64))).collect();
let s = rdp(&p, 0.05);
assert_eq!(s.len(), 3, "{s:?}");
}
#[test]
fn a_track_keeps_its_measured_width_and_a_blob_has_no_line() {
// a 9 pixel wide straight track
let mut m = Mask::new(120, 30);
for y in 10..19 { for x in 5..115 { m.set(x, y, true); } }
let t = track_lines(&m).expect("a line");
let w: Vec<f64> = t.iter().flat_map(|c| c.widths.iter().copied()).collect();
let mid = w[w.len() / 2];
assert!((7.0..=11.0).contains(&mid), "{mid}");
// a square has no line to speak of, or a line no longer than it is wide
let mut b = Mask::new(30, 30);
for y in 5..25 { for x in 5..25 { b.set(x, y, true); } }
assert!(track_lines(&b).is_none());
}
#[test]
fn chains_split_at_a_junction() {
let (w, h) = (9, 9); let mut m = vec![false; w * h];
for x in 0..9 { m[4 * w + x] = true; }
for y in 0..4 { m[y * w + 4] = true; }
let c = chains(&m, w, h);
assert!(c.len() >= 3, "{}", c.len());
}
}