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
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444
//! Attribution: every pour, track and via gets a net and the evidence for it, graded on a ladder. It never moves or
//! changes copper: only `net`, its tags and `netEvidence` are written back, and the nets themselves.
//!
//! Connectivity comes first and names second. Two stages, so attribution can be repeated with a better via list
//! without reading a photo again:
//!
//! - `connect measure` reads the photos once (`conduct.rs`): the pieces of copper, where pairs of pieces meet with no
//! gap between them, how far copper can be followed from each pad and via. Results: detect/attrib/.
//! - `connect attribute [--vias <file>]` reads only those files, copper.json and nets.json. It stitches pieces into
//! conductors, attaches pads, joins faces at vias, and only then lets the pin map name what it has built.
//!
//! The ladder, strongest first:
//! 1. `copper`: the copper touches a pad of that net with no gap between them, through stitches at most.
//! 2. `via`: reached through vias with a second via beside them between the same two conductors, or a track ending on
//! the via on both faces.
//! 3. `inferred`: reached through one via track to track, or a pad whose edge does not settle it.
//! 4. `group`: joined to other unnamed copper, or by a single unconfirmed via to a pour. A neutral name, still unknown.
//! 5. `none`: no evidence, isolated copper.
//!
//! `conflict` sits off the ladder's scale: two nets meet and nothing settles it. No name is forced.
use crate::board;
use crate::conduct;
use crate::kicad_out::Pad;
use crate::raster::{self, Mask};
use crate::trace::Traced;
use image::RgbImage;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
/// Parts of an island smaller than this (px at 40 px/mm, 0.04 mm2) say nothing.
const MIN_PIECE_PX: usize = 60;
/// How far copper is followed from each side under a pale line, px. Silk lines on the Driver photos are about 8 px
/// wide with a dark edge of up to 4 px beside them, and a clearance gap is wider than that edge.
const UNDER_SILK_PX: u8 = 12;
const SILK_EDGE_PX: f32 = 4.0;
/// Copper running under silk for at least this long is one piece, px (1 mm). A silk line that bridges a gap touches
/// each side only over its own width, far less than this. Measured on the Driver photos.
const UNDER_SILK_CONTACT_PX: u32 = 40;
/// A silk stitch is refused when this share of the meeting has a gap line right beside it.
const SILK_GAP_BESIDE: f64 = 0.2;
/// Half width, px: under this a piece is a track, the same 0.4 mm cut `islands::measure` uses for a pour.
const NARROW_HALF_PX: f32 = 16.0;
/// What makes a piece a track, as opposed to a patch left between parts: at most 0.6 mm wide, at least 0.25 mm2, and
/// long for its width. Patches beside a pad and the arc of a clearance ring were being read as tracks ending on pads.
const TRACK_HALF_PX: f32 = 12.0;
const TRACK_MIN_PX: f64 = 150.0;
const TRACK_ELONGATION: f64 = 3.0;
/// A piece can be stitched only if it looks like copper: less than half pale (a tinned rim or a glare is not), and at the
/// level of copper in brightness and in colour. Laminate read as copper stays below these on the Driver photos: its
/// median brightness below 0.75 and colour below 0.7 of the local copper. Read off crops.
const STITCH_PALE: f64 = 0.5;
/// A track's own width is blurred with the gaps beside it, so it reads lower in both.
const STITCH_VALUE_TRACK: f64 = 0.6;
const STITCH_CHROMA_TRACK: f64 = 0.55;
const STITCH_VALUE: f64 = 0.75;
const STITCH_CHROMA: f64 = 0.7;
/// A fine-pitch pad, its narrow side at most this wide, mm, and how far the glare of its chip's edge reaches from it, px.
const FINE_PAD_MM: f64 = 0.35;
const FINE_STRIP_PX: f32 = 10.0;
/// No stitch is read within this of a pad's outline, px (0.2 mm).
const PAD_KEEP_PX: f32 = 8.0;
/// A stitch: the corridor between two pieces, at index into `conduct::LEVELS`, and how long a meeting it needs, px. Two pours
/// need a corridor at least 10 px (0.25 mm) wide, wider than any break in a gap line seen on the Driver photos. A
/// track need only be followed at 6 px, since it is hardly wider than that itself.
/// A via counts as through a pad when it lies this far inside the pad's outline, mm: about the error of a via's position.
const VIA_IN_PAD_MARGIN_MM: f64 = 0.1;
/// A via's own pad, read from its metal outward: rings on the Driver bottom read 0.6 to 0.65 mm across, and inside one the
/// dome's shadow makes everything read dark.
const VIA_PAD_MM: f64 = 0.62;
/// A track ends on a via when its centre line stops within this of the via's centre, mm: a ring's radius on the Driver bottom
/// plus half a pixel run. On a pad, within this of its outline; less on fine pitch, where the next pin is 0.25 mm away.
const VIA_END_MM: f64 = 0.45;
const TRACK_END_PAD_MM: f64 = 0.2;
const TRACK_END_FINE_MM: f64 = 0.12;
const TRACK_END_BACK_MM: f64 = 0.4;
const POUR_LEVEL: usize = 2;
const POUR_OPEN_PX: u64 = 6;
const TRACK_LEVEL: usize = 1;
const TRACK_OPEN_PX: u64 = 3;
/// A pad joins a pour when copper at least 6 px wide leaves it over this many of sixteen directions, with this many
/// pixels of pour reached. A thermal spoke or a leak reaches over one or two. Read on tuning pads, by eye.
const POUR_ARC: u32 = 4;
/// Sectors in which the pour comes right up to the pad's metal through an open sector: copper near a pad that stops short of it
/// is a clearance, read on a resistor end the first check called joined.
const POUR_TOUCH_ARC: u32 = 3;
const POUR_REACH_PX: u64 = 30;
const NECK_ARC: u32 = 2;
/// A pad joined to a pour has a run of at least this many open sectors of sixteen, and at most this share of its visible
/// sectors closed by a gap or a lit edge; from this higher share up it stands isolated from whatever pour lies around it. On the
/// Driver photos plated GND pins read about 0.4 closed at the median, signal pins about 0.8. Read on tuning pads by eye.
const POUR_RUN: u32 = 3;
const POUR_CLOSED: f64 = 0.5;
const ISOLATED_CLOSED: f64 = 0.75;
/// A plated pin on the bottom: signal pins named by silkscreen reach an arc of 1.8 to 4.2 and GND pins stay near 1.5, and
/// the darkest level next to the pin over the pour's own is 0.2 to 0.76 for signal pins and 0.6 to 0.84 for GND.
const RIDGE_ISOLATED: f64 = 1.7;
const THT_VALLEY_ISOLATED: f64 = 0.55;
const THT_VALLEY_JOINED: f64 = 0.75;
/// A track is followed at least this far from the metal, px: copper hugging a chip's edge runs along it, not away.
const TRACK_LEAVES_PX: u64 = 10;
/// A track leaves a pad when at least this many pixels of it can be reached through a corridor 4 px wide.
const TRACK_REACH_PX: u64 = 12;
/// A track must lead somewhere: its far end at least this far beyond the pad, mm. A clearance arc hugs the pad.
const TRACK_REACH_MM: f64 = 0.5;
/// Fewer visible sectors of sixteen than this around a pad, and nothing can be said about it.
const MIN_VISIBLE: u64 = 3;
/// A via surrounded by copper: this share of its visible sectors is one conductor, and no other reaches the second share.
const SURROUND_MAIN: f64 = 0.6;
const SURROUND_OTHER: f64 = 0.25;
/// A closed ring of gap: this share of a via's visible sectors has a gap across it.
const RING_SHARE: f64 = 0.6;
/// A clear winner: this share of the votes, and two more than the next.
const DOMINANT: f64 = 0.75;
/// Votes for what touches a piece: a pin whose identity is seen (a connector, an IC, a diode) against a resistor or
/// capacitor end. A screw lug is bolted to its pour and decides nearly alone.
const KNOWN_PIN_WEIGHT: u32 = 3;
const LUG_WEIGHT: u32 = 15;
/// A lug's hole is open: its middle reads at most 0.69 of the mask around it on the Driver photos, and a soldered pin's
/// at least 0.82.
const LUG_OPEN_HOLE: f64 = 0.75;
/// Strength of each rung in the score. A name with weak evidence does not count as a name with strong evidence, and
/// a neutral group name counts for almost nothing. These are judgements, stated here so they can be argued with.
pub const WEIGHTS: [(&str, f64); 6] = [("copper", 1.0), ("via", 0.8), ("inferred", 0.4), ("group", 0.1), ("none", 0.0), ("conflict", 0.0)];
pub struct Piece { pub island: u32, pub area: usize, pub half: f32 }
/// Cut an island into copper actually seen: pixels of the island that are not pale or part, joined across a thin pale
/// line only where copper runs under it for a length. The reader lets silk and solder pass, so an island can leak
/// along a silk line, or a solder fillet, across a gap. The map holds the piece id on every pixel it owns.
pub fn pieces_of(t: &Traced) -> (Vec<u32>, Vec<Piece>) {
let (w, h) = (t.w, t.h);
let hard: Vec<bool> = (0..w * h).map(|i| t.labels[i] != 0 && !t.other.d[i]).collect();
let dt = Mask { w, h, d: hard.iter().map(|v| !*v).collect() }.distance_to_set();
let mut owner = vec![0u32; w * h];
let (mut area, mut half, mut island): (Vec<usize>, Vec<f32>, Vec<u32>) = (vec![0], vec![0.0], vec![0]);
let mut st = vec![];
for s in 0..w * h {
if !hard[s] || owner[s] != 0 { continue; }
let id = area.len() as u32; let l = t.labels[s]; owner[s] = id; st.push(s);
let (mut a, mut hw) = (0usize, 0f32);
while let Some(i) = st.pop() {
a += 1; hw = hw.max(dt.d[i]);
let (x, y) = (i % w, i / w);
for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < w, i + 1), (y > 0, i.wrapping_sub(w)), (y + 1 < h, i + w)] {
if ok && hard[j] && t.labels[j] == l && owner[j] == 0 { owner[j] = id; st.push(j); }
}
}
area.push(a); half.push(hw); island.push(l);
}
for o in owner.iter_mut() { if *o != 0 && area[*o as usize] < MIN_PIECE_PX { *o = 0; } }
// follow each piece a little way under pale pixels of its own island, and count where two fronts meet
let mut depth = vec![0u8; w * h];
let under = t.other.dilate(SILK_EDGE_PX);
let mut front: Vec<usize> = (0..w * h).filter(|i| owner[*i] != 0).collect();
let mut contact: HashMap<(u32, u32), u32> = HashMap::new();
for d in 0..UNDER_SILK_PX {
let mut next = vec![];
for &i in &front {
let (x, y) = (i % w, i / w);
for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < w, i + 1), (y > 0, i.wrapping_sub(w)), (y + 1 < h, i + w)] {
if !ok { continue; }
if owner[j] == 0 { if under.d[j] { owner[j] = owner[i]; depth[j] = d + 1; next.push(j); } }
else if owner[j] < owner[i] && island[owner[j] as usize] == island[owner[i] as usize] { *contact.entry((owner[j], owner[i])).or_default() += 1; }
}
}
front = next;
}
let mut parent: Vec<u32> = (0..area.len() as u32).collect();
fn find(p: &mut [u32], x: u32) -> u32 { let mut r = x; while p[r as usize] != r { r = p[r as usize]; } let mut y = x; while p[y as usize] != r { let n = p[y as usize]; p[y as usize] = r; y = n; } r }
for ((a, b), c) in &contact {
let narrow = half[*a as usize] < NARROW_HALF_PX && half[*b as usize] < NARROW_HALF_PX;
let need = if narrow { (0.6 * 2.0 * half[*a as usize].min(half[*b as usize])).max(4.0) as u32 } else { UNDER_SILK_CONTACT_PX };
if *c >= need { let (ra, rb) = (find(&mut parent, *a), find(&mut parent, *b)); if ra != rb { parent[ra as usize] = rb; } }
}
// final ids in scan order, so the same photos give the same ids
let mut remap: HashMap<u32, u32> = HashMap::new(); let mut out: Vec<Piece> = vec![];
for i in 0..w * h {
if owner[i] == 0 { continue; }
let r = find(&mut parent, owner[i]);
let id = *remap.entry(r).or_insert_with(|| { out.push(Piece { island: island[r as usize], area: 0, half: 0.0 }); out.len() as u32 });
let p = &mut out[id as usize - 1];
if hard[i] { p.area += 1; p.half = p.half.max(dt.d[i]); }
owner[i] = id;
}
(owner, out)
}
fn r2(v: f64) -> f64 { (v * 100.0).round() / 100.0 }
fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
fn write_rle(path: &Path, v: &[u32], w: usize, h: usize) -> std::io::Result<()> {
let mut b: Vec<u8> = vec![]; b.extend((w as u32).to_le_bytes()); b.extend((h as u32).to_le_bytes());
let mut i = 0; while i < v.len() { let mut j = i + 1; while j < v.len() && v[j] == v[i] { j += 1; } b.extend(v[i].to_le_bytes()); b.extend(((j - i) as u32).to_le_bytes()); i = j; }
std::fs::write(path, b)
}
fn read_rle(path: &Path) -> Result<(Vec<u32>, usize, usize), String> {
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
if b.len() < 8 || (b.len() - 8) % 8 != 0 { return Err(format!("{}: not a run-length map", path.display())); }
let word = |k: usize| u32::from_le_bytes([b[k], b[k + 1], b[k + 2], b[k + 3]]);
let (w, h) = (word(0) as usize, word(4) as usize);
let mut out = Vec::with_capacity(w * h); let mut k = 8; while k < b.len() { let (v, n) = (word(k), word(k + 4)); out.resize(out.len() + n as usize, v); k += 8; }
if out.len() != w * h { return Err(format!("{}: {} pixels, expected {}", path.display(), out.len(), w * h)); }
Ok((out, w, h))
}
pub struct FaceIn<'a> { pub side: &'a str, pub layer: &'a str, pub img: RgbImage, pub t: Traced }
/// A lug's hole: the median green in its middle over that of the mask around it.
fn hole_ratio(img: &RgbImage, p: &Pad) -> Option<f64> {
if p.drill <= 0.0 { return None; }
let (cu, cv) = board::to_px(p.x, p.y);
let (mut inner, mut outer) = (vec![], vec![]);
let r = (p.w.max(p.h) / 2.0 + 1.0) * board::RES;
for dv in -r as i64..=r as i64 { for du in -r as i64..=r as i64 {
let (u, v) = (cu as i64 + du, cv as i64 + dv); if u < 0 || v < 0 || u >= img.width() as i64 || v >= img.height() as i64 { continue; }
let (x, y) = board::to_mm(u as f64, v as f64); let g = img.get_pixel(u as u32, v as u32)[1] as f32;
if ((x - p.x).powi(2) + (y - p.y).powi(2)).sqrt() <= p.drill * 0.35 { inner.push(g); } else if !conduct::pad_inside(p, x, y, 0.15) { outer.push(g); }
} }
if inner.is_empty() || outer.is_empty() { return None; }
let med = |v: &mut Vec<f32>| { let k = v.len() / 2; v.select_nth_unstable_by(k, |a, b| a.partial_cmp(b).unwrap()); v[k] as f64 };
Some(r2(med(&mut inner) / med(&mut outer).max(1.0)))
}
/// The shape to read a pad's edge from. A pad traced from the photo carries the box of its metal with its halo, which reaches past
/// a clearance, and on a part turned 45 degrees or a fine-pitch row covers its neighbours. So a surface pad is read from the library
/// outline at the place the photo shows its metal; bright metal joined to that outline is added by the flood.
pub fn true_pads(flow: &Path, pads: &[Pad]) -> Vec<Pad> {
let Ok(doc) = crate::items::read_json(&flow.join(crate::photolayers::FILE)) else { return pads.to_vec() };
let Ok(parts) = crate::items::read_json(&flow.join("parts.json")) else { return pads.to_vec() };
let rot: HashMap<String, f64> = parts["parts"].as_array().into_iter().flatten().map(|p| (p["ref"].as_str().unwrap_or("").to_string(), p["rot"].as_f64().unwrap_or(0.0))).collect();
let mut lib: HashMap<(String, String), Vec<&Value>> = HashMap::new();
for p in doc["pads"].as_array().into_iter().flatten() { lib.entry((p["part"].as_str().unwrap_or("").to_string(), p["num"].as_str().unwrap_or("").to_string())).or_default().push(p); }
pads.iter().map(|p| {
let mut q = p.clone();
let r = rot.get(&p.part).copied().unwrap_or(0.0);
if p.drill > 0.0 { return q; }
let Some(rows) = lib.get(&(p.part.clone(), p.num.clone())) else { return q };
let Some(row) = rows.iter().min_by(|a, b| { let d = |v: &Value| (v["anchor"][0].as_f64().unwrap_or(0.0) - p.x).abs() + (v["anchor"][1].as_f64().unwrap_or(0.0) - p.y).abs(); d(a).partial_cmp(&d(b)).unwrap() }) else { return q };
let (lw, lh) = (row["libSize"][0].as_f64().unwrap_or(0.0), row["libSize"][1].as_f64().unwrap_or(0.0));
if lw <= 0.0 || lh <= 0.0 { return q; }
{ q.x = row["seenAt"][0].as_f64().or(row["lib"][0].as_f64()).unwrap_or(p.x); q.y = row["seenAt"][1].as_f64().or(row["lib"][1].as_f64()).unwrap_or(p.y); q.w = lw; q.h = lh; q.rot = r; }
q
}).collect()
}
/// The glare along a fine-pitch chip's edge reads as a thin strip of copper running past every pin. A pin's own track only
/// counts from beyond that strip, so narrow copper within the strip is left out of the pieces, and what it joined falls apart.
fn apart_at_fine_pins(mut map: Vec<u32>, ps: Vec<Piece>, pads: &[Pad], layer: &str, w: usize, h: usize) -> (Vec<u32>, Vec<Piece>) {
let fine: Vec<Pad> = pads.iter().filter(|p| p.on(layer) && p.w.min(p.h) <= FINE_PAD_MM && p.drill <= 0.0).cloned().collect();
if fine.is_empty() { return (map, ps); }
let rects = conduct::pad_rects(&fine, layer, w, h);
let zone = Mask { w, h, d: rects.iter().map(|c| *c != 0).collect() }.dilate(FINE_STRIP_PX);
let mut out: Vec<Piece> = vec![];
let mut seen = vec![false; w * h];
let mut next = vec![0u32; w * h];
let dt = Mask { w, h, d: map.iter().map(|v| *v == 0).collect() }.distance_to_set();
let narrow: Vec<bool> = ps.iter().map(|p| p.half < NARROW_HALF_PX).collect();
let keep = |i: usize, map: &[u32]| map[i] != 0 && !(zone.d[i] && narrow[map[i] as usize - 1]);
for s in 0..w * h {
if seen[s] || !keep(s, &map) { continue; }
let old = map[s]; let id = out.len() as u32 + 1; let (mut a, mut hw) = (0usize, 0f32);
let mut st = vec![s]; seen[s] = true;
while let Some(i) = st.pop() {
next[i] = id; a += 1; hw = hw.max(dt.d[i]);
let (x, y) = (i % w, i / w);
for (ok, j) in [(x > 0, i.wrapping_sub(1)), (x + 1 < w, i + 1), (y > 0, i.wrapping_sub(w)), (y + 1 < h, i + w)] { if ok && !seen[j] && map[j] == old && keep(j, &map) { seen[j] = true; st.push(j); } }
}
out.push(Piece { island: ps[old as usize - 1].island, area: a, half: hw });
}
// crumbs left beside a pin say nothing
let small: Vec<bool> = out.iter().map(|p| p.area < MIN_PIECE_PX).collect();
let mut remap = vec![0u32; out.len() + 1]; let mut kept = vec![];
for (k, p) in out.into_iter().enumerate() { if !small[k] { kept.push(p); remap[k + 1] = kept.len() as u32; } }
for v in next.iter_mut() { *v = remap[*v as usize]; }
map = next;
(map, kept)
}
/// Each traced pad's library outline at the place the photo shows its metal: what a via must lie inside to pass through it.
fn library_shapes(flow: &Path) -> HashMap<(String, String), Vec<(f64, f64, f64, f64, f64)>> {
let (mut out, rot): (HashMap<(String, String), Vec<(f64, f64, f64, f64, f64)>>, HashMap<String, f64>) = (HashMap::new(), crate::items::read_json(&flow.join("parts.json")).ok().map(|d| d["parts"].as_array().into_iter().flatten().map(|p| (p["ref"].as_str().unwrap_or("").to_string(), p["rot"].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default());
if let Ok(doc) = crate::items::read_json(&flow.join(crate::photolayers::FILE)) { for p in doc["pads"].as_array().into_iter().flatten() {
let part = p["part"].as_str().unwrap_or("").to_string();
let (x, y) = (p["seenAt"][0].as_f64().or(p["lib"][0].as_f64()).unwrap_or(0.0), p["seenAt"][1].as_f64().or(p["lib"][1].as_f64()).unwrap_or(0.0));
let (w, h) = (p["libSize"][0].as_f64().unwrap_or(0.0), p["libSize"][1].as_f64().unwrap_or(0.0));
let r = rot.get(&part).copied().unwrap_or(0.0);
out.entry((part, p["num"].as_str().unwrap_or("").to_string())).or_default().push((x, y, w, h, r));
} }
out
}
fn core_shape(shapes: &HashMap<(String, String), Vec<(f64, f64, f64, f64, f64)>>, p: &Pad) -> Value {
let hit = shapes.get(&(p.part.clone(), p.num.clone())).and_then(|v| v.iter().min_by(|a, b| ((a.0 - p.x).abs() + (a.1 - p.y).abs()).partial_cmp(&((b.0 - p.x).abs() + (b.1 - p.y).abs())).unwrap()));
match hit { Some(c) if c.0 > 0.0 && c.2 > 0.0 && c.3 > 0.0 => json!({"x": c.0, "y": c.1, "w": c.2, "h": c.3, "rot": c.4}), _ if p.w <= 0.0 => json!({"x": p.x, "y": p.y, "w": 0.3, "h": 0.3, "rot": p.rot}), _ => json!({"x": p.x, "y": p.y, "w": p.w, "h": p.h, "rot": p.rot}) }
}
/// Stage one: read what attribution needs from the traced photos.
pub fn measure(flow: &Path, faces: &[FaceIn], pads: &[Pad], domes: &[(f64, f64, String)]) -> Result<Value, String> {
let pads = &true_pads(flow, pads)[..];
let shapes = library_shapes(flow);
let dir = flow.join("detect/attrib");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut pieces = vec![]; let (mut stitch, mut silk) = (vec![], vec![]);
let mut read: Vec<(Vec<u32>, conduct::Seen, Vec<u32>, Vec<u32>, Vec<f32>, Vec<f32>)> = vec![];
for f in faces {
let (w, h) = (f.t.w, f.t.h);
let (map, ps) = pieces_of(&f.t);
let (map, ps) = apart_at_fine_pins(map, ps, pads, f.layer, w, h);
write_rle(&dir.join(format!("pieces-{}.rle", f.side)), &map, w, h).map_err(|e| e.to_string())?;
let seen = conduct::read_face(&f.img, &f.t);
write_rle(&dir.join(format!("gap-{}.rle", f.side)), &seen.gap.d.iter().map(|v| *v as u32).collect::<Vec<_>>(), w, h).map_err(|e| e.to_string())?;
let stats = conduct::piece_stats(&map, ps.len(), &seen, &f.t);
for (k, p) in ps.iter().enumerate() { pieces.push(json!({"side": f.side, "id": k + 1, "island": p.island, "area": p.area, "half": p.half, "value": stats[k]["value"], "chroma": stats[k]["chroma"], "pale": stats[k]["pale"]})); }
let rects = conduct::pad_rects(pads, f.layer, w, h);
// a pad's solder and glare say nothing about copper running past it: no stitch is read beside a pad
let near_pad = Mask { w, h, d: rects.iter().map(|c| *c != 0).collect() }.dilate(PAD_KEEP_PX);
stitch.extend(conduct::corridors(&map, &seen, &near_pad, &f.t, f.side));
silk.extend(conduct::silk_contacts(&map, &seen, &near_pad, &f.t, f.side));
let cells = conduct::pad_cells(&rects, w, h);
let green: Vec<f32> = f.img.pixels().map(|p| p[1] as f32).collect();
let smooth = raster::blur(&raster::Plane { w, h, d: green.clone() }, 1, 1).d;
read.push((map, seen, rects, cells, green, smooth));
}
let read = &read;
// what lies around each pad and each via: the same flood from its metal
let one_pad = |(k, p): (usize, &Pad)| -> Value {
let mut on = serde_json::Map::new();
for (fi, f) in faces.iter().enumerate() {
if !p.on(f.layer) { continue; }
let mut r = conduct::reach_from(p, k as u32 + 1, &read[fi].3, &read[fi].0, &read[fi].1, &f.t);
if let Some(h) = hole_ratio(&f.img, p) { r["hole"] = json!(h); let (ri, va) = conduct::plated_ring(&read[fi].4, &read[fi].5, &f.t.other, f.t.w, f.t.h, p); r["ridge"] = json!(ri); r["valley"] = json!(va); }
on.insert(f.side.to_string(), r);
}
let core = core_shape(&shapes, p);
json!({"part": p.part, "num": p.num, "x": p.x, "y": p.y, "w": p.w, "h": p.h, "rot": p.rot, "round": p.round, "drill": p.drill, "layers": p.layers, "faces": on, "core": core})
};
let one_dome = |d: &(f64, f64, String)| -> Value {
let vp = Pad { part: "via".into(), num: String::new(), x: d.0, y: d.1, w: VIA_PAD_MM, h: VIA_PAD_MM, rot: 0.0, round: true, drill: 0.0, layers: vec!["*.Cu".into()], net: String::new() };
let mut on = serde_json::Map::new();
for (fi, f) in faces.iter().enumerate() { on.insert(f.side.to_string(), conduct::reach_from(&vp, 0, &read[fi].2, &read[fi].0, &read[fi].1, &f.t)); }
json!({"pos_mm": [d.0, d.1], "side": d.2, "faces": on})
};
let indexed: Vec<(usize, &Pad)> = pads.iter().enumerate().collect();
let pad_parts: Vec<Vec<Value>> = std::thread::scope(|sc| { let hs: Vec<_> = indexed.chunks(indexed.len().div_ceil(8).max(1)).map(|c| { let one = &one_pad; sc.spawn(move || c.iter().map(|q| one(*q)).collect::<Vec<Value>>()) }).collect(); hs.into_iter().map(|h| h.join().unwrap()).collect() });
let dome_parts: Vec<Vec<Value>> = std::thread::scope(|sc| { let hs: Vec<_> = domes.chunks(domes.len().div_ceil(8).max(1)).map(|c| { let one = &one_dome; sc.spawn(move || c.iter().map(one).collect::<Vec<Value>>()) }).collect(); hs.into_iter().map(|h| h.join().unwrap()).collect() });
let (pad_out, dome_out): (Vec<Value>, Vec<Value>) = (pad_parts.into_iter().flatten().collect(), dome_parts.into_iter().flatten().collect());
let copper = crate::items::read_json(&flow.join("copper.json")).map_err(|e| e.to_string())?;
let doc = json!({"pieces": pieces, "stitch": stitch, "silk": silk, "pads": pad_out, "domes": dome_out, "copperSha": zones_key(&copper)});
std::fs::write(dir.join("measure.json"), serde_json::to_string(&doc).unwrap()).map_err(|e| e.to_string())?;
Ok(json!({"pieces": doc["pieces"].as_array().unwrap().len(), "pads": pads.len(), "domes": domes.len(), "meetings": doc["stitch"].as_array().unwrap().len()}))
}
// ---------------------------------------------------------------- stage two
#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
pub enum Tier { Solid = 1, Weak = 3 }
#[derive(Clone, Debug)]
pub struct Contact { pub pin: String, pub net: Option<String>, pub piece: usize, pub tier: Tier, pub weight: u32, pub kind: &'static str, pub side: &'static str }
#[derive(Clone, Debug)]
pub struct GVia { pub id: String, pub top: Option<usize>, pub bottom: Option<usize>, pub valid: bool, pub both: bool }
#[derive(Clone, Debug, PartialEq)]
pub enum Rung { Copper, Via, Inferred, Group, None, Conflict }
impl Rung {
pub fn name(&self) -> &'static str { match self { Rung::Copper => "copper", Rung::Via => "via", Rung::Inferred => "inferred", Rung::Group => "group", Rung::None => "none", Rung::Conflict => "conflict" } }
/// What the tag says about the net field.
pub fn tag(&self) -> &'static str { match self { Rung::Copper | Rung::Via => "MEASURED", Rung::Inferred => "INFERRED", _ => "UNKNOWN" } }
}
#[derive(Clone, Debug)]
pub struct Named { pub net: String, pub rung: Rung, pub why: String, pub candidates: Vec<String> }
pub struct Outcome { pub pieces: Vec<Named>, pub pin_clashes: Vec<Value>, pub weak_disagree: Vec<Value>, pub via_clashes: Vec<Value>, pub links: Vec<Value>, pub conflicts: Vec<Value> }
/// Clear winner of some votes, or the list when there is none.
fn winner(votes: &BTreeMap<String, u32>) -> Result<Option<String>, Vec<String>> {
if votes.is_empty() { return Ok(None); }
let total: u32 = votes.values().sum();
let mut v: Vec<(&String, &u32)> = votes.iter().collect();
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
let (top, second) = (*v[0].1, v.get(1).map(|x| *x.1).unwrap_or(0));
// several lone dissenters on different nets are false contacts more often than a short: three times the runner up also wins
if second == 0 || (top as f64 >= DOMINANT * total as f64 && top >= second + 2) || (top >= 3 && top >= 3 * second) { Ok(Some(v[0].0.clone())) } else { Err(v.iter().map(|x| x.0.clone()).collect()) }
}
/// Names over the copper graph, strongest evidence first. `narrow[k]`: piece k is a track.
/// Each piece keeps the strongest evidence it reached; a weaker path can never rename it or merge two nets.
pub fn propagate(narrow: &[bool], area: &[f64], contacts: &[Contact], vias: &[GVia]) -> Outcome {
let n = narrow.len();
let mut named: Vec<Option<Named>> = vec![None; n];
let mut conflict: Vec<Option<Vec<String>>> = vec![None; n];
let mut conflicts = vec![];
// rung 1: pads with no gap between them and the copper
let mut votes: Vec<BTreeMap<String, u32>> = vec![BTreeMap::new(); n];
let mut voters: Vec<Vec<String>> = vec![vec![]; n];
for c in contacts.iter().filter(|c| c.tier == Tier::Solid) { if let Some(nt) = &c.net { *votes[c.piece].entry(nt.clone()).or_default() += c.weight; voters[c.piece].push(c.pin.clone()); } }
for k in 0..n {
match winner(&votes[k]) {
Ok(Some(net)) => named[k] = Some(Named { net, rung: Rung::Copper, why: format!("touches {}", voters[k].join(", ")), candidates: vec![] }),
Ok(None) => {}
Err(list) => { conflicts.push(json!({"piece": k, "cause": "pads of different nets touch this copper", "votes": votes[k], "pins": voters[k]})); conflict[k] = Some(list); }
}
}
// links: pairs of pieces with the valid vias between them
let mut pairs: BTreeMap<(usize, usize), (u32, u32, Vec<String>)> = BTreeMap::new();
for v in vias.iter().filter(|v| v.valid) { if let (Some(a), Some(b)) = (v.top, v.bottom) { if a != b { let e = pairs.entry((a.min(b), a.max(b))).or_default(); e.0 += 1; if v.both { e.1 += 1; } e.2.push(v.id.clone()); } } }
// tier 2: a second path or both faces; tier 3: one via, track to track; tier 4: one via onto a pour proves nothing
let tier = |a: usize, b: usize, c: u32, both: u32| -> u8 { if both > 0 || c >= 2 { 2 } else if narrow[a] && narrow[b] { 3 } else { 4 } };
let links: Vec<(usize, usize, u8, u32)> = pairs.iter().map(|((a, b), e)| (*a, *b, tier(*a, *b, e.0, e.1), e.0)).collect();
// Spread names over the links of a tier, those with the most vias first: a link of twelve vias settles what its two
// pieces are before a link of two can say anything. Copper already named keeps its name, and two named clusters are
// never merged. An unnamed cluster reached by two names at the same strength takes only a clear winner.
let spread = |named: &mut Vec<Option<Named>>, conflict: &mut Vec<Option<Vec<String>>>, conflicts: &mut Vec<Value>, max_tier: u8, rung: Rung| {
let mut parent: Vec<usize> = (0..n).collect();
fn root(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }
// clusters of copper with a name start from what is already named; members share it
let mut levels: Vec<u32> = links.iter().filter(|l| l.2 <= max_tier).map(|l| l.3).collect();
levels.sort_unstable_by(|a, b| b.cmp(a)); levels.dedup();
let mut name_of: Vec<Option<String>> = (0..n).map(|k| named[k].as_ref().map(|m| m.net.clone())).collect();
let usable = |k: usize, conflict: &Vec<Option<Vec<String>>>| conflict[k].is_none();
for level in levels {
let here: Vec<(usize, usize)> = links.iter().filter(|l| l.2 <= max_tier && l.3 == level && usable(l.0, conflict) && usable(l.1, conflict)).map(|l| (l.0, l.1)).collect();
// first join what has no name yet, so a whole cluster weighs the names reaching it together
for (a, b) in &here { let (x, y) = (root(&mut parent, *a), root(&mut parent, *b)); if x != y && name_of[x].is_none() && name_of[y].is_none() { parent[x] = y; } }
let mut arrive: BTreeMap<usize, BTreeMap<String, u32>> = BTreeMap::new();
for (a, b) in &here {
let (x, y) = (root(&mut parent, *a), root(&mut parent, *b));
match (&name_of[x], &name_of[y]) {
(Some(nm), None) => *arrive.entry(y).or_default().entry(nm.clone()).or_default() += level,
(None, Some(nm)) => *arrive.entry(x).or_default().entry(nm.clone()).or_default() += level,
(Some(p), Some(q)) if p == q && x != y => parent[x] = y,
_ => {}
}
}
for (r, tally) in arrive {
match winner(&tally) {
Ok(Some(net)) => name_of[r] = Some(net),
Ok(None) => {}
Err(list) => {
conflicts.push(json!({"piece": r, "cause": "vias of equal strength lead to copper of different nets", "votes": tally}));
for k in 0..n { if root(&mut parent, k) == r && named[k].is_none() { conflict[k] = Some(list.clone()); } }
}
}
}
for k in 0..n { let r = root(&mut parent, k); if name_of[k].is_none() && name_of[r].is_some() { name_of[k] = name_of[r].clone(); } }
}
for k in 0..n {
if named[k].is_some() || conflict[k].is_some() { continue; }
if let Some(net) = &name_of[k] { named[k] = Some(Named { net: net.clone(), rung: rung.clone(), why: format!("joined through vias to {net} copper"), candidates: vec![] }); }
}
};
spread(&mut named, &mut conflict, &mut conflicts, 2, Rung::Via);
// rung 3: pads whose ring does not settle it. The name comes from the circuit, not from anything seen
let mut weak: Vec<BTreeMap<String, u32>> = vec![BTreeMap::new(); n];
for c in contacts.iter().filter(|c| c.tier == Tier::Weak) { if let Some(nt) = &c.net { *weak[c.piece].entry(nt.clone()).or_default() += c.weight; } }
for k in 0..n {
if named[k].is_some() || conflict[k].is_some() { continue; }
if let Ok(Some(net)) = winner(&weak[k]) { named[k] = Some(Named { why: format!("ring around a pad of {net} does not settle it"), net, rung: Rung::Inferred, candidates: vec![] }); }
}
spread(&mut named, &mut conflict, &mut conflicts, 3, Rung::Inferred);
// findings
let (mut pin_clashes, mut weak_disagree) = (vec![], vec![]);
for c in contacts {
let (Some(nt), Some(nm)) = (&c.net, &named[c.piece]) else { continue };
if *nt != nm.net { let e = json!({"pin": c.pin, "inferred": nt, "copper": nm.net, "piece": c.piece, "kind": c.kind}); if c.tier == Tier::Solid { pin_clashes.push(e); } else { weak_disagree.push(e); } }
}
let mut via_clashes = vec![];
for (a, b, t, c) in links.iter().filter(|l| l.2 <= 3) {
if let (Some(x), Some(y)) = (&named[*a], &named[*b]) { if x.net != y.net {
let e = &pairs[&(*a, *b)];
via_clashes.push(json!({"nets": [x.net, y.net], "pieces": [a, b], "tier": t, "vias": c, "ids": e.2}));
} }
}
// what is left: groups joined by any valid via, the rest isolated. Largest first, so ids are stable
let mut parent: Vec<usize> = (0..n).collect();
fn find(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }
let free = |k: usize| named[k].is_none() && conflict[k].is_none();
let mut hint: Vec<BTreeSet<String>> = vec![BTreeSet::new(); n];
for (a, b, _, _) in &links {
if free(*a) && free(*b) { let (x, y) = (find(&mut parent, *a), find(&mut parent, *b)); if x != y { parent[x] = y; } }
// copper with a valid via to named or disputed copper is not isolated, whatever that link proves
for (x, y) in [(*a, *b), (*b, *a)] {
if !free(x) { continue; }
if let Some(m) = &named[y] { hint[x].insert(m.net.clone()); }
if let Some(l) = &conflict[y] { hint[x].extend(l.iter().cloned()); }
}
}
let mut members: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
for k in 0..n { if free(k) { let r = find(&mut parent, k); members.entry(r).or_default().push(k); } }
let mut sets: Vec<Vec<usize>> = members.into_values().collect();
let size = |s: &Vec<usize>| s.iter().map(|k| area[*k]).sum::<f64>();
sets.sort_by(|a, b| size(b).partial_cmp(&size(a)).unwrap().then(a[0].cmp(&b[0])));
let (mut g, mut iso) = (0, 0);
for s in &sets {
let mut cand: BTreeSet<String> = BTreeSet::new(); for k in s { cand.extend(hint[*k].iter().cloned()); }
let linked = s.len() > 1 || !cand.is_empty();
let (net, rung, why) = if linked { g += 1; (format!("UNK_G{g}"), Rung::Group, format!("{} piece(s) joined by valid vias; no pad names them", s.len())) } else { iso += 1; (format!("UNK_ISO{iso}"), Rung::None, "no pad and no valid via".to_string()) };
for k in s { named[*k] = Some(Named { net: net.clone(), rung: rung.clone(), why: why.clone(), candidates: cand.iter().cloned().collect() }); }
}
let mut c = 0;
let out: Vec<Named> = (0..n).map(|k| { if let Some(list) = &conflict[k] { c += 1; Named { net: format!("UNK_CONFLICT{c}"), rung: Rung::Conflict, why: "two nets meet here; no name forced".into(), candidates: list.clone() } } else { named[k].clone().unwrap() } }).collect();
let links_out = pairs.iter().map(|((a, b), e)| json!({"pieces": [a, b], "tier": tier(*a, *b, e.0, e.1), "vias": e.0, "bothFaces": e.1, "ids": e.2})).collect();
Outcome { pieces: out, pin_clashes, weak_disagree, via_clashes, links: links_out, conflicts }
}
fn f(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }
/// What a flood from a pad or via reached, per conductor.
#[derive(Clone, Debug, PartialEq)]
pub struct Reached { pub node: usize, pub open: [u64; 4], pub arc: u32, pub depth: u64, pub touch: u32 }
fn reached(r: &Value, node_of: &dyn Fn(u32) -> Option<usize>) -> Vec<Reached> {
let mut m: BTreeMap<usize, Reached> = BTreeMap::new();
for q in r["reach"].as_array().into_iter().flatten() {
let Some(n) = node_of(q["piece"].as_u64().unwrap_or(0) as u32) else { continue };
let e = m.entry(n).or_insert(Reached { node: n, open: [0; 4], arc: 0, depth: 0, touch: 0 });
for (i, o) in e.open.iter_mut().enumerate() { *o += q["open"][i].as_u64().unwrap_or(0); }
e.arc |= q["arc"].as_u64().unwrap_or(0) as u32; e.depth = e.depth.max(q["depth"].as_u64().unwrap_or(0)); e.touch |= q["touch"].as_u64().unwrap_or(0) as u32;
}
let mut v: Vec<Reached> = m.into_values().collect();
v.sort_by(|a, b| b.open[0].cmp(&a.open[0]).then(a.node.cmp(&b.node)));
v
}
fn around_counts(r: &Value, node_of: &dyn Fn(u32) -> Option<usize>) -> Vec<(usize, u64)> {
let mut m: BTreeMap<usize, u64> = BTreeMap::new();
for q in r["around"].as_array().into_iter().flatten() { if let Some(n) = node_of(q.as_u64().unwrap_or(0) as u32) { *m.entry(n).or_default() += 1; } }
let mut v: Vec<(usize, u64)> = m.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v
}
/// Read one pad's edge on one face into what touches it. Returns the state and the contacts: conductor, tier, weight, kind.
pub fn pad_contacts(pad: &Value, side: &str, node_of: &dyn Fn(u32) -> Option<usize>, narrow: &dyn Fn(usize) -> bool, track: &dyn Fn(usize) -> bool) -> (&'static str, Vec<(usize, Tier, u32, &'static str)>) {
let r = &pad["faces"][side];
let (w, h, drill) = (f(pad, "w"), f(pad, "h"), f(pad, "drill"));
let (visible, gap_arc) = (r["visible"].as_u64().unwrap_or(0), r["gapArc"].as_u64().unwrap_or(0));
let cands = reached(r, node_of);
let mut out: Vec<(usize, Tier, u32, &'static str)> = vec![];
// a screw lug is bolted to its pour whatever the ring looks like; a big soldered pin (the XT30) is not bolted to anything
if drill > 0.0 && w * h >= 9.0 && r["hole"].as_f64().map(|v| v < LUG_OPEN_HOLE).unwrap_or(false) {
let around = around_counts(r, node_of);
if let Some(q) = around.iter().find(|q| !narrow(q.0)).or(around.first()) { out.push((q.0, Tier::Solid, 5, "lug")); }
return ("joined", out);
}
// Sectors with no gap and no lit edge between the metal and what lies beyond. The longest run of them tells a pad in a
// pour, open nearly all round, from a track through a clearance, open over a sector or two.
let open_mask = r["openMask"].as_u64().unwrap_or(0) as u32;
let closed_share = gap_arc as f64 / visible.max(1) as f64;
let run = longest_run(open_mask);
let through = |c: &Reached| (c.arc & open_mask).count_ones();
// a plated pin on the bottom photo shows its clearance as a valley and a bright arc; its hole is the thing to see through
let plated_bottom = drill > 0.0 && side == "bottom";
let (ridge, valley) = (f(r, "ridge"), f(r, "valley"));
let ring_says_isolated = plated_bottom && r["hole"].as_f64().map(|v| v < 0.6).unwrap_or(false) && (ridge >= RIDGE_ISOLATED || valley < THT_VALLEY_ISOLATED);
// a plated pin with its hole still dark shows its ring; one filled with solder hides it, and says nothing
let hole_dark = r["hole"].as_f64().map(|v| v < 0.6).unwrap_or(false);
let ring_says_joined = plated_bottom && hole_dark && ridge < RIDGE_ISOLATED && valley >= THT_VALLEY_JOINED;
let pour_ok = visible >= MIN_VISIBLE && if plated_bottom { ring_says_joined } else if drill > 0.0 { false } else { run >= POUR_RUN && closed_share <= POUR_CLOSED };
// copper spreading from the edge over many directions is a pour; over one or two it is a neck, or a leak through a gap
let mut pours: Vec<&Reached> = cands.iter().filter(|c| !narrow(c.node) && pour_ok && (plated_bottom || ((c.touch & open_mask).count_ones() >= POUR_TOUCH_ARC && through(c) >= POUR_ARC)) && c.open[1] >= POUR_REACH_PX).collect();
pours.sort_by(|a, b| through(b).cmp(&through(a)).then(b.open[1].cmp(&a.open[1])));
for (i, c) in pours.iter().enumerate() { out.push((c.node, if i == 0 { Tier::Solid } else { Tier::Weak }, 1, "pour")); }
for c in cands.iter().filter(|c| !narrow(c.node) && !pours.iter().any(|p| p.node == c.node) && visible >= MIN_VISIBLE && !ring_says_isolated && closed_share < ISOLATED_CLOSED && through(c) >= NECK_ARC && c.open[1] >= 10) { out.push((c.node, Tier::Weak, 1, "pour-neck")); }
// a track leaving: the only one, or clearly the main one, and followed well away from the metal
let fine = w.min(h) <= FINE_PAD_MM;
let tracks: Vec<&Reached> = cands.iter().filter(|c| (track(c.node) || fine) && c.open[0] >= TRACK_REACH_PX && c.depth >= TRACK_LEAVES_PX && (fine || visible < MIN_VISIBLE + 2 || through(c) >= 1)).collect();
if let Some(a) = tracks.first() {
let clear = tracks.len() == 1 || a.open[0] >= 2 * tracks[1].open[0];
if !out.iter().any(|o| o.0 == a.node) { out.push((a.node, if clear && pours.is_empty() { Tier::Solid } else { Tier::Weak }, 1, "track-end")); }
}
let state = if out.iter().any(|o| o.1 == Tier::Solid) { "joined" } else if !out.is_empty() { "unsettled" } else if visible < MIN_VISIBLE { "hidden" } else if ring_says_isolated || closed_share >= ISOLATED_CLOSED { "isolated" } else { "open" };
(state, out)
}
/// The longest cyclic run of set bits among sixteen.
fn longest_run(mask: u32) -> u32 { let m = mask & 0xffff; if m == 0xffff { return 16; } let (mut best, mut cur) = (0, 0); for i in 0..32 { if m >> (i % 16) & 1 == 1 { cur += 1; best = best.max(cur); } else { cur = 0; } } best.min(16) }
/// How a via sits on one face.
#[derive(Clone, Debug, PartialEq)]
pub enum Attach { Hidden, Ring, Nothing, Pour(usize), End(usize), Boundary(usize, usize) }
/// Which conductor a via sits in on one face: the one that surrounds it, or the track that ends on it. Near the boundary
/// of two, what can be reached from the via without crossing a gap decides, and otherwise it is reported and joins neither.
pub fn via_attach(r: &Value, node_of: &dyn Fn(u32) -> Option<usize>, narrow: &dyn Fn(usize) -> bool) -> Attach {
let visible = r["visible"].as_u64().unwrap_or(0);
if visible < MIN_VISIBLE { return Attach::Hidden; }
// a dome's highlight lights up like a copper edge, so around a via only a dark gap counts
let (gap_arc, cands, around) = (r["gapOnly"].as_u64().unwrap_or(0), reached(r, node_of), around_counts(r, node_of));
let v = visible as f64;
let track_end = || cands.iter().find(|c| narrow(c.node) && c.open[0] >= TRACK_REACH_PX).map(|c| Attach::End(c.node));
if gap_arc as f64 >= RING_SHARE * v { return track_end().unwrap_or(Attach::Ring); }
let (best, second) = (around.first().copied(), around.get(1).copied());
if let Some((b, nb)) = best {
let so = second.map(|s| s.1).unwrap_or(0) as f64;
if nb as f64 >= SURROUND_MAIN * v && so < SURROUND_OTHER * v { return if narrow(b) { Attach::End(b) } else { Attach::Pour(b) }; }
if so >= SURROUND_OTHER * v {
let s = second.unwrap().0;
let (rb, rs) = (cands.iter().any(|c| c.node == b), cands.iter().any(|c| c.node == s));
return match (rb, rs) { (true, false) => if narrow(b) { Attach::End(b) } else { Attach::Pour(b) }, (false, true) => if narrow(s) { Attach::End(s) } else { Attach::Pour(s) }, _ => Attach::Boundary(b, s) };
}
}
if let Some(e) = track_end() { return e; }
if let Some(c) = cands.iter().find(|c| !narrow(c.node) && c.arc.count_ones() >= NECK_ARC) { return Attach::Pour(c.node); }
Attach::Nothing
}
fn root(p: &mut [usize], x: usize) -> usize { let mut r = x; while p[r] != r { r = p[r]; } let mut y = x; while p[y] != r { let t = p[y]; p[y] = r; y = t; } r }
/// Where a drawn track ends: which face and conductor, and a point a little way back along it.
struct TrackEnd { side: &'static str, x: f64, y: f64, c: usize, from: (f64, f64) }
/// A fine-pitch pad's track arrives along the pad's long side, within 50 degrees.
fn along_pad(p: &Pad, e: &TrackEnd) -> bool {
let (c, s) = (p.rot.to_radians().cos(), p.rot.to_radians().sin());
let (dx, dy) = (e.x - e.from.0, e.y - e.from.1); let l = (dx * dx + dy * dy).sqrt().max(1e-6);
let (lx, ly) = ((dx * c - dy * s) / l, (dx * s + dy * c) / l);
let along = if p.w >= p.h { lx } else { ly };
along.abs() >= 50f64.to_radians().cos()
}
/// A via list: `{"vias": [...]}` or a bare array. Each entry has `pos_mm`, optionally `id`, `valid`, `seen`.
pub fn read_via_list(v: &Value) -> Vec<Value> { v["vias"].as_array().or(v.as_array()).cloned().unwrap_or_default() }
fn votes_at(map: &[u32], w: usize, h: usize, x: f64, y: f64, r: f64) -> Vec<(u32, u32)> {
let (cu, cv) = board::to_px(x, y); let rr = (r * board::RES) as i64; let mut m: HashMap<u32, u32> = HashMap::new();
for dv in -rr..=rr { for du in -rr..=rr { if du * du + dv * dv > rr * rr { continue; }
let (a, b) = (cu as i64 + du, cv as i64 + dv); if a < 0 || b < 0 || a >= w as i64 || b >= h as i64 { continue; }
let l = map[b as usize * w + a as usize]; if l != 0 { *m.entry(l).or_default() += 1; } } }
let mut v: Vec<(u32, u32)> = m.into_iter().collect(); v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); v
}
/// The pin map as inferred, whatever earlier runs of attribution moved: a net made from copper has no inferred pins.
fn inferred_pins(n: &Value) -> Vec<String> {
let key = if n["inferredPins"].is_array() { "inferredPins" } else { "pins" };
if n["fromCopper"].as_bool().unwrap_or(false) { return vec![]; }
n[key].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect()
}
/// The words attribution itself puts in `nameSource` when a net states none.
const NAME_CLASSES: [&str; 4] = ["copper", "silkscreen", "datasheet", "circuit"];
/// Where a net's name came from. What whoever named the net stated stays as stated: a citation is not ours to replace with a word.
/// Only a net that states nothing, or holds a class word from an earlier run, gets a class, read from its `why` and `source`.
fn name_source(n: &Value) -> String {
if let Some(s) = n["nameSource"].as_str().filter(|s| !s.is_empty() && !NAME_CLASSES.contains(s)) { return s.to_string(); }
if n["fromCopper"].as_bool().unwrap_or(false) { return "copper".into(); }
let t = format!("{} {}", n["why"].as_str().unwrap_or(""), n["source"].as_str().unwrap_or("")).to_lowercase();
(if t.contains("silk") { "silkscreen" } else if t.contains("datasheet") || t.contains("drv8323") || t.contains("typical application") { "datasheet" } else { "circuit" }).into()
}
/// The nets that go on, each with its pins, and the names of those the copper emptied. A net is carried whole as it was read, so `source`
/// and every other field attribution does not own goes through; a net an earlier run made from copper is carried under its name the same
/// way, not rebuilt blank. When the copper moves every pin of one net onto another, the record of the net whose name survives is the one kept.
fn nets_carried(old: &[Value], by_name: &BTreeMap<String, Vec<String>>) -> (Vec<(Value, Vec<String>)>, Vec<String>) {
let mut proto: Vec<Value> = old.iter().filter(|n| !n["fromCopper"].as_bool().unwrap_or(false)).cloned().collect();
for name in by_name.keys() { if !proto.iter().any(|n| n["name"].as_str() == Some(name)) {
proto.push(old.iter().find(|n| n["name"].as_str() == Some(name)).cloned().unwrap_or_else(|| json!({"name": name, "fromCopper": true, "tag": "INFERRED", "why": "no name in the pin map: pins joined by copper", "tests": ["T-pour-nets"]})));
} }
let (mut out, mut emptied) = (vec![], vec![]);
for n in proto { let name = n["name"].as_str().unwrap_or("").to_string(); match by_name.get(&name) { Some(p) if !p.is_empty() => out.push((n, p.clone())), _ => emptied.push(name) } }
(out, emptied)
}
/// Write on a net what attribution owns, and nothing else: its pins, the pin map's own claim, the tag the copper supports, the evidence
/// file when every pin is seen, the copper counts, and `nameSource` by `name_source`. Returns that name source.
fn stamp_net(n: &mut Value, pins: &[String], inferred: &[String], seen_all: bool, proven: usize, clash: usize, groups: &BTreeMap<usize, Vec<String>>) -> String {
let ns = name_source(n);
let from_copper = n["fromCopper"].as_bool().unwrap_or(false);
// what whoever wrote the net said of it, kept across runs: attribution writes `tag` and would otherwise forget it
let stated = n["statedTag"].as_str().map(str::to_string).unwrap_or_else(|| n["tag"].as_str().unwrap_or("").to_string());
if !from_copper && n["statedTag"].is_null() && !stated.is_empty() { n["statedTag"] = json!(stated); }
// the copper contradicts the net when a pin of it sits on copper named for another net, or when the copper moved a pin the map had off it
let moved: Vec<&String> = inferred.iter().filter(|p| !pins.contains(p)).collect();
let contradicted = clash > 0 || !moved.is_empty();
n["pins"] = json!(pins);
if !from_copper { n["inferredPins"] = json!(inferred); }
// a net named from a document keeps that tag and its source while the copper does not contradict it: not every pin being seen joined is no
// reason to call the document an inference. A contradiction is a finding with a test, never a silent retag
let documented = stated == "DATASHEET" && !n["source"].as_str().unwrap_or("").trim().is_empty();
n["tag"] = json!(if seen_all { "MEASURED" } else if documented && !contradicted { "DATASHEET" } else { "INFERRED" });
if seen_all { n["evidence"] = json!(["net-evidence.json"]); }
let name = n["name"].as_str().unwrap_or("").to_string();
if contradicted {
n["contradiction"] = json!({"pinClashes": clash, "pinsTheCopperMoved": moved, "stated": stated,
"finding": format!("the copper read from the photos disagrees with this net as {}: {clash} pin(s) sit on copper named for another net, {} pin(s) were moved off it. Either the net is wrong or an island was misread: the test settles which", if documented { "its document names it" } else { "it was stated" }, moved.len())});
} else if let Some(o) = n.as_object_mut() { o.remove("contradiction"); }
// an inferred net always names the test that would settle it, so the nets check cannot fail for the want of one
if n["tag"] == json!("INFERRED") && n["tests"].as_array().map(|a| a.is_empty()).unwrap_or(true) { n["tests"] = json!([format!("T-net-{name}")]); }
n["nameSource"] = json!(ns);
n["copperEvidence"] = json!({"pinsSeen": proven, "pins": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len()});
n["copperGroups"] = json!(groups.values().collect::<Vec<_>>());
ns
}
/// Stage two. Reads detect/attrib/, copper.json, nets.json and optionally a via list; writes copper.json and nets.json
/// (nets, tags and evidence only), net-evidence.json, detect/attribution.json and detect/connect-graph.json. Reads no photo.
pub fn attribute(flow: &Path, via_file: Option<&Path>) -> Result<Value, String> {
let rd = |p: &Path| -> Result<Value, String> { crate::items::read_json(p) };
let dir = flow.join("detect/attrib");
let ms = rd(&dir.join("measure.json")).map_err(|e| format!("{e}: run `connect measure` first"))?;
if ms["stitch"].is_null() { return Err("measure.json is from an older version: run `connect measure` again".into()); }
let copper_now = zones_key(&rd(&flow.join("copper.json"))?);
if ms["copperSha"].as_str().is_some_and(|s| s != copper_now) { return Err("copper.json has other zones or tracks than the ones `connect measure` saw: run `connect measure` again, since island ids come from the same trace".into()); }
let mut copper = rd(&flow.join("copper.json"))?;
let mut nets_doc = rd(&flow.join("nets.json"))?;
let mut maps_p = HashMap::new(); let (mut w, mut h) = (0, 0);
// a face no photo shows (`notread::missing`) has no pieces: its map is empty, and everything below finds nothing there
for side in ["top", "bottom"] { if !flow.join(format!("board/{side}.jpg")).is_file() { continue; } let (m, a, b) = read_rle(&dir.join(format!("pieces-{side}.rle")))?; w = a; h = b; maps_p.insert(side.to_string(), m); }
if maps_p.is_empty() { return Err("no board/top.jpg or board/bottom.jpg: `scale` writes them".into()); }
for side in ["top", "bottom"] { maps_p.entry(side.to_string()).or_insert_with(|| vec![0; w * h]); }
// pieces: global index by (side, id)
let ps = ms["pieces"].as_array().ok_or("measure.json has no pieces")?;
let n_p = ps.len();
let mut index: HashMap<(String, u32), usize> = HashMap::new();
let (mut narrow_p, mut area, mut half_p, mut copperlike) = (vec![], vec![], vec![], vec![]);
let mut by_island: HashMap<(String, u32), Vec<usize>> = HashMap::new();
for (k, p) in ps.iter().enumerate() {
let (side, id, isl) = (p["side"].as_str().unwrap_or("").to_string(), p["id"].as_u64().unwrap_or(0) as u32, p["island"].as_u64().unwrap_or(0) as u32);
index.insert((side.clone(), id), k);
let half = f(p, "half");
narrow_p.push((half as f32) < NARROW_HALF_PX); half_p.push(half);
{ let thin = (half as f32) < NARROW_HALF_PX; copperlike.push(f(p, "pale") < STITCH_PALE && f(p, "value") >= if thin { STITCH_VALUE_TRACK } else { STITCH_VALUE } && f(p, "chroma") >= if thin { STITCH_CHROMA_TRACK } else { STITCH_CHROMA }); }
area.push(f(p, "area") / (board::RES * board::RES));
by_island.entry((side, isl)).or_default().push(k);
}
let mut pin_net: HashMap<String, String> = HashMap::new();
for n in nets_doc["nets"].as_array().into_iter().flatten() { for q in inferred_pins(n) { pin_net.insert(q, n["name"].as_str().unwrap_or("").to_string()); } }
// each piece's extent on the board
let mut boxes: Vec<(usize, usize, usize, usize)> = vec![(usize::MAX, usize::MAX, 0, 0); n_p];
for side in ["top", "bottom"] { let m = &maps_p[side]; for i in 0..w * h { if m[i] != 0 { if let Some(k) = index.get(&(side.to_string(), m[i])) { let b = &mut boxes[*k]; let (x, y) = (i % w, i / w); b.0 = b.0.min(x); b.1 = b.1.min(y); b.2 = b.2.max(x); b.3 = b.3.max(y); } } } }
// 1. stitch: pieces with nothing but a reader artefact between them are one conductor, and the evidence is kept
let mut parent: Vec<usize> = (0..n_p).collect();
let mut stitches = vec![];
let mut refused = vec![];
for row in ms["stitch"].as_array().into_iter().flatten() {
let side = row["side"].as_str().unwrap_or("").to_string();
let (Some(&a), Some(&b)) = (index.get(&(side.clone(), row["a"].as_u64().unwrap_or(0) as u32)), index.get(&(side.clone(), row["b"].as_u64().unwrap_or(0) as u32))) else { continue };
let (open, track_like) = (|i: usize| row["open"][i].as_u64().unwrap_or(0), narrow_p[a] || narrow_p[b]);
let meets = if track_like { open(TRACK_LEVEL) >= TRACK_OPEN_PX } else { open(POUR_LEVEL) >= POUR_OPEN_PX };
if !meets { continue; }
if !(copperlike[a] && copperlike[b]) { refused.push(json!({"pieces": [a, b], "side": side, "at": row["at"], "why": "one side does not look like copper"})); continue; }
let m = &row["onMeeting"];
let (pa, di, co, cut) = (m["pale"].as_u64().unwrap_or(0), m["dim"].as_u64().unwrap_or(0), m["copper"].as_u64().unwrap_or(0), m["readerCut"].as_u64().unwrap_or(0));
let kind = if cut * 2 > pa + di + co { "reader cut, no gap in the photo" } else if pa >= di && pa >= co { "glint or dome highlight" } else if di >= co { "dome shadow or grain" } else { "no gap in the photo" };
stitches.push(json!({"pieces": [a, b], "side": side, "at": row["at"], "kind": kind, "open": row["open"]}));
let (x, y) = (root(&mut parent, a), root(&mut parent, b)); if x != y { parent[x] = y; }
}
for row in ms["silk"].as_array().into_iter().flatten() {
let side = row["side"].as_str().unwrap_or("").to_string();
let (Some(&a), Some(&b)) = (index.get(&(side.clone(), row["a"].as_u64().unwrap_or(0) as u32)), index.get(&(side.clone(), row["b"].as_u64().unwrap_or(0) as u32))) else { continue };
let long_enough = row["contact"].as_u64().unwrap_or(0) >= UNDER_SILK_CONTACT_PX as u64;
if !long_enough || !copperlike[a] || !copperlike[b] { continue; }
if f(row, "gapBeside") > SILK_GAP_BESIDE { refused.push(json!({"pieces": [a, b], "side": side, "at": row["at"], "why": "a gap line runs beside the silk"})); continue; }
stitches.push(json!({"pieces": [a, b], "side": side, "at": row["at"], "kind": "silk crossing", "contactPx": row["contact"]}));
let (x, y) = (root(&mut parent, a), root(&mut parent, b)); if x != y { parent[x] = y; }
}
// conductors, numbered in piece order
let mut remap: HashMap<usize, usize> = HashMap::new();
let cond: Vec<usize> = (0..n_p).map(|k| { let r = root(&mut parent, k); let n = remap.len(); *remap.entry(r).or_insert(n) }).collect();
let n_c = remap.len();
let (mut c_half, mut c_area, mut c_box) = (vec![0f64; n_c], vec![0f64; n_c], vec![(usize::MAX, usize::MAX, 0usize, 0usize); n_c]);
for k in 0..n_p { let c = cond[k]; c_half[c] = c_half[c].max(half_p[k]); c_area[c] += area[k]; let (a, b) = (boxes[k], &mut c_box[c]); b.0 = b.0.min(a.0); b.1 = b.1.min(a.1); b.2 = b.2.max(a.2); b.3 = b.3.max(a.3); }
let c_narrow: Vec<bool> = c_half.iter().map(|v| (*v as f32) < NARROW_HALF_PX).collect();
let c_track: Vec<bool> = (0..n_c).map(|c| { let px = c_area[c] * board::RES * board::RES; let half = c_half[c]; (half as f32) <= TRACK_HALF_PX && px >= TRACK_MIN_PX && px / (4.0 * half * half).max(1.0) >= TRACK_ELONGATION }).collect();
let cond_at = |side: &str, id: u32| -> Option<usize> { index.get(&(side.to_string(), id)).map(|k| cond[*k]) };
// where each drawn track ends, by conductor: following a track means going to its end
let mut track_ends: Vec<TrackEnd> = vec![];
for t in copper["traces"].as_array().into_iter().flatten() {
let id = t["id"].as_str().unwrap_or("");
if id.contains("stub") { continue; }
let side = if t["layer"].as_str() == Some("F.Cu") { "top" } else { "bottom" };
let path: 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();
if path.len() < 2 { continue; }
let mut votes: HashMap<u32, u32> = HashMap::new();
for p in &path { for (l, c) in votes_at(&maps_p[side], w, h, p.0, p.1, 0.1) { *votes.entry(l).or_default() += c; } }
let Some(c) = votes.into_iter().max_by_key(|x| (x.1, x.0)).and_then(|x| cond_at(side, x.0)) else { continue };
if !c_narrow[c] { continue; }
for rev in [false, true] {
let q: Vec<(f64, f64)> = if rev { path.iter().rev().copied().collect() } else { path.clone() };
// the direction the track arrives in, from a little way back
let (mut back, mut run) = (q[q.len() - 1], 0.0);
for k in (0..q.len() - 1).rev() { run += crate::geom::dist(q[k], q[k + 1]); back = q[k]; if run >= TRACK_END_BACK_MM { break; } }
track_ends.push(TrackEnd { side, x: q[q.len() - 1].0, y: q[q.len() - 1].1, c, from: back });
}
}
// 2. pads: what touches each pad, read at its own edge on the face it shows on
// a face whose copper failed `check copper` gives no join: no pad and no via takes a conductor from it, so no net is claimed through it (`notread`)
let not_read = crate::notread::faces(flow);
let mut contacts: Vec<Contact> = vec![]; let mut pad_states = vec![];
for pad in ms["pads"].as_array().into_iter().flatten() {
let pin = format!("{}.{}", pad["part"].as_str().unwrap_or(""), pad["num"].as_str().unwrap_or(""));
if pad["num"].as_str().unwrap_or("").is_empty() { continue; }
let net = pin_net.get(&pin).cloned();
for side in ["top", "bottom"] {
if pad["faces"][side].is_null() || crate::notread::skips(¬_read, side) { continue; }
let node = |id: u32| cond_at(side, id);
let nar = |c: usize| c_narrow[c];
// a track that leads somewhere from this pad, not one hugging it
let (px, py, half) = (f(pad, "x"), f(pad, "y"), f(pad, "w").max(f(pad, "h")) / 2.0);
let trk = |c: usize| { let b = c_box[c]; let (a, d) = (board::to_mm(b.0 as f64, b.1 as f64), board::to_mm(b.2 as f64, b.3 as f64)); let reach = (a.0 - px).abs().max((d.0 - px).abs()).max((a.1 - py).abs()).max((d.1 - py).abs()); c_track[c] && reach - half >= TRACK_REACH_MM };
let (mut state, mut cs) = pad_contacts(pad, side, &node, &nar, &trk);
// a track drawn to this pad's edge from outside it: the copper reader saw it arrive
if !cs.iter().any(|c| c.1 == Tier::Solid) {
let core = &pad["core"];
let shape = Pad { part: String::new(), num: String::new(), x: f(core, "x"), y: f(core, "y"), w: f(core, "w"), h: f(core, "h"), rot: f(core, "rot"), round: false, drill: 0.0, layers: vec![], net: String::new() };
let fine = shape.w.min(shape.h) <= FINE_PAD_MM;
let grow = if fine { TRACK_END_FINE_MM } else { TRACK_END_PAD_MM };
let mut hit: BTreeSet<usize> = BTreeSet::new();
for e in track_ends.iter().filter(|e| e.side == side && conduct::pad_inside(&shape, e.x, e.y, grow)) {
// it must come from outside: a strip running along a chip's edge passes its pins side on
if conduct::pad_inside(&shape, e.from.0, e.from.1, grow) { continue; }
if fine && !along_pad(&shape, e) { continue; }
hit.insert(e.c);
}
let tier = if hit.len() == 1 { Tier::Solid } else { Tier::Weak };
for c in hit { if !cs.iter().any(|q| q.0 == c) { cs.push((c, tier, 1, "track-end")); } else if tier == Tier::Solid { for q in cs.iter_mut() { if q.0 == c { q.1 = Tier::Solid; q.3 = "track-end"; } } } }
if cs.iter().any(|c| c.1 == Tier::Solid) { state = "joined"; } else if !cs.is_empty() && state != "isolated" { state = "unsettled"; }
}
let mut list = vec![];
for (c, tier, weight, kind) in cs {
// which end of a resistor or capacitor is pin 1 cannot be seen, so its pins say least about a net
let weight = if kind == "lug" { LUG_WEIGHT } else if symmetric(pad["part"].as_str().unwrap_or("")) { 1 } else { weight * KNOWN_PIN_WEIGHT };
contacts.push(Contact { pin: pin.clone(), net: net.clone(), piece: c, tier, weight, kind, side });
list.push(json!({"conductor": c, "tier": tier as u8, "kind": kind}));
}
pad_states.push(json!({"pin": pin, "side": side, "net": net, "state": state, "visible": pad["faces"][side]["visible"], "gapArc": pad["faces"][side]["gapArc"], "contacts": list, "x": pad["x"], "y": pad["y"]}));
}
}
// 3. vias: the list given, or copper.json's own. Each joins what surrounds it on each face. A via through a pad joins
// that pad: the barrel passes through the pad's copper, and the board could not be made otherwise
let copper_vias = copper["vias"].as_array().cloned().unwrap_or_default();
let (list, list_name): (Vec<Value>, String) = match via_file {
Some(p) => (read_via_list(&rd(p)?), p.display().to_string()),
None => (copper_vias.clone(), "copper.json".to_string()),
};
let domes = ms["domes"].as_array().cloned().unwrap_or_default();
let dome_at = |x: f64, y: f64| -> Option<&Value> { domes.iter().map(|d| (d, ((d["pos_mm"][0].as_f64().unwrap_or(0.0) - x).powi(2) + (d["pos_mm"][1].as_f64().unwrap_or(0.0) - y).powi(2)).sqrt())).filter(|q| q.1 < 0.1).min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()).map(|q| q.0) };
let pad_shapes: Vec<(Pad, String)> = ms["pads"].as_array().into_iter().flatten().filter(|p| !p["num"].as_str().unwrap_or("").is_empty() && f(p, "drill") <= 0.0).map(|p| (Pad { part: p["part"].as_str().unwrap_or("").into(), num: p["num"].as_str().unwrap_or("").into(), x: f(&p["core"], "x"), y: f(&p["core"], "y"), w: f(&p["core"], "w"), h: f(&p["core"], "h"), rot: f(&p["core"], "rot"), round: false, drill: 0.0, layers: vec![], net: String::new() }, if !p["faces"]["top"].is_null() { "top".to_string() } else { "bottom".to_string() })).collect();
let mut gvias: Vec<GVia> = vec![]; let mut via_meta: HashMap<String, Value> = HashMap::new(); let mut via_pos: Vec<(f64, f64, String, Value)> = vec![];
let mut boundary = vec![]; let mut in_pad = vec![];
let mut through: BTreeMap<(String, usize), BTreeMap<usize, u32>> = BTreeMap::new();
for (k, v) in list.iter().enumerate() {
let (x, y) = (v["pos_mm"][0].as_f64().unwrap_or(0.0), v["pos_mm"][1].as_f64().unwrap_or(0.0));
let id = v["id"].as_str().map(str::to_string).unwrap_or(format!("L{k}"));
let seen = v["seen"].as_str().unwrap_or("");
// the kept list has been checked by eye on samples: a list's own verdict is taken, and a via it keeps is real
let valid = v["valid"].as_bool().unwrap_or(true);
let d = dome_at(x, y);
let mut att = [Attach::Nothing, Attach::Nothing];
let mut attach = [None, None]; let mut ends = [false, false];
for (fi, side) in ["top", "bottom"].iter().enumerate() {
let Some(d) = d else { att[fi] = Attach::Hidden; continue };
if crate::notread::skips(¬_read, side) { att[fi] = Attach::Hidden; continue; }
let node = |id: u32| cond_at(side, id);
let c_nar = |c: usize| c_track[c] || c_area[c] < 0.5;
att[fi] = via_attach(&d["faces"][*side], &node, &c_nar);
// a ring, or nothing readable, with a track drawn to this via: the via is that track's end
if !matches!(att[fi], Attach::Pour(_) | Attach::End(_)) {
let mut near: Vec<(f64, usize)> = track_ends.iter().filter(|e| e.side == *side).map(|e| (crate::geom::dist((e.x, e.y), (x, y)), e.c)).filter(|q| q.0 <= VIA_END_MM).collect();
near.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let distinct: BTreeSet<usize> = near.iter().map(|q| q.1).collect();
if distinct.len() == 1 { att[fi] = Attach::End(near[0].1); }
}
match att[fi] { Attach::Pour(c) => attach[fi] = Some(c), Attach::End(c) => { attach[fi] = Some(c); ends[fi] = true; } Attach::Boundary(a, b) => boundary.push(json!({"via": id, "side": side, "at": [r3(x), r3(y)], "conductors": [a, b]})), _ => {} }
}
// through a pad on one face: the pad's pin joins what the via reaches on the other
if valid { for (p, face) in &pad_shapes {
if !conduct::pad_inside(p, x, y, -VIA_IN_PAD_MARGIN_MM) { continue; }
let other = if face == "top" { 1 } else { 0 };
if let Some(c) = attach[other] { *through.entry((format!("{}.{}", p.part, p.num), other)).or_default().entry(c).or_default() += 1; in_pad.push(json!({"via": id, "pin": format!("{}.{}", p.part, p.num), "at": [r3(x), r3(y)]})); }
} }
let name = |a: &Attach| match a { Attach::Hidden => "hidden", Attach::Ring => "ring", Attach::Nothing => "nothing", Attach::Pour(_) => "pour", Attach::End(_) => "track end", Attach::Boundary(..) => "boundary" };
via_meta.insert(id.clone(), json!({"valid": valid, "topState": name(&att[0]), "bottomState": name(&att[1]), "seen": seen, "topConductor": attach[0], "bottomConductor": attach[1]}));
via_pos.push((x, y, id.clone(), v.clone()));
gvias.push(GVia { id, top: attach[0], bottom: attach[1], valid, both: seen == "both-faces" || (ends[0] && ends[1]) });
}
// Vias through a pad: one under a part's body may only pass beneath it, so a pad takes a conductor from at least two, clearly
for ((pin, other), by) in &through {
let mut v: Vec<(usize, u32)> = by.iter().map(|q| (*q.0, *q.1)).collect(); v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
let solid = v[0].1 >= 2 && v[0].1 >= 3 * v.get(1).map(|q| q.1).unwrap_or(0);
for (i, q) in v.iter().enumerate() { contacts.push(Contact { net: pin_net.get(pin).cloned(), pin: pin.clone(), piece: q.0, tier: if solid && i == 0 { Tier::Solid } else { Tier::Weak }, weight: 1, kind: "via-in-pad", side: if *other == 0 { "top" } else { "bottom" } }); }
}
// Several fine-pitch pins of one part on one conductor: at 0.5 mm pitch the photo does not separate their tracks where they
// leave, so this says the reader could not resolve them, not that they are tied. Their contacts stay unsettled.
let mut fine_shared = 0;
{ let fine_pin: BTreeSet<String> = ms["pads"].as_array().into_iter().flatten().filter(|p| f(p, "w").min(f(p, "h")) <= FINE_PAD_MM && f(p, "drill") <= 0.0).map(|p| format!("{}.{}", p["part"].as_str().unwrap_or(""), p["num"].as_str().unwrap_or(""))).collect();
let mut on: HashMap<(String, usize), BTreeSet<String>> = HashMap::new();
for c in contacts.iter().filter(|c| c.kind == "track-end" && c.tier == Tier::Solid && fine_pin.contains(&c.pin)) { on.entry((c.pin.split('.').next().unwrap_or("").to_string(), c.piece)).or_default().insert(c.pin.clone()); }
for c in contacts.iter_mut() { if c.kind == "track-end" && c.tier == Tier::Solid { let k = (c.pin.split('.').next().unwrap_or("").to_string(), c.piece); if on.get(&k).map(|v| v.len() >= 2).unwrap_or(false) { c.tier = Tier::Weak; fine_shared += 1; } } } }
// Both ends of a two-pin part on one conductor would short the part out: what touches both is a sliver beside it or a
// leak, and is no evidence for either pin.
let mut self_short = vec![];
{
let mut seen: HashMap<(String, usize), BTreeSet<String>> = HashMap::new();
for c in &contacts { if symmetric(c.pin.split('.').next().unwrap_or("")) { seen.entry((c.pin.split('.').next().unwrap_or("").to_string(), c.piece)).or_default().insert(c.pin.clone()); } }
let bad: BTreeSet<(String, usize)> = seen.into_iter().filter(|(_, v)| v.len() > 1).map(|(k, _)| k).collect();
for (part, k) in &bad { self_short.push(json!({"part": part, "conductor": k})); }
contacts.retain(|c| !bad.contains(&(c.pin.split('.').next().unwrap_or("").to_string(), c.piece)));
}
// one pin speaks once per conductor, however many faces, pads or vias show it, by its strongest contact
{ let mut best: HashMap<(String, usize), usize> = HashMap::new(); let mut keep = vec![true; contacts.len()];
for (i, c) in contacts.iter().enumerate() { let k = (c.pin.clone(), c.piece); match best.get(&k) { Some(&j) if (contacts[j].tier as u8, std::cmp::Reverse(contacts[j].weight)) <= (c.tier as u8, std::cmp::Reverse(c.weight)) => keep[i] = false, Some(&j) => { keep[j] = false; best.insert(k, i); } None => { best.insert(k, i); } } }
let mut it = keep.iter(); contacts.retain(|_| *it.next().unwrap()); }
// a plated pin touching copper on both faces, or a pad's number appearing twice, is one piece of metal
let mut node_parent: Vec<usize> = (0..n_c).collect();
{ let mut by_pin: HashMap<String, Vec<usize>> = HashMap::new();
for c in contacts.iter().filter(|c| c.tier == Tier::Solid) { by_pin.entry(c.pin.clone()).or_default().push(c.piece); }
for v in by_pin.values() { for q in v.windows(2) { let (x, y) = (root(&mut node_parent, q[0]), root(&mut node_parent, q[1])); if x != y { node_parent[x] = y; } } } }
let mut remap2: HashMap<usize, usize> = HashMap::new();
let node_c: Vec<usize> = (0..n_c).map(|c| { let r = root(&mut node_parent, c); let n = remap2.len(); *remap2.entry(r).or_insert(n) }).collect();
let n_n = remap2.len();
let node_p: Vec<usize> = cond.iter().map(|c| node_c[*c]).collect();
for c in contacts.iter_mut() { c.piece = node_c[c.piece]; }
{ let mut seen: BTreeSet<(String, usize, u8)> = BTreeSet::new(); contacts.sort_by_key(|c| (c.tier as u8, std::cmp::Reverse(c.weight))); contacts.retain(|c| seen.insert((c.pin.clone(), c.piece, c.tier as u8))); }
for v in gvias.iter_mut() { v.top = v.top.map(|c| node_c[c]); v.bottom = v.bottom.map(|c| node_c[c]); }
for m in via_meta.values_mut() { m["top"] = json!(m["topConductor"].as_u64().map(|c| node_c[c as usize])); m["bottom"] = json!(m["bottomConductor"].as_u64().map(|c| node_c[c as usize])); }
let (mut n_area, mut n_track) = (vec![0f64; n_n], vec![true; n_n]);
for c in 0..n_c { n_area[node_c[c]] += c_area[c]; if !c_track[c] { n_track[node_c[c]] = false; } }
let centre_n: Vec<(f64, f64)> = { let mut b = vec![(usize::MAX, usize::MAX, 0usize, 0usize); n_n]; for c in 0..n_c { let a = c_box[c]; let e = &mut b[node_c[c]]; e.0 = e.0.min(a.0); e.1 = e.1.min(a.1); e.2 = e.2.max(a.2); e.3 = e.3.max(a.3); } b.iter().map(|e| board::to_mm((e.0 + e.2) as f64 / 2.0, (e.1 + e.3) as f64 / 2.0)).collect() };
// 4. names, last
let outcome = propagate(&n_track, &n_area, &contacts, &gvias);
// a conductor of two or more pins that no name reached is a net of its own: the copper shows it
let mut new_names: HashMap<usize, String> = HashMap::new();
{ let mut on: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
for c in contacts.iter().filter(|c| c.tier == Tier::Solid && c.net.is_none()) { on.entry(c.piece).or_default().insert(c.pin.clone()); }
// a pin a datasheet names gives its name to the copper it sits on, alone on it or not (`dspins`); the join is the copper's either way
let ds = crate::dspins::Names::load(flow);
for (n, pins) in on { if !matches!(outcome.pieces[n].rung, Rung::Group | Rung::None) { continue; }
if let Some(name) = ds.name_for(&pins) { new_names.insert(n, name); } else if pins.len() >= 2 { new_names.insert(n, format!("CU_{}", pins.iter().next().unwrap().replace('.', "_"))); } } }
let mut named_nodes = outcome.pieces.clone();
for (n, name) in &new_names { named_nodes[*n] = Named { net: name.clone(), rung: Rung::Copper, why: "pins with no name in the pin map share this copper".into(), candidates: vec![] }; }
// groups: what is joined as far as the photos show. A shared name joins nothing
let mut gp: Vec<usize> = (0..n_n).collect();
for l in &outcome.links { let (a, b) = (l["pieces"][0].as_u64().unwrap() as usize, l["pieces"][1].as_u64().unwrap() as usize); if l["tier"].as_u64().unwrap_or(9) <= 3 && named_nodes[a].net == named_nodes[b].net { let (x, y) = (root(&mut gp, a), root(&mut gp, b)); if x != y { gp[x] = y; } } }
let group_of: Vec<usize> = (0..n_n).map(|k| root(&mut gp, k)).collect();
// write back: nets and tags only, never geometry
let mut tally: BTreeMap<&'static str, [f64; 6]> = BTreeMap::new(); // area, length, zones, traces, vias, pieces per rung
for r in ["copper", "via", "inferred", "group", "none", "conflict"] { tally.insert(r, [0.0; 6]); }
let mut net_copper: BTreeMap<String, BTreeMap<&'static str, [f64; 3]>> = BTreeMap::new();
let mut piece_items: HashMap<usize, Vec<String>> = HashMap::new();
let mut group_items: HashMap<usize, (Vec<String>, Vec<String>, Vec<String>)> = HashMap::new();
let mut zones_out = vec![]; let mut stub_bad = 0;
let mut items_flat = vec![]; // every item with where to look, for checking
for k in 0..n_p { tally.get_mut(named_nodes[node_p[k]].rung.name()).unwrap()[5] += 1.0; }
if let Some(zs) = copper["zones"].as_array_mut() {
for z in zs.iter_mut() {
let id = z["id"].as_str().unwrap_or("").to_string();
let mut it = id.splitn(3, '-'); let (_, side, isl) = (it.next(), it.next().unwrap_or(""), it.next().unwrap_or("0").parse::<u32>().unwrap_or(0));
let parts: Vec<usize> = by_island.get(&(side.to_string(), isl)).cloned().unwrap_or_default();
let total: f64 = parts.iter().map(|k| area[*k]).sum();
let za = z["areaMm2"].as_f64().unwrap_or(0.0);
let mut sp: Vec<(usize, f64)> = parts.iter().map(|k| (*k, if total > 0.0 { area[*k] / total } else { 0.0 })).collect();
sp.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
if sp.is_empty() {
// no copper of its own left after pale pixels are cut away: nothing seen says what it is
set_net(z, &format!("UNK_ISO_{id}"), &Rung::None, "no copper seen in this zone", &[], &[]);
tally.get_mut("none").unwrap()[0] += za; tally.get_mut("none").unwrap()[2] += 1.0;
items_flat.push(json!({"id": id, "kind": "zone", "rung": "none", "net": z["net"], "side": side, "areaMm2": za}));
continue;
}
for (k, s) in &sp {
let nm = &named_nodes[node_p[*k]];
tally.get_mut(nm.rung.name()).unwrap()[0] += za * s;
net_copper.entry(nm.net.clone()).or_default().entry(nm.rung.name()).or_insert([0.0; 3])[0] += za * s;
piece_items.entry(*k).or_default().push(id.clone());
}
let top = &named_nodes[node_p[sp[0].0]];
let others: Vec<Value> = sp.iter().skip(1).filter(|q| q.1 >= 0.01).map(|q| json!({"piece": q.0, "net": named_nodes[node_p[q.0]].net, "rung": named_nodes[node_p[q.0]].rung.name(), "share": r2(q.1)})).collect();
let split: BTreeSet<&String> = sp.iter().filter(|q| q.1 >= 0.01).map(|q| &named_nodes[node_p[q.0]].net).collect();
let piece_ref = vec![sp[0].0];
set_net(z, &top.net, &top.rung, &top.why, &top.candidates, &piece_ref);
z["netEvidence"]["conductor"] = json!(cond[sp[0].0]);
z["netEvidence"]["share"] = json!(r2(sp[0].1));
if !others.is_empty() { z["netEvidence"]["otherPieces"] = json!(others); }
if split.len() > 1 { z["netEvidence"]["splitAcrossNets"] = json!(true); zones_out.push(id.clone()); }
tally.get_mut(top.rung.name()).unwrap()[2] += 1.0;
group_items.entry(group_of[node_p[sp[0].0]]).or_default().0.push(id.clone());
items_flat.push(json!({"id": id, "kind": "zone", "rung": top.rung.name(), "net": top.net, "side": side, "areaMm2": za, "piece": sp[0].0, "conductor": cond[sp[0].0], "share": r2(sp[0].1)}));
}
}
let pin_contact: HashMap<(String, usize), bool> = contacts.iter().map(|c| ((c.pin.clone(), c.piece), c.tier == Tier::Solid)).collect();
let pad_xy: Vec<(f64, f64, String)> = ms["pads"].as_array().into_iter().flatten().map(|p| (f(p, "x"), f(p, "y"), format!("{}.{}", p["part"].as_str().unwrap_or(""), p["num"].as_str().unwrap_or("")))).collect();
let via_set: HashMap<String, usize> = gvias.iter().enumerate().map(|(k, v)| (v.id.clone(), k)).collect();
if let Some(ts) = copper["traces"].as_array_mut() {
for t in ts.iter_mut() {
let id = t["id"].as_str().unwrap_or("").to_string();
let side = if t["layer"].as_str() == Some("F.Cu") { "top" } else { "bottom" };
let path: 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 len: f64 = path.windows(2).map(|q| crate::geom::dist(q[0], q[1])).sum();
let stub = id.contains("stub");
let mut votes: HashMap<u32, u32> = HashMap::new();
let usable = if stub && path.len() >= 2 { &path[..1] } else { &path[..] };
for p in usable { for (l, c) in votes_at(&maps_p[side], w, h, p.0, p.1, 0.1) { *votes.entry(l).or_default() += c; } }
for q in path.windows(2) { if stub { break; } let l = crate::geom::dist(q[0], q[1]); let steps = (l / 0.2) as usize; for s in 1..steps { let a = s as f64 / steps as f64; for (i, c) in votes_at(&maps_p[side], w, h, q[0].0 + (q[1].0 - q[0].0) * a, q[0].1 + (q[1].1 - q[0].1) * a, 0.1) { *votes.entry(i).or_default() += c; } } }
let picked = votes.into_iter().max_by_key(|x| (x.1, x.0)).and_then(|x| index.get(&(side.to_string(), x.0)).copied());
let mut it = id.splitn(3, '-'); let (_, _, rest) = (it.next(), it.next(), it.next().unwrap_or("0"));
let isl: u32 = rest.split('-').next().unwrap_or("0").parse().unwrap_or(0);
let piece = picked.or_else(|| by_island.get(&(side.to_string(), isl)).and_then(|v| v.iter().copied().max_by(|a, b| area[*a].partial_cmp(&area[*b]).unwrap())));
let Some(k) = piece else {
set_net(t, &format!("UNK_ISO_{id}"), &Rung::None, "no copper seen along this track", &[], &[]);
tally.get_mut("none").unwrap()[1] += len; tally.get_mut("none").unwrap()[3] += 1.0;
items_flat.push(json!({"id": id, "kind": "track", "rung": "none", "net": t["net"], "side": side, "lengthMm": r3(len), "path": path.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()}));
continue;
};
let nd = node_p[k];
let nm = &named_nodes[nd];
// a stub reaches a pad or via of this copper: if that contact did not survive, the stub rests on nothing seen
let mut rung = nm.rung.clone(); let mut net = nm.net.clone(); let mut why = nm.why.clone();
if stub {
if let Some(e) = path.last() {
let pad = pad_xy.iter().find(|p| (p.0 - e.0).abs() < 0.002 && (p.1 - e.1).abs() < 0.002);
let ok = match pad {
Some(p) => pin_contact.contains_key(&(p.2.clone(), nd)),
None => via_pos.iter().any(|v| (v.0 - e.0).abs() < 0.002 && (v.1 - e.1).abs() < 0.002 && { let g = &gvias[via_set[&v.2]]; g.valid && (g.top == Some(nd) || g.bottom == Some(nd)) }),
};
if !ok { stub_bad += 1; rung = Rung::None; net = format!("UNK_STUB_{id}"); why = "drawn to a pad or via this copper is not seen to reach".to_string(); }
}
}
set_net(t, &net, &rung, &why, &nm.candidates, &[k]);
t["netEvidence"]["conductor"] = json!(cond[k]);
tally.get_mut(rung.name()).unwrap()[1] += len; tally.get_mut(rung.name()).unwrap()[3] += 1.0;
net_copper.entry(net.clone()).or_default().entry(rung.name()).or_insert([0.0; 3])[1] += len;
piece_items.entry(k).or_default().push(id.clone());
if !net.starts_with("UNK_STUB") { group_items.entry(group_of[nd]).or_default().1.push(id.clone()); }
items_flat.push(json!({"id": id, "kind": "track", "rung": rung.name(), "net": net, "side": side, "lengthMm": r3(len), "piece": k, "conductor": cond[k], "path": path.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()}));
}
}
if let Some(vs) = copper["vias"].as_array_mut() {
for v in vs.iter_mut() {
let id = v["id"].as_str().unwrap_or("").to_string();
let (x, y) = (v["pos_mm"][0].as_f64().unwrap_or(0.0), v["pos_mm"][1].as_f64().unwrap_or(0.0));
// match to the list by position, since a new list has its own ids
let m = via_pos.iter().filter(|q| (q.0 - x).abs() < 0.1 && (q.1 - y).abs() < 0.1).min_by(|a, b| crate::geom::dist((a.0, a.1), (x, y)).partial_cmp(&crate::geom::dist((b.0, b.1), (x, y))).unwrap());
let meta = m.and_then(|q| via_meta.get(&q.2));
let (mut rung, mut net, mut why, mut cand) = (Rung::None, format!("UNK_VIA_{id}"), String::new(), vec![]);
let mut nodes = vec![];
match (m, meta) {
(None, _) | (_, None) => why = format!("not in the via list {list_name}"),
(_, Some(me)) if !me["valid"].as_bool().unwrap_or(false) => why = "the via list marks it not valid".to_string(),
(_, Some(me)) => {
nodes = ["top", "bottom"].iter().filter_map(|s| me[*s].as_u64().map(|q| q as usize)).collect();
let names: BTreeSet<String> = nodes.iter().map(|k: &usize| named_nodes[*k].net.clone()).filter(|n| !n.starts_with("UNK")).collect();
if nodes.is_empty() { why = format!("joins nothing it can be seen to reach: top {}, bottom {}", me["topState"], me["bottomState"]); }
else if names.len() > 1 { rung = Rung::Conflict; net = format!("UNK_CONFLICT_{id}"); why = "joins copper of different nets".into(); cand = names.into_iter().collect(); }
else if let Some(n) = names.into_iter().next() {
let lv = nodes.iter().filter(|k| named_nodes[**k].net == n).map(|k| named_nodes[*k].rung.clone()).min_by_key(weight_rank).unwrap();
rung = lv; net = n; why = "sits in this copper".into();
} else {
let g = nodes.iter().map(|k| &named_nodes[*k]).min_by_key(|m| weight_rank(&m.rung)).unwrap();
rung = g.rung.clone(); net = g.net.clone(); why = g.why.clone(); cand = g.candidates.clone();
}
if rung != Rung::Conflict { if let Some(k) = nodes.first() { group_items.entry(group_of[*k]).or_default().2.push(id.clone()); } }
}
}
set_net(v, &net, &rung, &why, &cand, &nodes);
tally.get_mut(rung.name()).unwrap()[4] += 1.0;
net_copper.entry(net.clone()).or_default().entry(rung.name()).or_insert([0.0; 3])[2] += 1.0;
items_flat.push(json!({"id": id, "kind": "via", "rung": rung.name(), "net": net, "x": x, "y": y, "nodes": nodes, "top": meta.map(|m| m["topState"].clone()), "bottom": meta.map(|m| m["bottomState"].clone())}));
}
}
// 5. the nets: the copper wins over the pin map, and every disagreement is reported
let mut solid_of: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for c in contacts.iter().filter(|c| c.tier == Tier::Solid) { solid_of.entry(c.pin.clone()).or_default().push(c.piece); }
let weak_pins: BTreeSet<String> = contacts.iter().filter(|c| c.tier == Tier::Weak).map(|c| c.pin.clone()).collect();
let mut moved = vec![]; let mut added = vec![];
let mut final_net: BTreeMap<String, String> = pin_net.iter().map(|(a, b)| (a.clone(), b.clone())).collect();
for (pin, nodes) in &solid_of {
if pin.starts_with("PHOTO1.") { continue; } // metal no footprint explains is on no net of its own, and is no part
let names: BTreeSet<&String> = nodes.iter().filter(|n| matches!(named_nodes[**n].rung, Rung::Copper | Rung::Via)).map(|n| &named_nodes[*n].net).collect();
if names.len() != 1 { continue; }
let copper_net = (*names.iter().next().unwrap()).clone();
match pin_net.get(pin) {
Some(o) if *o != copper_net => { moved.push(json!({"pin": pin, "inferred": o, "copper": copper_net})); final_net.insert(pin.clone(), copper_net); }
None => { added.push(json!({"pin": pin, "copper": copper_net})); final_net.insert(pin.clone(), copper_net); }
_ => {}
}
}
let mut unsupported = vec![];
let mut pin_rows_all: BTreeMap<String, BTreeMap<String, &'static str>> = BTreeMap::new();
let state_of = |pin: &str| -> Vec<&str> { pad_states.iter().filter(|p| p["pin"].as_str() == Some(pin)).filter_map(|p| p["state"].as_str()).collect() };
let mut net_rows = vec![];
let old_nets: Vec<Value> = nets_doc["nets"].as_array().cloned().unwrap_or_default();
let mut out_nets: Vec<Value> = vec![];
let mut names_seen: BTreeSet<String> = BTreeSet::new();
let mut by_name: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (p, n) in &final_net { by_name.entry(n.clone()).or_default().push(p.clone()); }
let (carried, emptied) = nets_carried(&old_nets, &by_name);
for (mut n, pins) in carried {
let name = n["name"].as_str().unwrap_or("").to_string();
let inferred = inferred_pins(&n);
names_seen.insert(name.clone());
let mut pin_rows = BTreeMap::new(); let mut groups: BTreeMap<usize, Vec<String>> = BTreeMap::new(); let (mut proven, mut clash) = (0, 0);
for pin in &pins {
let solid: Vec<usize> = solid_of.get(pin).cloned().unwrap_or_default();
let weak = weak_pins.contains(pin);
let on_net: Vec<usize> = solid.iter().copied().filter(|k| named_nodes[*k].net == name).collect();
let status: &'static str = if !on_net.is_empty() { proven += 1; for k in &on_net { let e = groups.entry(group_of[*k]).or_default(); if !e.contains(pin) { e.push(pin.clone()); } } if on_net.iter().any(|k| named_nodes[*k].rung == Rung::Copper) { "copper" } else { "via" } }
else if solid.iter().any(|k| !named_nodes[*k].net.starts_with("UNK")) { clash += 1; "clash" }
else if !solid.is_empty() { "touches unnamed copper" }
else if weak { "unsettled" } else { "no copper seen" };
pin_rows.insert(pin.clone(), status);
}
let seen_all = proven == pins.len() && pins.len() >= 2 && groups.len() == 1 && clash == 0;
// which of what the pin map claimed the copper no longer supports
for pin in &inferred {
if final_net.get(pin) != Some(&name) { unsupported.push(json!({"net": name, "pin": pin, "why": "the copper at this pin belongs to another net", "copper": final_net.get(pin)})); continue; }
let st = state_of(pin);
if !st.is_empty() && st.iter().all(|s| *s == "isolated") { unsupported.push(json!({"net": name, "pin": pin, "why": "a gap closes around this pad and no track leaves it"})); }
}
let name_source = stamp_net(&mut n, &pins, &inferred, seen_all, proven, clash, &groups);
let cop = net_copper.get(&name).cloned().unwrap_or_default();
pin_rows_all.insert(name.clone(), pin_rows.clone());
net_rows.push(json!({"net": name, "tag": n["tag"], "nameSource": name_source, "pins": pin_rows, "pinsSeen": proven, "pinsTotal": pins.len(), "pinClashes": clash, "groupsOfSeenPins": groups.len(), "groups": groups.values().collect::<Vec<_>>(),
"copper": cop.iter().map(|(k, v)| (k.to_string(), json!({"areaMm2": r2(v[0]), "lengthMm": r2(v[1]), "vias": v[2]}))).collect::<serde_json::Map<_, _>>()}));
out_nets.push(n);
}
nets_doc["nets"] = json!(out_nets);
// every place two nets look shorted: found, not settled
let centre = |k: usize| { let m = centre_n[k]; json!([r2(m.0), r2(m.1)]) };
let mut shorts = vec![];
for c in &outcome.conflicts { let k = c["piece"].as_u64().unwrap_or(0) as usize; shorts.push(json!({"kind": "copper touching pads of different nets, or vias of equal strength", "nets": c["votes"], "pins": c["pins"], "conductor": k, "atMm": centre(k), "areaMm2": r3(n_area[k])})); }
for c in &outcome.via_clashes { let (a, b) = (c["pieces"][0].as_u64().unwrap_or(0) as usize, c["pieces"][1].as_u64().unwrap_or(0) as usize); shorts.push(json!({"kind": "verified vias between copper of different nets", "nets": c["nets"], "vias": c["ids"], "conductors": [a, b], "atMm": centre(a)})); }
for c in &outcome.pin_clashes { let k = c["piece"].as_u64().unwrap_or(0) as usize; shorts.push(json!({"kind": "a pin touching copper of another net: the copper wins", "nets": [c["inferred"], c["copper"]], "pins": [c["pin"]], "conductor": k, "atMm": centre(k)})); }
// groups as the photos show them
let mut members: BTreeMap<usize, BTreeSet<String>> = BTreeMap::new();
for (pin, nodes) in &solid_of { for k in nodes { members.entry(group_of[*k]).or_default().insert(pin.clone()); } }
let mut group_rows: Vec<Value> = vec![];
let mut seen_group: BTreeSet<usize> = BTreeSet::new();
for k in 0..n_n { let g = group_of[k]; if !seen_group.insert(g) { continue; }
let nodes: Vec<usize> = (0..n_n).filter(|q| group_of[*q] == g).collect();
let (zs, ts, vs) = group_items.get(&g).cloned().unwrap_or_default();
let pins: Vec<String> = members.get(&g).map(|m| m.iter().cloned().collect()).unwrap_or_default();
group_rows.push(json!({"group": g, "net": named_nodes[g].net, "rung": named_nodes[g].rung.name(), "nodes": nodes, "areaMm2": r2(nodes.iter().map(|q| n_area[*q]).sum::<f64>()), "pins": pins, "zones": zs, "traces": ts, "vias": vs, "atMm": centre(g)}));
}
group_rows.sort_by(|a, b| b["pins"].as_array().map(|x| x.len()).cmp(&a["pins"].as_array().map(|x| x.len())).then(a["group"].as_u64().cmp(&b["group"].as_u64())));
// unnamed copper, by its neutral name
let mut unnamed = vec![];
for (name, cop) in &net_copper { if name.starts_with("UNK_G") || name.starts_with("UNK_CONFLICT") { unnamed.push(json!({"name": name, "copper": cop.iter().map(|(k, v)| (k.to_string(), json!({"areaMm2": r2(v[0]), "lengthMm": r2(v[1]), "vias": v[2]}))).collect::<serde_json::Map<_, _>>()})); } }
// the ladder
let totals = |i: usize| -> f64 { tally.values().map(|v| v[i]).sum() };
let (ta, tl, tz, tt, tv) = (totals(0), totals(1), totals(2), totals(3), totals(4));
let share = |x: f64, t: f64| if t > 0.0 { (x / t * 1000.0).round() / 10.0 } else { 0.0 };
let mut ladder = serde_json::Map::new();
for (r, _) in WEIGHTS { let v = &tally[r]; ladder.insert(r.to_string(), json!({"areaMm2": r2(v[0]), "areaPct": share(v[0], ta), "lengthMm": r2(v[1]), "lengthPct": share(v[1], tl), "zones": v[2], "zonesPct": share(v[2], tz), "traces": v[3], "tracesPct": share(v[3], tt), "vias": v[4], "viasPct": share(v[4], tv), "pieces": v[5]})); }
let weigh = |i: usize, t: f64| if t > 0.0 { WEIGHTS.iter().map(|(r, w)| tally[r][i] * w).sum::<f64>() / t * 100.0 } else { 0.0 };
let (sa, sl, sv) = (weigh(0, ta), weigh(1, tl), weigh(4, tv));
let score = json!({"weights": WEIGHTS.iter().map(|(r, w)| (r.to_string(), json!(w))).collect::<serde_json::Map<_, _>>(), "areaPct": r2(sa), "lengthPct": r2(sl), "viasPct": r2(sv), "mean": r2((sa + sl + sv) / 3.0), "why": "each rung counts by its strength, so a name on weak evidence does not count as a name on strong evidence"});
let invalid = gvias.iter().filter(|v| !v.valid).count();
let tier_count = |t: u64| outcome.links.iter().filter(|l| l["tier"].as_u64() == Some(t)).count();
let net_tags: BTreeMap<String, u32> = nets_doc["nets"].as_array().into_iter().flatten().fold(BTreeMap::new(), |mut m, n| { *m.entry(n["tag"].as_str().unwrap_or("").to_string()).or_default() += 1; m });
let mut causes: BTreeMap<String, u32> = BTreeMap::new();
for c in &outcome.pin_clashes { *causes.entry(c["kind"].as_str().unwrap_or("").to_string()).or_default() += 1; }
let mut clash_pairs: BTreeMap<String, Vec<Value>> = BTreeMap::new();
for c in &outcome.via_clashes { let mut v = vec![c["nets"][0].as_str().unwrap_or("").to_string(), c["nets"][1].as_str().unwrap_or("").to_string()]; v.sort(); clash_pairs.entry(v.join(" + ")).or_default().push(c.clone()); }
let swaps = swap_suspects(&pin_net, &contacts, &outcome);
let pins_total: usize = final_net.len();
let pins_seen: usize = net_rows.iter().map(|r| r["pinsSeen"].as_u64().unwrap_or(0) as usize).sum();
let summary = json!({"viaList": list_name, "vias": {"listed": gvias.len(), "failValidity": invalid, "linkPairs": outcome.links.len(), "tier2": tier_count(2), "tier3": tier_count(3), "tier4": tier_count(4), "onBoundary": boundary.len()},
"pieces": n_p, "conductors": n_c, "stitches": stitches.len(), "stitchesRefused": refused.len(), "groups": group_rows.len(), "groupsWithPins": group_rows.iter().filter(|g| g["pins"].as_array().map(|p| p.len() >= 2).unwrap_or(false)).count(),
"pinsTotal": pins_total, "pinsSeen": pins_seen, "pinsMoved": moved.len(), "pinsAdded": added.len(), "connectionsUnsupported": unsupported.len(), "netsEmptied": emptied,
"ladder": ladder, "score": score, "pinClashes": outcome.pin_clashes.len(), "pinClashKinds": causes, "weakDisagreements": outcome.weak_disagree.len(), "viaClashPairs": clash_pairs.len(), "viaClashLinks": outcome.via_clashes.len(), "conflicts": outcome.conflicts.len(), "netTags": net_tags, "stubsUnsupported": stub_bad, "zonesSplitAcrossNets": zones_out, "twoPinPartsOnOnePiece": self_short.len(), "finePinsUnresolved": fine_shared, "swapSuspects": swaps.len()});
let piece_rows: Vec<Value> = ps.iter().enumerate().map(|(k, p)| { let nm = &named_nodes[node_p[k]]; json!({"piece": k, "side": p["side"], "id": p["id"], "island": p["island"], "areaMm2": r3(area[k]), "narrow": narrow_p[k], "conductor": cond[k], "node": node_p[k], "group": group_of[node_p[k]], "net": nm.net, "rung": nm.rung.name(), "why": nm.why, "candidates": nm.candidates, "items": piece_items.get(&k)}) }).collect();
let attribution = json!({"summary": summary, "pieces": piece_rows, "padStates": pad_states, "pinClashes": outcome.pin_clashes, "weakDisagreements": outcome.weak_disagree, "viaClashes": clash_pairs, "conflicts": outcome.conflicts, "swapSuspects": swaps, "twoPinPartsOnOnePiece": self_short, "links": outcome.links, "vias": via_meta, "items": items_flat});
let graph = json!({"summary": {"pieces": n_p, "conductors": n_c, "nodes": n_n, "groups": group_rows.len()}, "stitches": stitches, "stitchesRefused": refused, "viasOnBoundary": boundary, "viasInPads": in_pad, "groups": group_rows, "contacts": contacts.iter().map(|c| json!({"pin": c.pin, "node": c.piece, "tier": c.tier as u8, "kind": c.kind, "inferred": c.net, "side": c.side})).collect::<Vec<_>>(), "links": outcome.links, "pinsMoved": moved, "pinsAdded": added, "unsupported": unsupported, "shorts": shorts, "netsEmptied": emptied, "nodeOfPiece": node_p, "conductorOfPiece": cond});
let wr = |p: &Path, v: &Value| std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").map_err(|e| format!("{}: {e}", p.display()));
wr(&flow.join("net-evidence.json"), &json!({"ladder": ["copper", "via", "inferred", "group", "none", "conflict"], "nets": net_rows, "shorts": graph["shorts"], "unsupported": graph["unsupported"], "pinsMoved": graph["pinsMoved"], "pinsAdded": graph["pinsAdded"], "unnamed": unnamed, "swapSuspects": swaps, "summary": summary}))?;
wr(&flow.join("detect/attribution.json"), &attribution)?;
wr(&flow.join("detect/connect-graph.json"), &graph)?;
wr(&flow.join("copper.json"), &copper)?;
wr(&flow.join("nets.json"), &nets_doc)?;
// what the router may take as already joined, and the numbers the review reads
let mp = flow.join("detect/copper-measured.json");
if let Ok(mut m) = rd(&mp) {
m["groups"] = json!(graph["groups"].as_array().into_iter().flatten().filter(|g| !g["net"].as_str().unwrap_or("UNK").starts_with("UNK")).map(|g| json!({"net": g["net"], "pins": g["pins"], "zones": g["zones"], "traces": g["traces"], "vias": g["vias"]})).collect::<Vec<_>>());
m["report"]["pinsJoined"] = json!(solid_of.len()); m["report"]["pinClashes"] = json!(outcome.pin_clashes.len()); m["report"]["viaClashes"] = json!(clash_pairs.len());
m["pinClashes"] = json!(outcome.pin_clashes); m["viaClashes"] = json!(clash_pairs.keys().collect::<Vec<_>>());
wr(&mp, &m)?;
}
Ok(summary)
}
/// A resistor or capacitor: nothing on the photo says which end is pin 1.
fn symmetric(part: &str) -> bool { let mut c = part.chars(); matches!(c.next(), Some('R') | Some('C')) && c.next().map(|x| x.is_ascii_digit()).unwrap_or(false) }
/// Two-pin parts whose copper says the opposite of the pin map: the pin numbers are probably swapped.
fn swap_suspects(pin_net: &HashMap<String, String>, contacts: &[Contact], o: &Outcome) -> Vec<Value> {
let mut parts: BTreeSet<String> = BTreeSet::new();
for c in contacts { let p = c.pin.split('.').next().unwrap_or(""); if symmetric(p) { parts.insert(p.to_string()); } }
let mut out = vec![];
for part in parts {
let (a, b) = (format!("{part}.1"), format!("{part}.2"));
let (Some(na), Some(nb)) = (pin_net.get(&a), pin_net.get(&b)) else { continue };
if na == nb { continue; }
let on = |pin: &str| -> Vec<String> { contacts.iter().filter(|c| c.pin == pin && c.tier == Tier::Solid).map(|c| o.pieces[c.piece].net.clone()).filter(|n| !n.starts_with("UNK")).collect() };
let (oa, ob) = (on(&a), on(&b));
let wrong_a = oa.iter().any(|n| n == nb) && !oa.iter().any(|n| n == na);
let wrong_b = ob.iter().any(|n| n == na) && !ob.iter().any(|n| n == nb);
if wrong_a || wrong_b { out.push(json!({"part": part, "pin1": na, "pin2": nb, "copperAtPin1": oa, "copperAtPin2": ob, "both": wrong_a && wrong_b})); }
}
out
}
/// What identifies the copper a measurement belongs to: ids and shapes, never nets, so attribution does not make itself stale.
pub fn zones_key(c: &Value) -> String {
let mut h: u64 = 0xcbf29ce484222325;
let mut eat = |s: &str| { for b in s.as_bytes() { h ^= *b as u64; h = h.wrapping_mul(0x100000001b3); } };
for z in c["zones"].as_array().into_iter().flatten() { eat(z["id"].as_str().unwrap_or("")); eat(&z["areaMm2"].to_string()); }
for t in c["traces"].as_array().into_iter().flatten() { eat(t["id"].as_str().unwrap_or("")); eat(&t["path_mm"].to_string()); }
format!("{h:016x}")
}
/// A hash of copper.json without the fields attribution writes. Checks that read shapes record this, not the file's.
pub fn geometry_hash(path: &Path) -> String {
let Ok(mut v) = crate::items::read_json(path) else { return "geometry:unreadable".into() };
for key in ["zones", "traces", "vias"] { for it in v[key].as_array_mut().into_iter().flatten() { if let Some(o) = it.as_object_mut() { o.remove("net"); o.remove("netEvidence"); o.remove("fieldTags"); o.remove("kind"); } } }
let text = serde_json::to_string(&v).unwrap_or_default();
format!("geometry:{}", crate::confidence::sha256_hex(text.as_bytes()))
}
fn weight_rank(r: &Rung) -> u8 { match r { Rung::Copper => 0, Rung::Via => 1, Rung::Inferred => 2, Rung::Group => 3, Rung::None => 4, Rung::Conflict => 5 } }
/// Set an item's net and how it was determined. This touches nothing else.
fn set_net(item: &mut Value, net: &str, rung: &Rung, why: &str, candidates: &[String], pieces: &[usize]) {
item["net"] = json!(net);
item["netEvidence"] = json!({"rung": rung.name(), "why": why, "candidates": candidates, "pieces": pieces});
item["fieldTags"] = json!({"net": rung.tag()});
if item["tests"].as_array().map(|a| a.is_empty()).unwrap_or(true) { item["tests"] = json!(["T-pour-nets"]); }
}
pub fn one_line(s: &Value) -> Vec<String> {
let mut h = vec![];
for r in ["copper", "via", "inferred", "group", "none", "conflict"] {
let l = &s["ladder"][r];
h.push(format!("{r}: {} mm2 ({}%), {} mm of track ({}%), {} zones, {} traces, {} vias ({}%)", l["areaMm2"], l["areaPct"], l["lengthMm"], l["lengthPct"], l["zones"], l["traces"], l["vias"], l["viasPct"]));
}
h.push(format!("score by strength: area {}%, length {}%, vias {}%, mean {}%", s["score"]["areaPct"], s["score"]["lengthPct"], s["score"]["viasPct"], s["score"]["mean"]));
h.push(format!("{} pieces into {} conductors by {} stitches; {} groups, {} with two or more pins; {} of {} pins seen on their net", s["pieces"], s["conductors"], s["stitches"], s["groups"], s["groupsWithPins"], s["pinsSeen"], s["pinsTotal"]));
h.push(format!("copper wins: {} pins moved, {} added, {} connections no longer supported; pin clashes {}, via clash pairs {}, conflicts {}, vias on a boundary {}, nets {}, unsupported stubs {}", s["pinsMoved"], s["pinsAdded"], s["connectionsUnsupported"], s["pinClashes"], s["viaClashPairs"], s["conflicts"], s["vias"]["onBoundary"], s["netTags"], s["stubsUnsupported"]));
h.push(format!("vias: {} listed from {}, {} marked not valid; links: {} verified, {} one via, {} unconfirmed", s["vias"]["listed"], s["viaList"], s["vias"]["failValidity"], s["vias"]["tier2"], s["vias"]["tier3"], s["vias"]["tier4"]));
h.push("Details: detect/connect-graph.json, detect/attribution.json; per net: net-evidence.json. `connect attribute --vias <file>` re-runs this without reading a photo.".into());
h
}
#[cfg(test)]
mod tests {
use super::*;
fn pin(p: &str, n: &str, k: usize) -> Contact { Contact { pin: p.into(), net: Some(n.into()), piece: k, tier: Tier::Solid, weight: 1, kind: "pour", side: "top" } }
fn via(id: &str, a: usize, b: usize, valid: bool) -> GVia { GVia { id: id.into(), top: Some(a), bottom: Some(b), valid, both: false } }
#[test]
fn a_net_keeps_what_attribution_does_not_own() {
let cite = "CM5 datasheet, Table 4 and Figure 4";
let mut n = json!({"name": "GPIO26", "pins": ["J1.24"], "tag": "DATASHEET", "nameSource": cite, "source": cite, "why": "connector pin named by the datasheet pinout", "tests": ["T-net-GPIO26"], "note": {"by": "hand"}});
let before = n.clone();
let ns = stamp_net(&mut n, &["J1.24".to_string(), "R5.1".to_string()], &["J1.24".to_string()], false, 1, 0, &BTreeMap::new());
for k in ["source", "nameSource", "why", "tests", "note", "name"] { assert_eq!(n[k], before[k], "{k} is not attribution's to change"); }
assert_eq!(ns, cite);
// a pin the copper added does not contradict the document: the net keeps its tag and its source
assert_eq!(n["pins"], json!(["J1.24", "R5.1"])); assert_eq!(n["inferredPins"], json!(["J1.24"])); assert_eq!(n["tag"], "DATASHEET"); assert_eq!(n["statedTag"], "DATASHEET"); assert_eq!(n["copperEvidence"]["pinsSeen"], 1);
assert!(n["contradiction"].is_null());
// the copper contradicts it (a pin of it sits on copper named for another net): a finding with a test, and the stated tag is remembered
let mut c = json!({"name": "VBUS", "pins": ["J1.1", "C3.1"], "tag": "DATASHEET", "source": cite});
stamp_net(&mut c, &["J1.1".to_string(), "C3.1".to_string()], &["J1.1".to_string(), "C3.1".to_string()], false, 1, 1, &BTreeMap::new());
assert_eq!(c["tag"], "INFERRED"); assert_eq!(c["statedTag"], "DATASHEET"); assert_eq!(c["source"], cite);
assert_eq!(c["tests"], json!(["T-net-VBUS"]));
assert_eq!(c["contradiction"]["pinClashes"], 1);
assert!(c["contradiction"]["finding"].as_str().unwrap().contains("test"));
// and so does the copper moving one of its pins away
let mut v = json!({"name": "EN", "pins": ["U1.3", "R2.1"], "tag": "DATASHEET", "source": cite});
stamp_net(&mut v, &["U1.3".to_string()], &["U1.3".to_string(), "R2.1".to_string()], false, 0, 0, &BTreeMap::new());
assert_eq!(v["tag"], "INFERRED"); assert_eq!(v["contradiction"]["pinsTheCopperMoved"], json!(["R2.1"]));
// the contradiction gone on a later run (a better via list), the document's tag comes back
stamp_net(&mut c, &["J1.1".to_string(), "C3.1".to_string()], &["J1.1".to_string(), "C3.1".to_string()], false, 1, 0, &BTreeMap::new());
assert_eq!(c["tag"], "DATASHEET"); assert!(c["contradiction"].is_null());
// a net nobody documented is inferred, and always names its test
let mut u = json!({"name": "N7", "pins": ["R1.1", "R2.2"], "why": "looks like a divider"});
stamp_net(&mut u, &["R1.1".to_string(), "R2.2".to_string()], &["R1.1".to_string(), "R2.2".to_string()], false, 0, 0, &BTreeMap::new());
assert_eq!(u["tag"], "INFERRED"); assert_eq!(u["tests"], json!(["T-net-N7"]));
// a net that states nothing gets a class word, and a class word from an earlier run is rewritten, not taken for a statement
let mut m = json!({"name": "SW", "pins": ["L1.1"], "why": "silkscreen label beside the pad"});
assert_eq!(stamp_net(&mut m, &["L1.1".to_string()], &["L1.1".to_string()], false, 0, 0, &BTreeMap::new()), "silkscreen");
m["why"] = json!("pin 3 in the datasheet's typical application");
assert_eq!(stamp_net(&mut m, &["L1.1".to_string()], &["L1.1".to_string()], false, 0, 0, &BTreeMap::new()), "datasheet");
}
#[test]
fn the_net_whose_name_survives_keeps_its_own_record() {
let old = vec![json!({"name": "A", "pins": ["X.1"], "source": "sheet a"}), json!({"name": "B", "pins": ["Y.1"], "source": "sheet b"}), json!({"name": "C", "fromCopper": true, "pins": ["Z.1"], "source": "named by hand since", "why": "mine"})];
// the copper moved A's only pin onto B, and C still holds its pin under its own name
let by_name: BTreeMap<String, Vec<String>> = [("B".to_string(), vec!["X.1".to_string(), "Y.1".to_string()]), ("C".to_string(), vec!["Z.1".to_string()])].into_iter().collect();
let (kept, emptied) = nets_carried(&old, &by_name);
assert_eq!(emptied, vec!["A".to_string()]);
assert_eq!(kept.len(), 2);
assert_eq!(kept[0].0["source"], "sheet b"); assert_eq!(kept[0].1, vec!["X.1".to_string(), "Y.1".to_string()]);
assert_eq!(kept[1].0["source"], "named by hand since", "a net made from copper is carried, not rebuilt blank"); assert_eq!(kept[1].0["why"], "mine");
// a name with no net yet gets a new one
let fresh: BTreeMap<String, Vec<String>> = [("D".to_string(), vec!["Q.1".to_string()])].into_iter().collect();
assert_eq!(nets_carried(&[], &fresh).0[0].0["fromCopper"], true);
}
#[test]
fn copper_touching_a_pad_is_the_top_rung() {
let o = propagate(&[false], &[10.0], &[pin("C1.1", "GND", 0)], &[]);
assert_eq!(o.pieces[0].net, "GND"); assert_eq!(o.pieces[0].rung, Rung::Copper);
}
#[test]
fn a_false_via_cannot_short_two_nets() {
let o = propagate(&[false, false], &[10.0, 10.0], &[pin("A.1", "GND", 0), pin("B.1", "VIN", 1)], &[via("v0", 0, 1, true), via("v1", 0, 1, true)]);
assert_eq!(o.pieces[0].net, "GND"); assert_eq!(o.pieces[1].net, "VIN");
assert_eq!(o.via_clashes.len(), 1, "the meeting is reported");
}
#[test]
fn one_via_onto_a_pour_names_nothing_two_verify() {
// a track below, a pour above
let o = propagate(&[true, false], &[1.0, 10.0], &[pin("A.1", "GND", 1)], &[via("v0", 0, 1, true)]);
assert!(o.pieces[0].net.starts_with("UNK_G"), "{}", o.pieces[0].net); assert_eq!(o.pieces[0].rung, Rung::Group);
assert_eq!(o.pieces[0].candidates, vec!["GND".to_string()]);
let o = propagate(&[true, false], &[1.0, 10.0], &[pin("A.1", "GND", 1)], &[via("v0", 0, 1, true), via("v1", 0, 1, true)]);
assert_eq!(o.pieces[0].net, "GND"); assert_eq!(o.pieces[0].rung, Rung::Via);
}
#[test]
fn a_strong_link_settles_a_piece_before_a_weak_one() {
// pieces 0 and 1 are stitched by four vias; 1 has two false vias to GND, 0 has three true ones to VIN
let mut v = vec![]; for i in 0..4 { v.push(via(&format!("a{i}"), 0, 1, true)); }
for i in 0..2 { v.push(via(&format!("b{i}"), 2, 1, true)); }
for i in 0..3 { v.push(via(&format!("c{i}"), 0, 3, true)); }
let o = propagate(&[false; 4], &[10.0; 4], &[pin("G.1", "GND", 2), pin("V.1", "VIN", 3)], &v);
assert_eq!(o.pieces[0].net, "VIN"); assert_eq!(o.pieces[1].net, "VIN"); assert_eq!(o.pieces[1].rung, Rung::Via);
assert_eq!(o.via_clashes.len(), 1, "the weaker link is reported, not followed");
}
#[test]
fn an_invalid_via_is_no_link() {
let o = propagate(&[true, true], &[1.0, 1.0], &[pin("A.1", "GND", 1)], &[via("v0", 0, 1, false)]);
assert_eq!(o.pieces[0].rung, Rung::None); assert!(o.pieces[0].net.starts_with("UNK_ISO"));
}
#[test]
fn one_via_track_to_track_is_only_inferred() {
let o = propagate(&[true, true], &[1.0, 1.0], &[pin("A.1", "SIG", 1)], &[via("v0", 0, 1, true)]);
assert_eq!(o.pieces[0].net, "SIG"); assert_eq!(o.pieces[0].rung, Rung::Inferred);
}
#[test]
fn evenly_split_pads_give_a_conflict_and_it_does_not_spread() {
let o = propagate(&[false, false], &[10.0, 10.0], &[pin("A.1", "GND", 0), pin("B.1", "VIN", 0)], &[via("v0", 0, 1, true), via("v1", 0, 1, true)]);
assert_eq!(o.pieces[0].rung, Rung::Conflict); assert_eq!(o.pieces[0].candidates.len(), 2);
assert!(o.pieces[1].net.starts_with("UNK"), "{}", o.pieces[1].net);
}
#[test]
fn a_clear_majority_names_copper_and_reports_the_dissenter() {
let mut c: Vec<Contact> = (0..5).map(|i| pin(&format!("G.{i}"), "GND", 0)).collect(); c.push(pin("X.1", "SIG", 0));
let o = propagate(&[false], &[10.0], &c, &[]);
assert_eq!(o.pieces[0].net, "GND"); assert_eq!(o.pin_clashes.len(), 1); assert_eq!(o.pin_clashes[0]["pin"], "X.1");
// several lone dissenters do not outvote it either
let mut c: Vec<Contact> = (0..4).map(|i| pin(&format!("G.{i}"), "GND", 0)).collect(); c.push(pin("X.1", "A", 0)); c.push(pin("Y.1", "B", 0)); c.push(pin("Z.1", "C", 0));
assert_eq!(propagate(&[false], &[10.0], &c, &[]).pieces[0].net, "GND");
}
#[test]
fn unknown_copper_stays_labelled_unknown() {
let o = propagate(&[true, true, true], &[1.0, 1.0, 1.0], &[], &[via("v0", 0, 1, true)]);
assert_eq!(o.pieces[0].net, o.pieces[1].net); assert_eq!(o.pieces[0].rung, Rung::Group);
assert_eq!(Rung::Group.tag(), "UNKNOWN");
assert_eq!(o.pieces[2].rung, Rung::None);
}
#[test]
fn copper_with_a_via_to_disputed_copper_is_a_group_not_isolated() {
let o = propagate(&[false, false], &[10.0, 10.0], &[pin("A.1", "GND", 0), pin("B.1", "VIN", 0)], &[via("v0", 0, 1, true)]);
assert_eq!(o.pieces[0].rung, Rung::Conflict);
assert_eq!(o.pieces[1].rung, Rung::Group); assert_eq!(o.pieces[1].candidates.len(), 2);
}
#[test]
fn a_pad_edge_says_pour_track_or_gap() {
let nar = |c: usize| c == 7; let trk = |c: usize| c == 7; let id = |q: u32| Some(q as usize);
let mk = |piece: u32, open: [u64; 4], arc: u32, vis: u64, gap: u64| { let mask: u32 = if gap == 0 { 0xffff } else { 0b11 }; json!({"w": 1.0, "h": 1.0, "drill": 0.0, "faces": {"top": {"visible": vis, "gapArc": gap, "openMask": mask, "around": [], "reach": [{"piece": piece, "open": open, "arc": arc, "depth": 14, "touch": arc}]}}}) };
// copper all round: a pour
let (s, c) = pad_contacts(&mk(3, [200, 150, 100, 0], 0xffff, 12, 0), "top", &id, &nar, &trk); assert_eq!(s, "joined"); assert_eq!(c[0].3, "pour"); assert_eq!(c[0].1, Tier::Solid);
// a neck into a pour is not enough
let (s, c) = pad_contacts(&mk(3, [40, 20, 0, 0], 0b11, 12, 8), "top", &id, &nar, &trk); assert_eq!(s, "unsettled"); assert_eq!(c[0].1, Tier::Weak);
// a ring of edges and a neck through it: the pour does not join
let (s, c) = pad_contacts(&mk(3, [40, 20, 0, 0], 0b11, 12, 10), "top", &id, &nar, &trk); assert_eq!(s, "isolated"); assert!(c.is_empty());
// a track leaving
let (s, c) = pad_contacts(&mk(7, [40, 20, 0, 0], 0b1, 12, 8), "top", &id, &nar, &trk); assert_eq!(s, "joined"); assert_eq!(c[0].3, "track-end");
// nothing reached and a gap around it
let pad = json!({"w": 1.0, "h": 1.0, "drill": 0.0, "faces": {"top": {"visible": 12, "gapArc": 10, "around": [], "reach": []}}});
let (s, c) = pad_contacts(&pad, "top", &id, &nar, &trk); assert_eq!(s, "isolated"); assert!(c.is_empty());
// too little to see
let pad = json!({"w": 1.0, "h": 1.0, "drill": 0.0, "faces": {"top": {"visible": 1, "gapArc": 0, "around": [], "reach": []}}});
assert_eq!(pad_contacts(&pad, "top", &id, &nar, &trk).0, "hidden");
}
#[test]
fn a_via_takes_what_surrounds_it_or_reports_a_boundary() {
let id = |q: u32| if q == 0 { None } else { Some(q as usize) }; let nar = |c: usize| c == 9;
let row = |around: Vec<u32>, reach: Vec<(u32, u64)>, gap: u64| json!({"visible": around.len(), "gapArc": gap, "gapOnly": gap, "around": around, "reach": reach.iter().map(|q| json!({"piece": q.0, "open": [q.1, 0, 0, 0], "arc": 0xff, "depth": 16})).collect::<Vec<_>>()});
assert_eq!(via_attach(&row(vec![3; 16], vec![(3, 100)], 0), &id, &nar), Attach::Pour(3));
// half and half, both reached: reported, joins neither
let half = [vec![3; 8], vec![4; 8]].concat();
assert_eq!(via_attach(&row(half.clone(), vec![(3, 50), (4, 50)], 0), &id, &nar), Attach::Boundary(3, 4));
// half and half, a gap keeps one away: the one reached
assert_eq!(via_attach(&row(half, vec![(4, 50)], 0), &id, &nar), Attach::Pour(4));
// a ring with a track through it joins the track, not the pour beyond
assert_eq!(via_attach(&row(vec![3; 16], vec![(9, 30)], 14), &id, &nar), Attach::End(9));
assert_eq!(via_attach(&row(vec![3; 16], vec![], 14), &id, &nar), Attach::Ring);
assert_eq!(via_attach(&json!({"visible": 1}), &id, &nar), Attach::Hidden);
}
#[test]
fn a_fine_pin_takes_a_track_that_arrives_along_it_not_a_strip_passing_it() {
let p = Pad { part: "U1".into(), num: "1".into(), x: 10.0, y: 10.0, w: 0.875, h: 0.25, rot: 0.0, round: false, drill: 0.0, layers: vec![], net: String::new() };
assert!(along_pad(&p, &TrackEnd { side: "top", x: 10.4, y: 10.0, c: 1, from: (10.8, 10.05) }));
assert!(!along_pad(&p, &TrackEnd { side: "top", x: 10.4, y: 10.0, c: 1, from: (10.4, 10.4) }));
}
#[test]
fn only_resistors_and_capacitors_are_symmetric() {
assert!(symmetric("R12")); assert!(symmetric("C3")); assert!(!symmetric("CN1")); assert!(!symmetric("D4")); assert!(!symmetric("RESET"));
}
#[test]
fn a_passive_with_the_opposite_net_on_each_end_is_a_swap_suspect() {
let pins: HashMap<String, String> = [("C1.1", "VIN"), ("C1.2", "GND")].iter().map(|(a, b)| (a.to_string(), b.to_string())).collect();
let c = vec![pin("C1.1", "VIN", 0), pin("G.1", "GND", 0), pin("G.2", "GND", 0), pin("G.3", "GND", 0)];
let o = propagate(&[false], &[1.0], &c, &[]);
let s = swap_suspects(&pins, &c, &o);
assert_eq!(s.len(), 1); assert_eq!(s[0]["part"], "C1");
}
#[test]
fn attribution_never_touches_geometry() {
let mut z = json!({"id": "isl-top-1", "layer": "F.Cu", "polygon_mm": [[1, 2], [3, 4], [5, 6]], "rle": [[1, 2, 3]], "areaMm2": 7.5, "net": "OLD", "tag": "MEASURED", "tests": ["T-a"]});
let before = z.clone();
set_net(&mut z, "GND", &Rung::Via, "why", &[], &[1]);
for k in ["id", "layer", "polygon_mm", "rle", "areaMm2", "tag", "tests"] { assert_eq!(z[k], before[k], "{k}"); }
assert_eq!(z["net"], "GND"); assert_eq!(z["fieldTags"]["net"], "MEASURED"); assert_eq!(z["netEvidence"]["rung"], "via");
}
#[test]
fn the_copper_key_ignores_nets_and_follows_shapes() {
let a = json!({"zones": [{"id": "z", "areaMm2": 1.0, "net": "A"}], "traces": [{"id": "t", "path_mm": [[0, 0], [1, 1]], "net": "A"}]});
let mut b = a.clone(); b["zones"][0]["net"] = json!("B"); assert_eq!(zones_key(&a), zones_key(&b));
let mut c = a.clone(); c["traces"][0]["path_mm"] = json!([[0, 0], [2, 2]]); assert_ne!(zones_key(&a), zones_key(&c));
}
#[test]
fn run_length_maps_round_trip() {
let dir = std::env::temp_dir().join(format!("attrib-rle-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap();
let v = vec![0u32, 0, 7, 7, 7, 1]; let p = dir.join("m.rle");
write_rle(&p, &v, 3, 2).unwrap();
let (o, w, h) = read_rle(&p).unwrap(); assert_eq!((o, w, h), (v, 3, 2));
std::fs::remove_dir_all(&dir).ok();
}
}