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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
//! A face that cannot be read is said to be unread, with why, and nothing is drawn from it; and no two pads of one two-pad part are joined to one conductor.
//!
//! Until 2026-11-11 a face one island had swallowed was written out like any other: test board 1's bottom gave one "GND" net of 38 pins that shorted three supplies,
//! and `check copper` could only stop the run after the fact. The tests are outcomes of the trace, applied before anything is written: one island with over two thirds
//! of the face's joined pins, over 95 percent of the seen face read as copper, or most of the face's two-pad parts with both pads on one island. How the levels sit
//! against the face's edges is never a reason: it is recorded as a warning on the face and the reading is kept. Such a face's islands are taken out, the copper is measured again without them, and the accounting carries `unread` for that face.
//! A wrong net is worse than a missing one: the pins on that face then join nothing, and their nets have to come from the other face, a datasheet or the bench.
use crate::islands::{Face, Options, SeenDome};
use crate::kicad_out::Pad;
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
/// Levels taken from a face's own edges that under this share of those edges straddle are worth a warning on the face, and no more. Test board 4's top holds 0.56 and did come
/// out with its gaps lost, but board 9's bottom holds 0.59 and board 5's top 0.66 and both read well (board 9's bottom agrees with by-eye labels at 24 of 25 places), so the
/// figure does not tell a face that reads from one that does not. It was a reason to give a face up for one build, 2026-11-11, and emptied two faces that read.
const STRADDLE_WARN: f32 = 2.0 / 3.0;
/// Two-pad parts with both pads on one island: from this many on a face, and over half of the face's two-pad parts whose pads both lie on copper, the face has lost its gaps
/// wholesale. A majority, not a fitted line. The hub board's top lost 18 pairs at its worst and read; the count and the share of each face are in `padPairsSeen`.
const PAIRS_MIN: usize = 10;
/// What is worth a look on each face without giving it up: side and sentence.
pub fn warnings(faces: &[Face]) -> Vec<(String, String)> {
let mut out = vec![];
for f in faces { let st = &f.t.steps;
if let Some(a) = &st.edge_check { if st.levels_from_edges && a.edges >= 500 && a.straddle < STRADDLE_WARN && !a.levels_are_wrong() {
out.push((f.side.to_string(), format!("only {:.0} percent of the photo's copper-sized step edges straddle the copper and laminate levels taken from those same edges (most faces hold 80 percent or more). The reading is kept: look at evidence/copper-islands-{}.jpg for pours whose gaps were lost before trusting its nets.", a.straddle * 100.0, f.side))); } } }
out
}
/// Why each face that cannot be read cannot, from what `islands::measure` made of it. Only outcomes count: a face one island has swallowed, a face read as all copper, a face
/// whose two-pad parts are shorted wholesale. How the levels sit against the edges is a warning (`warnings`), never a reason here.
pub fn unreadable(m: &Value, faces: &[Face]) -> Vec<(String, String)> {
let mut out = vec![];
for f in faces {
let mut per: BTreeMap<u64, usize> = BTreeMap::new();
for j in m["joins"].as_array().into_iter().flatten().filter(|j| j["side"].as_str() == Some(f.side)) { if let Some(i) = j["island"].as_u64() { *per.entry(i).or_default() += 1; } }
let total: usize = per.values().sum();
let st = &f.t.steps;
if let Some((isl, n)) = per.iter().max_by_key(|x| *x.1) { if total >= crate::copperchecks::MIN_PINS && *n as f64 > crate::copperchecks::SWALLOW_SHARE * total as f64 {
out.push((f.side.to_string(), format!("island {isl} held {n} of the {total} pins joined on this face ({:.0} percent); no one conductor holds two thirds of a face's pins, so laminate was read as copper or a gap was lost", *n as f64 / total as f64 * 100.0))); continue; } }
if st.copper_share as f64 > crate::copperchecks::MAX_COPPER_SHARE { out.push((f.side.to_string(), format!("{:.0} percent of what can be seen read as copper; no board is that full, so the laminate level was not found", st.copper_share * 100.0))); continue; }
let refused = m["padPairsRefused"].as_array().into_iter().flatten().filter(|r| r["side"].as_str() == Some(f.side)).count();
let seen = m["padPairsSeen"].as_array().into_iter().flatten().find(|r| r["side"].as_str() == Some(f.side)).and_then(|r| r["parts"].as_u64()).unwrap_or(0) as usize;
if refused >= PAIRS_MIN && refused * 2 > seen { out.push((f.side.to_string(), format!("{refused} of the {seen} two-pad parts whose pads both lie on copper had both pads on one island; a face that shorts most of its resistors and capacitors has lost its gaps"))); }
}
out
}
/// `islands::measure`, and again without the faces that cannot be read. The second pass sees those faces as showing no copper at all.
pub fn measure(faces: &mut [Face], pads: &[Pad], domes: &[SeenDome], pin_net: &HashMap<String, String>, opt: &Options) -> Value {
let m = crate::islands::measure(faces, pads, domes, pin_net, opt);
if std::env::var("TRACE_KEEP_UNREAD").is_ok() { return m; }
let bad = unreadable(&m, faces);
let warn = warnings(faces);
let with_warnings = |mut m: Value| -> Value {
if let Some(rows) = m["accounting"]["perFace"].as_array_mut() { for r in rows.iter_mut() { if let Some(b) = warn.iter().find(|b| Some(b.0.as_str()) == r["side"].as_str()) { r["warning"] = json!(b.1); } } }
m["warnings"] = json!(warn.iter().filter(|b| !bad.iter().any(|x| x.0 == b.0)).map(|b| json!({"side": b.0, "what": b.1})).collect::<Vec<_>>()); m };
if bad.is_empty() { return with_warnings(m); }
for f in faces.iter_mut() { if bad.iter().any(|b| b.0 == f.side) { for l in f.t.labels.iter_mut() { *l = 0; } f.t.regions.clear(); } }
let mut m2 = crate::islands::measure(faces, pads, domes, pin_net, opt);
if let Some(rows) = m2["accounting"]["perFace"].as_array_mut() { for r in rows.iter_mut() { if let Some(b) = bad.iter().find(|b| Some(b.0.as_str()) == r["side"].as_str()) { r["unread"] = json!(b.1); } } }
m2["unread"] = json!(bad.iter().map(|b| json!({"side": b.0, "why": b.1})).collect::<Vec<_>>());
with_warnings(m2)
}
/// The (pad, face) joins to refuse: every pad of a two-pad part that shares an island with the part's other pad on that face, and what was refused, for the report.
/// `on(pad index, face index)` is the island the pad lies on. A part is two-pad when its pads carry exactly two different numbers.
/// Third: per face, how many two-pad parts have pads of both numbers on some island, the count a face's refusals are judged against.
pub fn pairs_on_one_island(pads: &[Pad], faces: usize, on: &dyn Fn(usize, usize) -> Option<u32>) -> (HashSet<(usize, usize)>, Vec<Value>, Vec<usize>) {
let mut by_part: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for (i, p) in pads.iter().enumerate() { if !p.num.is_empty() { by_part.entry(p.part.as_str()).or_default().push(i); } }
let (mut refuse, mut rows) = (HashSet::new(), vec![]); let mut seen = vec![0usize; faces];
for (part, idx) in by_part {
let nums: HashSet<&str> = idx.iter().map(|i| pads[*i].num.as_str()).collect();
if nums.len() != 2 { continue; }
for fi in 0..faces {
let at: Vec<(usize, u32)> = idx.iter().filter_map(|i| on(*i, fi).map(|l| (*i, l))).collect();
if at.iter().map(|a| pads[a.0].num.as_str()).collect::<HashSet<_>>().len() == 2 { seen[fi] += 1; }
let shared: HashSet<u32> = at.iter().filter(|a| at.iter().any(|b| b.1 == a.1 && pads[b.0].num != pads[a.0].num)).map(|a| a.1).collect();
if shared.is_empty() { continue; }
for (i, l) in &at { if shared.contains(l) { refuse.insert((*i, fi)); } }
let mut isl: Vec<u32> = shared.into_iter().collect(); isl.sort();
rows.push(json!({"part": part, "face": fi, "islands": isl}));
}
}
(refuse, rows, seen)
}
/// The (pad, face) joins to refuse because one island holds over two thirds of the pins of a part with 16 or more: `copperchecks` fails a run on such an island ("several
/// conductors read as one"), and the wrong net it makes is worse than the missing ones. None of that part's pads is joined to that island; the rest of the face stands.
pub fn big_parts_on_one_island(pads: &[Pad], faces: usize, on: &dyn Fn(usize, usize) -> Option<u32>) -> (HashSet<(usize, usize)>, Vec<Value>) {
let mut by_part: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
for (i, p) in pads.iter().enumerate() { if !p.num.is_empty() { by_part.entry(p.part.as_str()).or_default().push(i); } }
let (mut refuse, mut rows) = (HashSet::new(), vec![]);
for (part, idx) in by_part {
let all = idx.iter().map(|i| pads[*i].num.as_str()).collect::<HashSet<_>>().len();
if all < crate::copperchecks::BIG_PART { continue; }
for fi in 0..faces {
let mut per: BTreeMap<u32, HashSet<&str>> = BTreeMap::new();
for i in &idx { if let Some(l) = on(*i, fi) { per.entry(l).or_default().insert(pads[*i].num.as_str()); } }
for (l, nums) in per { if nums.len() as f64 > crate::copperchecks::PART_SHARE * all as f64 {
for i in &idx { if on(*i, fi) == Some(l) { refuse.insert((*i, fi)); } }
rows.push(json!({"part": part, "face": fi, "island": l, "pins": nums.len(), "of": all})); } }
}
}
(refuse, rows)
}
#[cfg(test)]
mod tests {
use super::*;
fn pad(part: &str, num: &str) -> Pad { Pad { part: part.into(), num: num.into(), x: 0.0, y: 0.0, w: 1.0, h: 1.0, rot: 0.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: String::new() } }
#[test]
fn both_ends_of_a_two_pad_part_on_one_island_join_nothing() {
// D1 has both pads on island 7; R1 has its pads on 7 and 9; U1, with three pads, has two of them on 7
let pads = vec![pad("D1", "1"), pad("D1", "2"), pad("R1", "1"), pad("R1", "2"), pad("U1", "1"), pad("U1", "2"), pad("U1", "3")];
let on = |i: usize, _f: usize| -> Option<u32> { [Some(7), Some(7), Some(7), Some(9), Some(7), Some(7), None][i] };
let (refuse, rows, seen) = pairs_on_one_island(&pads, 1, &on);
assert_eq!(seen, vec![2], "D1 and R1 have both pads on copper");
assert!(refuse.contains(&(0, 0)) && refuse.contains(&(1, 0)), "{refuse:?}");
assert_eq!(refuse.len(), 2, "a resistor across two islands and a chip keep their joins: {refuse:?}");
assert_eq!(rows.len(), 1); assert_eq!(rows[0]["part"], json!("D1"));
}
#[test]
fn most_pins_of_a_large_part_on_one_island_join_nothing_and_the_rest_stand() {
let mut pads: Vec<Pad> = (1..=20).map(|k| pad("U1", &k.to_string())).collect(); pads.push(pad("R1", "1"));
// 15 of U1's 20 pins on island 5, the others each on their own, and a resistor pad on island 5 too
let on = |i: usize, _f: usize| -> Option<u32> { if i < 15 || i == 20 { Some(5) } else { Some(100 + i as u32) } };
let (refuse, rows) = big_parts_on_one_island(&pads, 1, &on);
assert_eq!(refuse.len(), 15, "{refuse:?}"); assert!(!refuse.contains(&(20, 0)) && !refuse.contains(&(16, 0)));
assert_eq!(rows[0]["pins"], json!(15));
let few = |i: usize, _f: usize| -> Option<u32> { if i < 12 { Some(5) } else { None } };
assert!(big_parts_on_one_island(&pads, 1, &few).0.is_empty(), "12 of 20 is no more than two thirds");
}
#[test]
fn a_part_with_two_pads_of_one_number_and_one_of_another_is_two_pad_and_only_the_clash_counts() {
// a diode drawn with two pads numbered 2: both of those on one island is no short
let pads = vec![pad("D2", "1"), pad("D2", "2"), pad("D2", "2")];
let on = |i: usize, _f: usize| -> Option<u32> { [Some(3), Some(4), Some(4)][i] };
assert!(pairs_on_one_island(&pads, 1, &on).0.is_empty());
}
}