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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
//! `check schematic`: what must hold before the schematic is handed over.
//!
//! 1. KiCad's ERC on the whole hierarchy, zero errors. It runs through KiCad's own command line
//! (`ADOM_AIFLOW_KICAD_CLI`, else `kicad-cli` on PATH). An ERC that did not run is unknown, never zero.
//! 2. The netlist KiCad exports from the sheets equals nets.json pin by pin: the same parts, the same
//! pins on each part as its footprint has pads, every net of nets.json with exactly its pins, and no
//! join between pins that nets.json does not hold.
//! 3. The owner's sheet rules a machine can read (PCB design process 1.3 to 1.8, ported from the AI Flow
//! schematic check): every label on a wire with its text running back along it, right angles only,
//! every sheet a titled block, no block of decoupling only or notes only, decoupling wired at its IC,
//! no text under 40 mil, capacitor values without F, pin names hidden on passives, nothing outside
//! the border, no text over a pin or a body, and a no-connect flag only on a pin nets.json lists as
//! unused. The geometry is estimated (text sizes from the font size), so the overlap rules are
//! heuristics; they are written apart from the generator so it does not mark its own homework.
use crate::checks::{Outcome, Pass};
use crate::sexp::{self, N};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Path, PathBuf};
type Pt = (f64, f64);
type Bx = (f64, f64, f64, f64);
fn close(a: Pt, b: Pt) -> bool { (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3 }
fn on_seg(p: Pt, a: Pt, b: Pt) -> bool {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let l2 = dx * dx + dy * dy;
if l2 < 1e-9 { return close(p, a); }
let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2;
if !(-1e-6..=1.0 + 1e-6).contains(&t) { return false; }
((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt() < 1e-3
}
fn hit(a: &Bx, b: &Bx) -> bool { a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3 }
fn xy(n: &N) -> Pt { (n.num(0).unwrap_or(0.0), n.num(1).unwrap_or(0.0)) }
fn hidden(n: &N) -> bool {
let flag = |x: &N| x.items().iter().any(|i| matches!(i, N::A(s) if s == "hide")) || x.child("hide").map(|h| h.arg(0).as_deref() != Some("no")).unwrap_or(false);
flag(n) || n.child("effects").map(flag).unwrap_or(false)
}
fn font_size(n: &N) -> f64 { n.child("effects").and_then(|e| e.child("font")).and_then(|f| f.child("size")).and_then(|s| s.num(0)).unwrap_or(1.27) }
fn ref_prefix(r: &str) -> String { r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_uppercase() }
/// KiCad's command line, if this machine has one that answers.
pub fn kicad_cli() -> Option<String> {
let c = std::env::var("ADOM_AIFLOW_KICAD_CLI").ok().filter(|s| !s.is_empty()).unwrap_or("kicad-cli".into());
let o = std::process::Command::new(&c).arg("version").output().ok()?;
let v = String::from_utf8_lossy(&o.stdout).trim().to_string();
(o.status.success() && v.chars().next().map(|ch| ch.is_ascii_digit()).unwrap_or(false)).then_some(c)
}
fn run_cli(cli: &str, args: &[&str], out: &Path, root: &Path) -> Result<String, String> {
let _ = std::fs::remove_file(out);
let o = std::process::Command::new(cli).args(args).arg("--output").arg(out).arg(root).output().map_err(|e| format!("{cli} is not runnable: {e}"))?;
std::fs::read_to_string(out).map_err(|_| format!("{cli} {} wrote nothing: {}", args.join(" "), String::from_utf8_lossy(&o.stderr).trim().chars().take(200).collect::<String>()))
}
pub struct Netlist { pub parts: BTreeMap<String, BTreeSet<String>>, pub nets: Vec<(String, Vec<(String, String)>)> }
/// Parts and nets of a KiCad netlist; symbols whose reference starts with `#` are no parts.
pub fn parse_netlist(text: &str) -> Result<Netlist, String> {
let n = sexp::parse(text)?;
let mut nl = Netlist { parts: BTreeMap::new(), nets: vec![] };
for c in n.child("components").into_iter().flat_map(|c| c.children("comp")) { if let Some(r) = c.child("ref").and_then(|r| r.arg(0)) { if !r.starts_with('#') { nl.parts.entry(r).or_default(); } } }
for net in n.child("nets").into_iter().flat_map(|c| c.children("net")) {
let name = net.child("name").and_then(|x| x.arg(0)).unwrap_or_default();
let nodes: Vec<(String, String)> = net.children("node").filter_map(|nd| Some((nd.child("ref")?.arg(0)?, nd.child("pin")?.arg(0)?))).filter(|(r, _)| !r.starts_with('#')).collect();
for (r, p) in &nodes { nl.parts.entry(r.clone()).or_default().insert(p.clone()); }
nl.nets.push((name, nodes));
}
Ok(nl)
}
/// The net name a label gives: nets.json names with the characters a KiCad label cannot hold replaced.
fn sheet_name(raw: &str) -> String { crate::schsym::ascii(raw).chars().map(|c| match c { ' ' | '/' | '\t' => '_', '{' => '(', '}' => ')', c => c }).collect() }
/// The netlist against nets.json and the footprints' pads. Returns (errors, summary).
pub fn compare(nl: &Netlist, nets_doc: &Value, pads: &BTreeMap<String, BTreeSet<String>>) -> (Vec<String>, Value) {
let mut errs = vec![];
for r in pads.keys() { if !nl.parts.contains_key(r) { errs.push(format!("{r} is in parts.json and not on the schematic")); } }
for r in nl.parts.keys() { if !pads.contains_key(r) { errs.push(format!("{r} is on the schematic and not in parts.json")); } }
for (r, want) in pads {
if let Some(have) = nl.parts.get(r) {
let (miss, extra): (Vec<&String>, Vec<&String>) = (want.difference(have).collect(), have.difference(want).collect());
if !miss.is_empty() { errs.push(format!("{r}: pad(s) {} of the footprint have no pin on the schematic", miss.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }
if !extra.is_empty() { errs.push(format!("{r}: pin(s) {} on the schematic are no pad of the footprint", extra.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "))); }
}
}
let where_is: HashMap<(String, String), usize> = nl.nets.iter().enumerate().flat_map(|(i, (_, nodes))| nodes.iter().map(move |n| (n.clone(), i))).collect();
let mut explained: BTreeSet<usize> = BTreeSet::new();
let (mut n_nets, mut n_pins, mut named_ok) = (0, 0, 0);
for n in nets_doc["nets"].as_array().into_iter().flatten() {
let raw = n["name"].as_str().unwrap_or("");
let want: BTreeSet<(String, String)> = n["pins"].as_array().into_iter().flatten().filter_map(|p| p.as_str()).filter_map(|p| p.rsplit_once('.')).filter(|(r, _)| pads.contains_key(*r)).map(|(r, p)| (r.to_string(), p.to_string())).collect();
if want.is_empty() { continue; }
n_nets += 1; n_pins += want.len();
let homes: BTreeSet<Option<usize>> = want.iter().map(|p| where_is.get(p).copied()).collect();
if homes.len() > 1 || homes.contains(&None) {
let lost: Vec<String> = want.iter().filter(|p| !where_is.contains_key(*p)).map(|(r, p)| format!("{r}.{p}")).collect();
errs.push(format!("net {raw}: its {} pins are on {} schematic nets{}", want.len(), homes.iter().filter(|h| h.is_some()).count(), if lost.is_empty() { String::new() } else { format!(" and {} are on none ({})", lost.len(), lost.iter().take(6).cloned().collect::<Vec<_>>().join(", ")) }));
continue;
}
let i = homes.into_iter().next().flatten().unwrap();
explained.insert(i);
let have: BTreeSet<(String, String)> = nl.nets[i].1.iter().cloned().collect();
let extra: Vec<String> = have.difference(&want).map(|(r, p)| format!("{r}.{p}")).collect();
if !extra.is_empty() { errs.push(format!("net {raw}: the schematic also joins {} to it", extra.iter().take(8).cloned().collect::<Vec<_>>().join(", "))); continue; }
// a net the run named carries that name on the sheet; one it only numbered may keep KiCad's own
let unnamed = n["nameSource"].as_str().map(|s| s.starts_with("none")).unwrap_or(false);
let got = nl.nets[i].0.rsplit('/').next().unwrap_or("");
if got == sheet_name(raw) { named_ok += 1; } else if !unnamed { errs.push(format!("net {raw} is called {} on the schematic", nl.nets[i].0)); }
}
for (i, (name, nodes)) in nl.nets.iter().enumerate() {
if nodes.len() > 1 && !explained.contains(&i) { errs.push(format!("the schematic joins {} (net {name}) and nets.json holds no such net", nodes.iter().take(6).map(|(r, p)| format!("{r}.{p}")).collect::<Vec<_>>().join(", "))); }
}
(errs, json!({"parts": pads.len(), "nets": n_nets, "pins": n_pins, "netsNamedAsInNetsJson": named_ok}))
}
struct LibSym { pins: Vec<(Pt, String, String)>, body: Option<Bx>, names_hidden: bool, numbers_hidden: bool, power: bool, min_text: f64, boxy: bool }
fn lib_sym(n: &N) -> LibSym {
let mut l = LibSym { pins: vec![], body: None, names_hidden: n.child("pin_names").map(hidden).unwrap_or(false), numbers_hidden: n.child("pin_numbers").map(hidden).unwrap_or(false), power: n.child("power").is_some(), min_text: f64::MAX, boxy: true };
let mut grow = |b: &mut Option<Bx>, p: Pt| { *b = Some(match *b { None => (p.0, p.1, p.0, p.1), Some(q) => (q.0.min(p.0), q.1.min(p.1), q.2.max(p.0), q.3.max(p.1)) }); };
let mut shapes = 0;
for sub in n.children("symbol") {
for g in sub.items().iter().skip(2) {
match g.head() {
"pin" => {
let at = g.child("at").map(xy).unwrap_or((0.0, 0.0));
l.pins.push((at, g.child("number").and_then(|c| c.arg(0)).unwrap_or_default(), g.child("name").and_then(|c| c.arg(0)).unwrap_or_default()));
if !hidden(g) {
if !l.names_hidden { if let Some(c) = g.child("name") { if !c.arg(0).unwrap_or_default().is_empty() { l.min_text = l.min_text.min(font_size(c)); } } }
if !l.numbers_hidden { if let Some(c) = g.child("number") { l.min_text = l.min_text.min(font_size(c)); } }
}
}
"rectangle" => { shapes += 1; for k in ["start", "end"] { if let Some(p) = g.child(k) { grow(&mut l.body, xy(p)); } } }
"polyline" | "bezier" => { shapes += 1; l.boxy = false; for p in g.child("pts").into_iter().flat_map(|p| p.children("xy")) { grow(&mut l.body, xy(p)); } }
"circle" => { shapes += 1; l.boxy = false; if let (Some(c), Some(r)) = (g.child("center").map(xy), g.child("radius").and_then(|r| r.num(0))) { grow(&mut l.body, (c.0 - r, c.1 - r)); grow(&mut l.body, (c.0 + r, c.1 + r)); } }
"arc" => { shapes += 1; l.boxy = false; for k in ["start", "mid", "end"] { if let Some(p) = g.child(k) { grow(&mut l.body, xy(p)); } } }
"text" => { l.min_text = l.min_text.min(font_size(g)); }
_ => {}
}
}
}
l.boxy = l.boxy && shapes == 1;
l
}
fn turn(v: Pt, r: f64) -> Pt { match (r.round() as i64).rem_euclid(360) { 0 => v, 90 => (v.1, -v.0), 180 => (-v.0, -v.1), _ => (-v.1, v.0) } }
const PAPER: [(&str, f64, f64); 5] = [("A4", 297.0, 210.0), ("A3", 420.0, 297.0), ("A2", 594.0, 420.0), ("A1", 841.0, 594.0), ("A0", 1189.0, 841.0)];
/// Text box of a run of text anchored at `at`, running along `dir` (degrees), sitting above its baseline when `above`.
fn text_box(at: Pt, len: f64, size: f64, dir: i64, above: bool) -> Bx {
let (lo, hi) = if above { (-size * 1.25, -0.15) } else { (-size * 0.6, size * 0.6) };
match dir { 0 => (at.0, at.1 + lo, at.0 + len, at.1 + hi), 180 => (at.0 - len, at.1 + lo, at.0, at.1 + hi), 90 => (at.0 + lo, at.1 - len, at.0 + hi, at.1), _ => (at.0 + lo, at.1, at.0 + hi, at.1 + len) }
}
/// The sheet rules on one file. `nc_ok`: the pins nets.json lists as unused. Returns the counts.
pub fn style(text: &str, file: &str, nc_ok: &BTreeSet<String>, errors: &mut Vec<String>, warnings: &mut Vec<String>) -> Result<Value, String> {
let root = sexp::parse(text)?;
let libs: HashMap<String, LibSym> = root.child("lib_symbols").into_iter().flat_map(|l| l.children("symbol")).map(|s| (s.arg(0).unwrap_or_default(), lib_sym(s))).collect();
let paper = root.child("paper").and_then(|p| p.arg(0)).unwrap_or("A4".into());
let (pw, ph) = PAPER.iter().find(|p| p.0 == paper).map(|p| (p.1, p.2)).unwrap_or((297.0, 210.0));
let outside = |p: Pt| p.0 < 10.0 || p.1 < 10.0 || p.0 > pw - 10.0 || p.1 > ph - 10.0 || (p.0 > pw - 120.0 && p.1 > ph - 45.0);
let e = |errors: &mut Vec<String>, s: String| errors.push(format!("{file}: {s}"));
// wires and what they join
let wires: Vec<(Pt, Pt, bool)> = root.children("wire").filter_map(|w| { let p: Vec<Pt> = w.child("pts")?.children("xy").map(xy).collect(); (p.len() == 2).then(|| (p[0], p[1], w.child("stroke").and_then(|s| s.child("type")).and_then(|t| t.arg(0)).map(|t| t != "default" && t != "solid").unwrap_or(false))) }).collect();
for w in &wires { if (w.0 .0 - w.1 .0).abs() > 1e-3 && (w.0 .1 - w.1 .1).abs() > 1e-3 { e(errors, format!("wire ({:.2}, {:.2}) to ({:.2}, {:.2}) is not at a right angle", w.0 .0, w.0 .1, w.1 .0, w.1 .1)); } }
let mut parent: Vec<usize> = (0..wires.len()).collect();
fn find(p: &mut Vec<usize>, mut i: usize) -> usize { while p[i] != i { p[i] = p[p[i]]; i = p[i]; } i }
let mut at_end: HashMap<(i64, i64), Vec<usize>> = HashMap::new();
let key = |p: Pt| ((p.0 * 100.0).round() as i64, (p.1 * 100.0).round() as i64);
for (i, w) in wires.iter().enumerate() { at_end.entry(key(w.0)).or_default().push(i); at_end.entry(key(w.1)).or_default().push(i); }
for v in at_end.values() { for k in 1..v.len() { let (a, b) = (find(&mut parent, v[0]), find(&mut parent, v[k])); parent[a] = b; } }
let wire_at = |p: Pt, parent: &mut Vec<usize>| -> Option<usize> { at_end.get(&key(p)).map(|v| v[0]).or_else(|| (0..wires.len()).find(|&i| on_seg(p, wires[i].0, wires[i].1))).map(|i| find(parent, i)) };
// symbols on the sheet
struct Inst { r: String, value: String, lib: String, at: Pt, body: Option<Bx>, pins: Vec<(Pt, String)>, power: bool }
let mut insts: Vec<Inst> = vec![];
let mut text_boxes: Vec<(Bx, String, usize)> = vec![]; // box, what, owner instance (usize::MAX for none)
for s in root.children("symbol") {
let lib = s.child("lib_id").and_then(|l| l.arg(0)).unwrap_or_default();
let (at, rot) = s.child("at").map(|a| (xy(a), a.num(2).unwrap_or(0.0))).unwrap_or(((0.0, 0.0), 0.0));
let prop = |k: &str| s.children("property").find(|p| p.arg(0).as_deref() == Some(k));
let r = prop("Reference").and_then(|p| p.arg(1)).unwrap_or_default();
let value = prop("Value").and_then(|p| p.arg(1)).unwrap_or_default();
let Some(l) = libs.get(&lib) else { e(errors, format!("{r}: its symbol {lib} is not embedded in the sheet")); continue };
let place = |p: Pt| { let o = turn((p.0, -p.1), rot); (at.0 + o.0, at.1 + o.1) };
let body = l.body.map(|b| { let (a, c) = (place((b.0, b.1)), place((b.2, b.3))); (a.0.min(c.0), a.1.min(c.1), a.0.max(c.0), a.1.max(c.1)) });
if outside(at) { e(errors, format!("{r} is outside the drawing border")); }
let k = insts.len();
for p in s.children("property").filter(|p| !hidden(p)) {
let (name, val) = (p.arg(0).unwrap_or_default(), p.arg(1).unwrap_or_default());
let size = font_size(p);
if size < 1.016 - 1e-6 { e(errors, format!("{r} {name}: text {size:.2} mm, under 40 mil")); }
let c = p.child("at").map(xy).unwrap_or(at);
let w = val.chars().count() as f64 * size * 0.85;
let b = (c.0 - w / 2.0, c.1 - size * 0.6, c.0 + w / 2.0, c.1 + size * 0.6);
if outside((b.0, b.1)) || outside((b.2, b.3)) { e(errors, format!("{r} {name} `{val}` is outside the drawing border")); }
if !r.starts_with('#') || name == "Value" { text_boxes.push((b, format!("{r} {name} `{val}`"), k)); }
}
if l.min_text < 1.016 - 1e-6 { e(errors, format!("{r}: symbol {lib} has text {:.2} mm, under 40 mil", l.min_text)); }
let pfx = ref_prefix(&r);
if pfx == "C" && !r.starts_with('#') && value.trim_end().ends_with(['F', 'f']) { e(errors, format!("{r}: value \"{value}\": write capacitor values without F (100n, 10u, 15p)")); }
if matches!(pfx.as_str(), "R" | "C" | "L" | "FB" | "LED" | "D") && !r.starts_with('#') {
if l.pins.len() == 2 && !(l.names_hidden && l.numbers_hidden) { e(errors, format!("{r}: pin names and numbers show on a passive or LED ({lib})")); }
if l.boxy && l.pins.len() == 2 && pfx != "R" && pfx != "FB" { warnings.push(format!("{file}: {r} is drawn as a box, not a standard shape")); }
}
insts.push(Inst { r, value, lib, at, body, pins: l.pins.iter().map(|(p, num, _)| (place(*p), num.clone())).collect(), power: l.power });
}
let real: Vec<usize> = (0..insts.len()).filter(|&i| !insts[i].r.starts_with('#')).collect();
// labels: on a wire, the text running back along it, the wire at least as long
let (mut n_labels, mut n_local) = (0, 0);
let mut local_at: BTreeMap<String, Vec<Pt>> = BTreeMap::new();
for (kind, l) in root.children("label").map(|l| ("label", l)).chain(root.children("hierarchical_label").map(|l| ("hierarchical label", l))).chain(root.children("global_label").map(|l| ("global label", l))) {
let name = l.arg(0).unwrap_or_default();
let (at, ang) = l.child("at").map(|a| (xy(a), a.num(2).unwrap_or(0.0))).unwrap_or(((0.0, 0.0), 0.0));
n_labels += 1;
if kind == "label" { n_local += 1; local_at.entry(name.clone()).or_default().push(at); }
let size = font_size(l);
if size < 1.016 - 1e-6 { e(errors, format!("{kind} {name}: text {size:.2} mm, under 40 mil")); }
let right = l.child("effects").and_then(|f| f.child("justify")).map(|j| j.items().iter().any(|i| matches!(i, N::A(s) if s == "right"))).unwrap_or(false);
let dir = match ((ang.round() as i64).rem_euclid(360), right) { (0, false) | (180, false) => 0, (0, true) | (180, true) => 180, (90, false) | (270, false) => 90, _ => 270 };
let v: Pt = match dir { 0 => (1.0, 0.0), 180 => (-1.0, 0.0), 90 => (0.0, -1.0), _ => (0.0, 1.0) };
let need = name.chars().count() as f64 * size * 0.8;
if outside(at) || outside((at.0 + v.0 * need, at.1 + v.1 * need)) { e(errors, format!("{kind} {name} is outside the drawing border")); }
let above = l.child("effects").and_then(|f| f.child("justify")).map(|j| j.items().iter().any(|i| matches!(i, N::A(s) if s == "bottom"))).unwrap_or(false);
text_boxes.push((text_box(at, name.chars().count() as f64 * size * 0.85, size, dir, above), format!("{kind} {name}"), usize::MAX));
if !wires.iter().any(|w| on_seg(at, w.0, w.1)) { e(errors, format!("{kind} {name} at ({:.2}, {:.2}) is not on a wire", at.0, at.1)); continue; }
// follow the wire from the label along the text
let (mut end, mut guard) = (at, 0);
loop {
let next = wires.iter().filter(|w| on_seg(end, w.0, w.1)).flat_map(|w| [w.0, w.1]).filter(|o| { let d = ((o.0 - end.0) * v.0 + (o.1 - end.1) * v.1, (o.0 - end.0) * v.1 - (o.1 - end.1) * v.0); d.1.abs() < 1e-3 && d.0 > 1e-3 }).fold(None, |m: Option<Pt>, o| Some(match m { Some(q) if (q.0 - end.0) * v.0 + (q.1 - end.1) * v.1 >= (o.0 - end.0) * v.0 + (o.1 - end.1) * v.1 => q, _ => o }));
match next { Some(n) if guard < 60 => { end = n; guard += 1; } _ => break }
}
let run = (end.0 - at.0) * v.0 + (end.1 - at.1) * v.1;
if run <= 0.0 { e(errors, format!("{kind} {name} at ({:.2}, {:.2}): its text hangs past the end of its wire", at.0, at.1)); }
else if run + 1e-6 < need { e(errors, format!("{kind} {name}: its wire ({run:.1} mm) is shorter than its text (about {need:.1} mm)")); }
}
// free text and block frames
let mut titles: Vec<(String, Pt)> = vec![];
for t in root.children("text") {
let s = t.arg(0).unwrap_or_default();
let at = t.child("at").map(xy).unwrap_or((0.0, 0.0));
let size = font_size(t);
if size < 1.016 - 1e-6 { e(errors, format!("text `{s}`: {size:.2} mm, under 40 mil")); }
let bold = t.child("effects").and_then(|f| f.child("font")).map(|f| f.child("bold").is_some() || f.items().iter().any(|i| matches!(i, N::A(a) if a == "bold"))).unwrap_or(false);
let just = t.child("effects").and_then(|f| f.child("justify")).map(|j| j.items().iter().filter_map(|i| match i { N::A(a) => Some(a.clone()), _ => None }).collect::<Vec<_>>()).unwrap_or_default();
let w = s.chars().count() as f64 * size * 0.85;
let x0 = if just.iter().any(|j| j == "left") { at.0 } else if just.iter().any(|j| j == "right") { at.0 - w } else { at.0 - w / 2.0 };
let b = (x0, at.1 - size * 0.6, x0 + w, at.1 + size * 0.6);
if outside((b.0, b.1)) || outside((b.2, b.3)) { e(errors, format!("text `{s}` is outside the drawing border")); }
if bold { titles.push((s, at)); } else { text_boxes.push((b, format!("text `{s}`"), usize::MAX)); }
}
let mut n_blocks = 0;
for r in root.children("rectangle") {
let (Some(a), Some(b)) = (r.child("start").map(xy), r.child("end").map(xy)) else { continue };
let bb = (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1));
if outside((bb.0, bb.1)) || outside((bb.2, bb.3)) { e(errors, "a block frame is outside the drawing border".into()); }
let inside = |p: Pt| p.0 >= bb.0 && p.0 <= bb.2 && p.1 >= bb.1 && p.1 <= bb.3;
let Some((title, tat)) = titles.iter().find(|t| inside(t.1) && t.1 .1 < bb.1 + 8.0) else { e(errors, "a block frame has no title".into()); continue };
n_blocks += 1;
if (tat.0 - (bb.0 + bb.2) / 2.0).abs() > 2.0 { e(errors, format!("block title \"{title}\" is not centred on its frame")); }
let up = title.to_uppercase();
if *title != up { e(errors, format!("block title \"{title}\": write it in uppercase")); }
if up.contains("DECOUPL") { e(errors, format!("block \"{title}\": decoupling is never its own block; each capacitor sits with its IC")); }
if up.contains("NOTE") || up.contains("TEST POINT") { e(errors, format!("block \"{title}\": notes and test points sit at their parts and nets, not in a block of their own")); }
let parts: Vec<&Inst> = real.iter().map(|&i| &insts[i]).filter(|i| inside(i.at)).collect();
let has_sheets = root.children("sheet").any(|s| s.child("at").map(|a| inside(xy(a))).unwrap_or(false));
if parts.is_empty() && !has_sheets && !up.contains("LEGEND") { e(errors, format!("block \"{title}\" holds no part: a block of notes only is not allowed")); }
if !parts.is_empty() && parts.iter().all(|i| ref_prefix(&i.r) == "C") { e(errors, format!("block \"{title}\" holds capacitors only: decoupling is never its own block")); }
if up.contains("PROT") && !parts.iter().any(|i| matches!(ref_prefix(&i.r).as_str(), "D" | "F" | "TVS" | "ESD")) { e(errors, format!("block \"{title}\": named PROT but holds no protection part")); }
if (1..=2).contains(&parts.len()) { warnings.push(format!("{file}: block \"{title}\" holds {} part(s): a function of one or two parts belongs in the block of the IC it serves (blocks.json)", parts.len())); }
}
if n_blocks == 0 { e(errors, "the sheet has no titled block frame".into()); }
// text over a pin or over another part's body
let mut overlaps = 0;
for (b, what, owner) in &text_boxes {
let shrunk = (b.0 + 0.2, b.1 + 0.2, b.2 - 0.2, b.3 - 0.2);
for (k, i) in insts.iter().enumerate() {
if k == *owner && i.r.starts_with('#') { continue; }
if let Some(body) = i.body { let body = (body.0 + 0.15, body.1 + 0.15, body.2 - 0.15, body.3 - 0.15); if body.0 < body.2 && body.1 < body.3 && hit(&shrunk, &body) && !(k == *owner && l_is_box(&libs, &i.lib)) { overlaps += 1; e(errors, format!("{what} lies over the body of {}", i.r)); } }
if k != *owner { for (p, num) in &i.pins { if p.0 > shrunk.0 && p.0 < shrunk.2 && p.1 > shrunk.1 && p.1 < shrunk.3 { overlaps += 1; e(errors, format!("{what} lies over pin {num} of {}", i.r)); } } }
}
}
for a in 0..text_boxes.len() { for b in a + 1..text_boxes.len() {
let (x, y) = (&text_boxes[a].0, &text_boxes[b].0);
let (x, y) = ((x.0 + 0.25, x.1 + 0.25, x.2 - 0.25, x.3 - 0.25), (y.0 + 0.25, y.1 + 0.25, y.2 - 0.25, y.3 - 0.25));
if hit(&x, &y) { overlaps += 1; e(errors, format!("{} lies over {}", text_boxes[a].1, text_boxes[b].1)); }
} }
// a wire through a text (a label sits above its own wire, so its box does not hold that wire)
for (b, what, _) in &text_boxes {
let t = (b.0 + 0.3, b.1 + 0.3, b.2 - 0.3, b.3 - 0.3);
if t.0 >= t.2 || t.1 >= t.3 { continue; }
if let Some(w) = wires.iter().find(|w| { let (x0, x1, y0, y1) = (w.0 .0.min(w.1 .0), w.0 .0.max(w.1 .0), w.0 .1.min(w.1 .1), w.0 .1.max(w.1 .1)); x0 < t.2 && x1 > t.0 && y0 < t.3 && y1 > t.1 }) { overlaps += 1; e(errors, format!("a wire at ({:.1}, {:.1}) runs through {what}", w.0 .0, w.0 .1)); }
}
// wires and labels inside the border
for w in &wires { if outside(w.0) || outside(w.1) { e(errors, format!("a wire at ({:.1}, {:.1}) is outside the drawing border", w.0 .0, w.0 .1)); } }
// no-connect flags only where the run says the pin is unused
let mut n_nc = 0;
for nc in root.children("no_connect") {
let at = nc.child("at").map(xy).unwrap_or((0.0, 0.0)); n_nc += 1;
let pin = insts.iter().flat_map(|i| i.pins.iter().map(move |(p, num)| (p, format!("{}.{num}", i.r)))).find(|(p, _)| close(**p, at));
match pin { Some((_, id)) if nc_ok.contains(&id) => {} Some((_, id)) => e(errors, format!("no-connect flag on {id}: nets.json does not list it under noConnect (a pin of unknown net takes the ? marker)")), None => e(errors, format!("no-connect flag at ({:.2}, {:.2}) is on no pin", at.0, at.1)) }
}
// decoupling: a small capacitor on a supply is wired to an IC pin when an IC on this sheet is on that supply
let mut wp = parent.clone();
let comp_of = |k: usize, wp: &mut Vec<usize>| -> Vec<Option<usize>> { insts[k].pins.iter().map(|(p, _)| wire_at(*p, wp)).collect() };
let comps: Vec<Vec<Option<usize>>> = (0..insts.len()).map(|k| comp_of(k, &mut wp)).collect();
let supply_of = |c: usize| -> Option<&str> { (0..insts.len()).find(|&k| insts[k].power && insts[k].lib.contains("PWR_") && !insts[k].lib.contains("FLAG") && comps[k].contains(&Some(c)) && !matches!(crate::sch::rail_of(&insts[k].value), crate::sch::Rail::Ground)).map(|k| insts[k].value.as_str()) };
let ic_rails: BTreeSet<&str> = real.iter().filter(|&&k| matches!(ref_prefix(&insts[k].r).as_str(), "U" | "IC")).flat_map(|&k| comps[k].iter().flatten().filter_map(|c| supply_of(*c)).collect::<Vec<_>>()).collect();
let (mut caps_at_ic, mut caps_on_rows) = (0, 0);
for &k in real.iter().filter(|&&k| ref_prefix(&insts[k].r) == "C" && insts[k].pins.len() == 2) {
let farads = cap_farads(&insts[k].value).unwrap_or(0.0);
let Some((c, rail)) = comps[k].iter().flatten().find_map(|c| supply_of(*c).map(|r| (*c, r))) else { continue };
let grounded = comps[k].iter().flatten().any(|c2| *c2 != c && (0..insts.len()).any(|q| insts[q].power && comps[q].contains(&Some(*c2))));
if !grounded || farads >= 4.7e-6 { continue; }
let at_ic = real.iter().any(|&q| matches!(ref_prefix(&insts[q].r).as_str(), "U" | "IC") && comps[q].contains(&Some(c)));
if at_ic { caps_at_ic += 1; } else if ic_rails.contains(rail) { e(errors, format!("{} ({}): decoupling on {rail} is not wired to an IC supply pin, and an IC on this sheet is on {rail}", insts[k].r, insts[k].value)); } else { caps_on_rows += 1; }
}
// labels where wires would do
let hops = local_at.iter().filter(|(_, v)| v.len() >= 2 && v.iter().enumerate().any(|(i, a)| v.iter().skip(i + 1).any(|b| ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() < 40.0))).count();
if hops > 0 { warnings.push(format!("{file}: {hops} net(s) hop a short way on labels inside the block where a wire would read better")); }
if n_local >= 8 && wires.len() < n_local { warnings.push(format!("{file}: {n_local} local labels and {} wires: a sheet of labels is hard to follow", wires.len())); }
Ok(json!({"file": file, "paper": paper, "symbols": real.len(), "wires": wires.len(), "wiresDashed": wires.iter().filter(|w| w.2).count(), "labels": n_labels, "blocks": n_blocks, "noConnects": n_nc,
"unknownMarkers": insts.iter().filter(|i| i.r.starts_with("#UNK")).count(), "textOverlaps": overlaps, "decouplingAtIc": caps_at_ic, "capsOnRailRows": caps_on_rows, "labelHops": hops}))
}
fn l_is_box(libs: &HashMap<String, LibSym>, lib: &str) -> bool { libs.get(lib).map(|l| l.boxy && l.pins.len() != 2).unwrap_or(false) }
fn cap_farads(v: &str) -> Option<f64> {
let s = v.trim().trim_end_matches(['F', 'f']);
let (num, mul) = match s.chars().last()? { 'p' => (&s[..s.len() - 1], 1e-12), 'n' => (&s[..s.len() - 1], 1e-9), 'u' => (&s[..s.len() - 1], 1e-6), 'm' => (&s[..s.len() - 1], 1e-3), _ => (s, 1.0) };
num.parse::<f64>().ok().map(|x| x * mul)
}
/// The sheets of a hierarchy: the root and every file its sheet symbols name.
pub fn sheet_files(root: &Path) -> Result<Vec<PathBuf>, String> {
let text = std::fs::read_to_string(root).map_err(|e| format!("{}: {e}", root.display()))?;
let n = sexp::parse(&text)?;
let mut v = vec![root.to_path_buf()];
for s in n.children("sheet") { if let Some(f) = s.children("property").find(|p| p.arg(0).as_deref() == Some("Sheetfile")).and_then(|p| p.arg(1)) { v.push(root.parent().unwrap_or(Path::new(".")).join(f)); } }
Ok(v)
}
/// A picture of every sheet under reverse-flow/schematic/, so the report and the end-of-run video can show the schematic
/// without KiCad: one PDF of the hierarchy through KiCad's command line, then a PNG per sheet when `pdftoppm` is on this
/// machine. Returns (sheet name, sheet file, picture) per sheet in page order. Drawn again only when a sheet is newer.
pub fn pictures(flow: &Path, state: &Value) -> Result<Vec<(String, String, Option<String>)>, String> {
let root = state["kicad"]["sch"].as_str().map(PathBuf::from).filter(|p| p.is_file()).ok_or("no schematic yet: run `build`")?;
let files = sheet_files(&root)?;
let n = sexp::parse(&std::fs::read_to_string(&root).map_err(|e| e.to_string())?)?;
let mut names = vec!["root".to_string()];
for s in n.children("sheet") { names.push(s.children("property").find(|p| p.arg(0).as_deref() == Some("Sheetname")).and_then(|p| p.arg(1)).unwrap_or_default()); }
let dir = flow.join("schematic");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let pdf = dir.join("schematic.pdf");
let age = |p: &Path| std::fs::metadata(p).and_then(|m| m.modified()).ok();
let png_of = |k: usize| format!("{:02}-{}.png", k + 1, crate::schsym::safe(&names[k].to_lowercase()));
let current = age(&pdf).is_some_and(|t| files.iter().all(|f| age(f).is_some_and(|x| x <= t)));
if !current {
let cli = kicad_cli().ok_or("KiCad's command line did not answer (set ADOM_AIFLOW_KICAD_CLI): the sheets were not rendered")?;
if let Ok(rd) = std::fs::read_dir(&dir) { for e in rd.flatten() { if e.path().extension().is_some_and(|x| x == "png") { let _ = std::fs::remove_file(e.path()); } } }
let _ = std::fs::remove_file(&pdf);
let o = std::process::Command::new(&cli).args(["sch", "export", "pdf", "--output"]).arg(&pdf).arg(&root).output().map_err(|e| format!("{cli}: {e}"))?;
if !pdf.is_file() { return Err(format!("the PDF export wrote nothing: {}", String::from_utf8_lossy(&o.stderr).trim().chars().take(200).collect::<String>())); }
// pdftoppm numbers its pages; each picture is then named for its sheet
if std::process::Command::new("pdftoppm").args(["-r", "110", "-png"]).arg(&pdf).arg(dir.join("page")).output().map(|o| o.status.success()).unwrap_or(false) {
let mut pages: Vec<PathBuf> = std::fs::read_dir(&dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.file_name().is_some_and(|f| f.to_string_lossy().starts_with("page-"))).collect()).unwrap_or_default();
pages.sort();
for (k, p) in pages.iter().enumerate() { if k < names.len() { let _ = std::fs::rename(p, dir.join(png_of(k))); } }
}
}
Ok(files.iter().enumerate().map(|(k, f)| (names.get(k).cloned().unwrap_or_default(), f.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(), names.get(k).map(|_| png_of(k)).filter(|p| dir.join(p).is_file()))).collect())
}
/// The schematic's part of the report tables: every sheet with its picture, and what `check schematic` found.
pub fn report_section(flow: &Path, state: &Value) -> String {
let mut md = String::from("\n## Schematic\n\n");
match pictures(flow, state) {
Err(e) => md += &format!("The sheets are in `kicad/`. Pictures were not written: {e}.\n"),
Ok(sheets) => {
md += "Drawn from parts.json, blocks.json, nets.json and net-evidence.json. Solid wire: the pad was seen joined to the net's copper. Dashed: inferred. `?`: net not known. All sheets in one file: `reverse-flow/schematic/schematic.pdf`.\n\n| Sheet | KiCad file | Picture |\n|---|---|---|\n";
for (k, (name, file, png)) in sheets.iter().enumerate() { md += &format!("| {} {} | `kicad/{file}` | {} |\n", k + 1, name, png.as_ref().map(|p| format!("`reverse-flow/schematic/{p}`")).unwrap_or("not rendered (no pdftoppm)".into())); }
}
}
let r = crate::items::read_json(&flow.join("checks/schematic.json")).unwrap_or(Value::Null);
let s = &r["summary"];
if s.is_object() {
md += &format!("\n`check schematic`{}: ERC {} error(s), {} warning(s); netlist against nets.json {} ({} nets, {} pins, {} parts); sheet rules {} error(s); {} wires, {} dashed; {} pins of unknown net.\n",
if crate::flow::exists(flow, "checks/schematic.ok") { " (passed)" } else { " (not passed on the files as they stand)" },
s["erc"]["errors"], s["erc"]["warnings"], if s["netlist"]["equal"] == json!(true) { "equal" } else { "NOT equal" }, s["netlist"]["compared"]["nets"], s["netlist"]["compared"]["pins"], s["netlist"]["compared"]["parts"], s["style"]["errors"], s["style"]["wires"], s["style"]["wiresDashed"], s["style"]["unknownMarkers"]);
} else { md += "\n`check schematic` has not run.\n"; }
md
}
pub fn schematic(flow: &Path, state: &Value) -> Outcome {
let Some(root) = state["kicad"]["sch"].as_str().map(PathBuf::from).filter(|p| p.is_file()) else { return Err(vec!["no schematic yet: run `build`".into()]) };
let rd = |f: &str| crate::items::read_json(&flow.join(f)).map_err(|e| vec![e]);
let (parts_doc, nets_doc) = (rd("parts.json")?, rd("nets.json")?);
let mut errors: Vec<String> = vec![];
let mut warnings: Vec<String> = vec![];
let mut findings: Vec<Value> = vec![];
let copper_doc = crate::items::read_json(&flow.join("copper.json")).unwrap_or(Value::Null);
std::fs::create_dir_all(flow.join("checks")).ok();
// 1. ERC and 2. the netlist, both from KiCad itself
let (mut erc, mut netlist) = (json!({"ran": false}), json!({"ran": false}));
match kicad_cli() {
None => errors.push("KiCad's command line did not answer, so the ERC and the netlist are unknown (not zero): set ADOM_AIFLOW_KICAD_CLI to a kicad-cli that runs".into()),
Some(cli) => {
match run_cli(&cli, &["sch", "erc", "--format", "json", "--severity-all"], &flow.join("checks/schematic-erc.json"), &root).and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| format!("ERC report: {e}"))) {
Err(e) => errors.push(format!("the ERC did not run: {e}")),
Ok(v) => {
let mut by: BTreeMap<String, usize> = BTreeMap::new();
let (mut ne, mut nw) = (0, 0);
for viol in v["sheets"].as_array().into_iter().flatten().flat_map(|s| s["violations"].as_array().into_iter().flatten()) {
let ty = viol["type"].as_str().unwrap_or("?");
let what = format!("ERC {ty}: {} ({})", viol["description"].as_str().unwrap_or(""), viol["items"].as_array().into_iter().flatten().filter_map(|i| i["description"].as_str()).take(2).collect::<Vec<_>>().join("; "));
// an error that comes from pins the copper reading joined is a finding about that join, not a stop (`ercfind`)
if viol["severity"] == "error" { match crate::ercfind::classify(viol, &nets_doc, &copper_doc) { Some(f) => findings.push(f), None => { ne += 1; errors.push(what); } } } else { nw += 1; *by.entry(ty.to_string()).or_default() += 1; if nw <= 40 { warnings.push(what); } }
}
erc = json!({"ran": true, "errors": ne, "warnings": nw, "warningsByType": by, "errorsFromCopperAsRead": findings.len()});
}
}
match run_cli(&cli, &["sch", "export", "netlist"], &flow.join("checks/schematic.net"), &root).and_then(|t| parse_netlist(&t)) {
Err(e) => errors.push(format!("the netlist was not exported: {e}")),
Ok(nl) => {
let cache = crate::kicad_out::cache_dir(flow);
let mut pads: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for p in parts_doc["parts"].as_array().into_iter().flatten() {
let t = crate::kicad_out::footprint_text(&cache, p["footprint"].as_str().unwrap_or(""), p).map_err(|e| vec![e])?;
pads.insert(p["ref"].as_str().unwrap_or("?").to_string(), crate::sch::pad_numbers(&t).into_iter().collect());
}
let (errs, sum) = compare(&nl, &nets_doc, &pads);
netlist = json!({"ran": true, "equal": errs.is_empty(), "differences": errs.len(), "compared": sum});
errors.extend(errs);
}
}
}
}
// 3. the sheet rules
let nc_ok: BTreeSet<String> = nets_doc["noConnect"].as_array().into_iter().flatten().filter_map(|p| p.as_str().or_else(|| p["pin"].as_str())).map(String::from).collect();
let files = sheet_files(&root).map_err(|e| vec![e])?;
let mut sheets = vec![];
let before = errors.len();
for f in &files {
let name = f.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
match std::fs::read_to_string(f).map_err(|e| e.to_string()).and_then(|t| style(&t, &name, &nc_ok, &mut errors, &mut warnings)) { Ok(v) => sheets.push(v), Err(e) => errors.push(format!("{name}: {e}")) }
if PAPER.iter().position(|p| Some(p.0) == sheets.last().and_then(|s| s["paper"].as_str())).unwrap_or(9) > 1 { errors.push(format!("{name}: larger than A3")); }
}
let sum = |k: &str| sheets.iter().map(|s| s[k].as_u64().unwrap_or(0)).sum::<u64>();
let style_sum = json!({"sheets": sheets.len(), "errors": errors.len() - before, "symbols": sum("symbols"), "wires": sum("wires"), "wiresDashed": sum("wiresDashed"), "labels": sum("labels"), "blocks": sum("blocks"), "unknownMarkers": sum("unknownMarkers"),
"noConnects": sum("noConnects"), "textOverlaps": sum("textOverlaps"), "decouplingAtIc": sum("decouplingAtIc"), "capsOnRailRows": sum("capsOnRailRows"), "labelHops": sum("labelHops")});
let mut summary = json!({"erc": erc, "netlist": netlist, "style": style_sum, "warnings": warnings.len()});
if !findings.is_empty() { summary["copperFindings"] = json!(findings); }
let report = json!({"summary": summary, "errors": errors, "warnings": warnings, "sheets": sheets});
let _ = std::fs::write(flow.join("checks/schematic.json"), serde_json::to_string_pretty(&report).unwrap_or_default() + "\n");
if !errors.is_empty() {
let n = errors.len();
let mut e: Vec<String> = errors.into_iter().take(30).collect();
if n > 30 { e.push(format!("{} more: all in reverse-flow/checks/schematic.json", n - 30)); }
e.push(format!("summary: {summary}"));
return Err(e);
}
let rel = |p: &Path| -> String { match (p.parent().and_then(|d| d.file_name()), p.file_name(), flow.parent()) { (Some(d), Some(f), Some(pr)) if pr.join(d).join(f) == *p || std::fs::canonicalize(pr.join(d).join(f)).ok() == std::fs::canonicalize(p).ok() => format!("../{}/{}", d.to_string_lossy(), f.to_string_lossy()), _ => p.display().to_string() } };
let mut read: Vec<String> = ["parts.json", "blocks.json", "nets.json", "net-evidence.json"].iter().filter(|f| flow.join(f).is_file()).map(|f| f.to_string()).collect();
read.extend(files.iter().map(|f| rel(f)));
let mut hints: Vec<String> = crate::ercfind::lines(&findings);
if !findings.is_empty() { hints.push(format!("{} ERC error(s) come from pins the copper reading joined: each is a finding with a test, counted against connectivity, and not yours to fix by editing nets. `tests generate` writes the tests.", findings.len())); }
hints.extend(warnings.iter().take(10).map(|w| format!("warning: {w}")));
if warnings.len() > 10 { hints.push(format!("{} more warnings in reverse-flow/checks/schematic.json", warnings.len() - 10)); }
Ok(Pass { summary, hints, files: read })
}
#[cfg(test)]
mod tests {
use super::*;
const NET: &str = r##"(export (version "E") (components (comp (ref "R1") (value "1k")) (comp (ref "U1") (value "x")) (comp (ref "#PWR01") (value "GND")))
(nets (net (code "1") (name "/Power/VIN") (node (ref "R1") (pin "1")) (node (ref "U1") (pin "1")))
(net (code "2") (name "GND") (node (ref "U1") (pin "2")) (node (ref "#PWR01") (pin "1")))
(net (code "3") (name "Net-(R1-Pad2)") (node (ref "R1") (pin "2")))
(net (code "4") (name "unconnected-(U1-Pad3)") (node (ref "U1") (pin "3")))))"##;
fn pads() -> BTreeMap<String, BTreeSet<String>> { [("R1", vec!["1", "2"]), ("U1", vec!["1", "2", "3"])].into_iter().map(|(r, p)| (r.to_string(), p.into_iter().map(String::from).collect())).collect() }
#[test]
fn an_equal_netlist_passes_and_every_kind_of_difference_is_named() {
let nl = parse_netlist(NET).unwrap();
assert_eq!(nl.parts.len(), 2, "a # reference is no part");
let good = json!({"nets": [{"name": "VIN", "pins": ["R1.1", "U1.1", "PHOTO1.M3"]}, {"name": "GND", "pins": ["U1.2"]}, {"name": "N001", "nameSource": "none: unnamed", "pins": ["R1.2"]}]});
let (errs, sum) = compare(&nl, &good, &pads());
assert!(errs.is_empty(), "{errs:?}");
assert_eq!(sum["pins"], 4);
// a pin moved to another net, a join nets.json does not hold, a wrong name, a missing part and a missing pad
let (errs, _) = compare(&nl, &json!({"nets": [{"name": "VIN", "pins": ["R1.1"]}, {"name": "GND", "pins": ["U1.2", "U1.1"]}]}), &pads());
assert!(errs.iter().any(|e| e.contains("net VIN") && e.contains("also joins U1.1")), "{errs:?}");
assert!(errs.iter().any(|e| e.contains("net GND") && e.contains("2 schematic nets")), "{errs:?}");
let (errs, _) = compare(&nl, &json!({"nets": [{"name": "GND", "pins": ["U1.2"]}]}), &pads());
assert!(errs.iter().any(|e| e.contains("joins R1.1, U1.1") && e.contains("no such net")), "{errs:?}");
let (errs, _) = compare(&nl, &json!({"nets": [{"name": "VBUS", "pins": ["R1.1", "U1.1"]}, {"name": "GND", "pins": ["U1.2"]}]}), &pads());
assert!(errs.iter().any(|e| e.contains("net VBUS is called /Power/VIN")), "{errs:?}");
let mut more = pads(); more.insert("C9".into(), ["1".to_string()].into_iter().collect()); more.get_mut("U1").unwrap().insert("4".into());
let (errs, _) = compare(&nl, &good, &more);
assert!(errs.iter().any(|e| e.contains("C9 is in parts.json and not on the schematic")) && errs.iter().any(|e| e.contains("U1: pad(s) 4")), "{errs:?}");
}
const HEAD: &str = r#"(kicad_sch (version 20231120) (paper "A4") (lib_symbols
(symbol "RE:C" (pin_numbers (hide yes)) (pin_names (offset 0) (hide yes)) (symbol "C_0_1" (polyline (pts (xy -2 0.7) (xy 2 0.7))) (polyline (pts (xy -2 -0.7) (xy 2 -0.7)))) (symbol "C_1_1" (pin passive line (at 0 3.81 270) (length 2.8) (name "") (number "1")) (pin passive line (at 0 -3.81 90) (length 2.8) (name "") (number "2"))))
(symbol "RE:PWR_GND" (power) (symbol "PWR_GND_0_1" (polyline (pts (xy 0 0) (xy 0 -1.27)))) (symbol "PWR_GND_1_1" (pin power_in line (at 0 0 270) (length 0) (name "GND") (number "1")))))
(rectangle (start 12.7 12.7) (end 284 162))"#;
fn cap(r: &str, v: &str, x: f64, y: f64) -> String { format!("(symbol (lib_id \"RE:C\") (at {x} {y} 0) (property \"Reference\" \"{r}\" (at {} {y} 0) (effects (font (size 1.27 1.27)))) (property \"Value\" \"{v}\" (at {} {} 0) (effects (font (size 1.27 1.27)))))", x + 5.0, x + 5.0, y + 2.5) }
fn run(body: &str) -> (Vec<String>, Vec<String>, Value) {
let (mut e, mut w) = (vec![], vec![]);
let v = style(&format!("{HEAD}\n{body})"), "t.kicad_sch", &BTreeSet::new(), &mut e, &mut w).unwrap();
(e, w, v)
}
#[test]
fn a_tidy_sheet_passes_and_each_rule_fails_alone() {
let title = "(text \"POWER ENTRY\" (at 148.35 16.5 0) (effects (font (size 1.905 1.905) (bold yes))))";
let good = format!("{title} {} {} (wire (pts (xy 50.8 46.99) (xy 50.8 40.64))) (wire (pts (xy 50.8 40.64) (xy 76.2 40.64)) (stroke (width 0) (type dash))) (label \"VSENSE\" (at 76.2 40.64 180) (effects (font (size 1.27 1.27)) (justify right bottom)))", cap("C1", "100n", 50.8, 50.8), cap("R1", "10K", 150.0, 100.0));
let (e, _, v) = run(&good);
assert!(e.is_empty(), "{e:?}");
assert_eq!((v["wires"].as_u64(), v["wiresDashed"].as_u64(), v["labels"].as_u64(), v["blocks"].as_u64()), (Some(2), Some(1), Some(1), Some(1)));
let fails = |body: String, what: &str| { let (e, _, _) = run(&body); assert!(e.iter().any(|x| x.contains(what)), "expected `{what}` in {e:?}"); };
fails(format!("{title} {}", cap("C1", "100nF", 50.8, 50.8)), "without F");
fails(format!("{title} {} (label \"LOST\" (at 100 100 0) (effects (font (size 1.27 1.27)) (justify left bottom)))", cap("C1", "1u", 50.8, 50.8)), "not on a wire");
fails(format!("{good} (label \"HANG\" (at 76.2 40.64 0) (effects (font (size 1.27 1.27)) (justify left bottom)))"), "hangs past");
fails(format!("{good} (wire (pts (xy 100 100) (xy 103 100))) (label \"MUCH_TOO_LONG_A_NAME\" (at 103 100 180) (effects (font (size 1.27 1.27)) (justify right bottom)))"), "shorter than its text");
fails(format!("{good} (wire (pts (xy 100 100) (xy 110 105)))"), "right angle");
fails(good.replace("POWER ENTRY", "Power entry"), "uppercase");
fails(good.replace("POWER ENTRY", "DECOUPLING"), "never its own block");
fails(good.replace("(size 1.27 1.27)) (justify right bottom)", "(size 0.9 0.9)) (justify right bottom)"), "under 40 mil");
fails(format!("{good} (text \"note\" (at 50.8 50.8 0) (effects (font (size 1.27 1.27))))"), "over the body of C1");
fails(format!("{good} (text \"note\" (at 290 100 0) (effects (font (size 1.27 1.27))))"), "outside the drawing border");
fails(format!("{good} (text \"note\" (at 60 40.64 0) (effects (font (size 1.27 1.27))))"), "runs through text `note`");
fails(format!("{good} (no_connect (at 50.8 54.61))"), "does not list it under noConnect");
fails(cap("C1", "1u", 50.8, 50.8), "no title");
fails(format!("{title} (text \"words\" (at 100 100 0) (effects (font (size 1.27 1.27))))"), "holds no part");
// a no-connect the run backs is fine
let (mut e, mut w) = (vec![], vec![]);
style(&format!("{HEAD}\n{good} (no_connect (at 50.8 54.61)))"), "t", &["C1.2".to_string()].into_iter().collect(), &mut e, &mut w).unwrap();
assert!(e.is_empty(), "{e:?}");
}
/// `next` reads the flow file: the step that writes the final schematic waits on this check and names it.
#[test]
fn next_names_the_check_at_the_step_that_writes_the_final_schematic() {
let fl = crate::flow::load();
let s = fl.steps.iter().find(|s| s.done.iter().any(|d| d == "checks/schematic.ok")).expect("a step waits on check schematic");
assert_eq!(s.name, "connect");
assert!(s.todo.contains("`schematic` write a root sheet") && s.todo.contains("`check schematic`"), "{}", s.todo);
}
#[test]
fn capacitors_alone_in_a_block_fail_and_farads_parse() {
let title = "(text \"FILTER\" (at 148.35 16.5 0) (effects (font (size 1.905 1.905) (bold yes))))";
let (e, _, _) = run(&format!("{title} {} {}", cap("C1", "1u", 50.8, 50.8), cap("C2", "1u", 80.0, 50.8)));
assert!(e.iter().any(|x| x.contains("capacitors only")), "{e:?}");
assert_eq!(cap_farads("100n").map(|v| (v * 1e9).round()), Some(100.0));
assert_eq!(cap_farads("4.7u").map(|v| (v * 1e7).round()), Some(47.0));
assert_eq!(cap_farads("?"), None);
}
}