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
Add the Rust source, flow definition, assets and install scripts
cf9809b
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
//! The flow window's content, and the record of the process. Every time a stage completes or is
//! updated (a check passes, a snapshot is saved, a review pass is recorded, the report is built, or
//! the AI runs `view` after editing the project), the stage's view is generated and appended to
//! views.json: the interactive PCB (adom-2dboard embed) and/or the interactive schematic
//! (adom-schematic embed) of the KiCad project as it stood, or the photos before a project exists.
//! Views are numbered and never overwritten, so views.json plus views/ replay the whole process. An
//! unchanged board or schematic reuses the previous file instead of embedding it again.
//! A view that fails to build is reported as a hint; it never fails the design check.
use crate::{ledger, sha256};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::process::Command;
/// What each stage shows. Stages before a KiCad project exists fall back to the photos.
pub fn kinds_for(step: &str) -> &'static [&'static str] {
match step {
"intake" | "scale" => &["photos"],
"parts" | "copper" | "snapshot" => &["pcb"],
"blocks" => &["sch"],
_ => &["pcb", "sch"],
}
}
pub fn disabled() -> bool { std::env::var("ADOM_AIFLOW_REVERSE_VIEWS").map(|v| v == "off").unwrap_or(false) }
fn sch_hash(sch: &Path, sheet_dir: &Path) -> String {
// the root sheet and every sibling sheet: a child-sheet edit changes the schematic view too
let mut files: Vec<PathBuf> = std::fs::read_dir(sheet_dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_sch").unwrap_or(false)).collect()).unwrap_or_default();
if !files.contains(&sch.to_path_buf()) { files.push(sch.to_path_buf()); }
files.sort();
files.iter().map(|f| sha256(f)).collect::<Vec<_>>().join("")
}
fn previous(doc: &Value, kind: &str, hash: &str) -> Option<String> {
doc["entries"].as_array()?.iter().rev().flat_map(|e| e["views"].as_array().cloned().unwrap_or_default())
.find(|v| v["kind"] == json!(kind) && v["sha"] == json!(hash)).and_then(|v| v["file"].as_str().map(str::to_string))
}
fn embed(tool: &str, args: &[&str]) -> Result<(), String> {
let o = Command::new(tool).args(args).output().map_err(|e| format!("{tool} is not runnable ({e}): adom-wiki pkg install adom/{tool}"))?;
let out = String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr);
if o.status.success() && out.contains("OK") { Ok(()) } else { Err(format!("{tool} embed failed: {}", out.trim().chars().take(300).collect::<String>())) }
}
/// Record the views for `step`. `pcb_override` shows a specific board (the snapshot's copy).
/// Returns the hints to print (what was recorded, or why a view could not be built).
pub fn record(flow: &Path, state: &Value, step: &str, reason: &str, thread: &str, pcb_override: Option<&Path>) -> Vec<String> {
if disabled() { return vec![]; }
let path = flow.join("views.json");
let mut doc = crate::items::read_json(&path).unwrap_or(json!({"entries": []}));
let n = doc["entries"].as_array().map(|a| a.len()).unwrap_or(0) + 1;
std::fs::create_dir_all(flow.join("views")).ok();
let kicad = &state["kicad"];
let pcb = pcb_override.map(Path::to_path_buf).or_else(|| kicad["pcb"].as_str().map(PathBuf::from)).filter(|p| p.is_file());
let sch = kicad["sch"].as_str().map(PathBuf::from).filter(|p| p.is_file());
let mut views = vec![];
let mut hints = vec![];
for kind in kinds_for(step) {
let (k, src) = match *kind {
"pcb" if pcb.is_some() => ("pcb", pcb.clone()),
"sch" if sch.is_some() => ("sch", sch.clone()),
"pcb" | "sch" if pcb.is_some() => ("pcb", pcb.clone()),
_ => ("photos", None),
};
if views.iter().any(|v: &Value| v["kind"] == json!(k)) { continue; }
let Some(src) = src else { views.push(json!({"kind": "photos"})); continue };
let hash = if k == "sch" { sch_hash(&src, src.parent().unwrap_or(Path::new("."))) } else { sha256(&src) };
let file = match previous(&doc, k, &hash) {
Some(f) if flow.join(&f).is_file() => f,
_ => {
let f = format!("views/{n:03}-{step}-{k}.html");
let out = flow.join(&f);
let (o, s) = (out.display().to_string(), src.display().to_string());
let r = if k == "pcb" { embed("adom-2dboard", &["embed", "--file", &s, "--out", &o]) } else {
let dir = src.parent().unwrap_or(Path::new(".")).display().to_string();
embed("adom-schematic", &["embed", "--file", &s, "--sheet-dir", &dir, "--out", &o])
};
if let Err(e) = r { hints.push(format!("flow window: the {k} view was not built: {e}")); continue; }
f
}
};
views.push(json!({"kind": k, "file": file, "source": src.display().to_string(), "sha": hash}));
}
let entry = json!({"n": n, "at": ledger::now(), "step": step, "reason": reason, "aiThread": thread, "views": views});
doc["entries"].as_array_mut().unwrap().push(entry);
if let Err(e) = std::fs::write(&path, serde_json::to_string_pretty(&doc).unwrap() + "\n") { hints.push(format!("flow window: views.json not written: {e}")); return hints; }
let shown: Vec<String> = views.iter().filter_map(|v| v["kind"].as_str().map(str::to_string)).collect();
hints.insert(0, format!("flow window: view {n} recorded for {step} ({})", if shown.is_empty() { "nothing".into() } else { shown.join(" + ") }));
if state["kicad"]["pcb"].is_null() && step != "intake" && step != "scale" {
hints.push("No KiCad project registered yet, so the flow window shows the photos. Register it with `kicad --pcb <board> --sch <root sheet>` as soon as the model writes one.".into());
}
hints
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stage_views() {
assert_eq!(kinds_for("intake"), &["photos"]);
assert_eq!(kinds_for("blocks"), &["sch"]);
assert_eq!(kinds_for("connect"), &["pcb", "sch"]);
}
#[test]
fn photos_without_a_project_and_history_numbering() {
let d = std::env::temp_dir().join(format!("views-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let st = json!({});
let h = record(&d, &st, "parts", "check parts", "t", None);
assert!(h[0].contains("view 1 recorded for parts (photos)"), "{h:?}");
let h = record(&d, &st, "blocks", "check blocks", "t", None);
assert!(h[0].contains("view 2"), "{h:?}");
let doc = crate::items::read_json(&d.join("views.json")).unwrap();
assert_eq!(doc["entries"].as_array().unwrap().len(), 2);
std::fs::remove_dir_all(&d).ok();
}
}