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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797
//! The visible layers, read from the photos instead of library footprints. `layers trace` writes
//! photo-layers.json: silkscreen as exact polygons on both faces, the shape of every exposed pad, exposed metal no
//! footprint explains, every hole, and the outline. `build` draws from it: footprints keep their pad numbers so nets
//! still work, but a pad the photo shows takes the photographed shape, and library silk goes.
use crate::gerber::Frame;
use crate::kicad_out::{self, Pad};
use crate::photomat::{self, Face};
use crate::raster::{self, Mask, Plane};
use crate::sexp::{a, f, l, q, N};
use crate::{board, geom};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::Path;
type P = (f64, f64);
pub const FILE: &str = "photo-layers.json";
/// Closed curves where `f` crosses zero, inside on the screen left, in pixel coordinates (pixel (u, v) has its value at
/// (u + 0.5, v + 0.5)). Outer curves come out with a negative shoelace sum on y-down axes, holes positive.
pub fn contours(fp: &Plane) -> Vec<Vec<P>> {
let (w, h) = (fp.w as i64, fp.h as i64);
let at = |x: i64, y: i64| -> f32 { if x < 0 || y < 0 || x >= w || y >= h { -1.0 } else { fp.d[(y * w + x) as usize] } };
// an edge id: horizontal edge from (x, y) to (x + 1, y), vertical edge from (x, y) to (x, y + 1)
let hid = |x: i64, y: i64| ((x + 1) * (h + 2) + (y + 1)) as u64 * 2;
let vid = |x: i64, y: i64| ((x + 1) * (h + 2) + (y + 1)) as u64 * 2 + 1;
let mut next: HashMap<u64, u64> = HashMap::new();
let mut extra: Vec<(u64, u64)> = vec![];
let mut pos: HashMap<u64, P> = HashMap::new();
let cross = |a: f32, b: f32| -> f64 { let t = a as f64 / (a as f64 - b as f64); t.clamp(0.02, 0.98) };
for y in -1..h { for x in -1..w {
let (fa, fb, fc, fd) = (at(x, y), at(x + 1, y), at(x + 1, y + 1), at(x, y + 1));
let k = (fa > 0.0) as u8 | ((fb > 0.0) as u8) << 1 | ((fc > 0.0) as u8) << 2 | ((fd > 0.0) as u8) << 3;
if k == 0 || k == 15 { continue; }
let (t, r, b, lf) = (hid(x, y), vid(x + 1, y), hid(x, y + 1), vid(x, y));
let (px, py) = (x as f64 + 0.5, y as f64 + 0.5);
let mut put = |id: u64, p: P| { pos.entry(id).or_insert(p); };
if (fa > 0.0) != (fb > 0.0) { put(t, (px + cross(fa, fb), py)); }
if (fb > 0.0) != (fc > 0.0) { put(r, (px + 1.0, py + cross(fb, fc))); }
if (fd > 0.0) != (fc > 0.0) { put(b, (px + cross(fd, fc), py + 1.0)); }
if (fa > 0.0) != (fd > 0.0) { put(lf, (px, py + cross(fa, fd))); }
let centre = (fa + fb + fc + fd) > 0.0;
let segs: &[(u64, u64)] = &match k {
1 => vec![(lf, t)], 2 => vec![(t, r)], 3 => vec![(lf, r)], 4 => vec![(r, b)],
5 => if centre { vec![(r, t), (lf, b)] } else { vec![(lf, t), (r, b)] },
6 => vec![(t, b)], 7 => vec![(lf, b)], 8 => vec![(b, lf)], 9 => vec![(b, t)],
10 => if centre { vec![(t, lf), (b, r)] } else { vec![(t, r), (b, lf)] },
11 => vec![(b, r)], 12 => vec![(r, lf)], 13 => vec![(r, t)], _ => vec![(t, lf)],
};
for (s, e) in segs { if next.insert(*s, *e).is_some() { extra.push((*s, *e)); } }
} }
let mut rings = vec![];
let mut starts: Vec<u64> = next.keys().copied().collect(); starts.sort_unstable();
for s in starts {
if !next.contains_key(&s) { continue; }
let mut ring = vec![]; let mut cur = s;
while let Some(n) = next.remove(&cur) { if let Some(p) = pos.get(&cur) { ring.push(*p); } cur = n; if cur == s { break; } }
if ring.len() >= 3 { rings.push(ring); }
}
rings
}
pub fn shoelace(r: &[P]) -> f64 { (0..r.len()).map(|i| { let (a, b) = (r[i], r[(i + 1) % r.len()]); a.0 * b.1 - b.0 * a.1 }).sum::<f64>() / 2.0 }
fn point_in(r: &[P], p: P) -> bool {
let mut c = false;
for i in 0..r.len() { let (a, b) = (r[i], r[(i + 1) % r.len()]); if (a.1 > p.1) != (b.1 > p.1) && p.0 < a.0 + (p.1 - a.1) / (b.1 - a.1) * (b.0 - a.0) { c = !c; } }
c
}
fn px_mm(p: P) -> P { let m = board::to_mm(p.0, p.1); ((m.0 * 10000.0).round() / 10000.0, (m.1 * 10000.0).round() / 10000.0) }
/// Rings to mm polygons with their holes bridged in, smallest features under `min_area` px dropped.
fn polygons(rings: Vec<Vec<P>>, tol: f64, min_area: f64) -> Vec<Vec<P>> {
let (mut outers, mut holes): (Vec<Vec<P>>, Vec<Vec<P>>) = (vec![], vec![]);
for r in rings { let s = shoelace(&r); if s.abs() < min_area { continue; } if s < 0.0 { outers.push(r); } else { holes.push(r); } }
let mut owned: Vec<Vec<Vec<P>>> = vec![vec![]; outers.len()];
let box_of = |r: &[P]| r.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, p| (b.0.min(p.0), b.1.min(p.1), b.2.max(p.0), b.3.max(p.1)));
let boxes: Vec<_> = outers.iter().map(|r| box_of(r)).collect();
let areas: Vec<f64> = outers.iter().map(|r| shoelace(r).abs()).collect();
for h in holes {
let p = h[0];
let mut best: Option<usize> = None;
for (i, r) in outers.iter().enumerate() { let b = boxes[i]; if p.0 < b.0 || p.0 > b.2 || p.1 < b.1 || p.1 > b.3 { continue; } if point_in(r, p) && best.map(|k| areas[i] < areas[k]).unwrap_or(true) { best = Some(i); } }
if let Some(i) = best { owned[i].push(h); }
}
outers.into_iter().zip(owned).map(|(o, hs)| {
let o = geom::simplify(&o, tol);
let hs: Vec<Vec<P>> = hs.into_iter().map(|h| geom::simplify(&h, tol)).filter(|h| h.len() >= 3).collect();
kicad_out::keyhole(o, hs).into_iter().map(px_mm).collect()
}).filter(|r: &Vec<P>| r.len() >= 3).collect()
}
fn ring_json(r: &[P]) -> Value { json!(r.iter().map(|p| json!([p.0, p.1])).collect::<Vec<_>>()) }
fn ring_of(v: &Value) -> Vec<P> { v.as_array().into_iter().flatten().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0))).collect() }
/// Silk polygons: the luminance half way between the mask and the silk, inside each kept component.
fn silk_polys(fc: &Face) -> Vec<Vec<P>> {
let mut field = Plane::new(fc.w, fc.h, -1.0);
for i in 0..fc.w * fc.h {
let v = fc.y.d[i] - (fc.ym.d[i] + 0.5 * (fc.silk_level - fc.ym.d[i]));
field.d[i] = if fc.cls[i] == photomat::SILK { v.max(1.0) } else { v.min(-1.0) };
}
// A hair-thin saddle should not join two letters: the rings below are what the class map draws, with the edge
// placed by luminance between neighbouring pixels.
polygons(contours(&field), 0.3, 12.0)
}
/// Where the classifier sees metal, with holes filled in so a pad's outline is its outer edge.
fn metal_mask(fc: &Face) -> Mask {
let m = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::GOLD || *c == photomat::SILVER).collect() };
let hole = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::HOLE).collect() };
m.or(&hole).close(1.0)
}
#[derive(Clone, Debug)]
pub struct HoleM { pub x: f64, pub y: f64, pub d: f64, pub faces: u8, pub ringed: bool }
/// Every hole either face shows, in mm, centred by area. Seen on both faces, the mean.
fn holes_of(top: &Face, bot: &Face) -> Vec<HoleM> {
let grab = |fc: &Face| -> Vec<(f64, f64, f64, bool)> {
let m = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::HOLE).collect() };
let metal = Mask { w: fc.w, h: fc.h, d: fc.cls.iter().map(|c| *c == photomat::GOLD || *c == photomat::SILVER).collect() }.dilate(2.0);
let (_, cs) = raster::components(&m);
cs.iter().map(|c| {
let (cx, cy) = (c.cx + 0.5, c.cy + 0.5); let r = (c.area as f64 / std::f64::consts::PI).sqrt();
let on = (0..32).filter(|k| { let t = *k as f64 / 32.0 * std::f64::consts::TAU; let (u, v) = (cx + (r + 3.0) * t.cos(), cy + (r + 3.0) * t.sin()); u >= 0.0 && v >= 0.0 && (u as usize) < fc.w && (v as usize) < fc.h && metal.at(u as usize, v as usize) }).count();
let (x, y) = board::to_mm(cx, cy);
(x, y, 2.0 * r / board::RES, on >= 16)
}).collect()
};
let (ta, tb) = (grab(top), grab(bot));
let mut out = vec![]; let mut used = vec![false; tb.len()];
for t in &ta {
let m = (0..tb.len()).filter(|i| !used[*i]).min_by(|i, j| geom::dist((t.0, t.1), (tb[*i].0, tb[*i].1)).partial_cmp(&geom::dist((t.0, t.1), (tb[*j].0, tb[*j].1))).unwrap());
match m.filter(|i| geom::dist((t.0, t.1), (tb[*i].0, tb[*i].1)) < 0.6) {
Some(i) => { used[i] = true; let b = tb[i]; out.push(HoleM { x: (t.0 + b.0) / 2.0, y: (t.1 + b.1) / 2.0, d: (t.2 + b.2) / 2.0, faces: 3, ringed: t.3 || b.3 }); }
None => out.push(HoleM { x: t.0, y: t.1, d: t.2, faces: 1, ringed: t.3 }),
}
}
for (i, b) in tb.iter().enumerate() { if !used[i] { out.push(HoleM { x: b.0, y: b.1, d: b.2, faces: 2, ringed: b.3 }); } }
out
}
/// One board edge read from a photo with a margin around it: the place wood turns to board, every 5 px, as a line.
/// Under this share of samples agreeing on a line, an edge is not read. The CM5 dark-mat photos with the wood test gave under 0.02 on every edge; read edges on the Driver give over 0.8.
const EDGE_MIN_SHARE: f64 = 0.3;
/// What the table and the board look like in this photo: the median colour of the margin outside the frame and of a band inside the edges.
/// The table can be wood, a dark mat or anything else; each pixel then goes with whichever it is nearer to.
fn side_model(img: &image::RgbImage, m: i64, wf: f64, hf: f64) -> ([f32; 3], [f32; 3]) {
let (w, h) = (img.width() as i64, img.height() as i64);
let (mut out, mut inn): (Vec<[f32; 3]>, Vec<[f32; 3]>) = (vec![], vec![]);
let (x1, y1) = (m + wf as i64, m + hf as i64);
let mut y = 0; while y < h { let mut x = 0; while x < w {
let p = img.get_pixel(x as u32, y as u32); let c = [p[0] as f32, p[1] as f32, p[2] as f32];
let outside = x < m - 12 || y < m - 12 || x > x1 + 12 || y > y1 + 12;
let d_in = (x - m).min(y - m).min(x1 - x).min(y1 - y);
// the warp leaves pure black past the photo's own edge: that is no table
if outside && c.iter().sum::<f32>() > 6.0 { out.push(c); } else if (40..=120).contains(&d_in) { inn.push(c); }
x += 3; } y += 3; }
let med = |v: &mut Vec<[f32; 3]>| -> [f32; 3] { if v.is_empty() { return [0.0; 3]; } let mut r = [0.0; 3]; for k in 0..3 { let mut c: Vec<f32> = v.iter().map(|q| q[k]).collect(); c.sort_by(|a, b| a.partial_cmp(b).unwrap()); r[k] = c[c.len() / 2]; } r };
(med(&mut out), med(&mut inn))
}
/// Mean colour of the 3 x 3 round a point, nearer the board's colour than the table's.
fn board_like(img: &image::RgbImage, x: i64, y: i64, model: &([f32; 3], [f32; 3])) -> Option<bool> {
let (w, h) = (img.width() as i64, img.height() as i64);
if x < 1 || y < 1 || x + 1 >= w || y + 1 >= h { return None; }
let mut c = [0f32; 3]; for dy in -1..=1 { for dx in -1..=1 { let p = img.get_pixel((x + dx) as u32, (y + dy) as u32); for k in 0..3 { c[k] += p[k] as f32 / 9.0; } } }
if c.iter().sum::<f32>() <= 6.0 { return None; }
let d = |q: &[f32; 3]| (0..3).map(|k| (c[k] - q[k]).powi(2)).sum::<f32>();
Some(d(&model.1) < d(&model.0))
}
/// Both tests read each edge, wood (table redder than the board) and the photo's own colour model; the one more samples agree on stands.
/// Where wood reads well, as on the Driver photos, the result is the wood reading.
fn edge_line(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64) -> Option<(f64, f64, usize, usize, f64)> {
let model = side_model(img, m, wf, hf);
let wood = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false);
let own = edge_line_by(img, m, along_x, near, wf, hf, &|x, y| board_like(img, x, y, &model), true);
match (wood, own) { (Some(a), Some(b)) => Some(if a.2 >= b.2 { a } else { b }), (a, b) => a.or(b) }
}
fn edge_line_by(img: &image::RgbImage, m: i64, along_x: bool, near: bool, wf: f64, hf: f64, like: &dyn Fn(i64, i64) -> Option<bool>, need_run: bool) -> Option<(f64, f64, usize, usize, f64)> {
let _ = img;
let span = if along_x { wf } else { hf };
let mut pts = vec![];
let (ends, wall) = (120.0, if along_x { if near { 0.0 } else { hf } } else if near { 0.0 } else { wf });
let mut t = ends;
while t < span - ends {
// walk from outside the board inwards to the first place wood stops
let mut found = None;
let dir = if near { 1.0 } else { -1.0 };
let mut s = -(m as f64) + 4.0;
let mut prev = None;
while s < 40.0 {
let q = wall + dir * s;
let (x, y) = if along_x { (t + m as f64, q + m as f64) } else { (q + m as f64, t + m as f64) };
let now = like(x as i64, y as i64);
// table, then board three pixels running: the edge
if prev == Some(false) && now == Some(true) {
let run = !need_run || (1..=3).all(|k| { let q2 = q + dir * k as f64; let (x2, y2) = if along_x { (t + m as f64, q2 + m as f64) } else { (q2 + m as f64, t + m as f64) }; like(x2 as i64, y2 as i64) == Some(true) });
if run { found = Some(q); break; }
}
prev = now;
s += 1.0;
}
if let Some(q) = found { pts.push((t, q)); }
t += 5.0;
}
let tot = (((span - 2.0 * ends) / 5.0) as usize).max(1);
let (sl, ic) = geom::robust_line(&pts, 2.0)?;
let keep: Vec<P> = pts.iter().filter(|p| (p.1 - (sl * p.0 + ic)).abs() < 3.0).copied().collect();
let bow = bow_of(&keep);
Some((sl, ic, keep.len(), tot, bow))
}
/// How far the middle of an edge stands off the straight line through its ends, in mm: what lens distortion does to a straight edge.
fn bow_of(pts: &[P]) -> f64 {
if pts.len() < 10 { return 0.0; }
let (t0, t1) = (pts[0].0, pts[pts.len() - 1].0);
let third = (t1 - t0) / 3.0;
let mean = |a: f64, b: f64| { let v: Vec<f64> = pts.iter().filter(|p| p.0 >= a && p.0 < b).map(|p| p.1).collect(); v.iter().sum::<f64>() / v.len().max(1) as f64 };
(mean(t0 + third, t1 - third) - (mean(t0, t0 + third) + mean(t1 - third, t1 + 1.0)) / 2.0) * 9.0 / 8.0 / board::RES
}
/// Read the outline from one side's photo, registered to the top frame, with a margin kept.
fn edges_from(flow: &Path, side: &str, q: Option<&crate::regframe::Quad>, prep: &Value, scale: &Value) -> Result<Vec<(String, (f64, f64, usize, usize, f64))>, String> {
let file = prep["primary"][side].as_str().ok_or("no primary photo")?;
edges_of_file(flow, file, side, q, prep, scale).map(|x| x.0)
}
/// The same reading for any photo of a side, with the corner radius seen along each diagonal.
fn edges_of_file(flow: &Path, file: &str, side: &str, q: Option<&crate::regframe::Quad>, prep: &Value, scale: &Value) -> Result<(Vec<(String, (f64, f64, usize, usize, f64))>, Vec<f64>), String> {
let rec = prep["photos"].as_array().into_iter().flatten().find(|p| p["file"].as_str() == Some(file)).ok_or("photo not in prep.json")?;
let c = rec["corners"].as_array().ok_or("no corners")?;
let cn: [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 (wmm, hmm) = (scale["board_mm"][0].as_f64().unwrap_or(0.0), scale["board_mm"][1].as_f64().unwrap_or(0.0));
let axis = scale["frame"]["bottomFlip"].as_str().unwrap_or("y");
let shift = if side == "bottom" { (scale["alignment"]["shift_mm"][0].as_f64().unwrap_or(0.0) * board::RES, scale["alignment"]["shift_mm"][1].as_f64().unwrap_or(0.0) * board::RES) } else { (0.0, 0.0) };
let dst = board::frame_corners(wmm, hmm, side, axis).map(|p| (p.0 + shift.0, p.1 + shift.1));
let m = 60i64;
let (fw, fh) = (wmm * board::RES, hmm * board::RES);
let img = crate::regframe::warp(&flow.join("photos").join(file), &cn, &dst, q, (fw.round() as i64 + 2 * m) as u32, (fh.round() as i64 + 2 * m) as u32, m)?;
let mut out = vec![];
for (name, ax, near) in [("top", true, true), ("bottom", true, false), ("left", false, true), ("right", false, false)] {
if let Some(l) = edge_line(&img, m, ax, near, fw, fh) { out.push((name.to_string(), l)); }
}
// each corner: where the fitted edges meet, then along the diagonal to the first board pixel
let model = side_model(&img, m, fw, fh);
// the corner walk uses the wood test where wood reads this photo's edges well, the photo's own colour model elsewhere
let wood_ok = { let wl = |p: &image::Rgb<u8>| p[0] as f32 - p[1] as f32; let _ = wl; let mut n = 0; for (ax, near) in [(true, true), (true, false), (false, true), (false, false)] { if let Some(l) = edge_line_by(&img, m, ax, near, fw, fh, &|x, y| { let (w, h) = (img.width() as i64, img.height() as i64); if x < 0 || y < 0 || x >= w || y >= h { return None; } let mut s = 0.0; for dy in -1..=1 { for dx in -1..=1 { let (u, v) = ((x + dx).clamp(0, w - 1), (y + dy).clamp(0, h - 1)); let p = img.get_pixel(u as u32, v as u32); s += p[0] as f32 - p[1] as f32; } } Some(s / 9.0 < 15.0) }, false) { if l.2 as f64 >= 0.6 * l.3 as f64 { n += 1; } } } n >= 2 };
let line = |n: &str| out.iter().find(|e| e.0 == n).map(|e| (e.1 .0, e.1 .1));
let mut radii = vec![];
if let (Some(t), Some(bo), Some(lf), Some(r)) = (line("top"), line("bottom"), line("left"), line("right")) {
let meet = |h: (f64, f64), v: (f64, f64)| -> P { let x = (v.0 * h.1 + v.1) / (1.0 - v.0 * h.0); (x, h.0 * x + h.1) };
for (c, sx, sy) in [(meet(t, lf), 1.0, 1.0), (meet(t, r), -1.0, 1.0), (meet(bo, r), -1.0, -1.0), (meet(bo, lf), 1.0, -1.0)] {
let mut d = 0.0; let mut hit = None;
while d < 120.0 {
let (u, v) = (c.0 + sx * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64, c.1 + sy * d * std::f64::consts::FRAC_1_SQRT_2 + m as f64);
if wood_ok { if u >= 1.0 && v >= 1.0 && (u as u32) + 1 < img.width() && (v as u32) + 1 < img.height() { let mut wn = 0.0; for dy in 0..3u32 { for dx in 0..3u32 { let p = img.get_pixel(u as u32 + dx - 1, v as u32 + dy - 1); wn += p[0] as f64 - p[1] as f64; } } if wn / 9.0 < 15.0 { hit = Some(d); break; } } }
else if board_like(&img, u as i64, v as i64, &model) == Some(true) { hit = Some(d); break; }
d += 1.0;
}
if let Some(h) = hit { radii.push(((h / (std::f64::consts::SQRT_2 - 1.0)) / board::RES * 100.0).round() / 100.0); }
}
}
Ok((out, radii))
}
/// The drawn Edge.Cuts against each photo's edges, the held-out second photos included.
pub fn outline_check(flow: &Path, edge_gerber: &str, prep: &Value, scale: &Value) -> Result<Value, String> {
let m = 60.0f64;
let (wmm, hmm) = (scale["board_mm"][0].as_f64().unwrap_or(0.0), scale["board_mm"][1].as_f64().unwrap_or(0.0));
let (fw, fh) = (wmm * board::RES, hmm * board::RES);
let fr = Frame { w: (fw + 2.0 * m) as usize, h: (fh + 2.0 * m) as usize, res: board::RES, ox: board::ORIGIN - m / board::RES, oy: board::ORIGIN - m / board::RES };
let drawn = crate::gerber::rasterize(edge_gerber, &fr)?;
let (w, h) = (fr.w as i64, fr.h as i64);
// first drawn pixel from outside along a line at t on an edge
let hit = |name: &str, t: f64| -> Option<f64> {
let (along_x, near) = match name { "top" => (true, true), "bottom" => (true, false), "left" => (false, true), _ => (false, false) };
let (lim, wall) = if along_x { (h, if near { 0.0 } else { fh }) } else { (w, if near { 0.0 } else { fw }) };
let dir = if near { 1.0 } else { -1.0 };
let mut s = -m + 1.0;
while s < 60.0 { let q = wall + dir * s + m; let (x, y) = if along_x { (t + m, q) } else { (q, t + m) }; if q >= 0.0 && (q as i64) < lim && x >= 0.0 && y >= 0.0 && (x as i64) < w && (y as i64) < h && drawn.at(x as usize, y as usize) { return Some(wall + dir * s); } s += 1.0; }
None
};
let mut out = serde_json::Map::new();
let mut files: Vec<(String, String, Option<crate::regframe::Quad>)> = vec![];
for p in prep["photos"].as_array().into_iter().flatten() {
let (file, side) = (p["file"].as_str().unwrap_or(""), p["side"].as_str().unwrap_or("top"));
let q = photo_quad(flow, file, side, prep, scale, None).map(|x| x.1);
files.push((file.to_string(), side.to_string(), q));
}
let mut worst = 0f64;
for (file, side, q) in &files {
let (edges, radii) = edges_of_file(flow, file, side, q.as_ref(), prep, scale)?;
let mut row = serde_json::Map::new();
for (name, l) in &edges {
if (l.2 as f64) < 0.5 * l.3 as f64 { row.insert(name.clone(), json!({"blocked": true})); continue; }
let span = if name == "top" || name == "bottom" { fw } else { fh };
let mut v: Vec<f64> = (1..10).filter_map(|k| { let t = span * k as f64 / 10.0; hit(name, t).map(|d| (l.0 * t + l.1 - d).abs() / board::RES) }).collect();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
if !v.is_empty() { let e = v[v.len() / 2]; worst = worst.max(e); row.insert(name.clone(), json!({"medianGapMm": (e * 1000.0).round() / 1000.0, "maxGapMm": (v[v.len() - 1] * 1000.0).round() / 1000.0})); }
}
row.insert("cornerRadiiMm".into(), json!(radii));
out.insert(file.clone(), Value::Object(row));
}
Ok(json!({"perPhoto": out, "worstMedianGapMm": (worst * 1000.0).round() / 1000.0}))
}
/// The quadratic that registers the bottom photo, recovered from the two hole lists in scale's own way.
pub fn bottom_quad(flow: &Path, prep: &Value, scale: &Value) -> Option<crate::regframe::Quad> { photo_quad(flow, prep["primary"]["bottom"].as_str()?, "bottom", prep, scale, None).map(|x| x.1) }
/// A photo drawn into the frame through its corners, its holes paired with the top frame's, and the quadratic that
/// pins it. Used for the bottom primary (again, so the outline reader needs no stored map) and for held-out photos.
pub fn photo_quad(flow: &Path, file: &str, side: &str, prep: &Value, scale: &Value, primary_for_refine: Option<&image::RgbImage>) -> Option<(image::RgbImage, crate::regframe::Quad, Value)> {
let rec = prep["photos"].as_array()?.iter().find(|p| p["file"].as_str() == Some(file))?;
let c = rec["corners"].as_array()?;
let cn: [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 (wmm, hmm) = (scale["board_mm"][0].as_f64()?, scale["board_mm"][1].as_f64()?);
let axis = scale["frame"]["bottomFlip"].as_str().unwrap_or("y");
let shift = if side == "bottom" { (scale["alignment"]["shift_mm"][0].as_f64().unwrap_or(0.0) * board::RES, scale["alignment"]["shift_mm"][1].as_f64().unwrap_or(0.0) * board::RES) } else { (0.0, 0.0) };
let dst = board::frame_corners(wmm, hmm, side, axis).map(|p| (p.0 + shift.0, p.1 + shift.1));
let (w, h) = ((wmm * board::RES).round() as u32, (hmm * board::RES).round() as u32);
let path = flow.join("photos").join(file);
let first = crate::regframe::warp(&path, &cn, &dst, None, w, h, 0).ok()?;
let hp = |i: &image::RgbImage| -> Vec<P> { crate::prep::holes(i, 0.45 * board::RES, 4.5 * board::RES).into_iter().map(|h| (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0))).collect() };
let top_h: Vec<P> = crate::items::read_json(&flow.join("detect/holes-top.json")).ok()?["holes"].as_array()?.iter().map(|h| board::to_px(h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0))).collect();
let prs = crate::regframe::pairs(&top_h, &hp(&first), 1.0 * board::RES);
let qd = crate::regframe::Quad::fit(&prs)?;
let rep = crate::regframe::report(&prs, &qd, board::RES);
let img = crate::regframe::warp(&path, &cn, &dst, Some(&qd), w, h, 0).ok()?;
// the held-out photo: holes alone leave about a pixel, and thin silk cannot be compared at that. Lay it on the
// primary photo patch by patch (photos only, no layer is involved), robustly, with the same quadratic form.
if let Some(pr) = primary_for_refine {
let ps = crate::regframe::patch_shifts(pr, &img);
let mut kept = ps.clone();
let mut fit = crate::regframe::Quad::fit(&kept)?;
for _ in 0..3 { let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(fit.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(0.5); kept.retain(|x| geom::dist(fit.apply(x.0), x.1) <= lim); fit = crate::regframe::Quad::fit(&kept)?; }
let comp: Vec<(P, P)> = kept.iter().map(|x| (x.0, qd.apply(fit.apply(x.0)))).collect();
let q2 = crate::regframe::Quad::fit(&comp)?;
let mut res: Vec<f64> = kept.iter().map(|x| geom::dist(fit.apply(x.0), x.1)).collect(); res.sort_by(|a, b| a.partial_cmp(b).unwrap());
let img2 = crate::regframe::warp(&path, &cn, &dst, Some(&q2), w, h, 0).ok()?;
let mut rp = rep; rp["patches"] = json!(ps.len()); rp["patchesKept"] = json!(kept.len()); rp["patchResidualMedianPx"] = json!((res[res.len() / 2] * 100.0).round() / 100.0);
return Some((img2, q2, rp));
}
Some((img, qd, rep))
}
/// A set of pixels: the window it lies in, so a pad costs its own size and not the board's.
#[derive(Clone)]
pub struct Px(pub Vec<usize>);
fn window_mask(px: &[usize], w: usize, pad: usize) -> Option<(Mask, usize, usize)> {
if px.is_empty() { return None; }
let (mut x0, mut y0, mut x1, mut y1) = (usize::MAX, usize::MAX, 0, 0);
for i in px { let (x, y) = (i % w, i / w); x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); }
let (x0, y0) = (x0.saturating_sub(pad), y0.saturating_sub(pad));
let mut m = Mask::new(x1 - x0 + pad + 1, y1 - y0 + pad + 1);
for i in px { m.set(i % w - x0, i / w - y0, true); }
Some((m, x0, y0))
}
/// Metal on one face, assigned to pads. Returns per-pad pixels and what no pad explains.
fn assign_pads(fc: &Face, pads: &[(usize, &Pad)], fr: &Frame) -> (HashMap<usize, Vec<usize>>, Vec<Vec<usize>>, Value) {
let metal = metal_mask(fc);
let (lab, cs) = raster::components(&metal);
let n = cs.len() + 1;
let (w, h) = (fc.w, fc.h);
let mut members: Vec<Vec<usize>> = vec![vec![]; n];
for i in 0..w * h { if lab[i] != 0 { members[lab[i] as usize].push(i); } }
let mut touch: Vec<Vec<usize>> = vec![vec![]; n];
let mut centres: HashMap<usize, P> = HashMap::new();
let mut own_px: HashMap<usize, Vec<usize>> = HashMap::new();
for (ix, p) in pads {
centres.insert(*ix, (p.x, p.y));
let rect = grown_rect(p, 0.15);
let (x0, y0, x1, y1) = rect.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, q| (b.0.min(q.0), b.1.min(q.1), b.2.max(q.0), b.3.max(q.1)));
let (u0, v0) = fr.px(x0, y0); let (u1, v1) = fr.px(x1, y1);
let (u0, v0, u1, v1) = (u0.max(0.0) as usize, v0.max(0.0) as usize, (u1.max(0.0) as usize).min(w - 1), (v1.max(0.0) as usize).min(h - 1));
let mut seen: HashMap<u32, usize> = HashMap::new();
let mut own = vec![];
for v in v0..=v1 { for u in u0..=u1 { let m = (fr.ox + (u as f64 + 0.5) / fr.res, fr.oy + (v as f64 + 0.5) / fr.res); if !point_in(&rect, m) { continue; } own.push(v * w + u); let l = lab[v * w + u]; if l != 0 { *seen.entry(l).or_default() += 1; } } }
for (l, c) in seen { if c >= 8 { touch[l as usize].push(*ix); } }
own_px.insert(*ix, own);
}
let mut masks: HashMap<usize, Vec<usize>> = HashMap::new();
let mut lone = vec![];
let (mut single, mut shared, mut none) = (0, 0, 0);
for c in &cs {
let l = c.label as usize;
let tl = &touch[l];
if tl.is_empty() {
none += 1;
// metal on no pad counts only if it is plainly plating and not a glint beside a part: 0.1 mm2, none of its edge on a part
let beside_part = members[l].iter().any(|i| { let (x, y) = (i % w, i / w); [(12i64, 0i64), (-12, 0), (0, 12), (0, -12)].iter().any(|(dx, dy)| { let (u, v) = (x as i64 + dx, y as i64 + dy); u >= 0 && v >= 0 && (u as usize) < w && (v as usize) < h && fc.cls[v as usize * w + u as usize] == photomat::OBJECT }) });
if (c.area as f64) >= 0.1 * photomat::MM2 && !beside_part { lone.push(members[l].clone()); }
continue;
}
if tl.len() == 1 { single += 1; } else { shared += 1; }
// every pixel of this metal goes to the nearest pad touching it
for i in &members[l] {
let mm = (fr.ox + ((i % w) as f64 + 0.5) / fr.res, fr.oy + ((i / w) as f64 + 0.5) / fr.res);
let k = if tl.len() == 1 { tl[0] } else { *tl.iter().min_by(|a, b| geom::dist(mm, centres[*a]).partial_cmp(&geom::dist(mm, centres[*b])).unwrap()).unwrap() };
masks.entry(k).or_default().push(*i);
}
}
// a pad's hidden part stays as the library drew it: under a body the photo says nothing
for (ix, own) in &own_px {
if let Some(m) = masks.get_mut(ix) { for i in own { if fc.cls[*i] == photomat::OBJECT { m.push(*i); } } }
}
(masks, lone, json!({"metalBlobs": cs.len(), "onOnePad": single, "sharedByPads": shared, "noPad": none}))
}
/// The outer ring of a pixel set in mm and the point inside it farthest from its edge, smoothed by a one-pixel blur so a staircase does not reach the board.
fn outer_ring_px(px: &[usize], w: usize) -> Option<(Vec<P>, P)> {
let (m, x0, y0) = window_mask(px, w, 4)?;
let bl = raster::blur(&Plane { w: m.w, h: m.h, d: m.d.iter().map(|b| if *b { 1.0 } else { 0.0 }).collect() }, 1, 1);
let fld = Plane { w: bl.w, h: bl.h, d: bl.d.iter().map(|v| v - 0.5).collect() };
let rings = contours(&fld);
let big = rings.into_iter().filter(|r| shoelace(r) < 0.0).max_by(|a, b| shoelace(a).abs().partial_cmp(&shoelace(b).abs()).unwrap())?;
let s = geom::simplify(&big, 0.3);
if s.len() < 3 { return None; }
let d = m.not().distance_to_set();
let mut best = (0usize, -1f32);
for i in 0..m.d.len() { if m.d[i] && d.d[i] > best.1 { best = (i, d.d[i]); } }
let inside = px_mm(((best.0 % m.w + x0) as f64 + 0.5, (best.0 / m.w + y0) as f64 + 0.5));
Some((s.into_iter().map(|p| px_mm((p.0 + x0 as f64, p.1 + y0 as f64))).collect(), inside))
}
/// Read everything and write photo-layers.json. `pads` are the placed library pads.
pub fn trace(flow: &Path, pads: &[Pad], ev: &Path) -> Result<Value, String> {
let open = |n: &str| image::open(flow.join(n)).map(|i| i.to_rgb8()).map_err(|e| e.to_string());
let (ti, bi) = (open("board/top.jpg")?, open("board/bottom.jpg")?);
let (mut top, mut bot) = (photomat::classify(&ti, photomat::carries_parts(&ti)), photomat::classify(&bi, photomat::carries_parts(&bi)));
photomat::link_holes(&mut top, &mut bot);
photomat::false_colour(&ti, &top).save(ev.join("materials-top.jpg")).map_err(|e| e.to_string())?;
photomat::false_colour(&bi, &bot).save(ev.join("materials-bottom.jpg")).map_err(|e| e.to_string())?;
let fr = Frame { w: top.w, h: top.h, res: board::RES, ox: board::ORIGIN, oy: board::ORIGIN };
let holes = holes_of(&top, &bot);
let silk_f = silk_polys(&top); let silk_b = silk_polys(&bot);
// the outline: both photos read it; the top is blocked along connectors, so the better-supported reading wins per edge
let prep = crate::items::read_json(&flow.join("prep.json"))?; let scale = crate::items::read_json(&flow.join("scale.json"))?;
let bq = bottom_quad(flow, &prep, &scale);
let et = edges_from(flow, "top", None, &prep, &scale)?; let eb = edges_from(flow, "bottom", bq.as_ref(), &prep, &scale)?;
let mut edge = HashMap::new(); let mut edge_doc = serde_json::Map::new();
for name in ["top", "bottom", "left", "right"] {
let (a, b) = (et.iter().find(|e| e.0 == name).map(|e| e.1), eb.iter().find(|e| e.0 == name).map(|e| e.1));
let share = |x: Option<(f64, f64, usize, usize, f64)>| x.map(|l| l.2 as f64 / l.3 as f64).unwrap_or(0.0);
let mut pick = if share(a) >= 0.6 { a } else if share(b) > share(a) { b } else { a };
// neither photo reads this edge: the frame came from the stated board, so its edge stands, and the document says so
let stated = share(a).max(share(b)) < EDGE_MIN_SHARE;
if stated { let wall = match name { "top" | "left" => 0.0, "bottom" => top.h as f64, _ => top.w as f64 }; pick = Some((0.0, wall, 0, 1, 0.0)); }
let at_mid = |x: Option<(f64, f64, usize, usize, f64)>, mid: f64| x.map(|l| ((l.0 * mid + l.1) / board::RES * 1000.0).round() / 1000.0);
let mid = if name == "top" || name == "bottom" { top.w as f64 / 2.0 } else { top.h as f64 / 2.0 };
edge_doc.insert(name.into(), json!({"topPhotoMm": at_mid(a, mid), "bottomPhotoMm": at_mid(b, mid), "topInlierShare": share(a), "bottomInlierShare": share(b), "tiltMradTop": a.map(|l| (l.0 * 1e6).round() / 1e3), "tiltMradBottom": b.map(|l| (l.0 * 1e6).round() / 1e3), "bowMmTop": a.map(|l| (l.4 * 1000.0).round() / 1000.0), "bowMmBottom": b.map(|l| (l.4 * 1000.0).round() / 1000.0)}));
if stated { edge_doc.get_mut(name).unwrap()["stated"] = json!(true); }
if let Some(l) = pick { edge.insert(name, (l.0, l.1)); }
}
// corners where the fitted edges meet, in board px
let meet = |h: (f64, f64), v: (f64, f64)| -> P { let x = (v.0 * h.1 + v.1) / (1.0 - v.0 * h.0); (x, h.0 * x + h.1) };
let outline = if edge.len() == 4 {
let (t, bo, lf, r) = (edge["top"], edge["bottom"], edge["left"], edge["right"]);
let c = [meet(t, lf), meet(t, r), meet(bo, r), meet(bo, lf)];
json!(c.iter().map(|p| { let m = px_mm(*p); json!([m.0, m.1]) }).collect::<Vec<_>>())
} else { Value::Null };
// the corner radius: the median of the corners both primary photos show
let radius = { let mut r: Vec<f64> = vec![]; for (side, q) in [("top", None), ("bottom", bq.as_ref())] { if let Some(file) = prep["primary"][side].as_str() { if let Ok((_, x)) = edges_of_file(flow, file, side, q, &prep, &scale) { r.extend(x.into_iter().filter(|v| *v > 0.5 && *v < 6.0)); } } } r.sort_by(|a, b| a.partial_cmp(b).unwrap()); if r.is_empty() { crate::items::read_json(&flow.join("copper.json")).ok().and_then(|c| c["outline"]["corner_mm"].as_f64()).unwrap_or(0.0) } else { r[r.len() / 2] } };
// pads, per face
let on_face = |layer: &str| -> Vec<(usize, &Pad)> { pads.iter().enumerate().filter(|(_, p)| p.on(layer)).collect() };
let (ftop, fbot) = (on_face("F.Cu"), on_face("B.Cu"));
let (tm, tlone, ts) = assign_pads(&top, &ftop, &fr);
let (bm, blone, bs) = assign_pads(&bot, &fbot, &fr);
let mut pad_doc = vec![];
let mut used_holes = vec![false; holes.len()];
let mut moved: Vec<f64> = vec![];
let mut kcount: HashMap<String, usize> = HashMap::new();
let mut part_offsets: HashMap<String, Vec<f64>> = HashMap::new();
let w = top.w;
for (ix, p) in pads.iter().enumerate() {
let key = format!("{}.{}", p.part, p.num);
let k = { let e = kcount.entry(key.clone()).or_default(); let v = *e; *e += 1; v };
// a through pad takes its shape from the face that really shows it: a glint beside a header must not outvote a whole joint
let shown = |fc: &Face, px: Option<&Vec<usize>>| -> usize { px.map(|v| v.iter().filter(|i| fc.cls[**i] == photomat::GOLD || fc.cls[**i] == photomat::SILVER).count()).unwrap_or(0) };
let m = if p.drill > 0.0 { if shown(&top, tm.get(&ix)) >= shown(&bot, bm.get(&ix)) { tm.get(&ix).or_else(|| bm.get(&ix)) } else { bm.get(&ix) } } else if p.on("F.Cu") { tm.get(&ix) } else { bm.get(&ix) };
// a photographed hole near a through pad gives the pad its position and drill
let mut hole_at = None;
if p.drill > 0.0 {
let found = (0..holes.len()).filter(|i| !used_holes[*i]).min_by(|i, j| geom::dist((holes[*i].x, holes[*i].y), (p.x, p.y)).partial_cmp(&geom::dist((holes[*j].x, holes[*j].y), (p.x, p.y))).unwrap());
if let Some(i) = found.filter(|i| geom::dist((holes[*i].x, holes[*i].y), (p.x, p.y)) < 0.9) { used_holes[i] = true; moved.push(geom::dist((holes[i].x, holes[i].y), (p.x, p.y))); hole_at = Some((holes[i].x, holes[i].y, holes[i].d)); }
}
// a pad cannot wander: keep what lies within a millimetre of where the library put it
let near = grown_rect(p, 1.0);
let shaped = m.and_then(|px| { let kept: Vec<usize> = px.iter().copied().filter(|i| point_in(&near, (fr.ox + ((i % w) as f64 + 0.5) / fr.res, fr.oy + ((i / w) as f64 + 0.5) / fr.res))).collect(); outer_ring_px(&kept, w) });
let (ring, inside) = match shaped { Some((r, i)) => (Some(r), Some(i)), None => (None, None) };
// A drill stays where the hole is. A through pad whose hole the photo did not find keeps the library's place: its outline from the photo is taken only when it holds that place, and
// the anchor, which carries the drill, does not move into the outline (test board 1: a mounting hole's drill ended 2.3 mm from its ring's centre, header pins up to 1.5 mm).
let (ring, inside) = if p.drill > 0.0 && hole_at.is_none() { match &ring { Some(r) if point_in(r, (p.x, p.y)) => (ring, Some((p.x, p.y))), _ => (None, None) } } else { (ring, inside) };
let anchor: Option<P> = hole_at.map(|h| (h.0, h.1)).or(inside);
// a pad where the photo plainly shows mask: the part's footprint does not fit here. The pad stays for its net, but
// it opens no mask the photo does not show. Half or more of it must be in plain view for that.
let plain_mask = |fc: &Face| -> f64 { let rect = grown_rect(p, 0.0); let (x0, y0, x1, y1) = rect.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |b, q| (b.0.min(q.0), b.1.min(q.1), b.2.max(q.0), b.3.max(q.1))); let (u0, v0) = fr.px(x0, y0); let (u1, v1) = fr.px(x1, y1); let (mut on, mut t) = (0, 0); for v in (v0.max(0.0) as usize)..=(v1.max(0.0) as usize).min(fc.h - 1) { for u in (u0.max(0.0) as usize)..=(u1.max(0.0) as usize).min(fc.w - 1) { if !point_in(&rect, (fr.ox + (u as f64 + 0.5) / fr.res, fr.oy + (v as f64 + 0.5) / fr.res)) { continue; } t += 1; if fc.cls[v * fc.w + u] == photomat::MASK { on += 1; } } } if t == 0 { 0.0 } else { on as f64 / t as f64 } };
if ring.is_none() && hole_at.is_none() {
let (f_open, b_open) = (p.on("F.Cu") && plain_mask(&top) > 0.5, p.on("B.Cu") && plain_mask(&bot) > 0.5);
if f_open || b_open { pad_doc.push(json!({"part": p.part, "num": p.num, "k": k, "noMaskF": f_open, "noMaskB": b_open})); }
continue;
}
// does the part sit where its pads show? Only pads the photo shows nearly whole count.
if let Some(rg) = &ring { let area = shoelace(rg).abs(); if area > 0.4 * p.w * p.h && area < 2.5 * p.w * p.h { let c = (rg.iter().map(|q| q.0).sum::<f64>() / rg.len() as f64, rg.iter().map(|q| q.1).sum::<f64>() / rg.len() as f64); part_offsets.entry(p.part.clone()).or_default().push(geom::dist(c, (p.x, p.y))); } }
let seen_at = hole_at.map(|h| (h.0, h.1)).or_else(|| ring.as_ref().map(|r| (r.iter().map(|q| q.0).sum::<f64>() / r.len() as f64, r.iter().map(|q| q.1).sum::<f64>() / r.len() as f64)));
pad_doc.push(json!({"part": p.part, "num": p.num, "k": k, "anchor": anchor.map(|a| json!([a.0, a.1])), "poly": ring.as_ref().map(|r| ring_json(r)), "drill": hole_at.map(|h| h.2), "lib": [p.x, p.y], "libSize": [p.w, p.h], "seenAt": seen_at.map(|a| json!([a.0, a.1]))}));
}
// a hole inside a photographed pad belongs to that pad
for (i, h) in holes.iter().enumerate() {
if used_holes[i] { continue; }
for row in pad_doc.iter_mut() {
let r = ring_of(&row["poly"]);
if r.len() >= 3 && point_in(&r, (h.x, h.y)) {
if !row["extraHoles"].is_array() { row["extraHoles"] = json!([]); }
row["extraHoles"].as_array_mut().unwrap().push(json!({"x": h.x, "y": h.y, "d": h.d}));
used_holes[i] = true; break;
}
}
}
// holes no pad claimed, and metal no pad claimed
let free_holes: Vec<Value> = holes.iter().enumerate().filter(|(i, _)| !used_holes[*i]).map(|(_, h)| json!({"x": h.x, "y": h.y, "d": h.d, "plated": h.ringed, "faces": h.faces})).collect();
let free = |lone: &[Vec<usize>], face: &str| -> Vec<Value> { lone.iter().filter_map(|m| outer_ring_px(m, w).map(|(r, ins)| json!({"face": face, "poly": ring_json(&r), "inside": [ins.0, ins.1], "areaMm2": ((m.len() as f64 / photomat::MM2) * 1000.0).round() / 1000.0}))).collect() };
let scale_check = scale_from_pads(&pad_doc);
let mut metal = free(&tlone, "F"); metal.extend(free(&blone, "B"));
let offs: Vec<f64> = part_offsets.values().map(|v| { let mut w = v.clone(); w.sort_by(|a, b| a.partial_cmp(b).unwrap()); w[w.len() / 2] }).collect();
let within = offs.iter().filter(|d| **d < 0.25).count();
let area = |rs: &Vec<Vec<P>>| rs.iter().map(|r| shoelace(r).abs()).sum::<f64>();
let doc = json!({"generatedBy": "layers trace", "note": "polygons in board mm; thresholds from the Driver photos, see photomat.rs",
"silk": {"F": silk_f.iter().map(|r| ring_json(r)).collect::<Vec<_>>(), "B": silk_b.iter().map(|r| ring_json(r)).collect::<Vec<_>>()},
"silkAreaMm2": {"F": (area(&silk_f) * 100.0).round() / 100.0, "B": (area(&silk_b) * 100.0).round() / 100.0},
"pads": pad_doc, "freeHoles": free_holes, "freeMetal": metal,
"holesSeen": holes.len(), "holeMoveFromLibraryMm": moved,
"outline": {"points_mm": outline, "corner_mm": radius, "edges": edge_doc},
"padAssignment": {"top": ts, "bottom": bs},
"scaleFromPads": scale_check,
"libraryPadsAgainstPhoto": {"partsChecked": offs.len(), "within025mm": within, "share": if offs.is_empty() { 0.0 } else { within as f64 / offs.len() as f64 }}});
Ok(doc)
}
/// Library pitches are standards, so a footprint's pads are a ruler laid on the photo at each part. For every pair of pads
/// in a part, the distance on the photo over the distance in the library, split by direction, and summed so long
/// baselines count for more. Holes give the sharpest points; small SMD pads next (a large pad's centre is pulled by
/// solder and the part's end). The spread between parts is the honest uncertainty.
fn scale_from_pads(rows: &[Value]) -> Value {
let mut parts: HashMap<String, Vec<(P, P, bool)>> = HashMap::new();
for r in rows {
let (Some(l), Some(s)) = (r["lib"].as_array(), r["seenAt"].as_array()) else { continue };
let hole = !r["drill"].is_null();
let lz = &r["libSize"];
if !hole && lz[0].as_f64().unwrap_or(9.0).min(lz[1].as_f64().unwrap_or(9.0)) > 0.8 { continue; }
parts.entry(r["part"].as_str().unwrap_or("").to_string()).or_default().push(((l[0].as_f64().unwrap_or(0.0), l[1].as_f64().unwrap_or(0.0)), (s[0].as_f64().unwrap_or(0.0), s[1].as_f64().unwrap_or(0.0)), hole));
}
let mut per = vec![];
let (mut holes_x, mut holes_y, mut fine_x, mut fine_y): (Vec<(f64, f64)>, Vec<(f64, f64)>, Vec<(f64, f64)>, Vec<(f64, f64)>) = (vec![], vec![], vec![], vec![]);
let mut names: Vec<&String> = parts.keys().collect(); names.sort();
for name in names {
let v = &parts[name];
let (mut sx, mut lx, mut sy, mut ly) = (0.0, 0.0, 0.0, 0.0);
for i in 0..v.len() { for j in i + 1..v.len() {
let (dl, ds) = ((v[j].0 .0 - v[i].0 .0, v[j].0 .1 - v[i].0 .1), (v[j].1 .0 - v[i].1 .0, v[j].1 .1 - v[i].1 .1));
if dl.0.abs() > 3.0 * dl.1.abs() { lx += dl.0.abs(); sx += ds.0 * dl.0.signum(); }
else if dl.1.abs() > 3.0 * dl.0.abs() { ly += dl.1.abs(); sy += ds.1 * dl.1.signum(); }
else { let n = (dl.0 * dl.0 + dl.1 * dl.1).sqrt(); let m = (ds.0 * dl.0 + ds.1 * dl.1) / n; lx += n / 2.0; sx += m / 2.0; ly += n / 2.0; sy += m / 2.0; }
} }
let hole = v.iter().filter(|q| q.2).count() * 2 >= v.len();
if v.len() < 3 { continue; }
let rx = if lx >= 3.0 { Some(sx / lx) } else { None }; let ry = if ly >= 3.0 { Some(sy / ly) } else { None };
if rx.is_none() && ry.is_none() { continue; }
if let Some(q) = rx { if hole { holes_x.push((q, lx)); } else { fine_x.push((q, lx)); } }
if let Some(q) = ry { if hole { holes_y.push((q, ly)); } else { fine_y.push((q, ly)); } }
per.push(json!({"name": name, "pads": v.len(), "holes": hole, "x": rx.map(|q| (q * 10000.0).round() / 10000.0), "y": ry.map(|q| (q * 10000.0).round() / 10000.0), "xBaselineMm": (lx * 10.0).round() / 10.0, "yBaselineMm": (ly * 10.0).round() / 10.0}));
}
// a weighted mean, and the spread of the parts about it
let summarise = |v: &[(f64, f64)]| -> Value { if v.is_empty() { return Value::Null; } let w: f64 = v.iter().map(|q| q.1).sum(); let m = v.iter().map(|q| q.0 * q.1).sum::<f64>() / w; let var = v.iter().map(|q| q.1 * (q.0 - m).powi(2)).sum::<f64>() / w; json!({"ratio": (m * 100000.0).round() / 100000.0, "spreadBetweenRulers": (var.sqrt() * 100000.0).round() / 100000.0, "rulers": v.len()}) };
json!({"meaning": "ratio 1.001 means the frame draws 0.1% larger than the library pitch", "holes": {"x": summarise(&holes_x), "y": summarise(&holes_y)}, "smallPads": {"x": summarise(&fine_x), "y": summarise(&fine_y)}, "perRuler": per})
}
fn grown_rect(p: &Pad, g: f64) -> Vec<P> {
let (hw, hh) = (p.w / 2.0 + g, p.h / 2.0 + g);
[(-hw, -hh), (hw, -hh), (hw, hh), (-hw, hh)].iter().map(|(lx, ly)| kicad_out::place(p.x, p.y, p.rot, *lx, *ly)).collect()
}
fn poly_node(r: &[P], layer: &str, id: &str) -> String {
let pts: String = r.iter().map(|p| format!("(xy {:.4} {:.4})", p.0, p.1)).collect::<Vec<_>>().join(" ");
format!("\t(gr_poly (pts {pts}) (stroke (width 0) (type solid)) (fill solid) (layer \"{layer}\") (uuid \"{}\"))\n", kicad_out::uuid(id))
}
/// Everything the document draws as board graphics: silk on both faces.
pub fn graphics(doc: &Value) -> String {
let mut out = String::new();
for (key, layer) in [("F", "F.SilkS"), ("B", "B.SilkS")] {
for (i, r) in doc["silk"][key].as_array().into_iter().flatten().enumerate() { out += &poly_node(&ring_of(r), layer, &format!("silk/{key}/{i}")); }
}
out
}
/// The measured outline, if the document has one, as Edge.Cuts: four straight edges and rounded corners.
pub fn edge_cuts(doc: &Value) -> Option<String> {
let pts = ring_of(&doc["outline"]["points_mm"]);
if pts.len() != 4 { return None; }
let r = doc["outline"]["corner_mm"].as_f64().unwrap_or(0.0);
let mut out = String::new();
let unit = |a: P, b: P| { let d = geom::dist(a, b).max(1e-9); ((b.0 - a.0) / d, (b.1 - a.1) / d) };
for i in 0..4 {
let (a, b, c) = (pts[i], pts[(i + 1) % 4], pts[(i + 2) % 4]);
let (u, v) = (unit(a, b), unit(b, c));
let s = (a.0 + u.0 * r, a.1 + u.1 * r); let e = (b.0 - u.0 * r, b.1 - u.1 * r);
out += &format!("\t(gr_line (start {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.05) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", s.0, s.1, e.0, e.1, kicad_out::uuid(&format!("photoedge/{i}")));
if r > 0.0 {
let ae = (b.0 + v.0 * r, b.1 + v.1 * r);
// the arc's centre sits r in from both edges; its middle lies on the way to the corner
let centre = (b.0 - u.0 * r + v.0 * r, b.1 - u.1 * r + v.1 * r);
let d = geom::dist(b, centre).max(1e-9);
let mid = (centre.0 + (b.0 - centre.0) / d * r, centre.1 + (b.1 - centre.1) / d * r);
out += &format!("\t(gr_arc (start {:.4} {:.4}) (mid {:.4} {:.4}) (end {:.4} {:.4}) (stroke (width 0.05) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))\n", e.0, e.1, mid.0, mid.1, ae.0, ae.1, kicad_out::uuid(&format!("photoarc/{i}")));
}
}
Some(out)
}
pub fn load(flow: &Path) -> Option<Value> { crate::items::read_json(&flow.join(FILE)).ok() }
/// Take library silk off a placed footprint: reference and value move to the fabrication layer.
pub fn strip_silk(n: &mut N) {
let on_silk = |c: &N| c.child("layer").and_then(|l| l.arg(0)).map(|s| s.ends_with(".SilkS")).unwrap_or(false);
n.items_mut().retain(|c| !(matches!(c.head(), "fp_line" | "fp_arc" | "fp_circle" | "fp_rect" | "fp_poly" | "fp_text" | "fp_curve") && on_silk(c)));
for c in n.items_mut().iter_mut() {
if c.head() == "property" { if let Some(ly) = c.child_mut("layer") { if ly.arg(0).map(|s| s.ends_with(".SilkS")).unwrap_or(false) { let front = ly.arg(0).map(|s| s.starts_with('F')).unwrap_or(true); *ly = l(vec![a("layer"), q(if front { "F.Fab" } else { "B.Fab" })]); } } }
}
}
/// The local position a footprint would need to put a child at a board position.
fn to_local(fx: f64, fy: f64, rot: f64, g: P) -> P {
let (c, s) = (rot.to_radians().cos(), rot.to_radians().sin());
let (dx, dy) = (g.0 - fx, g.1 - fy);
(dx * c - dy * s, dx * s + dy * c)
}
/// Rewrite the pads of one placed footprint from the document. Pads keep name, type, layers and net; shape and hole follow the photo.
pub fn apply_pads(doc: &Value, n: &mut N, pads: &mut Vec<Pad>, part: &Value, polys: &mut HashMap<String, Vec<P>>) {
let r = part["ref"].as_str().unwrap_or("?");
let (fx, fy) = (part["pos_mm"][0].as_f64().unwrap_or(0.0), part["pos_mm"][1].as_f64().unwrap_or(0.0));
let rot = part["rot"].as_f64().unwrap_or(0.0);
let mut k: HashMap<String, usize> = HashMap::new();
let mut pi = 0usize;
let pad_rows: Vec<&Value> = doc["pads"].as_array().into_iter().flatten().filter(|v| v["part"].as_str() == Some(r)).collect();
for c in n.items_mut().iter_mut() {
if c.head() != "pad" { continue; }
let num = c.arg(0).unwrap_or_default();
let this = pi; pi += 1;
let kk = { let e = k.entry(num.clone()).or_default(); let v = *e; *e += 1; v };
let Some(row) = pad_rows.iter().find(|v| v["num"].as_str() == Some(&num) && v["k"].as_u64() == Some(kk as u64)) else { continue };
// the photo shows mask over this pad: take the opening away on that face
if row["noMaskF"].as_bool().unwrap_or(false) || row["noMaskB"].as_bool().unwrap_or(false) {
let (nf, nb) = (row["noMaskF"].as_bool().unwrap_or(false), row["noMaskB"].as_bool().unwrap_or(false));
if let Some(ly) = c.items_mut().iter_mut().find(|x| x.head() == "layers") {
let names: Vec<String> = ly.items()[1..].iter().filter_map(|x| match x { N::A(s) => Some(crate::sexp::unq(s)), _ => None }).collect();
let mut out = vec![];
for n in names { if n == "*.Mask" { if !nf { out.push("F.Mask".to_string()); } if !nb { out.push("B.Mask".to_string()); } } else if (n == "F.Mask" && nf) || (n == "B.Mask" && nb) || (n == "F.Paste" && nf) { } else { out.push(n); } }
*ly = l(std::iter::once(a("layers")).chain(out.iter().map(|x| q(x))).collect());
}
continue;
}
let poly = ring_of(&row["poly"]);
let anchor = row["anchor"].as_array().map(|p| (p[0].as_f64().unwrap_or(0.0), p[1].as_f64().unwrap_or(0.0)));
let drill = row["drill"].as_f64();
let items = c.items_mut();
if let Some(an) = anchor {
let lp = to_local(fx, fy, rot, an);
let ang = if poly.len() >= 3 { 0.0 } else { items.iter().find(|x| x.head() == "at").and_then(|x| x.num(2)).unwrap_or(0.0) };
if let Some(at) = items.iter_mut().find(|x| x.head() == "at") { *at = l(vec![a("at"), f(lp.0), f(lp.1), f(ang)]); }
if let Some(pd) = pads.get_mut(this) { pd.x = an.0; pd.y = an.1; }
}
if let Some(d) = drill { if let Some(dr) = items.iter_mut().find(|x| x.head() == "drill") { *dr = l(vec![a("drill"), f(d)]); } if let Some(pd) = pads.get_mut(this) { pd.drill = d; } }
if poly.len() >= 3 {
let an = anchor.unwrap_or(poly[0]);
if items.len() > 3 { items[3] = a("custom"); }
let anc = if let Some(d) = drill { (d * 0.5).max(0.1) } else { 0.1 };
if let Some(sz) = items.iter_mut().find(|x| x.head() == "size") { *sz = l(vec![a("size"), f(anc), f(anc)]); }
items.retain(|x| !matches!(x.head(), "roundrect_rratio" | "chamfer_ratio" | "chamfer" | "rect_delta" | "options" | "primitives"));
// goes before the net and uuid so the file order stays what KiCad writes
let pts = l(std::iter::once(a("pts")).chain(poly.iter().map(|p| l(vec![a("xy"), f(p.0 - an.0), f(p.1 - an.1)]))).collect());
let prim = l(vec![a("primitives"), l(vec![a("gr_poly"), pts, l(vec![a("width"), a("0")]), l(vec![a("fill"), a("yes")])])]);
let opt = l(vec![a("options"), l(vec![a("clearance"), a("outline")]), l(vec![a("anchor"), a("circle")])]);
let at_net = items.iter().position(|x| x.head() == "net" || x.head() == "uuid").unwrap_or(items.len());
items.insert(at_net, prim); items.insert(at_net, opt);
if let Some(pd) = pads.get_mut(this) {
let (mx, my) = poly.iter().fold((0f64, 0f64), |b, p| (b.0.max((p.0 - an.0).abs()), b.1.max((p.1 - an.1).abs())));
pd.w = 2.0 * mx; pd.h = 2.0 * my; pd.rot = 0.0; pd.round = false;
polys.insert(format!("{}.{}#{}", r, num, kk), poly.clone());
}
}
}
// holes inside a photographed pad (the lugs have four around the middle): more pads of the same number, so the net holds
let mut added = vec![];
for row in &pad_rows {
let num = row["num"].as_str().unwrap_or("");
let parent = n.children("pad").find(|c| c.arg(0).as_deref() == Some(num)).cloned();
let Some(parent) = parent else { continue };
let net = parent.child("net").cloned();
let layers = parent.child("layers").cloned().unwrap_or(l(vec![a("layers"), q("*.Cu"), q("*.Mask")]));
for (i, h) in row["extraHoles"].as_array().into_iter().flatten().enumerate() {
let (x, y, d) = (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0), h["d"].as_f64().unwrap_or(0.3));
let lp = to_local(fx, fy, rot, (x, y));
let mut node = vec![a("pad"), q(num), a("thru_hole"), a("circle"), l(vec![a("at"), f(lp.0), f(lp.1)]), l(vec![a("size"), f(d + 0.3), f(d + 0.3)]), l(vec![a("drill"), f(d)]), layers.clone()];
if let Some(nt) = &net { node.push(nt.clone()); }
node.push(l(vec![a("uuid"), q(&kicad_out::uuid(&format!("extrahole/{r}/{num}/{i}")))]));
added.push(l(node));
let net_name = pads.iter().find(|p| p.num == num).map(|p| p.net.clone()).unwrap_or_default();
pads.push(Pad { part: r.to_string(), num: num.to_string(), x, y, w: d + 0.3, h: d + 0.3, rot: 0.0, round: true, drill: d, layers: vec!["*.Cu".into(), "*.Mask".into()], net: net_name });
}
}
n.items_mut().extend(added);
}
/// A footprint carrying every hole and exposed patch no part explains. It has no net.
pub fn extras_footprint(doc: &Value) -> Option<(String, Vec<Pad>)> {
let (holes, metal) = (doc["freeHoles"].as_array().cloned().unwrap_or_default(), doc["freeMetal"].as_array().cloned().unwrap_or_default());
if holes.is_empty() && metal.is_empty() { return None; }
let mut s = format!("(footprint \"Generated:PhotoOnly\" (layer \"F.Cu\") (at {o} {o}) (uuid \"{}\") (attr exclude_from_pos_files exclude_from_bom)\n (property \"Reference\" \"PHOTO1\" (at 0 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n (property \"Value\" \"seen on the photos, no part explains it\" (at 0 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15))))\n", kicad_out::uuid("fp/PHOTO1"), o = board::ORIGIN);
let mut pads = vec![];
for (i, h) in holes.iter().enumerate() {
let (x, y, d) = (h["x"].as_f64().unwrap_or(0.0), h["y"].as_f64().unwrap_or(0.0), h["d"].as_f64().unwrap_or(0.3));
let (lx, ly) = (x - board::ORIGIN, y - board::ORIGIN);
if h["plated"].as_bool().unwrap_or(false) {
let pd = d + 0.2;
s += &format!(" (pad \"\" thru_hole circle (at {lx:.4} {ly:.4}) (size {pd:.4} {pd:.4}) (drill {d:.4}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))\n", kicad_out::uuid(&format!("photohole/{i}")));
pads.push(Pad { part: "PHOTO1".into(), num: format!("H{i}"), x, y, w: pd, h: pd, rot: 0.0, round: true, drill: d, layers: vec!["*.Cu".into(), "*.Mask".into()], net: String::new() });
} else {
s += &format!(" (pad \"\" np_thru_hole circle (at {lx:.4} {ly:.4}) (size {d:.4} {d:.4}) (drill {d:.4}) (layers \"*.Cu\" \"*.Mask\") (uuid \"{}\"))\n", kicad_out::uuid(&format!("photohole/{i}")));
}
}
for (i, m) in metal.iter().enumerate() {
let r = ring_of(&m["poly"]); if r.len() < 3 { continue; }
let front = m["face"].as_str() == Some("F");
let (cu, ma) = if front { ("F.Cu", "F.Mask") } else { ("B.Cu", "B.Mask") };
let (cx, cy) = (m["inside"][0].as_f64().unwrap_or(r[0].0), m["inside"][1].as_f64().unwrap_or(r[0].1));
let pts: String = r.iter().map(|p| format!("(xy {:.4} {:.4})", p.0 - cx, p.1 - cy)).collect::<Vec<_>>().join(" ");
s += &format!(" (pad \"\" smd custom (at {:.4} {:.4}) (size 0.05 0.05) (layers \"{cu}\" \"{ma}\") (options (clearance outline) (anchor circle)) (primitives (gr_poly (pts {pts}) (width 0) (fill yes))) (uuid \"{}\"))\n", cx - board::ORIGIN, cy - board::ORIGIN, kicad_out::uuid(&format!("photometal/{i}")));
let (mx, my) = r.iter().fold((0f64, 0f64), |b, p| (b.0.max((p.0 - cx).abs()), b.1.max((p.1 - cy).abs())));
pads.push(Pad { part: "PHOTO1".into(), num: format!("M{i}"), x: cx, y: cy, w: 2.0 * mx, h: 2.0 * my, rot: 0.0, round: false, drill: 0.0, layers: vec![cu.into(), ma.into()], net: String::new() });
}
s += ")\n";
Some((s, pads))
}
#[cfg(test)]
mod tests {
#[test]
fn an_edge_reads_on_a_dark_mat_as_well_as_on_wood() {
// a straightened board with a 60 px margin; the table is a dark teal mat in one, wood in the other
for table in [image::Rgb([28u8, 52, 50]), image::Rgb([150, 100, 60])] {
let (m, fw, fh) = (60i64, 800.0, 600.0);
let img = image::RgbImage::from_fn((fw as i64 + 2 * m) as u32, (fh as i64 + 2 * m) as u32, |x, y| {
let (x, y) = (x as i64 - m, y as i64 - m);
if x >= 3 && y >= -2 && x < fw as i64 && y < fh as i64 { image::Rgb([60, 150, 80]) } else { table } });
let top = edge_line(&img, m, true, true, fw, fh).expect("top edge");
let left = edge_line(&img, m, false, true, fw, fh).expect("left edge");
assert!(top.2 as f64 / top.3 as f64 > 0.8 && (top.1 + 2.0).abs() < 1.5, "top {top:?}");
assert!(left.2 as f64 / left.3 as f64 > 0.8 && (left.1 - 3.0).abs() < 1.5, "left {left:?}");
}
}
use super::*;
#[test]
fn contours_wind_outers_and_holes_oppositely() {
let mut p = Plane::new(40, 40, -1.0);
for y in 5..35 { for x in 5..35 { p.d[y * 40 + x] = 1.0; } }
for y in 15..25 { for x in 15..25 { p.d[y * 40 + x] = -1.0; } }
let r = contours(&p);
assert_eq!(r.len(), 2);
let (mut o, mut h) = (0, 0);
for q in &r { if shoelace(q) < 0.0 { o += 1; assert!((shoelace(q).abs() - 900.0).abs() < 40.0, "{}", shoelace(q)); } else { h += 1; assert!((shoelace(q) - 100.0).abs() < 25.0, "{}", shoelace(q)); } }
assert_eq!((o, h), (1, 1));
}
#[test]
fn pads_become_photographed_shapes() {
let doc = json!({"pads": [{"part": "R1", "num": "1", "k": 0, "anchor": [10.5, 10.0], "poly": [[10.0, 9.5], [11.0, 9.5], [11.0, 10.5], [10.0, 10.5]], "drill": null}]});
let mut n = crate::sexp::parse("(footprint \"x\" (layer \"F.Cu\") (at 10 10 90) (pad \"1\" smd roundrect (at 0.5 0 90) (size 1 1) (layers \"F.Cu\" \"F.Mask\") (roundrect_rratio 0.25) (uuid \"u\")))").unwrap();
let part = json!({"ref": "R1", "pos_mm": [10.0, 10.0], "rot": 90.0});
let mut pads = vec![Pad { part: "R1".into(), num: "1".into(), x: 10.0, y: 9.5, w: 1.0, h: 1.0, rot: 90.0, round: false, drill: 0.0, layers: vec!["F.Cu".into()], net: "".into() }];
let mut polys = HashMap::new();
apply_pads(&doc, &mut n, &mut pads, &part, &mut polys);
let t = n.to_string();
assert!(t.contains("custom") && t.contains("primitives") && !t.contains("roundrect_rratio"), "{t}");
// the anchor goes back through the inverse of KiCad's placement
let at = n.child("pad").unwrap().child("at").unwrap();
let g = kicad_out::place(10.0, 10.0, 90.0, at.num(0).unwrap(), at.num(1).unwrap());
assert!(geom::dist(g, (10.5, 10.0)) < 1e-3, "{g:?}");
}
}