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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
//! `reframe`: move a run that was made on an older board frame onto the frame this build makes, without losing what the AI read.
//! `prep` and `scale` of this build give a different frame (a tight outline, both faces on one outline, the bottom laid through holes), so every
//! position in board millimetres, every ruler read in the straightened top photo's pixels and every candidate id of the old run goes stale.
//! Nothing here is typed again: each reading is carried through the photo it was read on, which did not change.
//! old board mm -> old frame pixels -> photo pixels -> new frame pixels -> new board mm
//! The scale is carried the same way (old straightened top -> photo -> new straightened top), the candidates are detected anew and the old
//! ids are matched to the new ones at their carried positions, and what was derived from the old frame is set aside so `auto` makes it again.
use crate::board;
use crate::geom::{self, P};
use crate::regframe::Quad;
use serde_json::{json, Value};
use std::path::Path;
fn rd(p: &Path) -> Value { std::fs::read_to_string(p).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null) }
fn wr(p: &Path, v: &Value) { if let Some(d) = p.parent() { let _ = std::fs::create_dir_all(d); } let _ = std::fs::write(p, serde_json::to_string_pretty(v).unwrap_or_default() + "\n"); }
fn r3(x: f64) -> f64 { (x * 1000.0).round() / 1000.0 }
fn corners(rec: &Value) -> Option<[P; 4]> { let c = rec["corners"].as_array()?; if c.len() != 4 { return None; } Some([0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)))) }
/// The photo `scale` frames a side with: the sharpest of that side, as `main::primary` picks it.
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| a["sharpness"].as_f64().unwrap_or(0.0).partial_cmp(&b["sharpness"].as_f64().unwrap_or(0.0)).unwrap())
}
pub fn quad_json(q: &Quad) -> Value { json!({"cx": q.cx, "cy": q.cy, "mid": [q.mid.0, q.mid.1], "half": [q.half.0, q.half.1]}) }
fn quad_from(v: &Value) -> Option<Quad> {
let six = |k: &str| -> Option<[f64; 6]> { let a = v[k].as_array()?; if a.len() != 6 { return None; } let mut o = [0.0; 6]; for i in 0..6 { o[i] = a[i].as_f64()?; } Some(o) };
Some(Quad { cx: six("cx")?, cy: six("cy")?, mid: (v["mid"][0].as_f64()?, v["mid"][1].as_f64()?), half: (v["half"][0].as_f64()?, v["half"][1].as_f64()?) })
}
/// One face of one frame: how its photo's pixels and the frame's pixels (40 per mm from the frame's corner) go into each other.
/// `scale` draws a face by taking each frame pixel p to the photo at `to_photo(quad(p))`; `quad` is the registration fit, when one was made and recorded.
pub struct Face { pub to_frame: [f64; 9], pub to_photo: [f64; 9], pub quad: Option<Quad> }
impl Face {
pub fn new(photo_corners: &[P; 4], frame_corners: &[P; 4], quad: Option<Quad>) -> Option<Face> {
let to_frame = geom::homography(photo_corners, frame_corners)?;
Some(Face { to_frame, to_photo: geom::invert(&to_frame)?, quad })
}
/// The photo pixel a frame pixel shows.
pub fn photo_of(&self, p: P) -> P { geom::apply(&self.to_photo, self.quad.as_ref().map_or(p, |q| q.apply(p))) }
/// The frame pixel that shows a photo pixel. The fit is near the identity, so it is undone by stepping toward the target.
pub fn frame_of(&self, x: P) -> P {
let t = geom::apply(&self.to_frame, x);
let Some(q) = &self.quad else { return t };
let mut p = t;
for _ in 0..30 { let e = q.apply(p); p = (p.0 + t.0 - e.0, p.1 + t.1 - e.1); }
p
}
}
/// The two faces of a frame from its prep.json and scale.json. The bottom is None for a run with one face.
/// `old`: a run made before the registration fit was recorded. Its bottom is then known through its corners and shift only.
pub struct Frame { pub top: Face, pub bottom: Option<Face>, pub axis: String, /// the bottom was warped by a fit this file does not hold
pub bottom_fit_lost: bool, /// the bottom is held to the top by a fit good to 0.1 mm on pairs left out of it
pub bottom_held: bool }
pub fn frame(prep: &Value, scale: &Value) -> Option<Frame> {
let (wmm, hmm) = (scale["board_mm"][0].as_f64()?, scale["board_mm"][1].as_f64()?);
let axis = scale["frame"]["bottomFlip"].as_str().unwrap_or("y").to_string();
let top = Face::new(&corners(primary(prep, "top")?)?, &board::frame_corners(wmm, hmm, "top", &axis), None)?;
let al = &scale["alignment"];
let shift = (al["shift_mm"][0].as_f64().unwrap_or(0.0) * board::RES, al["shift_mm"][1].as_f64().unwrap_or(0.0) * board::RES);
let quad = quad_from(&al["registrationQuad"]);
let had_fit = al["registration"].is_object();
let bottom = primary(prep, "bottom").and_then(corners).and_then(|c| Face::new(&c, &board::frame_corners(wmm, hmm, "bottom", &axis).map(|p| (p.0 + shift.0, p.1 + shift.1)), quad.clone()));
let held = (had_fit && al["registration"]["pairs"].as_u64().unwrap_or(0) >= 8 && al["registration"]["leaveOneOutRmsMm"].as_f64().map_or(false, |x| x <= 0.1)) || al["faces"]["registered"] == true;
Some(Frame { top, bottom, axis, bottom_fit_lost: had_fit && quad.is_none(), bottom_held: held })
}
/// A board position of the old frame in the new one, through the photo it was read on, and how far the transform turns things there (degrees, counter-clockwise on screen, KiCad's sense).
/// A bottom-face position goes through the bottom photo. When the old bottom was warped by a fit the old run did not record, it goes through the top photo instead if that
/// fit held the faces together (a point on the bottom then lies where the same point of the top frame lies); otherwise through the bottom's corners alone, and it is flagged.
pub fn carry(old: &Frame, new: &Frame, side: &str, mm: P) -> (P, f64, Option<String>) {
let mut flag = None;
let through_top = |p: P| new.top.frame_of(old.top.photo_of(p));
let go = |p: P, flag: &mut Option<String>| -> P {
if side != "bottom" { return through_top(p); }
match (&old.bottom, &new.bottom) {
(Some(ob), Some(nb)) if !old.bottom_fit_lost => nb.frame_of(ob.photo_of(p)),
(Some(_), Some(_)) if old.bottom_held && new.bottom_held => { *flag = Some("carried through the top photo: the old bottom face was held to the top by a fit the old run did not record".into()); through_top(p) }
(Some(ob), Some(nb)) => { *flag = Some("the old bottom face was warped by a fit that was not recorded and did not hold: carried through the bottom photo's corners alone, check this part against board/bottom.jpg".into()); nb.frame_of(ob.photo_of(p)) }
_ => { *flag = Some("no bottom photo mapping in one of the frames: carried through the top photo, check this part".into()); through_top(p) }
}
};
let px = board::to_px(mm.0, mm.1);
let (a, b) = (go(px, &mut flag), go((px.0 + board::RES, px.1), &mut flag));
let turn = -((b.1 - a.1).atan2(b.0 - a.0)).to_degrees();
(board::to_mm(a.0, a.1), turn, flag)
}
/// The scale of the old straightened top photo carried into the new one: how many new pixels an old pixel becomes, at `at` (old straightened pixels) or, with no place given,
/// as the mean over a 5 x 5 grid of the old picture with its least and greatest. Both pictures are the same photo straightened through different corners.
pub fn scale_factor(old_top: &Value, new_top: &Value, at: &[P]) -> Option<(f64, f64, f64)> {
let rect = |r: &Value| -> Option<[P; 4]> { let (w, h) = (r["rectPx"][0].as_f64()?, r["rectPx"][1].as_f64()?); Some([(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]) };
// the old picture was straightened through the corners fitted then; an old `scale --holes` moved `corners` and kept those as `cornersFitted`
let oc = old_top["cornersFitted"].as_array().filter(|a| a.len() == 4).map(|c| [0, 1, 2, 3].map(|i| (c[i][0].as_f64().unwrap_or(0.0), c[i][1].as_f64().unwrap_or(0.0)))).or(corners(old_top))?;
let to_photo = geom::homography(&rect(old_top)?, &oc)?;
let to_new = geom::homography(&corners(new_top)?, &rect(new_top)?)?;
let f = |p: P| geom::apply(&to_new, geom::apply(&to_photo, p));
let local = |p: P| { let (o, x, y) = (f(p), f((p.0 + 20.0, p.1)), f((p.0, p.1 + 20.0))); (geom::dist(o, x) * geom::dist(o, y)).sqrt() / 20.0 };
let (w, h) = (old_top["rectPx"][0].as_f64()?, old_top["rectPx"][1].as_f64()?);
let pts: Vec<P> = if at.is_empty() { (1..=5).flat_map(|i| (1..=5).map(move |j| (w * i as f64 / 6.0, h * j as f64 / 6.0))).collect() } else { at.to_vec() };
let v: Vec<f64> = pts.iter().map(|p| local(*p)).collect();
Some((v.iter().sum::<f64>() / v.len() as f64, v.iter().cloned().fold(f64::MAX, f64::min), v.iter().cloned().fold(f64::MIN, f64::max)))
}
/// Old candidate ids to new ones: each old candidate, carried to the new frame, takes the nearest new candidate of its face within `within` mm, nearest pairs first, each new one once.
pub fn match_candidates(old: &[(String, String, P)], new: &[(String, String, P)], within: f64) -> std::collections::HashMap<String, String> {
let mut prs: Vec<(f64, usize, usize)> = vec![];
for (i, o) in old.iter().enumerate() { for (j, n) in new.iter().enumerate() { if o.1 == n.1 { let d = geom::dist(o.2, n.2); if d <= within { prs.push((d, i, j)); } } } }
prs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let (mut uo, mut un) = (vec![false; old.len()], vec![false; new.len()]);
let mut out = std::collections::HashMap::new();
for (_, i, j) in prs { if !uo[i] && !un[j] { uo[i] = true; un[j] = true; out.insert(old[i].0.clone(), new[j].0.clone()); } }
out
}
/// How near an old dismissed candidate's carried place a new, unmatched candidate must be to take its dismissal, mm: the length of an 0603, the commonest thing a fragment is a piece of.
pub const DISMISSED_REACH_MM: f64 = 1.6;
/// How near its carried place an old candidate's new one must be, mm: half the smallest body a candidate is made from (0402, 1.0 mm long).
pub const CANDIDATE_REACH_MM: f64 = 0.5;
/// Run this program on the same project. Err with the tail of what it said when it fails.
fn sub(dir: &Path, th: &str, args: &[String]) -> Result<String, String> {
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let out = std::process::Command::new(exe).arg("--project").arg(dir).arg("--ai-thread").arg(th).args(args).output().map_err(|e| e.to_string())?;
let text = String::from_utf8_lossy(&out.stdout).to_string() + &String::from_utf8_lossy(&out.stderr);
if out.status.success() { Ok(text) } else { Err(format!("`{}` failed: {}", args.join(" "), text.lines().take(6).collect::<Vec<_>>().join(" | "))) }
}
/// Keep `name` as `<stem>.before-reframe.<ext>` unless that copy is already there (a second `reframe` starts from the same old run). Returns the kept copy's path.
fn keep(f: &Path, name: &str) -> std::path::PathBuf {
let p = Path::new(name);
let kept = f.join(p.with_file_name(format!("{}.before-reframe.{}", p.file_stem().unwrap().to_string_lossy(), p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default())));
if !kept.exists() && f.join(name).exists() { let _ = std::fs::copy(f.join(name), &kept); }
kept
}
/// The whole migration. Returns the summary lines, or what stopped it. `dir` is the project folder, `f` its reverse-flow.
pub fn run(dir: &Path, f: &Path, th: &str, stated_flip: Option<&str>) -> Result<Vec<String>, String> {
if let Some(a) = stated_flip { if a != "x" && a != "y" { return Err(format!("--flip {a}: expected x or y")); } }
if !f.join("prep.json").exists() || !f.join("scale.json").exists() { return Err("reframe moves a run that has a frame: this one has no prep.json and scale.json yet, run `prep` and `scale`".into()); }
let (old_prep, old_scale) = (rd(&keep(f, "prep.json")), rd(&keep(f, "scale.json")));
let had_parts = f.join("parts.json").exists();
let old_parts = if had_parts { rd(&keep(f, "parts.json")) } else { Value::Null };
let old_cands = if f.join("detect/parts.json").exists() || f.join("detect/parts.before-reframe.json").exists() { rd(&keep(f, "detect/parts.json")) } else { Value::Null };
let old = frame(&old_prep, &old_scale).ok_or("the old prep.json and scale.json do not give a frame (no corners or no board size)")?;
let mut h: Vec<String> = vec![];
// 1. the photos measured by this build
sub(dir, th, &["prep".into()])?;
let new_prep = rd(&f.join("prep.json"));
// 2. the flip: one this build finds by itself wins; otherwise the old run's, as it was settled then
let (of, nf) = (&old_prep["flip"], &new_prep["flip"]);
let old_axis = old_scale["frame"]["bottomFlip"].as_str().or(of["axis"].as_str()).unwrap_or("y").to_string();
let found = nf["how"].as_str().is_some();
// the old flip counts only if it was settled then: stated, read from through features, or decided by the holes. A default nobody settled is not carried
let old_settled = of["how"].as_str().is_some() || crate::flipdecide::holes_decide(of["matches"].as_u64().unwrap_or(0), of["other"].as_u64().unwrap_or(0));
let two_faces = primary(&new_prep, "bottom").is_some();
let flip_arg: Option<String> = if !two_faces { None } else if let Some(a) = stated_flip { Some(a.to_string()) } else if found || !old_settled { None } else { Some(old_axis.clone()) };
if let Some(a) = stated_flip { if found && nf["axis"].as_str() != Some(a) { h.push(format!("FLIP: you stated {a}, but this build finds {} by itself ({}). The stated one is used: look at board/top.jpg against board/bottom.jpg", nf["axis"].as_str().unwrap_or("?"), nf["how"].as_str().unwrap_or(""))); } }
else if found && nf["axis"].as_str() != Some(old_axis.as_str()) { h.push(format!("FLIP CHANGED: the old run laid the bottom turned over {} ({}); this build finds {} by itself ({}), and that is used", old_axis, of["how"].as_str().unwrap_or(if old_settled { "from the holes" } else { "a default nothing settled" }), nf["axis"].as_str().unwrap_or("?"), nf["how"].as_str().unwrap_or(""))); }
let unsettled = two_faces && flip_arg.is_none() && !found;
// 3. the scale, carried through the photo
let method = old_scale["method"].as_str().unwrap_or("").to_string();
let mut args: Vec<String> = vec!["scale".into()];
let carried;
if old_scale["holes"]["spacingMm"].is_array() {
// framed through four mounting holes at a stated spacing: the statement is carried, the holes are measured again
let hs = &old_scale["holes"];
args.extend(["--holes".into(), format!("{}x{}", hs["spacingMm"][0], hs["spacingMm"][1]), "--board".into(), format!("{}x{}", hs["boardMm"][0], hs["boardMm"][1]), "--source".into(), hs["source"].as_str().unwrap_or("carried over by reframe").to_string()]);
carried = json!({"how": "the stated hole spacing and board size, measured again on the photos"});
} else {
let (ot, nt) = (primary(&old_prep, "top").ok_or("no top photo in the old prep.json")?, primary(&new_prep, "top").ok_or("no top photo measured")?);
let k_old = old_scale["pxPerMm"].as_f64().ok_or("the old scale.json has no pxPerMm")?;
// where the scale was read, when the old run says: the ends of its rulers, or the body it was confirmed on
let mut at: Vec<P> = vec![];
for r in old_scale["body"]["rulers"].as_array().into_iter().flatten() { if let Some(e) = r["ends"].as_array().filter(|e| e.len() == 4) { at.push(((e[0].as_f64().unwrap_or(0.0) + e[2].as_f64().unwrap_or(0.0)) / 2.0, (e[1].as_f64().unwrap_or(0.0) + e[3].as_f64().unwrap_or(0.0)) / 2.0)); } }
if let Some(b) = old_scale["body"]["id"].as_str().and_then(|i| i.parse::<usize>().ok()).and_then(|i| ot["bodies"].as_array().and_then(|a| a.get(i))) { at.push((b["x"].as_f64().unwrap_or(0.0), b["y"].as_f64().unwrap_or(0.0))); }
let (fac, lo, hi) = scale_factor(ot, nt, &at).ok_or("the old and new straightened top photos cannot be related (corners missing)")?;
let k_new = r3(k_old * fac);
let how = if at.is_empty() { format!("the mean over the old straightened top photo: where the old rulers lay was not recorded. Across the picture the factor runs from {:.4} to {:.4}, so the carried scale is good to {:.1} percent", lo, hi, (hi - lo) / fac * 50.0) } else { "at the places the old scale was read (its rulers or its body)".to_string() };
carried = json!({"pxPerMm": k_new, "from": {"pxPerMm": k_old, "method": method, "basis": old_scale["body"], "at": old_scale["at"]}, "factor": r3(fac), "factorRange": [r3(lo), r3(hi)], "how": how,
"source": format!("carried over by `reframe` from the scale set before ({}): old straightened top to the photo to the new straightened top", old_scale["body"]["source"].as_str().unwrap_or(&method))});
wr(&f.join("scale.carry.json"), &carried);
args.extend(["--carried".into(), format!("{k_new}"), "--source".into(), carried["source"].as_str().unwrap_or("").to_string()]);
h.push(format!("scale carried: {k_old} px/mm in the old straightened top is {k_new} px/mm in the new one (factor {:.4}; {how})", fac));
}
if let Some(a) = &flip_arg { args.extend(["--flip".into(), a.clone()]); }
sub(dir, th, &args)?;
let mut new_prep = rd(&f.join("prep.json"));
if flip_arg.is_some() && stated_flip.is_none() {
// `scale` wrote the flip as stated now: say where the statement came from
new_prep["flip"]["how"] = json!(format!("carried over by reframe from the run before ({})", of["how"].as_str().unwrap_or("read from the holes then")));
wr(&f.join("prep.json"), &new_prep);
h.push(format!("flip carried: bottom turned over {} as the old run had it ({}); this build could not tell by itself", old_axis, of["how"].as_str().unwrap_or("from the holes")));
}
if unsettled && rd(&f.join("prep.json"))["flip"]["how"].as_str().is_none() {
h.push(format!("FLIP NOT SETTLED: the old run never settled which way the board was turned over (it used {old_axis} by default) and this build cannot tell either. Look at both photos and run `reframe --flip x` or `reframe --flip y` (it starts again from the kept old files); until then `check prep` fails and bottom-face parts sit where flip {} puts them", rd(&f.join("scale.json"))["frame"]["bottomFlip"].as_str().unwrap_or("?")));
}
let mut new_scale = rd(&f.join("scale.json"));
new_scale["carried"] = carried; new_scale["reframedFrom"] = json!({"board_mm": old_scale["board_mm"], "pxPerMm": old_scale["pxPerMm"], "bottomFlip": old_scale["frame"]["bottomFlip"], "kept": ["prep.before-reframe.json", "scale.before-reframe.json"]});
wr(&f.join("scale.json"), &new_scale);
let new = frame(&new_prep, &new_scale).ok_or("the new prep.json and scale.json do not give a frame")?;
h.push(format!("frame: board {} x {} mm before, {} x {} mm now", old_scale["board_mm"][0], old_scale["board_mm"][1], new_scale["board_mm"][0], new_scale["board_mm"][1]));
// 4. the parts, each through the photo of its face
let mut moved: Vec<f64> = vec![]; let mut flagged: Vec<String> = vec![];
let mut parts = old_parts.clone();
let pos = |v: &Value| -> Option<P> { Some((v["pos_mm"][0].as_f64()?, v["pos_mm"][1].as_f64()?)) };
for p in parts["parts"].as_array_mut().into_iter().flatten() {
let Some(at) = pos(p) else { continue };
let side = p["side"].as_str().unwrap_or("top").to_string();
let (to, turn, flag) = carry(&old, &new, &side, at);
let rot0 = p["rot"].as_f64();
p["reframedFrom"] = json!({"pos_mm": [at.0, at.1], "rot": rot0});
if !to.0.is_finite() || !to.1.is_finite() { let why = "the photo mapping of this face cannot be inverted here: position kept as it was, place it again".to_string(); flagged.push(format!("{}: {why}", p["ref"].as_str().unwrap_or("?"))); p["reframeFlag"] = json!(why); continue; }
p["pos_mm"] = json!([r3(to.0), r3(to.1)]);
// a turn under a quarter of a degree is the noise of two outlines: a part read at 0 or 90 stays there
if let Some(r) = rot0 { if turn.abs() >= 0.25 { p["rot"] = json!((((r + turn) % 360.0 + 360.0) % 360.0 * 10.0).round() / 10.0); } }
// the pads are found again in the new pictures by `parts snap`, which `auto` runs when no part carries a snap
if let Some(o) = p.as_object_mut() { if let Some(s) = o.remove("snap") { o.insert("snapBeforeReframe".into(), s); } }
if let Some(why) = flag { flagged.push(format!("{}: {why}", p["ref"].as_str().unwrap_or("?"))); p["reframeFlag"] = json!(why); }
moved.push(geom::dist(at, to));
}
// 5. candidates detected anew, the old ids matched at their carried places
let mut cand_note = String::new();
if old_cands.is_object() {
let bottom = old_cands["candidates"].as_array().into_iter().flatten().any(|c| c["side"] == "bottom");
let _ = std::fs::rename(f.join("detect/parts.json"), f.join("detect/parts.replaced-by-reframe.json"));
let mut a: Vec<String> = vec!["detect".into(), "parts".into()]; if bottom { a.push("--bottom".into()); }
sub(dir, th, &a)?;
let list = |d: &Value, carry_it: bool| -> Vec<(String, String, P)> { d["candidates"].as_array().into_iter().flatten().filter_map(|c| { let side = c["side"].as_str().unwrap_or("top").to_string(); let at = pos(c)?;
Some((c["id"].as_str()?.to_string(), side.clone(), if carry_it { carry(&old, &new, &side, at).0 } else { at })) }).collect() };
let (oc, nc) = (list(&old_cands, true), list(&rd(&f.join("detect/parts.json")), false));
let map = match_candidates(&oc, &nc, CANDIDATE_REACH_MM);
let (mut kept_refs, mut lost_refs, mut kept_dis, mut lost_dis) = (0, 0, 0, 0);
for p in parts["parts"].as_array_mut().into_iter().flatten() { if let Some(c) = p["candidate"].as_str().map(str::to_string) {
p["candidateBeforeReframe"] = json!(c);
match map.get(&c) { Some(n) => { p["candidate"] = json!(n); kept_refs += 1; } None => { if let Some(o) = p.as_object_mut() { o.remove("candidate"); } lost_refs += 1; } }
} }
let dis: Vec<Value> = parts["dismissed"].as_array().cloned().unwrap_or_default().into_iter().filter_map(|d| { let c = d["candidate"].as_str()?.to_string();
match map.get(&c) { Some(n) => { kept_dis += 1; let mut d = d.clone(); d["candidate"] = json!(n); d["candidateBeforeReframe"] = json!(c); Some(d) } None => { lost_dis += 1; None } } }).collect();
if parts["dismissed"].is_array() { parts["dismissed"] = json!(dis); }
if let Some(u) = parts["unassigned"].as_array().cloned() { parts["unassigned"] = json!(u.iter().filter_map(|x| x.as_str().and_then(|c| map.get(c)).map(|n| json!(n))).collect::<Vec<_>>()); }
// a new candidate that is no old one's match but lies within `DISMISSED_REACH_MM` of an old dismissed candidate's carried place is the same thing detected a little
// differently (a fragment of a part, a piece of silk): the old judgement is applied to it, and says so
let used: std::collections::HashSet<String> = map.values().cloned().collect();
let old_dis: Vec<(&(String, String, P), String)> = oc.iter().filter_map(|c| old_parts["dismissed"].as_array()?.iter().find(|d| d["candidate"].as_str() == Some(c.0.as_str())).map(|d| (c, d["why"].as_str().unwrap_or("").to_string()))).collect();
let mut near_dis = 0;
let mut extra: Vec<Value> = vec![];
for c in nc.iter().filter(|c| !used.contains(&c.0)) {
if let Some((o, why)) = old_dis.iter().filter(|(o, _)| o.1 == c.1).min_by(|a, b| geom::dist(a.0 .2, c.2).partial_cmp(&geom::dist(b.0 .2, c.2)).unwrap()) {
let d = geom::dist(o.2, c.2);
if d <= DISMISSED_REACH_MM { near_dis += 1; extra.push(json!({"candidate": c.0, "why": format!("{why} (carried by reframe: {:.2} mm from old candidate {}, dismissed for this)", d, o.0), "candidateBeforeReframe": o.0})); }
}
}
if let Some(a) = parts["dismissed"].as_array_mut() { a.extend(extra); }
let fresh = nc.len() - used.len() - near_dis;
let lost_dis = lost_dis; let kept_dis = kept_dis + near_dis;
cand_note = format!("candidates: {} old, {} detected now, {} matched at their carried places; {} part links kept, {} parts keep their reading without a candidate; {} dismissals carried, {} old dismissals have no candidate now; {} new candidates match nothing old (`parts reconcile` dismisses the ones inside placed parts, the rest are for you)", oc.len(), nc.len(), map.len(), kept_refs, lost_refs, kept_dis, lost_dis, fresh);
}
if had_parts { wr(&f.join("parts.json"), &parts); }
// 6. what was derived from the old frame is set aside, so that `auto` makes it again from the new pictures
let mut aside = vec![];
for name in ["detect/domes.json", "copper.json", crate::photolayers::FILE, "detect/attrib/measure.json"] {
let p = f.join(name);
if p.exists() { let q = Path::new(name); let to = f.join(q.with_file_name(format!("{}.before-reframe.{}", q.file_stem().unwrap().to_string_lossy(), q.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()))); if std::fs::rename(&p, &to).is_ok() { aside.push(name.to_string()); } }
}
// 7. the summary
moved.sort_by(|a, b| a.partial_cmp(b).unwrap());
if had_parts {
let n = moved.len();
h.push(format!("parts: {} carried through the photo of their face, moved {} mm at the median and {} mm at the most; {} flagged", n, if n > 0 { r3(moved[n / 2]) } else { 0.0 }, if n > 0 { r3(moved[n - 1]) } else { 0.0 }, flagged.len()));
for x in flagged.iter().take(12) { h.push(format!("flagged {x}")); }
if flagged.len() > 12 { h.push(format!("and {} more flagged: each carries `reframeFlag` in parts.json", flagged.len() - 12)); }
} else { h.push("no parts.json yet: nothing to carry".into()); }
if !cand_note.is_empty() { h.push(cand_note); }
if !aside.is_empty() { h.push(format!("set aside as *.before-reframe.*, to be made again from the new pictures: {}", aside.join(", "))); }
h.push("kept: prep.before-reframe.json, scale.before-reframe.json, parts.before-reframe.json, detect/parts.before-reframe.json. Blocks, nets, datasheet pin files and footprints are not touched.".into());
h.push("Now run `auto`: it checks the photos, reconciles and snaps the parts in the new pictures, reads the copper again, attributes, builds, and goes on. Look at evidence/overlay-top.jpg after the build.".into());
Ok(h)
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(top_c: [P; 4], bot_c: [P; 4], wh: P) -> (Value, Value) {
let rec = |side: &str, c: [P; 4]| json!({"file": format!("{side}.jpg"), "side": side, "sharpness": 1.0, "rectPx": [geom::dist(c[0], c[1]).round(), geom::dist(c[0], c[3]).round()], "corners": c.iter().map(|q| json!([q.0, q.1])).collect::<Vec<_>>()});
(json!({"photos": [rec("top", top_c), rec("bottom", bot_c)]}), json!({"board_mm": [wh.0, wh.1], "frame": {"bottomFlip": "x"}, "alignment": {"shift_mm": [0.0, 0.0]}}))
}
#[test]
fn a_part_keeps_its_place_in_the_photo_when_the_frame_moves() {
// 20 px/mm photos; the old frame began 2 mm inside the board at the left and 5 mm below its top, the new one at the board's corner
let (op, os) = doc([(140.0, 200.0), (2100.0, 200.0), (2100.0, 1100.0), (140.0, 1100.0)], [(100.0, 200.0), (2060.0, 200.0), (2060.0, 1100.0), (100.0, 1100.0)], (98.0, 45.0));
let (np, ns) = doc([(100.0, 100.0), (2100.0, 100.0), (2100.0, 1100.0), (100.0, 1100.0)], [(100.0, 100.0), (2100.0, 100.0), (2100.0, 1100.0), (100.0, 1100.0)], (100.0, 50.0));
let (old, new) = (frame(&op, &os).unwrap(), frame(&np, &ns).unwrap());
// a top part 10 mm right and 10 mm down of the old frame's corner: photo pixel (340, 400), which is 12 mm and 15 mm from the new corner
let (to, turn, flag) = carry(&old, &new, "top", (110.0, 110.0));
assert!(geom::dist(to, (112.0, 115.0)) < 1e-6 && turn.abs() < 1e-6 && flag.is_none(), "{to:?} {turn}");
// a bottom part: the old bottom frame's x runs from the photo's right corner (2060) leftwards, the new from 2100
let (to, _, flag) = carry(&old, &new, "bottom", (110.0, 110.0));
assert!(geom::dist(to, (112.0, 115.0)) < 1e-6 && flag.is_none(), "{to:?}");
}
#[test]
fn a_sheared_old_picture_turns_the_part_and_changes_the_scale() {
// the old corners were off: its straightened picture is the photo turned by about 1.7 degrees
let (op, os) = doc([(100.0, 100.0), (2100.0, 160.0), (2070.0, 1160.0), (70.0, 1100.0)], [(100.0, 100.0), (2100.0, 100.0), (2100.0, 1100.0), (100.0, 1100.0)], (100.0, 50.0));
let (np, ns) = doc([(100.0, 100.0), (2100.0, 100.0), (2100.0, 1100.0), (100.0, 1100.0)], [(100.0, 100.0), (2100.0, 100.0), (2100.0, 1100.0), (100.0, 1100.0)], (100.0, 50.0));
let (old, new) = (frame(&op, &os).unwrap(), frame(&np, &ns).unwrap());
let (_, turn, _) = carry(&old, &new, "top", (150.0, 125.0));
// the old frame's x axis points 1.7 degrees clockwise on screen in the photo, so a part read at 0 there sits at -1.7 (KiCad counts counter-clockwise)
assert!((turn + 1.72).abs() < 0.1, "{turn}");
let (f, lo, hi) = scale_factor(&op["photos"][0], &np["photos"][0], &[]).unwrap();
assert!((f - 1.0).abs() < 0.01 && lo <= f && f <= hi, "{f} {lo} {hi}");
}
#[test]
fn a_recorded_fit_is_undone_when_a_photo_point_is_put_in_the_frame() {
let prs: Vec<(P, P)> = (0..30).map(|i| { let p = ((i % 6) as f64 * 300.0, (i / 6) as f64 * 200.0); (p, (p.0 + 4.0 + 0.003 * p.0, p.1 - 3.0 + 0.002 * p.1)) }).collect();
let q = Quad::fit(&prs).unwrap();
let back = quad_from(&quad_json(&q)).unwrap();
let c = [(0.0, 0.0), (1500.0, 0.0), (1500.0, 800.0), (0.0, 800.0)];
let face = Face::new(&c, &c, Some(back)).unwrap();
for p in [(100.0, 100.0), (1400.0, 700.0)] { assert!(geom::dist(face.frame_of(face.photo_of(p)), p) < 1e-3); }
}
#[test]
fn old_candidate_ids_find_the_new_ones_at_their_places() {
let old = vec![("P001".to_string(), "top".to_string(), (110.0, 110.0)), ("P002".to_string(), "top".to_string(), (120.0, 110.0)), ("P003".to_string(), "bottom".to_string(), (110.1, 110.0))];
let new = vec![("P001".to_string(), "top".to_string(), (120.1, 110.2)), ("P002".to_string(), "top".to_string(), (110.2, 109.9)), ("P003".to_string(), "top".to_string(), (140.0, 140.0))];
let m = match_candidates(&old, &new, CANDIDATE_REACH_MM);
assert_eq!(m.get("P001").map(String::as_str), Some("P002"));
assert_eq!(m.get("P002").map(String::as_str), Some("P001"));
assert!(m.get("P003").is_none());
}
}