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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247
//! adom-aiflow-reverse 0.2: reverse engineer a PCB from photos. The program measures: board outline
//! and straightening, scale from hole-row pitches and part bodies, part bodies and packages, hole rows,
//! every island of copper on both faces, vias, and what each pad and via joins. It generates the KiCad copy
//! with the photographed pours and tracks, routes what cannot be seen on its own N-layer router, and jumps
//! the layer count until every connection closes and the DRC passes. The AI names (identities, markings,
//! nets, functions). Every stage records its interactive PCB or schematic in the flow window.
mod board;
mod bridge;
mod checks;
mod conduct;
mod confidence;
mod connect;
mod route;
mod trace;
mod trackbridge;
mod islands;
mod copper;
mod copperchecks;
mod notread;
mod ercfind;
mod window;
mod domes;
mod edgelevels; mod locallevels; mod faceread;
mod maskcolour;
mod maskread;
mod flipdecide;
mod draw;
mod dspins;
mod fidelity;
mod findings;
mod flow;
mod geom;
mod gerber;
mod intake;
mod items;
mod netconf;
mod netdraw;
mod netpaint;
mod netsections;
mod netview;
mod kicad_in;
mod kicad_out;
mod ledger;
mod out;
mod padjoin;
mod parts;
mod passives;
mod tone;
mod candconf;
mod photolayers;
mod photomat;
mod pinnets;
mod printgaps;
mod prep;
mod raster;
mod regframe;
mod report;
mod rules;
mod server;
mod attrib;
mod auto;
mod bench;
mod layerstate;
mod twoscores;
mod unrouted;
mod video;
mod sch;
mod schcheck;
mod schsym;
mod sexp;
mod snap;
mod timeline;
mod views;
mod witness;
mod viasig;
mod facereg;
mod outline;
mod reframe;
use clap::{Parser, Subcommand};
use out::{err, ok};
use serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io::Write;
use std::path::{Path, PathBuf};
const FLOW_DIR: &str = "reverse-flow";
#[derive(Parser)]
#[command(name = "adom-aiflow-reverse", version, about = "Reverse engineer a PCB from photos: the program measures the board, its parts, copper and connectivity, generates the KiCad copy and routes what cannot be seen; the AI names")]
struct Cli {
/// The name of the AI conversation calling (required for commands that change state)
#[arg(long, global = true)]
ai_thread: Option<String>,
/// Project folder (default: the nearest one above the current folder that has reverse-flow/)
#[arg(long, global = true)]
project: Option<String>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Start: `init --photos <folder or photo...>` (every photo of one board, no face labels: `prep` sorts and stitches them first)
/// or `init --top <photo...> --bottom <photo...>` (one photo per face is the board image; copied into reverse-flow/photos)
/// `--rules kicad-default|jlc-4l|<file.json>`: the design rules every later step uses (default kicad-default)
Init { #[arg(long, num_args = 0..)] top: Vec<String>, #[arg(long, num_args = 0..)] bottom: Vec<String>,
/// Every photo of the board, unsorted: files or a folder. Close-up tiles, whole-board overviews, both faces, in any order
#[arg(long, num_args = 1..)] photos: Vec<String>,
#[arg(long)] target: Option<String>, #[arg(long, default_value = "kicad-default")] rules: String },
/// Sort the photos given to `init --photos` into faces, tiles and overviews, and build one mosaic per face (run by `prep` when needed); writes stitch.json and stitch/
Stitch {
/// When stitch.json says the choice of top is close: the name of one photo that shows the top face. Stitches again with that face as the top
#[arg(long)] top: Option<String> },
/// Show or set the design rules: `rules` (show) or `rules --set <profile|file>` (only before the first build)
Rules { #[arg(long)] set: Option<String> },
/// What to do now
Next,
/// Every step with its state
Steps,
/// The run ledger
Ledger,
/// Measure every photo: outline, straightened image, sharpness, holes, hole rows, part bodies, bottom-to-top mapping
Prep,
/// Pixels per mm from hole pitches, confirmed by a datasheet body: `scale --body <id>=<L>x<W> --source "..."`, or through four mounting holes at a stated spacing: `scale --holes <x>x<y> --board <x>x<y> --source "..."`; then the board frame
Scale { #[arg(long)] body: Option<String>, #[arg(long)] source: Option<String>,
/// A known distance pointed out on the straightened top photo, `<x1>,<y1>,<x2>,<y2>=<mm>` in that photo's pixels (read them off `scale --crop`): for example the centres of the first and last lead of a chip's row, (leads - 1) x the datasheet pitch apart. Repeat for more rulers; the longer the ruler the finer the scale. Needs --source
#[arg(long)] ruler: Vec<String>,
/// Write a crop of the straightened top photo with pixel coordinates along its edges, to read ruler points from: `scale --crop <x>,<y>[,<size>]` (centre and size in pixels of evidence/prep-<top photo>.jpg; size 600 by default)
#[arg(long)] crop: Option<String>,
/// The mounting holes' centre spacing in mm, `<x>x<y>`, for a board with no rows of holes: four holes frame each photo. Needs --board and --source
#[arg(long)] holes: Option<String>,
/// The board's size in mm, `<x>x<y>`, from the same source; the hole pattern is taken as centred on it
#[arg(long)] board: Option<String>,
/// Which way the board was turned over, `x` or `y`, when neither holes nor through features can say
#[arg(long)] flip: Option<String>,
/// Written by `reframe`: the pixels per mm of the straightened top photo, carried over from the scale this run was given on an older frame
#[arg(long)] carried: Option<f64> },
/// Move a run made on an older frame onto the frame this build makes, keeping what was read: runs `prep`, carries the scale and the flip, carries every part of parts.json through the photo of its face, detects candidates anew and matches the old ids, and sets aside what `auto` must make again
Reframe {
/// Which way the board was turned over, `x` or `y`, when the old run never settled it and this build cannot tell
#[arg(long)] flip: Option<String> },
/// `detect parts [--bottom]`: part candidates with package matches, crops and contact sheets (--bottom: the bottom face has parts too)
Detect { what: String, #[arg(long)] bottom: bool },
/// `parts init [--force]` (parts.json from the candidates), `parts snap` (measured position and rotation from each footprint's pads), `parts reconcile` (dismiss candidates that are fragments of placed parts)
Parts { what: String, #[arg(long)] force: bool },
/// Read a resistor or inductor marking code: `decode 59C`
Decode { code: String },
/// `blocks propose`: groups of parts that sit together, for naming
Blocks { what: String },
/// `copper prep` (gap maps, grids, via candidates), `copper trace` (every island of copper from the photo) or `copper fit` (sketched pours onto the photo's gap lines)
Copper { what: String },
/// Write the KiCad project (footprints, outline, fitted and filled pours, traces, vias): `build [--layers N]`
Build { #[arg(long)] layers: Option<usize> },
/// Write the schematic again from parts.json, blocks.json, nets.json and net-evidence.json without rebuilding the board (after naming a part's `symbol` or `pins`); `build` does this too
Schematic,
/// Check a step: prep | parts | blocks | copper | mimic --pcb <board> | nets | layers [--pcb <board>] | schematic | fidelity [--pcb <board>] | review | tests
Check { what: String, #[arg(long)] pcb: Option<String>,
/// Run this check though earlier steps are not done, for testing a stage on its own; say why. The check is logged as such and does not count as passing that step
#[arg(long)] skip_earlier: Option<String> },
/// `layers trace`: silk, mask openings, exposed pads, holes and outline read from the photos into photo-layers.json; the next `build` draws them.
/// `layers state [--count <n> --source "<document>" | --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct]`: the layer count stated without routing: from outside evidence when given, otherwise UNKNOWN with its test and the minimum the photos prove (`--count 0` withdraws a stated count)
Layers { what: String, #[arg(long)] count: Option<usize>, #[arg(long)] source: Option<String>, #[arg(long)] evidence: Option<String>, #[arg(long)] kind: Option<String> },
/// Save the KiCad project as it stands: `snapshot --name mimic --dir <project folder>`. Goes stale when copper.json or parts.json change; run it again then, and the old one is kept
Snapshot { #[arg(long)] name: String, #[arg(long)] dir: String },
/// `connect measure` (read the photos once for attribution), `connect attribute [--vias <file>]` (every pour, track and via gets a net with graded evidence, no photo read), `connect witness` (each pin of a chip with `datasheets/<ref>.pins.json` against the copper: agree, disagree or not visible, findings with crops), `connect extract` (connectivity measured on the photos) or `connect route` (the router on each stackup, jumping layers until every connection closes and the DRC passes; `--layers 4,6` to choose, `--seconds` per stackup)
Connect { what: String, #[arg(long)] vias: Option<String>, #[arg(long)] layers: Option<String>, #[arg(long, default_value_t = 480.0)] seconds: f64, #[arg(long, default_value_t = 3)] stall: usize, #[arg(long, default_value_t = 8)] max_layers: usize },
/// `review` or `review pass [--target 90] [--max-passes 20] [--note "..."]`: one pass of the loop: measure, score every category, draw the audits still needed, and say CONTINUE, TARGET MET, STALLED or STOPPED. `review measure` lists disagreements only
Review { what: Option<String>, #[arg(long)] target: Option<f64>, #[arg(long)] max_passes: Option<usize>, #[arg(long)] note: Option<String> },
/// `audit draw --category <c> [--n <per stratum>]`, `audit show --category <c>`, `audit status`, `audit verdict --category <c> --sample <seed> --item <a001> --verdict ok|wrong|cannot_tell --who <auditor> [--note ..] [--evidence <file> --kind <xray|edge-micrograph|continuity-log|netlist|bom>]`, or `--file <[{item,verdict,note}]>` for a whole sheet. `audit preview [--n 24] --file <folder>` writes copper crops outside the audit, to check legibility
Audit { what: String, #[arg(long)] category: Option<String>, #[arg(long)] n: Option<usize>, #[arg(long)] sample: Option<String>, #[arg(long)] item: Option<String>, #[arg(long)] verdict: Option<String>, #[arg(long)] who: Option<String>, #[arg(long)] note: Option<String>, #[arg(long)] evidence: Option<String>, #[arg(long)] kind: Option<String>, #[arg(long)] file: Option<String> },
/// Every category's computed score and the inputs behind it (nothing here reads a typed level), plus evidence tag counts
Confidence,
/// `tests generate`: a test for every INFERRED or UNKNOWN item
Tests { what: String },
/// Generated tables for the report. `report --draft` also writes a starting report.md from them when there is none
Report { #[arg(long)] draft: bool },
/// `datasheet status` (which identified chips have `datasheets/<ref>.pins.json`, which wait) or `datasheet apply` (the pin names and types of every valid pins file into parts.json `pins`, for the schematic and the net names)
Datasheet { what: String },
/// Run every program-only step in order. Stops with `AUTO NEEDS AI: <input>` (exit 2) naming the file or command wanted, `AUTO COMPLETE` (exit 0) or `AUTO FAILED` (exit 1)
Auto { #[arg(long, default_value_t = 200)] max_steps: usize },
/// After the run: `bench plan` writes ordered instructions for a person with the real board and a multimeter (reverse-flow/bench/plan.md, with a crop for each pad); `bench ingest --file <results.json>` records their answers as MEASURED, and the scores move by computation. The flow never waits for this. docs/bench.md gives the results file format
Bench { what: String, #[arg(long)] file: Option<String> },
/// `nets draw [--net <name>]`: grade every net by its weakest link (net-confidence.json) and draw the nets under reverse-flow/nets/: both faces by confidence, a table, one picture per net, section sheets and an index
Nets { what: String, #[arg(long)] net: Option<String> },
/// `video build [--no-voice] [--workers <n>]`: the two minute video of the run, made from the run folder alone: reverse-flow/video/<project>-run.mp4 (1920x1080, 30 fps, H.264, AAC), a poster, .vtt and .srt captions, and video.json with every scene, the files it showed and where each figure was read. `auto` runs it after `report`
Video { what: String, #[arg(long)] no_voice: bool, #[arg(long, default_value_t = 2)] workers: usize },
/// Register the KiCad project the flow window shows: `kicad --pcb <board> [--sch <root sheet>]`
Kicad { #[arg(long)] pcb: String, #[arg(long)] sch: Option<String> },
/// Record the current stage's view again after an update
View { #[arg(long)] step: Option<String>, #[arg(long)] reason: Option<String> },
/// The flow window
Serve { #[arg(long, default_value_t = 8890)] port: u16 },
/// Record one desktop window for the video: `clip start --name <step> --hwnd <window>` / `clip stop`
Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },
}
fn thread(cli: &Cli) -> String { cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[])) }
fn project(cli: &Cli) -> ledger::Project {
let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-reverse init --photos <folder or photo...> --ai-thread <you> (or --top <photo...> --bottom <photo...> when the faces are already one photo each)".into()]))
}
pub fn sha256(p: &Path) -> String {
let o = std::process::Command::new("sha256sum").arg(p).output();
o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()
}
fn abs(p: &Path) -> String { std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()).display().to_string() }
fn rd(p: &Path) -> Value { items::read_json(p).unwrap_or_else(|e| err(&e, &[])) }
fn wr(p: &Path, v: &Value) { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } std::fs::write(p, serde_json::to_string_pretty(v).unwrap() + "\n").unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[])); }
fn fv(v: &Value, k: &str) -> f64 { v[k].as_f64().unwrap_or(0.0) }
fn img(p: &Path) -> image::RgbImage { image::open(p).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &["Run the step that writes it first (`scale` writes board/).".into()])).to_rgb8() }
fn save(i: &image::RgbImage, p: &Path) { if let Some(d) = p.parent() { std::fs::create_dir_all(d).ok(); } i.save(p).unwrap_or_else(|e| err(&format!("{}: {e}", p.display()), &[])); }
/// Where the files disagree with what was measured. Lists things to look at; a score reads the files itself.
fn review_findings(f: &Path) -> Vec<String> {
let mut lines = vec![];
let parts_doc = rd(&f.join("parts.json")); let det = items::read_json(&f.join("detect/parts.json")).unwrap_or(json!({}));
let cands: HashMap<String, Value> = det["candidates"].as_array().into_iter().flatten().map(|c| (c["id"].as_str().unwrap_or("").to_string(), c.clone())).collect();
for pt in parts_doc["parts"].as_array().into_iter().flatten() {
if let Some(c) = pt["candidate"].as_str().and_then(|c| cands.get(c)) {
let d = geom::dist((pt["pos_mm"][0].as_f64().unwrap(), pt["pos_mm"][1].as_f64().unwrap()), (c["pos_mm"][0].as_f64().unwrap(), c["pos_mm"][1].as_f64().unwrap()));
if d > 0.5 { lines.push(format!("{} sits {d:.2} mm from its measured body {}", pt["ref"].as_str().unwrap_or("?"), c["id"].as_str().unwrap())); }
}
}
for z in rd(&f.join("copper.json"))["zones"].as_array().into_iter().flatten() {
let s = z["fit"]["edgeShare"].as_f64().unwrap_or(0.0); if z["sketch_mm"].is_array() && s < 0.6 { lines.push(format!("zone {}: {:.0}% on measured gaps", z["id"].as_str().unwrap_or("?"), s * 100.0)); }
}
lines.extend(notread::sentences(f));
lines.extend(ercfind::recorded(f).iter().filter_map(|x| x["finding"].as_str().map(|t| format!("ERC {}: {t}", x["id"].as_str().unwrap_or("")))));
for z in rd(&f.join(kicad_out::LEFT_OUT))["noFill"].as_array().into_iter().flatten() { lines.push(format!("pour {} ({} mm2) is in copper.json and drawn in the KiCad copy as an outline with no fill: {}", z["id"].as_str().unwrap_or("?"), z["areaMm2"], z["why"].as_str().unwrap_or(""))); }
if let Ok(c) = items::read_json(&f.join("detect/connectivity.json")) { for x in c["clashes"].as_array().into_iter().flatten() { lines.push(format!("net clash: {}", x.as_str().unwrap_or(""))); } }
lines
}
/// The steps before `step` that are not done or are stale: a check and a review pass both wait for them.
fn steps_behind(fl: &flow::Flow, f: &Path, step: &str) -> Vec<String> {
let Some(i) = fl.steps.iter().position(|s| s.name == step) else { return vec![] };
fl.steps[..i].iter().filter(|s| !s.done.iter().all(|d| flow::exists(f, d))).map(|s| s.name.clone()).collect()
}
fn step_hint(fl: &flow::Flow, dir: &Path) -> Vec<String> {
match flow::current(fl, dir) {
None => {
let mut h = vec!["every step is done: the flow is finished. `confidence` prints both scores; `bench plan` writes the optional instructions for a person with the real board".to_string()];
// the video follows the report: `auto` builds it, and a failed build is said here, never a reason to wait
h.extend(auto::now_lines(dir, fl));
h.extend(auto::video_note(dir));
h
}
Some(s) => {
let mut h = intake::hint(dir);
for d in &s.done { let st = flow::stale(dir, d); if !st.is_empty() { h.push(format!("{d} is stale: {} changed after the check passed; run it again", st.join(", "))); } }
h.push(format!("next step {}: {}", s.name, s.todo));
h.extend(auto::now_lines(dir, fl));
h
}
}
}
/// Furthest step reached, and loop backs when an earlier step is re-done because its files changed.
fn note_progress(p: &mut ledger::Project, fl: &flow::Flow, idx: usize, was_stale: &[String]) {
let reached = p.state["reached"].as_u64().map(|x| x as usize);
if let Some(r) = reached {
if idx < r && !was_stale.is_empty() && !p.state["rework"].is_object() {
let lp = json!({"from": fl.steps[r].name, "to": fl.steps[idx].name, "why": format!("{} changed", was_stale.join(", ")), "at": ledger::now()});
if !p.state["loops"].is_array() { p.state["loops"] = json!([]); }
p.state["loops"].as_array_mut().unwrap().push(lp.clone());
p.state["rework"] = lp;
}
if idx >= r { p.state["rework"] = Value::Null; }
}
p.state["reached"] = json!(reached.unwrap_or(0).max(idx));
}
fn reach(p: &mut ledger::Project, fl: &flow::Flow, step: &str) { if let Some(i) = fl.steps.iter().position(|s| s.name == step) { note_progress(p, fl, i, &[]); p.save().ok(); } }
fn copy_tree(src: &Path, dst: &Path, rel: &Path, files: &mut Vec<(String, String)>) -> std::io::Result<()> {
for e in std::fs::read_dir(src.join(rel))? {
let e = e?; let name = e.file_name().to_string_lossy().to_string();
if name.ends_with("-backups") || name.ends_with(".lck") || name.ends_with('~') || name == "fp-info-cache" || name == ".history" { continue; }
let r = rel.join(&name);
if e.file_type()?.is_dir() { copy_tree(src, dst, &r, files)?; continue; }
std::fs::create_dir_all(dst.join(&r).parent().unwrap())?;
std::fs::copy(src.join(&r), dst.join(&r))?;
files.push((r.display().to_string(), sha256(&dst.join(&r))));
}
Ok(())
}
/// The primary photo of a side: the sharpest one.
fn primary<'a>(prep: &'a Value, side: &str) -> Option<&'a Value> {
prep["photos"].as_array()?.iter().filter(|p| p["side"] == json!(side)).max_by(|a, b| fv(a, "sharpness").partial_cmp(&fv(b, "sharpness")).unwrap())
}
/// Corner radius of the board outline: how far along each corner's diagonal the board starts.
/// The same where the table is not a warm wood: along the diagonal until mask starts. Framed through its holes the board's corner is exact, so this does not lean on the table's colour.
fn corner_radius_to_mask(top: &image::RgbImage) -> f64 {
let tone = prep::mask_tone(top);
let (w, h) = (top.width() as i64, top.height() as i64);
let mut ds = vec![];
for (cx, cy, sx, sy) in [(0i64, 0i64, 1i64, 1i64), (w - 1, 0, -1, 1), (w - 1, h - 1, -1, -1), (0, h - 1, 1, -1)] {
let mut d = 0; while d < 200 && !prep::is_mask_green(top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32), &tone) { d += 1; }
ds.push(d as f64 * std::f64::consts::SQRT_2 / board::RES);
}
ds.sort_by(|a, b| a.partial_cmp(b).unwrap());
(((ds[1] + ds[2]) / 2.0 / (std::f64::consts::SQRT_2 - 1.0)) * 10.0).round() / 10.0
}
fn corner_radius(top: &image::RgbImage) -> f64 {
let tone = prep::mask_tone(top);
let (w, h) = (top.width() as i64, top.height() as i64);
let mut ds = vec![];
for (cx, cy, sx, sy) in [(0i64, 0i64, 1i64, 1i64), (w - 1, 0, -1, 1), (w - 1, h - 1, -1, -1), (0, h - 1, 1, -1)] {
let mut d = 0; while d < 200 { let p = top.get_pixel((cx + sx * d) as u32, (cy + sy * d) as u32); let (hh, s, v) = raster::hsv(p); let wood = (0.0..45.0).contains(&hh) && s > 0.3 && v < 0.75 && !prep::is_mask_green(p, &tone); if !wood { break; } d += 1; }
ds.push(d as f64 * std::f64::consts::SQRT_2 / board::RES);
}
ds.sort_by(|a, b| a.partial_cmp(b).unwrap());
let med = (ds[1] + ds[2]) / 2.0;
((med / (std::f64::consts::SQRT_2 - 1.0)) * 10.0).round() / 10.0
}
/// `scale --holes`: a board with four mounting holes at a stated spacing and no rows of holes. Each photo is framed through its own four holes, which fixes scale and perspective
/// together, and the corners those holes imply replace the ones fitted to edges in prep.json, so everything later reads one frame. The flip comes from the holes if they can say, and from through features if the pattern is symmetric.
fn scale_by_holes(p: &mut ledger::Project, fl: &flow::Flow, th: &str, prep_doc: &mut Value, hs: &str, bs: &str, source: &str, flip: Option<&str>) {
let f = p.flow_dir.clone();
if source.is_empty() { err("--source: name the document that gives the hole spacing and board size", &[]); }
let dims = |s: &str, what: &str| -> (f64, f64) { s.split_once('x').and_then(|(a, b)| Some((a.parse::<f64>().ok()?, b.parse::<f64>().ok()?))).unwrap_or_else(|| err(&format!("{what}: expected <x>x<y> in mm, got {s}"), &[])) };
let (spacing, size) = (dims(hs, "--holes"), dims(bs, "--board"));
if spacing.0 >= size.0 || spacing.1 >= size.1 { err("the hole spacing must fit inside the board", &[]); }
let top_rec = primary(prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[]));
let bot_rec = primary(prep_doc, "bottom").cloned();
let photo = |r: &Value| f.join("photos").join(r["file"].as_str().unwrap());
let mut frames = serde_json::Map::new();
let mut framed = |r: &Value, out: &mut serde_json::Map<String, Value>| -> [geom::P; 4] {
let hf = board::hole_frame(&photo(r), r, spacing, size).unwrap_or_else(|e| err(&format!("{}: {e}", r["file"].as_str().unwrap_or("?")), &["Look at evidence/prep-<photo>.jpg: the four holes must be found.".into()]));
let r3 = |x: f64| (x * 1000.0).round() / 1000.0;
out.insert(r["file"].as_str().unwrap().to_string(), json!({"pxPerMm": [r3(hf.px_mm.0), r3(hf.px_mm.1)], "holesPx": hf.holes.iter().map(|q| json!([r3(q.0), r3(q.1)])).collect::<Vec<_>>(),
"fittedCornersOffMm": r3(hf.fitted_off_mm), "openingOffRingMm": r3(hf.opening_off_mm), "ringFitRmsPx": r3(hf.ring_fit_px), "cornersPx": hf.corners.iter().map(|q| json!([r3(q.0), r3(q.1)])).collect::<Vec<_>>()}));
hf.corners
};
let tc = framed(&top_rec, &mut frames);
let bc = bot_rec.as_ref().map(|b| framed(b, &mut frames));
let set_corners = |doc: &mut Value, file: &str, c: &[geom::P; 4]| { for ph in doc["photos"].as_array_mut().into_iter().flatten() { if ph["file"].as_str() == Some(file) { if ph["cornersFitted"].is_null() { ph["cornersFitted"] = ph["corners"].clone(); } ph["corners"] = json!(c.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>()); } } };
set_corners(prep_doc, top_rec["file"].as_str().unwrap(), &tc);
if let (Some(b), Some(c)) = (&bot_rec, &bc) { set_corners(prep_doc, b["file"].as_str().unwrap(), c); }
let tp = &frames[top_rec["file"].as_str().unwrap()]["pxPerMm"];
let k = (tp[0].as_f64().unwrap_or(0.0) + tp[1].as_f64().unwrap_or(0.0)) / 2.0;
let (wmm, hmm) = size;
let top_img = board::to_frame(&photo(&top_rec), &tc, &board::frame_corners(wmm, hmm, "top", "x"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));
save(&top_img, &f.join("board/top.jpg"));
let mut align = json!({"shift_mm": [0.0, 0.0], "pairs": 0, "residual_mm": Value::Null, "method": "each face framed through its own holes"});
let mut axis = prep_doc["flip"]["axis"].as_str().unwrap_or("x").to_string();
let mut h: Vec<String> = vec![];
if let (Some(b), Some(c)) = (&bot_rec, &bc) {
let bx = board::to_frame(&photo(b), c, &board::frame_corners(wmm, hmm, "bottom", "x"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));
let by = board::to_frame(&photo(b), c, &board::frame_corners(wmm, hmm, "bottom", "y"), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));
let (m, o) = (prep_doc["flip"]["matches"].as_u64().unwrap_or(0), prep_doc["flip"]["other"].as_u64().unwrap_or(0));
let (by_detail, cx, cy) = board::flip_by_detail(&top_img, &bx, &by);
let (chosen, how) = match flip {
Some(a) if a == "x" || a == "y" => (a.to_string(), "stated with --flip"),
Some(a) => err(&format!("--flip {a}: expected x or y"), &[]),
None => match by_detail { Some(a) if m.abs_diff(o) <= 1 => (a, "through features: the hole pattern is symmetric"), _ if m.abs_diff(o) > 1 => (axis.clone(), "the holes"),
_ => err(&format!("cannot tell which way the board was turned over: the holes agree both ways and through features correlate {cx:.3} mirrored in x, {cy:.3} in y"), &["Look at both photos and give --flip x or --flip y, saying why in your report.".into()]) },
};
axis = chosen;
h.push(format!("bottom turned over {} ({how}; plain mask detail correlates {cx:.3} mirrored in x, {cy:.3} in y)", if axis == "y" { "top-to-bottom" } else { "left-to-right" }));
align["flip"] = json!({"axis": axis, "how": how, "detailNccX": (cx * 1000.0).round() / 1000.0, "detailNccY": (cy * 1000.0).round() / 1000.0});
prep_doc["flip"] = json!({"axis": axis, "matches": m, "other": o, "how": how});
let bdst = board::frame_corners(wmm, hmm, "bottom", &axis);
let first = if axis == "y" { by } else { bx };
// what four holes leave: lay the faces on each other through what shows on both, where there is enough of it
let mut kept = board::tile_pairs(&top_img, &first);
let tiles = kept.len();
let fit = regframe::Quad::fit(&kept).and_then(|mut q| { for _ in 0..3 { let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(q.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap()); let lim = (res[res.len() / 2] * 3.0).max(1.5); kept.retain(|x| geom::dist(q.apply(x.0), x.1) <= lim); q = regframe::Quad::fit(&kept)?; } Some(q) });
let by_tiles = fit.filter(|_| kept.len() >= 12).map(|q| { let mut r = regframe::report(&kept, &q, board::RES); r["tilesCorrelated"] = json!(tiles); r["through"] = json!("correlated 5 mm tiles"); (q, r) });
// vias that show on both faces, where there are enough: use whichever holds better on what was left out of its fit
let by_pits = board::pit_pairs(&top_img, &first).and_then(|prs| regframe::Quad::fit(&prs).map(|q| { let mut r = regframe::report(&prs, &q, board::RES); r["through"] = json!("vias showing on both faces"); (q, r) })).or_else(|| viasig::relay(&top_img, &first, None));
let loo = |r: &Value| r["leaveOneOutRmsMm"].as_f64().unwrap_or(f64::MAX);
if let (Some(t), Some(v)) = (&by_tiles, &by_pits) { h.push(format!("face to face, left out of the fit: tiles {} mm RMS, vias {} mm RMS", t.1["leaveOneOutRmsMm"], v.1["leaveOneOutRmsMm"])); }
let chosen_fit = match (by_tiles, by_pits) { (Some(t), Some(v)) => Some(if loo(&v.1) < loo(&t.1) { v } else { t }), (t, v) => t.or(v) };
let bimg = match chosen_fit {
Some((q, r)) => { h.push(format!("bottom laid on top through {} {}: {}", r["pairs"], r["through"].as_str().unwrap_or(""), r)); align["registration"] = r; align["registrationQuad"] = reframe::quad_json(&q); regframe::warp(&photo(b), c, &bdst, Some(&q), first.width(), first.height(), 0).unwrap_or_else(|e| err(&e, &[])) }
None => { h.push(format!("only {tiles} tiles correlate between the faces and too few vias show on both, so no warp: the bottom rests on its four holes alone")); first }
};
save(&bimg, &f.join("board/bottom.jpg"));
save(&copper::grid_image(&bimg, 5.0), &f.join("board/bottom-grid.jpg"));
wr(&f.join("detect/holes-bottom.json"), &json!({"holes": holes_mm(&bimg)}));
}
wr(&f.join("prep.json"), prep_doc);
save(&copper::grid_image(&top_img, 5.0), &f.join("board/top-grid.jpg"));
let top_holes = holes_mm(&top_img);
let rows: Vec<Value> = prep::hole_rows(&top_holes.iter().map(|h| json!({"x": fv(h, "x"), "y": fv(h, "y"), "outer": fv(h, "outer_mm")})).collect::<Vec<_>>()).into_iter().map(|mut r| { r["pitch_mm"] = json!((fv(&r, "pitch") * 1000.0).round() / 1000.0); r }).collect();
wr(&f.join("detect/holes-top.json"), &json!({"holes": top_holes, "rows": rows}));
let radius = corner_radius_to_mask(&top_img);
let doc = json!({"pxPerMm": (k * 1000.0).round() / 1000.0, "method": "four mounting holes at a stated spacing", "holes": {"spacingMm": [spacing.0, spacing.1], "boardMm": [size.0, size.1], "source": source, "perPhoto": frames}, "candidates": [],
"board_mm": [wmm, hmm], "corner_mm": radius, "frame": {"origin": [board::ORIGIN, board::ORIGIN], "pxPerMm": board::RES, "bottomFlip": axis}, "alignment": align, "at": ledger::now()});
wr(&f.join("scale.json"), &doc);
p.log(th, "scale", doc.clone());
reach(p, fl, "scale");
h.insert(0, format!("{k:.2} px/mm on the top photo through its four holes, {source}; board {wmm} x {hmm} mm, corner radius about {radius} mm"));
for (file, v) in &frames { h.push(format!("{file}: {} px/mm, the corners fitted to its edges were {} mm off, the opening's centre {} mm from the ring's", v["pxPerMm"], v["fittedCornersOffMm"], v["openingOffRingMm"])); }
h.extend(views::record(&f, &p.state, "scale", "board frame written", th, None));
h.extend(step_hint(fl, &f));
ok("scale.json and the board frame written through the four holes (board/top.jpg, board/bottom.jpg, gridded copies)", &h);
}
fn holes_mm(i: &image::RgbImage) -> Vec<Value> {
prep::holes(i, 0.45 * board::RES, 4.5 * board::RES).into_iter().map(|h| {
let (x, y) = board::to_mm(fv(&h, "x"), fv(&h, "y"));
json!({"x": (x * 1000.0).round() / 1000.0, "y": (y * 1000.0).round() / 1000.0, "hole_mm": (fv(&h, "hole") / board::RES * 100.0).round() / 100.0, "outer_mm": (fv(&h, "outer") / board::RES * 100.0).round() / 100.0, "square": h["square"]})
}).collect()
}
fn gap_for(flow: &Path, layer: &str) -> (raster::Plane, f32) {
let f = if layer == "F.Cu" { "board/top.jpg" } else { "board/bottom.jpg" };
let g = copper::gap_map(&img(&flow.join(f)));
let s = copper::edge_strength(&g);
(g, s)
}
/// The run's rules (runs started before rules existed get the default profile).
fn rules_of(state: &Value) -> Value { if state["rules"].is_object() { state["rules"].clone() } else { rules::profile("kicad-default").unwrap() } }
/// The placed pads (2-layer build), for checks that need to know where pads are.
fn build_board_pads(flow: &Path) -> Vec<kicad_out::Pad> { build_board(flow, 2).pads }
fn build_board(flow: &Path, layers: usize) -> kicad_out::Board { build_board_for(flow, layers, false) }
/// The copper of one stackup: what was seen, plus the planes, each zone at the run's rule.
fn copper_for(flow: &Path, layers: usize, planes: &[(String, String)]) -> Value {
let mut copper = rd(&flow.join("copper.json"));
let st = items::read_json(&flow.join("state.json")).unwrap_or(json!({}));
let rl = rules_of(&st);
for z in copper["zones"].as_array_mut().into_iter().flatten() { if z["clearance_mm"].is_null() { z["clearance_mm"] = rl["clearance"].clone(); } }
copper["edge_clearance_mm"] = rl["edgeClearance"].clone();
copper["clearance_rule_mm"] = rl["clearance"].clone();
if layers > 2 {
let outline = copper["outline"]["points_mm"].clone();
for (layer, net) in planes {
let idx: usize = layer.trim_start_matches("In").trim_end_matches(".Cu").parse().unwrap_or(99);
if idx < layers - 1 { if let Some(z) = copper["zones"].as_array_mut() { z.push(json!({"id": format!("plane-{layer}"), "net": net, "layer": layer, "polygon_mm": outline, "clearance_mm": rl["clearance"], "connect": "solid", "tag": "INFERRED"})); } }
}
}
copper
}
fn board_title(flow: &Path) -> String { format!("{} (reverse engineered from photos)", flow.parent().and_then(|p| p.file_name()).map(|f| f.to_string_lossy().to_string()).unwrap_or_default()) }
/// The routed copper of a stackup, if it was routed on the copper.json there is now.
fn routes_for(flow: &Path, layers: usize) -> Option<Value> {
let rt = items::read_json(&flow.join(format!("routes/L{layers}/routed.json"))).ok()?;
(rt["copperSha"].as_str() == Some(sha256(&flow.join("copper.json")).as_str())).then_some(rt)
}
/// `_routing`: the board without routed copper.
fn build_board_for(flow: &Path, layers: usize, _routing: bool) -> kicad_out::Board {
let parts = rd(&flow.join("parts.json"));
let planes: Vec<(String, String)> = items::read_json(&flow.join("layers.json")).ok().map(|l| l["planes"].as_array().into_iter().flatten().map(|p| (p["layer"].as_str().unwrap_or("").to_string(), p["net"].as_str().unwrap_or("").to_string())).collect()).unwrap_or_default();
let mut copper = copper_for(flow, layers, &planes);
// the routed copper of this stackup, when it was routed on this copper, is part of the copy. Routes from older copper are not: merging them drew tracks across the new copper and displaced what it seen
if !_routing { if let Some(rt) = routes_for(flow, layers) { merge_routed(&mut copper, &rt); } }
let nets = items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));
kicad_out::board_opt(&kicad_out::cache_dir(flow), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &copper, &nets, layers, &board_title(flow), true).unwrap_or_else(|e| err(&e, &[]))
}
/// Seen tracks that routed copper of another net would touch give way: the router moved through them because
/// it could not do otherwise, and the count says so. Pours clear by themselves when they fill.
fn displace_seen(copper: &mut Value, rt: &Value, clr: f64) -> usize {
let pt = |v: &Value| (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0));
let seg_d = |p: (f64, f64), a: (f64, f64), b: (f64, f64)| { let (dx, dy) = (b.0 - a.0, b.1 - a.1); let l2 = dx * dx + dy * dy; let t = if l2 > 0.0 { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) } else { 0.0 }; ((p.0 - a.0 - t * dx).powi(2) + (p.1 - a.1 - t * dy).powi(2)).sqrt() };
let seg_seg = |a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)| { let mut m = seg_d(a, c, d).min(seg_d(b, c, d)).min(seg_d(c, a, b)).min(seg_d(d, a, b));
let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);
if side(a, b, c) * side(a, b, d) < 0.0 && side(c, d, a) * side(c, d, b) < 0.0 { m = 0.0; } m };
let routed: Vec<(&str, &str, f64, (f64, f64), (f64, f64))> = rt["traces"].as_array().into_iter().flatten().filter_map(|t| { let p = t["path_mm"].as_array()?; Some((t["net"].as_str()?, t["layer"].as_str()?, t["width_mm"].as_f64()?, pt(&p[0]), pt(&p[1]))) }).collect();
let vias: Vec<(&str, f64, (f64, f64))> = rt["routedVias"].as_array().into_iter().flatten().filter_map(|v| Some((v["net"].as_str()?, v["diameter_mm"].as_f64()?, pt(&v["pos_mm"])))).collect();
// a via of unknown net gives way the same way
let mut gone = 0;
if let Some(sv) = copper["vias"].as_array_mut() {
let n0 = sv.len();
sv.retain(|v| { let net = v["net"].as_str().unwrap_or(""); if !(net.is_empty() || net.starts_with("UNK")) || v["source"] == json!("router") { return true; }
let (p, d) = (pt(&v["pos_mm"]), v["diameter_mm"].as_f64().unwrap_or(0.6));
!(routed.iter().any(|r| seg_d(p, r.3, r.4) < d / 2.0 + r.2 / 2.0 + clr + 0.01) || vias.iter().any(|o| crate::geom::dist(p, o.2) < d / 2.0 + o.1 / 2.0 + clr + 0.01)) });
gone = n0 - sv.len();
}
let Some(t) = copper["traces"].as_array_mut() else { return gone };
let before = t.len() + gone;
t.retain(|s| {
let (net, layer, w) = (s["net"].as_str().unwrap_or(""), s["layer"].as_str().unwrap_or(""), s["width_mm"].as_f64().unwrap_or(0.2));
let path: Vec<(f64, f64)> = s["path_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();
!path.windows(2).any(|q| routed.iter().any(|r| r.0 != net && r.1 == layer && seg_seg(q[0], q[1], r.3, r.4) < w / 2.0 + r.2 / 2.0 + clr + 0.01) || vias.iter().any(|v| v.0 != net && seg_d(v.2, q[0], q[1]) < w / 2.0 + v.1 / 2.0 + clr + 0.01))
});
before - t.len()
}
fn merge_routed(copper: &mut Value, rt: &Value) {
let clr = copper["clearance_rule_mm"].as_f64().unwrap_or(0.2);
displace_seen(copper, rt, clr);
if let Some(t) = copper["traces"].as_array_mut() { t.extend(rt["traces"].as_array().cloned().unwrap_or_default()); }
if let Some(v) = copper["vias"].as_array_mut() { v.extend(rt["routedVias"].as_array().cloned().unwrap_or_default()); }
}
/// Vias read from the photos, each kept or dropped on what its own photo shows, with the measurements that decided it.
/// A face lit from one side shows tented vias as domes (`domes`). Any face may show them as holes: dark round centres, judged spot by spot against the other face (`viasig`).
/// Where the two faces cannot be laid on each other through their spots, a flat face falls back to the pit reader, which accepts or refuses the face as a whole.
fn write_domes(f: &std::path::Path) -> Vec<String> {
let (mut kept, mut all, mut pit_all, mut lines) = (vec![], vec![], vec![], vec![]);
let mut light = serde_json::Map::new();
let mut faces = vec![];
for side in ["top", "bottom"] {
let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; }
let i = img(&file);
let d = domes::measure(&i);
let coh = domes::face_coherence(&d);
light.insert(side.to_string(), json!({"toward": [d.light.0, d.light.1], "coherence": coh, "oneLight": coh >= domes::FACE_COHERENCE}));
let vs = domes::finish(&d);
let flat = coh < domes::FACE_COHERENCE;
lines.push(format!("{side}: {} candidates, {} read as domes, shading agreement {coh:.2}{}", d.cands.len(), vs.len(), if flat { ": no one light on this face, so none of them count as domes" } else { "" }));
for c in &d.cands { all.push(domes::cand_json(c, side)); }
faces.push((side, flat, vs, i));
}
let r3 = |v: f64| (v * 1000.0).round() / 1000.0;
// holes, spot by spot, where both faces are there to witness each other
let reading = if faces.len() == 2 { Some(viasig::read(&faces[0].3, &faces[1].3)) } else { None };
let by_holes = reading.as_ref().is_some_and(|r| r.lay.is_some());
if let Some(r) = &reading { lines.extend(r.lines.iter().cloned()); }
// without that, a flat face's pits are judged as a population against what the other face shows
let pits: Vec<Option<domes::PitFace>> = faces.iter().map(|fc| if fc.1 && !by_holes { Some(domes::read_pits(&fc.3)) } else { None }).collect();
let spots: Vec<Vec<(f64, f64)>> = faces.iter().zip(&pits).map(|(fc, pf)| match pf { Some(pf) => pf.pits.iter().filter(|q| q.keep).map(|q| (q.x, q.y)).collect(), None => fc.2.iter().map(|v| (v.x, v.y)).collect() }).collect();
for (k, (side, flat, vs, i)) in faces.iter().enumerate() {
let mut here: Vec<(f64, f64)> = vec![];
if !*flat { for v in vs { let (x, y) = board::to_mm(v.x, v.y); here.push((v.x, v.y)); kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(v.d_mm), "shape": v.shape, "z": (v.z * 100.0).round() / 100.0})); } }
if by_holes {
let rd = reading.as_ref().unwrap(); let sp = if k == 0 { &rd.top } else { &rd.bottom };
for q in sp.iter().filter(|q| q.keep) {
// a dome already stands for this via
if here.iter().any(|p| geom::dist(*p, (q.x, q.y)) < viasig::SAME_VIA_MM * board::RES) { continue; }
let (x, y) = board::to_mm(q.x, q.y); let (d, _) = viasig::width_mm(q);
kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(d), "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0, "seen": q.why}));
}
continue;
}
let Some(pf) = &pits[k] else { continue };
let n = pf.pits.iter().filter(|q| q.keep).count();
for q in &pf.pits { pit_all.push(domes::pit_json(q, side)); }
let other: Vec<(f64, f64)> = spots.iter().enumerate().filter(|(j, _)| *j != k).flat_map(|(_, v)| v.iter().copied()).collect();
let (hit, chance, sure) = domes::pits_confirmed(&spots[k], &other, (i.width() as f64, i.height() as f64));
let counts = pf.shows && sure;
lines.push(format!("{side}: {} dark spots measured, {n} look like pits, median {:.3} mm, middle half within {:.2} of it; {hit} have a via on the other face within 0.1 mm, {chance} by chance{}", pf.pits.len(), pf.median_mm, pf.spread, if counts { ": this face shows its vias as pits" } else { ": they are not vias, so none of them count" }));
if counts { for q in pf.pits.iter().filter(|q| q.keep) { let (x, y) = board::to_mm(q.x, q.y); kept.push(json!({"side": side, "pos_mm": [r3(x), r3(y)], "diameter_mm": r3(((q.len + q.wid) / 2.0) as f64), "shape": "pit", "z": (q.z as f64 * 100.0).round() / 100.0})); } }
}
wr(&f.join("detect/domes.json"), &json!({"domes": kept, "light": light, "note": "vias read from the photos: position and diameter measured; shape says what was seen (ring: dark circle round a pad, arc: near arc of a via, blob: the dome itself, edge: a dome on a copper edge, its width the face's median, pit: the dark centre of a via's hole, its width the pad's where a ring shows it and else the hole's own); seen: why a pit was kept (detect/via-spots.json lists every dark centre, kept or dropped, with its reason)"}));
wr(&f.join("detect/dome-candidates.json"), &json!({"candidates": all}));
wr(&f.join("detect/pit-candidates.json"), &json!({"candidates": pit_all}));
if let Some(rd) = &reading {
let mut sp = vec![]; for q in &rd.top { sp.push(viasig::spot_json(q, "top")); } for q in &rd.bottom { sp.push(viasig::spot_json(q, "bottom")); }
wr(&f.join("detect/via-spots.json"), &json!({"spots": sp, "bars": {"top": viasig::bars_json(&rd.bars[0]), "bottom": viasig::bars_json(&rd.bars[1])}, "lay": rd.lay.as_ref().map(|l| json!({"pairs": l.pairs, "shift_mm": [r3(l.shift.0 / board::RES), r3(l.shift.1 / board::RES)], "quadratic": l.quad.is_some(), "votes": l.votes, "mirroredVotes": l.chance})),
"note": "every dark round centre on both faces with what was measured on it; keep and why give the verdict; otherFace says what the other face shows at the same place (spot, bare mask, or covered)"}));
}
lines
}
/// Which inner layer carries what on an n-layer stackup: the first plane net next to the top, the last next
/// to the bottom, signal layers between them. Two inner layers are both planes; a third inner layer onward is routable.
fn plane_map(n: usize, nets: &[String]) -> Vec<(String, String)> {
if n < 4 { return vec![]; }
let inner = n - 2;
let mut v = vec![];
if let Some(a) = nets.first() { v.push(("In1.Cu".to_string(), a.clone())); }
if let Some(b) = nets.get(1) { if inner >= 2 { v.push((format!("In{inner}.Cu"), b.clone())); } }
v
}
/// The other photo of a face, laid on the primary (`board/<side>.jpg`), kept at `board/<side>-second.png`. None for a run with one photo of the face, or with TRACE_ONE_SHOT set.
fn second_shot(f: &Path, side: &str, base: &image::RgbImage) -> Option<image::RgbImage> {
if std::env::var("TRACE_ONE_SHOT").is_ok() { return None; }
let path = f.join(format!("board/{side}-second.png"));
let primary = f.join(format!("board/{side}.jpg"));
let fresh = match (std::fs::metadata(&path).and_then(|m| m.modified()), std::fs::metadata(&primary).and_then(|m| m.modified())) { (Ok(a), Ok(b)) => a >= b, _ => false };
if fresh { if let Ok(i) = image::open(&path) { let i = i.to_rgb8(); if i.dimensions() == base.dimensions() { return Some(i); } } }
let (prep, scale) = (items::read_json(&f.join("prep.json")).ok()?, items::read_json(&f.join("scale.json")).ok()?);
let prim = prep["primary"][side].as_str()?;
let file = prep["photos"].as_array()?.iter().find(|q| q["side"].as_str() == Some(side) && q["file"].as_str() != Some(prim))?["file"].as_str()?.to_string();
let (i, _, _) = photolayers::photo_quad(f, &file, side, &prep, &scale, Some(base))?;
let _ = i.save(&path);
Some(i)
}
/// The domes `copper domes` read on this face, in image pixels.
fn domes_of(f: &Path, side: &str) -> Vec<(f64, f64)> {
items::read_json(&f.join("detect/domes.json")).ok().map(|d| d["domes"].as_array().into_iter().flatten().filter(|x| x["side"].as_str() == Some(side)).filter_map(|x| Some(board::to_px(x["pos_mm"][0].as_f64()?, x["pos_mm"][1].as_f64()?))).collect()).unwrap_or_default()
}
/// The face's one light, as a unit vector towards it in image pixels, when `copper domes` found its bumps agree on one.
fn light_of(f: &Path, side: &str) -> Option<(f64, f64)> {
let d = items::read_json(&f.join("detect/domes.json")).ok()?; let l = &d["light"][side];
if l["oneLight"].as_bool() != Some(true) { return None; }
let (x, y) = (l["toward"][0].as_f64()?, l["toward"][1].as_f64()?); let n = (x * x + y * y).sqrt(); if n < 1e-6 { None } else { Some((x / n, y / n)) }
}
/// `scale --crop`: a crop of the straightened top photo, enlarged, with that photo's pixel coordinates along its top and
/// left edges, so two points a known distance apart can be read off it for `scale --ruler`.
fn scale_crop(f: &Path, prep_doc: &Value, spec: &str) {
let top = primary(prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured: run `prep` first", &[]));
let v: Vec<f64> = spec.split(',').filter_map(|t| t.trim().parse().ok()).collect();
if v.len() < 2 { err("--crop <x>,<y>[,<size>]: the centre, in pixels of the straightened top photo (evidence/prep-<top photo>.jpg), and the size in pixels (600 by default)", &[]); }
let size = v.get(2).copied().unwrap_or(600.0).clamp(100.0, 3000.0);
let img = image::open(f.join("photos").join(top["file"].as_str().unwrap_or(""))).unwrap_or_else(|e| err(&e.to_string(), &[])).to_rgb8();
let c = top["corners"].as_array().cloned().unwrap_or_default();
if c.len() != 4 { err("prep.json has no outline for the top photo", &[]); }
let corners: [geom::P; 4] = [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)));
let (w, h) = (top["rectPx"][0].as_f64().unwrap_or(0.0) as u32, top["rectPx"][1].as_f64().unwrap_or(0.0) as u32);
let (rect, _) = prep::rectify(&img, &corners, w, h, false).unwrap_or_else(|e| err(&e, &[]));
let (x0, y0) = ((v[0] - size / 2.0).round().clamp(0.0, (w as f64 - size).max(0.0)) as i64, (v[1] - size / 2.0).round().clamp(0.0, (h as f64 - size).max(0.0)) as i64);
let n = (size as i64).min(w as i64 - x0).min(h as i64 - y0).max(1);
let z = (1400 / n).clamp(1, 8); // whole-number enlargement, nearest pixel: no resampling blur
let m = 70i64; // margin for the coordinate scales
let mut out = image::RgbImage::from_pixel((n * z + m) as u32, (n * z + m) as u32, image::Rgb([18, 22, 28]));
for y in 0..n * z { for x in 0..n * z { out.put_pixel((x + m) as u32, (y + m) as u32, *rect.get_pixel((x0 + x / z) as u32, (y0 + y / z) as u32)); } }
let step = if n <= 150 { 5 } else if n <= 300 { 10 } else if n <= 800 { 20 } else if n <= 1600 { 50 } else { 100 };
let mut t = (x0 / step) * step; if t < x0 { t += step; }
while t < x0 + n { let px = ((t - x0) * z + m) as f64; let major = (t / step) % 5 == 0;
draw::line(&mut out, (px, if major { (m - 22) as f64 } else { (m - 10) as f64 }), (px, m as f64), [255, 220, 60], 1);
if major { draw::label(&mut out, (px - 18.0, 8.0), &t.to_string(), [255, 220, 60], 2); draw::line(&mut out, (px, m as f64), (px, (n * z + m) as f64), [255, 220, 60], 1); }
t += step; }
let mut t = (y0 / step) * step; if t < y0 { t += step; }
while t < y0 + n { let py = ((t - y0) * z + m) as f64; let major = (t / step) % 5 == 0;
draw::line(&mut out, (if major { (m - 22) as f64 } else { (m - 10) as f64 }, py), (m as f64, py), [255, 220, 60], 1);
if major { draw::label(&mut out, (2.0, py - 6.0), &t.to_string(), [255, 220, 60], 2); draw::line(&mut out, (m as f64, py), ((n * z + m) as f64, py), [255, 220, 60], 1); }
t += step; }
let name = format!("evidence/scale-crop-{}-{}-{}.jpg", v[0] as i64, v[1] as i64, n);
out.save(f.join(&name)).unwrap_or_else(|e| err(&e.to_string(), &[]));
ok(&format!("{name}: the straightened top photo from ({x0}, {y0}), {n} px square, enlarged {z}x; ticks every {step} px, labelled lines every {} px", step * 5),
&["Read the two ruler points off the labelled lines and ticks (photo pixels, not crop pixels), then: scale --ruler <x1>,<y1>,<x2>,<y2>=<mm> --source \"<datasheet, figure>\". Crop each end separately at a small size for a finer reading.".into()]);
}
fn drc_of(pcb: &Path, out: &Path) -> Value {
// A native kicad-cli named in ADOM_AIFLOW_KICAD_CLI is used first (it reads the project's own rules); otherwise the
// shared service. Both exit non-zero when the board HAS violations, so the report file decides whether the DRC ran:
// a report that reads is a DRC that ran, whatever the exit code (the USB hub baseline, 2026-11-10, read every DRC
// with errors as "did not run").
let _ = std::fs::remove_file(out);
let o = match std::env::var("ADOM_AIFLOW_KICAD_CLI") {
Ok(cli) if !cli.is_empty() => std::process::Command::new(cli).args(["pcb", "drc", "--format", "json", "--severity-all", "--output"]).arg(out).arg(pcb).output(),
_ => std::process::Command::new("service-kicad").args(["pcb", "drc"]).arg(pcb).args(["--format", "json", "--out"]).arg(out).output(),
};
match o {
Ok(o) => match items::read_json(out) { Ok(v) if v.is_object() => v, _ if o.status.success() => json!({}), _ => json!({"error": format!("{} {}", String::from_utf8_lossy(&o.stderr), String::from_utf8_lossy(&o.stdout)).trim().to_string()}) },
Err(e) => json!({"error": e.to_string()}),
}
}
/// Under this many known connections the routing test proposes nothing. The Driver had 253; the CM5 photos alone gave 8.
const MIN_CONNECTIONS: usize = 30;
/// The proposal: the smallest stackup that closes; if none does, the smallest beyond which more layers stop
/// helping. The same stackup routed again varies by about a quarter in open connections (6 layers on the Driver
/// board: 25 to 34), so a smaller difference is not a reason for more layers.
fn write_proposal(l: &mut Value, cands: &[Value], closed_at: Option<usize>) {
let best_open = cands.iter().filter_map(|c| c["open"].as_u64()).min().unwrap_or(0) as f64;
let best_cross = cands.iter().filter_map(|c| c["seenCrossingMm"].as_f64()).fold(f64::MAX, f64::min);
let levelled = cands.iter().find(|c| c["open"].as_f64().unwrap_or(f64::MAX) <= best_open * 1.25 + 2.0 && c["seenCrossingMm"].as_f64().unwrap_or(f64::MAX) <= best_cross * 1.5 + 10.0).and_then(|c| c["layers"].as_u64()).map(|n| n as usize);
// too few known connections and routing says nothing about the stackup: two layers close eight connections as well as ten do
let known = cands.iter().filter_map(|c| c["connections"].as_u64()).max().unwrap_or(0);
// and when no stackup closes more than another, what blocks the rest is not the layer count: routing says nothing about it
let opens: Vec<u64> = cands.iter().filter_map(|c| c["open"].as_u64()).collect();
let flat = closed_at.is_none() && opens.len() > 1 && opens.iter().max() == opens.iter().min();
let pick = if (known as usize) < MIN_CONNECTIONS || flat { None } else { closed_at.or(levelled) };
if pick.is_none() && flat && (known as usize) >= MIN_CONNECTIONS {
l["proposed"] = Value::Null; l["tag"] = json!("UNKNOWN"); l["tests"] = json!(["T-layers"]);
l["why"] = json!(format!("every stackup tried leaves the same {} of {known} connections open: more layers changed nothing, so the open ones are blocked by something else (hidden pins, missing footprints) and routing cannot tell the layer count. Measure the layers (cross-section, CT, or the edge under a microscope)", opens[0]));
}
if pick.is_none() && (known as usize) < MIN_CONNECTIONS {
l["proposed"] = Value::Null; l["tag"] = json!("UNKNOWN"); l["tests"] = json!(["T-layers"]);
l["why"] = json!(format!("only {known} connections are known, under {MIN_CONNECTIONS}: routing cannot tell one stackup from another. Name more pin nets, or measure the layers (cross-section, CT, or the edge under a microscope)"));
}
let trail: Vec<String> = cands.iter().map(|c| format!("{} layers: {} of {} connections open, {} new DRC errors, {} vias, {:.0} mm of track, {:.0} mm crossing seen copper", c["layers"], c["open"], c["connections"], c["drcErrors"], c["vias"], c["trackMm"].as_f64().unwrap_or(0.0), c["seenCrossingMm"].as_f64().unwrap_or(0.0))).collect();
if let Some(n) = pick {
let ch = cands.iter().find(|c| c["layers"].as_u64() == Some(n as u64)).cloned().unwrap_or(json!({}));
l["proposed"] = json!(n);
l["planes"] = ch["planes"].clone();
l["tag"] = json!("INFERRED"); l["tests"] = json!(["T-layers"]);
l["why"] = json!(match closed_at { Some(_) => format!("{n} layers is the smallest stackup where the router closes every connection and the DRC finds nothing new. {}", trail.join("; ")), None => format!("no stackup closed every connection. {n} layers is the smallest beyond which more layers stop helping. {}", trail.join("; ")) });
if ch["open"].as_u64().unwrap_or(0) > 0 { l["openNote"] = json!(format!("{} connection(s) stay open at {n} layers: {}", ch["open"], ch["failed"].as_array().into_iter().flatten().map(|x| format!("{} ({})", x["net"].as_str().unwrap_or(""), x["pins"].as_array().map(|p| p.iter().filter_map(|q| q.as_str()).collect::<Vec<_>>().join(" ")).unwrap_or_default())).collect::<Vec<_>>().join("; "))); } else if let Some(o) = l.as_object_mut() { o.remove("openNote"); }
}
l["trail"] = json!(trail);
l["candidates"] = json!(cands.iter().map(|c| { let mut x = c.clone(); if let Some(o) = x.as_object_mut() { o.remove("failed"); } x }).collect::<Vec<_>>());
}
/// A violation's identity for comparing two DRC reports: its type and its items, without measured lengths.
fn drc_keys(d: &Value) -> Vec<(String, String, Value)> {
d["violations"].as_array().into_iter().flatten().filter(|v| v["severity"] == json!("error")).map(|v| {
let mut it: Vec<String> = v["items"].as_array().into_iter().flatten().map(|i| { let s = i["description"].as_str().unwrap_or(""); s.split(", length").next().unwrap_or(s).to_string() }).collect();
it.sort();
(v["type"].as_str().unwrap_or("").to_string(), it.join(" | "), v.clone())
}).collect()
}
/// Route one stackup, write the copy with zones filled around the new copper, and ask KiCad what it thinks.
fn route_candidate(flow: &Path, n: usize, plane_nets: &[String], rl: &Value, seconds: f64, stall: usize, log: &mut dyn FnMut(String)) -> Value {
let dir = flow.join(format!("routes/L{n}"));
let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).ok();
let planes = plane_map(n, plane_nets);
let parts = rd(&flow.join("parts.json"));
let nets_doc = items::read_json(&flow.join("nets.json")).unwrap_or(json!({"nets": []}));
let plist = parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]);
let cache = kicad_out::cache_dir(flow);
let title = board_title(flow);
let mut copper = copper_for(flow, n, &planes);
// the board as it stands before routing: its DRC findings are not the router's
let b0 = kicad_out::board_opt(&cache, plist, &copper, &nets_doc, n, &title, true).unwrap_or_else(|e| err(&e, &[]));
std::fs::write(dir.join("unrouted.kicad_pcb"), &b0.text).ok();
let base = drc_of(&dir.join("unrouted.kicad_pcb"), &dir.join("drc-unrouted.json"));
let mut layers = vec!["F.Cu".to_string()]; for i in 1..n.saturating_sub(1) { layers.push(format!("In{i}.Cu")); } layers.push("B.Cu".into());
let lidx = |nm: &str| layers.iter().position(|x| x == nm);
let pt = |v: &Value| (v[0].as_f64().unwrap_or(0.0), v[1].as_f64().unwrap_or(0.0));
let plane_by_net: HashMap<String, usize> = planes.iter().filter_map(|(l, nt)| lidx(l).map(|i| (nt.clone(), i))).collect();
let mut zones = vec![];
for z in copper["zones"].as_array().into_iter().flatten() {
if z["id"].as_str().unwrap_or("").starts_with("plane-") { continue; }
if let Some(l) = lidx(z["layer"].as_str().unwrap_or("")) {
let poly: Vec<(f64, f64)> = z["polygon_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();
let m = kicad_out::zone_mask(&b0.grid, z, &poly);
zones.push(route::ZoneIn { id: z["id"].as_str().unwrap_or("").to_string(), net: z["net"].as_str().unwrap_or("").to_string(), layer: l, cells: (0..m.d.len()).filter(|i| m.d[*i]).collect() });
}
}
// every via seen is a hole through every layer, whatever its net
let vias: Vec<route::ViaIn> = copper["vias"].as_array().into_iter().flatten().map(|v| { let (x, y) = pt(&v["pos_mm"]); route::ViaIn { id: v["id"].as_str().unwrap_or("").to_string(), net: v["net"].as_str().unwrap_or("").to_string(), x, y, d: v["diameter_mm"].as_f64().unwrap_or(0.6), h: v["drill_mm"].as_f64().unwrap_or(0.3) } }).collect();
let tracks: Vec<route::TrackIn> = copper["traces"].as_array().into_iter().flatten().filter_map(|t| Some(route::TrackIn { id: t["id"].as_str()?.to_string(), net: t["net"].as_str().unwrap_or("").to_string(), layer: lidx(t["layer"].as_str()?)?, path: t["path_mm"].as_array()?.iter().map(pt).collect(), w: t["width_mm"].as_f64().unwrap_or(0.2) })).collect();
let str_list = |v: &Value| -> Vec<String> { v.as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect() };
let groups: Vec<route::GroupIn> = items::read_json(&flow.join("detect/copper-measured.json")).ok().map(|d| d["groups"].as_array().into_iter().flatten().map(|g| route::GroupIn { net: g["net"].as_str().unwrap_or("").to_string(), pins: str_list(&g["pins"]), zones: str_list(&g["zones"]), traces: str_list(&g["traces"]), vias: str_list(&g["vias"]) }).collect()).unwrap_or_default();
let power: HashSet<String> = nets_doc["nets"].as_array().into_iter().flatten().filter_map(|x| x["name"].as_str()).filter(|nm| *nm == "GND" || nm.starts_with('+') || nm.starts_with("VIN") || nm.contains("VCC") || nm.contains("VDD") || nm.starts_with("PHASE") || nm.contains("VBAT")).map(str::to_string).collect();
let (vs, vh) = rules::via(rl, "viaSignal"); let (ps, ph) = rules::via(rl, "viaPower");
let rr_clr = rules::num(rl, "clearance", 0.2);
let rr = route::Rules { clr: rules::num(rl, "clearance", 0.2), track: rules::num(rl, "track", 0.2), power_track: rules::num(rl, "powerTrack", 0.5), via: (vs, vh), power_via: (ps, ph), edge: rules::num(rl, "edgeClearance", 0.5), hole_clr: rules::num(rl, "holeClearance", 0.25), hole_hole: rules::num(rl, "holeToHole", 0.25) };
let outline: Vec<(f64, f64)> = copper["outline"]["points_mm"].as_array().map(|a| a.iter().map(pt).collect()).unwrap_or_default();
let inside = kicad_out::board_inside(&b0.grid, &outline, copper["outline"]["corner_mm"].as_f64().unwrap_or(0.0));
let inp = route::Input { pads: &b0.pads, layers: layers.clone(), planes: plane_by_net, inside: &inside, grid: &b0.grid, zones, vias, tracks, groups, rules: rr, power, seconds, stall };
let mut lines: Vec<String> = vec![];
let mut res = { let mut sink = |m: String| { log(m.clone()); lines.push(m); }; route::run(&inp, &mut sink) };
res["copperSha"] = json!(sha256(&flow.join("copper.json")));
wr(&dir.join("routed.json"), &res);
// the routed copy: zones fill around everything the router placed
let displaced = displace_seen(&mut copper.clone(), &res, rr_clr);
merge_routed(&mut copper, &res);
let b1 = kicad_out::board_opt(&cache, plist, &copper, &nets_doc, n, &title, true).unwrap_or_else(|e| err(&e, &[]));
let pcb = dir.join("board.kicad_pcb"); std::fs::write(&pcb, &b1.text).ok();
let d = drc_of(&pcb, &dir.join("drc.json"));
let known: HashSet<(String, String)> = drc_keys(&base).into_iter().map(|(a, b, _)| (a, b)).collect();
let fresh: Vec<Value> = drc_keys(&d).into_iter().filter(|(a, b, _)| !known.contains(&(a.clone(), b.clone()))).map(|x| x.2).collect();
let mut types: BTreeMap<String, u64> = BTreeMap::new();
for v in &fresh { *types.entry(v["type"].as_str().unwrap_or("").to_string()).or_default() += 1; }
let kicad_open = d["unconnected_items"].as_array().map(|a| a.len()).unwrap_or(0);
wr(&dir.join("gate.json"), &json!({"newErrors": fresh.len(), "newErrorTypes": types, "inherited": known.len(), "kicadOpen": kicad_open, "new": fresh, "unconnected": d["unconnected_items"], "drcError": d["error"]}));
std::fs::write(dir.join("route.log"), lines.join("\n") + "\n").ok();
// a DRC that did not run says nothing: its counts are unknown, never zero
let ran = d["error"].is_null() && d["violations"].is_array();
let (drc_errors, kicad_open_v) = if ran { (json!(fresh.len()), json!(kicad_open)) } else { (Value::Null, Value::Null) };
let mut c = json!({"layers": n, "open": res["open"], "connections": res["connections"], "drcErrors": drc_errors, "drcTypes": types, "inheritedErrors": known.len(), "kicadOpen": kicad_open_v, "drcError": if ran { Value::Null } else { json!(d["error"].as_str().unwrap_or("the DRC report has no violations list").trim()) },
"trackMm": res["trackMm"], "vias": res["vias"], "seenCrossingMm": res["seenCrossingMm"], "seenTracksDisplaced": displaced, "layerUse": res["layerUse"], "passes": res["passes"], "ripups": res["ripups"], "seconds": res["seconds"], "stop": res["stop"], "narrowedPower": res["narrowedPower"],
"planes": planes.iter().map(|(l, nt)| json!({"layer": l, "net": nt, "tag": "INFERRED", "tests": ["T-layers"], "why": "chosen for this stackup: the largest power nets on the layers next to the outer faces"})).collect::<Vec<_>>(),
"run": format!("reverse-flow/routes/L{n}"), "log": format!("reverse-flow/routes/L{n}/route.log"), "rules": rl["name"], "failed": res["failed"]});
if !b1.warnings.is_empty() { c["warnings"] = json!(b1.warnings.len()); }
c
}
fn main() {
let cli = Cli::parse();
let fl = flow::load();
match &cli.cmd {
Cmd::Init { top, bottom, photos: unsorted, target, rules: rules_arg } => {
let th = thread(&cli);
let rules_v = rules::load(rules_arg).unwrap_or_else(|e| err(&e, &[]));
let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
if dir.join(FLOW_DIR).join("state.json").is_file() { err(&format!("{FLOW_DIR}/ already exists in {}", dir.display()), &["Continue it with `next`, or start in a new folder.".into()]); }
let fdir = dir.join(FLOW_DIR);
if !unsorted.is_empty() {
// an unsorted set: kept as it came; `stitch` (run by `prep`) sorts it and builds the face images
if !top.is_empty() || !bottom.is_empty() { err("give either --photos (unsorted, the program sorts them) or --top/--bottom, not both", &[]); }
let files = intake::collect(unsorted).unwrap_or_else(|e| err(&e, &[]));
for d in ["photos", "evidence", "board", "detect"] { std::fs::create_dir_all(fdir.join(d)).ok(); }
let rec = intake::record(&fdir, &files).unwrap_or_else(|e| err(&e, &[]));
let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "photos": [], "intake": rec, "target": target, "started": ledger::now(), "rules": rules_v})).unwrap_or_else(|e| err(&e.to_string(), &[]));
p.log(&th, "init", json!({"unsorted": p.state["intake"]["unsorted"]}));
ok(&format!("reverse flow started in {} with {} unsorted photo(s)", p.flow_dir.display(), files.len()),
&["Next: `prep`. It first sorts the photos into faces, tiles and overviews and stitches one mosaic per face (`stitch` does that step alone).".into(),
"Start the flow window too: nohup adom-aiflow-reverse serve --port 8890 > reverse-flow/serve.log 2>&1 &".into()]);
return;
}
if top.is_empty() { err("init needs photos: `--photos <folder or photo...>` for an unsorted set, or at least one --top photo", &[]); }
for d in ["photos", "evidence", "board", "detect"] { std::fs::create_dir_all(fdir.join(d)).ok(); }
let mut photos = vec![];
for (side, list) in [("top", top), ("bottom", bottom)] {
for p in list {
let src = Path::new(p); if !src.is_file() { err(&format!("{p}: not a file"), &[]); }
let name = src.file_name().unwrap().to_string_lossy().to_string();
let dst = fdir.join("photos").join(&name);
std::fs::copy(src, &dst).unwrap_or_else(|e| err(&format!("{p}: {e}"), &[]));
photos.push(json!({"file": name, "side": side, "source": src.display().to_string(), "sha256": sha256(&dst)}));
}
}
let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "photos": photos, "target": target, "started": ledger::now(), "rules": rules_v})).unwrap_or_else(|e| err(&e.to_string(), &[]));
p.log(&th, "init", json!({"photos": p.state["photos"]}));
let mut h = vec![]; if bottom.is_empty() { h.push("No bottom photo: bottom copper, via rings and layer evidence will be unknown. Ask for one.".into()); }
h.extend(video::keep_stitch(&fdir, &top.iter().chain(bottom.iter()).map(PathBuf::from).collect::<Vec<_>>()));
h.push("Next: `prep`. Start the flow window too: nohup adom-aiflow-reverse serve --port 8890 > reverse-flow/serve.log 2>&1 &".into());
ok(&format!("reverse flow started in {} with {} photo(s)", p.flow_dir.display(), photos.len()), &h);
}
Cmd::Rules { set } => {
let mut p = project(&cli);
if let Some(s) = set {
let th = thread(&cli);
if p.state["kicad"]["pcb"].is_string() { err("rules are set before the first build: every check and route since used the current ones", &["Start a new run with `init --rules ...`, or say why in the review and rebuild every step.".into()]); }
p.state["rules"] = rules::load(s).unwrap_or_else(|e| err(&e, &[])); p.save().ok(); p.log(&th, "rules", p.state["rules"].clone());
}
let r = rules_of(&p.state);
ok(&format!("rules {}: clearance {} mm, track {} mm, signal via {}/{} mm, edge {} mm", r["name"].as_str().unwrap_or("custom"), r["clearance"], r["track"], r["viaSignal"][0], r["viaSignal"][1], r["edgeClearance"]), &[r["why"].as_str().unwrap_or("").to_string()]);
}
Cmd::Next => { let p = project(&cli); ok(&format!("step {}", flow::current(&fl, &p.flow_dir).map(|s| s.name.clone()).unwrap_or("none".into())), &step_hint(&fl, &p.flow_dir)); }
Cmd::Steps => {
let p = project(&cli); let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());
for s in &fl.steps {
let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x)); let stale = s.done.iter().any(|x| !flow::stale(&p.flow_dir, x).is_empty());
let why: Vec<String> = s.done.iter().flat_map(|x| flow::stale(&p.flow_dir, x)).collect();
let state = if done { "done " } else if stale { "stale" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { " " };
let now = if !stale { String::new() } else { format!(" ({} changed)", why.join(", ")) };
let _ = writeln!(std::io::stdout(), "{state} {}{now}", s.name);
}
// after the steps: the run video, which never holds the run up
if cur.is_none() { let w = video::state_word(&p.flow_dir); let _ = writeln!(std::io::stdout(), "{} video{}", match w { "done" => "done ", "failed" => "fail ", _ => "NOW " }, if w == "failed" { format!(" ({}; the run is complete without it)", video::record(&p.flow_dir)["error"].as_str().unwrap_or("not built")) } else { String::new() }); }
}
Cmd::Ledger => { let p = project(&cli); let _ = write!(std::io::stdout(), "{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default()); }
Cmd::Stitch { top } => {
let th = thread(&cli); let mut p = project(&cli);
if let Some(t) = top {
let name = Path::new(t).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
if !p.state["intake"]["unsorted"].as_array().into_iter().flatten().any(|r| r["file"].as_str() == Some(name.as_str())) { err(&format!("{name} is not one of this run's photos"), &["Name a photo given to `init --photos`: see reverse-flow/photos-raw/.".into()]); }
p.state["intake"]["topPhoto"] = json!(name);
}
let h = intake::run(&mut p, &th).unwrap_or_else(|e| err(&e, &["`stitch` works on a run started with `init --photos <folder or photo...>`.".into()]));
ok("photos sorted and stitched: stitch.json, photos/top.jpg and photos/bottom.jpg are the board images now", &h);
}
Cmd::Prep => {
let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();
// a run started with unsorted photos: sort and stitch them first, so that what follows measures the mosaics
let stitched: Vec<String> = if intake::pending(&p.state) { intake::run(&mut p, &th).unwrap_or_else(|e| err(&e, &[])) } else { vec![] };
let mut recs = vec![];
for ph in p.state["photos"].as_array().cloned().unwrap_or_default() {
let file = ph["file"].as_str().unwrap();
let r = board::prep_photo(&f.join("photos").join(file), ph["side"].as_str().unwrap_or("top"), &f.join("evidence")).unwrap_or_else(|e| err(&format!("{file}: {e}"), &["The board must be fully in frame on a contrasting background.".into()]));
recs.push(r);
}
let mut doc = json!({"photos": recs, "at": ledger::now()});
let (mut t, mut b) = (primary(&doc, "top").cloned(), primary(&doc, "bottom").cloned());
if let (Some(t), Some(b)) = (&t, &b) {
let wh = |v: &Value| (v["rectPx"][0].as_f64().unwrap(), v["rectPx"][1].as_f64().unwrap());
let (axis, m, o) = prep::flip_axis(t["holes"].as_array().unwrap(), wh(t), b["holes"].as_array().unwrap(), wh(b));
// through features matched between the faces decide it, and give both faces one outline; the outline fractions only when too few match, and then the mapping is weak
doc["primary"] = json!({"top": t["file"], "bottom": b["file"]});
let open = |v: &Value| image::open(f.join("photos").join(v["file"].as_str().unwrap())).unwrap_or_else(|e| err(&e.to_string(), &[])).to_rgb8();
let (ti, bi) = (open(t), open(b));
let (fl_rec, shared, tier) = facereg::unify(&doc, &ti, &bi);
doc["faceFit"] = tier;
doc["flip"] = fl_rec.unwrap_or_else(|| json!({"axis": axis, "matches": m, "other": o, "weak": "too few through features match between the faces, so this rests on the two outlines coinciding"}));
if let Some((tc, bc, from)) = shared {
for (rec, img, c) in [(t, &ti, tc), (b, &bi, bc)] {
let file = rec["file"].as_str().unwrap();
let again = board::prep_image(img, &f.join("photos").join(file), rec["side"].as_str().unwrap_or("top"), &f.join("evidence"), Some((c, from.clone(), rec["corners"].clone(), rec["maskBeyondOwnEdges"].clone()))).unwrap_or_else(|e| err(&format!("{file}: {e}"), &[]));
for ph in doc["photos"].as_array_mut().unwrap() { if ph["file"] == rec["file"] { *ph = again.clone(); } }
}
}
}
// the two primaries may have been measured again through the shared outline
let again = |doc: &Value, v: &Option<Value>| v.as_ref().and_then(|v| doc["photos"].as_array().unwrap().iter().find(|p| p["file"] == v["file"]).cloned());
(t, b) = (again(&doc, &t), again(&doc, &b));
doc["primary"] = json!({"top": t.as_ref().map(|v| v["file"].clone()), "bottom": b.as_ref().map(|v| v["file"].clone())});
let cands = t.as_ref().map(|t| board::scale_candidates(t["rows"].as_array().unwrap())).unwrap_or_default();
doc["scaleCandidates"] = json!(cands);
wr(&f.join("prep.json"), &doc);
p.log(&th, "prep", json!({"flip": doc["flip"], "photos": doc["photos"].as_array().map(|a| a.len())}));
reach(&mut p, &fl, "intake");
let mut h: Vec<String> = doc["photos"].as_array().unwrap().iter().map(|r| format!("{} ({}): sharpness {:.0}, {} holes, {} rows, {} bodies", r["file"].as_str().unwrap(), r["side"].as_str().unwrap(), fv(r, "sharpness"), r["holes"].as_array().unwrap().len(), r["rows"].as_array().unwrap().len(), r["bodies"].as_array().unwrap().len())).collect();
if doc["flip"].is_object() { h.push(format!("bottom maps onto the top turned over {} ({} holes agree, {} the other way{})", if doc["flip"]["axis"] == "y" { "top-to-bottom" } else { "left-to-right" }, doc["flip"]["matches"], doc["flip"]["other"],
match (doc["flip"]["by"].as_str(), doc["flip"]["weak"].as_str()) { (Some(b), _) => format!("; {b}, residual {} px", doc["flip"]["residualPx"]), (_, Some(w)) => format!("; WEAK: {w}"), _ => String::new() })); }
for r in doc["photos"].as_array().unwrap() { let cut: Vec<&str> = r["notBoardEdge"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).collect();
if !cut.is_empty() { h.push(format!("{}: the board runs out of the photo at the {}: the outline there is the photo's edge, NOT the board's edge, and the board's size across it is not known", r["file"].as_str().unwrap_or("?"), cut.join(" and "))); } }
for c in cands.iter().take(3) { h.push(format!("scale candidate {} px/mm explains {} holes in {} rows", c["pxPerMm"], c["score"], c["rows"].as_array().unwrap().len())); }
h.push("Run `check prep`, then `scale --body <id>=<L>x<W> --source \"...\"` with a body from evidence/prep-<top photo>.jpg whose size a datasheet gives.".into());
if cands.is_empty() { h.push("No rows of holes here. With four mounting holes at a spacing a document gives: `scale --holes <x>x<y> --board <x>x<y> --source \"...\"`.".into()); }
h.extend(views::record(&f, &p.state, "intake", "photos measured", &th, None));
let h: Vec<String> = stitched.into_iter().chain(h).collect();
ok("every photo measured: prep.json", &h);
}
Cmd::Reframe { flip } => {
let th = thread(&cli); let p = project(&cli);
match reframe::run(&p.dir, &p.flow_dir, &th, flip.as_deref()) {
Ok(h) => { p.log(&th, "reframe", json!({"summary": h})); ok("the run is on the new frame: prep.json, scale.json and parts.json carried over", &h); }
Err(e) => err(&format!("reframe: {e}"), &["Nothing is lost: the old files are kept as prep.before-reframe.json, scale.before-reframe.json and parts.before-reframe.json. Copy them back to undo.".into()]),
}
}
Cmd::Scale { body, source, holes, board: board_arg, flip, ruler, crop, carried } => {
let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();
let mut prep_doc = rd(&f.join("prep.json"));
if let Some(c) = crop { scale_crop(&f, &prep_doc, &c); return; }
if let Some(hs) = holes {
scale_by_holes(&mut p, &fl, &th, &mut prep_doc, hs, board_arg.as_deref().unwrap_or_else(|| err("--holes needs --board <x>x<y>: the board's size from the same source", &[])), source.as_deref().unwrap_or(""), flip.as_deref());
return;
}
let top = primary(&prep_doc, "top").cloned().unwrap_or_else(|| err("no top photo measured", &[]));
let cands: Vec<Value> = prep_doc["scaleCandidates"].as_array().cloned().unwrap_or_default();
let (k, method, basis): (f64, String, Value) = if let Some(kc) = carried {
// `reframe`: the scale an earlier frame of this run was given, converted through the photo; what it rests on is in scale.carry.json
if *kc <= 0.0 { err("--carried <px/mm>: a positive number, written by `reframe`", &[]); }
let mut b = rd(&f.join("scale.carry.json")); if !b.is_object() { b = json!({}); } b["pxPerMm"] = json!(kc);
(*kc, "carried over by `reframe` from the scale this run was given before".to_string(), b)
} else if !ruler.is_empty() {
// Rulers: known distances the AI points out on the photo. They do not depend on the board's hole rows being on a
// standard pitch (the MOSFET board of 2026-11-10 has one row, at 3.0 mm, and both pitch candidates near its chip
// body were 16% wrong). A hole-row candidate within 3% of the rulers refines them; otherwise the rulers stand.
if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet (and figure) that gives each ruler's length", &[]); }
let mut ks: Vec<(f64, f64, f64)> = vec![]; let mut ends: Vec<Vec<f64>> = vec![];
for r in ruler.iter() {
let (pts, mm) = r.split_once('=').unwrap_or_else(|| err("--ruler <x1>,<y1>,<x2>,<y2>=<mm>", &[]));
let v: Vec<f64> = pts.split(',').filter_map(|t| t.trim().parse().ok()).collect(); let mm: f64 = mm.trim().parse().unwrap_or(0.0);
if v.len() != 4 || mm <= 0.0 { err("--ruler <x1>,<y1>,<x2>,<y2>=<mm>: four pixel coordinates and a length in mm", &[]); }
let d = ((v[2] - v[0]).powi(2) + (v[3] - v[1]).powi(2)).sqrt();
if d < 80.0 { err(&format!("the ruler {r} is only {d:.0} px long: use a longer one (a whole row of leads, a connector, a header), since a 2 px reading error is {:.1}% of it", 200.0 / d.max(1.0)), &[]); }
ks.push((d / mm, d, mm)); ends.push(v.clone());
}
let mut sorted: Vec<f64> = ks.iter().map(|x| x.0).collect(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let k_r = if sorted.len() % 2 == 1 { sorted[sorted.len() / 2] } else { (sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0 };
let spread = (sorted[sorted.len() - 1] - sorted[0]) / k_r;
if spread > 0.04 { err(&format!("the rulers disagree by {:.1}% ({}): read the points again on `scale --crop`, or check each length against its datasheet", spread * 100.0, ks.iter().map(|x| format!("{:.2} px/mm", x.0)).collect::<Vec<_>>().join(", ")), &[]); }
let refined = cands.iter().map(|c| fv(c, "pxPerMm")).filter(|c| ((c / k_r).ln()).abs() < 0.03).min_by(|a, b| ((a / k_r).ln().abs()).partial_cmp(&(b / k_r).ln().abs()).unwrap());
let k = refined.unwrap_or((k_r * 1000.0).round() / 1000.0);
let method = if refined.is_some() { "rulers pointed out on the photo, refined by the hole-row pitch within 3% of them" } else { "rulers pointed out on the photo (no hole-row pitch solution lies within 3% of them, so the rows are not on a standard pitch)" };
(k, method.to_string(), json!({"rulers": ks.iter().zip(&ends).map(|(x, e)| json!({"px": x.1, "mm": x.2, "pxPerMm": x.0, "ends": e})).collect::<Vec<_>>(), "pxPerMm": k_r, "spread": spread, "source": source}))
} else {
let Some(b) = body else {
let mut h: Vec<String> = cands.iter().take(4).map(|c| format!("candidate {} px/mm: rows {}", c["pxPerMm"], c["rows"].as_array().unwrap().iter().map(|r| format!("{} mm", r["mm"])).collect::<Vec<_>>().join(", "))).collect();
h.push("scale --body <id>=<L>x<W> --source \"<datasheet>\" (ids on evidence/prep-<top photo>.jpg)".into());
h.push("or, with no rows of holes: scale --holes <x>x<y> --board <x>x<y> --source \"<document>\", four mounting holes at a stated spacing".into());
err("scale needs a body whose size a datasheet gives, to confirm the pitch solution", &h)
};
if source.as_deref().unwrap_or("").is_empty() { err("--source: name the datasheet and package drawing that give the body size", &[]); }
let (id, dims) = b.split_once('=').unwrap_or_else(|| err("--body <id>=<L>x<W>", &[]));
let (l, w) = dims.split_once('x').map(|(a, c)| (a.parse::<f64>().unwrap_or(0.0), c.parse::<f64>().unwrap_or(0.0))).unwrap_or((0.0, 0.0));
let bd = top["bodies"].as_array().and_then(|a| a.get(id.parse::<usize>().unwrap_or(usize::MAX))).cloned().unwrap_or_else(|| err(&format!("no body {id} on the top photo"), &[]));
let (bl, bw) = (fv(&bd["body"], "long"), fv(&bd["body"], "short"));
let (lm, wm) = if l >= w { (l, w) } else { (w, l) };
let k_body = (bl / lm + bw / wm) / 2.0;
// the pitches give the precise scale; the body only decides which standard the rows are (a body
// measures to about +-15%, the candidates differ by factors like 2 or 1.27): exactly one candidate
// may lie within 25% of it
// The candidate nearest the body's scale wins when it is within 25% and clearly nearer than the next one
// (0.06 in log ratio, about 6%): neighbouring standards sit 20 to 27% apart (2.0 against 2.5 or 1.5 mm), so a
// fixed 25% window could never single out a 2.0 mm board (USB hub, 2026-11-10).
let mut near: Vec<(f64, &Value)> = cands.iter().map(|c| ((fv(c, "pxPerMm") / k_body).ln().abs(), c)).collect();
near.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let k = match near.first() {
Some((d1, c)) if *d1 < 1.25f64.ln() => {
if let Some((d2, c2)) = near.get(1) { if d2 - d1 < 0.06 { err(&format!("the body ({k_body:.2} px/mm) cannot decide between candidates {} and {} px/mm", c["pxPerMm"], c2["pxPerMm"]), &["Measure a second body, or a larger one: the longer the body, the finer it decides.".into()]); } }
fv(c, "pxPerMm")
}
_ if cands.is_empty() => {
// no rows of holes on this photo: the body alone sets the scale, to about the precision its edges were found
eprintln!("note: no hole rows were measured, so the scale rests on this body alone ({k_body:.2} px/mm, about +-5%); a second body or `scale --holes` would tighten it");
k_body
}
_ => { let mut h: Vec<String> = cands.iter().take(12).map(|c| format!("candidate {} px/mm", c["pxPerMm"])).collect(); h.push("Check the body id and its datasheet size, or measure another body.".into());
err(&format!("the body gives {k_body:.2} px/mm ({bl:.0} x {bw:.0} px for {lm} x {wm} mm) but no hole-row solution is within 25% of it"), &h) }
};
(k, "hole-row pitches, confirmed by a datasheet body".to_string(), json!({"id": id, "mm": [lm, wm], "px": [bl, bw], "pxPerMm": k_body, "source": source}))
};
let (wmm, hmm) = (top["rectPx"][0].as_f64().unwrap() / k, top["rectPx"][1].as_f64().unwrap() / k);
// `--flip x|y` states which way the board was turned over when the holes cannot tell (it was honoured only with
// --holes until the MOSFET board of 2026-11-10); the statement is recorded in prep.json like a measured one
let mut axis = match flip.as_deref() {
Some(a) if a == "x" || a == "y" => { prep_doc["flip"] = json!({"axis": a, "how": "stated with --flip", "matches": prep_doc["flip"]["matches"], "other": prep_doc["flip"]["other"]}); wr(&f.join("prep.json"), &prep_doc); a.to_string() }
Some(a) => err(&format!("--flip {a}: expected x or y"), &[]),
None => prep_doc["flip"]["axis"].as_str().unwrap_or("y").to_string(),
};
let corners = |v: &Value| -> [geom::P; 4] { let c = v["corners"].as_array().unwrap(); [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap(), c[i][1].as_f64().unwrap())) };
let tdst = board::frame_corners(wmm, hmm, "top", &axis);
let top_img = board::to_frame(&f.join("photos").join(top["file"].as_str().unwrap()), &corners(&top), &tdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));
save(&top_img, &f.join("board/top.jpg"));
let top_holes = holes_mm(&top_img);
let mut align = json!(null);
// Not stated, and the holes cannot say (`flipdecide`): read from what shows through on both faces.
if let Some(bot) = primary(&prep_doc, "bottom").cloned().filter(|_| flip.is_none()) {
let bpath = f.join("photos").join(bot["file"].as_str().unwrap());
if let Some((a, how)) = flipdecide::decide(&prep_doc, None, &top_img, &|ax: &str| board::to_frame(&bpath, &corners(&bot), &board::frame_corners(wmm, hmm, "bottom", ax), wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]))) {
let (m, o) = (prep_doc["flip"]["matches"].as_u64().unwrap_or(0), prep_doc["flip"]["other"].as_u64().unwrap_or(0));
eprintln!("note: bottom turned over {} ({how})", if a == "y" { "top-to-bottom" } else { "left-to-right" });
axis = a; prep_doc["flip"] = json!({"axis": axis, "matches": m, "other": o, "how": how}); wr(&f.join("prep.json"), &prep_doc);
}
}
// the bottom photo's corners become where the top's outline lies in it, by the through features both faces show
let faces = facereg::lay(&mut prep_doc, &axis, k);
if faces["why"] != "one face only" { wr(&f.join("prep.json"), &prep_doc); }
if let Some(bot) = primary(&prep_doc, "bottom") {
let bdst = board::frame_corners(wmm, hmm, "bottom", &axis);
let bpath = f.join("photos").join(bot["file"].as_str().unwrap());
let first = board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, (0.0, 0.0)).unwrap_or_else(|e| err(&e, &[]));
let px = |v: &[Value]| v.iter().map(|h| { let q = board::to_px(fv(h, "x"), fv(h, "y")); json!({"x": q.0, "y": q.1}) }).collect::<Vec<_>>();
let (shift, pairs, res) = board::align(&px(&top_holes), &px(&holes_mm(&first)));
let shift = if faces["registered"] == true { (0.0, 0.0) } else { shift };
let bimg = if pairs >= 3 && shift != (0.0, 0.0) { board::to_frame(&bpath, &corners(bot), &bdst, wmm, hmm, shift).unwrap_or_else(|e| err(&e, &[])) } else { first };
align = json!({"shift_mm": [(shift.0 / board::RES * 1000.0).round() / 1000.0, (shift.1 / board::RES * 1000.0).round() / 1000.0], "pairs": pairs, "residual_mm": (res / board::RES * 1000.0).round() / 1000.0, "faces": faces});
// the shift cannot take out the warp a corner error leaves: pin the bottom to the top through the holes both show
let sdst: [geom::P; 4] = bdst.map(|p| (p.0 + shift.0, p.1 + shift.1));
let hp = |v: &[Value]| v.iter().map(|h| board::to_px(fv(h, "x"), fv(h, "y"))).collect::<Vec<_>>();
let prs = regframe::pairs(&hp(&top_holes), &hp(&holes_mm(&bimg)), 1.0 * board::RES);
let by_holes = regframe::Quad::fit(&prs);
let bimg = match &by_holes {
Some(q) => { align["registration"] = regframe::report(&prs, q, board::RES); align["registrationQuad"] = reframe::quad_json(q); regframe::warp(&bpath, &corners(bot), &sdst, Some(q), bimg.width(), bimg.height(), 0).unwrap_or_else(|e| err(&e, &[])) }
None => bimg,
};
// a few pin holes leave the faces off between them (0.35 mm on the USB hub): vias that show on both faces as holes hold them everywhere
let bimg = match viasig::relay(&top_img, &bimg, by_holes.as_ref()) {
Some((q, r)) => { align["registration"] = r; align["registrationQuad"] = reframe::quad_json(&q); regframe::warp(&bpath, &corners(bot), &sdst, Some(&q), bimg.width(), bimg.height(), 0).unwrap_or_else(|e| err(&e, &[])) }
None => bimg,
};
save(&bimg, &f.join("board/bottom.jpg"));
save(&copper::grid_image(&bimg, 5.0), &f.join("board/bottom-grid.jpg"));
wr(&f.join("detect/holes-bottom.json"), &json!({"holes": holes_mm(&bimg)}));
}
save(&copper::grid_image(&top_img, 5.0), &f.join("board/top-grid.jpg"));
let rows: Vec<Value> = prep::hole_rows(&top_holes.iter().map(|h| json!({"x": fv(h, "x"), "y": fv(h, "y"), "outer": fv(h, "outer_mm")})).collect::<Vec<_>>())
.into_iter().map(|mut r| { r["pitch_mm"] = json!((fv(&r, "pitch") * 1000.0).round() / 1000.0); r }).collect();
wr(&f.join("detect/holes-top.json"), &json!({"holes": top_holes, "rows": rows}));
let radius = corner_radius(&top_img);
let doc = json!({"pxPerMm": k, "method": method, "body": basis, "candidates": cands,
"board_mm": [(wmm * 100.0).round() / 100.0, (hmm * 100.0).round() / 100.0], "corner_mm": radius, "frame": {"origin": [board::ORIGIN, board::ORIGIN], "pxPerMm": board::RES, "bottomFlip": axis}, "alignment": align, "at": ledger::now()});
wr(&f.join("scale.json"), &doc);
p.log(&th, "scale", doc.clone());
reach(&mut p, &fl, "scale");
let mut h = vec![format!("{k} px/mm ({method}; basis {:.2} px/mm); board {:.2} x {:.2} mm, corner radius about {radius} mm", fv(&basis, "pxPerMm"), wmm, hmm)];
if align.is_object() { h.push(facereg::say(&align["faces"])); h.push(format!("bottom aligned to top on {} shared holes, residual {} mm", align["pairs"], align["residual_mm"])); }
if align["registration"]["through"].is_string() { h.push(format!("bottom laid on top through {} {}: {}", align["registration"]["pairs"], align["registration"]["through"].as_str().unwrap_or(""), align["registration"])); }
h.extend(views::record(&f, &p.state, "scale", "board frame written", &th, None));
h.extend(step_hint(&fl, &f));
ok("scale.json and the board frame written (board/top.jpg, board/bottom.jpg, gridded copies)", &h);
}
Cmd::Detect { what, bottom } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
if what != "parts" { err("detect parts", &[]); }
let top = img(&f.join("board/top.jpg"));
let bot = if f.join("board/bottom.jpg").is_file() { Some(img(&f.join("board/bottom.jpg"))) } else { None };
let ht = rd(&f.join("detect/holes-top.json"));
let (holes, rows) = (ht["holes"].as_array().unwrap(), ht["rows"].as_array().unwrap());
let holes_bot: Vec<Value> = items::read_json(&f.join("detect/holes-bottom.json")).ok().and_then(|d| d["holes"].as_array().cloned()).unwrap_or_else(|| holes.clone());
let (mut cands, mut left_out, mut replaced, mut body_first) = (vec![], vec![], 0, 0);
let mut levels = serde_json::Map::new();
for (side, face) in [("top", Some(&top)), ("bottom", bot.as_ref().filter(|_| *bottom))] {
let Some(face) = face else { continue };
let bodies = prep::bodies(face, (0.1 * board::RES) as f32, if side == "top" { 0.4 } else { 0.6 } * board::RES);
// chip passives are read body first; what the bodies and holes of this face already show to be a bigger part keeps them out of it
let zones = passives::zones(&bodies, if side == "top" { holes } else { &holes_bot }, rows, holes);
let (chips, taken, lv) = passives::find_with(face, &zones);
levels.insert(side.to_string(), lv.json());
// bottom parts: only bodies that match a package (the bottom face is mostly mask)
let old: Vec<Value> = parts::candidates(&bodies, side, 0).into_iter().filter(|c| side == "top" || c["fits"] == json!(true)).collect();
// where both describe one spot, the body-first passive wins
let n = old.len(); let keep: Vec<Value> = old.into_iter().filter(|c| !passives::same_spot(c, side, &chips)).collect();
replaced += n - keep.len(); body_first += chips.len(); cands.extend(keep);
cands.extend(passives::candidates(&chips, side, 0));
left_out.extend(taken.iter().map(|(p, what)| { let q = board::to_mm(p.centre().0, p.centre().1); json!({"side": side, "pos_mm": [(q.0 * 1000.0).round() / 1000.0, (q.1 * 1000.0).round() / 1000.0], "package": passives::PK[p.pk].0, "kind": p.kind, "inside": what}) }));
}
let n = cands.len();
cands.extend(parts::hole_parts(holes, rows, n));
// numbered in the order written: the two detectors and the faces each counted from their own start
for (k, c) in cands.iter_mut().enumerate() { c["id"] = json!(format!("P{:03}", k + 1)); }
// how sure each one is: print, leads and fragments rate low instead of flooding the list
let board_mm = items::read_json(&f.join("scale.json")).ok().and_then(|s| Some((s["board_mm"][0].as_f64()?, s["board_mm"][1].as_f64()?)));
candconf::rate(&mut cands, (holes, &holes_bot), board_mm);
std::fs::create_dir_all(f.join("evidence/parts")).ok();
// one map per face searched: a bottom candidate is marked on the bottom photo, where it can be seen
let mut crops = vec![]; let mut map = top.clone(); let mut map_bot = bot.clone().filter(|_| *bottom);
for c in &cands {
let map = match map_bot.as_mut() { Some(m) if c["side"] == json!("bottom") => m, _ => &mut map };
let pos = (c["pos_mm"][0].as_f64().unwrap(), c["pos_mm"][1].as_f64().unwrap());
let size = (c["size_mm"][0].as_f64().unwrap_or(3.0), c["size_mm"][1].as_f64().unwrap_or(3.0));
let src = if c["side"] == json!("bottom") { bot.as_ref().unwrap_or(&top) } else { &top };
let cr = parts::crop(src, pos, size, 3);
let id = c["id"].as_str().unwrap().to_string();
save(&cr, &f.join(format!("evidence/parts/{id}.jpg")));
crops.push((id.clone(), cr));
let q = board::to_px(pos.0, pos.1);
if c["method"] == json!("body") {
// a body-first passive is drawn as its package outline: yellow for a tan body, magenta for a dark one
draw::poly(map, &passives::corners(q, (size.0 * board::RES, size.1 * board::RES), -fv(c, "rot")), if c["kind"] == json!("tan") { [255, 255, 0] } else { [255, 0, 255] }, 1);
} else if c["confidence"].as_f64().unwrap_or(1.0) < candconf::SURE { draw::circle(map, q, 4.0, [150, 150, 150], 1); }
else { draw::circle(map, q, 6.0, if c["fits"] == json!(true) { [0, 255, 255] } else { [255, 80, 80] }, 2); }
let unsure = c["confidence"].as_f64().unwrap_or(1.0) < candconf::SURE;
draw::label(map, (q.0 + 6.0, q.1 - 14.0), id.trim_start_matches('P'), if unsure { [150, 150, 150] } else { [255, 255, 0] }, if c["method"] == json!("body") || unsure { 2 } else { 3 });
}
save(&map, &f.join("evidence/parts-map.jpg"));
if let Some(m) = &map_bot { save(m, &f.join("evidence/parts-map-bottom.jpg")); }
for (k, chunk) in crops.chunks(48).enumerate() { save(&parts::contact_sheet(chunk, 220, 8), &f.join(format!("evidence/parts-sheet-{}.jpg", k + 1))); }
let fits = cands.iter().filter(|c| c["fits"] == json!(true)).count();
wr(&f.join("detect/parts.json"), &json!({"candidates": cands, "passives": {"method": "body", "found": body_first, "replaced": replaced, "leftOut": left_out, "levels": levels}, "at": ledger::now()}));
p.log(&th, "detect", json!({"what": "parts", "candidates": cands.len(), "fits": fits, "bodyFirst": body_first}));
let sure = cands.iter().filter(|c| c["confidence"].as_f64().unwrap_or(0.0) >= candconf::SURE).count();
let mut h = vec!["Next: `parts init`, then read every crop and complete parts.json.".to_string()];
h.push(format!("{sure} of {} candidates are rated {} or more (`confidence` in detect/parts.json; `doubt` says what counts against one). The rest are drawn as small grey rings on the map: print, leads, pads and fragments mostly, but real parts hide among them, so read them most sure first.", cands.len(), candconf::SURE));
for (side, lv) in &levels { h.push(format!("{side} face colour levels: {}", if lv["bodies"].as_u64().unwrap_or(0) > 0 { format!("measured on {} of this photo's own chip bodies", lv["bodies"]) } else { "too few clear chip bodies on this photo to measure its own, so the CM5's numbers scaled by this photo's brightness were used: expect misses".to_string() })); }
h.push(format!("{body_first} chip passives read body first (\"method\": \"body\"): they took the place of {replaced} older candidates on the same spots; {} more were left out as lying inside a bigger part or on a hole (detect/parts.json, passives.leftOut).", left_out.len()));
if !*bottom && bot.is_some() { h.push("The bottom face was not searched for parts: if board/bottom.jpg shows components, run `detect parts --bottom`.".into()); }
ok(&format!("{} candidates ({fits} match a package by size); crops in evidence/parts/, contact sheets evidence/parts-sheet-*.jpg, map evidence/parts-map.jpg{}", cands.len(), if map_bot.is_some() { " and evidence/parts-map-bottom.jpg" } else { "" }), &h);
}
Cmd::Parts { what, force } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
if what == "snap" {
let mut doc = rd(&f.join("parts.json"));
let (face_top, face_bot) = (img(&f.join("board/top.jpg")), if f.join("board/bottom.jpg").is_file() { Some(img(&f.join("board/bottom.jpg"))) } else { None });
let metal_top = snap::metal_plane(&face_top);
let metal_bot = face_bot.as_ref().map(snap::metal_plane);
// each face's chip bodies as the photo shows them, read once: a chip part's size is checked against its own body
let bodies_top = passives::find_with(&face_top, &[]).0;
let bodies_bot = face_bot.as_ref().map(|i| passives::find_with(i, &[]).0).unwrap_or_default();
let mut resized = 0;
let (mut lines, mut weak) = (vec![], vec![]);
let cands_all: Vec<Value> = items::read_json(&f.join("detect/parts.json")).ok().and_then(|d| d["candidates"].as_array().cloned()).unwrap_or_default();
for pt in doc["parts"].as_array_mut().into_iter().flatten() {
let id = pt["footprint"].as_str().unwrap_or("").to_string(); if id.is_empty() || pt["snap"]["lock"] == json!(true) { continue; }
let metal = if pt["side"] == json!("bottom") { match &metal_bot { Some(m) => m, None => continue } } else { &metal_top };
let (x, y, r) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0), fv(pt, "rot"));
// a chip part's size is settled by its own body on the photo (`snap::settle_size`); how well each
// neighbouring size's pads fit the metal is recorded, can hold a change back, and makes one only
// where no body reads and the fit is far better
let mut id = id; let mut sized = None; let mut size_fits: Option<Value> = None; let mut size_why: Option<String> = None;
if let Some((stem, k)) = snap::chip_size(&id) {
let fit = |fid: &str| -> Option<f64> { let t = kicad_out::footprint_text(&kicad_out::cache_dir(&f), fid, pt).ok()?; let pd = snap::pads(&t); if pd.is_empty() { return None; }
let c = if pt["anchor"] == json!("center") { Some(snap::pad_centre(&pd)) } else { None }; Some(snap::best_pose_at(metal, &pd, x, y, &[r], snap::reach(&pd), c).3) };
if let Some(own) = fit(&id) {
let mut fits = serde_json::Map::new(); fits.insert(snap::CHIP_SIZES[k][..4].to_string(), json!((own * 1000.0).round() / 1000.0));
let mut tried = vec![];
for j in [k.wrapping_sub(1), k + 1] { if j < snap::CHIP_SIZES.len() { if let Some(sc) = fit(&format!("{stem}{}", snap::CHIP_SIZES[j])) { fits.insert(snap::CHIP_SIZES[j][..4].to_string(), json!((sc * 1000.0).round() / 1000.0)); tried.push((j, sc)); } } }
let body = snap::body_read(if pt["side"] == json!("bottom") { &bodies_bot } else { &bodies_top }, x, y);
let (to, why) = snap::settle_size(k, own, &tried, body);
let best = (tried.iter().find(|t| t.0 == to).map(|t| t.1).unwrap_or(own), to); size_why = Some(why);
size_fits = Some(Value::Object(fits));
if best.1 != k { sized = Some((id.clone(), own, best.0)); id = format!("{stem}{}", snap::CHIP_SIZES[best.1]); }
}
}
if let Some(sf) = size_fits { pt["sizeFits"] = sf; }
if let Some(w) = size_why { pt["sizeCheck"] = json!(w); }
if sized.is_some() { resized += 1; }
if let Some((was, a, b)) = &sized { pt["footprint"] = json!(id); pt["package"] = json!(&snap::CHIP_SIZES[snap::chip_size(&id).unwrap().1][..4]); pt["sizedByPads"] = json!({"was": was, "fit": (a * 1000.0).round() / 1000.0, "now": (b * 1000.0).round() / 1000.0}); }
let Ok(text) = kicad_out::footprint_text(&kicad_out::cache_dir(&f), &id, pt) else { continue };
let pads = snap::pads(&text); if pads.is_empty() { continue; }
// never try a turn that maps the pads onto themselves: it is the same part, pin 1 stays the AI's
let sym = snap::symmetric_turns(&pads);
let turns: Vec<f64> = [0.0, 90.0, 180.0, 270.0].iter().filter(|d| **d == 0.0 || !sym.iter().any(|s| (s - **d).abs() < 1e-6)).map(|d| (r + d) % 360.0).collect();
let pc = snap::pad_centre(&pads);
let centre = if pt["anchor"] == json!("center") { Some(pc) } else { None };
// the AI's rotation unless another turn is clearly better (0.1 higher and at least 0.3)
let reach = snap::reach(&pads);
let own = snap::best_pose_at(metal, &pads, x, y, &turns[..1], reach, centre);
let any = snap::best_pose_at(metal, &pads, x, y, &turns, reach, centre);
let (mut nx, mut ny, nr, sc) = if any.3 > own.3 + 0.1 && any.3 >= 0.3 { any } else { own };
let mut method = "pads";
if sc < 0.3 {
// pads hidden under the body (QFN, power packages) or a poor match: use the measured body
// centre of the nearest detected body, else keep the reading
let read_mid = if centre.is_some() { (x, y) } else { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };
let body = cands_all.iter().filter(|c| c["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max) > 1.5).unwrap_or(false))
.map(|c| (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0)))
.filter(|q| geom::dist(*q, read_mid) < 1.2).min_by(|a, b| geom::dist(*a, read_mid).partial_cmp(&geom::dist(*b, read_mid)).unwrap());
// a part placed by its pin 1 (from a measured hole) keeps that reading: its pads are not
// centred in its body, so a body centre would move it
let body = if centre.is_none() { None } else { body };
let m = body.unwrap_or(read_mid);
method = if body.is_some() { "body centre" } else { "reading" };
let (c, s) = (r.to_radians().cos(), r.to_radians().sin());
nx = m.0 - (pc.0 * c + pc.1 * s); ny = m.1 - (-pc.0 * s + pc.1 * c);
}
// a best pose at the edge of the search found nothing better inside it: keep the reading
let edge = { let (c, s) = (r.to_radians().cos(), r.to_radians().sin()); let rm = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };
let (c2, s2) = (nr.to_radians().cos(), nr.to_radians().sin()); geom::dist(rm, (nx + pc.0 * c2 + pc.1 * s2, ny - pc.0 * s2 + pc.1 * c2)) >= reach - 0.05 };
if sc >= 0.3 && edge {
let (c, s) = (r.to_radians().cos(), r.to_radians().sin());
let m = if centre.is_some() { (x, y) } else { (x + pc.0 * c + pc.1 * s, y - pc.0 * s + pc.1 * c) };
nx = m.0 - (pc.0 * c + pc.1 * s); ny = m.1 - (-pc.0 * s + pc.1 * c); method = "reading (pad match hit the search edge)";
}
let nr = if sc < 0.3 || edge { r } else { nr };
pt["anchor"] = json!("origin");
// compare where the part's middle was read with where it is now
let mid = |ox: f64, oy: f64, rr: f64| { let (c, s) = (rr.to_radians().cos(), rr.to_radians().sin()); (ox + pc.0 * c + pc.1 * s, oy - pc.0 * s + pc.1 * c) };
let before = if centre.is_some() { (x, y) } else { mid(x, y, r) };
let moved = geom::dist(before, mid(nx, ny, nr));
let r_ = pt["ref"].as_str().unwrap_or("?").to_string();
pt["snap"] = json!({"score": (sc * 1000.0).round() / 1000.0, "moved_mm": (moved * 1000.0).round() / 1000.0, "from": [x, y, r], "method": method});
pt["pos_mm"] = json!([(nx * 1000.0).round() / 1000.0, (ny * 1000.0).round() / 1000.0]);
if (nr - r).rem_euclid(360.0) > 0.5 { lines.push(format!("{r_}: turned {r} -> {nr} by its pads (check pin 1 against the mark)")); pt["rot"] = json!(nr); }
if sc < 0.3 { weak.push(format!("{r_} ({id}): pad match {sc:.2} (pads hidden or wrong footprint): placed by {method}")); }
}
wr(&f.join("parts.json"), &doc);
p.log(&th, "parts-snap", json!({"turned": lines.len(), "weak": weak.len(), "resized": resized}));
let mut h = lines; h.extend(weak);
h.push(format!("{resized} chip part(s) changed size: to what their own body measures on the photo or, where no body reads, to pads that fit far better (sizedByPads). Every chip part's sizeCheck says what its body and its pads read."));
return ok("parts snapped to their pads in the photo (positions now measured; see each part's snap score)", &h);
}
if what == "reconcile" {
let mut doc = rd(&f.join("parts.json"));
let det = rd(&f.join("detect/parts.json"));
let used: std::collections::HashSet<String> = doc["parts"].as_array().into_iter().flatten().filter_map(|p| p["candidate"].as_str().map(str::to_string)).collect();
let mut dismissed: Vec<Value> = doc["dismissed"].as_array().cloned().unwrap_or_default();
let already: std::collections::HashSet<String> = dismissed.iter().filter_map(|d| d["candidate"].as_str().map(str::to_string)).collect();
// each part's extent from its footprint pads (rotated), plus 0.4 mm
let mut extents: Vec<(String, f64, f64, f64, f64, f64)> = vec![];
for pt in doc["parts"].as_array().into_iter().flatten() {
let id = pt["footprint"].as_str().unwrap_or("");
let pads = kicad_out::footprint_text(&kicad_out::cache_dir(&f), id, pt).map(|t| snap::pads(&t)).unwrap_or_default();
let (x0, y0, x1, y1) = pads.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, q| (a.0.min(q.x - q.w / 2.0), a.1.min(q.y - q.h / 2.0), a.2.max(q.x + q.w / 2.0), a.3.max(q.y + q.h / 2.0)));
if x0 > x1 { continue; }
let body = pt["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max)).unwrap_or(0.0);
let hx = ((x1 - x0) / 2.0).max(body / 2.0) + 0.4; let hy = ((y1 - y0) / 2.0).max(body / 2.0) + 0.4;
// the extent is centred on the pads, not on the footprint origin (pin 1 for a header)
let (pcx, pcy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); let rr = fv(pt, "rot"); let (co, si) = (rr.to_radians().cos(), rr.to_radians().sin());
let (ox, oy) = (pt["pos_mm"][0].as_f64().unwrap_or(0.0), pt["pos_mm"][1].as_f64().unwrap_or(0.0));
extents.push((pt["ref"].as_str().unwrap_or("?").to_string(), ox + pcx * co + pcy * si, oy - pcx * si + pcy * co, rr, hx, hy));
}
let mut rest = vec![]; let mut auto = 0;
let board_box = items::read_json(&f.join("scale.json")).ok().and_then(|s| Some((board::ORIGIN, board::ORIGIN, board::ORIGIN + s["board_mm"][0].as_f64()?, board::ORIGIN + s["board_mm"][1].as_f64()?)));
for c in det["candidates"].as_array().into_iter().flatten() {
let id = c["id"].as_str().unwrap_or("").to_string();
if used.contains(&id) || already.contains(&id) { continue; }
let (cx, cy) = (c["pos_mm"][0].as_f64().unwrap_or(0.0), c["pos_mm"][1].as_f64().unwrap_or(0.0));
let host = extents.iter().find(|(_, x, y, r, hx, hy)| { let (co, si) = (r.to_radians().cos(), r.to_radians().sin()); let (dx, dy) = (cx - x, cy - y); let (lx, ly) = (dx * co - dy * si, dx * si + dy * co); lx.abs() <= *hx && ly.abs() <= *hy });
let size = c["size_mm"].as_array().map(|a| a.iter().filter_map(|v| v.as_f64()).fold(0.0, f64::max)).unwrap_or(0.0);
if let Some(h) = host { dismissed.push(json!({"candidate": id, "why": format!("part of {}", h.0)})); auto += 1; }
else if size < 0.45 { dismissed.push(json!({"candidate": id, "why": "under 0.45 mm: smaller than any package here (texture, via, silkscreen dot)"})); auto += 1; }
else if board_box.map(|(x0, y0, x1, y1)| cx - x0 < 0.8 || x1 - cx < 0.8 || cy - y0 < 0.8 || y1 - cy < 0.8).unwrap_or(false) { dismissed.push(json!({"candidate": id, "why": "within 0.8 mm of the board edge: edge shadow or background"})); auto += 1; }
else if c["method"] != json!("body") && c["size_mm"][1].as_f64().unwrap_or(1.0) < 0.35 { dismissed.push(json!({"candidate": id, "why": "under 0.35 mm wide: a silkscreen stroke or trace edge, not a package"})); auto += 1; }
else { rest.push(json!({"candidate": id, "pos_mm": c["pos_mm"], "size_mm": c["size_mm"], "colour": c["colour"]})); }
}
doc["dismissed"] = json!(dismissed);
doc["unassigned"] = json!(rest);
wr(&f.join("parts.json"), &doc);
p.log(&th, "parts-reconcile", json!({"dismissed": auto, "left": rest.len()}));
return ok(&format!("{auto} candidates dismissed as fragments of placed parts or too small; {} left in parts.json unassigned for you to judge", rest.len()),
&rest.iter().take(25).map(|r| format!("{} at {} size {} ({})", r["candidate"].as_str().unwrap(), r["pos_mm"], r["size_mm"], r["colour"].as_str().unwrap_or(""))).collect::<Vec<_>>());
}
if what != "init" { err("parts init | parts snap | parts reconcile", &[]); }
if f.join("parts.json").is_file() && !force { err("parts.json exists: it holds your edits. Use --force to overwrite it", &[]); }
let det = rd(&f.join("detect/parts.json"));
let mut cands: Vec<Value> = det["candidates"].as_array().cloned().unwrap_or_default();
cands.sort_by(|a, b| (a["pos_mm"][1].as_f64().unwrap(), a["pos_mm"][0].as_f64().unwrap()).partial_cmp(&(b["pos_mm"][1].as_f64().unwrap(), b["pos_mm"][0].as_f64().unwrap())).unwrap());
let mut count: HashMap<String, usize> = HashMap::new();
let mut list = vec![]; let mut unassigned = vec![];
for c in &cands {
// a candidate from a file written before candidates were rated counts as sure
let conf = c["confidence"].as_f64().unwrap_or(1.0);
if c["fits"] != json!(true) || conf < candconf::SURE { unassigned.push(json!({"candidate": c["id"], "size_mm": c["size_mm"], "colour": c["colour"], "best": c["matches"].get(0), "confidence": conf, "doubt": c["doubt"], "package": c["package"]})); continue; }
let pre = c["prefix"].as_str().unwrap_or("X").to_string();
let n = { let e = count.entry(pre.clone()).or_default(); *e += 1; *e };
let r = format!("{pre}{n}");
let one_way = !["C", "R", "TP", "J"].contains(&pre.as_str()) || c["package"].as_str().map(|s| s.contains("SOD") || s.starts_with("SM")).unwrap_or(false);
let mut e = json!({"ref": r, "candidate": c["id"], "side": c["side"], "pos_mm": c["pos_mm"], "rot": c["rot"], "package": c["package"], "footprint": c["footprint"],
"value": "", "marking": "", "function": "", "tag": "MEASURED", "evidence": [format!("evidence/parts/{}.jpg", c["id"].as_str().unwrap())],
"fieldTags": {"identity": "UNKNOWN"}, "tests": [format!("T-id-{r}")]});
if c["lug"].is_object() { e["lug"] = c["lug"].clone(); }
if one_way { e["pin1"] = json!({"mark": "", "evidence": format!("evidence/parts/{}.jpg", c["id"].as_str().unwrap())}); }
list.push(e);
}
// most sure first: the top of the list is where the real parts are
unassigned.sort_by(|a, b| b["confidence"].as_f64().unwrap_or(0.0).partial_cmp(&a["confidence"].as_f64().unwrap_or(0.0)).unwrap());
wr(&f.join("parts.json"), &json!({"parts": list, "unassigned": unassigned, "dismissed": []}));
p.log(&th, "parts-init", json!({"parts": list.len(), "unassigned": unassigned.len()}));
ok(&format!("parts.json: {} parts from candidates, {} candidates unassigned (add each as a part or dismiss it with a reason)", list.len(), unassigned.len()),
&["For every part: read its crop, set ref, value, marking, mpn and function, correct the footprint if the size match is wrong, set rot so pin 1 matches the photo, tag what you read SEEN (fieldTags) and remove its T-id test once the identity is seen. `decode <code>` reads resistor markings.".into()]);
}
Cmd::Decode { code } => match parts::decode(code) {
Some((v, how)) => ok(&format!("{code} = {} ({how})", parts::si(v, "Ω")), &["Inductor codes read the same way in µH (101 = 100 µH).".into()]),
None => err(&format!("{code}: not a resistor code this decoder knows (3-digit, 4-digit, R-notation, EIA-96)"), &["Look the marking up in the manufacturer's marking table instead.".into()]),
},
Cmd::Blocks { what } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
if what != "propose" { err("blocks propose", &[]); }
let list: Vec<Value> = rd(&f.join("parts.json"))["parts"].as_array().cloned().unwrap_or_default();
let n = list.len(); let mut g: Vec<usize> = (0..n).collect();
fn root(g: &mut [usize], x: usize) -> usize { let mut r = x; while g[r] != r { r = g[r]; } g[x] = r; r }
for i in 0..n { for j in i + 1..n {
let (a, b) = (&list[i]["pos_mm"], &list[j]["pos_mm"]);
if geom::dist((a[0].as_f64().unwrap(), a[1].as_f64().unwrap()), (b[0].as_f64().unwrap(), b[1].as_f64().unwrap())) < 2.5 { let (ri, rj) = (root(&mut g, i), root(&mut g, j)); g[ri] = rj; }
} }
let mut groups: HashMap<usize, Vec<String>> = HashMap::new();
for i in 0..n { let r = root(&mut g, i); groups.entry(r).or_default().push(list[i]["ref"].as_str().unwrap_or("").to_string()); }
let mut v: Vec<Vec<String>> = groups.into_values().collect(); v.sort_by(|a, b| b.len().cmp(&a.len()));
let prop: Vec<Value> = v.iter().enumerate().map(|(k, refs)| json!({"id": format!("group-{}", k + 1), "parts": refs})).collect();
wr(&f.join("detect/blocks-proposal.json"), &json!({"groups": prop}));
p.log(&th, "blocks-propose", json!({"groups": prop.len()}));
ok(&format!("{} proximity groups in detect/blocks-proposal.json", prop.len()), &["Name each block, say what it does, merge or split groups by topology and silkscreen, and write blocks.json.".into()]);
}
Cmd::Copper { what } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
match what.as_str() {
"prep" => {
for (side, file) in [("top", "board/top.jpg"), ("bottom", "board/bottom.jpg")] {
if !f.join(file).is_file() { continue; }
let i = img(&f.join(file));
save(&copper::gap_image(&copper::gap_map(&i)), &f.join(format!("evidence/gaps-{side}.jpg")));
}
let lines = write_domes(&f);
let dd = rd(&f.join("detect/domes.json"));
let out: Vec<Value> = dd["domes"].as_array().into_iter().flatten().enumerate().map(|(k, d)| json!({"id": format!("v{}", k + 1), "pos_mm": d["pos_mm"], "diameter_mm": d["diameter_mm"], "shape": d["shape"], "side": d["side"]})).collect();
for side in ["top", "bottom"] { if f.join(format!("board/{side}.jpg")).is_file() {
let mut vi = img(&f.join(format!("board/{side}.jpg")));
for v in out.iter().filter(|v| v["side"] == json!(side)) { let q = board::to_px(v["pos_mm"][0].as_f64().unwrap(), v["pos_mm"][1].as_f64().unwrap());
draw::circle(&mut vi, q, (v["diameter_mm"].as_f64().unwrap_or(0.6) / 2.0 * board::RES).max(3.0), match v["shape"].as_str().unwrap_or("") { "ring" => [255, 200, 0], "arc" => [255, 0, 255], "edge" => [255, 60, 60], _ => [0, 255, 120] }, 1); }
if !out.iter().all(|v| v["side"] != json!(side)) { save(&vi, &f.join(format!("evidence/vias-{side}.jpg"))); }
} }
wr(&f.join("detect/vias.json"), &json!({"vias": out, "note": "vias read from the photos, position and diameter measured; drawn on evidence/vias-*.jpg (yellow ring, magenta arc, red on a copper edge, green blob). Which layers a via joins is not read here: `copper trace` measures that."}));
p.log(&th, "copper-prep", json!({"vias": out.len()}));
let mut hints = lines;
hints.push("Sketch each pour in copper.json (zones[].sketch_mm, layer, net, settings) from board/*-grid.jpg and evidence/gaps-*.jpg, then `copper fit`.".into());
ok(&format!("gap maps evidence/gaps-*.jpg; {} vias in detect/domes.json and detect/vias.json", out.len()), &hints);
}
"domes" => {
let lines = write_domes(&f);
p.log(&th, "copper-domes", json!({"report": lines.clone()}));
ok("vias read on both faces: detect/domes.json; every candidate with its reason in detect/dome-candidates.json and detect/via-spots.json", &lines);
}
"trace" => {
// a run that never sketched any copper starts from the outline scale measured
if !f.join("copper.json").is_file() {
let sc = rd(&f.join("scale.json")); let (x, y) = (sc["board_mm"][0].as_f64().unwrap_or(0.0), sc["board_mm"][1].as_f64().unwrap_or(0.0));
let (x0, y0) = (board::ORIGIN, board::ORIGIN);
wr(&f.join("copper.json"), &json!({"outline": {"points_mm": [[x0, y0], [x0 + x, y0], [x0 + x, y0 + y], [x0, y0 + y]], "corner_mm": sc["corner_mm"], "evidence": ["board/top.jpg"], "tag": "MEASURED"}, "zones": [], "traces": [], "vias": [], "defaultEvidence": ["evidence/gaps-bottom.jpg"]}));
}
// the pads of the parts placed so far: the reader starts each pad's copper from the pad itself (`padjoin`)
let pads_early: Vec<kicad_out::Pad> = if f.join("parts.json").is_file() { let parts = rd(&f.join("parts.json")); let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []})); let empty = json!({"outline": rd(&f.join("copper.json"))["outline"], "zones": [], "traces": [], "vias": []});
kicad_out::board_opt(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &empty, &nets_doc, 2, "pads", false).map(|b| b.pads).unwrap_or_default() } else { vec![] };
if let Ok(dir) = std::env::var("TRACE_DUMP") { let v: Vec<Value> = pads_early.iter().map(|q| { let c = board::to_px(q.x, q.y); json!({"pin": format!("{}.{}", q.part, q.num), "px": [c.0, c.1], "w": q.w * board::RES, "h": q.h * board::RES, "rot": q.rot, "round": q.round, "drill": q.drill, "top": q.on("F.Cu"), "bottom": q.on("B.Cu")}) }).collect(); wr(&PathBuf::from(format!("{dir}/pads.json")), &json!(v)); }
let seeds_of = |layer: &str| -> Vec<padjoin::Seed> { padjoin::seeds_from(pads_early.iter().filter(|q| q.on(layer)).map(|q| { let c = board::to_px(q.x, q.y); (q.part.as_str(), c.0, c.1, q.w * board::RES, q.h * board::RES, q.rot, q.round) })) };
// every island of copper from the photos, turned into pours, tracks and vias, with what they connect
let mut faces: Vec<islands::Face> = vec![]; let mut h = vec![];
let mut imgs = vec![];
for (side, layer, file) in [("top", "F.Cu", "board/top.jpg"), ("bottom", "B.Cu", "board/bottom.jpg")] {
if !f.join(file).is_file() { continue; }
if std::env::var("TRACE_SIDE").map(|s| s != side).unwrap_or(false) { continue; }
let im = img(&f.join(file));
let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } };
let shot2 = second_shot(&f, side, &im);
let dome_px = domes_of(&f, side);
let pad_seeds = seeds_of(layer);
let t = trace::trace(&im, &trace::Params { bodies: photomat::face_carries_parts(&f, side, &im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), pads: &pad_seeds, ..Default::default() });
faces.push(islands::Face { side, layer, board_colour: islands::board_coloured(&im, &t.labels), t });
imgs.push((side, im));
}
// for working on the reader: read the masks with TRACE_DUMP and stop before anything is written
if let Ok(dir) = std::env::var("TRACE_DUMP") { for (side, im) in &imgs { let c = photomat::classify(im, photomat::face_carries_parts(&f, side, im)); let g = image::GrayImage::from_fn(c.w as u32, c.h as u32, |x, y| image::Luma([c.cls[y as usize * c.w + x as usize]])); let _ = g.save(format!("{dir}/{side}-cls.png")); } }
if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &PathBuf::from(format!("{dir}/{side}-islands.jpg"))); } }
if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, _)) in imgs.iter().enumerate() { let b: Vec<u8> = faces[fi].t.labels.iter().flat_map(|l| l.to_le_bytes()).collect(); let _ = std::fs::write(format!("{dir}/{side}-labels.u32"), b); } }
if std::env::var("TRACE_STOP").is_ok() { return; }
let parts = rd(&f.join("parts.json"));
let nets_doc = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));
let mut pin_net: HashMap<String, String> = HashMap::new();
for n in nets_doc["nets"].as_array().into_iter().flatten() { for q in n["pins"].as_array().into_iter().flatten().filter_map(|x| x.as_str()) { pin_net.insert(q.to_string(), n["name"].as_str().unwrap_or("").to_string()); } }
let empty = json!({"outline": rd(&f.join("copper.json"))["outline"], "zones": [], "traces": [], "vias": []});
let pads = kicad_out::board_opt(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &empty, &nets_doc, 2, "pads", false).unwrap_or_else(|e| err(&e, &[])).pads;
let dd = rd(&f.join("detect/domes.json"));
let domes: Vec<islands::SeenDome> = dd["domes"].as_array().into_iter().flatten().filter_map(|x| { let fi = faces.iter().position(|q| Some(q.side) == x["side"].as_str())?; Some(islands::SeenDome { x: x["pos_mm"][0].as_f64()?, y: x["pos_mm"][1].as_f64()?, face: fi, d: x["diameter_mm"].as_f64().unwrap_or(0.6), shape: x["shape"].as_str().unwrap_or("").to_string() }) }).collect();
// the photos of both faces have to show vias before one can be called isolated on both
let at: Vec<(f64, f64)> = domes.iter().filter(|d| d.shape == "blob" || d.shape == "ring" || d.shape == "pit").map(|d| (d.x, d.y)).collect();
let sig: Vec<(String, f64, usize)> = imgs.iter().map(|(side, im)| { let (s, n) = domes::face_signature(im, &at); (side.to_string(), s, n) }).collect();
let faces_show_vias = sig.len() >= 2 && sig.iter().all(|s| s.1.abs() >= domes::SIGNATURE_MIN);
for (side, s, n) in &sig { h.push(format!("{side} photo at {n} via positions: {s:.1} standard errors against the surroundings")); }
if !faces_show_vias { h.push("A photo shows no trace of the vias, so no via can be shown to lead only inward. No minimum layer count follows from the photos.".into()); }
// a drilled pad with a dark middle on the bottom photo has no pin in it
let open_holes: Vec<(f64, f64)> = imgs.iter().find(|i| i.0 == "bottom").map(|(_, im)| pads.iter().filter(|q| q.drill > 0.0).filter(|q| domes::hole_is_open(im, q.x, q.y, q.drill)).map(|q| (q.x, q.y)).collect()).unwrap_or_default();
let rl = rules_of(&p.state);
let opts = islands::Options { track: rules::num(&rl, "track", 0.2), via: rules::via(&rl, "viaSignal"), clearance: rules::num(&rl, "clearance", 0.2), hole_hole: rules::num(&rl, "holeToHole", 0.25), edge_clear: rules::num(&rl, "edgeClearance", 0.5), faces_show_vias, open_holes, bodies: kicad_out::bodies_of(&kicad_out::cache_dir(&f), parts["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[])), edge: { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))) } };
// a face that cannot be read is taken out and said to be unread (`faceread`)
let m = faceread::measure(&mut faces, &pads, &domes, &pin_net, &opts);
for u in m["unread"].as_array().into_iter().flatten() { h.push(format!("{} face NOT READ: {}. Nothing is drawn from it and its pins join nothing on that face.", u["side"].as_str().unwrap_or(""), u["why"].as_str().unwrap_or(""))); }
for u in m["warnings"].as_array().into_iter().flatten() { h.push(format!("{} face, worth a look: {}", u["side"].as_str().unwrap_or(""), u["what"].as_str().unwrap_or(""))); }
for r in m["partPinsRefused"].as_array().into_iter().flatten() { h.push(format!("{}: island {} on the {} face held {} of its {} pins; none of them was joined to it", r["part"].as_str().unwrap_or(""), r["island"], r["side"].as_str().unwrap_or(""), r["pins"], r["of"])); }
if let Some(n) = m["padPairsRefused"].as_array().map(|a| a.len()).filter(|n| *n > 0) { h.push(format!("{n} two-pad part(s) had both pads on one island; neither pad was joined to it (padPairsRefused in detect/copper-measured.json)")); }
for (fi, (side, im)) in imgs.iter().enumerate() { save(&trace::overlay(im, &faces[fi].t), &f.join(format!("evidence/copper-islands-{side}.jpg"))); }
if let Ok(dir) = std::env::var("TRACE_DUMP") { for (fi, (side, _)) in imgs.iter().enumerate() { let b: Vec<u8> = faces[fi].t.labels.iter().flat_map(|l| l.to_le_bytes()).collect(); let _ = std::fs::write(format!("{dir}/{side}-labels-final.u32"), b); } }
// the seen copper replaces the sketched copper; the old file is kept beside it
let old = rd(&f.join("copper.json"));
if !f.join("copper.sketched.json").is_file() { wr(&f.join("copper.sketched.json"), &old); }
let mut c = old.clone();
let mut vias_out = m["vias"].clone();
if let Some((_, bim)) = imgs.iter().find(|i| i.0 == "bottom") { let shot2 = second_shot(&f, "bottom", bim); let (report, line) = domes::size_vias(bim, shot2.as_ref(), &mut vias_out, rules::via(&rl, "viaSignal").1); wr(&f.join("detect/via-sizes.json"), &report); h.push(line); }
c["zones"] = m["zones"].clone(); c["traces"] = m["traces"].clone(); c["vias"] = vias_out; c["from"] = json!("islands");
wr(&f.join("copper.json"), &c);
wr(&f.join("detect/copper-island-accounting.json"), &m["accounting"]);
wr(&f.join("detect/copper-measured.json"), &json!({"trackPadConflicts": m["trackPadConflicts"], "report": m["report"], "groups": m["groups"], "pinAdds": m["pinAdds"], "pinClashes": m["pinClashes"], "viaClashes": m["viaClashes"], "mixed": m["mixed"], "joins": m["joins"], "profile": m["profile"], "viasDropped": m["viasDropped"], "padPairsRefused": m["padPairsRefused"], "partPinsRefused": m["partPinsRefused"], "padPairsSeen": m["padPairsSeen"], "unread": m["unread"], "warnings": m["warnings"]}));
// pins with no net yet that the copper names: seen, so they join that net
let mut nd = nets_doc.clone(); let mut added = 0;
if let Some(list) = nd["nets"].as_array_mut() { for a in m["pinAdds"].as_array().into_iter().flatten() {
if let (Some(pin), Some(net)) = (a["pin"].as_str(), a["net"].as_str()) { if let Some(n) = list.iter_mut().find(|n| n["name"].as_str() == Some(net)) {
if !n["pins"].as_array().map(|p| p.iter().any(|x| x.as_str() == Some(pin))).unwrap_or(false) { if let Some(pl) = n["pins"].as_array_mut() { pl.push(json!(pin)); added += 1; } }
let e = n["seenPins"].as_array().cloned().unwrap_or_default(); let mut e2 = e; e2.push(json!(pin)); n["seenPins"] = json!(e2);
} }
} }
if added > 0 { wr(&f.join("nets.json"), &nd); }
p.log(&th, "copper-trace", m["report"].clone());
h.push(format!("{}", m["report"]));
h.push("Measured connectivity: detect/copper-measured.json (groups, pin clashes). Next: `check copper`, `build`, `check mimic`.".into());
ok("copper.json rebuilt from the photos, island by island", &h);
}
"regions" => {
let mut lines = vec![];
let bodies = items::read_json(&f.join("parts.json")).ok().map(|p| kicad_out::bodies_of(&kicad_out::cache_dir(&f), p["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]))).unwrap_or_default();
for side in ["top", "bottom"] {
let file = f.join(format!("board/{side}.jpg")); if !file.is_file() { continue; }
let i = img(&file);
let (mut regs, lab, lw, nd) = copper::regions(&i, side);
// a region that is a part's own top is no candidate pour: it is listed apart, with its part
let on: HashMap<String, String> = islands::regions_on_bodies(&lab, &islands::board_coloured(&i, &lab).d, lw, lab.len() / lw.max(1), if side == "top" { "F.Cu" } else { "B.Cu" }, &bodies).into_iter().map(|(l, p)| (format!("{side}:{l}"), p)).collect();
let on_parts: Vec<Value> = regs.iter().filter_map(|r| on.get(r["id"].as_str().unwrap_or("")).map(|p| json!({"id": r["id"], "part": p, "area_mm2": r["area_mm2"], "centre_mm": r["centre_mm"]}))).collect();
regs.retain(|r| !on.contains_key(r["id"].as_str().unwrap_or("")));
{ let b = copper::line_barrier(&i); let mut bi = i.clone(); draw::tint(&mut bi, &b, [255, 0, 255], 0.8); save(&bi, &f.join(format!("evidence/copper-barrier-{side}.jpg"))); }
let ds = copper::domes(&i);
// evidence: region outlines (cyan likely copper: stitched; grey not), ids, domes
let mut ev = i.clone();
for (x, y) in &ds { draw::circle(&mut ev, board::to_px(*x, *y), 6.0, [255, 0, 255], 1); }
for r in ®s {
let poly: Vec<geom::P> = r["polygon_mm"].as_array().unwrap().iter().map(|q| board::to_px(q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();
let stitched = r["domesPerMm2"].as_f64().unwrap_or(0.0) >= 0.2;
draw::poly(&mut ev, &poly, if stitched { [0, 255, 255] } else { [150, 150, 150] }, 3);
let c = board::to_px(r["centre_mm"][0].as_f64().unwrap(), r["centre_mm"][1].as_f64().unwrap());
draw::label(&mut ev, (c.0 - 20.0, c.1 - 10.0), r["id"].as_str().unwrap().split(':').nth(1).unwrap_or(""), [255, 255, 0], 5);
}
save(&ev, &f.join(format!("evidence/copper-regions-{side}.jpg")));
let stitched = regs.iter().filter(|r| r["domesPerMm2"].as_f64().unwrap_or(0.0) >= 0.2).count();
lines.push(format!("{side}: {} regions ({stitched} stitched with vias, likely copper), {} on part bodies left out, {nd} vias found; evidence/copper-regions-{side}.jpg", regs.len(), on_parts.len()));
wr(&f.join(format!("detect/copper-regions-{side}.json")), &json!({"side": side, "regions": regs, "onParts": on_parts, "note": "regions enclosed by straight dark gap lines; domesPerMm2 >= 0.2 marks a stitched pour. Use them in copper.json zones as \"regions\": [\"<side>:<id>\", ...]"}));
}
lines.extend(write_domes(&f));
let all_domes = rd(&f.join("detect/domes.json"))["domes"].as_array().cloned().unwrap_or_default();
p.log(&th, "copper-regions", json!({"domes": all_domes.len()}));
lines.push("In copper.json, give each pour \"regions\": [ids] (merge several where the barrier split one pour), its layer and net; `copper fit` builds the outline from them and assigns every via inside it.".into());
ok("candidate pours and vias measured on both faces", &lines);
}
"assign" => {
// pours from the measured regions: a region holding pads of one net takes that net; every
// region of a net on a face joins one pour. A stitched region with no pad is copper of an
// unknown net (INFERRED, with a test). Zones the AI wrote by hand (no "auto") are kept.
let mut doc = rd(&f.join("copper.json"));
let pads = build_board_pads(&f);
let mut keep: Vec<Value> = doc["zones"].as_array().into_iter().flatten().filter(|z| z["auto"] != json!(true)).cloned().collect();
let hand_layers_nets: Vec<(String, String)> = keep.iter().map(|z| (z["layer"].as_str().unwrap_or("").to_string(), z["net"].as_str().unwrap_or("").to_string())).collect();
let hand_regions: std::collections::HashSet<String> = keep.iter().flat_map(|z| z["regions"].as_array().cloned().unwrap_or_default()).filter_map(|v| v.as_str().map(str::to_string)).collect();
let mut groups: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();
let (mut named, mut unnamed, mut skipped) = (0, 0, 0);
for side in ["bottom", "top"] {
let Ok(rg) = items::read_json(&f.join(format!("detect/copper-regions-{side}.json"))) else { continue };
let layer = if side == "top" { "F.Cu" } else { "B.Cu" };
let min_dens = if side == "top" { 0.3 } else { 0.2 };
let (gap, strength) = gap_for(&f, layer);
for r in rg["regions"].as_array().into_iter().flatten() {
if hand_regions.contains(r["id"].as_str().unwrap_or("")) { continue; }
let poly: Vec<geom::P> = r["polygon_mm"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect();
let mut votes: HashMap<String, usize> = HashMap::new();
// a pad votes only when its clearance ring is broken (thermal spokes or solid): an isolated pad
// inside a pour belongs to another net
for pd in pads.iter().filter(|pd| pd.drill > 0.0 && pd.on(layer) && !pd.net.is_empty() && pd.w * pd.h >= 9.0) {
if !connect::inside(&poly, (pd.x, pd.y)) { continue; }
let share = connect::ring_share(&gap, pd, 0.12, strength);
if connect::classify(share) != "isolated" { *votes.entry(pd.net.clone()).or_default() += 1; }
}
let stitched = r["domesPerMm2"].as_f64().unwrap_or(0.0) >= min_dens;
let area = r["area_mm2"].as_f64().unwrap_or(0.0);
// a small region around a plated pad is that pad's own copper, not a pour
let holds_pad = pads.iter().any(|pd| pd.drill > 0.0 && connect::inside(&poly, (pd.x, pd.y)));
if holds_pad && area < 8.0 { skipped += 1; continue; }
// a region mostly covered by one pad is that pad's copper (a lug, a large pad), not a pour
if pads.iter().any(|pd| pd.on(layer) && connect::inside(&poly, (pd.x, pd.y)) && pd.w * pd.h >= 0.4 * area) { skipped += 1; continue; }
// a region lying (mostly) within one pad's box is a piece of that pad
let in_pad = |pd: &kicad_out::Pad| { let m = pd.w.max(pd.h) / 2.0 + 0.4; poly.iter().filter(|q| (q.0 - pd.x).abs() <= m && (q.1 - pd.y).abs() <= m).count() as f64 >= 0.8 * poly.len() as f64 };
if pads.iter().any(|pd| pd.on(layer) && in_pad(pd)) { skipped += 1; continue; }
// pads of more than one net joined to one region: the region is not named by them
// a pour on this kind of board is stitched: an unstitched region (a silkscreen box round a
// header) is not named by the pins inside it
// plated pads of more than one net inside the region (a header): never named by them
let mixed = { let mut s: std::collections::HashSet<&str> = Default::default(); for pd in pads.iter().filter(|pd| pd.drill > 0.0 && pd.on(layer) && !pd.net.is_empty() && connect::inside(&poly, (pd.x, pd.y))) { s.insert(pd.net.as_str()); } s.len() > 1 };
let net = if votes.len() == 1 && !mixed && (stitched || side == "top") { votes.into_iter().next().map(|(n, _)| n) } else { None };
match net {
Some(n) => { named += 1; groups.entry((layer.to_string(), n)).or_default().push(r["id"].as_str().unwrap().to_string()); }
// on the top face parts cover the board: an unnamed region there is not taken as copper
None if stitched && side == "bottom" && r["area_mm2"].as_f64().unwrap_or(0.0) >= 3.0 => { unnamed += 1; groups.entry((layer.to_string(), format!("~{}", r["id"].as_str().unwrap()))).or_default().push(r["id"].as_str().unwrap().to_string()); }
None => { skipped += 1; }
}
}
}
let mut added = 0;
for ((layer, net), ids) in groups {
let unknown = net.starts_with('~');
if !unknown && hand_layers_nets.iter().any(|(l, n)| *l == layer && *n == net) { continue; }
let id = if unknown { format!("pour-{}", net.trim_start_matches('~').replace(':', "-")) } else { format!("pour-{}-{}", net.to_lowercase().replace(['+', '$'], ""), if layer == "F.Cu" { "top" } else { "bottom" }) };
// copper of unknown net still goes on the board, on a placeholder net of its own
let mut z = json!({"id": id, "layer": layer, "net": if unknown { format!("UNK_{}", net.trim_start_matches('~').replace(':', "_")) } else { net.clone() }, "regions": ids, "auto": true, "tag": "MEASURED", "evidence": [format!("evidence/copper-regions-{}.jpg", if layer == "F.Cu" { "top" } else { "bottom" })]});
if unknown { z["fieldTags"] = json!({"net": "UNKNOWN"}); z["tests"] = json!(["T-pour-nets"]); } else { z["fieldTags"] = json!({"net": "INFERRED"}); z["tests"] = json!(["T-pour-nets"]); }
keep.push(z); added += 1;
}
doc["zones"] = json!(keep);
wr(&f.join("copper.json"), &doc);
p.log(&th, "copper-assign", json!({"zones": added, "named": named, "unnamed": unnamed, "skipped": skipped}));
ok(&format!("{added} pours from measured regions ({named} regions named by their pads, {unnamed} stitched regions of unknown net, {skipped} not copper)"), &["Run `copper fit` to build their outlines and give their vias a net; review evidence/copper-fit-*.jpg; replace an auto pour with your own zone where the regions are wrong.".into()]);
}
"fit" => {
let mut doc = rd(&f.join("copper.json"));
// zones given as region ids: the union of those regions is the outline
let mut region_poly: HashMap<String, Vec<geom::P>> = HashMap::new();
for side in ["top", "bottom"] { if let Ok(r) = items::read_json(&f.join(format!("detect/copper-regions-{side}.json"))) {
for g in r["regions"].as_array().into_iter().flatten() { region_poly.insert(g["id"].as_str().unwrap_or("").to_string(), g["polygon_mm"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()); } } }
let mut missing = vec![];
for z in doc["zones"].as_array_mut().into_iter().flatten() {
let Some(ids) = z["regions"].as_array().cloned() else { continue };
let polys: Vec<Vec<geom::P>> = ids.iter().filter_map(|i| { let k = i.as_str().unwrap_or(""); let p = region_poly.get(k).cloned(); if p.is_none() { missing.push(k.to_string()); } p }).collect();
if polys.is_empty() { continue; }
let (x0, y0, x1, y1) = polys.iter().flatten().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1)));
let g = kicad_out::Grid { x0: x0 - 1.0, y0: y0 - 1.0, res: 20.0, w: ((x1 - x0 + 2.0) * 20.0) as usize + 1, h: ((y1 - y0 + 2.0) * 20.0) as usize + 1 };
let mut m = g.empty(); for p in &polys { g.poly(&mut m, p); }
let m = m.close(5.0).fill_holes(); // bridge the barrier seams between merged regions (0.25 mm)
let (lab, cs) = raster::components(&m);
let Some(c) = cs.iter().max_by_key(|c| c.area) else { continue };
let ring: Vec<geom::P> = geom::simplify(&raster::trace_boundary(&lab, m.w, m.h, c), 1.5).iter().map(|q| { let p = g.mm(q.0, q.1); ((p.0 * 1000.0).round() / 1000.0, (p.1 * 1000.0).round() / 1000.0) }).collect();
z["sketch_mm"] = json!(ring.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());
z["from"] = json!("regions");
}
if !missing.is_empty() { err(&format!("unknown region ids: {}", missing.join(", ")), &["Run `copper regions` and use ids from detect/copper-regions-<side>.json.".into()]); }
let mut gaps: HashMap<String, (raster::Plane, f32)> = HashMap::new();
let mut ov_top = img(&f.join("board/top.jpg"));
let mut ov_bot = if f.join("board/bottom.jpg").is_file() { img(&f.join("board/bottom.jpg")) } else { ov_top.clone() };
let mut lines = vec![];
let doc_ro = doc.clone();
for z in doc["zones"].as_array_mut().into_iter().flatten() {
let layer = z["layer"].as_str().unwrap_or("F.Cu").to_string();
if layer != "F.Cu" && layer != "B.Cu" { continue; }
let Some(sk) = z["sketch_mm"].as_array() else { continue };
let sketch: Vec<geom::P> = sk.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect();
if !gaps.contains_key(&layer) { gaps.insert(layer.clone(), gap_for(&f, &layer)); }
let (g, st) = &gaps[&layer];
let ol: Vec<geom::P> = doc_ro["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default();
let bbox = if ol.is_empty() { None } else { Some(ol.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1)))) };
// an outline built from measured regions already sits on the boundary lines: settle it only
let reach = if z["from"] == json!("regions") { 0.25 } else { 1.0 };
let (fit, share, moved) = copper::snap_polygon_in(g, &sketch, reach, *st, bbox);
z["polygon_mm"] = json!(fit.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>());
z["fit"] = json!({"edgeShare": (share * 1000.0).round() / 1000.0, "maxMove_mm": (moved * 1000.0).round() / 1000.0});
let ov = if layer == "F.Cu" { &mut ov_top } else { &mut ov_bot };
copper::draw_poly_mm(ov, &sketch, [255, 220, 0], 2); copper::draw_poly_mm(ov, &fit, [255, 0, 255], 3);
lines.push(format!("{} ({layer}): {:.0}% of the outline on a measured gap, corners moved up to {moved:.2} mm", z["id"].as_str().unwrap_or("?"), share * 100.0));
}
// vias: every dome inside a fitted pour with a named net becomes a stitching via of that net (written
// to the board); domes elsewhere stay as evidence. Domes on pads are not vias.
if let Ok(dd) = items::read_json(&f.join("detect/domes.json")) {
let pads = build_board_pads(&f);
let zones: Vec<(String, String, Vec<geom::P>)> = doc["zones"].as_array().into_iter().flatten().filter_map(|z| Some((z["layer"].as_str()?.to_string(), z["net"].as_str()?.to_string(), z["polygon_mm"].as_array()?.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()))).collect();
let mut vias = vec![]; let (mut named, mut free) = (0, 0);
for (k, d) in dd["domes"].as_array().into_iter().flatten().enumerate() {
let side = d["side"].as_str().unwrap_or("bottom"); let layer = if side == "top" { "F.Cu" } else { "B.Cu" };
let q = (d["pos_mm"][0].as_f64().unwrap(), d["pos_mm"][1].as_f64().unwrap());
if pads.iter().any(|pd| pd.on(layer) && (pd.x - q.0).abs() <= pd.w.max(pd.h) / 2.0 + 0.2 && (pd.y - q.1).abs() <= pd.w.max(pd.h) / 2.0 + 0.2) { continue; }
let z = zones.iter().find(|(l, n, poly)| l == layer && !n.is_empty() && !n.starts_with("UNK") && connect::inside(poly, q));
let z = z.or_else(|| zones.iter().find(|(l, _, poly)| l == layer && connect::inside(poly, q)));
match z {
Some((_, n, _)) if !n.is_empty() => { named += 1; vias.push(json!({"id": format!("d{k}"), "pos_mm": [q.0, q.1], "kind": "stitch", "net": n, "side": side, "seenTop": d["seenTop"], "diameter_mm": rules::via(&rules_of(&p.state), "viaSignal").0, "drill_mm": rules::via(&rules_of(&p.state), "viaSignal").1, "tag": "MEASURED", "fieldTags": {"net": "INFERRED"}, "tests": ["T-vias"]})); }
_ => { free += 1; vias.push(json!({"id": format!("d{k}"), "pos_mm": [q.0, q.1], "kind": if d["seenTop"] == json!(true) { "signal" } else { "unknown" }, "side": side, "tag": "MEASURED"})); }
}
}
doc["vias"] = json!(vias);
lines.push(format!("vias: {named} inside named pours (stitching, on the board with the pour's net), {free} elsewhere (evidence only)"));
}
wr(&f.join("copper.json"), &doc);
save(&ov_top, &f.join("evidence/copper-fit-top.jpg")); save(&ov_bot, &f.join("evidence/copper-fit-bottom.jpg"));
p.log(&th, "copper-fit", json!({"zones": lines.len()}));
ok("pours fitted to the photo's gap lines (yellow sketch, magenta fit: evidence/copper-fit-*.jpg)", &lines);
}
_ => err("copper prep | copper fit", &[]),
}
}
Cmd::Build { layers } => {
let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();
let n = layers.or_else(|| items::read_json(&f.join("layers.json")).ok().and_then(|l| l["proposed"].as_u64().map(|x| x as usize))).unwrap_or(2);
let b = build_board(&f, n);
let name = p.dir.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or("board".into());
let kdir = p.dir.join("kicad"); std::fs::create_dir_all(&kdir).ok();
let pcb = kdir.join(format!("{name}.kicad_pcb"));
std::fs::write(&pcb, &b.text).unwrap_or_else(|e| err(&e.to_string(), &[]));
let pro = kdir.join(format!("{name}.kicad_pro"));
std::fs::write(&pro, unrouted::with_evidence_classes(&rules::kicad_pro(&rules_of(&p.state), &name), &f)).unwrap_or_else(|e| err(&e.to_string(), &[]));
let hidden = unrouted::write(&f);
let mut faces = vec![];
for (layer, file, side) in [("F.Cu", "board/top.jpg", "top"), ("B.Cu", "board/bottom.jpg", "bottom")] {
if !f.join(file).is_file() { continue; }
let mut o = img(&f.join(file));
for (_, _, m) in b.fills.iter().filter(|(l, _, _)| l == layer) {
for v in 0..m.h { for u in 0..m.w { if m.at(u, v) { let (x, y) = b.grid.mm(u as f64 + 0.5, v as f64 + 0.5); let q = board::to_px(x, y); let (qx, qy) = (q.0 as i64, q.1 as i64);
for dy in 0..2 { for dx in 0..2 { if (qx + dx + qy + dy) % 3 == 0 { draw::px(&mut o, qx + dx, qy + dy, [255, 120, 40]); } } } } } }
}
for pd in b.pads.iter().filter(|pd| pd.on(layer)) {
let (hw, hh) = (pd.w / 2.0, pd.h / 2.0); let (c, s) = (pd.rot.to_radians().cos(), pd.rot.to_radians().sin());
let pts: Vec<geom::P> = [(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| board::to_px(pd.x + lx * c + ly * s, pd.y - lx * s + ly * c)).collect();
draw::poly(&mut o, &pts, [0, 255, 255], 2);
}
save(&o, &f.join(format!("evidence/overlay-{side}.jpg")));
faces.push(side);
}
// the schematic from the same files: a root sheet and one sheet per block (sch.rs)
let schp = kdir.join(format!("{name}.kicad_sch"));
let sch_hints = sch::write(&f, &kdir, &name).unwrap_or_else(|e| err(&format!("schematic: {e}"), &[]));
p.state["kicad"]["pcb"] = json!(abs(&pcb)); p.state["kicad"]["sch"] = json!(abs(&schp)); p.save().ok();
p.log(&th, "build", json!({"pcb": abs(&pcb), "layers": n, "warnings": b.warnings, "pads": b.pads.len(), "zones": b.zones_filled}));
let cur = flow::current(&fl, &f).map(|s| s.name.clone()).unwrap_or("mimic".into());
// parts the board does not carry are said first and kept in a file, so `auto` and `check mimic` name the footprint wanted
let left = kicad_out::left_out_rows(&b.warnings, items::read_json(&f.join("parts.json")).ok().and_then(|d| d["parts"].as_array().cloned()).unwrap_or_default().as_slice());
wr(&f.join(kicad_out::LEFT_OUT), &json!({"leftOut": left, "noFill": b.no_fill}));
let mut h: Vec<String> = left.iter().map(|r| format!("PART LEFT OUT: {}: {}", r["ref"].as_str().unwrap_or("?"), r["why"].as_str().unwrap_or(""))).collect();
h.extend(b.no_fill.iter().map(|z| format!("POUR WITH NO FILL: {} ({} mm2, net {}): {}. It stays in copper.json and is drawn as an outline; `check mimic` lists it as a finding", z["id"].as_str().unwrap_or("?"), z["areaMm2"], z["net"].as_str().unwrap_or(""), z["why"].as_str().unwrap_or(""))));
h.extend(b.warnings.iter().filter(|w| !w.starts_with(kicad_out::LEFT_OUT_MARK)).map(|w| format!("warning: {w}")));
h.push(format!("overlays: {}", faces.iter().map(|s| format!("evidence/overlay-{s}.jpg")).collect::<Vec<_>>().join(", ")));
h.extend(sch_hints);
if routes_for(&f, n).is_none() && hidden["count"].as_u64().unwrap_or(0) > 0 { h.push(format!("{} hidden connection(s) are left unrouted: each is a ratsnest line in KiCad, in a net class named for its evidence ({}); the list is reverse-flow/unrouted.json", hidden["count"], hidden["byTag"])); }
h.extend(views::record(&f, &p.state, &cur, &format!("build ({n} layers)"), &th, None));
ok(&format!("{} ({n} copper layers, {} pads, {} zones filled)", abs(&pcb), b.pads.len(), b.zones_filled), &h);
}
Cmd::Schematic => {
let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();
let name = p.dir.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or("board".into());
let kdir = p.dir.join("kicad"); std::fs::create_dir_all(&kdir).ok();
let h = sch::write(&f, &kdir, &name).unwrap_or_else(|e| err(&format!("schematic: {e}"), &[]));
let schp = kdir.join(format!("{name}.kicad_sch"));
let pro = kdir.join(format!("{name}.kicad_pro"));
if !pro.is_file() { std::fs::write(&pro, rules::kicad_pro(&rules_of(&p.state), &name)).unwrap_or_else(|e| err(&e.to_string(), &[])); }
p.state["kicad"]["sch"] = json!(abs(&schp)); p.save().ok();
p.log(&th, "schematic", json!({"sch": abs(&schp)}));
ok(&abs(&schp), &h);
}
Cmd::Check { what, pcb, skip_earlier } => {
let th = thread(&cli); let mut p = project(&cli);
let fdir = p.flow_dir.clone(); let f = &fdir;
let pcbp = pcb.as_ref().map(PathBuf::from).or_else(|| p.state["kicad"]["pcb"].as_str().map(PathBuf::from));
let step = match what.as_str() { "prep" => "intake", "nets" | "layers" | "schematic" => "connect", "pinout" => "parts", w => w };
let idx = fl.steps.iter().position(|s| s.name == step).unwrap_or(0);
{
let behind = steps_behind(&fl, f, step);
if !behind.is_empty() && skip_earlier.is_none() { err(&format!("check {what}: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, f)); }
}
let was_stale: Vec<String> = fl.steps.get(idx).map(|s| s.done.iter().flat_map(|d| flow::stale(f, d)).collect()).unwrap_or_default();
let r = match what.as_str() {
"prep" => checks::prep(f, &p.state),
"parts" => checks::parts(f), "blocks" => checks::blocks(f), "copper" => checks::copper(f),
"mimic" => { let Some(b) = &pcbp else { err("check mimic needs --pcb <board> (or run `build`)", &[]) }; checks::mimic(f, b, &p.state) }
"nets" => checks::nets(f), "layers" => checks::layers(f, pcbp.as_deref()), "review" => checks::review(f), "tests" => checks::tests(f),
"schematic" => schcheck::schematic(f, &p.state),
// the chain part, orientation, pinout: fails on a chip whose pinout is known and whose orientation is not settled
"pinout" => { let asks = pinnets::unsettled(f); if asks.is_empty() { Ok(checks::Pass { summary: json!({"orientationNotSettled": 0, "pinoutNetsCurrent": !pinnets::stale(f)}), hints: vec![format!("the chain for every part: reverse-flow/{} (written by `connect pinout`)", pinnets::FILE)], files: vec!["parts.json".into()] }) } else { Err(asks) } }
"fidelity" => {
let Some(b) = &pcbp else { err("check fidelity needs --pcb <board> (or run `build`)", &[]) };
// the photos are scored against the copper read from them: routed copper is not read and moves what was, so a board with fresh routes in it is scored without them
let routed = pcb.is_none() && [2usize, 4, 6, 8, 10, 12].iter().any(|n| routes_for(f, *n).is_some());
if routed { let tmp = f.join(".fidelity-board.kicad_pcb"); std::fs::write(&tmp, build_board_for(f, 2, true).text).unwrap_or_else(|e| err(&e.to_string(), &[])); let r = fidelity::check(f, &tmp); let _ = std::fs::remove_file(&tmp); r } else { fidelity::check(f, b) }
}
_ => err(&format!("unknown check {what}"), &["prep | parts | blocks | copper | mimic | nets | layers | schematic | fidelity | review | tests".into()]),
};
match r {
Err(e) => { p.log(&th, "check", json!({"what": what, "ok": false, "errors": e})); err(&format!("check {what}: {} problem(s)", e.len()), &e) }
Ok(pass) if skip_earlier.is_some() => {
p.log(&th, "check", json!({"what": what, "ok": true, "earlierStepsSkipped": skip_earlier, "summary": pass.summary}));
ok(&format!("check {what} passed on its own, earlier steps skipped, so it does not count: {}", pass.summary), &pass.hints);
}
Ok(pass) => {
checks::write_ok(f, what, &pass).unwrap_or_else(|e| err(&e.to_string(), &[]));
p.log(&th, "check", json!({"what": what, "ok": true, "summary": pass.summary}));
note_progress(&mut p, &fl, idx, &was_stale); p.save().ok();
let mut h = pass.hints.clone();
h.extend(views::record(f, &p.state, step, &if what == "copper" && pass.summary["notRead"].is_array() { "check copper failed and is recorded: copper NOT READ on a face".to_string() } else { format!("check {what} passed") }, &th, None));
h.extend(step_hint(&fl, f));
// a failed reading that only a program fix can change is said as a failure, recorded, and the run goes on
match notread::headline(&pass.summary).filter(|_| what == "copper") { Some(line) => ok(&line, &h), None => ok(&format!("check {what} passed: {}", pass.summary), &h) }
}
}
}
Cmd::Layers { what, count, source, evidence, kind } => {
let _th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
match what.as_str() {
"state" => {
let (doc, mut h) = layerstate::state(&f, &layerstate::Ask { count: *count, source: source.as_deref(), evidence: evidence.as_deref(), kind: kind.as_deref() }).unwrap_or_else(|e| err(&e, &["layers state [--count <n> --source \"<document>\" | --count <n> --evidence <file> --kind edge-micrograph|cross-section|xray|ct]".into()]));
p.log(&_th, "layers-state", json!({"proposed": doc["proposed"], "tag": doc["tag"], "minimum": doc["minimum"]}));
let hidden = unrouted::write(&f);
h.push(format!("{} hidden connection(s) stay unrouted (reverse-flow/unrouted.json): `connect route` is optional and no step needs it", hidden["count"]));
h.extend(step_hint(&fl, &f));
ok(&match doc["proposed"].as_u64() { Some(n) => format!("layers.json: {n} layers stated, tag {}", doc["tag"].as_str().unwrap_or("")), None => format!("layers.json: layer count UNKNOWN, at least {} from the photos ({})", doc["minimum"]["layers"], doc["minimum"]["tag"].as_str().unwrap_or("")) }, &h);
}
"classify" => {
for side in ["top", "bottom"] {
let im = img(&f.join(format!("board/{side}.jpg")));
let c = photomat::classify(&im, photomat::carries_parts(&im));
save(&photomat::false_colour(&im, &c), &f.join(format!("evidence/materials-{side}.jpg")));
}
ok("materials written", &[]);
}
"trace" => {
// library pads, whatever a previous trace said: the trace compares the photo with them
let keep = f.join(photolayers::FILE); let _ = std::fs::remove_file(&keep);
let pads = build_board_pads(&f);
let doc = photolayers::trace(&f, &pads, &f.join("evidence")).unwrap_or_else(|e| err(&e, &[]));
wr(&keep, &doc);
ok(&format!("{} written", photolayers::FILE), &[format!("silk {} / {} polygons, {} mm2 / {} mm2; {} pads shaped; {} holes seen, {} on no pad; {} exposed patches on no pad", doc["silk"]["F"].as_array().map(|a| a.len()).unwrap_or(0), doc["silk"]["B"].as_array().map(|a| a.len()).unwrap_or(0), doc["silkAreaMm2"]["F"], doc["silkAreaMm2"]["B"], doc["pads"].as_array().map(|a| a.len()).unwrap_or(0), doc["holesSeen"], doc["freeHoles"].as_array().map(|a| a.len()).unwrap_or(0), doc["freeMetal"].as_array().map(|a| a.len()).unwrap_or(0)), "Next: `build`, then `check fidelity`.".into()]);
}
_ => err("layers classify | trace | state", &[]),
}
}
Cmd::Snapshot { name, dir } => {
let th = thread(&cli); let mut p = project(&cli);
let src = Path::new(dir); if !src.is_dir() { err(&format!("{dir}: not a folder"), &[]); }
let base = p.path(&format!("snapshots/{name}"));
if !flow::exists(&p.flow_dir, "checks/mimic.ok") { err("check mimic is missing or stale: the project may not show the current copper", &["`build`, then `check mimic --pcb <board>`, then snapshot.".into()]); }
let man = format!("snapshots/{name}/manifest.json");
if base.exists() {
// never overwritten: a stale snapshot is kept under a new name and the new one takes its place
if flow::exists(&p.flow_dir, &man) { err(&format!("snapshot {name} is up to date with copper.json and parts.json"), &[]); }
let mut k = 1; while p.path(&format!("snapshots/{name}.old{k}")).exists() { k += 1; }
std::fs::rename(&base, p.path(&format!("snapshots/{name}.old{k}"))).unwrap_or_else(|e| err(&format!("cannot keep the old snapshot: {e}"), &[]));
}
let mut files = vec![]; copy_tree(src, &base.join("files"), Path::new(""), &mut files).unwrap_or_else(|e| err(&format!("copy failed: {e}"), &[]));
files.sort();
let inputs: serde_json::Map<String, Value> = flow::SNAPSHOT_INPUTS.iter().map(|f| (f.to_string(), json!(sha256(&p.path(f))))).collect();
wr(&base.join("manifest.json"), &json!({"name": name, "source": src.display().to_string(), "at": ledger::now(), "inputs": inputs, "files": files.iter().map(|(f, h)| (f.clone(), json!(h))).collect::<serde_json::Map<_, _>>()}));
p.log(&th, "snapshot", json!({"name": name, "files": files.len()}));
reach(&mut p, &fl, "snapshot");
let sp = files.iter().find(|(f, _)| f.ends_with(".kicad_pcb") && !f.contains('/')).map(|(f, _)| base.join("files").join(f));
let mut h = views::record(&p.flow_dir, &p.state, "snapshot", &format!("snapshot {name}"), &th, sp.as_deref()); h.extend(step_hint(&fl, &p.flow_dir));
ok(&format!("snapshot {name}: {} file(s) saved with sha256", files.len()), &h);
}
Cmd::Connect { what, vias, layers, seconds, stall, max_layers } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
match what.as_str() {
"measure" => {
// what attribution needs from the photos, once: the copper and its nets are not touched
let pads = build_board(&f, 2).pads;
let bodies = kicad_out::bodies_of(&kicad_out::cache_dir(&f), rd(&f.join("parts.json"))["parts"].as_array().map(|v| v.as_slice()).unwrap_or(&[]));
let mut faces: Vec<attrib::FaceIn> = vec![];
for (side, layer, file) in [("top", "F.Cu", "board/top.jpg"), ("bottom", "B.Cu", "board/bottom.jpg")] {
// a run with one face photographed goes on with that face; the other is recorded as NOT PHOTOGRAPHED (`notread::missing`)
if !f.join(file).is_file() { if notread::missing(&f).iter().any(|x| x.side == side) { continue; } err(&format!("{file} is missing: `scale` writes the board images"), &[]); }
let im = img(&f.join(file));
let board_px = { let o = rd(&f.join("copper.json")); let pts: Vec<(f64, f64)> = o["outline"]["points_mm"].as_array().map(|a| a.iter().map(|q| (q[0].as_f64().unwrap_or(0.0), q[1].as_f64().unwrap_or(0.0))).collect()).unwrap_or_default(); if pts.len() >= 3 { let (x0, y0, x1, y1) = pts.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |a, p| (a.0.min(p.0), a.1.min(p.1), a.2.max(p.0), a.3.max(p.1))); let (a, b) = board::to_px(x0, y0); let (c, d) = board::to_px(x1, y1); Some((a, b, c, d, o["outline"]["corner_mm"].as_f64().unwrap_or(0.0) * board::RES)) } else { None } };
let shot2 = second_shot(&f, side, &im);
let dome_px = domes_of(&f, side);
let pad_seeds = padjoin::seeds_from(pads.iter().filter(|q| q.on(layer)).map(|q| { let c = board::to_px(q.x, q.y); (q.part.as_str(), c.0, c.1, q.w * board::RES, q.h * board::RES, q.rot, q.round) }));
let mut t = trace::trace(&im, &trace::Params { bodies: photomat::face_carries_parts(&f, side, &im), board: board_px, second: shot2.as_ref(), domes: &dome_px, light: light_of(&f, side), pads: &pad_seeds, ..Default::default() });
// the same islands `copper trace` kept: what it read on a part's top is not copper here either
islands::clear_bodies(&mut t, &im, layer, &bodies);
faces.push(attrib::FaceIn { side, layer, img: im, t });
}
let dd = rd(&f.join("detect/domes.json"));
let mut domes: Vec<(f64, f64, String)> = dd["domes"].as_array().into_iter().flatten().filter_map(|x| Some((x["pos_mm"][0].as_f64()?, x["pos_mm"][1].as_f64()?, x["side"].as_str()?.to_string()))).collect();
for v in rd(&f.join("copper.json"))["vias"].as_array().into_iter().flatten() { if let (Some(x), Some(y)) = (v["pos_mm"][0].as_f64(), v["pos_mm"][1].as_f64()) { if !domes.iter().any(|d| (d.0 - x).abs() < 0.01 && (d.1 - y).abs() < 0.01) { domes.push((x, y, "copper".into())); } } }
let m = attrib::measure(&f, &faces, &pads, &domes).unwrap_or_else(|e| err(&e, &[]));
p.log(&th, "connect-measure", m.clone());
ok(&format!("detect/attrib/ written: {} pieces of copper, {} pads, {} dome candidates", m["pieces"], m["pads"], m["domes"]), &["Next: `connect attribute`, and `connect attribute --vias <file>` whenever the via list improves. Neither reads a photo.".into()]);
}
"attribute" => {
// a run in which nobody has named a net yet starts from none: attribution names the pins that share copper
let started = !f.join("nets.json").is_file();
if started { wr(&f.join("nets.json"), &json!({"nets": []})); }
let s = attrib::attribute(&f, vias.as_deref().map(Path::new)).unwrap_or_else(|e| err(&e, &[if e.contains("measure") { "`connect measure` reads the photos once and writes what this reads: run it first.".to_string() } else { "`next` names the step that writes what is missing.".to_string() }]));
bench::reapply(&f);
p.log(&th, "connect-attribute", s["ladder"].clone());
let mut h = attrib::one_line(&s);
// names the copper took from datasheet pins are tagged for what they are; the joins stay the copper's
match dspins::stamp(&f) { Ok(d) => { let k = d["netsNamedFromDatasheet"].as_array().map(|a| a.len()).unwrap_or(0); if k > 0 || d["netsCarryingDatasheetPins"].as_u64().unwrap_or(0) > 0 { h.push(format!("datasheet pins: {k} net(s) the copper proves took their name from the pin on them (name tagged DATASHEET with the document cited, the join as seen); {} of {} nets carry a pin a datasheet names", d["netsCarryingDatasheetPins"], d["nets"])); } } Err(e) => h.push(format!("datasheet names were not stamped: {e}")) }
// a pin a datasheet leaves unconnected that the copper put on a net is drawn on that net, as nets.json has it
match pinnets::reconcile(&f) { Ok(v) if !v.is_empty() => h.push(format!("{} pin(s) a datasheet leaves unconnected sit on copper joined to a net and are no longer marked not connected: {} (each is named on its net as `unconnectedPinsOnCopper`, and the witness raises it)", v.len(), v.join(", "))), Ok(_) => {}, Err(e) => h.push(format!("no-connect pins were not reconciled: {e}")) }
// the datasheets as a second witness: every pin of a chip with a pins file against the copper just attributed
match witness::run(&f, true) { Ok(w) => { if w["chips"].as_u64().unwrap_or(0) > 0 { h.extend(witness::one_line(&w)); } } Err(e) => h.push(format!("the datasheet witness did not run: {e}")) }
if started { h.push("nets.json did not exist, so it was started empty: every net here is named from copper (CU_<pin>). Pin names from datasheets go in nets.json whenever you have them ({\"name\", \"pins\": [\"U1.3\"], \"tag\": \"DATASHEET\", \"source\": \"<document, page>\"}); run `connect attribute` again after".into()); }
let contradicted = items::read_json(&f.join("nets.json")).ok().map(|d| d["nets"].as_array().into_iter().flatten().filter(|n| n["contradiction"].is_object()).map(|n| n["name"].as_str().unwrap_or("").to_string()).collect::<Vec<_>>()).unwrap_or_default();
if !contradicted.is_empty() { h.push(format!("{} net(s) are contradicted by the copper and are now INFERRED with a test, each with the finding in nets.json `contradiction`: {}", contradicted.len(), contradicted.iter().take(12).cloned().collect::<Vec<_>>().join(", "))); }
// attribution writes nets into copper.json: what was built on the old nets is stale, and the next lines say what to run
h.extend(step_hint(&fl, &f));
ok("copper.json and nets.json attributed, geometry untouched; every item has a net, a rung and how it was found", &h);
}
"pinout" => {
// nets from the parts first: identity, orientation, pinout, then a net for every pin with a known function, by named rules
let s = pinnets::build(&f).unwrap_or_else(|e| err(&e, &[]));
p.log(&th, "connect-pinout", s.clone());
let mut h = pinnets::one_line(&s); h.extend(step_hint(&fl, &f));
ok(&format!("{} net(s) from the pinouts in nets.json: {} DATASHEET, {} INFERRED with a test; none is SEEN or MEASURED", s["pinoutNets"], s["pinoutNetsDatasheet"], s["pinoutNetsInferred"]), &h);
}
"witness" => {
// changes neither copper nor nets: it records, per pin and per net, whether the datasheet agrees with the copper
let w = witness::run(&f, true).unwrap_or_else(|e| err(&e, &["`connect attribute` writes the copper graph this reads; `datasheet status` says which chips have a pins file.".into()]));
p.log(&th, "connect-witness", w.clone());
ok(&format!("datasheet witness: {} agree, {} disagree, {} not visible, over {} pin(s) of {} chip(s)", w["agree"], w["disagree"], w["notVisible"], w["pins"], w["chips"]), &witness::one_line(&w));
}
"extract" => {
let b = build_board(&f, 2);
let copper = rd(&f.join("copper.json"));
let mut gaps = HashMap::new();
for l in ["F.Cu", "B.Cu"] { if f.join(if l == "F.Cu" { "board/top.jpg" } else { "board/bottom.jpg" }).is_file() { gaps.insert(l.to_string(), gap_for(&f, l)); } }
let ex = connect::extract(&b.pads, &copper, &gaps, &b.grid);
// merge: measured nets replace earlier measured ones; the AI's nets stay; a pin claimed by both is reported
let old = items::read_json(&f.join("nets.json")).unwrap_or(json!({"nets": []}));
let mut keep: Vec<Value> = old["nets"].as_array().into_iter().flatten().filter(|n| n["source"] != json!("measured")).cloned().collect();
let mut measured: Vec<Value> = ex["nets"].as_array().cloned().unwrap_or_default();
for m in measured.iter_mut() { m["source"] = json!("measured"); }
let n_measured = measured.len();
let mpins: HashMap<String, String> = measured.iter().flat_map(|n| n["pins"].as_array().unwrap().iter().map(move |p| (p.as_str().unwrap().to_string(), n["name"].as_str().unwrap().to_string()))).collect();
let mut clashes = vec![];
for k in keep.iter_mut() {
let name = k["name"].as_str().unwrap_or("").to_string();
let mut rest = vec![];
for pn in k["pins"].as_array().cloned().unwrap_or_default() { let s = pn.as_str().unwrap_or("").to_string(); match mpins.get(&s) { Some(m) if *m != name => clashes.push(format!("{s}: your net {name}, measured {m}")), Some(_) => {}, None => rest.push(pn) } }
k["pins"] = json!(rest);
}
keep.retain(|k| !k["pins"].as_array().map(|a| a.is_empty()).unwrap_or(true));
// a net of yours with a measured net's name joins it: the measured pins stay listed as
// measuredPins, and the net takes your (weaker) tag and tests for the pins added
let mut rest = vec![];
for k in keep {
let name = k["name"].as_str().unwrap_or("").to_string();
if let Some(m) = measured.iter_mut().find(|m| m["name"].as_str() == Some(name.as_str())) {
if m["measuredPins"].is_null() { m["measuredPins"] = m["pins"].clone(); }
let mut pins = m["pins"].as_array().cloned().unwrap_or_default();
for pn in k["pins"].as_array().cloned().unwrap_or_default() { if !pins.contains(&pn) { pins.push(pn); } }
m["pins"] = json!(pins);
for key in ["tag", "tests", "source", "why"] { if !k[key].is_null() { m[key] = k[key].clone(); } }
} else { rest.push(k); }
}
measured.extend(rest);
let pin_net: HashMap<String, String> = measured.iter().flat_map(|n| n["pins"].as_array().unwrap().iter().map(move |p| (p.as_str().unwrap().to_string(), n["name"].as_str().unwrap().to_string()))).collect();
let planes = connect::plane_evidence(copper["vias"].as_array().map(|v| v.as_slice()).unwrap_or(&[]), &b.pads, &pin_net, 1.2);
wr(&f.join("nets.json"), &json!({"nets": measured}));
wr(&f.join("detect/connectivity.json"), &json!({"measurements": ex["measurements"], "conflicts": ex["conflicts"], "clashes": clashes, "planes": planes}));
if f.join("board/bottom.jpg").is_file() {
let mut o = img(&f.join("board/bottom.jpg"));
for m in ex["measurements"].as_array().into_iter().flatten().filter(|m| m["layer"] == json!("B.Cu")) {
let it = m["item"].as_str().unwrap_or("");
if let Some(pd) = b.pads.iter().find(|pd| format!("{}.{}", pd.part, pd.num) == it) {
draw::circle(&mut o, board::to_px(pd.x, pd.y), (pd.w.max(pd.h) / 2.0 + 0.4) * board::RES, match m["connection"].as_str().unwrap() { "isolated" => [255, 60, 60], "thermal" => [255, 200, 0], _ => [0, 255, 120] }, 3);
}
}
save(&o, &f.join("evidence/connectivity-bottom.jpg"));
}
p.log(&th, "connect-extract", json!({"nets": measured.len(), "measured": n_measured, "clashes": clashes.len()}));
let mut h = vec![format!("plane evidence: {}", planes["byNet"].as_array().unwrap().iter().map(|x| format!("{} inner vias beside {}", x["vias"], x["net"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))];
for c in clashes.iter().take(10) { h.push(format!("clash: {c}")); }
if !ex["conflicts"].as_array().unwrap().is_empty() { h.push(format!("{} measured group(s) carry two net names: see detect/connectivity.json conflicts", ex["conflicts"].as_array().unwrap().len())); }
h.push("Add the connections nobody can see (DATASHEET or INFERRED with a test), then `connect route --layers 2,4`.".into());
ok(&format!("nets.json: {} nets, {n_measured} measured from the photos; measurements in detect/connectivity.json, evidence/connectivity-bottom.jpg", measured.len()), &h);
}
"propose" => {
// the proposal again, from the candidates already routed
let mut l = rd(&f.join("layers.json"));
let mut cands: Vec<Value> = l["candidates"].as_array().cloned().unwrap_or_default();
for c in cands.iter_mut() { if let Ok(r) = items::read_json(&f.join(format!("routes/L{}/routed.json", c["layers"]))) { c["failed"] = r["failed"].clone(); } }
let closed_at = cands.iter().find(|c| c["open"] == json!(0) && c["kicadOpen"] == json!(0) && c["drcErrors"] == json!(0)).and_then(|c| c["layers"].as_u64()).map(|n| n as usize);
write_proposal(&mut l, &cands, closed_at);
wr(&f.join("layers.json"), &l);
p.log(&th, "connect-propose", json!({"proposed": l["proposed"]}));
ok(&match l["proposed"].as_u64() { Some(n) => format!("proposed {n} layers"), None => "no layer count proposed: too few known connections".to_string() }, &[l["why"].as_str().unwrap_or("").to_string()]);
}
"route" => {
// layer counts: the floor the evidence allows, then two more at a time. A stackup that cannot
// close its connections, or that closes them and fails the DRC, is a reason to jump.
let rl = rules_of(&p.state);
let copper = rd(&f.join("copper.json"));
let floor = if checks::inner_vias(&copper) > 0 { 4 } else { 2 };
let counts: Vec<usize> = match layers.as_deref() { Some(l) => l.split(',').filter_map(|x| x.trim().parse().ok()).collect(), None => (floor..=*max_layers).step_by(2).collect() };
let mut named: Vec<(String, String)> = vec![];
if let Ok(l) = items::read_json(&f.join("layers.json")) { for pl in l["planes"].as_array().into_iter().flatten() { named.push((pl["layer"].as_str().unwrap_or("").to_string(), pl["net"].as_str().unwrap_or("").to_string())); } }
let plane_nets: Vec<String> = { let mut v: Vec<(String, String)> = named.clone(); v.sort(); v.dedup_by(|a, b| a.1 == b.1); v.into_iter().map(|x| x.1).collect() };
let mut cands: Vec<Value> = vec![];
let mut closed_at: Option<usize> = None;
let mut last_why = String::new();
for n in counts {
let t = std::time::Instant::now();
let c = route_candidate(&f, n, &plane_nets, &rl, *seconds, *stall, &mut |m| println!(" {m}"));
println!(" {} layers: {} open, {} new DRC errors, {} missing connections per KiCad, {:.0} mm of track, {} vias, {:.0} mm crossing seen copper, {} seen tracks displaced, {:.0} s", n, c["open"], c["drcErrors"], c["kicadOpen"], c["trackMm"].as_f64().unwrap_or(0.0), c["vias"], c["seenCrossingMm"].as_f64().unwrap_or(0.0), c["seenTracksDisplaced"], t.elapsed().as_secs_f64());
p.log(&th, "connect-route-layer", json!({"layers": n, "open": c["open"], "drcErrors": c["drcErrors"], "kicadOpen": c["kicadOpen"], "vias": c["vias"], "trackMm": c["trackMm"], "seenCrossingMm": c["seenCrossingMm"]}));
for h in views::record(&f, &p.state, "connect", &format!("{n}-layer route: {} open, {} new DRC errors", c["open"], c["drcErrors"]), &th, Some(&f.join(format!("routes/L{n}/board.kicad_pcb")))) { println!(" {h}"); }
let closes = c["open"] == json!(0) && c["kicadOpen"] == json!(0) && c["drcErrors"] == json!(0);
last_why = format!("{n} layers: {} open, {} new DRC errors, {} missing per KiCad ({})", c["open"], c["drcErrors"], c["kicadOpen"], c["stop"].as_str().unwrap_or(""));
if let Some(e) = c["drcError"].as_str() { println!(" {n} layers: KiCad's DRC did not run ({e}): new errors and missing connections are unknown for this stackup, not zero"); }
cands.push(c);
if closes { closed_at = Some(n); break; }
// a clean route that leaves exactly what the last count left: more layers cannot change it
if cands.len() >= 2 { let (a, b) = (&cands[cands.len() - 2], &cands[cands.len() - 1]);
if a["open"] == b["open"] && a["trackMm"] == b["trackMm"] && a["vias"] == b["vias"] && cands.len() >= 3 && cands[cands.len() - 3]["open"] == b["open"] { println!(" stopping: three stackups in a row leave the same {} open with the same track: more layers change nothing", b["open"]); break; } }
println!(" jumping: {last_why}");
}
let mut l = items::read_json(&f.join("layers.json")).unwrap_or(json!({}));
// routing is an optional later stage: a count stated from outside evidence stays, and routing only adds what it found
let stated = l["method"] == json!("stated") && l["proposed"].as_u64().is_some();
if stated {
let mut said = json!({}); write_proposal(&mut said, &cands, closed_at);
l["candidates"] = said["candidates"].clone(); l["trail"] = said["trail"].clone(); l["routingSaid"] = said["why"].clone();
l["routing"] = json!({"ran": true, "note": "an optional `connect route`: one way to make the hidden connections, not what is inside the board. The count above is the stated one"});
} else { write_proposal(&mut l, &cands, closed_at); l["method"] = json!("routed"); }
let pick = l["proposed"].as_u64();
let trail: Vec<String> = l["trail"].as_array().into_iter().flatten().filter_map(|x| x.as_str().map(str::to_string)).collect();
l["escalation"] = json!(match closed_at { Some(n) => format!("{n} layers closed every connection and passed the gate"), None => format!("not closed; last: {last_why}") });
wr(&f.join("layers.json"), &l);
p.log(&th, "connect-route", json!({"proposed": pick, "closed": closed_at, "trail": trail}));
let mut h = trail.clone();
h.push(match closed_at { Some(n) => format!("proposed {n} layers"), None => match pick { Some(n) => format!("nothing closed: proposed {n} with the rest listed in openNote"), None => "no layer count proposed: see why in layers.json".to_string() } });
h.push("`build --layers <proposed>` writes the routed copy; then `check nets`, `check layers`.".into());
ok("routing results recorded in layers.json", &h);
}
_ => err("connect extract | connect route [--layers 4,6] [--seconds 480]", &[]),
}
}
Cmd::Review { what, target, max_passes, note } => {
let th = thread(&cli); let mut p = project(&cli); let f = p.flow_dir.clone();
match what.as_deref().unwrap_or("pass") {
"measure" => {
let lines = review_findings(&f);
p.log(&th, "review-measure", json!({"findings": lines.len()}));
if lines.is_empty() { ok("every measurement agrees with the files", &["Run the pass: `review pass`".into()]) } else { ok(&format!("{} finding(s)", lines.len()), &lines) }
}
"pass" => {
// one rule for the review and for every check: not while an earlier step is not done or stale. A reader fault that
// `check copper` recorded as NOT READ is a step done with its failure carried, so the review runs and counts it
let behind = steps_behind(&fl, &f, "review");
if !behind.is_empty() { err(&format!("review pass: earlier step(s) not done or stale: {}", behind.join(", ")), &step_hint(&fl, &f)); }
if let Some(t) = target {
if !(*t > 0.0 && *t <= 100.0) { err("--target must be above 0 and at most 100", &[]); }
p.state["confidenceTarget"] = json!(t); p.save().ok();
}
let t = confidence::target_of(&p.state);
let path = f.join("review.json"); let mut doc = items::read_json(&path).unwrap_or(json!({"passes": []}));
let prev = doc["passes"].as_array().cloned().unwrap_or_default();
let max = max_passes.unwrap_or(confidence::DEFAULT_MAX_PASSES);
let findings = review_findings(&f);
// the sheets this pass needs are drawn here: nobody chooses what gets checked or when
let mut drawn = vec![];
for c in confidence::score_all(&f, t).cats.iter().filter(|c| c.held && c.audit.status == "none") {
match confidence::draw_sample(&f, &c.category, None, &th, t) {
Ok(d) => { p.log(&th, "audit-draw", json!({"category": c.category, "seed": d["seed"], "items": d["items"].as_array().map(|a| a.len())})); drawn.push(format!("{}: {} crops to judge in reverse-flow/audits/{}/sheet.md, sample {}", c.category, d["items"].as_array().map(|a| a.len()).unwrap_or(0), c.category, d["seed"].as_str().unwrap_or(""))); }
Err(e) => drawn.push(format!("{}: could not draw: {e}", c.category)),
}
}
let s = confidence::score_all(&f, t);
let verdict = confidence::verdict_for(&prev, &s, max);
let k = prev.len() + 1;
doc["passes"].as_array_mut().unwrap().push(json!({"n": k, "at": ledger::now(), "aiThread": th, "note": note, "target": t, "maxPasses": max, "verdict": verdict, "findings": findings.len(), "fullPass": s.full, "hashes": checks::current_hashes(&f), "scores": s.json()}));
wr(&path, &doc);
wr(&f.join("confidence-scores.json"), &json!({"derived": "written by the program for reading; nothing reads it back", "at": ledger::now(), "verdict": verdict, "scores": s.json()}));
p.log(&th, "review", json!({"pass": k, "verdict": verdict, "photo": s.photo, "bench": s.bench, "overall": s.overall, "target": t, "findings": findings.len(), "scores": s.cats.iter().map(|c| (c.category.clone(), json!(c.score))).collect::<serde_json::Map<_, _>>()}));
reach(&mut p, &fl, "review");
let _ = write!(std::io::stdout(), "{}", confidence::table(&s));
let mut h = views::record(&f, &p.state, "review", &format!("review pass {k}: {verdict}, photo score {:.1}, bench score {:.1}", s.photo, s.bench), &th, None);
h.push(format!("{} finding(s) from the re-measure; `review measure` lists them", findings.len()));
h.extend(drawn);
match verdict {
"TARGET MET" => h.push("the photo score reached the target: run `check review`".into()),
"STALLED" => { h.push("a full pass raised no photo figure. Each category whose photo figure is below the target:".into()); h.extend(confidence::stall_lines(&s)); h.push("run `check review` to record the stall, or do one of the things above and pass again".into()); }
"STOPPED" => { h.push(format!("the limit of {max} passes was reached")); h.extend(confidence::stall_lines(&s)); }
_ => { h.extend(s.below().iter().filter_map(|c| c.photo.as_ref().map(|p| format!("{} photo figure {:.1}: {}", c.category, p.score, p.raise)))); h.push("do those, then `review pass` again".into()); }
}
// the bench score beside it: reported, never waited for
h.push(format!("bench score {:.1} (outside the target). What would raise it:", s.bench));
h.extend(confidence::bench_lines(&s));
h.extend(auto::now_lines(&f, &fl));
ok(&format!("review pass {k}: {}", confidence::overall_line(&s, verdict)), &h);
}
"record" => err("`review record` is gone: levels are no longer typed", &["Run `review pass`: the program computes each score from measurements and audits (docs/method.md).".into()]),
_ => err("review [pass] [--target 90] [--max-passes 20] [--note ...] | review measure", &[]),
}
}
Cmd::Audit { what, category, n, sample, item, verdict, who, note, evidence, kind, file } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
let t = confidence::target_of(&p.state);
let cat = || category.clone().unwrap_or_else(|| err("--category is needed", &[format!("one of {}", confidence::CATEGORIES.join(", "))]));
match what.as_str() {
"draw" => {
let c = cat();
let d = confidence::draw_sample(&f, &c, *n, &th, t).unwrap_or_else(|e| err(&e, &[]));
p.log(&th, "audit-draw", json!({"category": c, "seed": d["seed"], "draw": d["draw"], "counts": d["counts"], "items": d["items"].as_array().map(|a| a.len())}));
let mut hints = vec![format!("Claims in reverse-flow/audits/{c}/sheet.md, crops in crops/, contact sheets sheet-*.jpg. An auditor who did not produce this run judges every one."), "A sample counts only when complete. cannot_tell counts against the score; some claims are planted and false.".into()];
// planted copper claims go only where the photo is plain: say when that leaves one way round with none
if c == "copper" {
let by = rd(&f.join("audits/copper/exclusions.json"))["plantedClaims"]["eligibleByWhatTheClaimStates"].clone();
for (stated, other) in [("bare", "copper"), ("copper", "bare board")] { if by[stated].as_u64() == Some(0) { hints.push(format!("No point of this board is plain enough to plant a false `{stated}` claim on, so every planted claim states {other}: an auditor who calls every `{stated}` claim ok is not caught by this sample. audits/copper/exclusions.json says what refused the points.")); } }
}
ok(&format!("{c}: sample {} (draw {}): {} items, {} per stratum", d["seed"].as_str().unwrap_or(""), d["draw"], d["items"].as_array().map(|a| a.len()).unwrap_or(0), d["counts"]), &hints);
}
"show" => {
let c = cat();
let s = std::fs::read_to_string(f.join(format!("audits/{c}/sheet.md"))).unwrap_or_else(|_| err(&format!("{c}: no sample: `audit draw --category {c}`"), &[]));
let _ = write!(std::io::stdout(), "{s}");
}
"status" => {
let s = confidence::score_all(&f, t);
for c in &s.cats { let _ = writeln!(std::io::stdout(), "{:<13} {:>5.1} audit {}: {}", c.category, c.score, c.audit.status, c.audit.detail); }
}
"verdict" => {
let c = cat();
let w = who.clone().unwrap_or_else(|| th.clone());
let seed = sample.clone().unwrap_or_else(|| err("--sample <seed> is needed: the seed on the sheet you are judging", &[]));
let mut list: Vec<confidence::Answer> = vec![];
if let Some(path) = file {
for v in rd(Path::new(path)).as_array().cloned().unwrap_or_default() { list.push(confidence::Answer { a: v["item"].as_str().unwrap_or("").into(), verdict: v["verdict"].as_str().unwrap_or("").into(), note: v["note"].as_str().unwrap_or("").into() }); }
} else {
let (Some(i), Some(v)) = (item, verdict) else { err("audit verdict needs --item and --verdict, or --file", &[]) };
list.push(confidence::Answer { a: i.clone(), verdict: v.clone(), note: note.clone().unwrap_or_default() });
}
let total = list.len();
let refused = confidence::record_verdicts(&f, &c, &seed, &list, &w, evidence.as_deref(), kind.as_deref()).unwrap_or_else(|e| err(&e, &[]));
p.log(&th, "audit-verdict", json!({"category": c, "sample": seed, "who": w, "given": total, "refused": refused.len()}));
if !refused.is_empty() { err(&format!("{} of {total} verdict(s) refused", refused.len()), &refused); }
let s = confidence::score_all(&f, t);
let cs = s.cats.iter().find(|x| x.category == c).unwrap();
ok(&format!("{total} verdict(s) recorded for {c}"), &[format!("{c} now {:.1}: audit {}: {}", cs.score, cs.audit.status, cs.audit.detail)]);
}
"preview" => {
let out = file.clone().unwrap_or_else(|| err("audit preview needs --file <folder>", &[]));
let k = confidence::copper_preview(&f, n.unwrap_or(24), Path::new(&out)).unwrap_or_else(|e| err(&e, &[]));
ok(&format!("{k} copper crops for checking legibility in {out}, outside the audit"), &["key.json holds their claims: read it only after judging. Nothing here counts as an audit.".into()]);
}
_ => err("audit draw | show | status | verdict | preview", &[]),
}
}
Cmd::Confidence => {
let p = project(&cli); let (table, v) = report::stats(&p.flow_dir);
wr(&p.path("confidence-stats.json"), &v);
let (s, verdict) = report::current(&p.flow_dir);
wr(&p.path("confidence-scores.json"), &json!({"derived": "written by the program for reading; nothing reads it back", "at": ledger::now(), "verdict": verdict, "scores": s.json()}));
let _ = write!(std::io::stdout(), "{}\n{}\nEvidence tags (what the items claim, not what was checked):\n{table}", confidence::table(&s), confidence::inputs_text(&s));
let mut h = vec!["confidence-scores.json written; docs/method.md states every formula and which score each ceiling belongs to".to_string()];
h.push(format!("bench score {:.1} (outside the target). What would raise it:", s.bench));
h.extend(confidence::bench_lines(&s));
ok(&confidence::overall_line(&s, &verdict), &h);
}
Cmd::Tests { what } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
if what != "generate" { err("tests generate", &[]); }
let all = items::all(&f);
let mut tests: Vec<Value> = items::read_json(&f.join("tests.json")).ok().and_then(|t| t["tests"].as_array().cloned()).unwrap_or_default();
// a generated test that settles only items that no longer exist (the copper was traced again, a part was renamed) and that no item names
// is dropped, with a count: `check tests` refuses a test that settles nothing known, and nobody wrote this one by hand
let (ids, named): (HashSet<String>, HashSet<String>) = (all.iter().map(|i| i.id.clone()).collect(), all.iter().flat_map(|i| i.tests.iter().cloned()).collect());
let is_item = |x: &str| ids.contains(x) || x.starts_with("face:") || x.rsplit_once('.').map(|(a, _)| ids.contains(a)).unwrap_or(false);
let before = tests.len();
tests.retain(|t| t["generated"] != json!(true) || named.contains(t["id"].as_str().unwrap_or("")) || t["settles"].as_array().into_iter().flatten().filter_map(|x| x.as_str()).any(&is_item));
let dropped = before - tests.len();
for t in tests.iter_mut().filter(|t| t["generated"] == json!(true)) { if let Some(st) = t["settles"].as_array() { let keep: Vec<Value> = st.iter().filter(|x| x.as_str().map(&is_item).unwrap_or(false)).cloned().collect(); if !keep.is_empty() && keep.len() < st.len() { t["settles"] = json!(keep); } } }
let mut added = 0;
// a datasheet disagreement brings its own test, with the pins to probe; one whose finding cleared goes
let raised = witness::tests(&f);
tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-witness-") || raised.iter().any(|r| r["id"] == t["id"]));
for t in raised { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }
// an ERC finding from the copper as read brings a continuity test; it goes when the finding clears
let erc = ercfind::tests(&f);
tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-erc-") || erc.iter().any(|r| r["id"] == t["id"]));
for t in erc { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }
// a face whose copper was not read brings its own test; it goes when the face is read
let unread = notread::tests(&f);
tests.retain(|t| !t["id"].as_str().unwrap_or("").starts_with("T-copper-not-read-") || unread.iter().any(|r| r["id"] == t["id"]));
for t in unread { if !tests.iter().any(|x| x["id"] == t["id"]) { tests.push(t); added += 1; } }
for it in all.iter().filter(|i| i.open() || !i.tests.is_empty()) {
for tid in if it.tests.is_empty() { vec![format!("T-{}", it.id.replace(':', "-"))] } else { it.tests.clone() } {
if tests.iter().any(|t| t["id"] == json!(tid)) { continue; }
let (method, equip, expected, ifnot) = match it.kind.as_str() {
"parts" if !it.open() => ("Find pin 1 or the polarity: the cathode band or pin-1 mark under magnification, or a diode-mode reading across the part in circuit", "loupe or microscope, DMM in diode mode", "the orientation recorded in parts.json (pin1)", "turn the part in parts.json and rebuild; check every net on its pins"),
"parts" => ("Read the marking under raking light and magnification; if still unreadable, measure in circuit (resistance, diode test) or identify by pinout continuity", "lamp, loupe or microscope, DMM", "the marking or the measured value", "update the part's value, MPN and footprint in parts.json and rebuild"),
"nets" => ("Continuity from each pin of this net to the others, board unpowered", "DMM in continuity mode, fine probes", "every listed pin beeps to the others, and to no pin of another net", "move the pin to the net that beeps, update nets.json and rebuild"),
"planes" | "layers" => ("Count copper layers on the board edge under a microscope (or X-ray); resistance from a plane via to a pin of the named net", "microscope or X-ray, DMM", "the proposed copper layer count, and continuity to the plane's net", "re-propose the stackup and planes in layers.json"),
"vias" => ("Continuity from the via (probe its pad or scrape the tenting at its edge) to the pins of its net", "DMM, sharp probe", "continuity to the stated net", "correct the via's net or kind in copper.json"),
_ => ("Inspect against the photos and measure on the board", "DMM, microscope", "matches the file", "correct the item and rebuild"),
};
tests.push(json!({"id": tid, "settles": [it.id], "method": method, "equipment": equip, "expected": expected, "ifNot": ifnot, "generated": true})); added += 1;
}
}
wr(&f.join("tests.json"), &json!({"tests": tests}));
p.log(&th, "tests-generate", json!({"added": added, "dropped": dropped}));
let mut h = vec!["Edit the expected results the generator could not know (rail voltages from the divider, marking candidates), then `check tests`.".to_string()];
if dropped > 0 { h.push(format!("{dropped} generated test(s) dropped: every item they settled is gone from the artifacts")); }
ok(&format!("tests.json: {added} test(s) added, {} in all", tests.len()), &h);
}
Cmd::Report { draft } => {
let th = thread(&cli); let mut p = project(&cli);
// the schematic's sheets, each with a picture under reverse-flow/schematic/, so the report shows them without KiCad
let md = report::tables(&p.flow_dir, &fl) + &pinnets::report_section(&p.flow_dir) + &schcheck::report_section(&p.flow_dir, &p.state);
std::fs::write(p.path("report-tables.md"), &md).unwrap_or_else(|e| err(&e.to_string(), &[]));
let (s, verdict) = report::current(&p.flow_dir);
let line = confidence::overall_line(&s, &verdict);
// a starting report, only when there is none: the program's tables with the computed line, for the AI to write around
if *draft && !p.path("report.md").is_file() {
std::fs::write(p.path("report.md"), report::draft(&p.flow_dir, &line)).unwrap_or_else(|e| err(&e.to_string(), &[]));
p.log(&th, "report-draft", json!({}));
}
let hand = std::fs::read_to_string(p.path("report.md")).ok();
let Some(text) = hand else { ok("report-tables.md written", &[format!("Write report.md around the tables, with this line word for word: {line}"), "`report --draft` writes a starting report.md from the tables with that line in it. Then run `report` again.".into()]); return };
if !text.contains(&line) { err("report.md does not carry the computed confidence", &[format!("It must say, word for word: {line}")]); }
// what the run carries as failed, and a board the photos cut off, must be in the report in plain words
let owed: Vec<String> = notread::faces(&p.flow_dir).iter().map(notread::required).filter(|l| !text.contains(l.as_str())).collect();
if !owed.is_empty() { err("report.md does not say which copper was not read", &owed.iter().map(|l| format!("It must say, word for word: {l}")).collect::<Vec<_>>()); }
if !window::cut(&p.flow_dir).is_empty() && !text.contains("photographed window") { err("report.md does not say that the photos cut the board off", &[format!("Say it where the size is stated, for example: {}", window::size_sentence(&p.flow_dir).unwrap_or_default())]); }
if let Some(l) = confidence::false_claim(&text, &s) { err("report.md claims more confidence than the run earned", &[format!("This line: {l}"), format!("The real numbers: {line}")]); }
let mut files: Vec<String> = checks::REVIEWED.iter().chain(["review.json", "tests.json", "report.md"].iter()).filter(|f| p.path(f).is_file()).map(|f| f.to_string()).collect();
files.extend(confidence::audit_files(&p.flow_dir));
files.push("report-tables.md".into());
checks::write_ok(&p.flow_dir, "report", &checks::Pass { summary: json!({"confidence": line}), hints: vec![], files }).unwrap_or_else(|e| err(&e.to_string(), &[]));
p.log(&th, "report", json!({})); reach(&mut p, &fl, "report");
let mut h = views::record(&p.flow_dir, &p.state, "report", "report built", &th, None); h.extend(step_hint(&fl, &p.flow_dir));
match netdraw::run(&p.flow_dir, None) { Ok(s) => { h.push(netdraw::one_line(&s)); h.extend(netview::record(&p.flow_dir, "report", &th)); } Err(e) => h.push(format!("the nets pictures were not drawn: {e}")) }
ok("report-tables.md written", &h);
}
Cmd::Datasheet { what } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
match what.as_str() {
"status" => { let (line, h) = dspins::status_lines(&f); ok(&line, &h); }
"apply" => {
let s = dspins::apply(&f).unwrap_or_else(|e| err("datasheet apply: a pins file does not pass, and nothing was written", &e));
p.log(&th, "datasheet-apply", s.clone());
let mut h = vec![];
for d in s["declined"].as_array().into_iter().flatten() { h.push(format!("{}: none available: {}", d["part"].as_str().unwrap_or(""), d["reason"].as_str().unwrap_or(""))); }
if s["missing"].as_array().is_some_and(|a| !a.is_empty()) { h.push(format!("still waiting for a pins file or a \"none available\": {} (`datasheet status` says what each wants)", s["missing"])); }
h.extend(step_hint(&fl, &f));
ok(&format!("{} pin(s) of {} part(s) named from datasheets in parts.json `pins`{}", s["pins"], s["sheets"].as_array().map(|a| a.len()).unwrap_or(0), if s["partsJsonChanged"] == json!(true) { "" } else { " (already there: nothing changed)" }), &h);
}
_ => err("datasheet status | apply", &[]),
}
}
Cmd::Auto { max_steps } => {
let th = thread(&cli); let p = project(&cli);
let end = auto::run_all(&p.flow_dir, &p.dir, &fl, &th, *max_steps, &mut |m| println!("{m}"));
println!("{}", end.line);
for h in end.hints.iter().filter(|h| !h.is_empty()) { println!("Hint: {h}"); }
p.log(&th, "auto", json!({"state": end.state, "line": end.line, "ran": end.ran.len()}));
std::process::exit(match end.state { "complete" => 0, "needs-ai" => 2, _ => 1 });
}
Cmd::Bench { what, file } => {
let th = thread(&cli); let p = project(&cli); let f = p.flow_dir.clone();
match what.as_str() {
"plan" => {
let (doc, mut h) = bench::plan(&f).unwrap_or_else(|e| err(&e, &[]));
p.log(&th, "bench-plan", json!({"continuity": doc["continuity"].as_array().map(|a| a.len()), "values": doc["values"].as_array().map(|a| a.len()), "planHash": doc["planHash"]}));
if flow::current(&fl, &f).is_some() { h.push("the run is not complete yet: the plan is made from the files as they stand, and a later change to nets.json makes its probes stale".into()); }
ok(&format!("bench plan written: reverse-flow/bench/plan.md ({} continuity probes, {} part values, {} other measurements)", doc["continuity"].as_array().map(|a| a.len()).unwrap_or(0), doc["values"].as_array().map(|a| a.len()).unwrap_or(0), doc["other"].as_array().map(|a| a.len()).unwrap_or(0)), &h);
}
"ingest" => {
let path = file.clone().unwrap_or_else(|| err("bench ingest needs --file <results.json>", &["docs/bench.md gives the format; reverse-flow/bench/results-template.json is ready to fill in".into()]));
let (before, _) = report::current(&f);
let (sum, mut h) = bench::ingest(&f, Path::new(&path)).unwrap_or_else(|e| err(&e, &["docs/bench.md gives the results file format".into()]));
unrouted::write(&f);
p.log(&th, "bench-ingest", sum.clone());
let (after, verdict) = report::current(&f);
h.push(format!("scores, computed before and after: photo {:.1} -> {:.1}, bench {:.1} -> {:.1}", before.photo, after.photo, before.bench, after.bench));
for (b, a) in before.cats.iter().zip(after.cats.iter()) { if let (Some(x), Some(y)) = (&b.bench, &a.bench) { if (x.score - y.score).abs() >= 0.05 { h.push(format!("{} bench figure {:.1} -> {:.1}: now limited by {}", a.category, x.score, y.score, y.limit)); } } }
h.push(confidence::overall_line(&after, &verdict));
h.push("the artifacts changed, so the checks that read them are stale: `auto` runs them again (`build` refreshes the KiCad copy's evidence classes)".into());
ok(&format!("bench results recorded: {} connection(s) found joined, {} found open, {} value(s), {} layer statement(s), {} other; {} refused", sum["joined"], sum["open"], sum["values"], sum["layers"], sum["tests"], sum["refused"]), &h);
}
_ => err("bench plan | bench ingest --file <results.json>", &[]),
}
}
Cmd::Nets { what, net } => {
let th = thread(&cli); let p = project(&cli);
if what != "draw" { err("nets draw [--net <name>]", &[]); }
let s = netdraw::run(&p.flow_dir, net.as_deref()).unwrap_or_else(|e| err(&e, &["`nets draw` reads what `connect measure` and `connect attribute` wrote: run them first.".into()]));
p.log(&th, "nets-draw", s.clone());
let mut h = netdraw::hints(&s);
if net.is_none() { h.extend(netview::record(&p.flow_dir, "connect", &th)); }
ok(&netdraw::one_line(&s), &h);
}
Cmd::Video { what, no_voice, workers } => {
let th = thread(&cli); let p = project(&cli);
if what != "build" { err("video build [--no-voice] [--workers <n>]", &[]); }
let b = video::build(&p.flow_dir, &p.dir, &p.state, &fl, *no_voice, *workers);
p.log(&th, "video-build", json!({"ok": b.ok, "line": b.line, "call": b.call, "seconds": (b.seconds * 10.0).round() / 10.0, "tool": video::tool_hash()}));
let mut h = b.hints.clone();
if flow::current(&fl, &p.flow_dir).is_some() { h.push("the run is not complete: the video shows the files as they stand and says which steps are not done".into()); }
if b.ok { ok(&format!("video built: {}", b.line), &h); } else { err(&b.line, &h); }
}
Cmd::Kicad { pcb, sch } => {
let th = thread(&cli); let mut p = project(&cli);
if !Path::new(pcb).is_file() { err(&format!("{pcb}: not a file"), &[]); }
p.state["kicad"] = json!({"pcb": abs(Path::new(pcb)), "sch": sch.as_ref().map(|s| abs(Path::new(s)))}); p.save().ok();
p.log(&th, "kicad", p.state["kicad"].clone());
let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone()).unwrap_or("report".into());
ok("the flow window now shows this KiCad project", &views::record(&p.flow_dir, &p.state, &cur, "KiCad project registered", &th, None));
}
Cmd::View { step, reason } => {
let th = thread(&cli); let p = project(&cli);
let st = step.clone().or_else(|| flow::current(&fl, &p.flow_dir).map(|s| s.name.clone())).unwrap_or("report".into());
let h = views::record(&p.flow_dir, &p.state, &st, reason.as_deref().unwrap_or("updated"), &th, None);
p.log(&th, "view", json!({"step": st, "reason": reason}));
ok(&format!("view recorded for {st}"), &h);
}
Cmd::Serve { port } => {
let p = project(&cli); let local = format!("http://127.0.0.1:{port}/");
ok(&format!("flow window on {local}"), &[format!("Open it in a Hydrogen webview: adom-cli hydrogen webview open-or-refresh --name \"Reverse Flow\" --url \"{}\"", std::env::var("VSCODE_PROXY_URI").map(|u| u.replace("{{port}}", &port.to_string())).unwrap_or(local.clone()))]);
server::serve(p.flow_dir.clone(), fl, *port).unwrap_or_else(|e| err(&format!("cannot serve on port {port}: {e}"), &[]));
}
Cmd::Clip { what, name, hwnd } => {
let th = thread(&cli); let mut p = project(&cli);
let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));
let br = bridge::Bridge { target, ai_thread: th.clone() };
match what.as_str() {
"start" => {
let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &[]) };
let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));
p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()}); p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());
ok(&format!("recording {n}"), &[]);
}
"stop" => {
let c = p.state["clip"].clone(); let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));
let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok(); let s = br.record_stop(id, &dir);
p.state["clip"] = Value::Null; p.save().ok(); p.log(&th, "clip-stop", json!({"name": c["name"], "pulled": s.pulled}));
if !s.pulled { err("the clip did not land locally", &[]); }
ok(&format!("clip saved to {}", s.local.unwrap().display()), &[]);
}
_ => err("clip start|stop", &[]),
}
}
}
}
#[cfg(test)]
mod proposal_tests {
use super::*;
#[test]
fn eight_connections_propose_no_layer_count() {
let c = |n: u64, open: u64| json!({"layers": n, "open": open, "connections": 8, "drcErrors": 0, "vias": 1, "trackMm": 5.0, "seenCrossingMm": 0.0});
let mut l = json!({});
write_proposal(&mut l, &[c(2, 1), c(4, 1), c(6, 1)], None);
assert!(l["proposed"].is_null(), "{l}");
assert_eq!(l["tag"], json!("UNKNOWN"));
}
#[test]
fn no_layer_count_when_more_layers_change_nothing() {
let c = |n: u64| json!({"layers": n, "open": 39, "connections": 54, "drcErrors": 0, "vias": 3, "trackMm": 12.0, "seenCrossingMm": 0.0});
let mut l = json!({});
write_proposal(&mut l, &[c(2), c(4), c(6), c(8), c(10)], None);
assert!(l["proposed"].is_null(), "{l}");
let d = |n: u64, open: u64| json!({"layers": n, "open": open, "connections": 253, "drcErrors": 0, "vias": 3, "trackMm": 12.0, "seenCrossingMm": 0.0});
let mut m = json!({});
write_proposal(&mut m, &[d(2, 47), d(4, 43), d(6, 45)], None);
assert!(m["proposed"].as_u64().is_some(), "{m}");
}
}