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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
//! The step checks. Each reads its artifact, applies the rules the Driver board taught us, and on a
//! pass writes `checks/<name>.ok` with the sha256 of every file it read. A check whose files changed
//! afterwards is stale, so `next` sends the AI back to it: editing parts.json after the review undoes
//! the review.
use crate::items::{self, Item};
use crate::{kicad_in as kicad, ledger, sha256};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::Path;
pub struct Pass { pub summary: Value, pub hints: Vec<String>, pub files: Vec<String> }
pub type Outcome = Result<Pass, Vec<String>>;
/// Every artifact the review covers (and that a later edit would make stale).
pub const REVIEWED: [&str; 7] = ["prep.json", "scale.json", "parts.json", "blocks.json", "copper.json", "nets.json", "layers.json"];
pub const VIA_KINDS: [&str; 7] = ["stitch", "signal", "thermal", "inner", "unknown", "seen", "seen-net"];
fn doc(flow: &Path, name: &str) -> Result<Value, Vec<String>> {
items::read_json(&flow.join(name)).map_err(|e| vec![format!("{e}: the step that writes it has not run yet; `next` names the command")])
}
fn s<'a>(v: &'a Value, k: &str) -> &'a str { v[k].as_str().unwrap_or("") }
fn pos(v: &Value) -> Option<(f64, f64)> {
let a = v["pos_mm"].as_array()?;
Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
fn tag_counts(items: &[Item]) -> Value {
let mut m: BTreeMap<String, usize> = BTreeMap::new();
for it in items { *m.entry(it.tag.clone()).or_default() += 1; }
json!(m)
}
fn validate_all(flow: &Path, items: &[Item], errs: &mut Vec<String>) {
let mut seen = HashSet::new();
for it in items {
items::validate(flow, it, errs);
if !it.id.is_empty() && !seen.insert(it.id.clone()) { errs.push(format!("{}: listed twice in {}", it.id, it.artifact)); }
}
}
/// A part that only fits one way needs its orientation mark checked before anything is routed: the
/// Driver's two QFNs were placed 180 degrees out (the router failed on every pin of one), and its
/// MOSFETs' high or low side was read from which way the tab faced. ICs, transistors, MOSFETs,
/// regulators, diodes and LEDs: `pin1` names the mark (pin-1 dot, notch, bevel, tab, cathode band).
pub fn needs_pin1(p: &Value) -> bool {
let pk = s(p, "package").to_uppercase();
let r = s(p, "ref").to_uppercase();
const PKG: [&str; 22] = ["QFN", "QFP", "SOIC", "SOP", "MSOP", "BGA", "DFN", "SON", "LGA", "DIP", "PLCC", "SOT-23", "SOT23", "SOT-223", "SOT-89", "DPAK", "TO-2", "SMA", "SMB", "SMC", "SOD", "LED"];
PKG.iter().any(|k| pk.contains(k)) || ["U", "IC", "Q", "D", "LED"].iter().any(|x| r.starts_with(x) && r[x.len()..].starts_with(|c: char| c.is_ascii_digit()))
}
/// Every registered photo measured, and the bottom-to-top mapping decided clearly.
pub fn prep(flow: &Path, state: &Value) -> Outcome {
let d = doc(flow, "prep.json")?;
let mut errs = vec![];
let done: Vec<String> = d["photos"].as_array().into_iter().flatten().map(|p| s(p, "file").to_string()).collect();
for p in state["photos"].as_array().into_iter().flatten() { if !done.iter().any(|f| f == s(p, "file")) { errs.push(format!("{} was registered but not measured: run `prep`", s(p, "file"))); } }
let (m, o) = (d["flip"]["matches"].as_u64().unwrap_or(0), d["flip"]["other"].as_u64().unwrap_or(0));
// a symmetric hole pattern agrees both ways: `scale --holes` settles the flip from what shows through, and says how in prep.json
let settled = d["flip"]["how"].is_string();
let symmetric = d["flip"].is_object() && m == o && d["photos"].as_array().into_iter().flatten().all(|p| p["goldHoles"].as_array().map(|a| a.len()).unwrap_or(0) >= 4);
if d["flip"].is_object() && m < 3 * o.max(1) && !settled && !symmetric { errs.push(format!("the bottom-to-top mapping is not clear ({m} holes agree one way, {o} the other): check the photos show the whole board")); }
// once the frame is made: the faces must be held together by holes or vias, not by one shared hole. A flip the person stated lets a weak one through, with a warning.
let mut hints = vec![];
if let Ok(sc) = items::read_json(&flow.join("scale.json")) { if let Some(why) = crate::facereg::unregistered(&sc) {
if settled { hints.push(format!("WARNING: {why}. The flip was stated, so this passes; every via pair and every net across the faces rests on the bottom photo's outline.")); }
else { errs.push(format!("{why}. Look at board/top.jpg against board/bottom.jpg; if the board has no through features, state the flip (`scale ... --flip x|y`) and say so in your report")); }
} }
if !errs.is_empty() { return Err(errs); }
for p in d["photos"].as_array().into_iter().flatten() { let cut: Vec<&str> = p["notBoardEdge"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
if !cut.is_empty() { hints.push(format!("WARNING: {} does not show the whole board: its {} side(s) are the photo's edge, not the board's edge", s(p, "file"), cut.join(" and "))); } }
for p in d["photos"].as_array().into_iter().flatten() { hints.push(format!("{} ({}): sharpness {:.0}, {} holes, {} rows", s(p, "file"), s(p, "side"), p["sharpness"].as_f64().unwrap_or(0.0), p["holes"].as_array().map(|a| a.len()).unwrap_or(0), p["rows"].as_array().map(|a| a.len()).unwrap_or(0))); }
// the mosaics the photos were measured on: stitched again (`stitch --top`), the measurements are of the old faces and this check is stale
let mut files = vec!["prep.json".to_string()];
if flow.join("stitch.json").is_file() { files.push("stitch.json".into()); }
Ok(Pass { summary: json!({"photos": done.len(), "flip": d["flip"]}), hints, files })
}
pub fn parts(flow: &Path) -> Outcome {
let d = doc(flow, "parts.json")?;
let list = d["parts"].as_array().cloned().unwrap_or_default();
if list.is_empty() { return Err(vec!["parts.json has no parts".into()]); }
let its = items::items_of("parts.json", &d);
let mut errs = vec![];
validate_all(flow, &its, &mut errs);
let mut placed: Vec<(String, String, f64, f64)> = vec![];
for p in &list {
let r = s(p, "ref");
if r.is_empty() { continue; }
if !matches!(s(p, "side"), "top" | "bottom") { errs.push(format!("{r}: side must be top or bottom")); }
match pos(p) { Some((x, y)) => placed.push((r.into(), s(p, "side").into(), x, y)), None => errs.push(format!("{r}: pos_mm [x, y] missing (board frame, mm, y down, as KiCad)")) }
if p["rot"].as_f64().is_none() { errs.push(format!("{r}: rot (degrees, KiCad convention) missing")); }
if s(p, "package").is_empty() && s(p, "footprint").is_empty() { errs.push(format!("{r}: give the package or the footprint")); }
if needs_pin1(p) {
let pin = &p["pin1"];
if s(pin, "mark").is_empty() { errs.push(format!("{r}: a part that fits only one way, without pin1.mark: name the mark you saw (pin-1 dot, notch, bevel, tab, cathode band, silk triangle) and where it sits, before routing")); }
else if let Some(e) = pin["evidence"].as_str() { if items::resolve(flow, e).is_none() { errs.push(format!("{r}: pin1 evidence {e} is not there")); } }
else if is_seen(s(pin, "tag")) { errs.push(format!("{r}: pin1 tagged {} without an evidence crop", s(pin, "tag"))); }
}
}
for i in 0..placed.len() {
for j in i + 1..placed.len() {
let (a, b) = (&placed[i], &placed[j]);
if a.1 == b.1 && ((a.2 - b.2).powi(2) + (a.3 - b.3).powi(2)).sqrt() < 0.5 {
errs.push(format!("{} and {} sit within 0.5 mm of each other on the {}: one part counted twice?", a.0, b.0, a.1));
}
}
}
// every candidate the program measured is accounted for: used by a part, or dismissed with a reason
if let Ok(det) = items::read_json(&flow.join("detect/parts.json")) {
let used: std::collections::HashSet<String> = list.iter().filter_map(|p| p["candidate"].as_str().map(str::to_string)).collect();
let dismissed: HashMap<String, String> = d["dismissed"].as_array().into_iter().flatten().map(|x| (s(x, "candidate").to_string(), s(x, "why").to_string())).collect();
for (c, why) in &dismissed { if why.is_empty() { errs.push(format!("dismissed {c}: say why (silkscreen, logo, reflection, part of another body)")); } }
let open_c: Vec<String> = det["candidates"].as_array().into_iter().flatten().filter_map(|c| c["id"].as_str()).filter(|c| !used.contains(*c) && !dismissed.contains_key(*c)).map(str::to_string).collect();
if !open_c.is_empty() { errs.push(format!("{} measured candidate(s) neither used by a part nor dismissed: {}", open_c.len(), open_c.iter().take(30).cloned().collect::<Vec<_>>().join(", "))); }
}
if !errs.is_empty() { return Err(errs); }
let open = its.iter().filter(|i| i.open()).count();
let hints = vec![format!("{} parts, {open} with something inferred or unknown. Every one of those needs a test in step 7.", its.len())];
Ok(Pass { summary: json!({"parts": its.len(), "tags": tag_counts(&its), "open": open}), hints, files: vec!["parts.json".into()] })
}
fn is_seen(t: &str) -> bool { matches!(t, "SEEN" | "MEASURED") }
pub fn blocks(flow: &Path) -> Outcome {
let d = doc(flow, "blocks.json")?;
let p = doc(flow, "parts.json")?;
let refs: Vec<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();
let its = items::items_of("blocks.json", &d);
let mut errs = vec![];
if its.is_empty() { errs.push("blocks.json has no blocks".into()); }
validate_all(flow, &its, &mut errs);
let mut owner: HashMap<String, String> = HashMap::new();
for b in d["blocks"].as_array().into_iter().flatten() {
let id = s(b, "id");
if s(b, "name").is_empty() || s(b, "function").is_empty() { errs.push(format!("block:{id}: give a name and a function (what it does)")); }
for r in b["parts"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) {
if !refs.iter().any(|x| x == r) { errs.push(format!("block:{id}: part {r} is not in parts.json")); }
if let Some(o) = owner.insert(r.into(), id.into()) { errs.push(format!("{r} is in two blocks: {o} and {id}")); }
}
}
let loose: Vec<&String> = refs.iter().filter(|r| !owner.contains_key(*r)).collect();
if !loose.is_empty() { errs.push(format!("parts in no block (holes, logos and fiducials go in a `mechanical` block): {}", loose.iter().map(|x| x.as_str()).collect::<Vec<_>>().join(", "))); }
if !errs.is_empty() { return Err(errs); }
Ok(Pass { summary: json!({"blocks": its.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["blocks.json".into(), "parts.json".into()] })
}
pub fn inner_vias(copper: &Value) -> usize {
copper["vias"].as_array().into_iter().flatten().filter(|v| s(v, "kind") == "inner").count()
}
pub fn copper(flow: &Path) -> Outcome {
let d = doc(flow, "copper.json")?;
let its = items::items_of("copper.json", &d);
let mut errs = vec![];
validate_all(flow, &its, &mut errs);
match d["outline"]["points_mm"].as_array() {
Some(a) if a.len() >= 3 => {}
_ => errs.push("outline.points_mm needs at least three [x, y] points (the measured board edge)".into()),
}
for (key, need) in [("zones", ["net", "layer"]), ("traces", ["net", "layer"])] {
for v in d[key].as_array().into_iter().flatten() {
for k in need { if s(v, k).is_empty() { errs.push(format!("{key} {}: `{k}` missing (UNKNOWN is a valid net name only with an UNKNOWN tag)", s(v, "id"))); } }
}
}
for z in d["zones"].as_array().into_iter().flatten() {
let id = s(z, "id");
let pts = z["polygon_mm"].as_array().map(|a| a.len()).unwrap_or(0);
if pts < 3 { errs.push(format!("zone {id}: no polygon_mm: sketch it (sketch_mm) and run `copper fit`")); continue; }
// an outline traced from measured regions is measured by construction; a hand sketch must land on gaps
if matches!(s(z, "tag"), "SEEN" | "MEASURED") && s(z, "from") != "regions" && s(z, "from") != "islands" {
let share = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0);
if share < 0.6 { errs.push(format!("zone {id}: only {:.0}% of its outline sits on a measured gap line: re-sketch it closer to the photo, or tag it INFERRED with a test if its edges are hidden", share * 100.0)); }
}
}
for v in d["vias"].as_array().into_iter().flatten() {
if pos(v).is_none() { errs.push(format!("via {}: pos_mm missing", s(v, "id"))); }
if !VIA_KINDS.contains(&s(v, "kind")) { errs.push(format!("via {}: kind must be one of {}", s(v, "id"), VIA_KINDS.join(", "))); }
}
// the copper as a whole: no island may have swallowed a face (`copperchecks`)
// A face the reader got wrong as a whole is not something a runner can fix: it is recorded as NOT READ and the run goes on
// with the failure carried (`notread`). Anything else here is an error to fix, as before.
let (swallowed, look) = crate::copperchecks::of_run(flow);
let (mut not_read, other) = crate::notread::split(swallowed);
// a face the reader gave up on at trace time (`faceread`) is the same failure found earlier: one record, one set of words
for f in crate::notread::declared(flow) { if !not_read.iter().any(|x| x.side == f.side) { not_read.push(f); } }
let look: Vec<String> = look.into_iter().filter(|l| !l.contains(" face was NOT READ: ")).collect();
errs.extend(other);
if !errs.is_empty() { errs.extend(not_read.iter().flat_map(|f| f.messages.clone())); return Err(errs); }
let n = inner_vias(&d);
let mut hints: Vec<String> = not_read.iter().map(crate::notread::sentence).collect();
if !not_read.is_empty() { hints.push("This is a fault of the copper reader, not of your inputs: nothing you may type changes it, and nobody edits copper by hand. `auto` goes on: attribution runs again without that face, and the review, tests and report carry the failure.".into()); }
hints.extend(look);
if n > 0 { hints.push(format!("{n} inner via(s): ringed on both outer faces with no trace on either, so they continue on an inner layer. The board has at least 4 layers.")); }
else { hints.push("No inner vias recorded. If both faces are fully visible, 2 layers is possible; say so in step 5 only after looking under the large parts and pours.".into()); }
let mut summary = json!({"zones": d["zones"].as_array().map(|a| a.len()).unwrap_or(0), "traces": d["traces"].as_array().map(|a| a.len()).unwrap_or(0), "vias": d["vias"].as_array().map(|a| a.len()).unwrap_or(0), "innerVias": n, "tags": tag_counts(&its)});
if !not_read.is_empty() { summary["notRead"] = crate::notread::to_json(¬_read); }
Ok(Pass { summary, hints, files: vec!["copper.json".into()] })
}
fn find_glob(flow: &Path, prefix: &str) -> bool {
for base in [flow.to_path_buf(), flow.parent().map(Path::to_path_buf).unwrap_or_default()] {
if let Ok(rd) = std::fs::read_dir(base.join("evidence")) {
if rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with(prefix)) { return true; }
}
}
false
}
fn ang(a: f64, b: f64, m: f64) -> f64 { let d = (a - b).rem_euclid(m); d.min(m - d) }
/// The corner points of one zone's outline, from the text after its `(zone` up to its end.
fn zone_outline(z: &str) -> Vec<(f64, f64)> {
let Some(start) = z.find("(polygon") else { return vec![] };
let body = &z[start..];
let body = &body[..body.find("\n\t)").unwrap_or(body.len())];
body.split("(xy ").skip(1).filter_map(|t| { let mut w = t.split(|c: char| c == ' ' || c == ')'); Some((w.next()?.parse().ok()?, w.next()?.parse().ok()?)) }).collect()
}
fn outline_area(o: &[(f64, f64)]) -> f64 {
(0..o.len()).map(|i| { let (a, b) = (o[i], o[(i + 1) % o.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>().abs() / 2.0
}
#[cfg(test)]
mod zone_outline_tests {
use super::*;
#[test]
fn a_keep_out_rule_area_is_not_a_zone_with_no_fill_and_a_left_out_part_says_why() {
let d = std::env::temp_dir().join(format!("mimic-keepout-{}", std::process::id())).join("reverse-flow");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("evidence")).unwrap();
std::fs::write(d.join("evidence/overlay-top.jpg"), "x").unwrap();
std::fs::write(d.join("parts.json"), json!({"parts": []}).to_string()).unwrap();
std::fs::write(d.join("copper.json"), json!({"zones": [{"id": "z1"}]}).to_string()).unwrap();
let peg = "\n\t(zone\n\t\t(net 0)\n\t\t(layers \"F.Cu\")\n\t\t(name \"peg\")\n\t\t(keepout\n\t\t\t(tracks not_allowed)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)";
let pour = "\n\t(zone\n\t\t(net 1)\n\t\t(name \"z1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t\t(filled_polygon (pts (xy 0 0)))\n\t)";
let pcb = d.join("b.kicad_pcb");
std::fs::write(&pcb, format!("(kicad_pcb{peg}{peg}{pour}\n)")).unwrap();
let r = mimic(&d, &pcb, &json!({"photos": []}));
let pass = r.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(pass.summary["zones"], 1, "the two rule areas are not counted as copper zones");
// the same areas with no keepout would be zones that lost their fill
std::fs::write(&pcb, format!("(kicad_pcb{}{pour}\n)", peg.replace("(keepout", "(other"))).unwrap();
assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: peg"));
// a 6 mm2 pour with no fill is an error, unless the build recorded it with its area and what covers it: then it is a finding and the check goes on
let bare = pour.replace("\n\t\t(filled_polygon (pts (xy 0 0)))", "").replace("(xy 4 0) (xy 4 3)", "(xy 4 0) (xy 4 1.5)");
std::fs::write(&pcb, format!("(kicad_pcb{bare}\n)")).unwrap();
assert!(mimic(&d, &pcb, &json!({"photos": []})).err().unwrap()[0].contains("zones with no fill: z1"));
std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": [], "noFill": [{"id": "z1", "areaMm2": 6.0, "why": "every part of it lies inside pads of another net: J1.1"}]}).to_string()).unwrap();
let pass = mimic(&d, &pcb, &json!({"photos": []})).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(pass.summary["noFill"][0]["id"], "z1");
assert!(pass.hints[0].starts_with("FINDING, not passed: pour z1 (6.0 mm2) is drawn with no fill") && pass.hints[0].contains("J1.1"), "{:?}", pass.hints);
// a part the build left out is named with the footprint it wants
std::fs::write(d.join("parts.json"), json!({"parts": [{"ref": "SW2", "side": "top", "footprint": "RE6:THT_3Pin"}]}).to_string()).unwrap();
let rows = crate::kicad_out::left_out_rows(&[format!("{}SW2: unexpected token at 12", crate::kicad_out::LEFT_OUT_MARK), "zone z9: something else".into()], &[json!({"ref": "SW2", "footprint": "RE6:THT_3Pin"})]);
assert_eq!(rows.len(), 1);
std::fs::write(d.join(crate::kicad_out::LEFT_OUT), json!({"leftOut": rows}).to_string()).unwrap();
let e = mimic(&d, &pcb, &json!({"photos": []})).err().unwrap();
assert!(e.iter().any(|x| x.contains("SW2: in parts.json but not on the board: the build left it out") && x.contains("RE6:THT_3Pin") && x.contains("reverse-flow/lib/fp/RE6/THT_3Pin.kicad_mod")), "{e:?}");
std::fs::remove_dir_all(d.parent().unwrap()).ok();
}
#[test]
fn outline_stops_at_the_end_of_its_zone() {
let z = "\n\t\t(name \"a#1\")\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy 0 0) (xy 4 0) (xy 4 3) (xy 0 3)\n\t\t\t)\n\t\t)\n\t)\n\t(footprint (at 9 9))\n\t(xy 50 50)";
let o = zone_outline(z);
assert_eq!(o.len(), 4);
assert!((outline_area(&o) - 12.0).abs() < 1e-9);
}
}
pub fn mimic(flow: &Path, pcb: &Path, state: &Value) -> Outcome {
let d = doc(flow, "parts.json")?;
let text = std::fs::read_to_string(pcb).map_err(|e| vec![format!("{}: {e}", pcb.display())])?;
let fps = kicad::footprints(&text);
let mut errs = vec![];
let on_board: HashMap<String, &kicad::Footprint> = fps.iter().map(|f| (f.reference.clone(), f)).collect();
let mut wanted = HashSet::new();
for p in d["parts"].as_array().into_iter().flatten() {
let r = s(p, "ref");
if p["onBoard"] == json!(false) { continue; }
wanted.insert(r.to_string());
let Some(f) = on_board.get(r) else {
let why = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["leftOut"].as_array().and_then(|a| a.iter().find(|x| s(x, "ref") == r).map(|x| s(x, "why").to_string())));
errs.push(match why { Some(w) => format!("{r}: in parts.json but not on the board: the build left it out: {w}"), None => format!("{r}: in parts.json but not on the board") }); continue };
let side_ok = (s(p, "side") == "top") == f.layer.starts_with("F.");
if !side_ok { errs.push(format!("{r}: parts.json says {} but the footprint is on {}", s(p, "side"), f.layer)); }
if let Some((x, y)) = pos(p) {
let dd = ((f.x - x).powi(2) + (f.y - y).powi(2)).sqrt();
if dd > 0.5 { errs.push(format!("{r}: on the board at ({:.2}, {:.2}), measured at ({x:.2}, {y:.2}): {dd:.2} mm apart (limit 0.5)", f.x, f.y)); }
}
if needs_pin1(p) && s(p, "side") == "top" {
if let Some(rot) = p["rot"].as_f64() {
if ang(f.rot, rot, 360.0) > 2.0 { errs.push(format!("{r}: rotated {} on the board, {rot} in parts.json: check pin 1 against the photo", f.rot)); }
}
}
}
for f in &fps {
// PHOTO1 holds the holes and metal no footprint explains, drawn from the photo: it is no part
if f.reference.is_empty() || f.reference.starts_with("REF**") || f.reference == "PHOTO1" { continue; }
if !wanted.contains(&f.reference) { errs.push(format!("{}: on the board but not in parts.json (add it with its evidence, or remove it)", f.reference)); }
}
if !find_glob(flow, "overlay-top") { errs.push("evidence/overlay-top.* missing: overlay the rendered board on the top photo and look at every part".into()); }
let has_bottom = state["photos"].as_array().into_iter().flatten().any(|p| s(p, "side") == "bottom");
if has_bottom && !find_glob(flow, "overlay-bottom") { errs.push("evidence/overlay-bottom.* missing: overlay the bottom render on the bottom photo (mirror one of them)".into()); }
if !errs.is_empty() { return Err(errs); }
// a keep-out rule area (KiCad's own USB-C footprint carries two named `peg`) is not copper and never has a fill.
// KiCad writes a zone's `keepout` before its outline, so the text up to the outline says which it is
let rule_area = |z: &str| z[..z.find("(polygon").unwrap_or(z.len())].contains("(keepout");
let zones = text.split("\n\t(zone").skip(1).filter(|z| !rule_area(z)).count();
let unfilled: Vec<(String, Vec<(f64, f64)>)> = text.split("\n\t(zone").skip(1).filter(|z| !z.contains("filled_polygon") && !rule_area(z)).map(|z| (z.split("(name \"").nth(1).and_then(|x| x.split('"').next()).unwrap_or("?").to_string(), zone_outline(z))).collect();
// a traced island under 5 mm2 can be cut away whole by routed copper and its clearance; a pour must never be.
// Each zone is judged by its own drawn outline: a holed island is written as several zones (`id#k`) that copper.json lists as one.
// A small zone centred on a part's origin is metal that part's own pad already draws, so nothing is lost.
let lost: Vec<String> = unfilled.iter().filter(|(_, o)| {
if outline_area(o) < 5.0 { return false; }
let (x0, x1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.0), a.1.max(p.0)));
let (y0, y1) = o.iter().fold((f64::MAX, f64::MIN), |a, p| (a.0.min(p.1), a.1.max(p.1)));
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
let on_pad = (x1 - x0).max(y1 - y0) < 6.0 && fps.iter().any(|f| (f.x - cx).hypot(f.y - cy) < 0.3);
!on_pad
}).map(|(n, _)| n.clone()).collect();
let unfilled: Vec<String> = unfilled.into_iter().map(|(n, _)| n).collect();
// A pour the build itself found covered whole by other copper, and recorded with its area and what covers it (`build-left-out.json`
// noFill), is a finding: no rebuild and no input changes it, the copper stays in copper.json, and the run goes on. One the build
// did not record is still an error: that would be a fill lost without a reason.
let recorded: HashMap<String, (String, String)> = items::read_json(&flow.join(crate::kicad_out::LEFT_OUT)).ok().and_then(|d| d["noFill"].as_array().cloned()).unwrap_or_default().iter().map(|z| (s(z, "id").to_string(), (z["areaMm2"].to_string(), s(z, "why").to_string()))).collect();
let base = |n: &str| n.split('#').next().unwrap_or(n).to_string();
let (known, lost): (Vec<String>, Vec<String>) = lost.into_iter().partition(|n| recorded.contains_key(&base(n)));
if !lost.is_empty() { return Err(vec![format!("zones with no fill: {} (build fills every zone: rebuild, and check the zone is not cut away entirely by other-net copper)", lost.join(", "))]); }
let no_fill: Vec<Value> = { let mut ids: Vec<String> = known.iter().map(|n| base(n)).collect(); ids.sort(); ids.dedup(); ids.iter().map(|id| json!({"id": id, "areaMm2": recorded[id].0, "why": recorded[id].1})).collect() };
let cz = items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["zones"].as_array().map(|a| a.len())).unwrap_or(0);
let mut hints: Vec<String> = no_fill.iter().map(|z| format!("FINDING, not passed: pour {} ({} mm2) is drawn with no fill: {}. It stays in copper.json; the review lists it", s(z, "id"), s(z, "areaMm2"), s(z, "why"))).collect();
if !unfilled.is_empty() { hints.push(format!("{} small island(s) under 5 mm2 cut away entirely by other copper: {}", unfilled.len(), unfilled.join(", "))); }
if zones < cz { return Err(vec![format!("the board has {zones} zone(s), copper.json lists {cz}: every seen zone must be on the board")]); }
let mut summary = json!({"pcb": pcb.display().to_string(), "pcbSha256": sha256(pcb), "footprints": fps.len(), "zones": zones});
if !no_fill.is_empty() { summary["noFill"] = json!(no_fill); }
Ok(Pass { summary, hints, files: vec!["parts.json".into(), "copper.json".into()] })
}
pub fn nets(flow: &Path) -> Outcome {
let d = doc(flow, "nets.json")?;
let p = doc(flow, "parts.json")?;
let refs: HashSet<String> = p["parts"].as_array().into_iter().flatten().map(|x| s(x, "ref").to_string()).collect();
let its = items::items_of("nets.json", &d);
let mut errs = vec![];
if its.is_empty() { errs.push("nets.json has no nets".into()); }
validate_all(flow, &its, &mut errs);
let mut pin_net: HashMap<String, String> = HashMap::new();
for n in d["nets"].as_array().into_iter().flatten() {
let name = s(n, "name");
let pins: Vec<&str> = n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
if pins.is_empty() { errs.push(format!("net:{name}: no pins")); }
for pin in pins {
let Some((r, _)) = pin.rsplit_once('.') else { errs.push(format!("net:{name}: pin {pin} is not REF.PIN")); continue };
// PHOTO1 holds the holes and metal no footprint explains, as in `check mimic`: it is no part
if r != "PHOTO1" && !refs.contains(r) { errs.push(format!("net:{name}: {r} is not in parts.json")); }
if let Some(o) = pin_net.insert(pin.into(), name.into()) { errs.push(format!("{pin} is on two nets: {o} and {name}")); }
}
}
if !errs.is_empty() { return Err(errs); }
Ok(Pass { summary: json!({"nets": its.len(), "pins": pin_net.len(), "tags": tag_counts(&its)}), hints: vec![], files: vec!["nets.json".into(), "parts.json".into()] })
}
pub fn layers(flow: &Path, pcb: Option<&Path>) -> Outcome {
let d = doc(flow, "layers.json").map_err(|mut e| { e.push("`layers state` writes it without routing: from outside evidence when given, otherwise UNKNOWN with its test".into()); e })?;
// stated without routing (`layers state`): the default. The rules below judge a count the optional router proposed
if d["method"] == json!("stated") { return crate::layerstate::check(flow, &d, pcb); }
let c = doc(flow, "copper.json")?;
let its = items::items_of("layers.json", &d);
let mut errs = vec![];
validate_all(flow, &its, &mut errs);
let cands: Vec<&Value> = d["candidates"].as_array().into_iter().flatten().collect();
if cands.is_empty() { errs.push("no candidates: `connect route` tries each stackup and records the result".into()); }
for k in &cands {
if k["layers"].as_u64().is_none() || k["open"].as_u64().is_none() { errs.push(format!("candidate {k}: needs `layers` and `open` (connections left after routing)")); }
if s(k, "run").is_empty() { errs.push(format!("candidate {}: `run` must name the route folder or report it came from", k["layers"])); }
}
let proposed = d["proposed"].as_u64().unwrap_or(0);
// routing that could not tell the count says UNKNOWN with its test (`write_proposal`): that is an honest answer and passes
let unknown = proposed == 0 && s(&d, "tag") == "UNKNOWN" && d["tests"].as_array().map(|a| !a.is_empty()).unwrap_or(false) && !cands.is_empty();
if proposed == 0 && !unknown { errs.push("`proposed` (the layer count you propose) missing: `layers state` states it without routing".into()); }
if s(&d, "why").is_empty() { errs.push("`why` missing: the evidence for the proposal (inner vias, routing results, planes)".into()); }
let inner = inner_vias(&c);
let floor = if inner > 0 { 4 } else { 2 };
let tried = cands.iter().filter_map(|k| k["layers"].as_u64()).min().unwrap_or(0);
if tried > floor { errs.push(format!("the fewest layers routed is {tried}, but the evidence allows {floor}: route at {floor} first, so the proposal is the smallest count that works")); }
if inner > 0 && proposed > 0 && proposed < 4 { errs.push(format!("{inner} inner via(s) in copper.json prove at least 4 layers; {proposed} proposed")); }
let chosen = cands.iter().find(|k| k["layers"].as_u64() == Some(proposed));
if proposed > 0 && chosen.is_none() { errs.push(format!("{proposed} layers proposed but no candidate routed at {proposed}")); }
if let Some(k) = chosen {
if let Some(e) = k["drcError"].as_str() { errs.push(format!("KiCad's DRC did not run on the proposed {proposed}-layer route ({e}): its errors and missing connections are unknown. Run `connect route --layers {proposed}` again when the service answers")); }
if k["open"].as_u64().unwrap_or(0) > 0 && s(&d, "openNote").is_empty() {
errs.push(format!("the proposed {proposed}-layer route leaves {} connection(s) open: route more, propose more layers, or explain in `openNote` (and list them in tests)", k["open"]));
}
}
let nets: HashSet<String> = items::read_json(&flow.join("nets.json")).ok().map(|n| n["nets"].as_array().into_iter().flatten().map(|x| s(x, "name").to_string()).collect()).unwrap_or_default();
for pl in d["planes"].as_array().into_iter().flatten() {
let l = s(pl, "layer");
let idx = l.strip_prefix("In").and_then(|x| x.strip_suffix(".Cu")).and_then(|x| x.parse::<u64>().ok());
match idx { Some(i) if proposed >= 2 && i <= proposed - 2 => {}, _ => errs.push(format!("plane {l}: not an inner layer of a {proposed}-layer board (In1.Cu ..)")) }
if !nets.contains(s(pl, "net")) { errs.push(format!("plane {l}: net {} is not in nets.json", s(pl, "net"))); }
if s(pl, "why").is_empty() { errs.push(format!("plane {l}: `why` missing (which vias and pins say this layer is {})", s(pl, "net"))); }
}
if let Some(p) = pcb.filter(|_| !unknown) {
let t = std::fs::read_to_string(p).map_err(|e| vec![format!("{}: {e}", p.display())])?;
let n = kicad::copper_layers(&t);
if n as u64 != proposed { errs.push(format!("{} has {n} copper layers, {proposed} proposed: save the project with the proposed stackup", p.display())); }
}
if !errs.is_empty() { return Err(errs); }
let min_closed = cands.iter().filter(|k| k["open"].as_u64() == Some(0)).filter_map(|k| k["layers"].as_u64()).min();
let mut hints = vec![];
if let Some(m) = min_closed { if proposed > m.max(if inner > 0 { 4 } else { 2 }) { hints.push(format!("{m} layers already closed every connection; {proposed} proposed: make sure `why` says what the extra layers are for")); } }
Ok(Pass { summary: json!({"proposed": proposed, "innerVias": inner, "candidates": cands.len(), "planes": d["planes"].as_array().map(|a| a.len()).unwrap_or(0)}), hints, files: vec!["layers.json".into(), "nets.json".into(), "copper.json".into()] })
}
pub fn current_hashes(flow: &Path) -> BTreeMap<String, String> {
REVIEWED.iter().map(|f| f.to_string()).filter(|f| flow.join(f).is_file()).chain(crate::confidence::audit_files(flow)).map(|f| { let h = sha256(&flow.join(&f)); (f, h) }).collect()
}
/// Passes only when every category met the target or the stall is stated honestly: scores and the verdict
/// are computed again from the files, and what review.json records is only compared with them.
pub fn review(flow: &Path) -> Outcome {
let d = doc(flow, "review.json")?;
let (s, verdict) = crate::confidence::gate(flow, &d, ¤t_hashes(flow))?;
let mut files: Vec<String> = current_hashes(flow).into_keys().collect();
files.push("review.json".into());
let mut hints = vec![crate::confidence::overall_line(&s, &verdict)];
if s.target != crate::confidence::DEFAULT_TARGET { hints.push(format!("the target here is {}, not the default {}", s.target, crate::confidence::DEFAULT_TARGET)); }
if verdict != "TARGET MET" { hints.push(format!("{verdict}: the photo score did not meet the target. The report must say so")); hints.extend(crate::confidence::stall_lines(&s)); }
hints.push(format!("bench score {:.1}, reported beside the photo score and outside the target. What would raise it:", s.bench));
hints.extend(crate::confidence::bench_lines(&s));
Ok(Pass { summary: json!({"verdict": verdict, "target": s.target, "photo": s.photo, "bench": s.bench, "overall": s.overall, "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}), hints, files })
}
pub fn tests(flow: &Path) -> Outcome {
let d = doc(flow, "tests.json")?;
let all = items::all(flow);
let ids: HashSet<&str> = all.iter().map(|i| i.id.as_str()).collect();
// a face whose copper was not read is an item too, with its own test (`notread`)
let faces: HashSet<String> = crate::notread::faces(flow).iter().map(|f| crate::notread::item_id(&f.side)).collect();
let known = |x: &str| ids.contains(x) || faces.contains(x) || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);
let mut errs = vec![];
let mut test_ids = HashSet::new();
let mut settled: HashSet<String> = HashSet::new();
for t in d["tests"].as_array().into_iter().flatten() {
let id = s(t, "id");
if id.is_empty() || !test_ids.insert(id.to_string()) { errs.push(format!("test {id:?}: empty or repeated id")); }
for k in ["method", "equipment", "expected", "ifNot"] { if s(t, k).is_empty() { errs.push(format!("test {id}: `{k}` missing")); } }
let st: Vec<&str> = t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
if st.is_empty() { errs.push(format!("test {id}: settles nothing")); }
for x in st {
if !known(x) { errs.push(format!("test {id}: settles {x}, which is no item (part ref, block:, zone:, trace:, via:, net:, plane:, outline, layers)")); }
settled.insert(x.split('.').next().unwrap_or(x).to_string());
settled.insert(x.to_string());
}
}
for it in &all {
for t in &it.tests { if !test_ids.contains(t) { errs.push(format!("{}: names test {t}, which is not in tests.json", it.id)); } }
let named = it.tests.iter().any(|t| test_ids.contains(t));
if it.open() && !settled.contains(&it.id) && !named { errs.push(format!("{} ({}) is settled by no test in tests.json", it.id, it.open_what().join(", "))); }
}
for f in &faces { if !settled.contains(f) { errs.push(format!("{f}: its copper was not read and no test in tests.json settles it: `tests generate` writes one")); } }
if !errs.is_empty() { return Err(errs); }
let mut files: Vec<String> = items::ARTIFACTS.iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();
files.push("tests.json".into());
Ok(Pass { summary: json!({"tests": test_ids.len(), "openItems": all.iter().filter(|i| i.open()).count()}), hints: vec![], files })
}
pub fn write_ok(flow: &Path, name: &str, pass: &Pass) -> std::io::Result<()> {
std::fs::create_dir_all(flow.join("checks"))?;
// the copper check reads shapes: attribution writes nets into the same file and must not make it stale
let files: BTreeMap<String, String> = pass.files.iter().map(|f| (f.clone(), if name == "copper" && f == "copper.json" { crate::attrib::geometry_hash(&flow.join(f)) } else { sha256(&flow.join(f)) })).collect();
let v = json!({"check": name, "at": ledger::now(), "files": files, "summary": pass.summary});
std::fs::write(flow.join(format!("checks/{name}.ok")), serde_json::to_string_pretty(&v)? + "\n")
}